mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 17:30:31 +00:00
Simplify chat status dot states
This commit is contained in:
parent
a6c2236374
commit
52a7ed02a5
8 changed files with 123 additions and 75 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
33
styles.css
33
styles.css
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
Loading…
Reference in a new issue