Add composer status interactions

This commit is contained in:
murashit 2026-06-05 11:31:24 +09:00
parent d39403c8f5
commit 75ee797ed9
12 changed files with 571 additions and 40 deletions

View file

@ -29,6 +29,9 @@ export interface ChatComposerControllerOptions {
composerPlaceholder: () => string;
composerMeta: () => ComposerMetaViewModel;
currentModelForSuggestions: () => string | null;
togglePlan: () => void;
toggleAutoReview: () => void;
toggleFast: () => void;
renderIfDetached: () => void;
onDraftChange: () => void;
onComposerResize: () => void;
@ -108,6 +111,15 @@ export class ChatComposerController {
onSendOrInterrupt: () => {
this.options.onSubmit();
},
onTogglePlan: () => {
this.options.togglePlan();
},
onToggleAutoReview: () => {
this.options.toggleAutoReview();
},
onToggleFast: () => {
this.options.toggleFast();
},
onSuggestionHover: (index) => {
this.selectSuggestion(index);
},

View file

@ -253,6 +253,9 @@ export function createChatViewControllerAssembly(host: ChatViewControllerAssembl
composerPlaceholder: host.composerPlaceholder,
composerMeta: host.composerMetaViewModel,
currentModelForSuggestions: () => currentModel(host.runtimeSnapshot()),
togglePlan: () => void runtimeSettings.toggleCollaborationMode(),
toggleAutoReview: () => void runtimeSettings.toggleAutoReview(),
toggleFast: () => void runtimeSettings.toggleFastMode(),
renderIfDetached: host.effects.render.now,
onDraftChange: host.effects.liveState.refresh,
onComposerResize: () => {

View file

@ -1,9 +1,11 @@
import { setIcon } from "obsidian";
import type { ButtonHTMLAttributes, ComponentChild as UiNode, Ref } from "preact";
import { useLayoutEffect, useRef } from "preact/hooks";
import { useLayoutEffect, useRef, useState } from "preact/hooks";
import type { ComposerSuggestion } from "../composer/suggestions";
import type { ComposerMetaViewModel } from "../view-model";
import { IconButton, ObsidianIcon } from "../../../shared/ui/components";
import type { ToolbarChoice } from "../toolbar-model";
import { IconButton } from "../../../shared/ui/components";
import { renderUiRoot } from "../../../shared/ui/ui-root";
import { syncTextareaHeight } from "../../../shared/ui/textarea-autogrow";
@ -17,6 +19,9 @@ export interface ComposerCallbacks {
onUpdateSuggestions: () => void;
onKeydown: (event: KeyboardEvent) => void;
onSendOrInterrupt: () => void;
onTogglePlan?: () => void;
onToggleAutoReview?: () => void;
onToggleFast?: () => void;
onSuggestionHover: (index: number) => void;
onSuggestionInsert: (suggestion: ComposerSuggestion) => void;
}
@ -36,11 +41,14 @@ const DEFAULT_COMPOSER_META: ComposerMetaViewModel = {
],
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: [],
};
export function renderComposerShell(
@ -143,7 +151,7 @@ function ComposerShell({
callbacks.onKeydown(event);
}}
/>
<ComposerMeta meta={meta} sendMode={sendMode} onSendOrInterrupt={callbacks.onSendOrInterrupt} />
<ComposerMeta meta={meta} sendMode={sendMode} callbacks={callbacks} />
<ComposerSuggestions
containerRef={suggestionsRef}
selectedRef={selectedSuggestionRef}
@ -159,13 +167,17 @@ function ComposerShell({
function ComposerMeta({
meta,
sendMode,
onSendOrInterrupt,
callbacks,
}: {
meta: ComposerMetaViewModel;
sendMode: ComposerSendMode;
onSendOrInterrupt: () => void;
callbacks: ComposerCallbacks;
}): UiNode {
const metaRef = useRef<HTMLDivElement | null>(null);
const statusRef = useRef<HTMLSpanElement | null>(null);
const modelTriggerRef = useRef<HTMLSpanElement | null>(null);
const effortTriggerRef = useRef<HTMLSpanElement | null>(null);
const [picker, setPicker] = useState<ComposerMetaPickerState | null>(null);
useLayoutEffect(() => {
const status = statusRef.current;
if (!status) return;
@ -190,40 +202,134 @@ function ComposerMeta({
win.removeEventListener("resize", scheduleUpdate);
};
}, [meta]);
useLayoutEffect(() => {
if (!picker) return;
const metaRoot = metaRef.current;
const doc = metaRoot?.ownerDocument;
if (!metaRoot || !doc) return;
const closeOnOutsideMouse = (event: MouseEvent) => {
if (event.target && metaRoot.contains(event.target as Node)) return;
setPicker(null);
};
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") setPicker(null);
};
doc.addEventListener("mousedown", closeOnOutsideMouse);
doc.addEventListener("keydown", closeOnEscape);
return () => {
doc.removeEventListener("mousedown", closeOnOutsideMouse);
doc.removeEventListener("keydown", closeOnEscape);
};
}, [picker]);
const openPicker = (kind: ComposerMetaPickerKind) => {
const nextPicker = composerMetaPickerState(
kind,
kind === "model" ? modelTriggerRef.current : effortTriggerRef.current,
metaRef.current,
);
setPicker((current) => (current?.kind === kind ? null : nextPicker));
};
const closePicker = () => {
setPicker(null);
};
if (meta.fatal) {
return (
<div className="codex-panel__composer-meta codex-panel__composer-meta--fatal">
<span className="codex-panel__composer-meta-fatal">{meta.fatal}</span>
<ComposerSendButton sendMode={sendMode} onSendOrInterrupt={onSendOrInterrupt} />
<ComposerSendButton sendMode={sendMode} onSendOrInterrupt={callbacks.onSendOrInterrupt} />
</div>
);
}
return (
<div className="codex-panel__composer-meta">
<span ref={statusRef} className="codex-panel__composer-meta-status" aria-hidden="true">
<span className="codex-panel__composer-meta-modes">
<ComposerMetaIcon icon="list-todo" active={meta.planActive} />
<ComposerMetaIcon icon="shield" active={meta.autoReviewActive} />
<ComposerMetaIcon icon="zap" active={meta.fastActive} />
</span>
<span className="codex-panel__composer-meta-separator">|</span>
<ComposerContextMeter context={meta.context} />
<span className="codex-panel__composer-meta-field codex-panel__composer-meta-field--model">
<span className="codex-panel__composer-meta-separator">|</span>
<span className="codex-panel__composer-meta-model">{meta.model}</span>
</span>
{meta.effort ? (
<span className="codex-panel__composer-meta-field codex-panel__composer-meta-field--effort">
<span className="codex-panel__composer-meta-separator">|</span>
<span className="codex-panel__composer-meta-effort">{meta.effort}</span>
<div ref={metaRef} className="codex-panel__composer-meta">
<span ref={statusRef} className="codex-panel__composer-meta-status">
<span className="codex-panel__composer-meta-summary">{meta.statusSummary}</span>
<span className="codex-panel__composer-meta-status-visual" aria-hidden="true">
<span className="codex-panel__composer-meta-modes">
<ComposerMetaModeButton
icon="list-todo"
active={meta.planActive}
onMouseDown={() => {
callbacks.onTogglePlan?.();
}}
/>
<ComposerMetaModeButton
icon="shield"
active={meta.autoReviewActive}
onMouseDown={() => {
callbacks.onToggleAutoReview?.();
}}
/>
<ComposerMetaModeButton
icon="zap"
active={meta.fastActive}
onMouseDown={() => {
callbacks.onToggleFast?.();
}}
/>
</span>
) : null}
<span className="codex-panel__composer-meta-separator">|</span>
<ComposerContextMeter context={meta.context} />
<span className="codex-panel__composer-meta-field codex-panel__composer-meta-field--model">
<span className="codex-panel__composer-meta-separator">|</span>
<ComposerMetaPickerButton
triggerRef={modelTriggerRef}
kind="model"
value={meta.model}
onMouseDown={() => {
openPicker("model");
}}
/>
</span>
{meta.effort ? (
<span className="codex-panel__composer-meta-field codex-panel__composer-meta-field--effort">
<span className="codex-panel__composer-meta-separator">|</span>
<ComposerMetaPickerButton
triggerRef={effortTriggerRef}
kind="effort"
value={meta.effort}
onMouseDown={() => {
openPicker("effort");
}}
/>
</span>
) : null}
</span>
</span>
<ComposerSendButton sendMode={sendMode} onSendOrInterrupt={onSendOrInterrupt} />
{picker ? (
<ComposerMetaChoicePopover
kind={picker.kind}
choices={picker.kind === "model" ? (meta.modelChoices ?? []) : (meta.effortChoices ?? [])}
left={picker.left}
onClose={closePicker}
/>
) : null}
<ComposerSendButton sendMode={sendMode} onSendOrInterrupt={callbacks.onSendOrInterrupt} />
</div>
);
}
type ComposerMetaPickerKind = "model" | "effort";
interface ComposerMetaPickerState {
kind: ComposerMetaPickerKind;
left: number;
}
function composerMetaPickerState(
kind: ComposerMetaPickerKind,
trigger: HTMLElement | null,
metaRoot: HTMLElement | null,
): ComposerMetaPickerState {
if (!trigger || !metaRoot) return { kind, left: 0 };
const triggerRect = trigger.getBoundingClientRect();
const metaRect = metaRoot.getBoundingClientRect();
return {
kind,
left: Math.max(0, triggerRect.left - metaRect.left),
};
}
const COMPOSER_META_EFFORT_HIDDEN_CLASS = "is-effort-hidden";
const COMPOSER_META_MODEL_HIDDEN_CLASS = "is-model-hidden";
@ -261,8 +367,93 @@ function ComposerContextMeter({ context }: { context: ComposerMetaViewModel["con
);
}
function ComposerMetaIcon({ icon, active }: { icon: string; active: boolean }): UiNode {
return <ObsidianIcon icon={icon} className={["codex-panel__composer-meta-icon", active ? "is-active" : ""].filter(Boolean).join(" ")} />;
function ComposerMetaModeButton({ icon, active, onMouseDown }: { icon: string; active: boolean; onMouseDown: () => void }): UiNode {
const iconRef = useRef<HTMLSpanElement | null>(null);
useLayoutEffect(() => {
const element = iconRef.current;
if (!element) return;
element.replaceChildren();
setIcon(element, icon);
}, [icon]);
return (
<span
ref={iconRef}
className={["codex-panel__composer-meta-trigger", "codex-panel__composer-meta-icon", active ? "is-active" : ""]
.filter(Boolean)
.join(" ")}
onMouseDown={(event) => {
event.preventDefault();
onMouseDown();
}}
/>
);
}
function ComposerMetaPickerButton({
triggerRef,
kind,
value,
onMouseDown,
}: {
triggerRef: Ref<HTMLSpanElement>;
kind: ComposerMetaPickerKind;
value: string;
onMouseDown: () => void;
}): UiNode {
return (
<span
ref={triggerRef}
className={`codex-panel__composer-meta-trigger codex-panel__composer-meta-value codex-panel__composer-meta-${kind}`}
onMouseDown={(event) => {
event.preventDefault();
onMouseDown();
}}
>
{value}
</span>
);
}
function ComposerMetaChoicePopover({
kind,
choices,
left,
onClose,
}: {
kind: ComposerMetaPickerKind;
choices: ToolbarChoice[];
left: number;
onClose: () => void;
}): UiNode {
const style = {
"--codex-panel-composer-meta-popover-left": `${String(Math.round(left))}px`,
};
return (
<div className={`codex-panel__composer-meta-popover codex-panel__composer-meta-popover--${kind}`} style={style}>
{choices.map((choice) => (
<ComposerMetaChoice key={choice.label} choice={choice} onClose={onClose} />
))}
</div>
);
}
function ComposerMetaChoice({ choice, onClose }: { choice: ToolbarChoice; onClose: () => void }): UiNode {
const onSelect = () => {
if (choice.disabled) return;
choice.onClick();
onClose();
};
return (
<div
className={["codex-panel__composer-meta-option", choice.disabled ? "is-disabled" : ""].filter(Boolean).join(" ")}
onMouseDown={(event) => {
event.preventDefault();
onSelect();
}}
>
{choice.label}
</div>
);
}
interface ComposerSendMode {

View file

@ -27,11 +27,14 @@ export interface RuntimeSnapshotInput {
export interface ComposerMetaViewModel {
fatal: string | null;
context: ComposerContextMeterViewModel;
statusSummary: string;
model: string;
effort: string | null;
planActive: boolean;
autoReviewActive: boolean;
fastActive: boolean;
modelChoices?: ToolbarChoice[];
effortChoices?: ToolbarChoice[];
}
export interface ComposerContextMeterCellViewModel {
@ -160,6 +163,7 @@ export function composerMetaViewModel(state: ChatState, snapshot: RuntimeSnapsho
return {
fatal: "Codex app-server disconnected",
context: contextComposerMeter(null),
statusSummary: "Codex app-server disconnected",
model: "",
effort: null,
planActive: false,
@ -172,17 +176,53 @@ export function composerMetaViewModel(state: ChatState, snapshot: RuntimeSnapsho
const context = contextSummary(snapshot);
const model = currentModel(snapshot, config);
const effort = currentReasoningEffort(snapshot, config);
const composerContext = contextComposerMeter(context?.percent ?? null);
const compactEffort = effort ? compactReasoningEffortLabel(effort) : null;
const planActive = state.selectedCollaborationMode === "plan";
const reviewActive = autoReviewActive(snapshot, config);
const fastActive = fastModeActive(snapshot, config);
return {
fatal: null,
context: contextComposerMeter(context?.percent ?? null),
context: composerContext,
statusSummary: composerStatusSummary({
context: composerContext,
model: model ?? "default",
effort: compactEffort,
planActive,
autoReviewActive: reviewActive,
fastActive,
}),
model: model ?? "default",
effort: effort ? compactReasoningEffortLabel(effort) : null,
planActive: state.selectedCollaborationMode === "plan",
autoReviewActive: autoReviewActive(snapshot, config),
fastActive: fastModeActive(snapshot, config),
effort: compactEffort,
planActive,
autoReviewActive: reviewActive,
fastActive,
};
}
function composerStatusSummary(input: {
context: ComposerContextMeterViewModel;
model: string;
effort: string | null;
planActive: boolean;
autoReviewActive: boolean;
fastActive: boolean;
}): string {
const context = input.context.percent === "--%" ? "Context unavailable" : `Context ${input.context.percent.trim()}`;
return [
context,
`plan ${onOffLabel(input.planActive)}`,
`auto-review ${onOffLabel(input.autoReviewActive)}`,
`fast ${onOffLabel(input.fastActive)}`,
`model ${input.model}`,
`reasoning effort ${input.effort ?? "default"}`,
].join(", ");
}
function onOffLabel(active: boolean): string {
return active ? "on" : "off";
}
export function toolbarViewModel(input: ToolbarViewModelInput): ToolbarViewModel {
const { state, snapshot } = input;
const config = readRuntimeConfig(state.effectiveConfig);

View file

@ -78,6 +78,12 @@ export function composerSlotSnapshot(state: ChatState, activeComposerThreadName:
activeTurnId(state),
state.activeModel,
state.activeReasoningEffort,
state.activeCollaborationMode,
state.activeServiceTier,
state.activeApprovalsReviewer,
state.selectedCollaborationMode,
state.requestedServiceTier,
state.requestedApprovalsReviewer,
state.requestedModel,
state.requestedReasoningEffort,
state.tokenUsage,

View file

@ -556,7 +556,15 @@ export class CodexChatView extends ItemView {
}
private composerMetaViewModel() {
return buildComposerMetaViewModel(this.state, this.runtimeSnapshot());
return {
...buildComposerMetaViewModel(this.state, this.runtimeSnapshot()),
...runtimeToolbarChoices({
state: this.state,
snapshot: this.runtimeSnapshot(),
setRequestedModel: (model) => void this.setRequestedModelFromUi(model),
setRequestedReasoningEffort: (effort) => void this.setRequestedReasoningEffortFromUi(effort),
}),
};
}
private activeComposerThreadName(): string | null {

View file

@ -25,6 +25,7 @@
}
.codex-panel__composer-meta {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
min-width: 0;
@ -37,6 +38,7 @@
}
.codex-panel__composer-meta-status {
position: relative;
box-sizing: border-box;
display: flex;
min-width: 0;
@ -47,6 +49,24 @@
white-space: nowrap;
}
.codex-panel__composer-meta-summary {
position: absolute;
width: var(--codex-panel-rail-width);
height: var(--codex-panel-rail-width);
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.codex-panel__composer-meta-status-visual {
display: flex;
flex: 0 0 auto;
min-width: 0;
gap: var(--size-4-2);
align-items: center;
}
.codex-panel__composer-meta-context {
display: inline-flex;
flex: 0 0 auto;
@ -117,11 +137,54 @@
stroke-width: 2;
}
.codex-panel__composer-meta-model,
.codex-panel__composer-meta-effort {
.codex-panel__composer-meta-value.codex-panel__composer-meta-value {
display: inline-flex;
min-width: 0;
align-items: center;
text-align: left;
white-space: nowrap;
}
.codex-panel__composer-meta-popover {
position: absolute;
bottom: calc(100% + var(--codex-panel-panel-gap));
left: var(--codex-panel-composer-meta-popover-left);
z-index: var(--layer-popover, 30);
display: flex;
flex-direction: column;
width: max-content;
max-width: calc(100% - var(--codex-panel-composer-meta-popover-left));
max-height: min(var(--codex-panel-size-toolbar-panel-max-height), 36vh);
overflow-y: auto;
border: var(--codex-panel-border);
border-radius: var(--codex-panel-card-radius);
background: var(--codex-panel-surface);
box-shadow: var(--shadow-s);
padding: var(--codex-panel-panel-gap);
}
.codex-panel__composer-meta-option {
overflow: hidden;
min-width: 0;
padding: var(--size-4-1) var(--size-4-2);
color: var(--text-muted);
font: inherit;
line-height: var(--line-height-tight);
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
}
.codex-panel__composer-meta-option:where(:hover, :focus, :focus-visible, :active):not(.is-disabled) {
color: var(--nav-item-color-hover, var(--text-normal));
background: var(--background-modifier-hover);
outline: none;
}
.codex-panel__composer-meta-option.is-disabled {
opacity: 0.45;
}
.codex-panel__composer-meta--fatal {
color: var(--text-muted);
}

View file

@ -35,6 +35,7 @@ describe("ChatComposerController", () => {
],
percent: "--%",
},
statusSummary: "Context unavailable, plan off, auto-review off, fast off, model default, reasoning effort default",
model: "default",
effort: null,
planActive: false,
@ -42,6 +43,9 @@ describe("ChatComposerController", () => {
fastActive: false,
}),
currentModelForSuggestions: () => null,
togglePlan: vi.fn(),
toggleAutoReview: vi.fn(),
toggleFast: vi.fn(),
renderIfDetached: vi.fn(),
onDraftChange: vi.fn(),
onComposerResize: vi.fn(),
@ -64,6 +68,56 @@ describe("ChatComposerController", () => {
expect(composer(parent).getAttribute("aria-expanded")).toBe("false");
expect(composer(parent).hasAttribute("aria-activedescendant")).toBe(false);
});
it("delegates composer runtime toggles without forcing a local rerender", () => {
const stateStore = createChatStateStore();
const parent = document.createElement("div");
const togglePlan = vi.fn();
const controller = new ChatComposerController({
app: app(),
stateStore,
viewId: "view",
sendShortcut: () => "enter",
scrollThreadFromComposerEdges: () => false,
canInterrupt: () => false,
composerPlaceholder: () => "Ask Codex to work on this task...",
composerMeta: () => ({
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,
}),
currentModelForSuggestions: () => null,
togglePlan,
toggleAutoReview: vi.fn(),
toggleFast: vi.fn(),
renderIfDetached: vi.fn(),
onDraftChange: vi.fn(),
onComposerResize: vi.fn(),
onSubmit: vi.fn(),
onThreadScrollFromComposer: vi.fn(),
});
controller.render(parent);
expect(parent.querySelector<HTMLElement>(".codex-panel__composer-meta-icon")?.classList.contains("is-active")).toBe(false);
parent.querySelector<HTMLElement>(".codex-panel__composer-meta-icon")?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
expect(togglePlan).toHaveBeenCalledOnce();
expect(parent.querySelector<HTMLElement>(".codex-panel__composer-meta-icon")?.classList.contains("is-active")).toBe(false);
});
});
function app(): App {

View file

@ -15,6 +15,9 @@ function composerCallbacks() {
onUpdateSuggestions: vi.fn(),
onKeydown: vi.fn(),
onSendOrInterrupt: vi.fn(),
onTogglePlan: vi.fn(),
onToggleAutoReview: vi.fn(),
onToggleFast: vi.fn(),
onSuggestionHover: vi.fn(),
onSuggestionInsert: vi.fn(),
};
@ -43,7 +46,7 @@ describe("composer renderer decisions", () => {
expect(composer.getAttribute("placeholder")).toBe("Ask Codex to work on “Renamed thread”...");
});
it("renders composer meta as non-interactive context and runtime text", () => {
it("renders composer meta as interactive context and runtime text without changing normal text", () => {
const parent = document.createElement("div");
renderComposerShell(parent, "view", "", false, false, "Ask Codex to work on this task...", [], 0, composerCallbacks(), {
@ -57,20 +60,33 @@ describe("composer renderer decisions", () => {
],
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 meta = parent.querySelector<HTMLElement>(".codex-panel__composer-meta");
const statusItems = Array.from(parent.querySelectorAll<HTMLElement>(".codex-panel__composer-meta-status > span"));
const statusItems = Array.from(parent.querySelectorAll<HTMLElement>(".codex-panel__composer-meta-status-visual > span"));
const fields = Array.from(parent.querySelectorAll<HTMLElement>(".codex-panel__composer-meta-field"));
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(meta?.getAttribute("aria-hidden")).toBeNull();
expect(meta?.textContent).toBe("|⣿⣶⣀⣀42%|gpt-5.5|high");
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(statusItems.map((item) => item.className)).toEqual([
"codex-panel__composer-meta-modes",
"codex-panel__composer-meta-separator",
@ -79,7 +95,7 @@ describe("composer renderer decisions", () => {
"codex-panel__composer-meta-field codex-panel__composer-meta-field--effort",
]);
expect(fields.map((field) => field.textContent)).toEqual(["|gpt-5.5", "|high"]);
expect(parent.querySelector(".codex-panel__composer-meta-status")?.getAttribute("aria-hidden")).toBe("true");
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]);
@ -87,10 +103,107 @@ describe("composer renderer 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.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<HTMLElement>(".codex-panel__composer-meta-model")?.tagName).toBe("SPAN");
expect(parent.querySelector<HTMLElement>(".codex-panel__composer-meta-effort")?.tagName).toBe("SPAN");
expect(parent.querySelector<HTMLElement>(".codex-panel__composer-meta-model")?.getAttribute("role")).toBeNull();
expect(parent.querySelector<HTMLElement>(".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();
});
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();
renderComposerShell(parent, "view", "", false, false, "Ask Codex to work on this task...", [], 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(".codex-panel__composer-meta-popover--model")?.textContent).toBe("gpt-5.5gpt-5.4");
});
const modelOptions = Array.from(parent.querySelectorAll<HTMLElement>(".codex-panel__composer-meta-option"));
expect(modelOptions.map((option) => option.getAttribute("role"))).toEqual([null, null]);
expect(modelOptions.map((option) => option.getAttribute("tabindex"))).toEqual([null, null]);
expect(modelOptions.map((option) => option.getAttribute("aria-selected"))).toEqual([null, null]);
expect(modelOptions.every((option) => !option.classList.contains("is-selected"))).toBe(true);
expect(modelOptions.every((option) => !option.classList.contains("codex-panel-ui__nav-item"))).toBe(true);
expect(modelOptions.every((option) => !option.classList.contains("codex-panel__toolbar-panel-item"))).toBe(true);
parent.querySelector<HTMLElement>(".codex-panel__composer-meta-effort")?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
await waitForAsyncWork(() => {
expect(parent.querySelector(".codex-panel__composer-meta-popover--model")).toBeNull();
expect(parent.querySelector(".codex-panel__composer-meta-popover--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(".codex-panel__composer-meta-popover--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(".codex-panel__composer-meta-popover--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");
@ -105,6 +218,7 @@ describe("composer renderer decisions", () => {
],
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,
@ -147,6 +261,7 @@ describe("composer renderer decisions", () => {
],
percent: "--%",
},
statusSummary: "Codex app-server disconnected",
model: "",
effort: null,
planActive: false,

View file

@ -336,6 +336,9 @@ describe("CodexChatView connection lifecycle", () => {
expect(composerSlotSnapshot({ ...state, effectiveConfig: effectiveConfig("gpt-updated") }, null)).not.toBe(base);
expect(composerSlotSnapshot({ ...state, requestedModel: { kind: "set", value: "gpt-requested" } }, null)).not.toBe(base);
expect(composerSlotSnapshot({ ...state, selectedCollaborationMode: "plan" }, null)).not.toBe(base);
expect(composerSlotSnapshot({ ...state, requestedApprovalsReviewer: { kind: "set", value: "auto_review" } }, null)).not.toBe(base);
expect(composerSlotSnapshot({ ...state, requestedServiceTier: { kind: "set", value: "fast" } }, null)).not.toBe(base);
expect(composerSlotSnapshot({ ...state, availableSkills: [skillFixture("reader")] }, null)).not.toBe(base);
});

View file

@ -104,6 +104,7 @@ describe("chat view model", () => {
],
percent: "42%",
},
statusSummary: "Context 42%, plan on, auto-review on, fast on, model gpt-5.5, reasoning effort high",
model: "gpt-5.5",
effort: "high",
planActive: true,
@ -139,6 +140,7 @@ describe("chat view model", () => {
],
percent: "--%",
},
statusSummary: "Context unavailable, plan off, auto-review off, fast off, model gpt-5.5, reasoning effort default",
model: "gpt-5.5",
effort: null,
});
@ -163,6 +165,7 @@ describe("chat view model", () => {
],
percent: " 0%",
},
statusSummary: "Context 0%, plan off, auto-review off, fast off, model default, reasoning effort default",
});
});
@ -181,6 +184,7 @@ describe("chat view model", () => {
],
percent: "--%",
},
statusSummary: "Codex app-server disconnected",
model: "",
effort: null,
planActive: false,

View file

@ -128,15 +128,30 @@ describe("chat toolbar CSS", () => {
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(composerMetaStatus).toContain("position: relative");
expect(composerMetaStatus).toContain("padding-inline-start: calc(var(--size-4-1) / 2)");
expect(composerMetaFatal).toContain("padding-inline-start: calc(var(--size-4-1) / 2)");
});
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(--codex-panel-rail-width)");
expect(summary).toContain("height: var(--codex-panel-rail-width)");
expect(summary).toContain("clip-path: inset(50%)");
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-model,\n\.codex-panel__composer-meta-effort \{(?<body>[^}]+)\}/.exec(styles)?.groups?.["body"] ?? "";
/\.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"] ?? "";
@ -146,6 +161,7 @@ describe("chat toolbar CSS", () => {
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)");
@ -160,6 +176,22 @@ describe("chat toolbar CSS", () => {
);
});
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"] ?? "";