Standardize chat toolbar and add composer runtime meta

This commit is contained in:
murashit 2026-06-04 20:05:48 +09:00
parent fceea7f18b
commit e8d4a62877
20 changed files with 249 additions and 414 deletions

View file

@ -17,6 +17,7 @@ import { userInputWithWikiLinkMentionsAndSkills } from "./composer/wikilink-cont
import type { UserInput } from "../../generated/app-server/v2/UserInput";
import { renderComposerShell, syncComposerHeight } from "./ui/composer";
import { chatTurnBusy, type ChatAction, type ChatState, type ChatStateStore } from "./chat-state";
import type { ComposerMetaViewModel } from "./view-model";
export interface ChatComposerControllerOptions {
app: App;
@ -26,6 +27,7 @@ export interface ChatComposerControllerOptions {
scrollThreadFromComposerEdges: () => boolean;
canInterrupt: () => boolean;
composerPlaceholder: () => string;
composerMeta: () => ComposerMetaViewModel;
currentModelForSuggestions: () => string | null;
renderIfDetached: () => void;
onDraftChange: () => void;
@ -117,6 +119,7 @@ export class ChatComposerController {
this.insertSuggestion(suggestion);
},
},
this.options.composerMeta(),
);
this.composer = elements.composer;
syncComposerHeight(this.composer);

View file

