mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Standardize chat toolbar and add composer runtime meta
This commit is contained in:
parent
fceea7f18b
commit
e8d4a62877
20 changed files with 249 additions and 414 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, "");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue