Simplify chat status dot states

This commit is contained in:
murashit 2026-05-29 22:23:41 +09:00
parent a6c2236374
commit 52a7ed02a5
8 changed files with 123 additions and 75 deletions

View file

@ -14,8 +14,6 @@ export interface DiagnosticSection {
rows: DiagnosticRow[];
}
export type DiagnosticAlertLevel = "normal" | "warning" | "error";
export interface ConnectionDiagnosticsInput {
connected: boolean;
configuredCommand: string;
@ -50,16 +48,15 @@ export function connectionDiagnosticSections(input: ConnectionDiagnosticsInput):
];
}
export function diagnosticAlertLevel(diagnostics: AppServerDiagnostics): DiagnosticAlertLevel {
let hasWarning = false;
export function hasDiagnosticIssue(diagnostics: AppServerDiagnostics): boolean {
for (const probe of Object.values(diagnostics.probes)) {
if (probe.status === "failed") return "error";
if (probe.status === "failed") return true;
}
for (const server of diagnostics.mcpServers) {
if (server.startupStatus === "failed") return "error";
if (server.authStatus === "notLoggedIn") hasWarning = true;
if (server.startupStatus === "failed") return true;
if (server.authStatus === "notLoggedIn") return true;
}
return hasWarning ? "warning" : "normal";
return false;
}
function capabilityDiagnosticRow(probe: CapabilityProbeResult): DiagnosticRow {

View file

@ -1,8 +1,7 @@
import type { EffectiveConfigSection, RateLimitSummary } from "../../runtime/view";
export type ToolbarPanelKind = "history" | "status" | "runtime";
export type ToolbarStatusState = "offline" | "connected" | "running";
export type ToolbarDiagnosticAlertLevel = "normal" | "warning" | "error";
export type ToolbarStatusState = "offline" | "ready" | "degraded" | "blocked" | "running";
export interface ToolbarChoice {
label: string;
@ -58,5 +57,4 @@ export interface ToolbarViewModel {
effortChoices: ToolbarChoice[];
connectLabel: string;
diagnostics: ToolbarDiagnosticSection[];
diagnosticAlertLevel: ToolbarDiagnosticAlertLevel;
}

View file

@ -132,13 +132,11 @@ function ContextMeter({ context }: { context: ToolbarViewModel["context"] }): Re
}
function StatusButton({ model, actions }: { model: ToolbarViewModel; actions: ToolbarActions }): ReactNode {
const alertClass = model.diagnosticAlertLevel === "normal" ? "" : `codex-panel__status-dot--diagnostic-${model.diagnosticAlertLevel}`;
return (
<button
className={[
"clickable-icon codex-panel-ui__toolbar-control codex-panel__status-dot",
`codex-panel__status-dot--${model.statusState}`,
alertClass,
model.statusPanelOpen ? "is-active" : "",
]
.filter(Boolean)
@ -147,14 +145,7 @@ function StatusButton({ model, actions }: { model: ToolbarViewModel; actions: To
aria-label={model.statusPanelOpen ? "Hide connection status" : "Show connection status"}
aria-expanded={model.statusPanelOpen ? "true" : "false"}
onClick={actions.toggleStatusPanel}
>
{model.diagnosticAlertLevel !== "normal" ? (
<span
className={`codex-panel__status-dot-diagnostic codex-panel__status-dot-diagnostic--${model.diagnosticAlertLevel}`}
aria-hidden="true"
/>
) : null}
</button>
/>
);
}

View file

@ -16,10 +16,11 @@ import { sortedAvailableModels } from "../../runtime/model";
import { compactContextLabel } from "../../runtime/settings";
import { contextSummary, effectiveConfigSections, rateLimitSummary } from "../../runtime/view";
import { codexPanelDisplayTitle, explicitThreadName, getThreadTitle } from "../../domain/threads/model";
import { connectionDiagnosticSections, diagnosticAlertLevel } from "./diagnostics";
import type { AppServerDiagnostics } from "../../app-server/compatibility";
import { connectionDiagnosticSections, hasDiagnosticIssue } from "./diagnostics";
import type { ChatState } from "./chat-state";
import { statusValue, usageLimitStatusLines } from "./status-lines";
import type { ToolbarChoice, ToolbarThreadRow, ToolbarViewModel } from "./toolbar-model";
import type { ToolbarChoice, ToolbarStatusState, ToolbarThreadRow, ToolbarViewModel } from "./toolbar-model";
export interface RuntimeSnapshotInput {
state: ChatState;
@ -45,6 +46,13 @@ export interface ConnectionDiagnosticsModelInput {
configuredCommand: string;
}
export interface StatusDotStateInput {
connected: boolean;
turnBusy: boolean;
diagnostics: AppServerDiagnostics;
turnStartBlocked?: boolean;
}
export interface RuntimeToolbarChoicesInput {
state: ChatState;
snapshot: RuntimeSnapshot;
@ -144,7 +152,11 @@ export function toolbarViewModel(input: ToolbarViewModelInput): ToolbarViewModel
const historyOpen = state.openDetails.has("history");
const statusPanelOpen = state.openDetails.has("status-panel");
const runtimeOpen = state.runtimePicker !== null;
const statusState = input.turnBusy ? "running" : input.connected ? "connected" : "offline";
const statusState = statusDotState({
connected: input.connected,
turnBusy: input.turnBusy,
diagnostics: state.appServerDiagnostics,
});
const model = currentModel(snapshot, config);
const effort = currentReasoningEffort(snapshot, config);
return {
@ -180,10 +192,16 @@ export function toolbarViewModel(input: ToolbarViewModelInput): ToolbarViewModel
connected: input.connected,
configuredCommand: input.configuredCommand,
}),
diagnosticAlertLevel: diagnosticAlertLevel(state.appServerDiagnostics),
};
}
export function statusDotState(input: StatusDotStateInput): ToolbarStatusState {
if (input.turnBusy) return "running";
if (!input.connected) return "offline";
if (input.turnStartBlocked) return "blocked";
return hasDiagnosticIssue(input.diagnostics) ? "degraded" : "ready";
}
export function connectionDiagnosticsModel(input: ConnectionDiagnosticsModelInput): ReturnType<typeof connectionDiagnosticSections> {
return connectionDiagnosticSections({
connected: input.connected,

View file

@ -59,6 +59,8 @@
--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);
@ -776,7 +778,6 @@
cursor: pointer;
flex: 0 0 auto;
margin-left: auto;
position: relative;
}
.codex-panel__status-dot::before {
@ -788,34 +789,26 @@
box-shadow: 0 0 0 3px var(--codex-panel-status-ring-faint);
}
.codex-panel__status-dot--connected::before {
.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__status-dot-diagnostic {
position: absolute;
top: var(--codex-panel-control-gap);
right: var(--codex-panel-control-gap);
width: var(--codex-panel-item-gap);
height: var(--codex-panel-item-gap);
border: var(--border-width, 1px) solid var(--codex-panel-surface);
border-radius: var(--codex-panel-radius-pill);
}
.codex-panel__status-dot-diagnostic--warning {
background: var(--codex-panel-color-warning);
}
.codex-panel__status-dot-diagnostic--error {
background: var(--codex-panel-color-danger);
}
.codex-panel__meter-compact {
display: flex;
align-items: center;

View file

@ -6,7 +6,7 @@ import {
createAppServerDiagnostics,
upsertMcpServerDiagnostic,
} from "../../../src/app-server/compatibility";
import { connectionDiagnosticSections, diagnosticAlertLevel } from "../../../src/features/chat/diagnostics";
import { connectionDiagnosticSections, hasDiagnosticIssue } from "../../../src/features/chat/diagnostics";
describe("connection diagnostics", () => {
it("formats base rows, capability probes, and MCP issues for /doctor", () => {
@ -60,15 +60,15 @@ describe("connection diagnostics", () => {
);
});
it("derives a top-level diagnostic alert without treating unknown probes as warnings", () => {
expect(diagnosticAlertLevel(createAppServerDiagnostics())).toBe("normal");
it("detects diagnostic issues without treating unknown probes as issues", () => {
expect(hasDiagnosticIssue(createAppServerDiagnostics())).toBe(false);
const failed = createAppServerDiagnostics();
failed.probes["model/list"] = capabilityProbeError("model/list", new Error("network down"), 1);
expect(diagnosticAlertLevel(failed)).toBe("error");
expect(hasDiagnosticIssue(failed)).toBe(true);
});
it("derives MCP diagnostic alerts with error priority", () => {
it("detects MCP diagnostic issues", () => {
let authIssue = upsertMcpServerDiagnostic(createAppServerDiagnostics(), {
name: "docs",
startupStatus: "ready",
@ -76,7 +76,7 @@ describe("connection diagnostics", () => {
toolCount: 0,
message: null,
});
expect(diagnosticAlertLevel(authIssue)).toBe("warning");
expect(hasDiagnosticIssue(authIssue)).toBe(true);
authIssue = upsertMcpServerDiagnostic(authIssue, {
name: "github",
@ -85,6 +85,6 @@ describe("connection diagnostics", () => {
toolCount: null,
message: "missing token",
});
expect(diagnosticAlertLevel(authIssue)).toBe("error");
expect(hasDiagnosticIssue(authIssue)).toBe(true);
});
});

View file

@ -437,26 +437,15 @@ describe("toolbar renderer decisions", () => {
expect(refreshStatus).toHaveBeenCalled();
});
it("renders diagnostic alert badges on the status dot", () => {
const normal = document.createElement("div");
renderToolbar(normal, toolbarModel({ diagnosticAlertLevel: "normal" }), toolbarActions());
const normalStatus = normal.querySelector(".codex-panel__status-dot");
expect(normalStatus?.querySelector(".codex-panel__status-dot-diagnostic")).toBeNull();
expect(normalStatus?.getAttribute("aria-label")).toBe("Show connection status");
const warning = document.createElement("div");
renderToolbar(warning, toolbarModel({ diagnosticAlertLevel: "warning" }), toolbarActions());
const warningStatus = warning.querySelector(".codex-panel__status-dot");
expect(warningStatus?.classList.contains("codex-panel__status-dot--diagnostic-warning")).toBe(true);
expect(warningStatus?.querySelector(".codex-panel__status-dot-diagnostic--warning")).not.toBeNull();
expect(warningStatus?.getAttribute("aria-label")).toBe("Show connection status");
const error = document.createElement("div");
renderToolbar(error, toolbarModel({ diagnosticAlertLevel: "error" }), toolbarActions());
const errorStatus = error.querySelector(".codex-panel__status-dot");
expect(errorStatus?.classList.contains("codex-panel__status-dot--diagnostic-error")).toBe(true);
expect(errorStatus?.querySelector(".codex-panel__status-dot-diagnostic--error")).not.toBeNull();
expect(errorStatus?.getAttribute("aria-label")).toBe("Show connection status");
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", () => {
@ -965,7 +954,7 @@ function toolbarModel(overrides: Partial<ToolbarViewModel> = {}): ToolbarViewMod
return {
connected: true,
status: "Connected.",
statusState: "connected",
statusState: "ready",
historyOpen: false,
statusPanelOpen: false,
runtimeOpen: true,
@ -984,7 +973,6 @@ function toolbarModel(overrides: Partial<ToolbarViewModel> = {}): ToolbarViewMod
effortChoices: [{ label: "Default", selected: true, onClick: vi.fn() }],
connectLabel: "Reconnect",
diagnostics: [{ title: "Process", rows: [{ label: "Codex App Server", value: "codex-cli/test" }] }],
diagnosticAlertLevel: "normal",
...overrides,
};
}

View file

@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { createAppServerDiagnostics } from "../../../src/app-server/compatibility";
import { capabilityProbeError, createAppServerDiagnostics, upsertMcpServerDiagnostic } from "../../../src/app-server/compatibility";
import { createChatState } from "../../../src/features/chat/chat-state";
import {
activeComposerThreadName,
@ -11,6 +11,7 @@ import {
runtimeToolbarChoices,
modelStatusLines,
runtimeSnapshotForChatState,
statusDotState,
statusSummaryLines,
toolbarViewModel,
} from "../../../src/features/chat/view-model";
@ -75,6 +76,68 @@ 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: 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", () => {
const state = createChatState();
state.appServerDiagnostics.probes["model/list"] = capabilityProbeError("model/list", new Error("network down"), 1);
const model = toolbarViewModel({
state,
snapshot: runtimeSnapshotForChatState({ state }),
connected: true,
turnBusy: false,
vaultPath: "/vault",
configuredCommand: "codex",
archiveConfirmThreadId: null,
archiveExportEnabled: true,
modelChoices: [],
effortChoices: [],
renameState: () => null,
});
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("builds slash-command status lines from chat state", () => {
const state = createChatState();
state.activeThreadId = "thread-1";