@ -7,6 +7,7 @@ import type { RuntimeSnapshot } from "../../runtime/state";
import { currentModel } from "../../runtime/state";
import type { ChatState, ChatStateStore } from "./chat-state";
import type { DisplayDetailSection } from "./display/types";
import type { ComposerMetaViewModel } from "./view-model";
import { ChatAppServerController } from "./chat-app-server-controller";
import { ChatComposerController } from "./chat-composer-controller";
import { ChatController } from "./chat-controller";
@ -95,6 +96,7 @@ export interface ChatViewControllerAssemblyHost {
pendingRequestsSignature: () => string;
activeComposerThreadName: () => string | null;
composerPlaceholder: () => string;
composerMetaViewModel: () => ComposerMetaViewModel;
runtimeSnapshot: () => RuntimeSnapshot;
collaborationModeLabel: () => string;
connectionDiagnosticDetails: () => DisplayDetailSection[];
@ -248,6 +250,7 @@ export function createChatViewControllerAssembly(host: ChatViewControllerAssembl
canInterrupt: () =>
host.getState().turnLifecycle.kind !== "idle" && Boolean(host.getState().activeThreadId && activeTurnId(host.getState())),
composerPlaceholder: host.composerPlaceholder,
composerMeta: host.composerMetaViewModel,
currentModelForSuggestions: () => currentModel(host.runtimeSnapshot()),
renderIfDetached: host.effects.render.now,
onDraftChange: host.effects.liveState.refresh,

View file

@ -1,7 +1,6 @@
import type { EffectiveConfigSection, RateLimitSummary } from "../../runtime/view";
export type ToolbarPanelKind = "history" | "status" | "runtime";
export type ToolbarStatusState = "offline" | "ready" | "degraded" | "blocked" | "running";
export interface ToolbarChoice {
label: string;
@ -38,17 +37,12 @@ export interface ToolbarDiagnosticSection {
export interface ToolbarViewModel {
connected: boolean;
status: string;
statusState: ToolbarStatusState;
historyOpen: boolean;
statusPanelOpen: boolean;
runtimeOpen: boolean;
planActive: boolean;
autoReviewActive: boolean;
fastActive: boolean;
runtimeSummary: string;
runtimeTitle: string;
runtimeEmphasized: boolean;
context: { level: "ok" | "warn" | "danger"; title: string; label: string; percent: number | null } | null;
rateLimit: RateLimitSummary | null;
configSections: EffectiveConfigSection[];
openPanel: ToolbarPanelKind | null;

View file

@ -2,6 +2,7 @@ import type { ButtonHTMLAttributes, ComponentChild as UiNode, Ref } from "preact
import { useLayoutEffect, useRef } from "preact/hooks";
import type { ComposerSuggestion } from "../composer/suggestions";
import type { ComposerMetaViewModel } from "../view-model";
import { IconButton } from "../../../shared/ui/components";
import { renderUiRoot } from "../../../shared/ui/ui-root";
import { syncTextareaHeight } from "../../../shared/ui/textarea-autogrow";
@ -25,6 +26,12 @@ type ButtonProps = ButtonHTMLAttributes & {
disabled?: boolean | undefined;
};
const DEFAULT_COMPOSER_META: ComposerMetaViewModel = {
fatal: null,
contextIndicator: "⣀⣀⣀⣀⣀⣀⣀⣀",
runtime: "default",
};
export function renderComposerShell(
parent: HTMLElement,
viewId: string,
@ -35,6 +42,7 @@ export function renderComposerShell(
suggestions: readonly ComposerSuggestion[],
selectedSuggestionIndex: number,
callbacks: ComposerCallbacks,
meta: ComposerMetaViewModel = DEFAULT_COMPOSER_META,
): ComposerElements {
const elements: Partial<ComposerElements> = {};
renderUiRoot(
@ -45,6 +53,7 @@ export function renderComposerShell(
busy={busy}
canInterrupt={canInterrupt}
normalPlaceholder={normalPlaceholder}
meta={meta}
suggestions={suggestions}
selectedSuggestionIndex={selectedSuggestionIndex}
callbacks={callbacks}
@ -63,6 +72,7 @@ function ComposerShell({
busy,
canInterrupt,
normalPlaceholder,
meta,
suggestions,
selectedSuggestionIndex,
callbacks,
@ -73,6 +83,7 @@ function ComposerShell({
busy: boolean;
canInterrupt: boolean;
normalPlaceholder: string;
meta: ComposerMetaViewModel;
suggestions: readonly ComposerSuggestion[];
selectedSuggestionIndex: number;
callbacks: ComposerCallbacks;
@ -137,6 +148,7 @@ function ComposerShell({
onClick={callbacks.onSendOrInterrupt}
/>
</div>
<ComposerMeta meta={meta} />
<ComposerSuggestions
containerRef={suggestionsRef}
selectedRef={selectedSuggestionRef}
@ -149,6 +161,18 @@ function ComposerShell({
);
}
function ComposerMeta({ meta }: { meta: ComposerMetaViewModel }): UiNode {
if (meta.fatal) {
return <div className="codex-panel__composer-meta codex-panel__composer-meta--fatal">{meta.fatal}</div>;
}
return (
<div className="codex-panel__composer-meta" aria-hidden="true">
<span className="codex-panel__composer-meta-context">{meta.contextIndicator}</span>
<span className="codex-panel__composer-meta-runtime">{meta.runtime}</span>
</div>
);
}
function composerSendMode(
busy: boolean,
canInterrupt: boolean,

View file

@ -36,19 +36,18 @@ export function renderToolbar(toolbar: HTMLElement, model: ToolbarViewModel, act
function Toolbar({ model, actions }: { model: ToolbarViewModel; actions: ToolbarActions }): UiNode {
return (
<>
<div className="codex-panel__toolbar-primary">
<ToolbarIconButton
icon="history"
label={model.historyOpen ? "Hide thread list" : "Show thread list"}
className={["codex-panel__history-toggle", model.historyOpen ? "is-active" : ""].filter(Boolean).join(" ")}
aria-pressed={model.historyOpen ? "true" : "false"}
onClick={actions.toggleHistory}
/>
<div className="codex-panel__runtime-area">
<RuntimeStrip model={model} actions={actions} />
<div className="nav-header codex-panel__toolbar-primary">
<div className="nav-buttons-container codex-panel__toolbar-buttons">
<ToolbarIconButton
icon="history"
label={model.historyOpen ? "Hide thread list" : "Show thread list"}
className={["codex-panel__history-toggle", model.historyOpen ? "is-active" : ""].filter(Boolean).join(" ")}
aria-pressed={model.historyOpen ? "true" : "false"}
onClick={actions.toggleHistory}
/>
<RuntimeButtons model={model} actions={actions} />
<StatusButton model={model} actions={actions} />
</div>
<ContextMeter context={model.context} />
<StatusButton model={model} actions={actions} />
</div>
<ToolbarPanel model={model} actions={actions} />
</>
@ -75,9 +74,9 @@ function ToolbarIconButton({
);
}
function RuntimeStrip({ model, actions }: { model: ToolbarViewModel; actions: ToolbarActions }): UiNode {
function RuntimeButtons({ model, actions }: { model: ToolbarViewModel; actions: ToolbarActions }): UiNode {
return (
<div className="codex-panel__runtime-strip">
<>
<RuntimeIcon
icon="list-checks"
label="Toggle plan mode"
@ -93,25 +92,15 @@ function RuntimeStrip({ model, actions }: { model: ToolbarViewModel; actions: To
onClick={actions.toggleAutoReview}
/>
<RuntimeIcon icon="zap" label="Toggle fast mode" active={model.fastActive} onClick={actions.toggleFast} />
<button
className={[
"clickable-icon",
"codex-panel__runtime-model",
"codex-panel-ui__toolbar-control",
model.runtimeEmphasized ? "is-emphasized" : "",
model.runtimeOpen ? "is-active" : "",
]
.filter(Boolean)
.join(" ")}
type="button"
<ToolbarIconButton
icon="brain"
label="Change model and reasoning effort"
className={["codex-panel__runtime-model", model.runtimeOpen ? "is-active" : ""].filter(Boolean).join(" ")}
aria-label="Change model and reasoning effort"
aria-expanded={model.runtimeOpen ? "true" : "false"}
title={model.runtimeTitle}
onClick={actions.toggleRuntime}
>
<span className="codex-panel__runtime-model-value">{model.runtimeSummary}</span>
</button>
</div>
/>
</>
);
}
@ -139,33 +128,12 @@ function RuntimeIcon({
);
}
function ContextMeter({ context }: { context: ToolbarViewModel["context"] }): UiNode {
if (!context) return null;
return (
<div className={`codex-panel__meter-compact codex-panel__context-compact codex-panel__meter-compact--${context.level}`}>
<span className="codex-panel__meter-compact-label codex-panel__context-compact-label">{context.label}</span>
<span className="codex-panel__meter-compact-bar codex-panel__context-compact-bar">
<span
className="codex-panel__meter-compact-fill codex-panel__context-compact-fill"
style={{ width: `${String(context.percent ?? 0)}%` }}
/>
</span>
</div>
);
}
function StatusButton({ model, actions }: { model: ToolbarViewModel; actions: ToolbarActions }): UiNode {
return (
<button
className={[
"clickable-icon codex-panel-ui__toolbar-control codex-panel__status-dot",
`codex-panel__status-dot--${model.statusState}`,
model.statusPanelOpen ? "is-active" : "",
]
.filter(Boolean)
.join(" ")}
type="button"
aria-label={model.statusPanelOpen ? "Hide connection status" : "Show connection status"}
<ToolbarIconButton
icon="ellipsis"
label={model.statusPanelOpen ? "Hide panel menu" : "Show panel menu"}
className={["codex-panel__status-menu-toggle", model.statusPanelOpen ? "is-active" : ""].filter(Boolean).join(" ")}
aria-expanded={model.statusPanelOpen ? "true" : "false"}
onClick={actions.toggleStatusPanel}
/>

View file

@ -7,25 +7,29 @@ import {
currentReasoningEffort,
fastModeActive,
pendingRuntimeSettingLabel,
runtimeSummaryLabel,
serviceTierLabel,
supportedReasoningEfforts,
} from "../../runtime/state";
import { readRuntimeConfig } from "../../runtime/config";
import { sortedAvailableModels } from "../../runtime/model";
import { compactContextLabel } from "../../runtime/settings";
import { compactReasoningEffortLabel } from "../../runtime/settings";
import { contextSummary, effectiveConfigSections, rateLimitSummary } from "../../runtime/view";
import { codexPanelDisplayTitle, explicitThreadName, getThreadTitle } from "../../domain/threads/model";
import type { AppServerDiagnostics } from "../../app-server/compatibility";
import { connectionDiagnosticSections, hasDiagnosticIssue } from "./diagnostics";
import { connectionDiagnosticSections } from "./diagnostics";
import type { ChatState } from "./chat-state";
import { statusValue, usageLimitStatusLines } from "./status-lines";
import type { ToolbarChoice, ToolbarStatusState, ToolbarThreadRow, ToolbarViewModel } from "./toolbar-model";
import type { ToolbarChoice, ToolbarThreadRow, ToolbarViewModel } from "./toolbar-model";
export interface RuntimeSnapshotInput {
state: ChatState;
}
export interface ComposerMetaViewModel {
fatal: string | null;
contextIndicator: string;
runtime: string;
}
export interface ToolbarViewModelInput {
state: ChatState;
snapshot: RuntimeSnapshot;
@ -46,14 +50,6 @@ export interface ConnectionDiagnosticsModelInput {
configuredCommand: string;
}
export interface StatusDotStateInput {
connected: boolean;
turnBusy: boolean;
diagnostics: AppServerDiagnostics;
connectionFailed?: boolean;
turnStartBlocked?: boolean;
}
export interface RuntimeToolbarChoicesInput {
state: ChatState;
snapshot: RuntimeSnapshot;
@ -145,36 +141,42 @@ export function composerPlaceholder(threadName: string | null): string {
return threadName ? `Ask Codex to work on “${threadName}”...` : "Ask Codex to work on this task...";
}
export function composerMetaViewModel(state: ChatState, snapshot: RuntimeSnapshot): ComposerMetaViewModel {
if (state.status === "Connection failed.") {
return {
fatal: "Codex app-server disconnected",
contextIndicator: "",
runtime: "",
};
}
const config = readRuntimeConfig(state.effectiveConfig);
const context = contextSummary(snapshot);
const model = currentModel(snapshot, config);
const effort = currentReasoningEffort(snapshot, config);
return {
fatal: null,
contextIndicator: brailleContextIndicator(context?.percent ?? null),
runtime: runtimeComposerLabel(model, effort),
};
}
export function toolbarViewModel(input: ToolbarViewModelInput): ToolbarViewModel {
const { state, snapshot } = input;
const config = readRuntimeConfig(state.effectiveConfig);
const context = contextSummary(snapshot);
const limit = rateLimitSummary(snapshot);
const historyOpen = state.openDetails.has("history");
const statusPanelOpen = state.openDetails.has("status-panel");
const runtimeOpen = state.runtimePicker !== null;
const statusState = statusDotState({
connected: input.connected,
turnBusy: input.turnBusy,
diagnostics: state.appServerDiagnostics,
connectionFailed: state.status === "Connection failed.",
});
const model = currentModel(snapshot, config);
const effort = currentReasoningEffort(snapshot, config);
return {
connected: input.connected,
status: state.status,
statusState,
historyOpen,
statusPanelOpen,
runtimeOpen,
planActive: state.selectedCollaborationMode === "plan",
autoReviewActive: autoReviewActive(snapshot, config),
fastActive: fastModeActive(snapshot, config),
runtimeSummary: runtimeSummaryLabel(model, effort),
runtimeTitle: `Model: ${model ?? "(Codex default)"}; Effort: ${effort ?? "(Codex default)"}`,
runtimeEmphasized: false,
context: context ? { ...context, label: compactContextLabel(context.percent, context.label) } : null,
rateLimit: limit,
configSections: effectiveConfigSections(snapshot, input.vaultPath),
openPanel: historyOpen ? "history" : runtimeOpen ? "runtime" : statusPanelOpen ? "status" : null,
@ -197,12 +199,23 @@ export function toolbarViewModel(input: ToolbarViewModelInput): ToolbarViewModel
};
}
export function statusDotState(input: StatusDotStateInput): ToolbarStatusState {
if (input.turnBusy) return "running";
if (input.connectionFailed) return "blocked";
if (!input.connected) return "offline";
if (input.turnStartBlocked) return "blocked";
return hasDiagnosticIssue(input.diagnostics) ? "degraded" : "ready";
const CONTEXT_INDICATOR_WIDTH = 8;
const CONTEXT_BRAILLE_LEVELS = ["⣀", "⣄", "⣤", "⣦", "⣶", "⣷", "⣿"] as const;
function brailleContextIndicator(percent: number | null): string {
if (percent === null) return CONTEXT_BRAILLE_LEVELS[0].repeat(CONTEXT_INDICATOR_WIDTH);
const clamped = Math.max(0, Math.min(100, percent));
const filled = (clamped / 100) * CONTEXT_INDICATOR_WIDTH;
return Array.from({ length: CONTEXT_INDICATOR_WIDTH }, (_, index) => {
const local = Math.max(0, Math.min(1, filled - index));
const levelIndex = Math.round(local * (CONTEXT_BRAILLE_LEVELS.length - 1));
return CONTEXT_BRAILLE_LEVELS[levelIndex];
}).join("");
}
function runtimeComposerLabel(model: string | null, effort: ReasoningEffort | null): string {
const modelLabel = model ?? "default";
return effort ? `${modelLabel} ${compactReasoningEffortLabel(effort)}` : modelLabel;
}
export function connectionDiagnosticsModel(input: ConnectionDiagnosticsModelInput): ReturnType<typeof connectionDiagnosticSections> {

View file

@ -72,9 +72,16 @@ export function messagesSlotSnapshot(state: ChatState, pendingRequestsSignature:
export function composerSlotSnapshot(state: ChatState, activeComposerThreadName: string | null): ChatPanelSlotSnapshot {
return signatureParts(
state.composerDraft,
state.status,
chatTurnBusy(state),
state.activeThreadId,
activeTurnId(state),
state.activeModel,
state.activeReasoningEffort,
state.requestedModel,
state.requestedReasoningEffort,
state.tokenUsage,
state.effectiveConfig,
currentModel(runtimeSnapshotForChatState({ state }), readRuntimeConfig(state.effectiveConfig)),
state.availableSkills.length,
skillsSignature(state.availableSkills),

View file

@ -21,6 +21,7 @@ import {
activeComposerThreadName as buildActiveComposerThreadName,
activeThreadTitle as buildActiveThreadTitle,
chatViewDisplayTitle,
composerMetaViewModel as buildComposerMetaViewModel,
connectionDiagnosticsModel,
composerPlaceholder as buildComposerPlaceholder,
effortStatusLines as buildEffortStatusLines,
@ -131,6 +132,7 @@ export class CodexChatView extends ItemView {
pendingRequestsSignature: () => this.pendingRequestsSignature(),
activeComposerThreadName: () => this.activeComposerThreadName(),
composerPlaceholder: () => this.composerPlaceholder(),
composerMetaViewModel: () => this.composerMetaViewModel(),
runtimeSnapshot: () => this.runtimeSnapshot(),
collaborationModeLabel: () => this.collaborationModeLabel(),
connectionDiagnosticDetails: () => this.connectionDiagnosticDetails(),
@ -553,6 +555,10 @@ export class CodexChatView extends ItemView {
return buildComposerPlaceholder(this.activeComposerThreadName());
}
private composerMetaViewModel() {
return buildComposerMetaViewModel(this.state, this.runtimeSnapshot());
}
private activeComposerThreadName(): string | null {
return buildActiveComposerThreadName(this.state, this.restoredThreadPlaceholder());
}

View file

@ -36,9 +36,3 @@ export function compactReasoningEffortLabel(effort: ReasoningEffort | null): str
if (effort === "minimal") return "min";
return effort;
}
export function compactContextLabel(percent: number | null, label: string): string {
if (percent !== null) return `${String(percent)}%`;
if (label === "Context unknown") return "?";
return label === "Context waiting" ? "wait" : label.replace(/^Context\s+/i, "");
}

View file

@ -30,8 +30,6 @@
--codex-panel-size-composer-suggestions-max-height: 168px;
--codex-panel-icon-button-inline-size: calc(var(--codex-panel-control-icon-size) + var(--codex-panel-item-gap) * 2);
--codex-panel-icon-button-block-size: calc(var(--codex-panel-control-icon-size) + var(--codex-panel-control-gap) * 2);
--codex-panel-status-dot-size: var(--codex-panel-section-gap);
--codex-panel-meter-bar-height: var(--codex-panel-control-gap);
--codex-panel-composer-control-size: var(--codex-panel-icon-button-block-size);
--codex-panel-composer-min-height: calc((var(--codex-panel-composer-control-size) * 2) + var(--codex-panel-control-gap));
--codex-panel-status-bar-clearance: var(--status-bar-height, calc(var(--codex-panel-section-gap) * 3));
@ -54,13 +52,7 @@
--codex-panel-color-success: var(--text-success);
--codex-panel-color-warning: var(--text-warning);
--codex-panel-color-danger: var(--text-error);
--codex-panel-status-ring-faint: color-mix(in srgb, var(--codex-panel-text-faint) 12%, transparent);
--codex-panel-status-ring-success: color-mix(in srgb, var(--codex-panel-color-success) 12%, transparent);
--codex-panel-status-ring-warning: color-mix(in srgb, var(--codex-panel-color-warning) 14%, transparent);
--codex-panel-status-ring-danger: color-mix(in srgb, var(--codex-panel-color-danger) 14%, transparent);
--codex-panel-status-ring-accent: color-mix(in srgb, var(--codex-panel-color-accent) 14%, transparent);
--codex-panel-edge-padding-x: var(--size-4-3, 12px);
--codex-panel-toolbar-button-gap: var(--size-2-1, 2px);
--codex-panel-section-gap: var(--size-4-2, 8px);
--codex-panel-item-gap: var(--size-2-3, 6px);
--codex-panel-control-gap: var(--size-2-2, 4px);

View file

@ -2,19 +2,8 @@
flex: 0 0 auto;
display: flex;
flex-direction: column;
padding: var(--codex-panel-section-gap);
}
.codex-panel__toolbar-primary {
display: flex;
gap: var(--codex-panel-toolbar-button-gap);
align-items: center;
min-width: 0;
}
.codex-panel__meter-compact-label,
.codex-panel__context-compact-label,
.codex-panel__runtime-model-value,
.codex-panel__limit-panel-label,
.codex-panel__limit-panel-value,
.codex-panel__limit-panel-reset,
@ -27,72 +16,10 @@
white-space: nowrap;
}
.codex-panel__runtime-area {
flex: 0 1 auto;
min-width: 0;
}
.codex-panel__runtime-strip {
display: inline-flex;
align-items: center;
gap: var(--codex-panel-toolbar-button-gap);
max-width: 100%;
min-width: 0;
white-space: nowrap;
}
.codex-panel__runtime-icon {
flex: 0 0 auto;
}
.codex-panel__runtime-model {
display: inline-flex;
align-items: center;
flex: 0 1 auto;
width: fit-content;
max-width: 100%;
gap: var(--codex-panel-control-gap);
padding: var(--codex-panel-control-gap) var(--codex-panel-item-gap);
border: 0;
appearance: none;
background: transparent;
box-shadow: none;
color: var(--text-muted);
font: inherit;
font-size: var(--codex-panel-toolbar-text-size);
line-height: var(--codex-panel-toolbar-line-height);
text-align: left;
cursor: default;
user-select: none;
}
.codex-panel__runtime-model:hover,
.codex-panel__runtime-model.is-active {
color: var(--text-normal);
}
.codex-panel__runtime-model:hover {
background: var(--background-modifier-hover);
}
.codex-panel__runtime-model.is-active,
.codex-panel__runtime-model.is-active:hover,
.codex-panel__runtime-model.is-active:focus-visible,
.codex-panel__runtime-model.is-active:active {
background: var(--background-modifier-active-hover);
color: var(--icon-color-active);
}
.codex-panel__runtime-model-value {
flex: 0 1 auto;
min-width: 0;
color: var(--codex-panel-toolbar-text-color);
}
.codex-panel__runtime-model.is-active .codex-panel__runtime-model-value {
color: currentcolor;
}
.codex-panel__runtime-picker {
display: flex;
flex-direction: column;

View file

@ -1,81 +1,5 @@
.codex-panel__status-dot {
cursor: default;
.codex-panel__status-menu-toggle {
flex: 0 0 auto;
margin-left: auto;
}
.codex-panel__status-dot::before {
content: "";
width: var(--codex-panel-status-dot-size);
height: var(--codex-panel-status-dot-size);
border-radius: var(--codex-panel-radius-pill);
background: var(--codex-panel-text-faint);
box-shadow: 0 0 0 3px var(--codex-panel-status-ring-faint);
}
.codex-panel__status-dot--ready::before {
background: var(--codex-panel-color-success);
box-shadow: 0 0 0 3px var(--codex-panel-status-ring-success);
}
.codex-panel__status-dot--degraded::before {
background: var(--codex-panel-color-warning);
box-shadow: 0 0 0 3px var(--codex-panel-status-ring-warning);
}
.codex-panel__status-dot--blocked::before {
background: var(--codex-panel-color-danger);
box-shadow: 0 0 0 3px var(--codex-panel-status-ring-danger);
}
.codex-panel__status-dot--running::before {
background: var(--codex-panel-color-accent);
box-shadow: 0 0 0 3px var(--codex-panel-status-ring-accent);
}
.codex-panel__meter-compact {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--codex-panel-item-gap);
height: var(--codex-panel-icon-button-block-size);
min-width: 0;
max-width: calc(var(--codex-panel-icon-button-inline-size) * 3);
padding: 0 var(--codex-panel-panel-gap);
color: var(--codex-panel-toolbar-text-color);
font-size: var(--codex-panel-toolbar-text-size);
line-height: var(--codex-panel-toolbar-line-height);
}
.codex-panel__context-compact {
margin-left: auto;
}
.codex-panel__context-compact + .codex-panel__status-dot {
margin-left: 0;
}
.codex-panel__meter-compact-bar {
flex: 0 0 calc(var(--codex-panel-icon-button-inline-size) + var(--size-4-1, 4px));
height: var(--codex-panel-meter-bar-height);
overflow: hidden;
border-radius: var(--codex-panel-radius-pill);
background: var(--codex-panel-border-color);
}
.codex-panel__meter-compact-fill {
display: block;
height: 100%;
border-radius: inherit;
background: var(--codex-panel-color-success);
}
.codex-panel__meter-compact--warn .codex-panel__meter-compact-fill {
background: var(--codex-panel-color-warning);
}
.codex-panel__meter-compact--danger .codex-panel__meter-compact-fill {
background: var(--codex-panel-color-danger);
}
.codex-panel__toolbar-panel {
@ -155,7 +79,7 @@
.codex-panel__limit-panel-meter {
align-self: center;
position: relative;
height: var(--codex-panel-meter-bar-height);
height: var(--codex-panel-control-gap);
margin: 0;
overflow: hidden;
border-radius: var(--codex-panel-radius-pill);

View file

@ -24,6 +24,38 @@
color: var(--icon-color-active);
}
.codex-panel__composer-meta {
grid-column: 1 / -1;
display: flex;
min-width: 0;
gap: var(--size-4-3);
align-items: center;
color: var(--text-muted);
font-size: var(--font-ui-smaller);
line-height: var(--line-height-tight);
user-select: none;
}
.codex-panel__composer-meta-context {
flex: 0 0 auto;
font-variant-numeric: tabular-nums;
white-space: pre;
}
.codex-panel__composer-meta-runtime {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.codex-panel__composer-meta--fatal {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.codex-panel__composer-suggestions {
position: absolute;
right: var(--codex-panel-edge-padding-x);

View file

@ -1,35 +1,4 @@
@container (max-width: 280px) {
.codex-panel__toolbar-primary {
flex-wrap: wrap;
}
.codex-panel__runtime-strip {
gap: var(--codex-panel-panel-gap);
}
.codex-panel__runtime-model {
padding: 0 var(--codex-panel-item-gap);
}
.codex-panel__runtime-area {
order: 3;
flex: 1 1 100%;
}
.codex-panel__meter-compact {
min-width: 0;
height: var(--codex-panel-control-icon-size);
padding-left: 0;
}
.codex-panel__meter-compact-label {
max-width: calc(var(--codex-panel-icon-button-inline-size) * 2);
}
.codex-panel__meter-compact-bar {
flex-basis: calc(var(--codex-panel-icon-button-inline-size) + calc(var(--codex-panel-section-gap) * 2));
}
.codex-panel__composer {
grid-template-columns: minmax(0, 1fr);
gap: var(--codex-panel-control-gap);

View file

@ -24,6 +24,7 @@ describe("ChatComposerController", () => {
scrollThreadFromComposerEdges: () => false,
canInterrupt: () => false,
composerPlaceholder: () => "Ask Codex to work on this task...",
composerMeta: () => ({ fatal: null, contextIndicator: "⣀⣀⣀⣀⣀⣀⣀⣀", runtime: "default" }),
currentModelForSuggestions: () => null,
renderIfDetached: vi.fn(),
onDraftChange: vi.fn(),

View file

@ -43,6 +43,35 @@ 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", () => {
const parent = document.createElement("div");
renderComposerShell(parent, "view", "", false, false, "Ask Codex to work on this task...", [], 0, composerCallbacks(), {
fatal: null,
contextIndicator: "⣿⣿⣿⣤⣀⣀⣀⣀",
runtime: "gpt-5.5 high",
});
const meta = parent.querySelector<HTMLElement>(".codex-panel__composer-meta");
expect(meta?.getAttribute("aria-hidden")).toBe("true");
expect(meta?.textContent).toBe("⣿⣿⣿⣤⣀⣀⣀⣀gpt-5.5 high");
expect(parent.querySelector(".codex-panel__composer-meta-context")?.textContent).toBe("⣿⣿⣿⣤⣀⣀⣀⣀");
expect(parent.querySelector(".codex-panel__composer-meta-runtime")?.textContent).toBe("gpt-5.5 high");
});
it("replaces composer meta with fatal status text", () => {
const parent = document.createElement("div");
renderComposerShell(parent, "view", "", false, false, "Ask Codex to work on this task...", [], 0, composerCallbacks(), {
fatal: "Codex app-server disconnected",
contextIndicator: "",
runtime: "",
});
expect(parent.querySelector(".codex-panel__composer-meta")?.textContent).toBe("Codex app-server disconnected");
expect(parent.querySelector(".codex-panel__composer-meta-context")).toBeNull();
});
it("renders composer suggestions inside the composer root", () => {
const parent = document.createElement("div");
const onSuggestionInsert = vi.fn();

View file

@ -14,7 +14,7 @@ function expectPresent<T>(value: T | null | undefined): T {
}
describe("toolbar renderer decisions", () => {
it("renders toolbar controls as buttons and updates live status state", () => {
it("renders toolbar controls as Obsidian-style action buttons", () => {
const parent = document.createElement("div");
const toggleHistory = vi.fn();
const toggleAutoReview = vi.fn();
@ -22,11 +22,25 @@ describe("toolbar renderer decisions", () => {
renderToolbar(parent, baseModel, toolbarActions({ toggleHistory, toggleAutoReview }));
const statusButton = parent.querySelector(".codex-panel__status-dot");
const navHeader = parent.querySelector(".codex-panel__toolbar-primary");
expect(navHeader?.classList.contains("nav-header")).toBe(true);
const navButtons = parent.querySelector(".codex-panel__toolbar-buttons");
expect(navButtons?.classList.contains("nav-buttons-container")).toBe(true);
expect(parent.querySelector(".codex-panel__runtime-area")).toBeNull();
expect(parent.querySelector(".codex-panel__runtime-strip")).toBeNull();
expect([...expectPresent(navButtons).children].map((button) => button.getAttribute("aria-label"))).toEqual([
"Show thread list",
"Toggle plan mode",
"Toggle auto-review",
"Toggle fast mode",
"Change model and reasoning effort",
"Show panel menu",
]);
const statusButton = parent.querySelector(".codex-panel__status-menu-toggle");
expect(statusButton?.tagName).toBe("BUTTON");
expect(statusButton?.getAttribute("role")).toBeNull();
expect(statusButton?.getAttribute("aria-label")).toBe("Show connection status");
expect(statusButton?.classList.contains("nav-action-button")).toBe(false);
expect(statusButton?.getAttribute("aria-label")).toBe("Show panel menu");
expect(statusButton?.classList.contains("nav-action-button")).toBe(true);
expect(statusButton?.classList.contains("clickable-icon")).toBe(true);
const historyButton = parent.querySelector<HTMLButtonElement>(".codex-panel__history-toggle");
expect(historyButton?.getAttribute("aria-label")).toBe("Show thread list");
@ -50,23 +64,21 @@ describe("toolbar renderer decisions", () => {
expect(autoReviewButton?.classList.contains("clickable-icon")).toBe(true);
autoReviewButton?.click();
expect(toggleAutoReview).toHaveBeenCalled();
expect([...parent.querySelectorAll(".codex-panel__runtime-strip > button")].map((button) => button.getAttribute("aria-label"))).toEqual(
["Toggle plan mode", "Toggle auto-review", "Toggle fast mode", "Change model and reasoning effort"],
);
parent.empty();
renderToolbar(parent, toolbarModel({ status: "Turn running...", statusState: "running", autoReviewActive: true }), toolbarActions());
expect(parent.querySelector(".codex-panel__status-dot")?.getAttribute("aria-label")).toBe("Show connection status");
renderToolbar(parent, toolbarModel({ status: "Turn running...", autoReviewActive: true }), toolbarActions());
expect(parent.querySelector(".codex-panel__status-menu-toggle")?.getAttribute("aria-label")).toBe("Show panel menu");
expect(parent.querySelector(".codex-panel__auto-review-toggle")?.getAttribute("aria-pressed")).toBe("true");
parent.empty();
renderToolbar(parent, toolbarModel({ historyOpen: true, statusPanelOpen: true }), toolbarActions());
expect(parent.querySelector(".codex-panel__history-toggle")?.getAttribute("aria-label")).toBe("Hide thread list");
expect(parent.querySelector(".codex-panel__history-toggle")?.classList.contains("is-active")).toBe(true);
expect(parent.querySelector(".codex-panel__status-dot")?.getAttribute("aria-label")).toBe("Hide connection status");
expect(parent.querySelector(".codex-panel__status-dot")?.classList.contains("is-active")).toBe(true);
expect(parent.querySelector(".codex-panel__status-menu-toggle")?.getAttribute("aria-label")).toBe("Hide panel menu");
expect(parent.querySelector(".codex-panel__status-menu-toggle")?.classList.contains("is-active")).toBe(true);
expect(parent.querySelector(".codex-panel__runtime-model")?.getAttribute("aria-label")).toBe("Change model and reasoning effort");
expect(parent.querySelector(".codex-panel__runtime-model")?.classList.contains("is-active")).toBe(true);
expect(parent.querySelector(".codex-panel__runtime-model")?.classList.contains("nav-action-button")).toBe(true);
});
it("keeps frequently changed effort choices first inside the runtime menu", () => {
@ -95,7 +107,7 @@ describe("toolbar renderer decisions", () => {
}
});
it("renders context as a compact meter and Codex limits only in the status menu", () => {
it("keeps context out of the toolbar and Codex limits inside the status menu", () => {
const parent = document.createElement("div");
renderToolbar(
@ -103,7 +115,6 @@ describe("toolbar renderer decisions", () => {
toolbarModel({
statusPanelOpen: true,
openPanel: "status",
context: { label: "12%", title: "Context: 12%.", percent: 12, level: "ok" },
rateLimit: {
title: "Codex: 5h 42%, 1w 21%",
level: "ok",
@ -132,7 +143,7 @@ describe("toolbar renderer decisions", () => {
toolbarActions(),
);
expect(parent.querySelector(".codex-panel__context-compact")?.textContent).toContain("12%");
expect(parent.querySelector(".codex-panel__context-compact")).toBeNull();
expect(parent.querySelector(".codex-panel__limit-compact")).toBeNull();
expect(parent.querySelector(".codex-panel__limit-panel")?.textContent).toContain("5h");
expect(parent.querySelector(".codex-panel__limit-panel")?.textContent).toContain("42%");
@ -179,17 +190,6 @@ describe("toolbar renderer decisions", () => {
expect(refreshStatus).toHaveBeenCalled();
});
it("renders status dot states without diagnostic overlay badges", () => {
for (const statusState of ["ready", "degraded", "blocked", "running", "offline"] as const) {
const parent = document.createElement("div");
renderToolbar(parent, toolbarModel({ statusState }), toolbarActions());
const status = parent.querySelector(".codex-panel__status-dot");
expect(status?.classList.contains(`codex-panel__status-dot--${statusState}`)).toBe(true);
expect(status?.childElementCount).toBe(0);
expect(status?.getAttribute("aria-label")).toBe("Show connection status");
}
});
it("renders effective config inside the status menu without a separate toggle", () => {
const parent = document.createElement("div");
@ -365,17 +365,12 @@ function toolbarModel(overrides: Partial<ToolbarViewModel> = {}): ToolbarViewMod
return {
connected: true,
status: "Connected.",
statusState: "ready",
historyOpen: false,
statusPanelOpen: false,
runtimeOpen: true,
planActive: false,
autoReviewActive: false,
fastActive: false,
runtimeSummary: "5.5 high",
runtimeTitle: "Model: gpt-5.5; Effort: high",
runtimeEmphasized: false,
context: null,
rateLimit: null,
configSections: [],
openPanel: "runtime",

View file

@ -1,17 +1,17 @@
import { describe, expect, it } from "vitest";
import { capabilityProbeError, createAppServerDiagnostics, upsertMcpServerDiagnostic } from "../../../src/app-server/compatibility";
import { createAppServerDiagnostics } from "../../../src/app-server/compatibility";
import { createChatState } from "../../../src/features/chat/chat-state";
import {
activeComposerThreadName,
activeThreadTitle,
chatViewDisplayTitle,
composerMetaViewModel,
composerPlaceholder,
effortStatusLines,
runtimeToolbarChoices,
modelStatusLines,
runtimeSnapshotForChatState,
statusDotState,
statusSummaryLines,
toolbarViewModel,
} from "../../../src/features/chat/view-model";
@ -43,7 +43,6 @@ describe("chat view model", () => {
renameState: (threadId) => (threadId === "thread-1" ? { draft: "Active", generating: false } : null),
});
expect(model.statusState).toBe("running");
expect(model.openPanel).toBe("history");
expect(model.threads).toMatchObject([
{ threadId: "thread-1", title: "Active", selected: true, disabled: false, rename: { draft: "Active" } },
@ -76,88 +75,54 @@ describe("chat view model", () => {
expect(model.fastActive).toBe(true);
});
it("derives status dot state with running and offline priority", () => {
const diagnostics = createAppServerDiagnostics();
expect(statusDotState({ connected: true, turnBusy: true, diagnostics })).toBe("running");
expect(statusDotState({ connected: false, turnBusy: false, diagnostics })).toBe("offline");
expect(statusDotState({ connected: false, turnBusy: false, diagnostics, connectionFailed: true })).toBe("blocked");
expect(statusDotState({ connected: true, turnBusy: false, diagnostics })).toBe("ready");
expect(statusDotState({ connected: true, turnBusy: false, diagnostics, turnStartBlocked: true })).toBe("blocked");
});
it("marks connected status dot as degraded for non-fatal diagnostic issues", () => {
const failedProbe = createAppServerDiagnostics();
failedProbe.probes["model/list"] = capabilityProbeError("model/list", new Error("network down"), 1);
expect(statusDotState({ connected: true, turnBusy: false, diagnostics: failedProbe })).toBe("degraded");
expect(statusDotState({ connected: true, turnBusy: true, diagnostics: failedProbe })).toBe("running");
const authIssue = upsertMcpServerDiagnostic(createAppServerDiagnostics(), {
name: "docs",
startupStatus: "ready",
authStatus: "notLoggedIn",
toolCount: 0,
message: null,
});
expect(statusDotState({ connected: true, turnBusy: false, diagnostics: authIssue })).toBe("degraded");
});
it("wires status dot state into the toolbar model", () => {
it("builds composer meta from context and runtime state", () => {
const state = createChatState();
state.appServerDiagnostics.probes["model/list"] = capabilityProbeError("model/list", new Error("network down"), 1);
state.activeThreadId = "thread-1";
state.effectiveConfig = effectiveConfigFixture({ model: "gpt-5.5", model_reasoning_effort: "high" });
state.tokenUsage = {
last: { inputTokens: 42, cachedInputTokens: 0, outputTokens: 2, reasoningOutputTokens: 0, totalTokens: 44 },
total: { inputTokens: 40, cachedInputTokens: 0, outputTokens: 2, reasoningOutputTokens: 0, totalTokens: 42 },
modelContextWindow: 100,
};
const model = toolbarViewModel({
state,
snapshot: runtimeSnapshotForChatState({ state }),
connected: true,
turnBusy: false,
vaultPath: "/vault",
configuredCommand: "codex",
archiveConfirmThreadId: null,
archiveExportEnabled: true,
modelChoices: [],
effortChoices: [],
renameState: () => null,
expect(composerMetaViewModel(state, runtimeSnapshotForChatState({ state }))).toEqual({
fatal: null,
contextIndicator: "⣿⣿⣿⣤⣀⣀⣀⣀",
runtime: "gpt-5.5 high",
});
expect(model.statusState).toBe("degraded");
const runningModel = toolbarViewModel({
state,
snapshot: runtimeSnapshotForChatState({ state }),
connected: true,
turnBusy: true,
vaultPath: "/vault",
configuredCommand: "codex",
archiveConfirmThreadId: null,
archiveExportEnabled: true,
modelChoices: [],
effortChoices: [],
renameState: () => null,
});
expect(runningModel.statusState).toBe("running");
});
it("marks connection failure status as blocked in the toolbar model", () => {
it("uses a neutral composer context indicator when usage is unavailable", () => {
const state = createChatState();
state.activeThreadId = "thread-1";
state.displayItems = [
{
id: "item",
kind: "message",
messageKind: "assistantResponse",
messageState: "completed",
text: "Existing turn",
role: "assistant",
},
];
state.effectiveConfig = effectiveConfigFixture({ model: "gpt-5.5" });
expect(composerMetaViewModel(state, runtimeSnapshotForChatState({ state }))).toMatchObject({
fatal: null,
contextIndicator: "⣀⣀⣀⣀⣀⣀⣀⣀",
runtime: "gpt-5.5",
});
});
it("replaces composer meta with fatal connection state", () => {
const state = createChatState();
state.status = "Connection failed.";
const model = toolbarViewModel({
state,
snapshot: runtimeSnapshotForChatState({ state }),
connected: false,
turnBusy: false,
vaultPath: "/vault",
configuredCommand: "codex",
archiveConfirmThreadId: null,
archiveExportEnabled: true,
modelChoices: [],
effortChoices: [],
renameState: () => null,
expect(composerMetaViewModel(state, runtimeSnapshotForChatState({ state }))).toEqual({
fatal: "Codex app-server disconnected",
contextIndicator: "",
runtime: "",
});
expect(model.statusState).toBe("blocked");
});
it("builds slash-command status lines from chat state", () => {

View file

@ -5,7 +5,6 @@ import type { Model } from "../../src/generated/app-server/v2/Model";
import type { Thread } from "../../src/generated/app-server/v2/Thread";
import { createAppServerDiagnostics } from "../../src/app-server/compatibility";
import {
compactContextLabel,
compactModelLabel,
compactReasoningEffortLabel,
modelOverrideMessage,
@ -102,13 +101,6 @@ describe("runtime settings", () => {
expect(compactReasoningEffortLabel(null)).toBe("default");
});
it("formats compact context labels", () => {
expect(compactContextLabel(42, "Context 42%")).toBe("42%");
expect(compactContextLabel(null, "Context waiting")).toBe("wait");
expect(compactContextLabel(null, "Context unknown")).toBe("?");
expect(compactContextLabel(null, "1.2K tokens")).toBe("1.2K tokens");
});
it("keeps runtime defaults, resets, and collaboration mode semantics distinct", () => {
const snapshot = runtimeSnapshot({
requestedModel: resetRuntimeSettingToConfig(),

View file

@ -22,6 +22,7 @@ describe("panel CSS token scope", () => {
describe("chat toolbar CSS", () => {
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"] ?? "";
expect(toolbarAction).toContain("--icon-size: var(--codex-panel-control-icon-size)");
expect(toolbarAction).toContain("--icon-stroke: var(--icon-m-stroke-width, 1.75px)");
@ -29,6 +30,9 @@ describe("chat toolbar CSS", () => {
expect(toolbarAction).not.toContain("height:");
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 controls", () => {
@ -47,7 +51,7 @@ describe("chat toolbar CSS", () => {
expect(toolbarActionMouseFocus).toContain("color: var(--icon-color)");
});
it("uses one active state for toolbar actions and text controls", () => {
it("uses the shared active state for toolbar actions", () => {
const toolbarControlActive =
/\.codex-panel-ui__toolbar-control\.is-active,\n\.codex-panel-ui__toolbar-control\.is-active:hover,\n\.codex-panel-ui__toolbar-control\.is-active:focus-visible,\n\.codex-panel-ui__toolbar-control\.is-active:active \{(?<body>[^}]+)\}/.exec(
styles,
@ -56,20 +60,13 @@ describe("chat toolbar CSS", () => {
/\.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"] ?? "";
const runtimeModelActive =
/\.codex-panel__runtime-model\.is-active,\n\.codex-panel__runtime-model\.is-active:hover,\n\.codex-panel__runtime-model\.is-active:focus-visible,\n\.codex-panel__runtime-model\.is-active:active \{(?<body>[^}]+)\}/.exec(
styles,
)?.groups?.["body"] ?? "";
const runtimeModelActiveValue =
/\.codex-panel__runtime-model\.is-active \.codex-panel__runtime-model-value \{(?<body>[^}]+)\}/.exec(styles)?.groups?.["body"] ?? "";
expect(toolbarControlActive).toContain("background: var(--background-modifier-active-hover)");
expect(toolbarControlActive).toContain("color: var(--icon-color-active)");
expect(toolbarActionActive).toContain("background: var(--background-modifier-active-hover)");
expect(toolbarActionActive).toContain("color: var(--icon-color-active)");
expect(runtimeModelActive).toContain("background: var(--background-modifier-active-hover)");
expect(runtimeModelActive).toContain("color: var(--icon-color-active)");
expect(runtimeModelActiveValue).toContain("color: currentcolor");
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", () => {