Files
Claude-Code-Monitor/client/src/components/StatusBadge.tsx
T
nntrivi2001 b673363351 feat(theme): dark/light mode with a Radix Colors-based palette
Adds a working Dark/Light toggle (next to the language switcher, same row
as EN/VI) and re-themes the whole dashboard, not just the handful of
components that already used semantic tokens.

- Tailwind darkMode:"class" + CSS-variable color tokens (client/src/index.css,
  tailwind.config.js): surface.0-5, border/border-light, accent/accent-hover,
  fg.primary/secondary/muted, status.success/danger/warning. One class flip
  on <html> re-themes everything — no per-element dark: variant pairs.
- useTheme() hook: localStorage-persisted, defaults to dark, no
  prefers-color-scheme fallback (client/src/hooks/useTheme.ts).
- Mechanical, table-driven migration (scripts/migrate-color-tokens.mjs,
  scripts/tokenize-status-colors.mjs, scripts/darken-status-colors.mjs) of
  every raw neutral/gray/slate + emerald/red/amber Tailwind utility across
  client/src onto the new tokens, so every badge/button/component pulls the
  same shade per status/role instead of each picking its own.
- Palette values are the literal Radix Colors (radix-ui.com/colors) scale
  constants — slate/blue/green/red/amber steps 1-12 — adopted after three
  rounds of hand-picked values that kept overshooting (flat, then too dark,
  then glaring); see docs/superpowers/specs/2026-07-31-color-redesign-
  dark-light-mode-design.md for the full history and role mapping.
- PipelineMap: done/current/failed/passed-no-evidence/detected share one
  visual language (border + text + translucent wash of the same status
  color); `current` alone stays a solid accent fill, the one state that
  gets to look bolder ("you are here").
- LaneCard: removed the stage/kind/auto-stage chips that duplicated the
  Workspace lane-detail header already showing them.

Categorical/decorative hues (violet, indigo, cyan, teal, sky, rose, pink,
orange, yellow, and blue where it plays a role-coloring part e.g. message
bubbles) are deliberately out of scope — collapsing those onto shared
tokens would erase the distinction between different kinds of thing, not
a status.
2026-07-31 10:54:31 +07:00

178 lines
8.8 KiB
TypeScript

/**
* @file StatusBadge.tsx
* @description Defines reusable React components for displaying the status of agents and sessions in a visually distinct way using badges. The AgentStatusBadge component shows the current status of an agent with an optional pulsing effect for active states, while the SessionStatusBadge component indicates the status of a session. When a row is in the yellow "Waiting" overlay state, both badges can additionally render WHY it waits (the server's awaiting_reason: needs input / turn done / at prompt / interrupted) as a nested icon+label chip with a hover tooltip carrying the full explanation — or, in `compact` mode for tight card layouts, as the hover tooltip alone. Both components utilize predefined configurations for consistent styling across the application.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
* =============================================================================
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
*
* ## Design constraints
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
*
* ## Remote data & SSH
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Observability
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
* Docker Compose profiles are documented in `monitoring/README.md`.
*
* ## Internal dependencies
* - `../lib/types`
* - `./Tip`
*
* ## Public surface
* - `REASON_ICONS` — exported API; see TSDoc on the symbol for behavior.
* - `AgentStatusBadge` — exported API; see TSDoc on the symbol for behavior.
* - `SessionStatusBadge` — exported API; see TSDoc on the symbol for behavior.
*
* ## Testing pointers
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
* - Server contract changes require `npm run test:server` and OpenAPI sync.
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
*
* ## Related docs
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
* - `docs/API.md` — REST reference.
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
* ============================================================================= */
/* -----------------------------------------------------------------------------
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
* -----------------------------------------------------------------------------
* **REASON_ICONS**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **AgentStatusBadge**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **SessionStatusBadge**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* ----------------------------------------------------------------------------- */
import { useTranslation } from "react-i18next";
import { BellRing, MessageSquareReply, Terminal, OctagonPause } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { STATUS_CONFIG, SESSION_STATUS_CONFIG, AWAITING_REASON_CONFIG } from "../lib/types";
import type { EffectiveAgentStatus, EffectiveSessionStatus, AwaitingReason } from "../lib/types";
import { Tip } from "./Tip";
/** Per-reason icon, kept here (not in types.ts) so the presentation lookup in
* lib/ stays JSX-free. Matches the semantics documented on {@link AwaitingReason}.
* Exported so richer surfaces (e.g. SessionDetail's waiting banner) reuse the
* same icon per reason as the badges. */
export const REASON_ICONS: Record<AwaitingReason, LucideIcon> = {
notification: BellRing, // blocked on a permission/input prompt - ring the bell
stop: MessageSquareReply, // Claude replied; your reply is the next move
session_start: Terminal, // fresh CLI sitting at an empty prompt
interrupted: OctagonPause, // turn cut short (Esc / recovered hook)
};
/**
* The "why" chip nested inside a Waiting badge: a small rounded pill with the
* reason's icon and short label. Urgent reasons (permission prompts,
* interruptions) get a hotter amber fill than the calm idle-between-turns
* ones so a scan of a list surfaces the rows that actually block on the human.
*/
function ReasonChip({ reason }: { reason: AwaitingReason }) {
const { t } = useTranslation();
const cfg = AWAITING_REASON_CONFIG[reason];
const Icon = REASON_ICONS[reason];
return (
<span
className={`inline-flex items-center gap-1 rounded-full border px-1.5 text-[10px] font-medium leading-4 ${
cfg.urgent
? "bg-status-warning/15 border-status-warning/25 text-status-warning"
: "bg-yellow-500/10 border-yellow-500/20 text-yellow-400/90"
}`}
>
<Icon className="w-2.5 h-2.5 flex-shrink-0" aria-hidden="true" />
{t(cfg.labelKey)}
</span>
);
}
interface AgentStatusBadgeProps {
status: EffectiveAgentStatus;
pulse?: boolean;
/** WHY the agent is waiting (from `agentAwaitingReason`); rendered as a
* nested icon+label chip with a tooltip. Ignored unless `status` is "waiting". */
reason?: AwaitingReason | null;
/** Tooltip-only mode for tight layouts (Kanban/Dashboard cards): keeps the
* hover explanation but suppresses the inline reason chip so the badge
* never squeezes the card title. */
compact?: boolean;
}
export function AgentStatusBadge({ status, pulse, reason, compact }: AgentStatusBadgeProps) {
const { t } = useTranslation();
const config = STATUS_CONFIG[status];
// "waiting" pulses by default so the user's eye is drawn to sessions that
// need their attention, matching the pulsing for active/working states.
const shouldPulse = pulse ?? (status === "working" || status === "waiting");
// Only decorate the Waiting overlay - a reason on any other status is stale.
const shownReason = status === "waiting" && reason ? reason : null;
return (
// Tip renders children unwrapped when raw is undefined (non-waiting rows).
<Tip raw={shownReason ? t(AWAITING_REASON_CONFIG[shownReason].descKey) : undefined}>
<span className={`badge ${config.bg} ${config.color}`}>
<span
className={`w-1.5 h-1.5 rounded-full ${config.dot} ${
shouldPulse ? "animate-pulse-dot" : ""
}`}
/>
{t(config.labelKey)}
{shownReason && !compact && <ReasonChip reason={shownReason} />}
</span>
</Tip>
);
}
interface SessionStatusBadgeProps {
status: EffectiveSessionStatus;
pulse?: boolean;
/** WHY the session is waiting (from `sessionAwaitingReason`); rendered as a
* nested icon+label chip with a tooltip. Ignored unless `status` is "waiting". */
reason?: AwaitingReason | null;
/** Tooltip-only mode for tight layouts (Kanban/Dashboard cards): keeps the
* hover explanation but suppresses the inline reason chip so the badge
* never squeezes the card title. */
compact?: boolean;
}
export function SessionStatusBadge({ status, pulse, reason, compact }: SessionStatusBadgeProps) {
const { t } = useTranslation();
const config = SESSION_STATUS_CONFIG[status];
const shouldPulse = pulse ?? status === "waiting";
const shownReason = status === "waiting" && reason ? reason : null;
return (
<Tip raw={shownReason ? t(AWAITING_REASON_CONFIG[shownReason].descKey) : undefined}>
<span className={`badge ${config.bg} ${config.color}`}>
{shouldPulse && (
<span
className={`w-1.5 h-1.5 rounded-full ${config.dot} animate-pulse-dot`}
aria-hidden="true"
/>
)}
{t(config.labelKey)}
{shownReason && !compact && <ReasonChip reason={shownReason} />}
</span>
</Tip>
);
}