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.
This commit is contained in:
2026-07-31 10:54:31 +07:00
parent 4905d63b97
commit b673363351
82 changed files with 3776 additions and 2578 deletions
+1 -1
View File
@@ -2896,7 +2896,7 @@ Claude Code invokes this command on each update, piping a JSON payload to stdin.
| **ws** | Fastest, most lightweight WebSocket library for Node. No Socket.IO overhead needed since we only push JSON messages | | **ws** | Fastest, most lightweight WebSocket library for Node. No Socket.IO overhead needed since we only push JSON messages |
| **React 18** | Stable, widely known, strong TypeScript support. No need for Server Components or RSC given this is a client-rendered SPA | | **React 18** | Stable, widely known, strong TypeScript support. No need for Server Components or RSC given this is a client-rendered SPA |
| **Vite** | Fast builds, native ESM, excellent dev experience. Proxy config handles the dev server split cleanly | | **Vite** | Fast builds, native ESM, excellent dev experience. Proxy config handles the dev server split cleanly |
| **Tailwind CSS** | Utility-first approach keeps styles colocated with markup. No CSS module boilerplate. Custom theme config for the dark UI | | **Tailwind CSS** | Utility-first approach keeps styles colocated with markup. No CSS module boilerplate. Colors are CSS-variable-backed tokens (`darkMode: "class"`) so the Dark/Light toggle re-themes the app with one class flip rather than per-component `dark:` variants |
| **React Router 6** | Standard routing for React SPAs. Layout routes with `<Outlet>` give clean shell composition | | **React Router 6** | Standard routing for React SPAs. Layout routes with `<Outlet>` give clean shell composition |
| **Lucide React** | Tree-shakeable icon library. Only imports what's used (~20 icons) | | **Lucide React** | Tree-shakeable icon library. Only imports what's used (~20 icons) |
| **TypeScript Strict** | Catches null/undefined bugs at compile time. `noUncheckedIndexedAccess` prevents array bounds issues | | **TypeScript Strict** | Catches null/undefined bugs at compile time. `noUncheckedIndexedAccess` prevents array bounds issues |
+5 -4
View File
@@ -54,7 +54,7 @@ The client is a single-page application (SPA) built with modern web technologies
- **React 18.3** - Component-based UI with hooks and concurrent features - **React 18.3** - Component-based UI with hooks and concurrent features
- **TypeScript 5.7** - Full type safety across components, utilities, and API contracts - **TypeScript 5.7** - Full type safety across components, utilities, and API contracts
- **Vite 6.1** - Lightning-fast HMR during development, optimized production builds - **Vite 6.1** - Lightning-fast HMR during development, optimized production builds
- **Tailwind CSS 3.4** - Utility-first CSS framework for rapid UI development - **Tailwind CSS 3.4** - Utility-first CSS framework; colors are CSS-variable-backed tokens (`darkMode: "class"`) so a Dark/Light toggle (next to the language switcher in the sidebar, `useTheme` hook) re-themes the whole app by flipping one class
- **React Router 6.28** - Client-side routing with nested layouts - **React Router 6.28** - Client-side routing with nested layouts
- **WebSocket** - Real-time event streaming from server - **WebSocket** - Real-time event streaming from server
- **Lucide Icons** - Modern, consistent icon set - **Lucide Icons** - Modern, consistent icon set
@@ -203,17 +203,18 @@ client/
│ │ │ │
│ ├── hooks/ │ ├── hooks/
│ │ ├── useWebSocket.ts # Auto-reconnecting WebSocket hook │ │ ├── useWebSocket.ts # Auto-reconnecting WebSocket hook
│ │ ── useNotifications.ts # Browser push notification triggers │ │ ── useNotifications.ts # Browser push notification triggers
│ │ └── useTheme.ts # Dark/light mode: toggles the `dark` class, persists to localStorage
│ │ │ │
│ ├── i18n/ # Internationalization (en / zh / vi / ko) │ ├── i18n/ # Internationalization (en / zh / vi / ko)
│ ├── App.tsx # Root component + router setup │ ├── App.tsx # Root component + router setup
│ ├── main.tsx # Entry point │ ├── main.tsx # Entry point
│ └── index.css # Tailwind + custom utilities │ └── index.css # Tailwind + CSS-variable color tokens (dark/light)
├── public/ # Static assets (sw.js service worker) ├── public/ # Static assets (sw.js service worker)
├── index.html # HTML template ├── index.html # HTML template
├── vite.config.ts # Vite + proxy config ├── vite.config.ts # Vite + proxy config
├── tailwind.config.js # Custom dark theme ├── tailwind.config.js # Dark/light color tokens (`darkMode: "class"`, CSS-variable-backed)
├── tsconfig.json # Strict TypeScript config ├── tsconfig.json # Strict TypeScript config
└── package.json └── package.json
``` ```
+5 -5
View File
@@ -194,14 +194,14 @@ export function AgentCard({ agent, session, label, onClick }: AgentCardProps) {
{isMain ? <Bot className="w-3.5 h-3.5" /> : <GitBranch className="w-3.5 h-3.5" />} {isMain ? <Bot className="w-3.5 h-3.5" /> : <GitBranch className="w-3.5 h-3.5" />}
</div> </div>
<div className="min-w-0 overflow-hidden"> <div className="min-w-0 overflow-hidden">
<p className="text-sm font-medium text-gray-200 truncate"> <p className="text-sm font-medium text-fg-secondary truncate">
{/* Auto-generated main-agent titles (e.g. "Main Agent - Session {/* Auto-generated main-agent titles (e.g. "Main Agent - Session
229d93fd" or "Main Agent - work - e3f8e613") swap the 229d93fd" or "Main Agent - work - e3f8e613") swap the
placeholder for the real session name when one exists; custom placeholder for the real session name when one exists; custom
(sub)agent names are left untouched. */} (sub)agent names are left untouched. */}
{isMain ? mainAgentDisplayName(agent.name, realSessionName) : agent.name} {isMain ? mainAgentDisplayName(agent.name, realSessionName) : agent.name}
</p> </p>
{subtitle && <p className="text-[11px] text-gray-500 truncate">{subtitle}</p>} {subtitle && <p className="text-[11px] text-fg-muted truncate">{subtitle}</p>}
</div> </div>
</div> </div>
{/* compact: cards are narrow inline reason chip would squeeze the {/* compact: cards are narrow inline reason chip would squeeze the
@@ -210,10 +210,10 @@ export function AgentCard({ agent, session, label, onClick }: AgentCardProps) {
</div> </div>
{agent.task && ( {agent.task && (
<p className="text-xs text-gray-400 mb-3 line-clamp-2 leading-relaxed">{agent.task}</p> <p className="text-xs text-fg-secondary mb-3 line-clamp-2 leading-relaxed">{agent.task}</p>
)} )}
<div className="flex items-center gap-3 text-[11px] text-gray-500 min-w-0 overflow-hidden flex-wrap"> <div className="flex items-center gap-3 text-[11px] text-fg-muted min-w-0 overflow-hidden flex-wrap">
{agent.current_tool && ( {agent.current_tool && (
<span className="flex items-center gap-1 flex-shrink-0"> <span className="flex items-center gap-1 flex-shrink-0">
<Wrench className="w-3 h-3" /> <Wrench className="w-3 h-3" />
@@ -243,7 +243,7 @@ export function AgentCard({ agent, session, label, onClick }: AgentCardProps) {
{t("ran")} {t("ran")}
{formatDuration(agent.started_at, agent.ended_at)} {formatDuration(agent.started_at, agent.ended_at)}
</span> </span>
<span className="text-gray-600 flex-shrink-0">{timeAgo(agent.ended_at)}</span> <span className="text-fg-muted flex-shrink-0">{timeAgo(agent.ended_at)}</span>
</> </>
) : ( ) : (
<span className="flex items-center gap-1 flex-shrink-0"> <span className="flex items-center gap-1 flex-shrink-0">
+33 -33
View File
@@ -403,8 +403,8 @@ export function AlertsNotifications() {
onClick={() => setTab(tb.key)} onClick={() => setTab(tb.key)}
className={`inline-flex items-center gap-2 text-xs font-medium px-3.5 py-2 rounded-lg transition-colors ${ className={`inline-flex items-center gap-2 text-xs font-medium px-3.5 py-2 rounded-lg transition-colors ${
active active
? "bg-surface-4 text-gray-100 shadow-sm" ? "bg-surface-4 text-fg-primary shadow-sm"
: "text-gray-500 hover:text-gray-300 hover:bg-surface-3" : "text-fg-muted hover:text-fg-secondary hover:bg-surface-3"
}`} }`}
> >
<Icon className="w-3.5 h-3.5" /> <Icon className="w-3.5 h-3.5" />
@@ -413,10 +413,10 @@ export function AlertsNotifications() {
<span <span
className={`text-[10px] font-semibold rounded-full px-1.5 min-w-[18px] text-center ${ className={`text-[10px] font-semibold rounded-full px-1.5 min-w-[18px] text-center ${
tb.key === "activity" tb.key === "activity"
? "text-amber-300 bg-amber-500/15" ? "text-status-warning bg-status-warning/15"
: active : active
? "text-accent bg-accent/15" ? "text-accent bg-accent/15"
: "text-gray-400 bg-surface-2" : "text-fg-secondary bg-surface-2"
}`} }`}
> >
{tb.badge} {tb.badge}
@@ -432,8 +432,8 @@ export function AlertsNotifications() {
<div className="card p-4"> <div className="card p-4">
<div className="flex items-center justify-between gap-3 mb-3"> <div className="flex items-center justify-between gap-3 mb-3">
<div> <div>
<h4 className="text-sm font-semibold text-gray-200">{t("rules.title")}</h4> <h4 className="text-sm font-semibold text-fg-secondary">{t("rules.title")}</h4>
<p className="text-xs text-gray-500 mt-0.5">{ts("alertsHub.rulesHint")}</p> <p className="text-xs text-fg-muted mt-0.5">{ts("alertsHub.rulesHint")}</p>
</div> </div>
<button <button
onClick={() => { onClick={() => {
@@ -450,7 +450,7 @@ export function AlertsNotifications() {
{formOpen && ( {formOpen && (
<div className="rounded-lg border border-border bg-surface-2 p-3 mb-3 space-y-3"> <div className="rounded-lg border border-border bg-surface-2 p-3 mb-3 space-y-3">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<label className="block text-xs text-gray-400"> <label className="block text-xs text-fg-secondary">
<span className="inline-flex items-center gap-1"> <span className="inline-flex items-center gap-1">
{t("rules.form.name")} {t("rules.form.name")}
<FieldHelp description={t("rules.help.name")} /> <FieldHelp description={t("rules.help.name")} />
@@ -463,7 +463,7 @@ export function AlertsNotifications() {
className="input mt-1 w-full" className="input mt-1 w-full"
/> />
</label> </label>
<label className="block text-xs text-gray-400"> <label className="block text-xs text-fg-secondary">
<span className="inline-flex items-center gap-1"> <span className="inline-flex items-center gap-1">
{t("rules.form.type")} {t("rules.form.type")}
<FieldHelp title={t("rules.form.type")} description={t("rules.help.type")} /> <FieldHelp title={t("rules.form.type")} description={t("rules.help.type")} />
@@ -480,16 +480,16 @@ export function AlertsNotifications() {
</option> </option>
))} ))}
</select> </select>
<ChevronDown className="w-3.5 h-3.5 absolute right-2.5 top-1/2 -translate-y-1/2 text-gray-500 pointer-events-none" /> <ChevronDown className="w-3.5 h-3.5 absolute right-2.5 top-1/2 -translate-y-1/2 text-fg-muted pointer-events-none" />
</div> </div>
</label> </label>
</div> </div>
<p className="text-[11px] text-gray-500">{t(`ruleTypeHints.${form.rule_type}`)}</p> <p className="text-[11px] text-fg-muted">{t(`ruleTypeHints.${form.rule_type}`)}</p>
{form.rule_type === "event_pattern" && ( {form.rule_type === "event_pattern" && (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<label className="block text-xs text-gray-400"> <label className="block text-xs text-fg-secondary">
<span className="inline-flex items-center gap-1"> <span className="inline-flex items-center gap-1">
{t("rules.form.eventType")} {t("rules.form.eventType")}
<FieldHelp <FieldHelp
@@ -506,7 +506,7 @@ export function AlertsNotifications() {
className="input mt-1 w-full" className="input mt-1 w-full"
/> />
</label> </label>
<label className="block text-xs text-gray-400"> <label className="block text-xs text-fg-secondary">
<span className="inline-flex items-center gap-1"> <span className="inline-flex items-center gap-1">
{t("rules.form.toolName")} {t("rules.form.toolName")}
<FieldHelp <FieldHelp
@@ -523,7 +523,7 @@ export function AlertsNotifications() {
className="input mt-1 w-full" className="input mt-1 w-full"
/> />
</label> </label>
<label className="block text-xs text-gray-400"> <label className="block text-xs text-fg-secondary">
<span className="inline-flex items-center gap-1"> <span className="inline-flex items-center gap-1">
{t("rules.form.summaryContains")} {t("rules.form.summaryContains")}
<FieldHelp <FieldHelp
@@ -540,7 +540,7 @@ export function AlertsNotifications() {
className="input mt-1 w-full" className="input mt-1 w-full"
/> />
</label> </label>
<label className="block text-xs text-gray-400"> <label className="block text-xs text-fg-secondary">
<span className="inline-flex items-center gap-1"> <span className="inline-flex items-center gap-1">
{t("rules.form.count")} {t("rules.form.count")}
<FieldHelp description={t("rules.help.count")} /> <FieldHelp description={t("rules.help.count")} />
@@ -554,7 +554,7 @@ export function AlertsNotifications() {
/> />
</label> </label>
{parseInt(form.count, 10) > 1 && ( {parseInt(form.count, 10) > 1 && (
<label className="block text-xs text-gray-400"> <label className="block text-xs text-fg-secondary">
<span className="inline-flex items-center gap-1"> <span className="inline-flex items-center gap-1">
{t("rules.form.windowMinutes")} {t("rules.form.windowMinutes")}
<FieldHelp description={t("rules.help.window")} /> <FieldHelp description={t("rules.help.window")} />
@@ -574,7 +574,7 @@ export function AlertsNotifications() {
{(form.rule_type === "inactivity" || form.rule_type === "status_duration") && ( {(form.rule_type === "inactivity" || form.rule_type === "status_duration") && (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
{form.rule_type === "status_duration" && ( {form.rule_type === "status_duration" && (
<label className="block text-xs text-gray-400"> <label className="block text-xs text-fg-secondary">
<span className="inline-flex items-center gap-1"> <span className="inline-flex items-center gap-1">
{t("rules.form.agentStatus")} {t("rules.form.agentStatus")}
<FieldHelp description={t("rules.help.status")} /> <FieldHelp description={t("rules.help.status")} />
@@ -588,11 +588,11 @@ export function AlertsNotifications() {
<option value="working">working</option> <option value="working">working</option>
<option value="waiting">waiting</option> <option value="waiting">waiting</option>
</select> </select>
<ChevronDown className="w-3.5 h-3.5 absolute right-2.5 top-1/2 -translate-y-1/2 text-gray-500 pointer-events-none" /> <ChevronDown className="w-3.5 h-3.5 absolute right-2.5 top-1/2 -translate-y-1/2 text-fg-muted pointer-events-none" />
</div> </div>
</label> </label>
)} )}
<label className="block text-xs text-gray-400"> <label className="block text-xs text-fg-secondary">
<span className="inline-flex items-center gap-1"> <span className="inline-flex items-center gap-1">
{t("rules.form.minutes")} {t("rules.form.minutes")}
<FieldHelp <FieldHelp
@@ -616,7 +616,7 @@ export function AlertsNotifications() {
{form.rule_type === "token_threshold" && ( {form.rule_type === "token_threshold" && (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<label className="block text-xs text-gray-400"> <label className="block text-xs text-fg-secondary">
<span className="inline-flex items-center gap-1"> <span className="inline-flex items-center gap-1">
{t("rules.form.totalTokens")} {t("rules.form.totalTokens")}
<FieldHelp description={t("rules.help.totalTokens")} /> <FieldHelp description={t("rules.help.totalTokens")} />
@@ -633,7 +633,7 @@ export function AlertsNotifications() {
)} )}
<div className="flex flex-wrap items-end justify-between gap-3"> <div className="flex flex-wrap items-end justify-between gap-3">
<label className="block text-xs text-gray-400"> <label className="block text-xs text-fg-secondary">
<span className="mb-1.5 flex items-center gap-1"> <span className="mb-1.5 flex items-center gap-1">
{t("rules.form.cooldown")} {t("rules.form.cooldown")}
<FieldHelp description={t("rules.help.cooldown")} /> <FieldHelp description={t("rules.help.cooldown")} />
@@ -655,7 +655,7 @@ export function AlertsNotifications() {
{saving ? t("rules.saving") : t("rules.create")} {saving ? t("rules.saving") : t("rules.create")}
</button> </button>
</div> </div>
{formError && <p className="text-xs text-red-400">{formError}</p>} {formError && <p className="text-xs text-status-danger">{formError}</p>}
</div> </div>
)} )}
@@ -680,7 +680,7 @@ export function AlertsNotifications() {
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span <span
className={`text-sm font-medium truncate ${rule.enabled ? "text-gray-200" : "text-gray-500 line-through"}`} className={`text-sm font-medium truncate ${rule.enabled ? "text-fg-secondary" : "text-fg-muted line-through"}`}
> >
{rule.name} {rule.name}
</span> </span>
@@ -688,7 +688,7 @@ export function AlertsNotifications() {
{t(`ruleTypes.${rule.rule_type}`)} {t(`ruleTypes.${rule.rule_type}`)}
</span> </span>
</div> </div>
<p className="text-xs text-gray-500 mt-0.5 truncate"> <p className="text-xs text-fg-muted mt-0.5 truncate">
{describeRule(rule, t)} ·{" "} {describeRule(rule, t)} ·{" "}
{t("rules.cooldown", { seconds: rule.cooldown_seconds })} {t("rules.cooldown", { seconds: rule.cooldown_seconds })}
</p> </p>
@@ -698,8 +698,8 @@ export function AlertsNotifications() {
onClick={() => onToggleRule(rule)} onClick={() => onToggleRule(rule)}
className={`text-xs px-2.5 py-1.5 rounded-md border transition-colors ${ className={`text-xs px-2.5 py-1.5 rounded-md border transition-colors ${
rule.enabled rule.enabled
? "border-emerald-500/30 text-emerald-400 hover:bg-emerald-500/10" ? "border-status-success/30 text-status-success hover:bg-status-success/10"
: "border-border text-gray-500 hover:text-gray-300 hover:bg-surface-3" : "border-border text-fg-muted hover:text-fg-secondary hover:bg-surface-3"
}`} }`}
title={rule.enabled ? t("rules.disable") : t("rules.enable")} title={rule.enabled ? t("rules.disable") : t("rules.enable")}
> >
@@ -707,7 +707,7 @@ export function AlertsNotifications() {
</button> </button>
<button <button
onClick={() => setConfirmRule(rule)} onClick={() => setConfirmRule(rule)}
className="p-1.5 rounded-md text-gray-500 hover:text-red-400 hover:bg-red-500/10 transition-colors" className="p-1.5 rounded-md text-fg-muted hover:text-status-danger hover:bg-status-danger/10 transition-colors"
title={t("rules.delete")} title={t("rules.delete")}
aria-label={t("rules.delete")} aria-label={t("rules.delete")}
> >
@@ -728,10 +728,10 @@ export function AlertsNotifications() {
{tab === "activity" && ( {tab === "activity" && (
<div className="card p-4"> <div className="card p-4">
<div className="flex flex-wrap items-center justify-between gap-3 mb-3"> <div className="flex flex-wrap items-center justify-between gap-3 mb-3">
<h4 className="text-sm font-semibold text-gray-200"> <h4 className="text-sm font-semibold text-fg-secondary">
{t("feed.title")} {t("feed.title")}
{unacked > 0 && ( {unacked > 0 && (
<span className="ml-2 text-[10px] font-semibold text-amber-300 bg-amber-500/10 border border-amber-500/30 rounded-full px-2 py-0.5"> <span className="ml-2 text-[10px] font-semibold text-status-warning bg-status-warning/10 border border-status-warning/30 rounded-full px-2 py-0.5">
{t("feed.unackedCount", { count: unacked })} {t("feed.unackedCount", { count: unacked })}
</span> </span>
)} )}
@@ -782,17 +782,17 @@ export function AlertsNotifications() {
className={`flex flex-wrap items-center justify-between gap-3 rounded-lg border px-3 py-2.5 ${ className={`flex flex-wrap items-center justify-between gap-3 rounded-lg border px-3 py-2.5 ${
alert.acknowledged_at alert.acknowledged_at
? "border-border bg-surface-2 opacity-70" ? "border-border bg-surface-2 opacity-70"
: "border-amber-500/30 bg-amber-500/5" : "border-status-warning/30 bg-status-warning/5"
}`} }`}
> >
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<BellRing <BellRing
className={`w-3.5 h-3.5 flex-shrink-0 ${alert.acknowledged_at ? "text-gray-500" : "text-amber-400"}`} className={`w-3.5 h-3.5 flex-shrink-0 ${alert.acknowledged_at ? "text-fg-muted" : "text-status-warning"}`}
/> />
<span className="text-sm text-gray-200 truncate">{alert.message}</span> <span className="text-sm text-fg-secondary truncate">{alert.message}</span>
</div> </div>
<p className="text-[11px] text-gray-500 mt-0.5 font-mono"> <p className="text-[11px] text-fg-muted mt-0.5 font-mono">
{timeAgo(alert.triggered_at)} · {alert.rule_name} {timeAgo(alert.triggered_at)} · {alert.rule_name}
{alert.session_id && ( {alert.session_id && (
<> <>
@@ -810,7 +810,7 @@ export function AlertsNotifications() {
{!alert.acknowledged_at && ( {!alert.acknowledged_at && (
<button <button
onClick={() => onAck(alert.id)} onClick={() => onAck(alert.id)}
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-md border border-border text-gray-300 hover:text-gray-100 hover:bg-surface-3 transition-colors flex-shrink-0" className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-md border border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3 transition-colors flex-shrink-0"
> >
<Check className="w-3.5 h-3.5" /> <Check className="w-3.5 h-3.5" />
{t("feed.ack")} {t("feed.ack")}
+3 -1
View File
@@ -105,7 +105,9 @@ export function Checkbox({ checked, onChange, label, className, labelClassName }
{checked && <Check className="h-3 w-3 text-white" strokeWidth={3} />} {checked && <Check className="h-3 w-3 text-white" strokeWidth={3} />}
</span> </span>
{label != null && ( {label != null && (
<span className={labelClassName ?? "text-xs text-gray-400 group-hover:text-gray-300"}> <span
className={labelClassName ?? "text-xs text-fg-secondary group-hover:text-fg-secondary"}
>
{label} {label}
</span> </span>
)} )}
+6 -6
View File
@@ -133,16 +133,16 @@ export function ConfirmModal({
> >
<div className="flex items-start gap-3 p-5"> <div className="flex items-start gap-3 p-5">
{destructive && ( {destructive && (
<div className="w-9 h-9 rounded-lg bg-red-500/10 border border-red-500/20 flex items-center justify-center flex-shrink-0"> <div className="w-9 h-9 rounded-lg bg-status-danger/10 border border-status-danger/20 flex items-center justify-center flex-shrink-0">
<AlertTriangle className="w-4.5 h-4.5 text-red-400" /> <AlertTriangle className="w-4.5 h-4.5 text-status-danger" />
</div> </div>
)} )}
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<h3 id={titleId} className="text-sm font-semibold text-gray-100"> <h3 id={titleId} className="text-sm font-semibold text-fg-primary">
{title} {title}
</h3> </h3>
{message && ( {message && (
<p id={messageId} className="text-xs text-gray-400 mt-1 leading-relaxed"> <p id={messageId} className="text-xs text-fg-secondary mt-1 leading-relaxed">
{message} {message}
</p> </p>
)} )}
@@ -151,7 +151,7 @@ export function ConfirmModal({
<button <button
type="button" type="button"
onClick={onCancel} onClick={onCancel}
className="text-gray-500 hover:text-gray-300 p-1 -mt-1 -mr-1" className="text-fg-muted hover:text-fg-secondary p-1 -mt-1 -mr-1"
aria-label={cancelLabel} aria-label={cancelLabel}
> >
<X className="w-4 h-4" /> <X className="w-4 h-4" />
@@ -172,7 +172,7 @@ export function ConfirmModal({
disabled={busy || disabled} disabled={busy || disabled}
className={`inline-flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-md transition-colors disabled:opacity-50 ${ className={`inline-flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-md transition-colors disabled:opacity-50 ${
destructive destructive
? "text-red-200 bg-red-500/15 border border-red-500/30 hover:bg-red-500/25" ? "text-status-danger bg-status-danger/15 border border-status-danger/30 hover:bg-status-danger/25"
: "btn-primary" : "btn-primary"
}`} }`}
> >
+10 -10
View File
@@ -180,7 +180,7 @@ export function DateTimePicker({
? "bg-accent text-white font-medium" ? "bg-accent text-white font-medium"
: isToday : isToday
? "bg-surface-3 text-accent font-medium" ? "bg-surface-3 text-accent font-medium"
: "hover:bg-surface-2 text-gray-300 hover:text-white" : "hover:bg-surface-2 text-fg-secondary hover:text-white"
}`} }`}
> >
{i} {i}
@@ -202,12 +202,12 @@ export function DateTimePicker({
title={title} title={title}
className={`flex items-center gap-2 bg-surface-2 border ${isOpen ? "border-accent" : "border-border"} rounded px-2 py-1.5 min-w-[150px] text-xs focus:outline-none focus:border-accent transition-colors w-full text-left`} className={`flex items-center gap-2 bg-surface-2 border ${isOpen ? "border-accent" : "border-border"} rounded px-2 py-1.5 min-w-[150px] text-xs focus:outline-none focus:border-accent transition-colors w-full text-left`}
> >
<Calendar className="w-3.5 h-3.5 text-gray-400 shrink-0" /> <Calendar className="w-3.5 h-3.5 text-fg-secondary shrink-0" />
<span className={`flex-1 truncate ${!dateObj ? "text-gray-500" : "text-gray-200"}`}> <span className={`flex-1 truncate ${!dateObj ? "text-fg-muted" : "text-fg-secondary"}`}>
{dateObj ? formatDisplay(dateObj) : placeholder} {dateObj ? formatDisplay(dateObj) : placeholder}
</span> </span>
{dateObj && ( {dateObj && (
<X className="w-3 h-3 text-gray-500 hover:text-white shrink-0" onClick={clearValue} /> <X className="w-3 h-3 text-fg-muted hover:text-white shrink-0" onClick={clearValue} />
)} )}
</button> </button>
@@ -222,11 +222,11 @@ export function DateTimePicker({
onClick={() => onClick={() =>
setViewDate(new Date(viewDate.getFullYear(), viewDate.getMonth() - 1, 1)) setViewDate(new Date(viewDate.getFullYear(), viewDate.getMonth() - 1, 1))
} }
className="p-1 hover:bg-surface-2 rounded text-gray-400 hover:text-white" className="p-1 hover:bg-surface-2 rounded text-fg-secondary hover:text-white"
> >
<ChevronLeft className="w-4 h-4" /> <ChevronLeft className="w-4 h-4" />
</button> </button>
<span className="text-xs font-medium text-gray-200"> <span className="text-xs font-medium text-fg-secondary">
{viewDate.toLocaleString(undefined, { month: "long", year: "numeric" })} {viewDate.toLocaleString(undefined, { month: "long", year: "numeric" })}
</span> </span>
<button <button
@@ -234,7 +234,7 @@ export function DateTimePicker({
onClick={() => onClick={() =>
setViewDate(new Date(viewDate.getFullYear(), viewDate.getMonth() + 1, 1)) setViewDate(new Date(viewDate.getFullYear(), viewDate.getMonth() + 1, 1))
} }
className="p-1 hover:bg-surface-2 rounded text-gray-400 hover:text-white" className="p-1 hover:bg-surface-2 rounded text-fg-secondary hover:text-white"
> >
<ChevronRight className="w-4 h-4" /> <ChevronRight className="w-4 h-4" />
</button> </button>
@@ -244,7 +244,7 @@ export function DateTimePicker({
<div> <div>
<div className="grid grid-cols-7 gap-1 mb-1"> <div className="grid grid-cols-7 gap-1 mb-1">
{["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"].map((day) => ( {["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"].map((day) => (
<div key={day} className="w-6 text-center text-[10px] font-medium text-gray-500"> <div key={day} className="w-6 text-center text-[10px] font-medium text-fg-muted">
{day} {day}
</div> </div>
))} ))}
@@ -254,7 +254,7 @@ export function DateTimePicker({
{/* Time Picker */} {/* Time Picker */}
<div className="pt-3 border-t border-border flex items-center justify-between"> <div className="pt-3 border-t border-border flex items-center justify-between">
<div className="flex items-center gap-1.5 text-gray-400"> <div className="flex items-center gap-1.5 text-fg-secondary">
<Clock className="w-3.5 h-3.5" /> <Clock className="w-3.5 h-3.5" />
<span className="text-[11px] font-medium">Time</span> <span className="text-[11px] font-medium">Time</span>
</div> </div>
@@ -262,7 +262,7 @@ export function DateTimePicker({
type="time" type="time"
value={timeValue} value={timeValue}
onChange={handleTimeChange} onChange={handleTimeChange}
className="bg-surface-2 border border-border rounded px-2 py-1 text-xs text-gray-200 focus:outline-none focus:border-accent w-[85px] time-input-custom" className="bg-surface-2 border border-border rounded px-2 py-1 text-xs text-fg-secondary focus:outline-none focus:border-accent w-[85px] time-input-custom"
/> />
</div> </div>
</div> </div>
+3 -3
View File
@@ -91,10 +91,10 @@ export function EmptyState({ icon: Icon, title, description, action }: EmptyStat
return ( return (
<div className="flex flex-col items-center justify-center py-20 text-center"> <div className="flex flex-col items-center justify-center py-20 text-center">
<div className="w-14 h-14 rounded-2xl bg-surface-4 flex items-center justify-center mb-5"> <div className="w-14 h-14 rounded-2xl bg-surface-4 flex items-center justify-center mb-5">
<Icon className="w-6 h-6 text-gray-500" /> <Icon className="w-6 h-6 text-fg-muted" />
</div> </div>
<h3 className="text-base font-medium text-gray-300 mb-2">{title}</h3> <h3 className="text-base font-medium text-fg-secondary mb-2">{title}</h3>
<p className="text-sm text-gray-500 max-w-md mb-6">{description}</p> <p className="text-sm text-fg-muted max-w-md mb-6">{description}</p>
{action} {action}
</div> </div>
); );
+13 -13
View File
@@ -284,7 +284,7 @@ function SummaryBlock({
return ( return (
<div className="border border-border rounded overflow-hidden bg-surface-3/30"> <div className="border border-border rounded overflow-hidden bg-surface-3/30">
<div className="px-3 py-1 border-b border-border bg-black/20"> <div className="px-3 py-1 border-b border-border bg-black/20">
<span className="text-gray-500 text-[10px] uppercase tracking-wide font-semibold"> <span className="text-fg-muted text-[10px] uppercase tracking-wide font-semibold">
{t("eventDetail.summary")} {t("eventDetail.summary")}
</span> </span>
</div> </div>
@@ -293,19 +293,19 @@ function SummaryBlock({
<span className="text-base leading-none" aria-hidden="true"> <span className="text-base leading-none" aria-hidden="true">
{summary.icon} {summary.icon}
</span> </span>
<span className="text-[12px] text-gray-100 font-medium break-words"> <span className="text-[12px] text-fg-primary font-medium break-words">
{summary.headline} {summary.headline}
</span> </span>
</div> </div>
{summary.bullets.length > 0 && ( {summary.bullets.length > 0 && (
<ul className="list-disc pl-6 space-y-0.5 text-[11px] text-gray-400"> <ul className="list-disc pl-6 space-y-0.5 text-[11px] text-fg-secondary">
{summary.bullets.map((b, i) => ( {summary.bullets.map((b, i) => (
<li key={i}>{b}</li> <li key={i}>{b}</li>
))} ))}
</ul> </ul>
)} )}
{hint && ( {hint && (
<div className="text-[11px] text-gray-500 italic pt-1 border-t border-border/40"> <div className="text-[11px] text-fg-muted italic pt-1 border-t border-border/40">
{hint} {hint}
</div> </div>
)} )}
@@ -337,7 +337,7 @@ function FieldRow({
if (view) { if (view) {
return ( return (
<div className="grid grid-cols-[160px_1fr] gap-x-4 items-start text-[11px]"> <div className="grid grid-cols-[160px_1fr] gap-x-4 items-start text-[11px]">
<div className="text-gray-500 font-mono pt-2">{label}</div> <div className="text-fg-muted font-mono pt-2">{label}</div>
<div>{view}</div> <div>{view}</div>
</div> </div>
); );
@@ -348,7 +348,7 @@ function FieldRow({
if (view) { if (view) {
return ( return (
<div className="grid grid-cols-[160px_1fr] gap-x-4 items-start text-[11px]"> <div className="grid grid-cols-[160px_1fr] gap-x-4 items-start text-[11px]">
<div className="text-gray-500 font-mono pt-2">{label}</div> <div className="text-fg-muted font-mono pt-2">{label}</div>
<div>{view}</div> <div>{view}</div>
</div> </div>
); );
@@ -358,8 +358,8 @@ function FieldRow({
if (isInlineScalar(value)) { if (isInlineScalar(value)) {
return ( return (
<div className="grid grid-cols-[160px_1fr] gap-x-4 items-start text-[11px]"> <div className="grid grid-cols-[160px_1fr] gap-x-4 items-start text-[11px]">
<div className="text-gray-500 font-mono pt-0.5">{label}</div> <div className="text-fg-muted font-mono pt-0.5">{label}</div>
<div className="text-gray-300 font-mono break-all"> <div className="text-fg-secondary font-mono break-all">
<ScalarValue value={value} /> <ScalarValue value={value} />
</div> </div>
</div> </div>
@@ -368,7 +368,7 @@ function FieldRow({
return ( return (
<div className="grid grid-cols-[160px_1fr] gap-x-4 items-start text-[11px]"> <div className="grid grid-cols-[160px_1fr] gap-x-4 items-start text-[11px]">
<div className="text-gray-500 font-mono pt-2">{label}</div> <div className="text-fg-muted font-mono pt-2">{label}</div>
<CodeView value={value} /> <CodeView value={value} />
</div> </div>
); );
@@ -382,11 +382,11 @@ function isInlineScalar(value: unknown): boolean {
} }
function ScalarValue({ value }: { value: unknown }) { function ScalarValue({ value }: { value: unknown }) {
if (value == null) return <span className="text-gray-500 italic">null</span>; if (value == null) return <span className="text-fg-muted italic">null</span>;
if (typeof value === "boolean") { if (typeof value === "boolean") {
const color = value const color = value
? "text-green-400 border-green-500/30 bg-green-500/10" ? "text-green-400 border-green-500/30 bg-green-500/10"
: "text-gray-400 border-gray-500/30 bg-gray-500/10"; : "text-fg-secondary border-border-light/30 bg-surface-4/10";
return ( return (
<span className={`inline-block px-2 py-0.5 rounded border ${color}`}>{String(value)}</span> <span className={`inline-block px-2 py-0.5 rounded border ${color}`}>{String(value)}</span>
); );
@@ -402,12 +402,12 @@ function CodeView({ value }: { value: unknown }) {
return ( return (
<div className="relative bg-black/70 border border-border rounded font-mono text-[11px] overflow-hidden"> <div className="relative bg-black/70 border border-border rounded font-mono text-[11px] overflow-hidden">
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border bg-black/40"> <div className="flex items-center justify-between px-3 py-1.5 border-b border-border bg-black/40">
<span className="text-gray-500 text-[10px] uppercase tracking-wide"> <span className="text-fg-muted text-[10px] uppercase tracking-wide">
{typeof value === "string" ? "text" : Array.isArray(value) ? "array" : "json"} {typeof value === "string" ? "text" : Array.isArray(value) ? "array" : "json"}
</span> </span>
<CopyButton text={text} /> <CopyButton text={text} />
</div> </div>
<pre className="px-3 py-2 text-gray-200 whitespace-pre-wrap break-words max-h-96 overflow-auto"> <pre className="px-3 py-2 text-fg-secondary whitespace-pre-wrap break-words max-h-96 overflow-auto">
{text} {text}
</pre> </pre>
</div> </div>
+8 -8
View File
@@ -232,16 +232,16 @@ export function EventFilters({
return ( return (
<div className="card p-3 space-y-2"> <div className="card p-3 space-y-2">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Filter className="w-3.5 h-3.5 text-gray-500 flex-shrink-0" /> <Filter className="w-3.5 h-3.5 text-fg-muted flex-shrink-0" />
<div className="relative flex-1 min-w-[180px]"> <div className="relative flex-1 min-w-[180px]">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 text-gray-500" /> <Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 text-fg-muted" />
<input <input
type="text" type="text"
value={searchDraft} value={searchDraft}
onChange={(e) => setSearchDraft(e.target.value)} onChange={(e) => setSearchDraft(e.target.value)}
placeholder={t("eventFilters.searchPlaceholder")} placeholder={t("eventFilters.searchPlaceholder")}
aria-label={t("eventFilters.searchPlaceholder")} aria-label={t("eventFilters.searchPlaceholder")}
className="w-full bg-surface-2 border border-border rounded pl-7 pr-2 py-1.5 text-xs text-gray-200 placeholder-gray-500 focus:outline-none focus:border-accent" className="w-full bg-surface-2 border border-border rounded pl-7 pr-2 py-1.5 text-xs text-fg-secondary placeholder-fg-muted focus:outline-none focus:border-accent"
/> />
</div> </div>
<DateTimePicker <DateTimePicker
@@ -251,7 +251,7 @@ export function EventFilters({
title={t("eventFilters.from")} title={t("eventFilters.from")}
placeholder={t("eventFilters.from")} placeholder={t("eventFilters.from")}
/> />
<span className="text-xs text-gray-600"></span> <span className="text-xs text-fg-muted"></span>
<DateTimePicker <DateTimePicker
value={value.to} value={value.to}
onChange={(val: string) => onChange({ ...value, to: val })} onChange={(val: string) => onChange({ ...value, to: val })}
@@ -263,7 +263,7 @@ export function EventFilters({
<button <button
type="button" type="button"
onClick={() => onChange(EMPTY_FILTERS)} onClick={() => onChange(EMPTY_FILTERS)}
className="flex items-center gap-1 text-[11px] px-2 py-1 rounded text-gray-400 hover:text-gray-200 hover:bg-surface-2 cursor-pointer" className="flex items-center gap-1 text-[11px] px-2 py-1 rounded text-fg-secondary hover:text-fg-primary hover:bg-surface-2 cursor-pointer"
aria-label={t("eventFilters.clearAll")} aria-label={t("eventFilters.clearAll")}
> >
<X className="w-3 h-3" /> <X className="w-3 h-3" />
@@ -359,7 +359,7 @@ function ChipGroup({
className={`text-[11px] px-2 py-1 rounded border cursor-pointer flex items-center gap-1.5 ${ className={`text-[11px] px-2 py-1 rounded border cursor-pointer flex items-center gap-1.5 ${
selectedCount > 0 selectedCount > 0
? "border-accent/40 bg-accent/10 text-accent" ? "border-accent/40 bg-accent/10 text-accent"
: "border-border bg-surface-2 text-gray-400 hover:text-gray-200" : "border-border bg-surface-2 text-fg-secondary hover:text-fg-primary"
}`} }`}
> >
<span>{label}</span> <span>{label}</span>
@@ -374,7 +374,7 @@ function ChipGroup({
className="absolute left-0 mt-1 z-20 min-w-[220px] max-h-64 overflow-auto bg-surface-1 border border-border rounded shadow-xl p-1.5" className="absolute left-0 mt-1 z-20 min-w-[220px] max-h-64 overflow-auto bg-surface-1 border border-border rounded shadow-xl p-1.5"
> >
{options.length === 0 ? ( {options.length === 0 ? (
<p className="text-[11px] text-gray-500 px-2 py-1 italic"> <p className="text-[11px] text-fg-muted px-2 py-1 italic">
{t("eventFilters.noOptions")} {t("eventFilters.noOptions")}
</p> </p>
) : ( ) : (
@@ -384,7 +384,7 @@ function ChipGroup({
return ( return (
<label <label
key={opt} key={opt}
className="flex items-center gap-2 px-2 py-1 text-[11px] text-gray-300 rounded hover:bg-surface-3 cursor-pointer" className="flex items-center gap-2 px-2 py-1 text-[11px] text-fg-secondary rounded hover:bg-surface-3 cursor-pointer"
> >
<input <input
type="checkbox" type="checkbox"
+14 -12
View File
@@ -71,17 +71,17 @@ export function EventFiltersInfo() {
const { t } = useTranslation("common"); const { t } = useTranslation("common");
return ( return (
<details className="card bg-surface-2/40 border border-border rounded overflow-hidden"> <details className="card bg-surface-2/40 border border-border rounded overflow-hidden">
<summary className="cursor-pointer select-none px-3 py-2 flex items-center text-[11px] text-gray-400 hover:text-gray-200 hover:bg-surface-2/80"> <summary className="cursor-pointer select-none px-3 py-2 flex items-center text-[11px] text-fg-secondary hover:text-fg-primary hover:bg-surface-2/80">
<Info className="w-3.5 h-3.5 mr-2" /> <Info className="w-3.5 h-3.5 mr-2" />
<span className="font-semibold uppercase tracking-wide mr-1.5"> <span className="font-semibold uppercase tracking-wide mr-1.5">
{t("eventFilters.help.title")} {t("eventFilters.help.title")}
</span> </span>
<span className="text-gray-500 font-normal">- {t("eventFilters.help.subtitle")}</span> <span className="text-fg-muted font-normal">- {t("eventFilters.help.subtitle")}</span>
</summary> </summary>
<div className="divide-y divide-border"> <div className="divide-y divide-border">
<Section title={t("eventFilters.help.statusesTitle")}> <Section title={t("eventFilters.help.statusesTitle")}>
<p className="text-[11px] text-gray-500 mb-2">{t("eventFilters.help.statusesIntro")}</p> <p className="text-[11px] text-fg-muted mb-2">{t("eventFilters.help.statusesIntro")}</p>
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1.5 text-[11px]"> <dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1.5 text-[11px]">
<dt> <dt>
<AgentStatusBadge status="working" /> <AgentStatusBadge status="working" />
@@ -103,17 +103,19 @@ export function EventFiltersInfo() {
</Section> </Section>
<Section title={t("eventFilters.help.lifecycleTitle")}> <Section title={t("eventFilters.help.lifecycleTitle")}>
<p className="text-[11px] text-gray-400 mb-2">{t("eventFilters.help.lifecycleDesc")}</p> <p className="text-[11px] text-fg-secondary mb-2">
<code className="block bg-black/40 border border-border rounded p-2 text-[11px] font-mono text-gray-300 whitespace-pre-wrap"> {t("eventFilters.help.lifecycleDesc")}
</p>
<code className="block bg-black/40 border border-border rounded p-2 text-[11px] font-mono text-fg-secondary whitespace-pre-wrap">
{t("eventFilters.help.lifecycleFlow")} {t("eventFilters.help.lifecycleFlow")}
</code> </code>
</Section> </Section>
<Section title={t("eventFilters.help.filtersTitle")}> <Section title={t("eventFilters.help.filtersTitle")}>
<ul className="list-disc pl-5 space-y-1 text-[11px] text-gray-400"> <ul className="list-disc pl-5 space-y-1 text-[11px] text-fg-secondary">
<li>{t("eventFilters.help.filterTip1")}</li> <li>{t("eventFilters.help.filterTip1")}</li>
<li>{t("eventFilters.help.filterTip2")}</li> <li>{t("eventFilters.help.filterTip2")}</li>
<li className="text-amber-300/90">{t("eventFilters.help.filterTipGrouping")}</li> <li className="text-status-warning/90">{t("eventFilters.help.filterTipGrouping")}</li>
<li>{t("eventFilters.help.filterTip3")}</li> <li>{t("eventFilters.help.filterTip3")}</li>
<li>{t("eventFilters.help.filterTip4")}</li> <li>{t("eventFilters.help.filterTip4")}</li>
</ul> </ul>
@@ -157,9 +159,9 @@ export function EventFiltersInfo() {
function Section({ title, children }: { title: string; children: React.ReactNode }) { function Section({ title, children }: { title: string; children: React.ReactNode }) {
return ( return (
<details className="group" open> <details className="group" open>
<summary className="cursor-pointer select-none px-3 py-1.5 text-[11px] text-gray-300 hover:bg-surface-2/60 flex items-center gap-2"> <summary className="cursor-pointer select-none px-3 py-1.5 text-[11px] text-fg-secondary hover:bg-surface-2/60 flex items-center gap-2">
<span className="text-gray-500 transition-transform group-open:rotate-90"></span> <span className="text-fg-muted transition-transform group-open:rotate-90"></span>
<span className="font-semibold uppercase tracking-wide text-gray-400">{title}</span> <span className="font-semibold uppercase tracking-wide text-fg-secondary">{title}</span>
</summary> </summary>
<div className="px-3 pb-3 pt-1">{children}</div> <div className="px-3 pb-3 pt-1">{children}</div>
</details> </details>
@@ -170,8 +172,8 @@ function Section({ title, children }: { title: string; children: React.ReactNode
function Field({ label, desc }: { label: string; desc: string }) { function Field({ label, desc }: { label: string; desc: string }) {
return ( return (
<> <>
<dt className="font-semibold text-gray-300 whitespace-nowrap">{label}</dt> <dt className="font-semibold text-fg-secondary whitespace-nowrap">{label}</dt>
<dd className="text-gray-400">{desc}</dd> <dd className="text-fg-secondary">{desc}</dd>
</> </>
); );
} }
+5 -5
View File
@@ -136,7 +136,7 @@ export function FieldHelp({ title, description, examples, note }: FieldHelpProps
e.preventDefault(); e.preventDefault();
setOpen((v) => !v); setOpen((v) => !v);
}} }}
className="text-gray-500 hover:text-gray-300 transition-colors" className="text-fg-muted hover:text-fg-secondary transition-colors"
> >
<HelpCircle className="w-3.5 h-3.5" /> <HelpCircle className="w-3.5 h-3.5" />
</button> </button>
@@ -154,11 +154,11 @@ export function FieldHelp({ title, description, examples, note }: FieldHelpProps
}} }}
className="rounded-lg border border-border bg-surface-1 shadow-xl shadow-black/40 p-3 w-[300px] pointer-events-none" className="rounded-lg border border-border bg-surface-1 shadow-xl shadow-black/40 p-3 w-[300px] pointer-events-none"
> >
{title && <p className="text-xs font-semibold text-gray-200 mb-1">{title}</p>} {title && <p className="text-xs font-semibold text-fg-secondary mb-1">{title}</p>}
<p className="text-[11px] leading-relaxed text-gray-400">{description}</p> <p className="text-[11px] leading-relaxed text-fg-secondary">{description}</p>
{examples && examples.length > 0 && ( {examples && examples.length > 0 && (
<div className="mt-2"> <div className="mt-2">
<p className="text-[10px] uppercase tracking-wider text-gray-600 mb-1"> <p className="text-[10px] uppercase tracking-wider text-fg-muted mb-1">
{t("examples")} {t("examples")}
</p> </p>
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
@@ -173,7 +173,7 @@ export function FieldHelp({ title, description, examples, note }: FieldHelpProps
</div> </div>
</div> </div>
)} )}
{note && <p className="text-[10px] text-gray-500 mt-2 leading-relaxed">{note}</p>} {note && <p className="text-[10px] text-fg-muted mt-2 leading-relaxed">{note}</p>}
</div>, </div>,
document.body document.body
)} )}
+61 -61
View File
@@ -285,12 +285,12 @@ export function ImportHistory() {
return ( return (
<section> <section>
<h3 className="text-sm font-medium text-gray-300 flex items-center gap-2 mb-1"> <h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
<History className="w-4 h-4 text-gray-500" /> <History className="w-4 h-4 text-fg-muted" />
{t("import.title")} {t("import.title")}
</h3> </h3>
<p className="text-xs text-gray-500 mb-1">{t("import.description")}</p> <p className="text-xs text-fg-muted mb-1">{t("import.description")}</p>
<p className="text-[11px] text-gray-600 italic mb-4 leading-snug">{t("cursorPathsNote")}</p> <p className="text-[11px] text-fg-muted italic mb-4 leading-snug">{t("cursorPathsNote")}</p>
<div className="card p-5 space-y-5"> <div className="card p-5 space-y-5">
{/* Step-by-step instructions */} {/* Step-by-step instructions */}
@@ -299,33 +299,33 @@ export function ImportHistory() {
onClick={() => setInstructionsOpen((v) => !v)} onClick={() => setInstructionsOpen((v) => !v)}
className="w-full flex items-center justify-between px-4 py-3 bg-surface-2 hover:bg-surface-3 transition-colors" className="w-full flex items-center justify-between px-4 py-3 bg-surface-2 hover:bg-surface-3 transition-colors"
> >
<span className="flex items-center gap-2 text-xs font-semibold text-gray-300 uppercase tracking-wider"> <span className="flex items-center gap-2 text-xs font-semibold text-fg-secondary uppercase tracking-wider">
<ListChecks className="w-3.5 h-3.5 text-blue-400" /> <ListChecks className="w-3.5 h-3.5 text-blue-500" />
{t("import.instructions")} {t("import.instructions")}
</span> </span>
<span className="text-[11px] text-gray-500">{instructionsOpen ? "▾" : "▸"}</span> <span className="text-[11px] text-fg-muted">{instructionsOpen ? "▾" : "▸"}</span>
</button> </button>
{instructionsOpen && ( {instructionsOpen && (
<div className="px-4 py-4 space-y-4 text-sm text-gray-300 bg-surface-1"> <div className="px-4 py-4 space-y-4 text-sm text-fg-secondary bg-surface-1">
{/* Default location card */} {/* Default location card */}
{guide && ( {guide && (
<div className="flex flex-wrap items-center gap-2 text-xs bg-surface-2 border border-border rounded-md px-3 py-2"> <div className="flex flex-wrap items-center gap-2 text-xs bg-surface-2 border border-border rounded-md px-3 py-2">
<HardDrive className="w-3.5 h-3.5 text-gray-500 flex-shrink-0" /> <HardDrive className="w-3.5 h-3.5 text-fg-muted flex-shrink-0" />
<span className="text-gray-400">{t("import.defaultLocation")}:</span> <span className="text-fg-secondary">{t("import.defaultLocation")}:</span>
<code className="font-mono text-gray-200 truncate"> <code className="font-mono text-fg-secondary truncate">
{guide.default_projects_dir_display} {guide.default_projects_dir_display}
</code> </code>
{guide.default_projects_dir_exists ? ( {guide.default_projects_dir_exists ? (
<span className="inline-flex items-center gap-1 text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 px-2 py-0.5 rounded-full"> <span className="inline-flex items-center gap-1 text-status-success bg-status-success/10 border border-status-success/20 px-2 py-0.5 rounded-full">
<CheckCircle2 className="w-3 h-3" /> <CheckCircle2 className="w-3 h-3" />
{t("import.locationFound")} {t("import.locationFound")}
<span className="text-gray-500 ml-1"> <span className="text-fg-muted ml-1">
· {guide.default_projects_dir_stats.projects} {t("import.projectsLabel")},{" "} · {guide.default_projects_dir_stats.projects} {t("import.projectsLabel")},{" "}
{guide.default_projects_dir_stats.jsonl_files} {t("import.jsonlLabel")} {guide.default_projects_dir_stats.jsonl_files} {t("import.jsonlLabel")}
</span> </span>
</span> </span>
) : ( ) : (
<span className="inline-flex items-center gap-1 text-amber-400 bg-amber-500/10 border border-amber-500/20 px-2 py-0.5 rounded-full"> <span className="inline-flex items-center gap-1 text-status-warning bg-status-warning/10 border border-status-warning/20 px-2 py-0.5 rounded-full">
<AlertTriangle className="w-3 h-3" /> <AlertTriangle className="w-3 h-3" />
{t("import.locationMissing")} {t("import.locationMissing")}
</span> </span>
@@ -339,13 +339,13 @@ export function ImportHistory() {
<Step title={t("import.stepArchive")} body={t("import.stepArchiveBody")}> <Step title={t("import.stepArchive")} body={t("import.stepArchiveBody")}>
{guide && ( {guide && (
<div className="mt-2 flex items-center gap-2 bg-surface-2 border border-border rounded-md px-3 py-2"> <div className="mt-2 flex items-center gap-2 bg-surface-2 border border-border rounded-md px-3 py-2">
<Terminal className="w-3.5 h-3.5 text-gray-500 flex-shrink-0" /> <Terminal className="w-3.5 h-3.5 text-fg-muted flex-shrink-0" />
<code className="flex-1 text-xs font-mono text-gray-200 truncate"> <code className="flex-1 text-xs font-mono text-fg-secondary truncate">
{guide.archive_command} {guide.archive_command}
</code> </code>
<button <button
onClick={copyArchiveCmd} onClick={copyArchiveCmd}
className="text-xs text-gray-400 hover:text-gray-200 flex items-center gap-1 flex-shrink-0" className="text-xs text-fg-secondary hover:text-fg-primary flex items-center gap-1 flex-shrink-0"
> >
{copied ? ( {copied ? (
<> <>
@@ -364,7 +364,7 @@ export function ImportHistory() {
<Step title={t("import.stepVerify")} body={t("import.stepVerifyBody")} /> <Step title={t("import.stepVerify")} body={t("import.stepVerifyBody")} />
</div> </div>
<div className="text-[11px] text-gray-500 flex items-start gap-2 pt-2 border-t border-border"> <div className="text-[11px] text-fg-muted flex items-start gap-2 pt-2 border-t border-border">
<Info className="w-3 h-3 mt-0.5 flex-shrink-0" /> <Info className="w-3 h-3 mt-0.5 flex-shrink-0" />
<span>{t("import.accuracyNote")}</span> <span>{t("import.accuracyNote")}</span>
</div> </div>
@@ -409,8 +409,8 @@ export function ImportHistory() {
{mode === "rescan" && ( {mode === "rescan" && (
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3 min-w-0"> <div className="flex items-center gap-3 min-w-0">
<FolderOpen className="w-4 h-4 text-gray-500 flex-shrink-0" /> <FolderOpen className="w-4 h-4 text-fg-muted flex-shrink-0" />
<code className="font-mono text-xs text-gray-300 truncate"> <code className="font-mono text-xs text-fg-secondary truncate">
{guide?.default_projects_dir_display || "~/.claude/projects"} {guide?.default_projects_dir_display || "~/.claude/projects"}
</code> </code>
</div> </div>
@@ -440,7 +440,7 @@ export function ImportHistory() {
className="input w-full text-sm font-mono" className="input w-full text-sm font-mono"
spellCheck={false} spellCheck={false}
/> />
<p className="text-[11px] text-gray-500 mt-1.5">{t("import.folderHelper")}</p> <p className="text-[11px] text-fg-muted mt-1.5">{t("import.folderHelper")}</p>
</div> </div>
<div className="flex justify-end"> <div className="flex justify-end">
<button <button
@@ -475,13 +475,13 @@ export function ImportHistory() {
}} }}
className={`border-2 border-dashed rounded-lg px-4 py-8 text-center cursor-pointer transition-colors ${ className={`border-2 border-dashed rounded-lg px-4 py-8 text-center cursor-pointer transition-colors ${
dragging dragging
? "border-blue-400 bg-blue-500/5" ? "border-blue-500 bg-blue-600/5"
: "border-border hover:border-gray-500 bg-surface-1" : "border-border hover:border-border-light bg-surface-1"
}`} }`}
> >
<UploadCloud className="w-6 h-6 text-gray-500 mx-auto mb-2" /> <UploadCloud className="w-6 h-6 text-fg-muted mx-auto mb-2" />
<p className="text-sm text-gray-300">{t("import.dropzoneHint")}</p> <p className="text-sm text-fg-secondary">{t("import.dropzoneHint")}</p>
<p className="text-[11px] text-gray-500 mt-1">{t("import.dropzoneSub")}</p> <p className="text-[11px] text-fg-muted mt-1">{t("import.dropzoneSub")}</p>
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
@@ -493,17 +493,17 @@ export function ImportHistory() {
</div> </div>
{files.length > 0 && ( {files.length > 0 && (
<div className="flex flex-wrap items-center justify-between gap-2 text-xs bg-surface-3 rounded-md px-3 py-2"> <div className="flex flex-wrap items-center justify-between gap-2 text-xs bg-surface-3 rounded-md px-3 py-2">
<span className="text-gray-400"> <span className="text-fg-secondary">
<FileArchive className="w-3.5 h-3.5 inline mr-1.5 text-gray-500" /> <FileArchive className="w-3.5 h-3.5 inline mr-1.5 text-fg-muted" />
{t("import.filesSelected", { count: files.length })} {t("import.filesSelected", { count: files.length })}
<span className="text-gray-600 ml-2">({formatBytes(totalSize)})</span> <span className="text-fg-muted ml-2">({formatBytes(totalSize)})</span>
</span> </span>
<button <button
onClick={() => { onClick={() => {
setFiles([]); setFiles([]);
if (fileInputRef.current) fileInputRef.current.value = ""; if (fileInputRef.current) fileInputRef.current.value = "";
}} }}
className="text-gray-500 hover:text-gray-300 text-[11px]" className="text-fg-muted hover:text-fg-secondary text-[11px]"
> >
{t("import.clearSelection")} {t("import.clearSelection")}
</button> </button>
@@ -543,13 +543,13 @@ export function ImportHistory() {
}} }}
className={`border-2 border-dashed rounded-lg px-4 py-8 text-center cursor-pointer transition-colors ${ className={`border-2 border-dashed rounded-lg px-4 py-8 text-center cursor-pointer transition-colors ${
dragging dragging
? "border-blue-400 bg-blue-500/5" ? "border-blue-500 bg-blue-600/5"
: "border-border hover:border-gray-500 bg-surface-1" : "border-border hover:border-border-light bg-surface-1"
}`} }`}
> >
<DatabaseBackup className="w-6 h-6 text-gray-500 mx-auto mb-2" /> <DatabaseBackup className="w-6 h-6 text-fg-muted mx-auto mb-2" />
<p className="text-sm text-gray-300">{t("import.backupHint")}</p> <p className="text-sm text-fg-secondary">{t("import.backupHint")}</p>
<p className="text-[11px] text-gray-500 mt-1">{t("import.backupSub")}</p> <p className="text-[11px] text-fg-muted mt-1">{t("import.backupSub")}</p>
<input <input
ref={backupInputRef} ref={backupInputRef}
type="file" type="file"
@@ -560,17 +560,17 @@ export function ImportHistory() {
</div> </div>
{backupFile && ( {backupFile && (
<div className="flex flex-wrap items-center justify-between gap-2 text-xs bg-surface-3 rounded-md px-3 py-2"> <div className="flex flex-wrap items-center justify-between gap-2 text-xs bg-surface-3 rounded-md px-3 py-2">
<span className="text-gray-400 min-w-0"> <span className="text-fg-secondary min-w-0">
<FileArchive className="w-3.5 h-3.5 inline mr-1.5 text-gray-500" /> <FileArchive className="w-3.5 h-3.5 inline mr-1.5 text-fg-muted" />
<span className="font-mono truncate">{backupFile.name}</span> <span className="font-mono truncate">{backupFile.name}</span>
<span className="text-gray-600 ml-2">({formatBytes(backupFile.size)})</span> <span className="text-fg-muted ml-2">({formatBytes(backupFile.size)})</span>
</span> </span>
<button <button
onClick={() => { onClick={() => {
setBackupFile(null); setBackupFile(null);
if (backupInputRef.current) backupInputRef.current.value = ""; if (backupInputRef.current) backupInputRef.current.value = "";
}} }}
className="text-gray-500 hover:text-gray-300 text-[11px]" className="text-fg-muted hover:text-fg-secondary text-[11px]"
> >
{t("import.clearSelection")} {t("import.clearSelection")}
</button> </button>
@@ -596,11 +596,11 @@ export function ImportHistory() {
{/* In-flight progress */} {/* In-flight progress */}
{running && progressText && ( {running && progressText && (
<div className="flex items-center gap-2 text-xs text-gray-400 bg-surface-2 border border-border rounded-md px-3 py-2"> <div className="flex items-center gap-2 text-xs text-fg-secondary bg-surface-2 border border-border rounded-md px-3 py-2">
<Loader2 className="w-3.5 h-3.5 animate-spin text-blue-400 flex-shrink-0" /> <Loader2 className="w-3.5 h-3.5 animate-spin text-blue-500 flex-shrink-0" />
<span className="truncate">{progressText}</span> <span className="truncate">{progressText}</span>
{progress?.current && ( {progress?.current && (
<code className="font-mono text-[11px] text-gray-600 truncate"> <code className="font-mono text-[11px] text-fg-muted truncate">
· {progress.current.split("/").slice(-2).join("/")} · {progress.current.split("/").slice(-2).join("/")}
</code> </code>
)} )}
@@ -609,7 +609,7 @@ export function ImportHistory() {
{/* Errors */} {/* Errors */}
{errorMsg && ( {errorMsg && (
<div className="flex items-start gap-2 text-xs text-red-400 bg-red-500/10 border border-red-500/20 rounded-md px-3 py-2"> <div className="flex items-start gap-2 text-xs text-status-danger bg-status-danger/10 border border-status-danger/20 rounded-md px-3 py-2">
<XCircle className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" /> <XCircle className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
<span>{errorMsg}</span> <span>{errorMsg}</span>
</div> </div>
@@ -617,8 +617,8 @@ export function ImportHistory() {
{/* Result summary */} {/* Result summary */}
{result && !running && ( {result && !running && (
<div className="border border-emerald-500/20 bg-emerald-500/5 rounded-lg px-4 py-3 space-y-2"> <div className="border border-status-success/20 bg-status-success/5 rounded-lg px-4 py-3 space-y-2">
<div className="flex items-center gap-2 text-xs font-semibold text-emerald-400 uppercase tracking-wider"> <div className="flex items-center gap-2 text-xs font-semibold text-status-success uppercase tracking-wider">
<CheckCircle2 className="w-3.5 h-3.5" /> <CheckCircle2 className="w-3.5 h-3.5" />
{t("import.result.title")} {t("import.result.title")}
</div> </div>
@@ -626,26 +626,26 @@ export function ImportHistory() {
<ResultStat <ResultStat
label={t("import.result.imported", { count: result.imported })} label={t("import.result.imported", { count: result.imported })}
value={result.imported} value={result.imported}
color="text-emerald-300" color="text-status-success"
/> />
<ResultStat <ResultStat
label={t("import.result.backfilled", { count: result.backfilled ?? 0 })} label={t("import.result.backfilled", { count: result.backfilled ?? 0 })}
value={result.backfilled ?? 0} value={result.backfilled ?? 0}
color="text-blue-300" color="text-blue-400"
/> />
<ResultStat <ResultStat
label={t("import.result.skipped", { count: result.skipped })} label={t("import.result.skipped", { count: result.skipped })}
value={result.skipped} value={result.skipped}
color="text-gray-400" color="text-fg-secondary"
/> />
<ResultStat <ResultStat
label={t("import.result.errors", { count: result.errors })} label={t("import.result.errors", { count: result.errors })}
value={result.errors} value={result.errors}
color={result.errors > 0 ? "text-red-300" : "text-gray-500"} color={result.errors > 0 ? "text-status-danger" : "text-fg-muted"}
/> />
</div> </div>
{typeof result.files_scanned === "number" && ( {typeof result.files_scanned === "number" && (
<p className="text-[11px] text-gray-500"> <p className="text-[11px] text-fg-muted">
{t("import.result.filesScanned", { count: result.files_scanned })} {t("import.result.filesScanned", { count: result.files_scanned })}
{result.path ? ` · ${result.path}` : ""} {result.path ? ` · ${result.path}` : ""}
</p> </p>
@@ -655,8 +655,8 @@ export function ImportHistory() {
{/* Restore-from-backup result summary */} {/* Restore-from-backup result summary */}
{backupResult && !running && ( {backupResult && !running && (
<div className="border border-emerald-500/20 bg-emerald-500/5 rounded-lg px-4 py-3 space-y-2"> <div className="border border-status-success/20 bg-status-success/5 rounded-lg px-4 py-3 space-y-2">
<div className="flex items-center gap-2 text-xs font-semibold text-emerald-400 uppercase tracking-wider"> <div className="flex items-center gap-2 text-xs font-semibold text-status-success uppercase tracking-wider">
<CheckCircle2 className="w-3.5 h-3.5" /> <CheckCircle2 className="w-3.5 h-3.5" />
{t("import.backupResult.title")} {t("import.backupResult.title")}
</div> </div>
@@ -666,14 +666,14 @@ export function ImportHistory() {
count: backupResult.sessions_imported, count: backupResult.sessions_imported,
})} })}
value={backupResult.sessions_imported} value={backupResult.sessions_imported}
color="text-emerald-300" color="text-status-success"
/> />
<ResultStat <ResultStat
label={t("import.backupResult.sessionsSkipped", { label={t("import.backupResult.sessionsSkipped", {
count: backupResult.sessions_skipped, count: backupResult.sessions_skipped,
})} })}
value={backupResult.sessions_skipped} value={backupResult.sessions_skipped}
color="text-gray-400" color="text-fg-secondary"
/> />
<ResultStat <ResultStat
label={t("import.backupResult.events", { count: backupResult.events })} label={t("import.backupResult.events", { count: backupResult.events })}
@@ -686,7 +686,7 @@ export function ImportHistory() {
color="text-cyan-300" color="text-cyan-300"
/> />
</div> </div>
<p className="text-[11px] text-gray-500"> <p className="text-[11px] text-fg-muted">
{t("import.backupResult.detail", { {t("import.backupResult.detail", {
agents: backupResult.agents, agents: backupResult.agents,
workflows: backupResult.workflows, workflows: backupResult.workflows,
@@ -712,8 +712,8 @@ function Step({
}) { }) {
return ( return (
<div> <div>
<p className="text-sm font-medium text-gray-200">{title}</p> <p className="text-sm font-medium text-fg-secondary">{title}</p>
<p className="text-xs text-gray-400 mt-1 whitespace-pre-line">{body}</p> <p className="text-xs text-fg-secondary mt-1 whitespace-pre-line">{body}</p>
{children} {children}
</div> </div>
); );
@@ -737,19 +737,19 @@ function ModeButton({
onClick={onClick} onClick={onClick}
className={`text-left p-3 rounded-lg border transition-colors ${ className={`text-left p-3 rounded-lg border transition-colors ${
active active
? "border-blue-500/40 bg-blue-500/10" ? "border-blue-600/40 bg-blue-600/10"
: "border-border bg-surface-2 hover:bg-surface-3" : "border-border bg-surface-2 hover:bg-surface-3"
}`} }`}
> >
<div <div
className={`flex items-center gap-1.5 text-xs font-medium mb-1 ${ className={`flex items-center gap-1.5 text-xs font-medium mb-1 ${
active ? "text-blue-300" : "text-gray-300" active ? "text-blue-400" : "text-fg-secondary"
}`} }`}
> >
{icon} {icon}
{title} {title}
</div> </div>
<p className="text-[11px] text-gray-500 leading-snug">{desc}</p> <p className="text-[11px] text-fg-muted leading-snug">{desc}</p>
</button> </button>
); );
} }
@@ -758,7 +758,7 @@ function ResultStat({ label, value, color }: { label: string; value: number; col
return ( return (
<div className="bg-surface-2 rounded-md px-2.5 py-2"> <div className="bg-surface-2 rounded-md px-2.5 py-2">
<p className={`text-sm font-semibold ${color}`}>{value.toLocaleString()}</p> <p className={`text-sm font-semibold ${color}`}>{value.toLocaleString()}</p>
<p className="text-[10px] text-gray-500 uppercase tracking-wider mt-0.5">{label}</p> <p className="text-[10px] text-fg-muted uppercase tracking-wider mt-0.5">{label}</p>
</div> </div>
); );
} }
+43 -40
View File
@@ -101,14 +101,17 @@ const EMPTY_FORM: RemoteSourceInput = {
/** Compact status pill for a source's last-known sync state. */ /** Compact status pill for a source's last-known sync state. */
function StatusPill({ status }: { status: RemoteSource["status"] }) { function StatusPill({ status }: { status: RemoteSource["status"] }) {
const map: Record<RemoteSource["status"], { cls: string; label: string; pulse?: boolean }> = { const map: Record<RemoteSource["status"], { cls: string; label: string; pulse?: boolean }> = {
idle: { cls: "text-gray-400 bg-gray-500/10 border-gray-500/20", label: "Idle" }, idle: { cls: "text-fg-secondary bg-surface-4/10 border-border-light/20", label: "Idle" },
syncing: { syncing: {
cls: "text-amber-300 bg-amber-500/10 border-amber-500/25", cls: "text-status-warning bg-status-warning/10 border-status-warning/25",
label: "Syncing", label: "Syncing",
pulse: true, pulse: true,
}, },
ok: { cls: "text-emerald-300 bg-emerald-500/10 border-emerald-500/25", label: "OK" }, ok: { cls: "text-status-success bg-status-success/10 border-status-success/25", label: "OK" },
error: { cls: "text-red-300 bg-red-500/10 border-red-500/25", label: "Error" }, error: {
cls: "text-status-danger bg-status-danger/10 border-status-danger/25",
label: "Error",
},
}; };
const s = map[status] || map.idle; const s = map[status] || map.idle;
return ( return (
@@ -318,17 +321,17 @@ export function RemoteSources() {
return ( return (
<div> <div>
<h3 className="text-sm font-medium text-gray-300 flex items-center gap-2 mb-1"> <h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
<Cloud className="w-4 h-4 text-gray-500" /> <Cloud className="w-4 h-4 text-fg-muted" />
{t("remoteSources.title", "Remote Data Sources")} {t("remoteSources.title", "Remote Data Sources")}
</h3> </h3>
<p className="text-xs text-gray-500 mb-4"> <p className="text-xs text-fg-muted mb-4">
{t( {t(
"remoteSources.description", "remoteSources.description",
"Collect Claude Code usage from other machines over SSH — e.g. a dev box or cloud VM you drive over SSH while running this dashboard locally. Authentication uses your own SSH setup (~/.ssh/config, keys, agent); no passwords are stored here." "Collect Claude Code usage from other machines over SSH — e.g. a dev box or cloud VM you drive over SSH while running this dashboard locally. Authentication uses your own SSH setup (~/.ssh/config, keys, agent); no passwords are stored here."
)} )}
</p> </p>
<p className="text-[11px] text-gray-600 italic mb-4 leading-snug"> <p className="text-[11px] text-fg-muted italic mb-4 leading-snug">
{t( {t(
"cursorPathsNote", "cursorPathsNote",
"Informational: Cursor sessions count here too — Cursor happens to use the same ~/.claude paths as Claude Code (locally and on synced remotes)." "Informational: Cursor sessions count here too — Cursor happens to use the same ~/.claude paths as Claude Code (locally and on synced remotes)."
@@ -339,11 +342,11 @@ export function RemoteSources() {
<div className="card p-5 mb-4"> <div className="card p-5 mb-4">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<Wifi className="w-4 h-4 text-accent" /> <Wifi className="w-4 h-4 text-accent" />
<span className="text-sm font-medium text-gray-200"> <span className="text-sm font-medium text-fg-secondary">
{t("remoteSources.scopeTitle", "Data scope")} {t("remoteSources.scopeTitle", "Data scope")}
</span> </span>
</div> </div>
<p className="text-xs text-gray-500 mb-3"> <p className="text-xs text-fg-muted mb-3">
{t( {t(
"remoteSources.scopeDesc", "remoteSources.scopeDesc",
"Choose which machines' data the whole dashboard shows. Changes apply immediately across every page — sessions, analytics, and cost." "Choose which machines' data the whole dashboard shows. Changes apply immediately across every page — sessions, analytics, and cost."
@@ -399,13 +402,13 @@ export function RemoteSources() {
{active && ( {active && (
<CheckCircle className="w-4 h-4 text-accent absolute top-2.5 right-2.5" /> <CheckCircle className="w-4 h-4 text-accent absolute top-2.5 right-2.5" />
)} )}
<Icon className={`w-5 h-5 mb-2 ${active ? "text-accent" : "text-gray-400"}`} /> <Icon className={`w-5 h-5 mb-2 ${active ? "text-accent" : "text-fg-secondary"}`} />
<div <div
className={`text-sm font-medium ${active ? "text-gray-100" : "text-gray-300"}`} className={`text-sm font-medium ${active ? "text-fg-primary" : "text-fg-secondary"}`}
> >
{title} {title}
</div> </div>
<div className="text-[11px] text-gray-500 mt-0.5 leading-snug">{desc}</div> <div className="text-[11px] text-fg-muted mt-0.5 leading-snug">{desc}</div>
{mode === "selected" && scope.mode === "selected" && ( {mode === "selected" && scope.mode === "selected" && (
<div className="text-[11px] text-accent mt-1"> <div className="text-[11px] text-accent mt-1">
{t("remoteSources.scopeSelectedCount", "{{n}} of {{total}} selected", { {t("remoteSources.scopeSelectedCount", "{{n}} of {{total}} selected", {
@@ -420,7 +423,7 @@ export function RemoteSources() {
</div> </div>
{scope.mode === "selected" && ( {scope.mode === "selected" && (
<div className="mt-3 pt-3 border-t border-border"> <div className="mt-3 pt-3 border-t border-border">
<div className="text-[11px] uppercase tracking-wider text-gray-500 mb-2"> <div className="text-[11px] uppercase tracking-wider text-fg-muted mb-2">
{t("remoteSources.scopePickMachines", "Machines to include")} {t("remoteSources.scopePickMachines", "Machines to include")}
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
@@ -434,7 +437,7 @@ export function RemoteSources() {
className={`inline-flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-full border transition-colors ${ className={`inline-flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-full border transition-colors ${
on on
? "bg-accent/15 border-accent/40 text-accent" ? "bg-accent/15 border-accent/40 text-accent"
: "bg-surface-2 border-border text-gray-400 hover:text-gray-200" : "bg-surface-2 border-border text-fg-secondary hover:text-fg-primary"
}`} }`}
> >
{on ? <Check className="w-3 h-3" /> : <Server className="w-3 h-3" />} {on ? <Check className="w-3 h-3" /> : <Server className="w-3 h-3" />}
@@ -449,7 +452,7 @@ export function RemoteSources() {
{/* Sources list header + add button */} {/* Sources list header + add button */}
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<span className="text-xs font-medium text-gray-400 uppercase tracking-wider"> <span className="text-xs font-medium text-fg-secondary uppercase tracking-wider">
{t("remoteSources.listTitle", "Configured sources")} {t("remoteSources.listTitle", "Configured sources")}
</span> </span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -473,14 +476,14 @@ export function RemoteSources() {
{/* Add/Edit form */} {/* Add/Edit form */}
{showForm && ( {showForm && (
<div className="card p-5 mb-4 space-y-3 border-accent/30"> <div className="card p-5 mb-4 space-y-3 border-accent/30">
<div className="text-sm font-medium text-gray-200"> <div className="text-sm font-medium text-fg-secondary">
{editingId {editingId
? t("remoteSources.editTitle", "Edit source") ? t("remoteSources.editTitle", "Edit source")
: t("remoteSources.addTitle", "Add a remote source")} : t("remoteSources.addTitle", "Add a remote source")}
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div> <div>
<label className="block text-xs text-gray-500 mb-1"> <label className="block text-xs text-fg-muted mb-1">
{t("remoteSources.fieldLabel", "Label")} * {t("remoteSources.fieldLabel", "Label")} *
</label> </label>
<input <input
@@ -491,7 +494,7 @@ export function RemoteSources() {
/> />
</div> </div>
<div> <div>
<label className="block text-xs text-gray-500 mb-1"> <label className="block text-xs text-fg-muted mb-1">
{t("remoteSources.fieldHost", "SSH host")} * {t("remoteSources.fieldHost", "SSH host")} *
</label> </label>
<input <input
@@ -502,7 +505,7 @@ export function RemoteSources() {
/> />
</div> </div>
<div> <div>
<label className="block text-xs text-gray-500 mb-1"> <label className="block text-xs text-fg-muted mb-1">
{t("remoteSources.fieldPort", "Port (optional)")} {t("remoteSources.fieldPort", "Port (optional)")}
</label> </label>
<input <input
@@ -519,7 +522,7 @@ export function RemoteSources() {
/> />
</div> </div>
<div> <div>
<label className="block text-xs text-gray-500 mb-1"> <label className="block text-xs text-fg-muted mb-1">
{t("remoteSources.fieldIdentity", "Identity file (optional)")} {t("remoteSources.fieldIdentity", "Identity file (optional)")}
</label> </label>
<input <input
@@ -530,7 +533,7 @@ export function RemoteSources() {
/> />
</div> </div>
<div className="sm:col-span-2"> <div className="sm:col-span-2">
<label className="block text-xs text-gray-500 mb-1"> <label className="block text-xs text-fg-muted mb-1">
{t("remoteSources.fieldRemoteHome", "Remote Claude home (optional)")} {t("remoteSources.fieldRemoteHome", "Remote Claude home (optional)")}
</label> </label>
<input <input
@@ -539,7 +542,7 @@ export function RemoteSources() {
value={form.remote_home ?? ""} value={form.remote_home ?? ""}
onChange={(e) => setForm((f) => ({ ...f, remote_home: e.target.value }))} onChange={(e) => setForm((f) => ({ ...f, remote_home: e.target.value }))}
/> />
<p className="mt-1 text-[11px] text-gray-500 leading-snug"> <p className="mt-1 text-[11px] text-fg-muted leading-snug">
{t( {t(
"remoteSources.fieldRemoteHomeHint", "remoteSources.fieldRemoteHomeHint",
"Linux/macOS: default ~/.claude (or an absolute path like /home/you/.claude). Windows SSH + Claude in WSL: leave blank (auto-detect) or use wsl:~/.claude. Native Windows: C:/Users/you/.claude." "Linux/macOS: default ~/.claude (or an absolute path like /home/you/.claude). Windows SSH + Claude in WSL: leave blank (auto-detect) or use wsl:~/.claude. Native Windows: C:/Users/you/.claude."
@@ -554,12 +557,12 @@ export function RemoteSources() {
checked={!!form.enabled} checked={!!form.enabled}
onChange={(e) => setForm((f) => ({ ...f, enabled: e.target.checked }))} onChange={(e) => setForm((f) => ({ ...f, enabled: e.target.checked }))}
/> />
<span className="text-sm text-gray-300"> <span className="text-sm text-fg-secondary">
{t("remoteSources.fieldEnabled", "Sync automatically in the background")} {t("remoteSources.fieldEnabled", "Sync automatically in the background")}
</span> </span>
</label> </label>
{formError && ( {formError && (
<div className="text-xs text-red-300 bg-red-500/10 border border-red-500/25 rounded-lg px-3 py-2"> <div className="text-xs text-status-danger bg-status-danger/10 border border-status-danger/25 rounded-lg px-3 py-2">
{formError} {formError}
</div> </div>
)} )}
@@ -585,14 +588,14 @@ export function RemoteSources() {
{/* Sources list */} {/* Sources list */}
{loading ? ( {loading ? (
<div className="text-xs text-gray-500">{t("common:loading", "Loading…")}</div> <div className="text-xs text-fg-muted">{t("common:loading", "Loading…")}</div>
) : sources.length === 0 ? ( ) : sources.length === 0 ? (
<div className="card p-6 text-center"> <div className="card p-6 text-center">
<Server className="w-6 h-6 text-gray-600 mx-auto mb-2" /> <Server className="w-6 h-6 text-fg-muted mx-auto mb-2" />
<p className="text-sm text-gray-400"> <p className="text-sm text-fg-secondary">
{t("remoteSources.empty", "No remote sources yet.")} {t("remoteSources.empty", "No remote sources yet.")}
</p> </p>
<p className="text-xs text-gray-600 mt-1"> <p className="text-xs text-fg-muted mt-1">
{t( {t(
"remoteSources.emptyHint", "remoteSources.emptyHint",
"Add a machine you reach over SSH to pull its Claude Code usage in." "Add a machine you reach over SSH to pull its Claude Code usage in."
@@ -610,16 +613,16 @@ export function RemoteSources() {
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<Server className="w-4 h-4 text-accent shrink-0" /> <Server className="w-4 h-4 text-accent shrink-0" />
<span className="text-sm font-medium text-gray-200">{s.label}</span> <span className="text-sm font-medium text-fg-secondary">{s.label}</span>
<StatusPill status={s.status} /> <StatusPill status={s.status} />
{!s.enabled && ( {!s.enabled && (
<span className="text-[10px] text-gray-500 bg-surface-2 border border-border px-1.5 py-0.5 rounded-full"> <span className="text-[10px] text-fg-muted bg-surface-2 border border-border px-1.5 py-0.5 rounded-full">
{t("remoteSources.paused", "Auto-sync off")} {t("remoteSources.paused", "Auto-sync off")}
</span> </span>
)} )}
{s.session_count != null && s.session_count > 0 && ( {s.session_count != null && s.session_count > 0 && (
<span <span
className="text-[10px] text-gray-400 bg-surface-2 border border-border px-1.5 py-0.5 rounded-full" className="text-[10px] text-fg-secondary bg-surface-2 border border-border px-1.5 py-0.5 rounded-full"
title={t( title={t(
"remoteSources.sessionCountHint", "remoteSources.sessionCountHint",
"Sessions linked to this source (not every session visible when data scope is All)" "Sessions linked to this source (not every session visible when data scope is All)"
@@ -631,12 +634,12 @@ export function RemoteSources() {
</span> </span>
)} )}
</div> </div>
<div className="text-[11px] text-gray-500 font-mono mt-1 truncate"> <div className="text-[11px] text-fg-muted font-mono mt-1 truncate">
{s.host} {s.host}
{s.ssh_port ? `:${s.ssh_port}` : ""} {s.ssh_port ? `:${s.ssh_port}` : ""}
{s.remote_home ? ` · ${s.remote_home}` : ""} {s.remote_home ? ` · ${s.remote_home}` : ""}
</div> </div>
<div className="text-[11px] text-gray-600 mt-1"> <div className="text-[11px] text-fg-muted mt-1">
{s.last_sync_at {s.last_sync_at
? t("remoteSources.lastSync", "Last sync: {{when}}", { ? t("remoteSources.lastSync", "Last sync: {{when}}", {
when: new Date(s.last_sync_at).toLocaleString(), when: new Date(s.last_sync_at).toLocaleString(),
@@ -652,14 +655,14 @@ export function RemoteSources() {
})}`} })}`}
</div> </div>
{s.status === "error" && s.last_error && ( {s.status === "error" && s.last_error && (
<div className="text-[11px] text-red-300 mt-1 break-words"> <div className="text-[11px] text-status-danger mt-1 break-words">
{s.last_error} {s.last_error}
</div> </div>
)} )}
{test && test.message && ( {test && test.message && (
<div <div
className={`flex items-start gap-1.5 text-[11px] mt-2 ${ className={`flex items-start gap-1.5 text-[11px] mt-2 ${
test.ok ? "text-emerald-300" : "text-red-300" test.ok ? "text-status-success" : "text-status-danger"
}`} }`}
> >
{test.ok ? ( {test.ok ? (
@@ -712,7 +715,7 @@ export function RemoteSources() {
</button> </button>
<button <button
onClick={() => setConfirmDelete({ id: s.id, purge: false })} onClick={() => setConfirmDelete({ id: s.id, purge: false })}
className="btn-ghost text-xs text-red-300 hover:text-red-200" className="btn-ghost text-xs text-status-danger hover:text-status-danger"
title={t("common:delete", "Delete")} title={t("common:delete", "Delete")}
> >
<Trash2 className="w-3.5 h-3.5" /> <Trash2 className="w-3.5 h-3.5" />
@@ -723,7 +726,7 @@ export function RemoteSources() {
{/* Inline delete confirmation */} {/* Inline delete confirmation */}
{confirmDelete?.id === s.id && ( {confirmDelete?.id === s.id && (
<div className="mt-3 pt-3 border-t border-border"> <div className="mt-3 pt-3 border-t border-border">
<p className="text-xs text-gray-300 mb-2"> <p className="text-xs text-fg-secondary mb-2">
{t("remoteSources.confirmDelete", "Remove this source?")} {t("remoteSources.confirmDelete", "Remove this source?")}
</p> </p>
<label className="flex items-center gap-2 mb-3 cursor-pointer"> <label className="flex items-center gap-2 mb-3 cursor-pointer">
@@ -735,7 +738,7 @@ export function RemoteSources() {
setConfirmDelete((c) => c && { ...c, purge: e.target.checked }) setConfirmDelete((c) => c && { ...c, purge: e.target.checked })
} }
/> />
<span className="text-xs text-gray-400"> <span className="text-xs text-fg-secondary">
{t( {t(
"remoteSources.purgeData", "remoteSources.purgeData",
"Also delete the sessions imported from this source (cannot be undone)" "Also delete the sessions imported from this source (cannot be undone)"
@@ -746,7 +749,7 @@ export function RemoteSources() {
<button <button
onClick={doDelete} onClick={doDelete}
disabled={busy} disabled={busy}
className="btn-primary text-xs bg-red-600 hover:bg-red-500 disabled:opacity-40" className="btn-primary text-xs bg-status-danger hover:bg-status-danger disabled:opacity-40"
> >
{busy ? ( {busy ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" /> <Loader2 className="w-3.5 h-3.5 animate-spin" />
+4 -4
View File
@@ -179,10 +179,10 @@ export function Select<T extends string>({ value, onChange, options, disabled }:
disabled={disabled} disabled={disabled}
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((v) => !v)}
onKeyDown={onKey} onKeyDown={onKey}
className="w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-gray-100 focus:outline-none focus:border-accent/50 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed" className="w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent/50 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
> >
<span className="truncate">{current?.label ?? "-"}</span> <span className="truncate">{current?.label ?? "-"}</span>
<ChevronDown className="w-3.5 h-3.5 text-gray-500 flex-shrink-0" /> <ChevronDown className="w-3.5 h-3.5 text-fg-muted flex-shrink-0" />
</button> </button>
{open && ( {open && (
<div <div
@@ -207,7 +207,7 @@ export function Select<T extends string>({ value, onChange, options, disabled }:
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span <span
className={`text-[11px] flex-1 truncate ${ className={`text-[11px] flex-1 truncate ${
isSelected ? "text-accent font-medium" : "text-gray-200" isSelected ? "text-accent font-medium" : "text-fg-secondary"
}`} }`}
> >
{opt.label} {opt.label}
@@ -215,7 +215,7 @@ export function Select<T extends string>({ value, onChange, options, disabled }:
{isSelected && <Check className="w-3 h-3 text-accent flex-shrink-0" />} {isSelected && <Check className="w-3 h-3 text-accent flex-shrink-0" />}
</div> </div>
{opt.hint && ( {opt.hint && (
<div className="text-[10px] text-gray-500 truncate mt-0.5">{opt.hint}</div> <div className="text-[10px] text-fg-muted truncate mt-0.5">{opt.hint}</div>
)} )}
</button> </button>
); );
+5 -5
View File
@@ -113,8 +113,8 @@ export function SessionCard({ session, onClick }: SessionCardProps) {
<FolderOpen className="w-3.5 h-3.5" /> <FolderOpen className="w-3.5 h-3.5" />
</div> </div>
<div className="min-w-0 overflow-hidden"> <div className="min-w-0 overflow-hidden">
<p className="text-sm font-medium text-gray-200 truncate">{title}</p> <p className="text-sm font-medium text-fg-secondary truncate">{title}</p>
<p className="text-[11px] text-gray-500 font-mono truncate"> <p className="text-[11px] text-fg-muted font-mono truncate">
{session.id.slice(0, 12)} {session.id.slice(0, 12)}
</p> </p>
</div> </div>
@@ -125,12 +125,12 @@ export function SessionCard({ session, onClick }: SessionCardProps) {
</div> </div>
{session.cwd && ( {session.cwd && (
<p className="text-xs text-gray-400 mb-3 truncate font-mono leading-relaxed"> <p className="text-xs text-fg-secondary mb-3 truncate font-mono leading-relaxed">
{session.cwd} {session.cwd}
</p> </p>
)} )}
<div className="flex items-center gap-3 text-[11px] text-gray-500 min-w-0 overflow-hidden flex-wrap"> <div className="flex items-center gap-3 text-[11px] text-fg-muted min-w-0 overflow-hidden flex-wrap">
<span className="flex items-center gap-1 flex-shrink-0"> <span className="flex items-center gap-1 flex-shrink-0">
<Bot className="w-3 h-3" /> <Bot className="w-3 h-3" />
{t("session.agentSummary", { count: agentCount })} {t("session.agentSummary", { count: agentCount })}
@@ -153,7 +153,7 @@ export function SessionCard({ session, onClick }: SessionCardProps) {
? `${t("ran")}${formatDuration(session.started_at, session.ended_at)}` ? `${t("ran")}${formatDuration(session.started_at, session.ended_at)}`
: `${t("running")}${formatDuration(session.started_at, new Date().toISOString())}`} : `${t("running")}${formatDuration(session.started_at, new Date().toISOString())}`}
</span> </span>
<span className="text-gray-600 flex-shrink-0 ml-auto"> <span className="text-fg-muted flex-shrink-0 ml-auto">
{timeAgo(session.ended_at || lastActivity)} {timeAgo(session.ended_at || lastActivity)}
</span> </span>
</div> </div>
+45 -42
View File
@@ -100,35 +100,35 @@ function StatTile({
tone?: "default" | "violet" | "emerald" | "amber" | "rose" | "cyan" | "blue"; tone?: "default" | "violet" | "emerald" | "amber" | "rose" | "cyan" | "blue";
}) { }) {
const palette = { const palette = {
default: "border-surface-3 bg-surface-2 text-gray-200", default: "border-surface-3 bg-surface-2 text-fg-secondary",
violet: "border-violet-500/20 bg-violet-500/5 text-violet-200", violet: "border-violet-500/20 bg-violet-500/5 text-violet-200",
emerald: "border-emerald-500/20 bg-emerald-500/5 text-emerald-200", emerald: "border-status-success/20 bg-status-success/5 text-status-success",
amber: "border-amber-500/20 bg-amber-500/5 text-amber-200", amber: "border-status-warning/20 bg-status-warning/5 text-status-warning",
rose: "border-rose-500/20 bg-rose-500/5 text-rose-200", rose: "border-rose-500/20 bg-rose-500/5 text-rose-200",
cyan: "border-cyan-500/20 bg-cyan-500/5 text-cyan-200", cyan: "border-cyan-500/20 bg-cyan-500/5 text-cyan-200",
blue: "border-blue-500/20 bg-blue-500/5 text-blue-200", blue: "border-blue-600/20 bg-blue-600/5 text-blue-300",
}[tone]; }[tone];
const iconTone = { const iconTone = {
default: "text-gray-500", default: "text-fg-muted",
violet: "text-violet-400", violet: "text-violet-400",
emerald: "text-emerald-400", emerald: "text-status-success",
amber: "text-amber-400", amber: "text-status-warning",
rose: "text-rose-400", rose: "text-rose-400",
cyan: "text-cyan-400", cyan: "text-cyan-400",
blue: "text-blue-400", blue: "text-blue-500",
}[tone]; }[tone];
return ( return (
<div className={`rounded-lg border px-3 py-2.5 ${palette}`}> <div className={`rounded-lg border px-3 py-2.5 ${palette}`}>
<div className="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-gray-500"> <div className="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-fg-muted">
<span className={iconTone}>{icon}</span> <span className={iconTone}>{icon}</span>
{label} {label}
</div> </div>
<div className="mt-1 font-mono text-lg font-semibold text-gray-100 leading-tight"> <div className="mt-1 font-mono text-lg font-semibold text-fg-primary leading-tight">
{value} {value}
</div> </div>
{hint && <div className="text-[10px] text-gray-500 mt-0.5">{hint}</div>} {hint && <div className="text-[10px] text-fg-muted mt-0.5">{hint}</div>}
</div> </div>
); );
} }
@@ -156,7 +156,7 @@ function ToolUsageRow({ toolName, count, max }: { toolName: string; count: numbe
style={{ width: `${pct}%` }} style={{ width: `${pct}%` }}
/> />
</div> </div>
<span className="font-mono text-xs text-gray-400 w-14 text-right flex-shrink-0"> <span className="font-mono text-xs text-fg-secondary w-14 text-right flex-shrink-0">
{count.toLocaleString()} {count.toLocaleString()}
</span> </span>
</div> </div>
@@ -286,23 +286,26 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
<div className="space-y-5 mb-6"> <div className="space-y-5 mb-6">
{/* Active-agent banner - only shows when session is running */} {/* Active-agent banner - only shows when session is running */}
{activeAgent && ( {activeAgent && (
<div className="flex items-center gap-2.5 px-3 py-2 rounded-lg border border-emerald-500/20 bg-emerald-500/5"> <div className="flex items-center gap-2.5 px-3 py-2 rounded-lg border border-status-success/20 bg-status-success/5">
<span className="relative flex h-2 w-2 flex-shrink-0"> <span className="relative flex h-2 w-2 flex-shrink-0">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-60" /> <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-status-success opacity-60" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-emerald-400" /> <span className="relative inline-flex rounded-full h-2 w-2 bg-status-success" />
</span> </span>
<Bot className="w-3.5 h-3.5 text-emerald-300 flex-shrink-0" /> <Bot className="w-3.5 h-3.5 text-status-success flex-shrink-0" />
<span className="text-xs text-emerald-200 font-medium flex-shrink-0"> <span className="text-xs text-status-success font-medium flex-shrink-0">
{activeAgent.name || "Agent"} {activeAgent.name || "Agent"}
</span> </span>
{activeAgent.current_tool && ( {activeAgent.current_tool && (
<span className="text-[11px] text-gray-400 font-mono inline-flex items-center gap-1"> <span className="text-[11px] text-fg-secondary font-mono inline-flex items-center gap-1">
<span className="text-gray-600">running</span> <span className="text-fg-muted">running</span>
<span className="text-emerald-300">{activeAgent.current_tool}</span> <span className="text-status-success">{activeAgent.current_tool}</span>
</span> </span>
)} )}
{activeAgent.task && ( {activeAgent.task && (
<span className="text-[11px] text-gray-400 truncate min-w-0" title={activeAgent.task}> <span
className="text-[11px] text-fg-secondary truncate min-w-0"
title={activeAgent.task}
>
· {activeAgent.task} · {activeAgent.task}
</span> </span>
)} )}
@@ -361,16 +364,16 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
{/* Tool usage */} {/* Tool usage */}
<div className="lg:col-span-2 rounded-lg border border-surface-3 bg-surface-2/60 p-3.5"> <div className="lg:col-span-2 rounded-lg border border-surface-3 bg-surface-2/60 p-3.5">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<h3 className="text-xs font-semibold text-gray-300 uppercase tracking-wider flex items-center gap-1.5"> <h3 className="text-xs font-semibold text-fg-secondary uppercase tracking-wider flex items-center gap-1.5">
<Wrench className="w-3.5 h-3.5 text-violet-400" /> <Wrench className="w-3.5 h-3.5 text-violet-400" />
Top tools Top tools
</h3> </h3>
<span className="text-[10px] text-gray-500 font-mono"> <span className="text-[10px] text-fg-muted font-mono">
{stats.tools_used.length} total {stats.tools_used.length} total
</span> </span>
</div> </div>
{stats.tools_used.length === 0 ? ( {stats.tools_used.length === 0 ? (
<div className="text-center py-6 text-xs text-gray-500">No tool calls yet.</div> <div className="text-center py-6 text-xs text-fg-muted">No tool calls yet.</div>
) : ( ) : (
<div className="space-y-1.5"> <div className="space-y-1.5">
{stats.tools_used.slice(0, 8).map((t) => ( {stats.tools_used.slice(0, 8).map((t) => (
@@ -420,26 +423,26 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
return ( return (
<> <>
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<h3 className="text-xs font-semibold text-gray-300 uppercase tracking-wider flex items-center gap-1.5"> <h3 className="text-xs font-semibold text-fg-secondary uppercase tracking-wider flex items-center gap-1.5">
<GitBranch className="w-3.5 h-3.5 text-cyan-400" /> <GitBranch className="w-3.5 h-3.5 text-cyan-400" />
Subagents Subagents
</h3> </h3>
<span className="text-[10px] text-gray-500 font-mono">{totalRuns} runs</span> <span className="text-[10px] text-fg-muted font-mono">{totalRuns} runs</span>
</div> </div>
{rows.length === 0 ? ( {rows.length === 0 ? (
<div className="text-center py-6 text-xs text-gray-500"> <div className="text-center py-6 text-xs text-fg-muted">
No subagents in this session. No subagents in this session.
</div> </div>
) : ( ) : (
<div className="space-y-1.5"> <div className="space-y-1.5">
{rows.slice(0, 8).map((r) => { {rows.slice(0, 8).map((r) => {
const pct = max > 0 ? Math.max(4, Math.round((r.count / max) * 100)) : 0; const pct = max > 0 ? Math.max(4, Math.round((r.count / max) * 100)) : 0;
const barClass = r.isCompaction ? "bg-amber-500/60" : "bg-cyan-500/60"; const barClass = r.isCompaction ? "bg-status-warning/60" : "bg-cyan-500/60";
return ( return (
<div key={r.key} className="flex items-center gap-2"> <div key={r.key} className="flex items-center gap-2">
<span <span
className={`font-mono text-xs truncate flex-1 min-w-0 ${ className={`font-mono text-xs truncate flex-1 min-w-0 ${
r.isCompaction ? "text-amber-300" : "text-gray-300" r.isCompaction ? "text-status-warning" : "text-fg-secondary"
}`} }`}
title={r.label} title={r.label}
> >
@@ -451,7 +454,7 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
style={{ width: `${pct}%` }} style={{ width: `${pct}%` }}
/> />
</div> </div>
<span className="font-mono text-xs text-gray-400 w-8 text-right flex-shrink-0"> <span className="font-mono text-xs text-fg-secondary w-8 text-right flex-shrink-0">
{r.count} {r.count}
</span> </span>
</div> </div>
@@ -469,11 +472,11 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
{totalTokens > 0 && ( {totalTokens > 0 && (
<div className="rounded-lg border border-surface-3 bg-surface-2/60 p-3.5"> <div className="rounded-lg border border-surface-3 bg-surface-2/60 p-3.5">
<div className="flex items-center justify-between mb-2.5"> <div className="flex items-center justify-between mb-2.5">
<h3 className="text-xs font-semibold text-gray-300 uppercase tracking-wider flex items-center gap-1.5"> <h3 className="text-xs font-semibold text-fg-secondary uppercase tracking-wider flex items-center gap-1.5">
<Coins className="w-3.5 h-3.5 text-amber-400" /> <Coins className="w-3.5 h-3.5 text-status-warning" />
Token flow Token flow
</h3> </h3>
<span className="text-[10px] text-gray-500 font-mono">{fmt(totalTokens)} total</span> <span className="text-[10px] text-fg-muted font-mono">{fmt(totalTokens)} total</span>
</div> </div>
<TokenFlowBar tokens={tokens} total={totalTokens} /> <TokenFlowBar tokens={tokens} total={totalTokens} />
</div> </div>
@@ -482,8 +485,8 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
{/* Event-type breakdown - secondary, only top 6 */} {/* Event-type breakdown - secondary, only top 6 */}
{stats.events_by_type.length > 0 && ( {stats.events_by_type.length > 0 && (
<div className="rounded-lg border border-surface-3 bg-surface-2/60 p-3.5"> <div className="rounded-lg border border-surface-3 bg-surface-2/60 p-3.5">
<h3 className="text-xs font-semibold text-gray-300 uppercase tracking-wider mb-3 flex items-center gap-1.5"> <h3 className="text-xs font-semibold text-fg-secondary uppercase tracking-wider mb-3 flex items-center gap-1.5">
<Activity className="w-3.5 h-3.5 text-gray-400" /> <Activity className="w-3.5 h-3.5 text-fg-secondary" />
Event mix Event mix
</h3> </h3>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
@@ -492,9 +495,9 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
key={e.event_type} key={e.event_type}
className="inline-flex items-center gap-1.5 text-[11px] font-mono bg-surface-3/60 border border-surface-3 rounded-md px-2 py-0.5" className="inline-flex items-center gap-1.5 text-[11px] font-mono bg-surface-3/60 border border-surface-3 rounded-md px-2 py-0.5"
> >
<span className="text-gray-400">{e.event_type}</span> <span className="text-fg-secondary">{e.event_type}</span>
<span className="text-gray-500">·</span> <span className="text-fg-muted">·</span>
<span className="text-gray-200">{e.count.toLocaleString()}</span> <span className="text-fg-secondary">{e.count.toLocaleString()}</span>
</span> </span>
))} ))}
</div> </div>
@@ -524,8 +527,8 @@ function TokenFlowBar({ tokens, total }: { tokens: SessionStats["tokens"]; total
key: "input", key: "input",
label: "Input", label: "Input",
value: tokens.input_tokens, value: tokens.input_tokens,
cls: "bg-emerald-500", cls: "bg-status-success",
text: "text-emerald-300", text: "text-status-success",
}, },
{ {
key: "output", key: "output",
@@ -558,11 +561,11 @@ function TokenFlowBar({ tokens, total }: { tokens: SessionStats["tokens"]; total
return ( return (
<div key={s.key} className="flex items-center gap-2"> <div key={s.key} className="flex items-center gap-2">
<span className={`block w-2 h-2 rounded-full ${s.cls}`} /> <span className={`block w-2 h-2 rounded-full ${s.cls}`} />
<span className="text-gray-500 text-[11px]">{s.label}</span> <span className="text-fg-muted text-[11px]">{s.label}</span>
<span className={`font-mono ml-auto ${s.text}`}> <span className={`font-mono ml-auto ${s.text}`}>
{fmt(s.value)} {fmt(s.value)}
{pct > 0 && ( {pct > 0 && (
<span className="text-gray-600 text-[10px] ml-1"> <span className="text-fg-muted text-[10px] ml-1">
{pct >= 1 ? Math.round(pct) : pct.toFixed(1)}% {pct >= 1 ? Math.round(pct) : pct.toFixed(1)}%
</span> </span>
)} )}
+135 -75
View File
@@ -79,10 +79,13 @@ import {
Gauge, Gauge,
ChevronUp, ChevronUp,
ChevronDown, ChevronDown,
Sun,
Moon,
} from "lucide-react"; } from "lucide-react";
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import { api } from "../lib/api"; import { api } from "../lib/api";
import { eventBus } from "../lib/eventBus"; import { eventBus } from "../lib/eventBus";
import { useTheme } from "../hooks/useTheme";
import type { UpdateStatusPayload, WSMessage } from "../lib/types"; import type { UpdateStatusPayload, WSMessage } from "../lib/types";
function isUpdatePayload(x: unknown): x is UpdateStatusPayload { function isUpdatePayload(x: unknown): x is UpdateStatusPayload {
@@ -180,6 +183,7 @@ interface SidebarProps {
export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) { export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const { theme, setTheme, toggleTheme } = useTheme();
// Track whether nav items are clipped by overflow so we can render // Track whether nav items are clipped by overflow so we can render
// chevron affordances pointing toward the hidden items. Recomputed on // chevron affordances pointing toward the hidden items. Recomputed on
// scroll, resize, and any structural change (e.g. collapse toggle). // scroll, resize, and any structural change (e.g. collapse toggle).
@@ -376,6 +380,8 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
const switchLanguageTitle = t("nav:switchLanguage", { const switchLanguageTitle = t("nav:switchLanguage", {
language: t(`nav:languageNames.${nextLanguage}`), language: t(`nav:languageNames.${nextLanguage}`),
}); });
const nextTheme = theme === "dark" ? "light" : "dark";
const switchThemeTitle = t("nav:switchTheme", { theme: t(`nav:themeNames.${nextTheme}`) });
const toggleLang = () => { const toggleLang = () => {
i18n.changeLanguage(nextLanguage); i18n.changeLanguage(nextLanguage);
@@ -401,8 +407,8 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
</div> </div>
{!collapsed && ( {!collapsed && (
<div className="min-w-0"> <div className="min-w-0">
<h1 className="text-sm font-semibold text-gray-100 truncate">{t("nav:brand")}</h1> <h1 className="text-sm font-semibold text-fg-primary truncate">{t("nav:brand")}</h1>
<p className="text-[11px] text-gray-500">{t("nav:brandSub")}</p> <p className="text-[11px] text-fg-muted">{t("nav:brandSub")}</p>
</div> </div>
)} )}
</div> </div>
@@ -428,7 +434,7 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
} ${ } ${
isActive isActive
? "bg-accent/10 text-accent border border-accent/20" ? "bg-accent/10 text-accent border border-accent/20"
: "text-gray-400 hover:text-gray-200 hover:bg-surface-3 border border-transparent" : "text-fg-secondary hover:text-fg-primary hover:bg-surface-3 border border-transparent"
}` }`
} }
> >
@@ -444,7 +450,7 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
onClick={() => scrollNavBy(-160)} onClick={() => scrollNavBy(-160)}
aria-label={t("nav:scrollUp")} aria-label={t("nav:scrollUp")}
title={t("nav:scrollUp")} title={t("nav:scrollUp")}
className="absolute top-1.5 right-[7px] z-10 inline-flex items-center justify-center w-6 h-6 rounded-md border border-border bg-surface-2/90 text-gray-300 hover:text-gray-50 hover:bg-surface-3 shadow-md backdrop-blur-sm transition-colors animate-fade-in" className="absolute top-1.5 right-[7px] z-10 inline-flex items-center justify-center w-6 h-6 rounded-md border border-border bg-surface-2/90 text-fg-secondary hover:text-fg-primary hover:bg-surface-3 shadow-md backdrop-blur-sm transition-colors animate-fade-in"
> >
<ChevronUp className="w-3.5 h-3.5" aria-hidden /> <ChevronUp className="w-3.5 h-3.5" aria-hidden />
</button> </button>
@@ -455,52 +461,106 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
onClick={() => scrollNavBy(160)} onClick={() => scrollNavBy(160)}
aria-label={t("nav:scrollDown")} aria-label={t("nav:scrollDown")}
title={t("nav:scrollDown")} title={t("nav:scrollDown")}
className="absolute bottom-1.5 right-[7px] z-10 inline-flex items-center justify-center w-6 h-6 rounded-md border border-border bg-surface-2/90 text-gray-300 hover:text-gray-50 hover:bg-surface-3 shadow-md backdrop-blur-sm transition-colors animate-fade-in" className="absolute bottom-1.5 right-[7px] z-10 inline-flex items-center justify-center w-6 h-6 rounded-md border border-border bg-surface-2/90 text-fg-secondary hover:text-fg-primary hover:bg-surface-3 shadow-md backdrop-blur-sm transition-colors animate-fade-in"
> >
<ChevronDown className="w-3.5 h-3.5" aria-hidden /> <ChevronDown className="w-3.5 h-3.5" aria-hidden />
</button> </button>
)} )}
</div> </div>
{/* Language controls */} {/* Language + theme controls. Theme sits next to language: same icon
row when collapsed, same row as the EN/VI buttons when expanded -
both are 2-state click-to-flip toggles, so they read as one family
of controls rather than two unrelated settings. */}
<div className="px-2 pb-2 flex-shrink-0"> <div className="px-2 pb-2 flex-shrink-0">
{collapsed ? ( {collapsed ? (
<button <div className="flex flex-col gap-1">
onClick={toggleLang} <button
className="w-full h-9 rounded-lg border border-border bg-surface-2 text-gray-300 hover:bg-surface-3 hover:text-gray-100 transition-colors flex flex-col items-center justify-center gap-0.5" onClick={toggleLang}
title={switchLanguageTitle} className="w-full h-9 rounded-lg border border-border bg-surface-2 text-fg-secondary hover:bg-surface-3 hover:text-fg-primary transition-colors flex flex-col items-center justify-center gap-0.5"
aria-label={switchLanguageTitle} title={switchLanguageTitle}
> aria-label={switchLanguageTitle}
<Languages className="w-3.5 h-3.5" /> >
<span className="text-[10px] font-semibold leading-none"> <Languages className="w-3.5 h-3.5" />
{t(`nav:languageShort.${currentLanguage}`)} <span className="text-[10px] font-semibold leading-none">
</span> {t(`nav:languageShort.${currentLanguage}`)}
</button> </span>
</button>
<button
onClick={toggleTheme}
className="w-full h-9 rounded-lg border border-border bg-surface-2 text-fg-secondary hover:bg-surface-3 hover:text-fg-primary transition-colors flex flex-col items-center justify-center gap-0.5"
title={switchThemeTitle}
aria-label={switchThemeTitle}
>
{theme === "dark" ? (
<Moon className="w-3.5 h-3.5" />
) : (
<Sun className="w-3.5 h-3.5" />
)}
<span className="text-[10px] font-semibold leading-none">
{t(`nav:themeNames.${theme}`).slice(0, 1)}
</span>
</button>
</div>
) : ( ) : (
<div className="rounded-lg border border-border bg-surface-2 p-2"> <div className="rounded-lg border border-border bg-surface-2 p-2 space-y-2">
<p className="px-1 text-[10px] font-semibold uppercase tracking-wider text-gray-500"> <div className="flex items-center justify-between gap-2">
{t("nav:language")} <div className="flex-1">
</p> <p className="px-1 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
<div className="mt-2 grid grid-cols-4 gap-1"> {t("nav:language")}
{SUPPORTED_LANGUAGES.map((language) => { </p>
const active = language === currentLanguage; <div className="mt-2 grid grid-cols-4 gap-1">
return ( {SUPPORTED_LANGUAGES.map((language) => {
<button const active = language === currentLanguage;
key={language} return (
onClick={() => changeLanguage(language)} <button
aria-pressed={active} key={language}
aria-label={t(`nav:languageNames.${language}`)} onClick={() => changeLanguage(language)}
title={t(`nav:languageNames.${language}`)} aria-pressed={active}
className={`rounded-md px-2 py-1.5 text-[11px] font-semibold transition-colors ${ aria-label={t(`nav:languageNames.${language}`)}
active title={t(`nav:languageNames.${language}`)}
? "bg-accent/20 text-accent border border-accent/30" className={`rounded-md px-2 py-1.5 text-[11px] font-semibold transition-colors ${
: "bg-surface-1 text-gray-400 border border-border hover:bg-surface-3 hover:text-gray-200" active
}`} ? "bg-accent/20 text-accent border border-accent/30"
> : "bg-surface-1 text-fg-secondary border border-border hover:bg-surface-3 hover:text-fg-primary"
{t(`nav:languageShort.${language}`)} }`}
</button> >
); {t(`nav:languageShort.${language}`)}
})} </button>
);
})}
</div>
</div>
<div>
<p className="px-1 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
{t("nav:theme")}
</p>
<div className="mt-2 grid grid-cols-2 gap-1">
{(["dark", "light"] as const).map((option) => {
const active = option === theme;
return (
<button
key={option}
onClick={() => setTheme(option)}
aria-pressed={active}
aria-label={t(`nav:themeNames.${option}`)}
title={t(`nav:themeNames.${option}`)}
className={`rounded-md px-2 py-1.5 text-[11px] font-semibold transition-colors flex items-center justify-center ${
active
? "bg-accent/20 text-accent border border-accent/30"
: "bg-surface-1 text-fg-secondary border border-border hover:bg-surface-3 hover:text-fg-primary"
}`}
>
{option === "dark" ? (
<Moon className="w-3.5 h-3.5" />
) : (
<Sun className="w-3.5 h-3.5" />
)}
</button>
);
})}
</div>
</div>
</div> </div>
</div> </div>
)} )}
@@ -512,8 +572,8 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
onClick={onToggle} onClick={onToggle}
className={`w-full h-10 rounded-lg border border-border bg-surface-2 transition-colors ${ className={`w-full h-10 rounded-lg border border-border bg-surface-2 transition-colors ${
collapsed collapsed
? "flex items-center justify-center text-gray-400 hover:text-gray-200 hover:bg-surface-3" ? "flex items-center justify-center text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
: "flex items-center gap-2.5 px-3 text-gray-300 hover:text-gray-100 hover:bg-surface-3" : "flex items-center gap-2.5 px-3 text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
}`} }`}
title={collapsed ? t("nav:expand") : t("nav:collapse")} title={collapsed ? t("nav:expand") : t("nav:collapse")}
aria-label={collapsed ? t("nav:expand") : t("nav:collapse")} aria-label={collapsed ? t("nav:expand") : t("nav:collapse")}
@@ -551,7 +611,7 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
> >
<span <span
className={`inline-flex items-center gap-2 ${ className={`inline-flex items-center gap-2 ${
wsConnected ? "text-emerald-400" : "text-gray-500" wsConnected ? "text-status-success" : "text-fg-muted"
}`} }`}
> >
{wsConnected ? ( {wsConnected ? (
@@ -566,7 +626,7 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
)} )}
</span> </span>
{!collapsed && ( {!collapsed && (
<span className="text-[11px] font-medium text-gray-600">{`v${__APP_VERSION__}`}</span> <span className="text-[11px] font-medium text-fg-muted">{`v${__APP_VERSION__}`}</span>
)} )}
</div> </div>
</button> </button>
@@ -579,15 +639,15 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
aria-label={checkTitle} aria-label={checkTitle}
className={`relative w-8 h-8 mx-auto flex items-center justify-center rounded-lg border bg-surface-2 transition-colors disabled:opacity-60 ${ className={`relative w-8 h-8 mx-auto flex items-center justify-center rounded-lg border bg-surface-2 transition-colors disabled:opacity-60 ${
updateAvailable updateAvailable
? "border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10" ? "border-status-success/40 text-status-success hover:bg-status-success/10"
: checkError : checkError
? "border-amber-500/40 text-amber-300 hover:bg-amber-500/10" ? "border-status-warning/40 text-status-warning hover:bg-status-warning/10"
: "border-border text-gray-400 hover:text-gray-200 hover:bg-surface-3" : "border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
}`} }`}
> >
<RefreshCw className={`w-3.5 h-3.5 ${checking ? "animate-spin" : ""}`} aria-hidden /> <RefreshCw className={`w-3.5 h-3.5 ${checking ? "animate-spin" : ""}`} aria-hidden />
{updateAvailable && !checking && ( {updateAvailable && !checking && (
<span className="absolute top-1 right-1 w-1.5 h-1.5 rounded-full bg-emerald-400" /> <span className="absolute top-1 right-1 w-1.5 h-1.5 rounded-full bg-status-success" />
)} )}
</button> </button>
) : ( ) : (
@@ -598,10 +658,10 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
title={checkTitle} title={checkTitle}
className={`w-full rounded-lg border bg-surface-2 px-2.5 py-2 text-xs transition-colors disabled:opacity-60 flex items-center justify-between gap-2 ${ className={`w-full rounded-lg border bg-surface-2 px-2.5 py-2 text-xs transition-colors disabled:opacity-60 flex items-center justify-between gap-2 ${
updateAvailable updateAvailable
? "border-emerald-500/40 text-emerald-300 hover:bg-emerald-500/10" ? "border-status-success/40 text-status-success hover:bg-status-success/10"
: checkError : checkError
? "border-amber-500/40 text-amber-300 hover:bg-amber-500/10" ? "border-status-warning/40 text-status-warning hover:bg-status-warning/10"
: "border-border text-gray-300 hover:text-gray-100 hover:bg-surface-3" : "border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
}`} }`}
> >
<span className="inline-flex items-center gap-2 truncate"> <span className="inline-flex items-center gap-2 truncate">
@@ -612,7 +672,7 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
<span className="font-medium truncate">{checkTitle}</span> <span className="font-medium truncate">{checkTitle}</span>
</span> </span>
{updateAvailable && !checking && ( {updateAvailable && !checking && (
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 flex-shrink-0" /> <span className="w-1.5 h-1.5 rounded-full bg-status-success flex-shrink-0" />
)} )}
</button> </button>
)} )}
@@ -741,8 +801,8 @@ function ConnectionStatusModal({
<div <div
className={`w-9 h-9 rounded-lg border flex items-center justify-center flex-shrink-0 ${ className={`w-9 h-9 rounded-lg border flex items-center justify-center flex-shrink-0 ${
wsConnected wsConnected
? "bg-emerald-500/10 border-emerald-500/30 text-emerald-400" ? "bg-status-success/10 border-status-success/30 text-status-success"
: "bg-surface-3 border-border text-gray-400" : "bg-surface-3 border-border text-fg-secondary"
}`} }`}
> >
{wsConnected ? ( {wsConnected ? (
@@ -754,19 +814,19 @@ function ConnectionStatusModal({
<div className="min-w-0"> <div className="min-w-0">
<h2 <h2
id="connection-status-title" id="connection-status-title"
className="text-base font-semibold text-gray-50 truncate tracking-tight leading-tight" className="text-base font-semibold text-fg-primary truncate tracking-tight leading-tight"
> >
{t("nav:connectionDetails")} {t("nav:connectionDetails")}
</h2> </h2>
<p <p
className={`text-[11px] font-medium inline-flex items-center gap-1.5 leading-tight ${ className={`text-[11px] font-medium inline-flex items-center gap-1.5 leading-tight ${
wsConnected ? "text-emerald-400" : "text-gray-500" wsConnected ? "text-status-success" : "text-fg-muted"
}`} }`}
> >
{wsConnected && ( {wsConnected && (
<span className="relative flex w-1.5 h-1.5"> <span className="relative flex w-1.5 h-1.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75" /> <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-status-success opacity-75" />
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-emerald-400" /> <span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-status-success" />
</span> </span>
)} )}
{wsConnected ? t("nav:live") : t("nav:disconnected")} {wsConnected ? t("nav:live") : t("nav:disconnected")}
@@ -777,7 +837,7 @@ function ConnectionStatusModal({
type="button" type="button"
onClick={close} onClick={close}
aria-label={t("nav:close")} aria-label={t("nav:close")}
className="p-1.5 -m-1 rounded-lg text-gray-500 hover:text-gray-200 hover:bg-surface-4 transition-colors flex-shrink-0" className="p-1.5 -m-1 rounded-lg text-fg-muted hover:text-fg-secondary hover:bg-surface-4 transition-colors flex-shrink-0"
> >
<X className="w-4 h-4" /> <X className="w-4 h-4" />
</button> </button>
@@ -835,7 +895,7 @@ function ConnectionStatusModal({
{/* Top event types */} {/* Top event types */}
<Section title={t("nav:topEventTypes")} icon={BarChart3}> <Section title={t("nav:topEventTypes")} icon={BarChart3}>
{topTypes.length === 0 ? ( {topTypes.length === 0 ? (
<p className="text-xs text-gray-500 italic">{t("nav:noEventsYet")}</p> <p className="text-xs text-fg-muted italic">{t("nav:noEventsYet")}</p>
) : ( ) : (
<div className="space-y-1.5"> <div className="space-y-1.5">
{topTypes.map(([type, count]) => ( {topTypes.map(([type, count]) => (
@@ -848,7 +908,7 @@ function ConnectionStatusModal({
{/* Recent activity */} {/* Recent activity */}
<Section title={t("nav:recentActivity")} icon={Clock}> <Section title={t("nav:recentActivity")} icon={Clock}>
{recentEvents.length === 0 ? ( {recentEvents.length === 0 ? (
<p className="text-xs text-gray-500 italic">{t("nav:noEventsYet")}</p> <p className="text-xs text-fg-muted italic">{t("nav:noEventsYet")}</p>
) : ( ) : (
<ul className="space-y-1"> <ul className="space-y-1">
{recentEvents.map((evt, i) => ( {recentEvents.map((evt, i) => (
@@ -856,8 +916,8 @@ function ConnectionStatusModal({
key={`${evt.at}-${i}`} key={`${evt.at}-${i}`}
className="flex items-center justify-between gap-3 text-[11px] font-mono px-2 py-1 rounded bg-surface-2/50" className="flex items-center justify-between gap-3 text-[11px] font-mono px-2 py-1 rounded bg-surface-2/50"
> >
<span className="text-gray-200 truncate">{evt.type}</span> <span className="text-fg-secondary truncate">{evt.type}</span>
<span className="text-gray-500 flex-shrink-0">{formatRelative(evt.at, t)}</span> <span className="text-fg-muted flex-shrink-0">{formatRelative(evt.at, t)}</span>
</li> </li>
))} ))}
</ul> </ul>
@@ -866,11 +926,11 @@ function ConnectionStatusModal({
</div> </div>
<div className="flex items-center justify-between gap-2 px-5 py-3 border-t border-border bg-surface-2/40"> <div className="flex items-center justify-between gap-2 px-5 py-3 border-t border-border bg-surface-2/40">
<span className="text-[10px] text-gray-500">{t("nav:statsPersisted")}</span> <span className="text-[10px] text-fg-muted">{t("nav:statsPersisted")}</span>
<button <button
type="button" type="button"
onClick={onResetStats} onClick={onResetStats}
className="text-[11px] font-medium text-gray-400 hover:text-gray-100 hover:bg-surface-3 px-2 py-1 rounded transition-colors" className="text-[11px] font-medium text-fg-secondary hover:text-fg-primary hover:bg-surface-3 px-2 py-1 rounded transition-colors"
> >
{t("nav:resetStats")} {t("nav:resetStats")}
</button> </button>
@@ -896,7 +956,7 @@ function Section({
<span className="w-5 h-5 rounded-md bg-accent/15 border border-accent/25 flex items-center justify-center flex-shrink-0"> <span className="w-5 h-5 rounded-md bg-accent/15 border border-accent/25 flex items-center justify-center flex-shrink-0">
<Icon className="w-3 h-3 text-accent" aria-hidden /> <Icon className="w-3 h-3 text-accent" aria-hidden />
</span> </span>
<h3 className="text-[13px] font-semibold text-gray-100 tracking-tight">{title}</h3> <h3 className="text-[13px] font-semibold text-fg-primary tracking-tight">{title}</h3>
</div> </div>
{children} {children}
</section> </section>
@@ -906,12 +966,12 @@ function Section({
function KpiTile({ label, value, unit }: { label: string; value: string; unit: string }) { function KpiTile({ label, value, unit }: { label: string; value: string; unit: string }) {
return ( return (
<div className="rounded-lg border border-border bg-surface-2 px-2.5 py-2"> <div className="rounded-lg border border-border bg-surface-2 px-2.5 py-2">
<div className="text-[9px] font-semibold uppercase tracking-wider text-gray-500 truncate"> <div className="text-[9px] font-semibold uppercase tracking-wider text-fg-muted truncate">
{label} {label}
</div> </div>
<div className="mt-0.5 flex items-baseline gap-1 truncate"> <div className="mt-0.5 flex items-baseline gap-1 truncate">
<span className="text-base font-semibold text-gray-100 font-mono">{value}</span> <span className="text-base font-semibold text-fg-primary font-mono">{value}</span>
<span className="text-[10px] font-medium text-gray-500 truncate">{unit}</span> <span className="text-[10px] font-medium text-fg-muted truncate">{unit}</span>
</div> </div>
</div> </div>
); );
@@ -920,10 +980,10 @@ function KpiTile({ label, value, unit }: { label: string; value: string; unit: s
function DetailRow({ label, value, mono }: { label: string; value: string; mono?: boolean }) { function DetailRow({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return ( return (
<div className="flex items-start justify-between gap-3 text-xs"> <div className="flex items-start justify-between gap-3 text-xs">
<span className="text-gray-500 font-medium uppercase tracking-wider text-[10px] pt-0.5"> <span className="text-fg-muted font-medium uppercase tracking-wider text-[10px] pt-0.5">
{label} {label}
</span> </span>
<span className={`text-gray-200 text-right break-all min-w-0 ${mono ? "font-mono" : ""}`}> <span className={`text-fg-secondary text-right break-all min-w-0 ${mono ? "font-mono" : ""}`}>
{value} {value}
</span> </span>
</div> </div>
@@ -946,8 +1006,8 @@ function TypeBar({
return ( return (
<div className="text-[11px]"> <div className="text-[11px]">
<div className="flex items-center justify-between gap-2 mb-0.5"> <div className="flex items-center justify-between gap-2 mb-0.5">
<span className="font-mono text-gray-200 truncate">{type}</span> <span className="font-mono text-fg-secondary truncate">{type}</span>
<span className="text-gray-500 flex-shrink-0 font-mono"> <span className="text-fg-muted flex-shrink-0 font-mono">
{count} · {sharePct}% {count} · {sharePct}%
</span> </span>
</div> </div>
@@ -1005,7 +1065,7 @@ function Sparkline({
/> />
)} )}
</svg> </svg>
<div className="flex items-center justify-between mt-1.5 text-[10px] text-gray-500 font-mono"> <div className="flex items-center justify-between mt-1.5 text-[10px] text-fg-muted font-mono">
<span>60s</span> <span>60s</span>
<span>{avgLabel}</span> <span>{avgLabel}</span>
<span>{"now"}</span> <span>{"now"}</span>
+3 -3
View File
@@ -98,7 +98,7 @@ export function StatCard({
return ( return (
<div className="card p-5"> <div className="card p-5">
<div className="flex items-center justify-between gap-3 mb-3"> <div className="flex items-center justify-between gap-3 mb-3">
<span className="text-xs font-medium text-gray-500 uppercase tracking-wider truncate"> <span className="text-xs font-medium text-fg-muted uppercase tracking-wider truncate">
{label} {label}
</span> </span>
<Icon className={`w-5 h-5 flex-shrink-0 ${accentColor}`} /> <Icon className={`w-5 h-5 flex-shrink-0 ${accentColor}`} />
@@ -108,11 +108,11 @@ export function StatCard({
<StatValueSkeleton /> <StatValueSkeleton />
) : ( ) : (
<Tip raw={raw}> <Tip raw={raw}>
<span className="text-2xl font-semibold text-gray-100 truncate">{value}</span> <span className="text-2xl font-semibold text-fg-primary truncate">{value}</span>
</Tip> </Tip>
)} )}
{!loading && trend && ( {!loading && trend && (
<span className="text-xs text-gray-500 mb-1 flex-shrink-0">{trend}</span> <span className="text-xs text-fg-muted mb-1 flex-shrink-0">{trend}</span>
)} )}
</div> </div>
</div> </div>
+1 -1
View File
@@ -96,7 +96,7 @@ function ReasonChip({ reason }: { reason: AwaitingReason }) {
<span <span
className={`inline-flex items-center gap-1 rounded-full border px-1.5 text-[10px] font-medium leading-4 ${ className={`inline-flex items-center gap-1 rounded-full border px-1.5 text-[10px] font-medium leading-4 ${
cfg.urgent cfg.urgent
? "bg-amber-500/15 border-amber-500/25 text-amber-300" ? "bg-status-warning/15 border-status-warning/25 text-status-warning"
: "bg-yellow-500/10 border-yellow-500/20 text-yellow-400/90" : "bg-yellow-500/10 border-yellow-500/20 text-yellow-400/90"
}`} }`}
> >
+21 -15
View File
@@ -113,15 +113,17 @@ export function TabbyPanel({
<span className="text-base leading-none" aria-hidden> <span className="text-base leading-none" aria-hidden>
🐾 🐾
</span> </span>
<span className="text-sm font-semibold text-gray-100">Tabby</span> <span className="text-sm font-semibold text-fg-primary">Tabby</span>
<span <span
className={`ml-0.5 inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[10px] font-medium ${ className={`ml-0.5 inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[10px] font-medium ${
status.connected ? "bg-emerald-500/15 text-emerald-300" : "bg-red-500/15 text-red-300" status.connected
? "bg-status-success/15 text-status-success"
: "bg-status-danger/15 text-status-danger"
}`} }`}
> >
<span <span
className={`inline-block h-1.5 w-1.5 rounded-full ${ className={`inline-block h-1.5 w-1.5 rounded-full ${
status.connected ? "bg-emerald-400" : "bg-red-500" status.connected ? "bg-status-success" : "bg-status-danger"
}`} }`}
aria-hidden aria-hidden
/> />
@@ -129,7 +131,7 @@ export function TabbyPanel({
</span> </span>
</div> </div>
<button <button
className="rounded-md p-1 text-gray-500 transition-colors hover:bg-surface-4 hover:text-gray-200" className="rounded-md p-1 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-secondary"
onClick={onClose} onClick={onClose}
aria-label="Close Tabby" aria-label="Close Tabby"
> >
@@ -191,7 +193,7 @@ export function TabbyPanel({
{/* ask */} {/* ask */}
<form onSubmit={submit} className="flex items-center gap-1.5"> <form onSubmit={submit} className="flex items-center gap-1.5">
<input <input
className="flex-1 rounded-lg border border-border bg-surface-1 px-2.5 py-1.5 text-xs text-gray-200 placeholder-gray-500 transition-colors focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/30" className="flex-1 rounded-lg border border-border bg-surface-1 px-2.5 py-1.5 text-xs text-fg-secondary placeholder-fg-muted transition-colors focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/30"
placeholder="Ask Tabby… (e.g. any errors?)" placeholder="Ask Tabby… (e.g. any errors?)"
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
@@ -206,7 +208,7 @@ export function TabbyPanel({
</button> </button>
</form> </form>
{answer && ( {answer && (
<p className="mt-2 rounded-lg bg-surface-1/70 px-2.5 py-2 text-xs leading-relaxed text-gray-300"> <p className="mt-2 rounded-lg bg-surface-1/70 px-2.5 py-2 text-xs leading-relaxed text-fg-secondary">
{answer} {answer}
</p> </p>
)} )}
@@ -223,18 +225,22 @@ interface Tone {
const TONE_MUTED: Tone = { const TONE_MUTED: Tone = {
wrap: "border-border bg-surface-1", wrap: "border-border bg-surface-1",
value: "text-gray-300", value: "text-fg-secondary",
icon: "text-gray-500", icon: "text-fg-muted",
}; };
const TONES: Record<string, Tone> = { const TONES: Record<string, Tone> = {
accent: { wrap: "border-accent/30 bg-accent/10", value: "text-gray-100", icon: "text-accent" }, accent: { wrap: "border-accent/30 bg-accent/10", value: "text-fg-primary", icon: "text-accent" },
amber: { amber: {
wrap: "border-amber-500/30 bg-amber-500/10", wrap: "border-status-warning/30 bg-status-warning/10",
value: "text-amber-200", value: "text-status-warning",
icon: "text-amber-400", icon: "text-status-warning",
},
red: {
wrap: "border-status-danger/30 bg-status-danger/10",
value: "text-status-danger",
icon: "text-status-danger",
}, },
red: { wrap: "border-red-500/30 bg-red-500/10", value: "text-red-200", icon: "text-red-400" },
muted: TONE_MUTED, muted: TONE_MUTED,
}; };
@@ -256,7 +262,7 @@ function StatChip({
<span className={`text-base font-semibold leading-none tabular-nums ${t.value}`}> <span className={`text-base font-semibold leading-none tabular-nums ${t.value}`}>
{value} {value}
</span> </span>
<span className="text-[9px] uppercase tracking-wider text-gray-500">{label}</span> <span className="text-[9px] uppercase tracking-wider text-fg-muted">{label}</span>
</div> </div>
); );
} }
@@ -274,7 +280,7 @@ function ActionButton({
}) { }) {
return ( return (
<button <button
className="flex items-center gap-1.5 rounded-lg bg-surface-1 px-2 py-1.5 text-xs text-gray-300 transition-colors hover:bg-surface-4 hover:text-gray-100 disabled:cursor-not-allowed disabled:opacity-40" className="flex items-center gap-1.5 rounded-lg bg-surface-1 px-2 py-1.5 text-xs text-fg-secondary transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
onClick={onClick} onClick={onClick}
disabled={disabled} disabled={disabled}
> >
+1 -1
View File
@@ -144,7 +144,7 @@ export function Tip({ raw, children, maxWidth = 320, block = false }: TipProps)
<div <div
ref={tipRef} ref={tipRef}
style={tipStyle} style={tipStyle}
className="px-2.5 py-1.5 text-[11px] leading-relaxed font-mono text-gray-100 bg-[#12121f] border border-[#2a2a4a] rounded-lg shadow-xl pointer-events-none whitespace-pre-wrap break-words" className="px-2.5 py-1.5 text-[11px] leading-relaxed font-mono text-fg-primary bg-[#12121f] border border-[#2a2a4a] rounded-lg shadow-xl pointer-events-none whitespace-pre-wrap break-words"
> >
{raw} {raw}
</div>, </div>,
+10 -10
View File
@@ -213,11 +213,11 @@ export function UpdateNotifier() {
<div className="min-w-0"> <div className="min-w-0">
<h2 <h2
id="update-notifier-title" id="update-notifier-title"
className="text-sm font-semibold text-gray-100 truncate" className="text-sm font-semibold text-fg-primary truncate"
> >
{t("title")} {t("title")}
</h2> </h2>
<p className="text-[11px] text-gray-500 mt-0.5 font-mono truncate"> <p className="text-[11px] text-fg-muted mt-0.5 font-mono truncate">
{t("commitsBehind", { count: behind, ref: refLabel })} {t("commitsBehind", { count: behind, ref: refLabel })}
</p> </p>
</div> </div>
@@ -226,7 +226,7 @@ export function UpdateNotifier() {
type="button" type="button"
onClick={dismiss} onClick={dismiss}
aria-label={t("dismiss")} aria-label={t("dismiss")}
className="p-1.5 -m-1 rounded-lg text-gray-500 hover:text-gray-200 hover:bg-surface-4 transition-colors flex-shrink-0" className="p-1.5 -m-1 rounded-lg text-fg-muted hover:text-fg-secondary hover:bg-surface-4 transition-colors flex-shrink-0"
> >
<X className="w-4 h-4" /> <X className="w-4 h-4" />
</button> </button>
@@ -234,29 +234,29 @@ export function UpdateNotifier() {
{/* Body */} {/* Body */}
<div className="px-5 py-4 space-y-3"> <div className="px-5 py-4 space-y-3">
<p className="text-sm text-gray-300 leading-relaxed">{t("lead")}</p> <p className="text-sm text-fg-secondary leading-relaxed">{t("lead")}</p>
{status.fetch_error ? ( {status.fetch_error ? (
<div className="text-xs text-amber-300/90 bg-amber-500/5 border border-amber-500/20 rounded-lg px-3 py-2"> <div className="text-xs text-status-warning/90 bg-status-warning/5 border border-status-warning/20 rounded-lg px-3 py-2">
{t("fetchError")} {t("fetchError")}
</div> </div>
) : null} ) : null}
{!status.git_repo ? ( {!status.git_repo ? (
<div className="text-xs text-gray-400 bg-surface-2 border border-border rounded-lg px-3 py-2"> <div className="text-xs text-fg-secondary bg-surface-2 border border-border rounded-lg px-3 py-2">
{t("notGit")} {t("notGit")}
</div> </div>
) : null} ) : null}
{status.situation_note ? ( {status.situation_note ? (
<div className="text-xs text-amber-200/90 bg-amber-500/5 border border-amber-500/20 rounded-lg px-3 py-2 leading-relaxed"> <div className="text-xs text-status-warning/90 bg-status-warning/5 border border-status-warning/20 rounded-lg px-3 py-2 leading-relaxed">
{status.situation_note} {status.situation_note}
</div> </div>
) : null} ) : null}
{status.manual_command ? ( {status.manual_command ? (
<pre <pre
className="bg-surface-1 border border-border rounded-lg px-3 py-2.5 text-[11px] font-mono text-gray-200 whitespace-pre-wrap break-all leading-relaxed" className="bg-surface-1 border border-border rounded-lg px-3 py-2.5 text-[11px] font-mono text-fg-secondary whitespace-pre-wrap break-all leading-relaxed"
aria-label={t("commandLabel")} aria-label={t("commandLabel")}
> >
{status.manual_command} {status.manual_command}
@@ -268,11 +268,11 @@ export function UpdateNotifier() {
* are fetch-only - restarting the dashboard would change nothing. */} * are fetch-only - restarting the dashboard would change nothing. */}
{status.situation === "tracking_canonical" || {status.situation === "tracking_canonical" ||
status.situation === "fork_or_diverged_tracking" ? ( status.situation === "fork_or_diverged_tracking" ? (
<p className="text-[11px] text-gray-500 leading-relaxed">{t("restartNote")}</p> <p className="text-[11px] text-fg-muted leading-relaxed">{t("restartNote")}</p>
) : null} ) : null}
{error ? ( {error ? (
<p className="text-xs text-red-400" role="alert"> <p className="text-xs text-status-danger" role="alert">
{error} {error}
</p> </p>
) : null} ) : null}
+39 -39
View File
@@ -110,7 +110,7 @@ const TYPE_STYLES: Partial<Record<WebhookType, string>> = {
opsgenie: "text-[#2684FF] bg-[#2684FF]/10 border-[#2684FF]/20", opsgenie: "text-[#2684FF] bg-[#2684FF]/10 border-[#2684FF]/20",
splunk_oncall: "text-[#F99D1C] bg-[#F99D1C]/10 border-[#F99D1C]/20", splunk_oncall: "text-[#F99D1C] bg-[#F99D1C]/10 border-[#F99D1C]/20",
}; };
const NEUTRAL_STYLE = "text-gray-300 bg-surface-2 border-border"; const NEUTRAL_STYLE = "text-fg-secondary bg-surface-2 border-border";
interface HeaderRow { interface HeaderRow {
key: string; key: string;
@@ -155,7 +155,7 @@ function Toggle({
aria-label={label} aria-label={label}
onClick={() => onChange(!checked)} onClick={() => onChange(!checked)}
className={`relative inline-flex h-5 w-9 flex-shrink-0 items-center rounded-full transition-colors ${ className={`relative inline-flex h-5 w-9 flex-shrink-0 items-center rounded-full transition-colors ${
checked ? "bg-blue-500" : "bg-surface-4" checked ? "bg-blue-600" : "bg-surface-4"
}`} }`}
> >
<span <span
@@ -390,7 +390,7 @@ export function WebhookSettings() {
return ( return (
<div className="card p-5 space-y-4"> <div className="card p-5 space-y-4">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 text-xs text-gray-500"> <div className="flex items-center gap-2 text-xs text-fg-muted">
<Webhook className="w-3.5 h-3.5" /> <Webhook className="w-3.5 h-3.5" />
{t("webhooks.count", { count: targets.length })} {t("webhooks.count", { count: targets.length })}
</div> </div>
@@ -408,9 +408,9 @@ export function WebhookSettings() {
{/* Target list */} {/* Target list */}
{loading ? ( {loading ? (
<p className="text-xs text-gray-500">{t("webhooks.loading")}</p> <p className="text-xs text-fg-muted">{t("webhooks.loading")}</p>
) : targets.length === 0 && !formOpen ? ( ) : targets.length === 0 && !formOpen ? (
<div className="flex items-center gap-2 text-xs text-gray-500 py-2"> <div className="flex items-center gap-2 text-xs text-fg-muted py-2">
<Webhook className="w-3.5 h-3.5" /> <Webhook className="w-3.5 h-3.5" />
{t("webhooks.empty")} {t("webhooks.empty")}
</div> </div>
@@ -431,12 +431,12 @@ export function WebhookSettings() {
> >
{labelOf(target.type)} {labelOf(target.type)}
</span> </span>
<span className="text-sm text-gray-200 font-medium">{target.name}</span> <span className="text-sm text-fg-secondary font-medium">{target.name}</span>
<code className="text-[11px] text-gray-500 font-mono truncate max-w-[220px]"> <code className="text-[11px] text-fg-muted font-mono truncate max-w-[220px]">
{target.url_preview} {target.url_preview}
</code> </code>
{target.rule_ids && target.rule_ids.length > 0 && ( {target.rule_ids && target.rule_ids.length > 0 && (
<span className="text-[10px] text-amber-400/80"> <span className="text-[10px] text-status-warning/80">
{t("webhooks.scopedTo", { count: target.rule_ids.length })} {t("webhooks.scopedTo", { count: target.rule_ids.length })}
</span> </span>
)} )}
@@ -446,8 +446,8 @@ export function WebhookSettings() {
title={target.last_delivery.error || undefined} title={target.last_delivery.error || undefined}
className={`inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded-full ${ className={`inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded-full ${
target.last_delivery.status === "success" target.last_delivery.status === "success"
? "text-emerald-400 bg-emerald-500/10" ? "text-status-success bg-status-success/10"
: "text-red-400 bg-red-500/10" : "text-status-danger bg-status-danger/10"
}`} }`}
> >
{target.last_delivery.status === "success" ? ( {target.last_delivery.status === "success" ? (
@@ -470,7 +470,7 @@ export function WebhookSettings() {
<button <button
onClick={() => onTest(target.id)} onClick={() => onTest(target.id)}
disabled={testing === target.id} disabled={testing === target.id}
className="inline-flex items-center gap-1.5 text-[11px] px-2.5 py-1 rounded-md text-gray-400 hover:text-gray-200 hover:bg-surface-4 border border-border transition-colors disabled:opacity-50" className="inline-flex items-center gap-1.5 text-[11px] px-2.5 py-1 rounded-md text-fg-secondary hover:text-fg-primary hover:bg-surface-4 border border-border transition-colors disabled:opacity-50"
> >
{testing === target.id ? ( {testing === target.id ? (
<Loader2 className="w-3 h-3 animate-spin" /> <Loader2 className="w-3 h-3 animate-spin" />
@@ -481,14 +481,14 @@ export function WebhookSettings() {
</button> </button>
<button <button
onClick={() => openEdit(target)} onClick={() => openEdit(target)}
className="inline-flex items-center gap-1.5 text-[11px] px-2.5 py-1 rounded-md text-gray-400 hover:text-gray-200 hover:bg-surface-4 border border-border transition-colors" className="inline-flex items-center gap-1.5 text-[11px] px-2.5 py-1 rounded-md text-fg-secondary hover:text-fg-primary hover:bg-surface-4 border border-border transition-colors"
> >
<Pencil className="w-3 h-3" /> <Pencil className="w-3 h-3" />
{t("webhooks.edit")} {t("webhooks.edit")}
</button> </button>
<button <button
onClick={() => setConfirmDelete(target.id)} onClick={() => setConfirmDelete(target.id)}
className="inline-flex items-center gap-1.5 text-[11px] px-2.5 py-1 rounded-md text-gray-500 hover:text-red-400 hover:bg-red-500/10 border border-border transition-colors" className="inline-flex items-center gap-1.5 text-[11px] px-2.5 py-1 rounded-md text-fg-muted hover:text-status-danger hover:bg-status-danger/10 border border-border transition-colors"
> >
<Trash2 className="w-3 h-3" /> <Trash2 className="w-3 h-3" />
{t("webhooks.delete")} {t("webhooks.delete")}
@@ -496,7 +496,7 @@ export function WebhookSettings() {
{result && ( {result && (
<span <span
className={`inline-flex items-center gap-1 text-[11px] ${ className={`inline-flex items-center gap-1 text-[11px] ${
result.ok ? "text-emerald-400" : "text-red-400" result.ok ? "text-status-success" : "text-status-danger"
}`} }`}
> >
{result.ok ? ( {result.ok ? (
@@ -522,17 +522,17 @@ export function WebhookSettings() {
{formOpen && form && provider && ( {formOpen && form && provider && (
<div className="border border-border rounded-lg p-4 space-y-3 bg-surface-1"> <div className="border border-border rounded-lg p-4 space-y-3 bg-surface-1">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h4 className="text-xs font-semibold text-gray-300 uppercase tracking-wide"> <h4 className="text-xs font-semibold text-fg-secondary uppercase tracking-wide">
{isEdit ? t("webhooks.editTitle") : t("webhooks.addTitle")} {isEdit ? t("webhooks.editTitle") : t("webhooks.addTitle")}
</h4> </h4>
<button onClick={closeForm} className="text-gray-500 hover:text-gray-300"> <button onClick={closeForm} className="text-fg-muted hover:text-fg-secondary">
<X className="w-4 h-4" /> <X className="w-4 h-4" />
</button> </button>
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<label className="block"> <label className="block">
<span className="text-[11px] text-gray-500">{t("webhooks.fieldName")}</span> <span className="text-[11px] text-fg-muted">{t("webhooks.fieldName")}</span>
<input <input
value={form.name} value={form.name}
onChange={(e) => set({ name: e.target.value })} onChange={(e) => set({ name: e.target.value })}
@@ -541,7 +541,7 @@ export function WebhookSettings() {
/> />
</label> </label>
<label className="block"> <label className="block">
<span className="text-[11px] text-gray-500">{t("webhooks.fieldType")}</span> <span className="text-[11px] text-fg-muted">{t("webhooks.fieldType")}</span>
<div className="mt-1"> <div className="mt-1">
<Select<WebhookType> <Select<WebhookType>
value={form.type} value={form.type}
@@ -564,12 +564,12 @@ export function WebhookSettings() {
{/* URL (hidden for providers that derive their own URL) */} {/* URL (hidden for providers that derive their own URL) */}
{showUrl && ( {showUrl && (
<label className="block"> <label className="block">
<span className="text-[11px] text-gray-500"> <span className="text-[11px] text-fg-muted">
{t("webhooks.fieldUrl")} {t("webhooks.fieldUrl")}
{isEdit ? ( {isEdit ? (
<span className="text-gray-600"> - {t("webhooks.urlKeepHint")}</span> <span className="text-fg-muted"> - {t("webhooks.urlKeepHint")}</span>
) : urlOptional ? ( ) : urlOptional ? (
<span className="text-gray-600"> - {t("webhooks.urlOptional")}</span> <span className="text-fg-muted"> - {t("webhooks.urlOptional")}</span>
) : null} ) : null}
</span> </span>
<input <input
@@ -583,7 +583,7 @@ export function WebhookSettings() {
</label> </label>
)} )}
{!showUrl && ( {!showUrl && (
<p className="text-[11px] text-gray-600 flex items-center gap-1.5"> <p className="text-[11px] text-fg-muted flex items-center gap-1.5">
<Webhook className="w-3 h-3" /> <Webhook className="w-3 h-3" />
{t("webhooks.urlAuto")} {t("webhooks.urlAuto")}
</p> </p>
@@ -594,19 +594,19 @@ export function WebhookSettings() {
<button <button
type="button" type="button"
onClick={() => setGuideOpen((o) => !o)} onClick={() => setGuideOpen((o) => !o)}
className="w-full flex items-center justify-between gap-2 px-3 py-2 text-[11px] text-gray-300 hover:bg-surface-3 transition-colors" className="w-full flex items-center justify-between gap-2 px-3 py-2 text-[11px] text-fg-secondary hover:bg-surface-3 transition-colors"
> >
<span className="inline-flex items-center gap-1.5"> <span className="inline-flex items-center gap-1.5">
<BookOpen className="w-3.5 h-3.5 text-gray-500" /> <BookOpen className="w-3.5 h-3.5 text-fg-muted" />
{t("webhooks.guideToggle", { provider: provider.label })} {t("webhooks.guideToggle", { provider: provider.label })}
</span> </span>
<ChevronDown <ChevronDown
className={`w-3.5 h-3.5 text-gray-500 transition-transform ${guideOpen ? "rotate-180" : ""}`} className={`w-3.5 h-3.5 text-fg-muted transition-transform ${guideOpen ? "rotate-180" : ""}`}
/> />
</button> </button>
{guideOpen && ( {guideOpen && (
<div className="px-3 pb-3 pt-2 space-y-2.5 border-t border-border"> <div className="px-3 pb-3 pt-2 space-y-2.5 border-t border-border">
<ol className="list-decimal list-inside space-y-1 text-[11px] leading-relaxed text-gray-400 marker:text-gray-600"> <ol className="list-decimal list-inside space-y-1 text-[11px] leading-relaxed text-fg-secondary marker:text-fg-muted">
{(t(`webhookGuides.${form.type}.steps`, { returnObjects: true }) as string[]).map( {(t(`webhookGuides.${form.type}.steps`, { returnObjects: true }) as string[]).map(
(s, i) => ( (s, i) => (
<li key={i}>{s}</li> <li key={i}>{s}</li>
@@ -624,7 +624,7 @@ export function WebhookSettings() {
{t("webhooks.guideDocs", { provider: provider.label })} {t("webhooks.guideDocs", { provider: provider.label })}
</a> </a>
)} )}
<p className="flex items-start gap-1.5 text-[10px] text-gray-500 leading-relaxed pt-2 border-t border-border"> <p className="flex items-start gap-1.5 text-[10px] text-fg-muted leading-relaxed pt-2 border-t border-border">
<Info className="w-3 h-3 mt-0.5 flex-shrink-0" /> <Info className="w-3 h-3 mt-0.5 flex-shrink-0" />
{t("webhooks.guideStaleNote")} {t("webhooks.guideStaleNote")}
</p> </p>
@@ -637,9 +637,9 @@ export function WebhookSettings() {
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-1 border-t border-border"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-1 border-t border-border">
{provider.fields.map((f) => ( {provider.fields.map((f) => (
<label key={f.key} className="block"> <label key={f.key} className="block">
<span className="text-[11px] text-gray-500"> <span className="text-[11px] text-fg-muted">
{f.label} {f.label}
{f.required && <span className="text-red-400"> *</span>} {f.required && <span className="text-status-danger"> *</span>}
</span> </span>
{f.type === "enum" && f.options ? ( {f.type === "enum" && f.options ? (
<div className="mt-1"> <div className="mt-1">
@@ -667,7 +667,7 @@ export function WebhookSettings() {
{provider.supports_secret && ( {provider.supports_secret && (
<div className="space-y-3 pt-1 border-t border-border"> <div className="space-y-3 pt-1 border-t border-border">
<label className="block"> <label className="block">
<span className="text-[11px] text-gray-500">{t("webhooks.fieldSecret")}</span> <span className="text-[11px] text-fg-muted">{t("webhooks.fieldSecret")}</span>
<input <input
type="password" type="password"
value={form.secret} value={form.secret}
@@ -677,18 +677,18 @@ export function WebhookSettings() {
} }
className="input w-full mt-1 py-1.5 text-[11px] leading-normal font-mono" className="input w-full mt-1 py-1.5 text-[11px] leading-normal font-mono"
/> />
<span className="text-[10px] text-gray-600">{t("webhooks.secretHint")}</span> <span className="text-[10px] text-fg-muted">{t("webhooks.secretHint")}</span>
</label> </label>
<div> <div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-[11px] text-gray-500">{t("webhooks.fieldHeaders")}</span> <span className="text-[11px] text-fg-muted">{t("webhooks.fieldHeaders")}</span>
{isEdit && ( {isEdit && (
<Checkbox <Checkbox
checked={form.replaceHeaders} checked={form.replaceHeaders}
onChange={(v) => set({ replaceHeaders: v })} onChange={(v) => set({ replaceHeaders: v })}
label={t("webhooks.replaceHeaders")} label={t("webhooks.replaceHeaders")}
labelClassName="text-[10px] text-gray-500 group-hover:text-gray-400" labelClassName="text-[10px] text-fg-muted group-hover:text-fg-secondary"
/> />
)} )}
</div> </div>
@@ -724,7 +724,7 @@ export function WebhookSettings() {
onClick={() => onClick={() =>
set({ headerRows: form.headerRows.filter((_, j) => j !== i) }) set({ headerRows: form.headerRows.filter((_, j) => j !== i) })
} }
className="text-gray-600 hover:text-red-400 p-1" className="text-fg-muted hover:text-status-danger p-1"
> >
<X className="w-3.5 h-3.5" /> <X className="w-3.5 h-3.5" />
</button> </button>
@@ -734,7 +734,7 @@ export function WebhookSettings() {
onClick={() => onClick={() =>
set({ headerRows: [...form.headerRows, { key: "", value: "" }] }) set({ headerRows: [...form.headerRows, { key: "", value: "" }] })
} }
className="inline-flex items-center gap-1 text-[10px] text-gray-500 hover:text-gray-300" className="inline-flex items-center gap-1 text-[10px] text-fg-muted hover:text-fg-secondary"
> >
<Plus className="w-3 h-3" /> <Plus className="w-3 h-3" />
{t("webhooks.addHeader")} {t("webhooks.addHeader")}
@@ -752,7 +752,7 @@ export function WebhookSettings() {
checked={form.scopeAll} checked={form.scopeAll}
onChange={(v) => set({ scopeAll: v })} onChange={(v) => set({ scopeAll: v })}
label={t("webhooks.scopeAll")} label={t("webhooks.scopeAll")}
labelClassName="text-[11px] text-gray-400 group-hover:text-gray-300" labelClassName="text-[11px] text-fg-secondary group-hover:text-fg-secondary"
/> />
{!form.scopeAll && ( {!form.scopeAll && (
<div className="mt-2 grid grid-cols-1 sm:grid-cols-2 gap-1"> <div className="mt-2 grid grid-cols-1 sm:grid-cols-2 gap-1">
@@ -768,7 +768,7 @@ export function WebhookSettings() {
}) })
} }
label={rule.name} label={rule.name}
labelClassName="text-[11px] text-gray-400 group-hover:text-gray-300 truncate" labelClassName="text-[11px] text-fg-secondary group-hover:text-fg-secondary truncate"
/> />
))} ))}
</div> </div>
@@ -777,14 +777,14 @@ export function WebhookSettings() {
)} )}
<div className="flex items-center gap-3 pt-1"> <div className="flex items-center gap-3 pt-1">
<label className="inline-flex items-center gap-2 text-[11px] text-gray-400 cursor-pointer"> <label className="inline-flex items-center gap-2 text-[11px] text-fg-secondary cursor-pointer">
<Toggle checked={form.enabled} onChange={(v) => set({ enabled: v })} /> <Toggle checked={form.enabled} onChange={(v) => set({ enabled: v })} />
{t("webhooks.enabledOnSave")} {t("webhooks.enabledOnSave")}
</label> </label>
</div> </div>
{formError && ( {formError && (
<div className="flex items-center gap-1.5 text-[11px] text-red-400"> <div className="flex items-center gap-1.5 text-[11px] text-status-danger">
<AlertTriangle className="w-3.5 h-3.5" /> <AlertTriangle className="w-3.5 h-3.5" />
{formError} {formError}
</div> </div>
@@ -206,7 +206,7 @@ describe("AgentCard", () => {
it("should not render subagent_type when null", () => { it("should not render subagent_type when null", () => {
const { container } = renderCard(<AgentCard agent={makeAgent({ subagent_type: null })} />); const { container } = renderCard(<AgentCard agent={makeAgent({ subagent_type: null })} />);
// Only the name should be in the name container, no subagent type // Only the name should be in the name container, no subagent type
expect(container.querySelectorAll(".text-\\[11px\\].text-gray-500.truncate")).toHaveLength(0); expect(container.querySelectorAll(".text-\\[11px\\].text-fg-muted.truncate")).toHaveLength(0);
}); });
it("should render task when present", () => { it("should render task when present", () => {
@@ -44,10 +44,10 @@ describe("StatCard", () => {
it("should apply custom accent color", () => { it("should apply custom accent color", () => {
const { container } = render( const { container } = render(
<StatCard label="Test" value={0} icon={Activity} accentColor="text-emerald-400" /> <StatCard label="Test" value={0} icon={Activity} accentColor="text-status-success" />
); );
const svg = container.querySelector("svg"); const svg = container.querySelector("svg");
expect(svg?.className?.baseVal ?? svg?.getAttribute("class")).toContain("text-emerald-400"); expect(svg?.className?.baseVal ?? svg?.getAttribute("class")).toContain("text-status-success");
}); });
it("should apply default accent color when not specified", () => { it("should apply default accent color when not specified", () => {
@@ -136,9 +136,9 @@ describe("awaiting-reason suffix", () => {
it("marks urgent reasons with the hotter amber tint", () => { it("marks urgent reasons with the hotter amber tint", () => {
const { container } = render(<AgentStatusBadge status="waiting" reason="notification" />); const { container } = render(<AgentStatusBadge status="waiting" reason="notification" />);
expect(container.querySelector(".text-amber-300")).toBeInTheDocument(); expect(container.querySelector(".text-status-warning")).toBeInTheDocument();
const { container: calm } = render(<AgentStatusBadge status="waiting" reason="stop" />); const { container: calm } = render(<AgentStatusBadge status="waiting" reason="stop" />);
expect(calm.querySelector(".text-amber-300")).not.toBeInTheDocument(); expect(calm.querySelector(".text-status-warning")).not.toBeInTheDocument();
}); });
it("compact mode suppresses the inline chip but keeps the hover tooltip", () => { it("compact mode suppresses the inline chip but keeps the hover tooltip", () => {
@@ -142,20 +142,20 @@ export function CodeBlock({
const palette = const palette =
tone === "danger" tone === "danger"
? { ? {
wrapper: "border-red-500/30 bg-red-500/5", wrapper: "border-status-danger/30 bg-status-danger/5",
chrome: "bg-red-500/10 border-b border-red-500/20", chrome: "bg-status-danger/10 border-b border-status-danger/20",
label: "text-red-300", label: "text-status-danger",
} }
: tone === "success" : tone === "success"
? { ? {
wrapper: "border-emerald-500/30 bg-emerald-500/5", wrapper: "border-status-success/30 bg-status-success/5",
chrome: "bg-emerald-500/10 border-b border-emerald-500/20", chrome: "bg-status-success/10 border-b border-status-success/20",
label: "text-emerald-300", label: "text-status-success",
} }
: { : {
wrapper: "border-surface-3 bg-surface-4/50", wrapper: "border-surface-3 bg-surface-4/50",
chrome: "bg-surface-3/70 border-b border-surface-3", chrome: "bg-surface-3/70 border-b border-surface-3",
label: "text-gray-400", label: "text-fg-secondary",
}; };
const preStyle: React.CSSProperties = {}; const preStyle: React.CSSProperties = {};
@@ -177,7 +177,7 @@ export function CodeBlock({
{/* Filename + lang together when both are set */} {/* Filename + lang together when both are set */}
{filename && !label && ( {filename && !label && (
<span className="text-gray-600 font-mono lowercase">{langDisplay(lang)}</span> <span className="text-fg-muted font-mono lowercase">{langDisplay(lang)}</span>
)} )}
{filename && label && ( {filename && label && (
<span className={`font-mono uppercase tracking-wider ${palette.label}`}>· {label}</span> <span className={`font-mono uppercase tracking-wider ${palette.label}`}>· {label}</span>
@@ -186,7 +186,7 @@ export function CodeBlock({
{/* Right side: line count + copy */} {/* Right side: line count + copy */}
<div className="ml-auto flex items-center gap-3"> <div className="ml-auto flex items-center gap-3">
{totalLines > 1 && ( {totalLines > 1 && (
<span className="text-gray-600 font-mono"> <span className="text-fg-muted font-mono">
{totalLines} {totalLines === 1 ? "line" : "lines"} {totalLines} {totalLines === 1 ? "line" : "lines"}
</span> </span>
)} )}
@@ -194,7 +194,7 @@ export function CodeBlock({
type="button" type="button"
onClick={handleCopy} onClick={handleCopy}
className={`inline-flex items-center gap-1 transition-colors ${ className={`inline-flex items-center gap-1 transition-colors ${
copied ? "text-emerald-300" : "text-gray-500 hover:text-gray-200" copied ? "text-status-success" : "text-fg-muted hover:text-fg-secondary"
}`} }`}
aria-label="Copy code" aria-label="Copy code"
> >
@@ -221,7 +221,7 @@ export function CodeBlock({
{lineTokens.map((line, i) => ( {lineTokens.map((line, i) => (
<tr key={i} className="align-top"> <tr key={i} className="align-top">
<td <td
className="select-none text-right pl-3 pr-3 text-gray-600 font-mono text-[11px] leading-[1.6] sticky left-0 bg-inherit" className="select-none text-right pl-3 pr-3 text-fg-muted font-mono text-[11px] leading-[1.6] sticky left-0 bg-inherit"
style={{ width: "1%", whiteSpace: "nowrap" }} style={{ width: "1%", whiteSpace: "nowrap" }}
> >
{i + 1} {i + 1}
@@ -399,7 +399,7 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
<select <select
value={selectedTranscript || ""} value={selectedTranscript || ""}
onChange={(e) => setSelectedTranscript(e.target.value || null)} onChange={(e) => setSelectedTranscript(e.target.value || null)}
className="appearance-none bg-surface-2 border border-surface-3 rounded-lg px-3 py-1.5 pr-8 text-sm text-gray-300 focus:outline-none focus:border-violet-500/50 hover:border-violet-500/30 cursor-pointer transition-colors" className="appearance-none bg-surface-2 border border-surface-3 rounded-lg px-3 py-1.5 pr-8 text-sm text-fg-secondary focus:outline-none focus:border-violet-500/50 hover:border-violet-500/30 cursor-pointer transition-colors"
> >
{transcripts.map((t) => ( {transcripts.map((t) => (
<option key={t.id} value={t.id}> <option key={t.id} value={t.id}>
@@ -407,10 +407,10 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
</option> </option>
))} ))}
</select> </select>
<ChevronDown className="w-3.5 h-3.5 text-gray-500 absolute right-2.5 top-1/2 -translate-y-1/2 pointer-events-none" /> <ChevronDown className="w-3.5 h-3.5 text-fg-muted absolute right-2.5 top-1/2 -translate-y-1/2 pointer-events-none" />
</div> </div>
)} )}
<span className="inline-flex items-center gap-1.5 text-[11px] text-gray-500 font-mono bg-surface-2 border border-surface-3 rounded-md px-2 py-1"> <span className="inline-flex items-center gap-1.5 text-[11px] text-fg-muted font-mono bg-surface-2 border border-surface-3 rounded-md px-2 py-1">
<MessagesSquare className="w-3 h-3" /> <MessagesSquare className="w-3 h-3" />
{total} message{total !== 1 ? "s" : ""} {total} message{total !== 1 ? "s" : ""}
</span> </span>
@@ -420,7 +420,7 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
disabled={refreshing || loading} disabled={refreshing || loading}
title="Refresh conversation" title="Refresh conversation"
aria-label="Refresh conversation" aria-label="Refresh conversation"
className="inline-flex items-center gap-1.5 text-[11px] text-gray-400 hover:text-gray-200 bg-surface-2 border border-surface-3 hover:border-violet-500/30 rounded-md px-2 py-1 transition-colors disabled:opacity-50 disabled:cursor-not-allowed" className="inline-flex items-center gap-1.5 text-[11px] text-fg-secondary hover:text-fg-primary bg-surface-2 border border-surface-3 hover:border-violet-500/30 rounded-md px-2 py-1 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
> >
<RefreshCw className={`w-3 h-3 ${refreshing ? "animate-spin" : ""}`} /> <RefreshCw className={`w-3 h-3 ${refreshing ? "animate-spin" : ""}`} />
Refresh Refresh
@@ -430,7 +430,7 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
{/* Error alert */} {/* Error alert */}
{error && ( {error && (
<div className="text-sm text-red-400 bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3 flex-shrink-0"> <div className="text-sm text-status-danger bg-status-danger/10 border border-status-danger/20 rounded-lg px-4 py-3 flex-shrink-0">
{error} {error}
</div> </div>
)} )}
@@ -445,31 +445,31 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
{/* History loading indicator */} {/* History loading indicator */}
{loadingHistory && ( {loadingHistory && (
<div className="flex justify-center py-3"> <div className="flex justify-center py-3">
<Loader2 className="w-4 h-4 text-gray-500 animate-spin" /> <Loader2 className="w-4 h-4 text-fg-muted animate-spin" />
<span className="text-xs text-gray-500 ml-2">Loading history...</span> <span className="text-xs text-fg-muted ml-2">Loading history...</span>
</div> </div>
)} )}
{/* Scroll-up for history hint */} {/* Scroll-up for history hint */}
{hasMore && !loadingHistory && !loading && ( {hasMore && !loadingHistory && !loading && (
<div className="flex justify-center py-2"> <div className="flex justify-center py-2">
<span className="text-[11px] text-gray-600"> Scroll up for older messages</span> <span className="text-[11px] text-fg-muted"> Scroll up for older messages</span>
</div> </div>
)} )}
{loading ? ( {loading ? (
<div className="flex items-center justify-center py-12 text-gray-500 text-sm"> <div className="flex items-center justify-center py-12 text-fg-muted text-sm">
Loading conversation... Loading conversation...
</div> </div>
) : messages.length === 0 ? ( ) : messages.length === 0 ? (
<div className="mx-auto max-w-md py-12 text-center"> <div className="mx-auto max-w-md py-12 text-center">
<p className="text-sm text-gray-400">No conversation records found.</p> <p className="text-sm text-fg-secondary">No conversation records found.</p>
<p className="mt-2 text-xs leading-relaxed text-gray-500"> <p className="mt-2 text-xs leading-relaxed text-fg-muted">
This session's metadata was imported, but its transcript file is no longer on disk. This session's metadata was imported, but its transcript file is no longer on disk.
Claude Code automatically deletes inactive session transcripts after a retention Claude Code automatically deletes inactive session transcripts after a retention
period (<code className="text-gray-400">cleanupPeriodDays</code>, default 30 days), so period (<code className="text-fg-secondary">cleanupPeriodDays</code>, default 30
older conversations may already be gone. Sessions imported from now on are snapshotted days), so older conversations may already be gone. Sessions imported from now on are
and kept even after Claude Code prunes the originals. snapshotted and kept even after Claude Code prunes the originals.
</p> </p>
</div> </div>
) : ( ) : (
@@ -274,7 +274,7 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
const codeM = rest.match(/^`([^`\n]+)`/); const codeM = rest.match(/^`([^`\n]+)`/);
if (codeM) { if (codeM) {
push( push(
<code className="rounded bg-surface-4 border border-surface-3 px-1.5 py-0.5 font-mono text-[12.5px] text-amber-200"> <code className="rounded bg-surface-4 border border-surface-3 px-1.5 py-0.5 font-mono text-[12.5px] text-status-warning">
{codeM[1]} {codeM[1]}
</code> </code>
); );
@@ -286,7 +286,7 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
const boldM = rest.match(/^(\*\*|__)(.+?)\1/); const boldM = rest.match(/^(\*\*|__)(.+?)\1/);
if (boldM) { if (boldM) {
push( push(
<strong className="font-semibold text-gray-50"> <strong className="font-semibold text-fg-primary">
{renderInline(boldM[2]!, `${baseKey}-b${n}`)} {renderInline(boldM[2]!, `${baseKey}-b${n}`)}
</strong> </strong>
); );
@@ -298,7 +298,9 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
const italicM = rest.match(/^(\*|_)([^*_\n]+?)\1/); const italicM = rest.match(/^(\*|_)([^*_\n]+?)\1/);
if (italicM) { if (italicM) {
push( push(
<em className="italic text-gray-200">{renderInline(italicM[2]!, `${baseKey}-i${n}`)}</em> <em className="italic text-fg-secondary">
{renderInline(italicM[2]!, `${baseKey}-i${n}`)}
</em>
); );
i += italicM[0].length; i += italicM[0].length;
continue; continue;
@@ -308,7 +310,7 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
const strikeM = rest.match(/^~~(.+?)~~/); const strikeM = rest.match(/^~~(.+?)~~/);
if (strikeM) { if (strikeM) {
push( push(
<span className="line-through text-gray-500"> <span className="line-through text-fg-muted">
{renderInline(strikeM[1]!, `${baseKey}-s${n}`)} {renderInline(strikeM[1]!, `${baseKey}-s${n}`)}
</span> </span>
); );
@@ -366,11 +368,13 @@ function renderListItem(item: string, key: string): React.ReactNode {
<span className="inline-flex items-baseline gap-2"> <span className="inline-flex items-baseline gap-2">
<span <span
className={`inline-block w-3 h-3 rounded-sm border flex-shrink-0 translate-y-0.5 ${ className={`inline-block w-3 h-3 rounded-sm border flex-shrink-0 translate-y-0.5 ${
checked ? "bg-emerald-500/40 border-emerald-400/60" : "bg-surface-4 border-surface-3" checked
? "bg-status-success/40 border-status-success/60"
: "bg-surface-4 border-surface-3"
}`} }`}
aria-hidden="true" aria-hidden="true"
/> />
<span className={checked ? "text-gray-500 line-through" : ""}> <span className={checked ? "text-fg-muted line-through" : ""}>
{renderInline(taskMatch[2]!, key)} {renderInline(taskMatch[2]!, key)}
</span> </span>
</span> </span>
@@ -386,12 +390,12 @@ interface MarkdownContentProps {
} }
const HEADING_STYLES = [ const HEADING_STYLES = [
"text-[18px] font-semibold text-gray-50 mt-2 pb-1 border-b border-surface-3", "text-[18px] font-semibold text-fg-primary mt-2 pb-1 border-b border-surface-3",
"text-[16px] font-semibold text-gray-50 mt-2", "text-[16px] font-semibold text-fg-primary mt-2",
"text-[15px] font-semibold text-gray-100", "text-[15px] font-semibold text-fg-primary",
"text-sm font-semibold text-gray-100", "text-sm font-semibold text-fg-primary",
"text-sm font-medium text-gray-200", "text-sm font-medium text-fg-secondary",
"text-xs font-medium text-gray-300 uppercase tracking-wider", "text-xs font-medium text-fg-secondary uppercase tracking-wider",
]; ];
export function MarkdownContent({ text, dense = false }: MarkdownContentProps) { export function MarkdownContent({ text, dense = false }: MarkdownContentProps) {
@@ -399,7 +403,7 @@ export function MarkdownContent({ text, dense = false }: MarkdownContentProps) {
const gap = dense ? "space-y-1.5" : "space-y-2.5"; const gap = dense ? "space-y-1.5" : "space-y-2.5";
return ( return (
<div className={`text-sm text-gray-300 leading-relaxed ${gap}`}> <div className={`text-sm text-fg-secondary leading-relaxed ${gap}`}>
{blocks.map((b, idx) => { {blocks.map((b, idx) => {
switch (b.kind) { switch (b.kind) {
case "code": case "code":
@@ -419,10 +423,10 @@ export function MarkdownContent({ text, dense = false }: MarkdownContentProps) {
return ( return (
<ol <ol
key={idx} key={idx}
className="list-decimal pl-5 space-y-1 marker:text-gray-500 marker:font-mono marker:text-xs" className="list-decimal pl-5 space-y-1 marker:text-fg-muted marker:font-mono marker:text-xs"
> >
{b.items.map((item, i) => ( {b.items.map((item, i) => (
<li key={i} className="text-sm text-gray-300"> <li key={i} className="text-sm text-fg-secondary">
{renderListItem(item, `li${idx}-${i}`)} {renderListItem(item, `li${idx}-${i}`)}
</li> </li>
))} ))}
@@ -432,7 +436,7 @@ export function MarkdownContent({ text, dense = false }: MarkdownContentProps) {
return ( return (
<ul key={idx} className="list-disc pl-5 space-y-1 marker:text-violet-400/60"> <ul key={idx} className="list-disc pl-5 space-y-1 marker:text-violet-400/60">
{b.items.map((item, i) => ( {b.items.map((item, i) => (
<li key={i} className="text-sm text-gray-300"> <li key={i} className="text-sm text-fg-secondary">
{renderListItem(item, `li${idx}-${i}`)} {renderListItem(item, `li${idx}-${i}`)}
</li> </li>
))} ))}
@@ -443,7 +447,7 @@ export function MarkdownContent({ text, dense = false }: MarkdownContentProps) {
return ( return (
<blockquote <blockquote
key={idx} key={idx}
className="relative border-l-2 border-violet-400/50 pl-3 pr-2 py-1 text-gray-400 italic bg-violet-500/[0.04] rounded-r" className="relative border-l-2 border-violet-400/50 pl-3 pr-2 py-1 text-fg-secondary italic bg-violet-500/[0.04] rounded-r"
> >
{renderInline(b.text, `q${idx}`)} {renderInline(b.text, `q${idx}`)}
</blockquote> </blockquote>
@@ -471,7 +475,7 @@ export function MarkdownContent({ text, dense = false }: MarkdownContentProps) {
{b.header.map((cell, i) => ( {b.header.map((cell, i) => (
<th <th
key={i} key={i}
className={`px-3 py-1.5 font-semibold text-gray-200 border-b border-surface-3 ${alignClass(b.aligns[i] ?? null)}`} className={`px-3 py-1.5 font-semibold text-fg-secondary border-b border-surface-3 ${alignClass(b.aligns[i] ?? null)}`}
> >
{renderInline(cell, `th${idx}-${i}`)} {renderInline(cell, `th${idx}-${i}`)}
</th> </th>
@@ -487,7 +491,7 @@ export function MarkdownContent({ text, dense = false }: MarkdownContentProps) {
{row.map((cell, ci) => ( {row.map((cell, ci) => (
<td <td
key={ci} key={ci}
className={`px-3 py-1.5 text-gray-300 align-top ${alignClass(b.aligns[ci] ?? null)}`} className={`px-3 py-1.5 text-fg-secondary align-top ${alignClass(b.aligns[ci] ?? null)}`}
> >
{renderInline(cell, `td${idx}-${ri}-${ci}`)} {renderInline(cell, `td${idx}-${ri}-${ci}`)}
</td> </td>
@@ -502,7 +506,7 @@ export function MarkdownContent({ text, dense = false }: MarkdownContentProps) {
case "para": case "para":
return ( return (
<p key={idx} className="text-sm text-gray-300 whitespace-pre-wrap break-words"> <p key={idx} className="text-sm text-fg-secondary whitespace-pre-wrap break-words">
{renderInline(b.text, `p${idx}`)} {renderInline(b.text, `p${idx}`)}
</p> </p>
); );
@@ -88,9 +88,9 @@ const SENDER_STYLES: Record<
label: "User", label: "User",
icon: User, icon: User,
avatarRing: avatarRing:
"bg-gradient-to-br from-blue-500/30 to-cyan-500/20 text-blue-200 ring-1 ring-blue-400/30", "bg-gradient-to-br from-blue-600/30 to-cyan-500/20 text-blue-300 ring-1 ring-blue-500/30",
accentBar: "before:bg-blue-500/40", accentBar: "before:bg-blue-600/40",
headerText: "text-blue-200", headerText: "text-blue-300",
}, },
assistant: { assistant: {
label: "Assistant", label: "Assistant",
@@ -104,7 +104,7 @@ const SENDER_STYLES: Record<
label: "Main agent", label: "Main agent",
icon: Workflow, icon: Workflow,
avatarRing: avatarRing:
"bg-gradient-to-br from-teal-500/30 to-emerald-500/20 text-teal-200 ring-1 ring-teal-400/30", "bg-gradient-to-br from-teal-500/30 to-status-success/20 text-teal-200 ring-1 ring-teal-400/30",
accentBar: "before:bg-teal-500/40", accentBar: "before:bg-teal-500/40",
headerText: "text-teal-200", headerText: "text-teal-200",
}, },
@@ -112,17 +112,17 @@ const SENDER_STYLES: Record<
label: "System", label: "System",
icon: Cog, icon: Cog,
avatarRing: avatarRing:
"bg-gradient-to-br from-slate-500/30 to-gray-500/20 text-gray-300 ring-1 ring-slate-400/30", "bg-gradient-to-br from-slate-500/30 to-gray-500/20 text-fg-secondary ring-1 ring-border-light/30",
accentBar: "before:bg-slate-500/40", accentBar: "before:bg-surface-4/40",
headerText: "text-gray-300", headerText: "text-fg-secondary",
}, },
tool: { tool: {
label: "Tool", label: "Tool",
icon: Terminal, icon: Terminal,
avatarRing: avatarRing:
"bg-gradient-to-br from-amber-500/30 to-orange-500/20 text-amber-200 ring-1 ring-amber-400/30", "bg-gradient-to-br from-status-warning/30 to-orange-500/20 text-status-warning ring-1 ring-status-warning/30",
accentBar: "before:bg-amber-500/40", accentBar: "before:bg-status-warning/40",
headerText: "text-amber-200", headerText: "text-status-warning",
}, },
}; };
import { ToolCallBlock } from "./ToolCallBlock"; import { ToolCallBlock } from "./ToolCallBlock";
@@ -174,12 +174,12 @@ function formatLocalTime(iso: string): string {
function SessionEventRow({ title, timestamp }: { title?: string; timestamp: string | null }) { function SessionEventRow({ title, timestamp }: { title?: string; timestamp: string | null }) {
return ( return (
<div className="flex items-center justify-center py-1"> <div className="flex items-center justify-center py-1">
<div className="inline-flex items-center gap-2 text-[11px] text-gray-400 bg-surface-2/70 border border-surface-3 rounded-full px-3 py-1 max-w-full"> <div className="inline-flex items-center gap-2 text-[11px] text-fg-secondary bg-surface-2/70 border border-surface-3 rounded-full px-3 py-1 max-w-full">
<Pencil className="w-3 h-3 text-violet-300/70 flex-shrink-0" /> <Pencil className="w-3 h-3 text-violet-300/70 flex-shrink-0" />
<span className="text-gray-500">Renamed session </span> <span className="text-fg-muted">Renamed session </span>
<span className="text-gray-200 font-medium truncate">{title || "(untitled)"}</span> <span className="text-fg-secondary font-medium truncate">{title || "(untitled)"}</span>
{timestamp && ( {timestamp && (
<span className="text-[10px] text-gray-600 font-mono flex-shrink-0"> <span className="text-[10px] text-fg-muted font-mono flex-shrink-0">
{formatLocalTime(timestamp)} {formatLocalTime(timestamp)}
</span> </span>
)} )}
@@ -191,8 +191,8 @@ function SessionEventRow({ title, timestamp }: { title?: string; timestamp: stri
/** Compact pill for /command invocations parsed out of TUI markup. */ /** Compact pill for /command invocations parsed out of TUI markup. */
function CommandPill({ display }: { display: string }) { function CommandPill({ display }: { display: string }) {
return ( return (
<div className="inline-flex items-center gap-2 text-sm text-emerald-300 font-mono bg-emerald-500/10 border border-emerald-500/20 rounded-md px-3 py-1.5 max-w-full"> <div className="inline-flex items-center gap-2 text-sm text-status-success font-mono bg-status-success/10 border border-status-success/20 rounded-md px-3 py-1.5 max-w-full">
<span className="text-emerald-500/70"></span> <span className="text-status-success/70"></span>
<span className="break-all">{display}</span> <span className="break-all">{display}</span>
</div> </div>
); );
@@ -203,9 +203,9 @@ function TerminalBlock({ text, stream }: { text: string; stream: "stdout" | "std
const cleaned = stripAnsi(text).replace(/^\n+|\n+$/g, ""); const cleaned = stripAnsi(text).replace(/^\n+|\n+$/g, "");
const isErr = stream === "stderr"; const isErr = stream === "stderr";
const accent = isErr const accent = isErr
? "border-red-500/30 bg-red-950/30 text-red-200/90" ? "border-status-danger/30 bg-status-danger/30 text-status-danger/90"
: "border-surface-3 bg-surface-4/60 text-gray-200"; : "border-surface-3 bg-surface-4/60 text-fg-secondary";
const labelColor = isErr ? "text-red-300/80" : "text-gray-400"; const labelColor = isErr ? "text-status-danger/80" : "text-fg-secondary";
return ( return (
<div className={`rounded-lg border ${accent} overflow-hidden`}> <div className={`rounded-lg border ${accent} overflow-hidden`}>
<div <div
@@ -224,7 +224,7 @@ function TerminalBlock({ text, stream }: { text: string; stream: "stdout" | "std
/** Subtle inline note for the local-command-caveat banner. */ /** Subtle inline note for the local-command-caveat banner. */
function CaveatBlock({ text }: { text: string }) { function CaveatBlock({ text }: { text: string }) {
return ( return (
<div className="flex items-start gap-2 rounded-md border border-amber-500/15 bg-amber-500/[0.05] px-3 py-1.5 text-[11px] text-amber-200/70"> <div className="flex items-start gap-2 rounded-md border border-status-warning/15 bg-status-warning/[0.05] px-3 py-1.5 text-[11px] text-status-warning/70">
<Info className="w-3.5 h-3.5 mt-px flex-shrink-0 opacity-60" /> <Info className="w-3.5 h-3.5 mt-px flex-shrink-0 opacity-60" />
<span className="leading-relaxed italic">{stripAnsi(text).trim()}</span> <span className="leading-relaxed italic">{stripAnsi(text).trim()}</span>
</div> </div>
@@ -247,11 +247,11 @@ function renderSegment(seg: TuiSegment, key: number): React.ReactNode {
<CollapsibleBlock <CollapsibleBlock
key={key} key={key}
text={seg.text} text={seg.text}
icon={<AlertTriangle className="w-3.5 h-3.5 text-amber-400/70 flex-shrink-0" />} icon={<AlertTriangle className="w-3.5 h-3.5 text-status-warning/70 flex-shrink-0" />}
title="System reminder" title="System reminder"
borderClass="border-amber-500/20" borderClass="border-status-warning/20"
bgClass="bg-amber-500/5" bgClass="bg-status-warning/5"
textClass="text-amber-300/80" textClass="text-status-warning/80"
/> />
); );
case "persisted-output": case "persisted-output":
@@ -326,7 +326,7 @@ export function MessageList({ messages, loading }: MessageListProps) {
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center py-12 text-gray-500 text-sm"> <div className="flex items-center justify-center py-12 text-fg-muted text-sm">
Loading conversation... Loading conversation...
</div> </div>
); );
@@ -334,7 +334,7 @@ export function MessageList({ messages, loading }: MessageListProps) {
if (messages.length === 0) { if (messages.length === 0) {
return ( return (
<div className="text-center py-12 text-gray-500 text-sm">No conversation records found.</div> <div className="text-center py-12 text-fg-muted text-sm">No conversation records found.</div>
); );
} }
@@ -392,19 +392,19 @@ export function MessageList({ messages, loading }: MessageListProps) {
{style.label} {style.label}
</span> </span>
{msg.model && ( {msg.model && (
<span className="text-[10px] text-gray-400 font-mono bg-surface-3/60 border border-surface-3 rounded px-1.5 py-0.5"> <span className="text-[10px] text-fg-secondary font-mono bg-surface-3/60 border border-surface-3 rounded px-1.5 py-0.5">
{formatModelName(msg.model)} {formatModelName(msg.model)}
</span> </span>
)} )}
{msg.usage && ( {msg.usage && (
<span className="text-[10px] text-gray-500 font-mono inline-flex items-center gap-1"> <span className="text-[10px] text-fg-muted font-mono inline-flex items-center gap-1">
<span className="text-emerald-300/70"> {fmt(msg.usage.input_tokens)}</span> <span className="text-status-success/70"> {fmt(msg.usage.input_tokens)}</span>
<span className="text-gray-700">·</span> <span className="text-fg-muted">·</span>
<span className="text-orange-300/70"> {fmt(msg.usage.output_tokens)}</span> <span className="text-orange-300/70"> {fmt(msg.usage.output_tokens)}</span>
</span> </span>
)} )}
{msg.timestamp && ( {msg.timestamp && (
<span className="text-[10px] text-gray-600 ml-auto font-mono"> <span className="text-[10px] text-fg-muted ml-auto font-mono">
{formatLocalTime(msg.timestamp)} {formatLocalTime(msg.timestamp)}
</span> </span>
)} )}
@@ -436,11 +436,11 @@ export function MessageList({ messages, loading }: MessageListProps) {
<CollapsibleBlock <CollapsibleBlock
key={bIdx} key={bIdx}
text={block.text} text={block.text}
icon={<ScrollText className="w-3.5 h-3.5 text-blue-400/60 flex-shrink-0" />} icon={<ScrollText className="w-3.5 h-3.5 text-blue-500/60 flex-shrink-0" />}
title={skillPath} title={skillPath}
borderClass="border-blue-500/20" borderClass="border-blue-600/20"
bgClass="bg-blue-500/5" bgClass="bg-blue-600/5"
textClass="text-blue-400/80" textClass="text-blue-500/80"
/> />
); );
} }
@@ -470,7 +470,7 @@ export function MessageList({ messages, loading }: MessageListProps) {
return ( return (
<div <div
key={bIdx} key={bIdx}
className="rounded-lg border border-amber-500/20 bg-amber-500/5 overflow-hidden" className="rounded-lg border border-status-warning/20 bg-status-warning/5 overflow-hidden"
> >
<button <button
onClick={() => onClick={() =>
@@ -481,23 +481,23 @@ export function MessageList({ messages, loading }: MessageListProps) {
return next; return next;
}) })
} }
className="w-full flex items-center gap-2 px-3 py-1.5 text-left hover:bg-amber-500/10 transition-colors" className="w-full flex items-center gap-2 px-3 py-1.5 text-left hover:bg-status-warning/10 transition-colors"
> >
<ChevronRight <ChevronRight
className={`w-3.5 h-3.5 text-amber-500/60 transition-transform duration-150 ${ className={`w-3.5 h-3.5 text-status-warning/60 transition-transform duration-150 ${
isExpanded ? "rotate-90" : "" isExpanded ? "rotate-90" : ""
}`} }`}
/> />
<Brain className="w-3.5 h-3.5 text-amber-400/80" /> <Brain className="w-3.5 h-3.5 text-status-warning/80" />
<span className="text-xs text-amber-200/90 font-medium">Thinking</span> <span className="text-xs text-status-warning/90 font-medium">Thinking</span>
{!isExpanded && ( {!isExpanded && (
<span className="text-[10px] text-amber-300/40 font-mono ml-auto"> <span className="text-[10px] text-status-warning/40 font-mono ml-auto">
{block.text.length.toLocaleString()} chars {block.text.length.toLocaleString()} chars
</span> </span>
)} )}
</button> </button>
{isExpanded && ( {isExpanded && (
<div className="border-t border-amber-500/10 px-3 py-2 text-amber-100/80"> <div className="border-t border-status-warning/10 px-3 py-2 text-status-warning/80">
<MarkdownContent text={block.text} dense /> <MarkdownContent text={block.text} dense />
</div> </div>
)} )}
@@ -138,7 +138,7 @@ function renderInput(toolUse: TranscriptContent) {
<div className="space-y-2"> <div className="space-y-2">
<CodeBlock code={obj.command} lang="bash" label="Command" /> <CodeBlock code={obj.command} lang="bash" label="Command" />
{typeof obj.description === "string" && ( {typeof obj.description === "string" && (
<p className="text-xs text-gray-500 italic px-1">{obj.description}</p> <p className="text-xs text-fg-muted italic px-1">{obj.description}</p>
)} )}
</div> </div>
); );
@@ -161,11 +161,11 @@ function renderInput(toolUse: TranscriptContent) {
const lang = langFromPath(obj.file_path); const lang = langFromPath(obj.file_path);
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-1.5 text-xs text-gray-400"> <div className="flex items-center gap-1.5 text-xs text-fg-secondary">
<FileText className="w-3.5 h-3.5 text-violet-400" /> <FileText className="w-3.5 h-3.5 text-violet-400" />
<span className="font-mono">{obj.file_path}</span> <span className="font-mono">{obj.file_path}</span>
{obj.replace_all === true && ( {obj.replace_all === true && (
<span className="text-[10px] uppercase tracking-wider text-amber-300/80 bg-amber-500/10 border border-amber-500/20 rounded px-1.5 py-0.5"> <span className="text-[10px] uppercase tracking-wider text-status-warning/80 bg-status-warning/10 border border-status-warning/20 rounded px-1.5 py-0.5">
replace all replace all
</span> </span>
)} )}
@@ -183,11 +183,11 @@ function renderInput(toolUse: TranscriptContent) {
// Read: just show the path with offset/limit // Read: just show the path with offset/limit
if (tool === "read" && typeof obj.file_path === "string") { if (tool === "read" && typeof obj.file_path === "string") {
return ( return (
<div className="flex items-center gap-1.5 text-xs text-gray-300 bg-surface-4/40 border border-surface-3 rounded-md px-3 py-2"> <div className="flex items-center gap-1.5 text-xs text-fg-secondary bg-surface-4/40 border border-surface-3 rounded-md px-3 py-2">
<FileText className="w-3.5 h-3.5 text-sky-400 flex-shrink-0" /> <FileText className="w-3.5 h-3.5 text-sky-400 flex-shrink-0" />
<span className="font-mono break-all">{obj.file_path}</span> <span className="font-mono break-all">{obj.file_path}</span>
{(typeof obj.offset === "number" || typeof obj.limit === "number") && ( {(typeof obj.offset === "number" || typeof obj.limit === "number") && (
<span className="text-gray-500 font-mono ml-auto flex-shrink-0"> <span className="text-fg-muted font-mono ml-auto flex-shrink-0">
{typeof obj.offset === "number" ? `:${obj.offset}` : ""} {typeof obj.offset === "number" ? `:${obj.offset}` : ""}
{typeof obj.limit === "number" ? `+${obj.limit}` : ""} {typeof obj.limit === "number" ? `+${obj.limit}` : ""}
</span> </span>
@@ -201,7 +201,7 @@ function renderInput(toolUse: TranscriptContent) {
return ( return (
<div className="space-y-1.5"> <div className="space-y-1.5">
<div className="flex items-center gap-2 text-xs"> <div className="flex items-center gap-2 text-xs">
<span className="text-gray-500 font-mono uppercase tracking-wider text-[10px]"> <span className="text-fg-muted font-mono uppercase tracking-wider text-[10px]">
Pattern Pattern
</span> </span>
<code className="font-mono text-cyan-300 bg-surface-4 border border-surface-3 rounded px-1.5 py-0.5"> <code className="font-mono text-cyan-300 bg-surface-4 border border-surface-3 rounded px-1.5 py-0.5">
@@ -210,18 +210,18 @@ function renderInput(toolUse: TranscriptContent) {
</div> </div>
{typeof obj.path === "string" && ( {typeof obj.path === "string" && (
<div className="flex items-center gap-2 text-xs"> <div className="flex items-center gap-2 text-xs">
<span className="text-gray-500 font-mono uppercase tracking-wider text-[10px]"> <span className="text-fg-muted font-mono uppercase tracking-wider text-[10px]">
Path Path
</span> </span>
<code className="font-mono text-gray-300">{obj.path}</code> <code className="font-mono text-fg-secondary">{obj.path}</code>
</div> </div>
)} )}
{typeof obj.glob === "string" && ( {typeof obj.glob === "string" && (
<div className="flex items-center gap-2 text-xs"> <div className="flex items-center gap-2 text-xs">
<span className="text-gray-500 font-mono uppercase tracking-wider text-[10px]"> <span className="text-fg-muted font-mono uppercase tracking-wider text-[10px]">
Glob Glob
</span> </span>
<code className="font-mono text-gray-300">{obj.glob}</code> <code className="font-mono text-fg-secondary">{obj.glob}</code>
</div> </div>
)} )}
</div> </div>
@@ -235,7 +235,7 @@ function renderInput(toolUse: TranscriptContent) {
/** Render the result pane: detect diff/json/text. */ /** Render the result pane: detect diff/json/text. */
function renderResult(toolResult: TranscriptContent, toolName: string) { function renderResult(toolResult: TranscriptContent, toolName: string) {
const text = toolResult.output ?? ""; const text = toolResult.output ?? "";
if (text.length === 0) return <div className="text-xs text-gray-500 italic px-1">(empty)</div>; if (text.length === 0) return <div className="text-xs text-fg-muted italic px-1">(empty)</div>;
const isError = !!toolResult.is_error; const isError = !!toolResult.is_error;
const tool = toolName.toLowerCase(); const tool = toolName.toLowerCase();
@@ -269,8 +269,8 @@ export function ToolCallBlock({ toolUse, toolResult }: ToolCallBlockProps) {
const style = styleForTool(toolUse.name); const style = styleForTool(toolUse.name);
const Icon = style.Icon; const Icon = style.Icon;
const wrapperBorder = isError ? "border-red-500/30" : style.border; const wrapperBorder = isError ? "border-status-danger/30" : style.border;
const wrapperBg = isError ? "bg-red-500/5" : "bg-surface-2/60"; const wrapperBg = isError ? "bg-status-danger/5" : "bg-surface-2/60";
return ( return (
<div <div
@@ -282,7 +282,7 @@ export function ToolCallBlock({ toolUse, toolResult }: ToolCallBlockProps) {
className="w-full flex items-center gap-2.5 px-3 py-2 text-left hover:bg-surface-3/40 transition-colors" className="w-full flex items-center gap-2.5 px-3 py-2 text-left hover:bg-surface-3/40 transition-colors"
> >
<ChevronRight <ChevronRight
className={`w-3.5 h-3.5 text-gray-500 flex-shrink-0 transition-transform duration-150 ${ className={`w-3.5 h-3.5 text-fg-muted flex-shrink-0 transition-transform duration-150 ${
expanded ? "rotate-90" : "" expanded ? "rotate-90" : ""
}`} }`}
/> />
@@ -295,23 +295,23 @@ export function ToolCallBlock({ toolUse, toolResult }: ToolCallBlockProps) {
{toolUse.name} {toolUse.name}
</span> </span>
{summary && ( {summary && (
<span className="text-gray-500 text-xs font-mono truncate min-w-0" title={summary}> <span className="text-fg-muted text-xs font-mono truncate min-w-0" title={summary}>
{summary} {summary}
</span> </span>
)} )}
<span className="ml-auto flex-shrink-0"> <span className="ml-auto flex-shrink-0">
{isError ? ( {isError ? (
<span className="inline-flex items-center gap-1 text-[10px] uppercase tracking-wider text-red-300 bg-red-500/15 border border-red-500/20 rounded px-1.5 py-0.5"> <span className="inline-flex items-center gap-1 text-[10px] uppercase tracking-wider text-status-danger bg-status-danger/15 border border-status-danger/20 rounded px-1.5 py-0.5">
<AlertCircle className="w-3 h-3" /> <AlertCircle className="w-3 h-3" />
error error
</span> </span>
) : hasResult ? ( ) : hasResult ? (
<span className="inline-flex items-center gap-1 text-[10px] uppercase tracking-wider text-emerald-300/80 bg-emerald-500/10 border border-emerald-500/20 rounded px-1.5 py-0.5"> <span className="inline-flex items-center gap-1 text-[10px] uppercase tracking-wider text-status-success/80 bg-status-success/10 border border-status-success/20 rounded px-1.5 py-0.5">
<CheckCircle2 className="w-3 h-3" /> <CheckCircle2 className="w-3 h-3" />
ok ok
</span> </span>
) : ( ) : (
<span className="text-gray-600 text-[10px] uppercase tracking-wider font-mono"> <span className="text-fg-muted text-[10px] uppercase tracking-wider font-mono">
pending pending
</span> </span>
)} )}
@@ -130,7 +130,7 @@ export function CopyButton({ text }: { text: string }) {
<button <button
type="button" type="button"
onClick={copy} onClick={copy}
className="flex items-center gap-1 text-[10px] py-0.5 px-1.5 rounded text-gray-400 hover:text-gray-200 hover:bg-surface-2 cursor-pointer" className="flex items-center gap-1 text-[10px] py-0.5 px-1.5 rounded text-fg-secondary hover:text-fg-primary hover:bg-surface-2 cursor-pointer"
aria-label={t("eventDetail.copy")} aria-label={t("eventDetail.copy")}
> >
{copied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />} {copied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
@@ -145,13 +145,13 @@ export function Terminal({ command, description }: { command: string; descriptio
return ( return (
<div className="relative bg-black/70 border border-border rounded font-mono text-[11px] overflow-hidden"> <div className="relative bg-black/70 border border-border rounded font-mono text-[11px] overflow-hidden">
<div className="flex items-center justify-between px-3 py-1 border-b border-border bg-black/40"> <div className="flex items-center justify-between px-3 py-1 border-b border-border bg-black/40">
<span className="text-gray-500 text-[10px] uppercase tracking-wide">terminal</span> <span className="text-fg-muted text-[10px] uppercase tracking-wide">terminal</span>
<CopyButton text={command} /> <CopyButton text={command} />
</div> </div>
<pre className="px-3 py-2 text-gray-200 whitespace-pre-wrap break-words"> <pre className="px-3 py-2 text-fg-secondary whitespace-pre-wrap break-words">
{description && <div className="text-gray-500 mb-1"># {description}</div>} {description && <div className="text-fg-muted mb-1"># {description}</div>}
<div> <div>
<span className="text-emerald-400 select-none">$ </span> <span className="text-status-success select-none">$ </span>
{command} {command}
</div> </div>
</pre> </pre>
@@ -176,11 +176,14 @@ export function TerminalOutput({
const hasStderr = typeof stderr === "string" && stderr.length > 0; const hasStderr = typeof stderr === "string" && stderr.length > 0;
const flag = const flag =
interrupted === true interrupted === true
? { label: "interrupted", color: "text-red-400 border-red-500/40 bg-red-500/10" } ? {
label: "interrupted",
color: "text-status-danger border-status-danger/40 bg-status-danger/10",
}
: typeof exitCode === "number" && exitCode !== 0 : typeof exitCode === "number" && exitCode !== 0
? { ? {
label: `exit ${exitCode}`, label: `exit ${exitCode}`,
color: "text-red-400 border-red-500/40 bg-red-500/10", color: "text-status-danger border-status-danger/40 bg-status-danger/10",
} }
: null; : null;
@@ -208,11 +211,11 @@ function OutputBlock({
text: string; text: string;
variant: "out" | "err"; variant: "out" | "err";
}) { }) {
const color = variant === "err" ? "text-red-300" : "text-gray-200"; const color = variant === "err" ? "text-status-danger" : "text-fg-secondary";
return ( return (
<div className="relative bg-black/70 border border-border rounded font-mono text-[11px] overflow-hidden"> <div className="relative bg-black/70 border border-border rounded font-mono text-[11px] overflow-hidden">
<div className="flex items-center justify-between px-3 py-1 border-b border-border bg-black/40"> <div className="flex items-center justify-between px-3 py-1 border-b border-border bg-black/40">
<span className="text-gray-500 text-[10px] uppercase tracking-wide">{label}</span> <span className="text-fg-muted text-[10px] uppercase tracking-wide">{label}</span>
<CopyButton text={text} /> <CopyButton text={text} />
</div> </div>
<pre className={`px-3 py-2 whitespace-pre-wrap break-words max-h-96 overflow-auto ${color}`}> <pre className={`px-3 py-2 whitespace-pre-wrap break-words max-h-96 overflow-auto ${color}`}>
@@ -240,7 +243,7 @@ export function LineNumberedCode({
<div className="relative bg-black/70 border border-border rounded font-mono text-[11px] overflow-hidden"> <div className="relative bg-black/70 border border-border rounded font-mono text-[11px] overflow-hidden">
{label && ( {label && (
<div className="flex items-center justify-between px-3 py-1 border-b border-border bg-black/40"> <div className="flex items-center justify-between px-3 py-1 border-b border-border bg-black/40">
<span className="text-gray-500 text-[10px] uppercase tracking-wide">{label}</span> <span className="text-fg-muted text-[10px] uppercase tracking-wide">{label}</span>
<CopyButton text={text} /> <CopyButton text={text} />
</div> </div>
)} )}
@@ -249,10 +252,10 @@ export function LineNumberedCode({
<tbody> <tbody>
{lines.map((line, i) => ( {lines.map((line, i) => (
<tr key={i}> <tr key={i}>
<td className="px-2 text-right text-gray-600 select-none bg-black/40 border-r border-border align-top"> <td className="px-2 text-right text-fg-muted select-none bg-black/40 border-r border-border align-top">
{i + startLine} {i + startLine}
</td> </td>
<td className="px-3 text-gray-200 whitespace-pre-wrap break-words">{line}</td> <td className="px-3 text-fg-secondary whitespace-pre-wrap break-words">{line}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
@@ -274,7 +277,7 @@ export type DiffHunk = {
export function UnifiedDiff({ hunks }: { hunks: DiffHunk[] }) { export function UnifiedDiff({ hunks }: { hunks: DiffHunk[] }) {
if (hunks.length === 0) { if (hunks.length === 0) {
return <p className="text-[11px] text-gray-500 italic">no diff</p>; return <p className="text-[11px] text-fg-muted italic">no diff</p>;
} }
return ( return (
<div className="relative bg-black/70 border border-border rounded font-mono text-[11px] overflow-hidden"> <div className="relative bg-black/70 border border-border rounded font-mono text-[11px] overflow-hidden">
@@ -306,17 +309,17 @@ function HunkView({ hunk }: { hunk: DiffHunk }) {
kind === "add" kind === "add"
? "bg-green-500/10 text-green-200" ? "bg-green-500/10 text-green-200"
: kind === "remove" : kind === "remove"
? "bg-red-500/10 text-red-200" ? "bg-status-danger/10 text-status-danger"
: "text-gray-300"; : "text-fg-secondary";
const oldCell = showOld ? oldLine++ : ""; const oldCell = showOld ? oldLine++ : "";
const newCell = showNew ? newLine++ : ""; const newCell = showNew ? newLine++ : "";
const sign = kind === "add" ? "+" : kind === "remove" ? "-" : " "; const sign = kind === "add" ? "+" : kind === "remove" ? "-" : " ";
return ( return (
<tr key={i} className={rowBg}> <tr key={i} className={rowBg}>
<td className="px-2 text-right text-gray-600 select-none border-r border-border/40 w-[36px]"> <td className="px-2 text-right text-fg-muted select-none border-r border-border/40 w-[36px]">
{oldCell} {oldCell}
</td> </td>
<td className="px-2 text-right text-gray-600 select-none border-r border-border/40 w-[36px]"> <td className="px-2 text-right text-fg-muted select-none border-r border-border/40 w-[36px]">
{newCell} {newCell}
</td> </td>
<td className="px-1 text-center select-none w-[16px]">{sign}</td> <td className="px-1 text-center select-none w-[16px]">{sign}</td>
@@ -347,7 +350,7 @@ export function KeyValueCard({
const ordered = [...priorityEntries, ...restEntries]; const ordered = [...priorityEntries, ...restEntries];
if (ordered.length === 0) { if (ordered.length === 0) {
return <p className="text-[11px] text-gray-500 italic">empty</p>; return <p className="text-[11px] text-fg-muted italic">empty</p>;
} }
return ( return (
@@ -355,10 +358,10 @@ export function KeyValueCard({
<tbody> <tbody>
{ordered.map(([k, v], i) => ( {ordered.map(([k, v], i) => (
<tr key={k} className={i > 0 ? "border-t border-border" : ""}> <tr key={k} className={i > 0 ? "border-t border-border" : ""}>
<td className="text-gray-500 align-top py-1.5 px-2 font-mono bg-surface-3/60 w-[28%] break-all"> <td className="text-fg-muted align-top py-1.5 px-2 font-mono bg-surface-3/60 w-[28%] break-all">
{k} {k}
</td> </td>
<td className="text-gray-300 align-top py-1.5 px-2"> <td className="text-fg-secondary align-top py-1.5 px-2">
<ValueCell value={v} /> <ValueCell value={v} />
</td> </td>
</tr> </tr>
@@ -369,32 +372,33 @@ export function KeyValueCard({
} }
function ValueCell({ value }: { value: unknown }) { function ValueCell({ value }: { value: unknown }) {
if (value == null) return <span className="text-gray-500 italic">null</span>; if (value == null) return <span className="text-fg-muted italic">null</span>;
if (typeof value === "boolean") if (typeof value === "boolean")
return ( return (
<span <span
className={`inline-block px-2 py-0.5 rounded border text-[11px] font-mono ${ className={`inline-block px-2 py-0.5 rounded border text-[11px] font-mono ${
value value
? "text-green-400 border-green-500/30 bg-green-500/10" ? "text-green-400 border-green-500/30 bg-green-500/10"
: "text-gray-400 border-gray-500/30 bg-gray-500/10" : "text-fg-secondary border-border-light/30 bg-surface-4/10"
}`} }`}
> >
{String(value)} {String(value)}
</span> </span>
); );
if (typeof value === "number") return <span className="font-mono text-gray-300">{value}</span>; if (typeof value === "number")
return <span className="font-mono text-fg-secondary">{value}</span>;
if (typeof value === "string") { if (typeof value === "string") {
if (value.length > 120 || value.includes("\n")) { if (value.length > 120 || value.includes("\n")) {
return ( return (
<pre className="bg-surface-3 text-gray-300 text-[11px] font-mono p-2 rounded border border-border whitespace-pre-wrap break-words max-h-48 overflow-auto"> <pre className="bg-surface-3 text-fg-secondary text-[11px] font-mono p-2 rounded border border-border whitespace-pre-wrap break-words max-h-48 overflow-auto">
{value} {value}
</pre> </pre>
); );
} }
return <span className="font-mono text-gray-300 break-all">{value}</span>; return <span className="font-mono text-fg-secondary break-all">{value}</span>;
} }
if (Array.isArray(value)) { if (Array.isArray(value)) {
if (value.length === 0) return <span className="text-gray-500 italic">[]</span>; if (value.length === 0) return <span className="text-fg-muted italic">[]</span>;
return ( return (
<ol className="list-decimal pl-4 space-y-1"> <ol className="list-decimal pl-4 space-y-1">
{value.map((item, i) => ( {value.map((item, i) => (
@@ -406,7 +410,7 @@ function ValueCell({ value }: { value: unknown }) {
); );
} }
return ( return (
<pre className="bg-surface-3 text-gray-300 text-[11px] font-mono p-2 rounded border border-border whitespace-pre-wrap break-words max-h-48 overflow-auto"> <pre className="bg-surface-3 text-fg-secondary text-[11px] font-mono p-2 rounded border border-border whitespace-pre-wrap break-words max-h-48 overflow-auto">
{safeStringify(value)} {safeStringify(value)}
</pre> </pre>
); );
@@ -423,11 +427,11 @@ function safeStringify(value: unknown): string {
// ───────────────────────── File list / match list ───────────────────────── // ───────────────────────── File list / match list ─────────────────────────
export function FileList({ paths }: { paths: string[] }) { export function FileList({ paths }: { paths: string[] }) {
if (paths.length === 0) return <p className="text-[11px] text-gray-500 italic">no files</p>; if (paths.length === 0) return <p className="text-[11px] text-fg-muted italic">no files</p>;
return ( return (
<ul className="divide-y divide-border border border-border rounded overflow-hidden text-[11px] max-h-80 overflow-y-auto"> <ul className="divide-y divide-border border border-border rounded overflow-hidden text-[11px] max-h-80 overflow-y-auto">
{paths.map((p, i) => ( {paths.map((p, i) => (
<li key={i} className="px-3 py-1 font-mono text-gray-300 break-all"> <li key={i} className="px-3 py-1 font-mono text-fg-secondary break-all">
{p} {p}
</li> </li>
))} ))}
@@ -442,14 +446,14 @@ export type GrepMatch = {
}; };
export function MatchList({ matches }: { matches: GrepMatch[] }) { export function MatchList({ matches }: { matches: GrepMatch[] }) {
if (matches.length === 0) return <p className="text-[11px] text-gray-500 italic">no matches</p>; if (matches.length === 0) return <p className="text-[11px] text-fg-muted italic">no matches</p>;
return ( return (
<ul className="divide-y divide-border border border-border rounded overflow-hidden text-[11px] max-h-80 overflow-y-auto font-mono"> <ul className="divide-y divide-border border border-border rounded overflow-hidden text-[11px] max-h-80 overflow-y-auto font-mono">
{matches.map((m, i) => ( {matches.map((m, i) => (
<li key={i} className="px-3 py-1 text-gray-300 break-all"> <li key={i} className="px-3 py-1 text-fg-secondary break-all">
{m.file && <span className="text-cyan-300">{m.file}</span>} {m.file && <span className="text-cyan-300">{m.file}</span>}
{m.line != null && <span className="text-gray-500">:{m.line}</span>} {m.line != null && <span className="text-fg-muted">:{m.line}</span>}
{m.text && <span className="text-gray-400">: {m.text}</span>} {m.text && <span className="text-fg-secondary">: {m.text}</span>}
</li> </li>
))} ))}
</ul> </ul>
@@ -397,9 +397,9 @@ export function ToolResponseView({
{hunks.length > 0 && <UnifiedDiff hunks={hunks} />} {hunks.length > 0 && <UnifiedDiff hunks={hunks} />}
{originalFile && ( {originalFile && (
<details className="bg-surface-2/40 border border-border rounded overflow-hidden"> <details className="bg-surface-2/40 border border-border rounded overflow-hidden">
<summary className="cursor-pointer select-none px-3 py-1.5 text-[11px] text-gray-400 hover:text-gray-200 hover:bg-surface-2"> <summary className="cursor-pointer select-none px-3 py-1.5 text-[11px] text-fg-secondary hover:text-fg-primary hover:bg-surface-2">
<span className="font-semibold uppercase tracking-wide">original file</span> <span className="font-semibold uppercase tracking-wide">original file</span>
<span className="text-gray-500 font-normal ml-2"> <span className="text-fg-muted font-normal ml-2">
({originalFile.split(/\r?\n/).length} lines) ({originalFile.split(/\r?\n/).length} lines)
</span> </span>
</summary> </summary>
+9 -9
View File
@@ -130,7 +130,7 @@ export function AddLaneModal({
> >
<div className="space-y-3"> <div className="space-y-3">
<div> <div>
<label className="mb-1 block text-xs text-neutral-400" htmlFor="add-lane-repo"> <label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-repo">
{t("addLaneRepoLabel")} {t("addLaneRepoLabel")}
</label> </label>
<CwdAutocomplete <CwdAutocomplete
@@ -139,11 +139,11 @@ export function AddLaneModal({
onChange={setSourceRepo} onChange={setSourceRepo}
suggestions={cwdSuggestions} suggestions={cwdSuggestions}
/> />
<p className="mt-1 text-[10px] text-neutral-500">{t("addLaneRepoHint")}</p> <p className="mt-1 text-[10px] text-fg-muted">{t("addLaneRepoHint")}</p>
</div> </div>
<div> <div>
<label className="mb-1 block text-xs text-neutral-400" htmlFor="add-lane-title"> <label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-title">
{t("addLaneTitleLabel")} {t("addLaneTitleLabel")}
</label> </label>
<input <input
@@ -151,23 +151,23 @@ export function AddLaneModal({
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
placeholder={t("addLaneTitlePlaceholder")} placeholder={t("addLaneTitlePlaceholder")}
className="w-full rounded-md border border-neutral-700 bg-neutral-900 px-3 py-1.5 text-xs text-neutral-100 placeholder:text-neutral-600 focus:border-blue-400 focus:outline-none" className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary placeholder:text-fg-muted focus:border-blue-500 focus:outline-none"
/> />
</div> </div>
{branches && ( {branches && (
<div> <div>
<label className="mb-1 block text-xs text-neutral-400" htmlFor="add-lane-base"> <label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-base">
{t("addLaneBaseLabel")} {t("addLaneBaseLabel")}
</label> </label>
{branches.length === 0 ? ( {branches.length === 0 ? (
<p className="text-[10px] text-neutral-500">{t("addLaneNoBranches")}</p> <p className="text-[10px] text-fg-muted">{t("addLaneNoBranches")}</p>
) : ( ) : (
<select <select
id="add-lane-base" id="add-lane-base"
value={base} value={base}
onChange={(e) => setBase(e.target.value)} onChange={(e) => setBase(e.target.value)}
className="w-full rounded-md border border-neutral-700 bg-neutral-900 px-3 py-1.5 text-xs text-neutral-100 focus:border-blue-400 focus:outline-none" className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary focus:border-blue-500 focus:outline-none"
> >
{branches.map((b) => ( {branches.map((b) => (
<option key={b} value={b}> <option key={b} value={b}>
@@ -179,11 +179,11 @@ export function AddLaneModal({
</div> </div>
)} )}
{branchesError && !branches && ( {branchesError && !branches && (
<p className="text-[10px] text-amber-400">{branchesError}</p> <p className="text-[10px] text-status-warning">{branchesError}</p>
)} )}
{error && ( {error && (
<p role="alert" className="text-xs text-red-400"> <p role="alert" className="text-xs text-status-danger">
{error} {error}
</p> </p>
)} )}
@@ -153,18 +153,18 @@ export function DestructiveLaneModal({
onConfirm({ expect: expectFor(preflight), ...(force ? { force: true as const } : {}) }); onConfirm({ expect: expectFor(preflight), ...(force ? { force: true as const } : {}) });
}} }}
> >
{loading && <p className="mt-3 text-xs text-neutral-400">{t("destructive.loading")}</p>} {loading && <p className="mt-3 text-xs text-fg-secondary">{t("destructive.loading")}</p>}
{error && ( {error && (
<p className="mt-3 text-xs text-red-300"> <p className="mt-3 text-xs text-status-danger">
{t("preflightErrorWithMessage", { message: error })} {t("preflightErrorWithMessage", { message: error })}
</p> </p>
)} )}
{preflight && ( {preflight && (
<table className="mt-3 w-full text-left text-xs text-neutral-300"> <table className="mt-3 w-full text-left text-xs text-fg-secondary">
<tbody> <tbody>
{facts.map(([name, value]) => ( {facts.map(([name, value]) => (
<tr key={name} className="border-t border-neutral-800"> <tr key={name} className="border-t border-border">
<th scope="row" className="py-1.5 font-medium text-neutral-400"> <th scope="row" className="py-1.5 font-medium text-fg-secondary">
{t(`destructive.count.${name}`)} {t(`destructive.count.${name}`)}
</th> </th>
<td className="py-1.5 text-right tabular-nums">{value ?? "—"}</td> <td className="py-1.5 text-right tabular-nums">{value ?? "—"}</td>
@@ -172,8 +172,8 @@ export function DestructiveLaneModal({
))} ))}
{/* Not part of `expect`: an estimate the server never verifies. */} {/* Not part of `expect`: an estimate the server never verifies. */}
{purge && ( {purge && (
<tr className="border-t border-neutral-800"> <tr className="border-t border-border">
<th scope="row" className="py-1.5 font-medium text-neutral-400"> <th scope="row" className="py-1.5 font-medium text-fg-secondary">
{t("destructive.count.bytesEstimate")} {t("destructive.count.bytesEstimate")}
</th> </th>
<td className="py-1.5 text-right tabular-nums"> <td className="py-1.5 text-right tabular-nums">
@@ -185,19 +185,19 @@ export function DestructiveLaneModal({
</table> </table>
)} )}
{blocked && ( {blocked && (
<p className="mt-3 rounded border border-amber-700/60 bg-amber-950/30 px-2 py-1.5 text-xs text-amber-200"> <p className="mt-3 rounded border border-status-warning/60 bg-status-warning/30 px-2 py-1.5 text-xs text-status-warning">
{t(`destructive.blocked.${blocked}`)} {t(`destructive.blocked.${blocked}`)}
</p> </p>
)} )}
{purge?.activeSessionSkipped && ( {purge?.activeSessionSkipped && (
<p className="mt-3 rounded border border-neutral-700 bg-neutral-800/50 px-2 py-1.5 text-xs text-neutral-300"> <p className="mt-3 rounded border border-border-light bg-surface-2/50 px-2 py-1.5 text-xs text-fg-secondary">
{t("destructive.notice.activeSessionSkipped")} {t("destructive.notice.activeSessionSkipped")}
</p> </p>
)} )}
{noticesFor(preflight).map((notice) => ( {noticesFor(preflight).map((notice) => (
<p <p
key={notice} key={notice}
className="mt-3 rounded border border-neutral-700 bg-neutral-800/50 px-2 py-1.5 text-xs text-neutral-300" className="mt-3 rounded border border-border-light bg-surface-2/50 px-2 py-1.5 text-xs text-fg-secondary"
> >
{t(`destructive.notice.${notice}`)} {t(`destructive.notice.${notice}`)}
</p> </p>
@@ -205,13 +205,13 @@ export function DestructiveLaneModal({
{warningsFor(preflight).map((warning) => ( {warningsFor(preflight).map((warning) => (
<p <p
key={warning} key={warning}
className="mt-3 rounded border border-neutral-700 bg-neutral-800/50 px-2 py-1.5 text-xs text-neutral-300" className="mt-3 rounded border border-border-light bg-surface-2/50 px-2 py-1.5 text-xs text-fg-secondary"
> >
{t(`destructive.warning.${warning}`)} {t(`destructive.warning.${warning}`)}
</p> </p>
))} ))}
{requiresForce && ( {requiresForce && (
<label className="mt-3 flex items-start gap-2 text-xs text-amber-200"> <label className="mt-3 flex items-start gap-2 text-xs text-status-warning">
<input <input
type="checkbox" type="checkbox"
checked={force} checked={force}
@@ -222,7 +222,7 @@ export function DestructiveLaneModal({
</label> </label>
)} )}
{action === "reset" && ( {action === "reset" && (
<p className="mt-3 text-xs text-neutral-400">{t("destructive.reset.survives")}</p> <p className="mt-3 text-xs text-fg-secondary">{t("destructive.reset.survives")}</p>
)} )}
</ConfirmModal> </ConfirmModal>
); );
+96 -122
View File
@@ -1,11 +1,13 @@
/** /**
* @file One lane's card: title, stage badge with time-on-phase, progress bar, * @file One lane's card: title, progress bar with time-on-phase, branch/CI/PR
* branch/CI/PR facts, the "needs you" banner sourced from Claude Code's * facts, the "needs you" banner sourced from Claude Code's Notification hook,
* Notification hook, and the control row. A dead lane (its driving session went * and the control row which ends in the two deletions (lane, history) as
* silent while it should have been working) is called out loudly that is the * plain buttons, each gated by DestructiveLaneModal. A dead lane (its driving
* failure this view exists to catch. An "auto: <stage>" chip appears only when * session went silent while it should have been working) is called out
* the server's detected stage is ahead of the agent's own declaration when the * loudly that is the failure this view exists to catch. Stage, kind, and
* declaration leads or matches, it stays the sole headline. * the "auto: <stage>" chip are NOT repeated here this card only ever
* renders inside the Workspace lane-detail section, whose header above it
* already shows them.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/ */
@@ -54,9 +56,9 @@ function useLaneGitFacts(laneId: number): LaneGitFacts | null {
} }
const LIVENESS_DOT: Record<Lane["liveness"], string> = { const LIVENESS_DOT: Record<Lane["liveness"], string> = {
active: "bg-emerald-400", active: "bg-status-success",
idle: "bg-neutral-500", idle: "bg-surface-4",
dead: "bg-red-500", dead: "bg-status-danger",
}; };
function since(sec: number | null): string { function since(sec: number | null): string {
@@ -66,15 +68,6 @@ function since(sec: number | null): string {
return `${Math.floor(sec / 3600)}h ${Math.floor((sec % 3600) / 60)}m`; return `${Math.floor(sec / 3600)}h ${Math.floor((sec % 3600) / 60)}m`;
} }
/** Whether `lane.detected_stage` is strictly ahead of `lane.stage` in the
* pipeline's node order. Unknown ids sort as "not found" (-1), so an unmatched
* detected stage never outranks a matched declaration. */
function detectionLeadsDeclaration(lane: Lane): boolean {
if (!lane.detected_stage) return false;
const ids = lane.pipeline_nodes.map((n) => n.id);
return ids.indexOf(lane.detected_stage) > ids.indexOf(lane.stage);
}
export default function LaneCard({ export default function LaneCard({
lane, lane,
onAction, onAction,
@@ -93,15 +86,15 @@ export default function LaneCard({
<> <>
<div <div
data-testid={`lane-card-${lane.id}`} data-testid={`lane-card-${lane.id}`}
className="flex flex-col rounded-xl border border-neutral-800 bg-neutral-900/70 p-4 transition-colors hover:border-neutral-700" className="flex flex-col rounded-xl border border-border bg-surface-4 p-4 transition-colors hover:border-border-light"
> >
{/* Identity strip: which lane, and is it alive. Kept on one line and in {/* Identity strip: which lane, and is it alive. Kept on one line and in
uppercase so a wall of cards can be scanned vertically. */} uppercase so a wall of cards can be scanned vertically. */}
<div className="mb-3 flex items-center justify-between"> <div className="mb-3 flex items-center justify-between">
<span className="text-[11px] font-semibold uppercase tracking-widest text-neutral-500"> <span className="text-[11px] font-semibold uppercase tracking-widest text-fg-muted">
{t("cardId", { id: lane.id })} {t("cardId", { id: lane.id })}
</span> </span>
<span className="flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wide text-neutral-300"> <span className="flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wide text-fg-secondary">
<span className={`h-2 w-2 rounded-full ${LIVENESS_DOT[lane.liveness]}`} /> <span className={`h-2 w-2 rounded-full ${LIVENESS_DOT[lane.liveness]}`} />
{/* i18next returns the KEY on a miss, so `|| raw` never fires and a {/* i18next returns the KEY on a miss, so `|| raw` never fires and a
non-standard status rendered as the literal "status.foo". non-standard status rendered as the literal "status.foo".
@@ -112,81 +105,57 @@ export default function LaneCard({
</span> </span>
</div> </div>
<h3 className="mb-3 text-[15px] font-semibold leading-snug text-neutral-50"> <h3 className="mb-3 text-[15px] font-semibold leading-snug text-fg-primary">
{lane.title || lane.cwd} {lane.title || lane.cwd}
</h3> </h3>
{/* Stage line: the declared stage, how far through, and how long it has {/* Progress line: how far through the pipeline and how long the
been sitting there the three facts that say whether a lane is current stage has been sitting there. The stage's own name is
moving. The inferred chip sits beside them, never instead of them. */} already in the Workspace header above; not repeated here. */}
<div className="mb-2 flex items-center gap-2 text-xs"> <div className="mb-2 flex items-center gap-2 text-xs">
<span
data-testid="lane-stage"
className="rounded bg-neutral-800 px-2 py-0.5 font-medium text-neutral-200"
>
{lane.stage}
</span>
<div <div
className={`h-1 flex-1 overflow-hidden rounded-full ${ className={`h-1 flex-1 overflow-hidden rounded-full ${
lane.progress > 0 ? "bg-neutral-800" : "bg-transparent" lane.progress > 0 ? "bg-surface-2" : "bg-transparent"
}`} }`}
> >
<div <div
data-testid="lane-progress-fill" data-testid="lane-progress-fill"
className="h-full rounded-full bg-blue-500 transition-[width]" className="h-full rounded-full bg-blue-600 transition-[width]"
style={{ width: `${lane.progress}%` }} style={{ width: `${lane.progress}%` }}
/> />
</div> </div>
{lane.progress > 0 && ( {lane.progress > 0 && (
<span className="tabular-nums text-neutral-400">{lane.progress}%</span> <span className="tabular-nums text-fg-secondary">{lane.progress}%</span>
)} )}
<span className="tabular-nums text-neutral-600">{since(lane.stage_seconds)}</span> <span className="tabular-nums text-fg-muted">{since(lane.stage_seconds)}</span>
</div> </div>
<div className="mb-3 flex flex-wrap items-center gap-1.5 text-[11px]"> {lane.ci_status && (
<span <div className="mb-3 flex flex-wrap items-center gap-1.5 text-[11px]">
className={`rounded px-1.5 py-0.5 ${ <span className="rounded bg-surface-2 px-1.5 py-0.5 text-fg-secondary">
lane.kind === "managed"
? "bg-blue-500/15 text-blue-300"
: "bg-violet-500/15 text-violet-300"
}`}
>
{t(`kind.${lane.kind}`)}
</span>
{detectionLeadsDeclaration(lane) && (
<span
data-testid="lane-auto-stage"
title={lane.detected_signal || undefined}
className="rounded border border-dashed border-amber-500 px-1.5 py-0.5 text-amber-300"
>
{t("autoStage", { stage: lane.detected_stage })}
</span>
)}
{lane.ci_status && (
<span className="rounded bg-neutral-800 px-1.5 py-0.5 text-neutral-300">
CI {lane.ci_status} CI {lane.ci_status}
</span> </span>
)} </div>
</div> )}
{lane.needs_action && ( {lane.needs_action && (
<div className="mb-3 rounded border border-amber-600/50 bg-amber-500/10 px-2 py-1.5 text-xs text-amber-300"> <div className="mb-3 rounded border border-status-warning/50 bg-status-warning/10 px-2 py-1.5 text-xs text-status-warning">
{lane.needs_action} {lane.needs_action}
</div> </div>
)} )}
<dl className="mb-3 space-y-1 font-mono text-[11px] text-neutral-400"> <dl className="mb-3 space-y-1 font-mono text-[11px] text-fg-secondary">
{git?.available && ( {git?.available && (
<div data-testid="lane-git" className="space-y-1"> <div data-testid="lane-git" className="space-y-1">
<div className="truncate"> <div className="truncate">
{git.branch} {git.branch}
<span className="ml-2 text-neutral-500">{git.head}</span> <span className="ml-2 text-fg-muted">{git.head}</span>
</div> </div>
<div className="truncate text-neutral-500" title={git.subject}> <div className="truncate text-fg-muted" title={git.subject}>
{git.subject} {git.subject}
</div> </div>
{(git.dirty > 0 || git.untracked > 0) && ( {(git.dirty > 0 || git.untracked > 0) && (
<div className="text-amber-400/80"> <div className="text-status-warning/80">
{t("git.uncommitted", { dirty: git.dirty, untracked: git.untracked })} {t("git.uncommitted", { dirty: git.dirty, untracked: git.untracked })}
</div> </div>
)} )}
@@ -195,14 +164,14 @@ export default function LaneCard({
{/* The lane's own recorded branch, shown only when git could not be {/* The lane's own recorded branch, shown only when git could not be
read otherwise it duplicates the live branch above. */} read otherwise it duplicates the live branch above. */}
{!git?.available && lane.branch && <div className="truncate"> {lane.branch}</div>} {!git?.available && lane.branch && <div className="truncate"> {lane.branch}</div>}
<div className="truncate text-neutral-500" title={lane.cwd}> <div className="truncate text-fg-muted" title={lane.cwd}>
{lane.cwd} {lane.cwd}
</div> </div>
</dl> </dl>
{/* mt-auto pins the controls to the bottom so cards of differing height {/* mt-auto pins the controls to the bottom so cards of differing height
in one grid row still line their buttons up. */} in one grid row still line their buttons up. */}
<div className="mt-auto flex items-center gap-1 border-t border-neutral-800 pt-2 text-xs"> <div className="mt-auto flex items-center gap-1 border-t border-border pt-2 text-xs">
{(["start", "stop", "clear"] as const).map((a) => ( {(["start", "stop", "clear"] as const).map((a) => (
<button <button
key={a} key={a}
@@ -214,77 +183,82 @@ export default function LaneCard({
}} }}
className={`rounded px-2 py-1 transition-colors ${ className={`rounded px-2 py-1 transition-colors ${
a === "start" a === "start"
? "bg-blue-500/15 text-blue-300 hover:bg-blue-500/25" ? "bg-blue-600/15 text-blue-400 hover:bg-blue-600/25"
: "text-neutral-400 hover:bg-neutral-800 hover:text-neutral-200" : "text-fg-secondary hover:bg-surface-2 hover:text-fg-secondary"
}`} }`}
title={a === "start" ? t("tooltipStart") : undefined} title={a === "start" ? t("tooltipStart") : undefined}
> >
{t(`action.${a}`)} {t(`action.${a}`)}
</button> </button>
))} ))}
{/* Destructive verbs sit behind a menu so the card is not a wall of {/* Deleting the lane and deleting its history are each their own
red. Red is kept for the items inside, where it means something. */} button, by request: hiding "delete" behind a made it unfindable,
<div className="relative ml-auto"> and the one label that read as "delete" was `clear`. Neither fires
directly both open DestructiveLaneModal, which shows the exact
counts and demands a confirmation. `reset` stays in the menu; it
is the rare one and only exists for managed lanes. */}
<div className="ml-auto flex items-center gap-1">
<button <button
type="button" type="button"
data-testid="lane-more" data-testid="lane-action-purge"
aria-haspopup="menu"
aria-expanded={menuOpen}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setMenuOpen((v) => !v); setDestructiveAction("purge");
}} }}
className="rounded px-2 py-1 text-neutral-500 hover:bg-neutral-800 hover:text-neutral-200" className="rounded px-2 py-1 text-status-danger/80 transition-colors hover:bg-status-danger/10 hover:text-status-danger"
title={t("moreActions")}
> >
{t("action.purge")}
</button> </button>
{menuOpen && ( <button
<div type="button"
role="menu" data-testid="lane-action-remove"
className="absolute right-0 z-10 mt-1 min-w-40 overflow-hidden rounded-md border border-neutral-700 bg-neutral-900 py-1 shadow-lg shadow-black/40" onClick={(e) => {
> e.stopPropagation();
{lane.kind === "managed" && ( setDestructiveAction("remove");
<button }}
type="button" className="rounded px-2 py-1 text-status-danger transition-colors hover:bg-status-danger/15 hover:text-status-danger"
role="menuitem" >
data-testid="lane-action-reset" {lane.kind === "adopted" ? t("action.forget") : t("action.remove")}
onClick={(e) => { </button>
e.stopPropagation(); {/* Adopted lanes have no worktree to reset, so the menu would be
setMenuOpen(false); empty it is not rendered at all rather than opening onto
setDestructiveAction("reset"); nothing. */}
}} {lane.kind === "managed" && (
className="block w-full px-3 py-1.5 text-left text-amber-300 hover:bg-amber-500/10" <div className="relative">
<button
type="button"
data-testid="lane-more"
aria-haspopup="menu"
aria-expanded={menuOpen}
onClick={(e) => {
e.stopPropagation();
setMenuOpen((v) => !v);
}}
className="rounded px-2 py-1 text-fg-muted hover:bg-surface-2 hover:text-fg-secondary"
title={t("moreActions")}
>
</button>
{menuOpen && (
<div
role="menu"
className="absolute right-0 z-10 mt-1 min-w-40 overflow-hidden rounded-md border border-border-light bg-surface-0 py-1 shadow-lg shadow-black/40"
> >
{t("action.reset")} <button
</button> type="button"
role="menuitem"
data-testid="lane-action-reset"
onClick={(e) => {
e.stopPropagation();
setMenuOpen(false);
setDestructiveAction("reset");
}}
className="block w-full px-3 py-1.5 text-left text-status-warning hover:bg-status-warning/10"
>
{t("action.reset")}
</button>
</div>
)} )}
<button
type="button"
role="menuitem"
data-testid="lane-action-remove"
onClick={(e) => {
e.stopPropagation();
setMenuOpen(false);
setDestructiveAction("remove");
}}
className="block w-full px-3 py-1.5 text-left text-red-400 hover:bg-red-500/10"
>
{lane.kind === "adopted" ? t("action.forget") : t("action.remove")}
</button>
<button
type="button"
role="menuitem"
data-testid="lane-action-purge"
onClick={(e) => {
e.stopPropagation();
setMenuOpen(false);
setDestructiveAction("purge");
}}
className="block w-full px-3 py-1.5 text-left text-red-400 hover:bg-red-500/10"
>
{t("action.purge")}
</button>
</div> </div>
)} )}
</div> </div>
+14 -14
View File
@@ -11,9 +11,9 @@ import { useTranslation } from "react-i18next";
import type { Lane } from "../../lib/types"; import type { Lane } from "../../lib/types";
const LIVENESS_DOT: Record<Lane["liveness"], string> = { const LIVENESS_DOT: Record<Lane["liveness"], string> = {
active: "bg-emerald-400", active: "bg-status-success",
idle: "bg-neutral-500", idle: "bg-surface-4",
dead: "bg-red-500", dead: "bg-status-danger",
}; };
/** Whether the inferred stage sits ahead of the declared one in node order. */ /** Whether the inferred stage sits ahead of the declared one in node order. */
@@ -42,49 +42,49 @@ export default function LaneStripCard({
aria-pressed={selected} aria-pressed={selected}
onClick={onSelect} onClick={onSelect}
title={lane.cwd} title={lane.cwd}
className={`w-56 shrink-0 snap-start rounded-lg border p-3 text-left transition-colors ${ className={`w-56 shrink-0 snap-start rounded-lg border p-3 text-left shadow-sm transition-colors ${
selected selected
? "border-blue-400/70 bg-blue-500/[0.07]" ? "border-accent bg-accent/10"
: "border-neutral-800 bg-neutral-900/60 hover:border-neutral-700" : "border-border bg-surface-2 hover:border-border-light hover:bg-surface-3"
}`} }`}
> >
<div className="mb-1.5 flex items-center gap-1.5"> <div className="mb-1.5 flex items-center gap-1.5">
<span className={`h-2 w-2 shrink-0 rounded-full ${LIVENESS_DOT[lane.liveness]}`} /> <span className={`h-2 w-2 shrink-0 rounded-full ${LIVENESS_DOT[lane.liveness]}`} />
<span className="text-[10px] font-semibold uppercase tracking-widest text-neutral-500"> <span className="text-[10px] font-semibold uppercase tracking-widest text-fg-muted">
{t("cardId", { id: lane.id })} {t("cardId", { id: lane.id })}
</span> </span>
{lane.needs_action && ( {lane.needs_action && (
<span className="ml-auto text-amber-400" title={lane.needs_action}> <span className="ml-auto text-status-warning" title={lane.needs_action}>
</span> </span>
)} )}
</div> </div>
<div className="mb-2 truncate text-[13px] font-medium text-neutral-100"> <div className="mb-2 truncate text-[13px] font-medium text-fg-primary">
{lane.title || lane.cwd} {lane.title || lane.cwd}
</div> </div>
<div className="flex items-center gap-1.5 text-[11px]"> <div className="flex items-center gap-1.5 text-[11px]">
<span className="truncate rounded bg-neutral-800 px-1.5 py-0.5 text-neutral-300"> <span className="truncate rounded bg-surface-4 px-1.5 py-0.5 text-fg-secondary">
{lane.stage} {lane.stage}
</span> </span>
{detectionLeads(lane) && ( {detectionLeads(lane) && (
<span <span
data-testid={`lane-tile-auto-${lane.id}`} data-testid={`lane-tile-auto-${lane.id}`}
title={lane.detected_signal || undefined} title={lane.detected_signal || undefined}
className="shrink-0 rounded border border-dashed border-amber-500 px-1 text-amber-300" className="shrink-0 rounded border border-dashed border-status-warning/50 px-1 text-status-warning"
> >
{lane.detected_stage} {lane.detected_stage}
</span> </span>
)} )}
{lane.progress > 0 && ( {lane.progress > 0 && (
<span className="ml-auto shrink-0 tabular-nums text-neutral-500">{lane.progress}%</span> <span className="ml-auto shrink-0 tabular-nums text-fg-muted">{lane.progress}%</span>
)} )}
</div> </div>
{lane.progress > 0 && ( {lane.progress > 0 && (
<div className="mt-1.5 h-0.5 overflow-hidden rounded-full bg-neutral-800"> <div className="mt-1.5 h-0.5 overflow-hidden rounded-full bg-surface-4">
<div className="h-full rounded-full bg-blue-500" style={{ width: `${lane.progress}%` }} /> <div className="h-full rounded-full bg-accent" style={{ width: `${lane.progress}%` }} />
</div> </div>
)} )}
</button> </button>
+21 -16
View File
@@ -2,28 +2,33 @@
* @file The lane pipeline map: a horizontal chain of stage nodes coloured by * @file The lane pipeline map: a horizontal chain of stage nodes coloured by
* state. Layout is computed from the node list (flex + connectors), never from * state. Layout is computed from the node list (flex + connectors), never from
* hardcoded coordinates, so a lane can use a longer or shorter template without * hardcoded coordinates, so a lane can use a longer or shorter template without
* touching this component. "passed without evidence" is deliberately its own * touching this component. Every state but `current` shares one visual
* colour: a stage the agent claimed but left no artifact for is not the same as * language coloured border, coloured text, a translucent wash of the same
* a stage that is genuinely done. A `detected` node (the server's heuristic saw * colour so status colour means the same thing everywhere in the app, not
* tool-event evidence but the agent never declared it) gets a FOURTH treatment * a different shade per component. `current` is the sole solid fill, in the
* dashed amber, overriding whatever `state` it carries because it must never * app's own accent colour: the one state that gets to look bolder than the
* be mistaken for the solid green of a real "done". * rest, because it answers "where am I right now". "passed without
* evidence" is its own colour: a stage the agent claimed but left no
* artifact for is not the same as a stage that is genuinely done. A
* `detected` node (the server's heuristic saw tool-event evidence but the
* agent never declared it) gets a FOURTH treatment thin dashed warning,
* no fill because it must never be mistaken for a real declaration.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/ */
import type { LaneNode } from "../../lib/types"; import type { LaneNode } from "../../lib/types";
const STATE_CLASS: Record<LaneNode["state"], string> = { const STATE_CLASS: Record<LaneNode["state"], string> = {
done: "border-emerald-500 text-emerald-400 bg-emerald-500/10", done: "border-status-success/60 text-status-success bg-status-success/10",
current: "border-blue-400 text-blue-300 bg-blue-500/20 ring-2 ring-blue-400/40", current: "border-accent bg-accent text-white ring-2 ring-accent/30 shadow-sm",
"passed-no-evidence": "border-amber-500 text-amber-400 bg-amber-500/10", "passed-no-evidence": "border-status-warning/60 text-status-warning bg-status-warning/10",
failed: "border-red-500 text-red-400 bg-red-500/10", failed: "border-status-danger/60 text-status-danger bg-status-danger/10",
pending: "border-neutral-700 text-neutral-500 bg-transparent", pending: "border-border-light text-fg-muted bg-transparent",
}; };
// Dashed border distinguishes an inferred stage from every other class above, // Thin dashed border and no fill keep an inference visually lighter than
// including the solid amber of "passed-no-evidence" — never let it read as done. // every outlined state above — so it never reads as more certain than a claim.
const DETECTED_CLASS = "border-dashed border-amber-400 text-amber-300 bg-amber-500/5"; const DETECTED_CLASS = "border-dashed border-status-warning/50 text-status-warning bg-transparent";
export default function PipelineMap({ export default function PipelineMap({
nodes, nodes,
@@ -32,7 +37,7 @@ export default function PipelineMap({
nodes: LaneNode[]; nodes: LaneNode[];
detectedSignal?: string | null; detectedSignal?: string | null;
}) { }) {
if (!nodes.length) return <div className="text-xs text-neutral-500">no pipeline</div>; if (!nodes.length) return <div className="text-xs text-fg-muted">no pipeline</div>;
return ( return (
// The map spans the panel: every node takes an equal share and the // The map spans the panel: every node takes an equal share and the
// connectors absorb the slack, so the pipeline reads as one track across // connectors absorb the slack, so the pipeline reads as one track across
@@ -54,7 +59,7 @@ export default function PipelineMap({
<span className="shrink-0 text-[13px] leading-none">{n.icon}</span> <span className="shrink-0 text-[13px] leading-none">{n.icon}</span>
<span className="truncate">{n.label}</span> <span className="truncate">{n.label}</span>
</div> </div>
{i < nodes.length - 1 && <div className="h-px w-2 shrink-0 bg-neutral-700 sm:w-3" />} {i < nodes.length - 1 && <div className="h-px w-2 shrink-0 bg-surface-3 sm:w-3" />}
</div> </div>
))} ))}
</div> </div>
@@ -74,52 +74,6 @@ const pipelineNodes: Lane["pipeline_nodes"] = [
{ id: "tests", label: "tests", icon: "🧪", gate: false, state: "pending" }, { id: "tests", label: "tests", icon: "🧪", gate: false, state: "pending" },
]; ];
describe("LaneCard auto: chip", () => {
it("shows the auto chip when the detected stage is ahead of the declared stage", () => {
render(
<LaneCard
lane={makeLane({ stage: "plan", pipeline_nodes: pipelineNodes, detected_stage: "tests" })}
onAction={vi.fn()}
/>
);
expect(screen.getByText("auto: tests")).toBeInTheDocument();
});
it("hides the auto chip when the detected stage matches the declared stage", () => {
render(
<LaneCard
lane={makeLane({ stage: "plan", pipeline_nodes: pipelineNodes, detected_stage: "plan" })}
onAction={vi.fn()}
/>
);
expect(screen.queryByText("auto: plan")).not.toBeInTheDocument();
});
it("hides the auto chip when the detected stage trails the declared stage", () => {
render(
<LaneCard
lane={makeLane({
stage: "implement",
pipeline_nodes: pipelineNodes,
detected_stage: "intake",
})}
onAction={vi.fn()}
/>
);
expect(screen.queryByText("auto: intake")).not.toBeInTheDocument();
});
it("hides the auto chip when nothing is detected", () => {
render(
<LaneCard
lane={makeLane({ stage: "plan", pipeline_nodes: pipelineNodes, detected_stage: null })}
onAction={vi.fn()}
/>
);
expect(screen.queryByText(/^auto:/)).not.toBeInTheDocument();
});
});
describe("LaneCard rebuilt layout", () => { describe("LaneCard rebuilt layout", () => {
const full = (over: Partial<Lane> = {}) => const full = (over: Partial<Lane> = {}) =>
makeLane({ makeLane({
@@ -141,9 +95,8 @@ describe("LaneCard rebuilt layout", () => {
expect(screen.getByTestId("lane-card-7")).toBeInTheDocument(); expect(screen.getByTestId("lane-card-7")).toBeInTheDocument();
}); });
it("shows the declared stage, the progress percentage and the time on stage", () => { it("shows the progress percentage and the time on stage", () => {
render(<LaneCard lane={full()} onAction={vi.fn()} />); render(<LaneCard lane={full()} onAction={vi.fn()} />);
expect(screen.getByTestId("lane-stage").textContent).toBe("plan");
expect(screen.getByText("48%")).toBeInTheDocument(); expect(screen.getByText("48%")).toBeInTheDocument();
expect(screen.getByText("2m 32s")).toBeInTheDocument(); expect(screen.getByText("2m 32s")).toBeInTheDocument();
}); });
@@ -165,16 +118,26 @@ describe("LaneCard rebuilt layout", () => {
expect(onAction).toHaveBeenCalledWith("stop"); expect(onAction).toHaveBeenCalledWith("stop");
}); });
it("keeps the destructive verbs out of the card until the menu is opened", async () => { it("shows both deletions as buttons, and keeps reset behind the menu", async () => {
render(<LaneCard lane={full()} onAction={vi.fn()} />); render(<LaneCard lane={full()} onAction={vi.fn()} />);
// A wall of red buttons makes none of them read as the dangerous one, so // Deleting the lane and deleting its history are the two the user goes
// reset/remove/purge live behind the ⋯ menu. // looking for, so they are visible without opening anything. Reset is not.
expect(screen.getByTestId("lane-action-remove")).toBeInTheDocument();
expect(screen.getByTestId("lane-action-purge")).toBeInTheDocument();
expect(screen.queryByTestId("lane-action-reset")).toBeNull(); expect(screen.queryByTestId("lane-action-reset")).toBeNull();
expect(screen.queryByTestId("lane-action-remove")).toBeNull();
await userEvent.setup().click(screen.getByTestId("lane-more")); await userEvent.setup().click(screen.getByTestId("lane-more"));
expect(screen.getByTestId("lane-action-reset")).toBeInTheDocument(); expect(screen.getByTestId("lane-action-reset")).toBeInTheDocument();
expect(screen.getByTestId("lane-action-remove")).toBeInTheDocument(); });
it("routes both deletions through the modal rather than firing them", async () => {
const onAction = vi.fn();
render(<LaneCard lane={full()} onAction={onAction} />);
const user = userEvent.setup();
await user.click(screen.getByTestId("lane-action-remove"));
expect(onAction).not.toHaveBeenCalled();
await user.click(screen.getByTestId("lane-action-purge"));
expect(onAction).not.toHaveBeenCalled();
}); });
it("routes reset through the confirmation modal rather than firing it", async () => { it("routes reset through the confirmation modal rather than firing it", async () => {
@@ -186,16 +149,18 @@ describe("LaneCard rebuilt layout", () => {
expect(onAction).not.toHaveBeenCalled(); expect(onAction).not.toHaveBeenCalled();
}); });
it("offers reset only for a managed lane", async () => { it("offers reset only for a managed lane, and drops the menu entirely without it", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const { unmount } = render(<LaneCard lane={full()} onAction={vi.fn()} />); const { unmount } = render(<LaneCard lane={full()} onAction={vi.fn()} />);
await user.click(screen.getByTestId("lane-more")); await user.click(screen.getByTestId("lane-more"));
expect(screen.getByTestId("lane-action-reset")).toBeInTheDocument(); expect(screen.getByTestId("lane-action-reset")).toBeInTheDocument();
unmount(); unmount();
// An adopted lane has nothing left in the menu, so there is no ⋯ to open.
render(<LaneCard lane={full({ kind: "adopted" })} onAction={vi.fn()} />); render(<LaneCard lane={full({ kind: "adopted" })} onAction={vi.fn()} />);
await user.click(screen.getByTestId("lane-more")); expect(screen.queryByTestId("lane-more")).toBeNull();
expect(screen.queryByTestId("lane-action-reset")).toBeNull(); expect(screen.queryByTestId("lane-action-reset")).toBeNull();
expect(screen.getByTestId("lane-action-remove")).toBeInTheDocument();
}); });
}); });
@@ -36,9 +36,10 @@ describe("PipelineMap", () => {
render(<PipelineMap nodes={nodes} />); render(<PipelineMap nodes={nodes} />);
const done = screen.getByTestId("pipeline-node-plan").className; const done = screen.getByTestId("pipeline-node-plan").className;
const amber = screen.getByTestId("pipeline-node-implement").className; const amber = screen.getByTestId("pipeline-node-implement").className;
// The done node must contain emerald colour token and the amber node must contain amber token. // The done node must contain the success status token and the amber node
expect(done).toContain("emerald"); // must contain the warning status token.
expect(amber).toContain("amber"); expect(done).toContain("status-success");
expect(amber).toContain("status-warning");
expect(done).not.toEqual(amber); expect(done).not.toEqual(amber);
}); });
@@ -94,8 +95,8 @@ describe("PipelineMap", () => {
render(<PipelineMap nodes={impossible} detectedSignal="npm run test:server" />); render(<PipelineMap nodes={impossible} detectedSignal="npm run test:server" />);
const node = screen.getByTestId("pipeline-node-tests"); const node = screen.getByTestId("pipeline-node-tests");
expect(node.className).toContain("border-dashed"); expect(node.className).toContain("border-dashed");
expect(node.className).toContain("amber"); expect(node.className).toContain("status-warning");
expect(node.className).not.toContain("emerald"); expect(node.className).not.toContain("status-success");
}); });
it("non-detected nodes carry no data-detected attribute", () => { it("non-detected nodes carry no data-detected attribute", () => {
+59 -43
View File
@@ -256,21 +256,22 @@ function TokenMeter({ stats }: { stats: TokenStats }) {
const pct = Math.min(100, Math.round((total / cap) * 100)); const pct = Math.min(100, Math.round((total / cap) * 100));
// Colour is the whole warning mechanism here - the meter is one status line, // Colour is the whole warning mechanism here - the meter is one status line,
// so there is no room for a bar plus five labelled figures. // so there is no room for a bar plus five labelled figures.
const tone = pct >= 95 ? "text-red-300" : pct >= 80 ? "text-amber-300" : "text-gray-400"; const tone =
pct >= 95 ? "text-status-danger" : pct >= 80 ? "text-status-warning" : "text-fg-secondary";
return ( return (
<div className="flex flex-wrap items-center gap-x-3 gap-y-0.5 border-t border-border px-3 py-1.5 font-mono text-[11.5px] text-gray-500"> <div className="flex flex-wrap items-center gap-x-3 gap-y-0.5 border-t border-border px-3 py-1.5 font-mono text-[11.5px] text-fg-muted">
<span className="select-none opacity-60" aria-hidden> <span className="select-none opacity-60" aria-hidden>
</span> </span>
<span className={tone}>{`${formatNum(total)} / ${formatNum(cap)} (${pct}%)`}</span> <span className={tone}>{`${formatNum(total)} / ${formatNum(cap)} (${pct}%)`}</span>
<span title={t("tokens.output")}>{formatNum(stats.outputTokens)}</span> <span title={t("tokens.output")}>{formatNum(stats.outputTokens)}</span>
{stats.cacheReadTokens > 0 && ( {stats.cacheReadTokens > 0 && (
<span className="text-emerald-400/70" title={t("tokens.cacheRead")}> <span className="text-status-success/70" title={t("tokens.cacheRead")}>
{formatNum(stats.cacheReadTokens)} {formatNum(stats.cacheReadTokens)}
</span> </span>
)} )}
{stats.costUsd != null && ( {stats.costUsd != null && (
<span className="text-gray-400">${stats.costUsd.toFixed(4)}</span> <span className="text-fg-secondary">${stats.costUsd.toFixed(4)}</span>
)} )}
</div> </div>
); );
@@ -323,11 +324,11 @@ function commandSourceLabel(s: SlashCommand["source"]): string {
function commandSourceTone(s: SlashCommand["source"]): string { function commandSourceTone(s: SlashCommand["source"]): string {
return s === "builtin" return s === "builtin"
? "bg-gray-500/10 text-gray-400 border-gray-500/30" ? "bg-surface-4/10 text-fg-secondary border-border-light/30"
: s === "user" : s === "user"
? "bg-sky-500/10 text-sky-300 border-sky-500/30" ? "bg-sky-500/10 text-sky-300 border-sky-500/30"
: s === "project" : s === "project"
? "bg-emerald-500/10 text-emerald-300 border-emerald-500/30" ? "bg-status-success/10 text-status-success border-status-success/30"
: "bg-violet-500/10 text-violet-300 border-violet-500/30"; : "bg-violet-500/10 text-violet-300 border-violet-500/30";
} }
@@ -565,11 +566,11 @@ export function PromptEditor({
placeholder={placeholder} placeholder={placeholder}
rows={rows} rows={rows}
spellCheck={false} spellCheck={false}
className="w-full bg-surface-2 border border-border rounded-md px-3 py-2 text-sm text-gray-100 placeholder:text-gray-500 focus:outline-none focus:border-accent/50 resize-y font-sans leading-relaxed" className="w-full bg-surface-2 border border-border rounded-md px-3 py-2 text-sm text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50 resize-y font-sans leading-relaxed"
/> />
{state && ( {state && (
<div className="absolute z-30 left-0 right-0 bottom-full mb-1 rounded-md border border-border bg-surface-1 shadow-lg shadow-black/40 max-h-72 overflow-auto py-1"> <div className="absolute z-30 left-0 right-0 bottom-full mb-1 rounded-md border border-border bg-surface-1 shadow-lg shadow-black/40 max-h-72 overflow-auto py-1">
<div className="px-3 py-1.5 border-b border-border text-[10px] font-semibold uppercase tracking-wider text-gray-500 inline-flex items-center gap-1.5"> <div className="px-3 py-1.5 border-b border-border text-[10px] font-semibold uppercase tracking-wider text-fg-muted inline-flex items-center gap-1.5">
{state.kind === "slash" ? ( {state.kind === "slash" ? (
<> <>
<SlashIcon className="w-3 h-3" /> <SlashIcon className="w-3 h-3" />
@@ -583,7 +584,7 @@ export function PromptEditor({
)} )}
</div> </div>
{items.length === 0 ? ( {items.length === 0 ? (
<div className="px-3 py-2 text-[11px] text-gray-500">{t("autocomplete.noMatches")}</div> <div className="px-3 py-2 text-[11px] text-fg-muted">{t("autocomplete.noMatches")}</div>
) : state.kind === "slash" ? ( ) : state.kind === "slash" ? (
(items as SlashCommand[]).map((c, idx) => ( (items as SlashCommand[]).map((c, idx) => (
<button <button
@@ -597,7 +598,7 @@ export function PromptEditor({
}`} }`}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-mono text-[12px] text-gray-100">/{c.name}</span> <span className="font-mono text-[12px] text-fg-primary">/{c.name}</span>
<span <span
className={`text-[9px] font-mono px-1.5 py-0.5 rounded border ${commandSourceTone(c.source)}`} className={`text-[9px] font-mono px-1.5 py-0.5 rounded border ${commandSourceTone(c.source)}`}
> >
@@ -605,7 +606,7 @@ export function PromptEditor({
</span> </span>
</div> </div>
{c.description && ( {c.description && (
<div className="text-[10.5px] text-gray-500 truncate mt-0.5">{c.description}</div> <div className="text-[10.5px] text-fg-muted truncate mt-0.5">{c.description}</div>
)} )}
</button> </button>
)) ))
@@ -621,8 +622,8 @@ export function PromptEditor({
idx === active ? "bg-accent/15" : "hover:bg-surface-3" idx === active ? "bg-accent/15" : "hover:bg-surface-3"
}`} }`}
> >
<FileCode className="w-3 h-3 text-gray-500 flex-shrink-0" /> <FileCode className="w-3 h-3 text-fg-muted flex-shrink-0" />
<span className="font-mono text-[11px] text-gray-200 truncate">{p}</span> <span className="font-mono text-[11px] text-fg-secondary truncate">{p}</span>
</button> </button>
)) ))
)} )}
@@ -693,16 +694,16 @@ export function RunConsole(props: RunConsoleProps) {
<div className="flex flex-wrap items-center gap-2 border-b border-border px-3 py-1.5 font-mono text-[11.5px]"> <div className="flex flex-wrap items-center gap-2 border-b border-border px-3 py-1.5 font-mono text-[11.5px]">
<StatusPill status={props.handle.status} /> <StatusPill status={props.handle.status} />
<ModeBadge mode={props.mode} /> <ModeBadge mode={props.mode} />
{init?.model && <span className="text-gray-500">{init.model}</span>} {init?.model && <span className="text-fg-muted">{init.model}</span>}
{props.handle.sessionId && ( {props.handle.sessionId && (
<span className="truncate text-gray-600">{props.handle.sessionId.slice(0, 8)}</span> <span className="truncate text-fg-muted">{props.handle.sessionId.slice(0, 8)}</span>
)} )}
<div className="flex-1" /> <div className="flex-1" />
{props.isLive && ( {props.isLive && (
<button <button
onClick={props.onStop} onClick={props.onStop}
disabled={props.busy === "stop"} disabled={props.busy === "stop"}
className="inline-flex items-center gap-1 text-red-300 hover:text-red-200 disabled:opacity-60 transition-colors" className="inline-flex items-center gap-1 text-status-danger hover:text-status-danger disabled:opacity-60 transition-colors"
> >
<Square className="w-3 h-3" /> <Square className="w-3 h-3" />
{props.busy === "stop" ? t("actions.stopping") : t("actions.stop")} {props.busy === "stop" ? t("actions.stopping") : t("actions.stop")}
@@ -711,7 +712,7 @@ export function RunConsole(props: RunConsoleProps) {
{props.handle.sessionId && ( {props.handle.sessionId && (
<Link <Link
to={`/sessions/${encodeURIComponent(props.handle.sessionId)}`} to={`/sessions/${encodeURIComponent(props.handle.sessionId)}`}
className="inline-flex items-center gap-1 text-gray-400 hover:text-gray-200 transition-colors" className="inline-flex items-center gap-1 text-fg-secondary hover:text-fg-primary transition-colors"
> >
<ExternalLink className="w-3 h-3" /> <ExternalLink className="w-3 h-3" />
{t("actions.viewSession")} {t("actions.viewSession")}
@@ -756,7 +757,7 @@ export function RunConsole(props: RunConsoleProps) {
fileCwd={props.handle.cwd} fileCwd={props.handle.cwd}
/> />
<div className="mt-2 flex items-center justify-between"> <div className="mt-2 flex items-center justify-between">
<div className="text-[10px] text-gray-600">{t("hint.shortcut")} · / · @</div> <div className="text-[10px] text-fg-muted">{t("hint.shortcut")} · / · @</div>
<button <button
onClick={props.onSend} onClick={props.onSend}
disabled={!props.followUp.trim() || props.busy === "send"} disabled={!props.followUp.trim() || props.busy === "send"}
@@ -775,7 +776,7 @@ function EmptyStream({ isLive }: { isLive: boolean }) {
const { t } = useTranslation("run"); const { t } = useTranslation("run");
if (isLive) { if (isLive) {
return ( return (
<div className="text-center py-12 text-gray-500 flex flex-col items-center gap-2"> <div className="text-center py-12 text-fg-muted flex flex-col items-center gap-2">
<RefreshCw className="w-5 h-5 animate-spin" /> <RefreshCw className="w-5 h-5 animate-spin" />
<span className="text-xs">{t("status.spawning")}</span> <span className="text-xs">{t("status.spawning")}</span>
</div> </div>
@@ -783,25 +784,37 @@ function EmptyStream({ isLive }: { isLive: boolean }) {
} }
return ( return (
<div className="text-center py-12 flex flex-col items-center gap-2"> <div className="text-center py-12 flex flex-col items-center gap-2">
<Sparkles className="w-6 h-6 text-gray-600" /> <Sparkles className="w-6 h-6 text-fg-muted" />
<div className="text-sm font-medium text-gray-400">{t("empty.title")}</div> <div className="text-sm font-medium text-fg-secondary">{t("empty.title")}</div>
<div className="text-xs text-gray-500 max-w-md">{t("empty.body")}</div> <div className="text-xs text-fg-muted max-w-md">{t("empty.body")}</div>
</div> </div>
); );
} }
export function StatusPill({ status }: { status: string }) { export function StatusPill({ status }: { status: string }) {
const { t } = useTranslation("run"); const { t } = useTranslation("run");
const idle = { color: "bg-surface-3 text-gray-400 border-border", icon: Clock as typeof Play }; const idle = {
color: "bg-surface-3 text-fg-secondary border-border",
icon: Clock as typeof Play,
};
const config: Record<string, { color: string; icon: typeof Play }> = { const config: Record<string, { color: string; icon: typeof Play }> = {
spawning: { color: "bg-amber-500/15 text-amber-300 border-amber-500/30", icon: RefreshCw }, spawning: {
running: { color: "bg-emerald-500/15 text-emerald-300 border-emerald-500/30", icon: Sparkles }, color: "bg-status-warning/15 text-status-warning border-status-warning/30",
icon: RefreshCw,
},
running: {
color: "bg-status-success/15 text-status-success border-status-success/30",
icon: Sparkles,
},
completed: { completed: {
color: "bg-emerald-500/15 text-emerald-300 border-emerald-500/30", color: "bg-status-success/15 text-status-success border-status-success/30",
icon: CheckCircle2, icon: CheckCircle2,
}, },
error: { color: "bg-red-500/15 text-red-300 border-red-500/30", icon: XCircle }, error: {
killed: { color: "bg-gray-500/15 text-gray-400 border-gray-500/30", icon: Square }, color: "bg-status-danger/15 text-status-danger border-status-danger/30",
icon: XCircle,
},
killed: { color: "bg-surface-4/15 text-fg-secondary border-border-light/30", icon: Square },
abandoned: { abandoned: {
color: "bg-orange-500/10 text-orange-300 border-orange-500/30", color: "bg-orange-500/10 text-orange-300 border-orange-500/30",
icon: Square, icon: Square,
@@ -826,7 +839,7 @@ export function StatusPill({ status }: { status: string }) {
export function ModeBadge({ mode }: { mode: RunMode }) { export function ModeBadge({ mode }: { mode: RunMode }) {
const { t } = useTranslation("run"); const { t } = useTranslation("run");
return ( return (
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-surface-3 text-gray-400 border border-border inline-flex items-center gap-1"> <span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-surface-3 text-fg-secondary border border-border inline-flex items-center gap-1">
{mode === "conversation" ? ( {mode === "conversation" ? (
<Terminal className="w-3 h-3" /> <Terminal className="w-3 h-3" />
) : ( ) : (
@@ -894,7 +907,7 @@ function UserTurn({ env }: { env: UserMessage }) {
<span className="select-none text-indigo-400" aria-hidden> <span className="select-none text-indigo-400" aria-hidden>
&gt; &gt;
</span> </span>
<span className="min-w-0 flex-1 whitespace-pre-wrap break-words text-gray-200"> <span className="min-w-0 flex-1 whitespace-pre-wrap break-words text-fg-secondary">
{text || "-"} {text || "-"}
</span> </span>
</div> </div>
@@ -928,10 +941,13 @@ function AssistantTurn({ env }: { env: AssistantMessage }) {
// The gutter glyph is the only speaker marker - no avatar, no label // The gutter glyph is the only speaker marker - no avatar, no label
// row. Prose keeps its markdown rendering; only the chrome is gone. // row. Prose keeps its markdown rendering; only the chrome is gone.
<div className="flex gap-2"> <div className="flex gap-2">
<span className="select-none font-mono text-[12.5px] leading-relaxed text-accent" aria-hidden> <span
className="select-none font-mono text-[12.5px] leading-relaxed text-accent"
aria-hidden
>
</span> </span>
<div className="min-w-0 flex-1 text-[13px] leading-relaxed text-gray-200 prose-claude"> <div className="min-w-0 flex-1 text-[13px] leading-relaxed text-fg-secondary prose-claude">
<MarkdownContent text={text} /> <MarkdownContent text={text} />
</div> </div>
</div> </div>
@@ -978,14 +994,14 @@ function ToolUseBlock({ toolUse }: { toolUse: Extract<ContentBlock, { type: "too
className="flex w-full items-baseline gap-2 text-left hover:bg-white/[0.03] transition-colors" className="flex w-full items-baseline gap-2 text-left hover:bg-white/[0.03] transition-colors"
title={t("events.tool")} title={t("events.tool")}
> >
<span className="select-none text-amber-400" aria-hidden> <span className="select-none text-status-warning" aria-hidden>
</span> </span>
<span className="font-medium text-amber-200">{toolUse.name}</span> <span className="font-medium text-status-warning">{toolUse.name}</span>
{summary && <span className="truncate text-gray-500">{summary}</span>} {summary && <span className="truncate text-fg-muted">{summary}</span>}
</button> </button>
{open && ( {open && (
<pre className="mt-0.5 ml-1.5 max-h-72 overflow-auto border-l border-amber-500/25 pl-3 whitespace-pre-wrap break-words text-gray-400"> <pre className="mt-0.5 ml-1.5 max-h-72 overflow-auto border-l border-status-warning/25 pl-3 whitespace-pre-wrap break-words text-fg-secondary">
{JSON.stringify(toolUse.input, null, 2)} {JSON.stringify(toolUse.input, null, 2)}
</pre> </pre>
)} )}
@@ -1010,7 +1026,7 @@ function ToolResultBlock({ result }: { result: Extract<ContentBlock, { type: "to
.join("\n") .join("\n")
: JSON.stringify(result.content); : JSON.stringify(result.content);
const lines = text.split("\n").length; const lines = text.split("\n").length;
const tone = result.is_error ? "text-red-300" : "text-emerald-300/90"; const tone = result.is_error ? "text-status-danger" : "text-status-success/90";
// First line is the useful one nine times out of ten, so it doubles as the // First line is the useful one nine times out of ten, so it doubles as the
// collapsed summary instead of a generic "tool result" label. // collapsed summary instead of a generic "tool result" label.
const firstLine = text.split("\n").find((l) => l.trim()) || ""; const firstLine = text.split("\n").find((l) => l.trim()) || "";
@@ -1037,9 +1053,9 @@ function ToolResultBlock({ result }: { result: Extract<ContentBlock, { type: "to
function UnknownTurn({ env }: { env: Envelope }) { function UnknownTurn({ env }: { env: Envelope }) {
return ( return (
<details className="font-mono text-[11.5px] leading-relaxed text-gray-500"> <details className="font-mono text-[11.5px] leading-relaxed text-fg-muted">
<summary className="cursor-pointer">? {(env.type as string) || "unknown"}</summary> <summary className="cursor-pointer">? {(env.type as string) || "unknown"}</summary>
<pre className="mt-0.5 ml-1.5 max-h-48 overflow-auto border-l border-border pl-3 whitespace-pre-wrap break-words text-gray-500"> <pre className="mt-0.5 ml-1.5 max-h-48 overflow-auto border-l border-border pl-3 whitespace-pre-wrap break-words text-fg-muted">
{JSON.stringify(env, null, 2)} {JSON.stringify(env, null, 2)}
</pre> </pre>
</details> </details>
@@ -1061,21 +1077,21 @@ function ResultFooter({ result }: { result: ResultEnvelope }) {
const { t } = useTranslation("run"); const { t } = useTranslation("run");
const isError = result.is_error; const isError = result.is_error;
const parts: string[] = []; const parts: string[] = [];
if (typeof result.duration_ms === "number") parts.push(`${(result.duration_ms / 1000).toFixed(1)}s`); if (typeof result.duration_ms === "number")
parts.push(`${(result.duration_ms / 1000).toFixed(1)}s`);
if (typeof result.total_cost_usd === "number") parts.push(`$${result.total_cost_usd.toFixed(4)}`); if (typeof result.total_cost_usd === "number") parts.push(`$${result.total_cost_usd.toFixed(4)}`);
if (typeof result.num_turns === "number") parts.push(t("footer.turns") + " " + result.num_turns); if (typeof result.num_turns === "number") parts.push(t("footer.turns") + " " + result.num_turns);
return ( return (
<div <div
className={`border-t border-border px-3 py-1.5 font-mono text-[11.5px] ${ className={`border-t border-border px-3 py-1.5 font-mono text-[11.5px] ${
isError ? "text-red-300" : "text-emerald-300/90" isError ? "text-status-danger" : "text-status-success/90"
}`} }`}
> >
<span className="select-none opacity-60" aria-hidden> <span className="select-none opacity-60" aria-hidden>
{" "} {" "}
</span> </span>
{isError ? t("status.error") : t("status.completed")} {isError ? t("status.error") : t("status.completed")}
{parts.length > 0 && <span className="text-gray-500"> · {parts.join(" · ")}</span>} {parts.length > 0 && <span className="text-fg-muted"> · {parts.join(" · ")}</span>}
</div> </div>
); );
} }
+27 -27
View File
@@ -148,20 +148,20 @@ export function ActiveRunsSwitcher({
disabled={totalCount === 0} disabled={totalCount === 0}
className={`inline-flex items-center gap-2 rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${ className={`inline-flex items-center gap-2 rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
liveCount > 0 liveCount > 0
? "border-emerald-500/40 bg-emerald-500/10 text-emerald-200 hover:bg-emerald-500/15" ? "border-status-success/40 bg-status-success/10 text-status-success hover:bg-status-success/15"
: "border-border bg-surface-2 text-gray-300 hover:bg-surface-3" : "border-border bg-surface-2 text-fg-secondary hover:bg-surface-3"
}`} }`}
> >
<ListOrdered className="w-3.5 h-3.5" /> <ListOrdered className="w-3.5 h-3.5" />
{liveCount > 0 ? ( {liveCount > 0 ? (
<> <>
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" /> <span className="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse" />
{t("runs.viewActive_other", { count: liveCount })} {t("runs.viewActive_other", { count: liveCount })}
</> </>
) : ( ) : (
<> <>
{t("runs.switcher")} {t("runs.switcher")}
{totalCount > 0 && <span className="text-gray-500 font-mono">{totalCount}</span>} {totalCount > 0 && <span className="text-fg-muted font-mono">{totalCount}</span>}
</> </>
)} )}
</button> </button>
@@ -277,10 +277,10 @@ export function RunsModal({
<ListOrdered className="w-4 h-4 text-accent" /> <ListOrdered className="w-4 h-4 text-accent" />
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<h2 className="text-sm font-semibold text-gray-100"> <h2 className="text-sm font-semibold text-fg-primary">
{t("runs.modalTitle", "Dashboard runs")} {t("runs.modalTitle", "Dashboard runs")}
</h2> </h2>
<p className="text-[11px] text-gray-500"> <p className="text-[11px] text-fg-muted">
{t( {t(
"runs.modalSubtitle", "runs.modalSubtitle",
"Every run started from this dashboard, regardless of status" "Every run started from this dashboard, regardless of status"
@@ -289,7 +289,7 @@ export function RunsModal({
</div> </div>
<button <button
onClick={onRefresh} onClick={onRefresh}
className="w-7 h-7 rounded-md text-gray-500 hover:text-gray-200 hover:bg-surface-3 inline-flex items-center justify-center" className="w-7 h-7 rounded-md text-fg-muted hover:text-fg-secondary hover:bg-surface-3 inline-flex items-center justify-center"
aria-label={t("runs.refresh", "Refresh")} aria-label={t("runs.refresh", "Refresh")}
title={t("runs.refresh", "Refresh")} title={t("runs.refresh", "Refresh")}
> >
@@ -304,7 +304,7 @@ export function RunsModal({
</Link> </Link>
<button <button
onClick={onClose} onClick={onClose}
className="w-7 h-7 rounded-md text-gray-500 hover:text-gray-200 hover:bg-surface-3 inline-flex items-center justify-center" className="w-7 h-7 rounded-md text-fg-muted hover:text-fg-secondary hover:bg-surface-3 inline-flex items-center justify-center"
aria-label={t("limitations.dismiss")} aria-label={t("limitations.dismiss")}
> >
<X className="w-4 h-4" /> <X className="w-4 h-4" />
@@ -314,18 +314,18 @@ export function RunsModal({
{/* Filter bar */} {/* Filter bar */}
<div className="px-5 py-3 border-b border-border flex flex-col gap-2.5 flex-shrink-0"> <div className="px-5 py-3 border-b border-border flex flex-col gap-2.5 flex-shrink-0">
<div className="flex items-center gap-2 bg-surface-2 border border-border rounded-md px-2.5 py-1.5"> <div className="flex items-center gap-2 bg-surface-2 border border-border rounded-md px-2.5 py-1.5">
<Search className="w-3.5 h-3.5 text-gray-500 flex-shrink-0" /> <Search className="w-3.5 h-3.5 text-fg-muted flex-shrink-0" />
<input <input
autoFocus autoFocus
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
placeholder={t("runs.searchPlaceholder", "Search prompt, cwd, model, or session id…")} placeholder={t("runs.searchPlaceholder", "Search prompt, cwd, model, or session id…")}
className="flex-1 bg-transparent text-[12px] text-gray-100 placeholder:text-gray-600 focus:outline-none" className="flex-1 bg-transparent text-[12px] text-fg-primary placeholder:text-fg-muted focus:outline-none"
/> />
{search && ( {search && (
<button <button
onClick={() => setSearch("")} onClick={() => setSearch("")}
className="text-gray-500 hover:text-gray-200 text-[10px]" className="text-fg-muted hover:text-fg-secondary text-[10px]"
aria-label="Clear" aria-label="Clear"
> >
<X className="w-3 h-3" /> <X className="w-3 h-3" />
@@ -359,7 +359,7 @@ export function RunsModal({
{/* List */} {/* List */}
<div className="flex-1 min-h-0 overflow-auto divide-y divide-border"> <div className="flex-1 min-h-0 overflow-auto divide-y divide-border">
{filtered.length === 0 ? ( {filtered.length === 0 ? (
<div className="px-5 py-12 text-center text-[12px] text-gray-500"> <div className="px-5 py-12 text-center text-[12px] text-fg-muted">
{rows.length === 0 {rows.length === 0
? t( ? t(
"runs.modalEmpty", "runs.modalEmpty",
@@ -392,11 +392,11 @@ export function RunsModal({
{/* Footer */} {/* Footer */}
<div className="px-5 py-2.5 border-t border-border bg-surface-2/40 flex items-center gap-2 flex-shrink-0"> <div className="px-5 py-2.5 border-t border-border bg-surface-2/40 flex items-center gap-2 flex-shrink-0">
<Info className="w-3 h-3 text-gray-500 flex-shrink-0" /> <Info className="w-3 h-3 text-fg-muted flex-shrink-0" />
<span className="text-[10.5px] text-gray-500 leading-relaxed flex-1"> <span className="text-[10.5px] text-fg-muted leading-relaxed flex-1">
{t("runs.scopeNote")} {t("runs.scopeNote")}
</span> </span>
<span className="text-[10.5px] text-gray-500 font-mono"> <span className="text-[10.5px] text-fg-muted font-mono">
{filtered.length} / {rows.length} {filtered.length} / {rows.length}
</span> </span>
</div> </div>
@@ -418,7 +418,7 @@ function FilterChipGroup<T extends string>({
}) { }) {
return ( return (
<div className="flex items-center gap-1.5 flex-wrap"> <div className="flex items-center gap-1.5 flex-wrap">
<span className="text-[10px] uppercase tracking-wider font-semibold text-gray-500 mr-1"> <span className="text-[10px] uppercase tracking-wider font-semibold text-fg-muted mr-1">
{label} {label}
</span> </span>
{options.map((opt) => { {options.map((opt) => {
@@ -432,11 +432,11 @@ function FilterChipGroup<T extends string>({
className={`text-[10.5px] font-medium px-2 py-0.5 rounded-full border transition-colors disabled:opacity-40 ${ className={`text-[10.5px] font-medium px-2 py-0.5 rounded-full border transition-colors disabled:opacity-40 ${
active active
? "bg-accent/15 border-accent/50 text-accent" ? "bg-accent/15 border-accent/50 text-accent"
: "bg-surface-2 border-border text-gray-300 hover:bg-surface-3 hover:border-border-strong" : "bg-surface-2 border-border text-fg-secondary hover:bg-surface-3 hover:border-border-strong"
}`} }`}
> >
{opt.label} {opt.label}
<span className="ml-1 text-gray-500 font-mono">{opt.count}</span> <span className="ml-1 text-fg-muted font-mono">{opt.count}</span>
</button> </button>
); );
})} })}
@@ -481,8 +481,8 @@ function UnifiedRunRowView({
<StatusPill status={row.status} /> <StatusPill status={row.status} />
<ModeBadge mode={row.mode} /> <ModeBadge mode={row.mode} />
{row.isLive && ( {row.isLive && (
<span className="text-[10px] font-semibold text-emerald-300 bg-emerald-500/10 border border-emerald-500/25 px-1.5 py-0.5 rounded-full inline-flex items-center gap-1"> <span className="text-[10px] font-semibold text-status-success bg-status-success/10 border border-status-success/25 px-1.5 py-0.5 rounded-full inline-flex items-center gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" /> <span className="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse" />
{t("runs.liveBadge", "live")} {t("runs.liveBadge", "live")}
</span> </span>
)} )}
@@ -495,7 +495,7 @@ function UnifiedRunRowView({
{row.isLive && !isCurrent && ( {row.isLive && !isCurrent && (
<button <button
onClick={onAttach} onClick={onAttach}
className="inline-flex items-center gap-1 rounded-md border border-emerald-500/40 bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-200 px-2 py-0.5 text-[10.5px] font-medium transition-colors" className="inline-flex items-center gap-1 rounded-md border border-status-success/40 bg-status-success/10 hover:bg-status-success/20 text-status-success px-2 py-0.5 text-[10.5px] font-medium transition-colors"
> >
<Play className="w-3 h-3" /> <Play className="w-3 h-3" />
{t("runs.attachLabel", "Attach")} {t("runs.attachLabel", "Attach")}
@@ -513,7 +513,7 @@ function UnifiedRunRowView({
{canView && ( {canView && (
<button <button
onClick={onView} onClick={onView}
className="inline-flex items-center gap-1 rounded-md border border-border bg-surface-2 hover:bg-surface-3 text-gray-300 hover:text-gray-100 px-2 py-0.5 text-[10.5px] font-medium transition-colors" className="inline-flex items-center gap-1 rounded-md border border-border bg-surface-2 hover:bg-surface-3 text-fg-secondary hover:text-fg-primary px-2 py-0.5 text-[10.5px] font-medium transition-colors"
> >
<Eye className="w-3 h-3" /> <Eye className="w-3 h-3" />
{t("runs.viewLabel", "View")} {t("runs.viewLabel", "View")}
@@ -522,18 +522,18 @@ function UnifiedRunRowView({
</span> </span>
</div> </div>
{row.promptPreview && ( {row.promptPreview && (
<div className="text-[12px] text-gray-300 line-clamp-2 leading-snug"> <div className="text-[12px] text-fg-secondary line-clamp-2 leading-snug">
{row.promptPreview} {row.promptPreview}
</div> </div>
)} )}
<div className="font-mono text-[10px] text-gray-500 truncate mt-1">{row.cwd}</div> <div className="font-mono text-[10px] text-fg-muted truncate mt-1">{row.cwd}</div>
<div className="text-[10px] text-gray-600 mt-0.5 flex items-center gap-2 flex-wrap"> <div className="text-[10px] text-fg-muted mt-0.5 flex items-center gap-2 flex-wrap">
<span>{startedLabel}</span> <span>{startedLabel}</span>
{row.model && <span className="font-mono text-gray-500">· {row.model}</span>} {row.model && <span className="font-mono text-fg-muted">· {row.model}</span>}
{row.sessionId && ( {row.sessionId && (
<Link <Link
to={`/sessions/${encodeURIComponent(row.sessionId)}`} to={`/sessions/${encodeURIComponent(row.sessionId)}`}
className="inline-flex items-center gap-1 text-gray-500 hover:text-gray-300 transition-colors" className="inline-flex items-center gap-1 text-fg-muted hover:text-fg-secondary transition-colors"
title={t("actions.viewSession")} title={t("actions.viewSession")}
> >
<ExternalLink className="w-2.5 h-2.5" /> <ExternalLink className="w-2.5 h-2.5" />
+34 -33
View File
@@ -54,7 +54,6 @@ import type { SlashCommand } from "./RunConsole";
// ── Limitations banner (above the config card) ──────────────────────── // ── Limitations banner (above the config card) ────────────────────────
interface RunSetupProps { interface RunSetupProps {
mode: RunMode; mode: RunMode;
onModeChange: (m: RunMode) => void; onModeChange: (m: RunMode) => void;
@@ -159,7 +158,7 @@ export function RunSetup(props: RunSetupProps) {
{/* Prompt */} {/* Prompt */}
<div className="px-4 py-3 border-b border-border"> <div className="px-4 py-3 border-b border-border">
<label className="block text-[11px] font-semibold uppercase tracking-wider text-gray-500 mb-1.5"> <label className="block text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1.5">
{t("fields.prompt")} {t("fields.prompt")}
</label> </label>
<PromptEditor <PromptEditor
@@ -171,7 +170,7 @@ export function RunSetup(props: RunSetupProps) {
slashCommands={props.slashCommands} slashCommands={props.slashCommands}
fileCwd={props.resumeSession?.cwd || props.cwd} fileCwd={props.resumeSession?.cwd || props.cwd}
/> />
<div className="mt-1 text-[10px] text-gray-600"> <div className="mt-1 text-[10px] text-fg-muted">
{t("hint.shortcut")} · / for slash commands · @ for file references {t("hint.shortcut")} · / for slash commands · @ for file references
</div> </div>
</div> </div>
@@ -180,8 +179,8 @@ export function RunSetup(props: RunSetupProps) {
<div className="grid grid-cols-1 gap-3 px-4 py-3 sm:grid-cols-2 lg:grid-cols-4"> <div className="grid grid-cols-1 gap-3 px-4 py-3 sm:grid-cols-2 lg:grid-cols-4">
<Field label={t("fields.cwd")}> <Field label={t("fields.cwd")}>
{isResume && props.resumeSession ? ( {isResume && props.resumeSession ? (
<div className="bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] font-mono text-gray-300 flex items-center gap-2"> <div className="bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] font-mono text-fg-secondary flex items-center gap-2">
<Lock className="w-3 h-3 text-gray-500 flex-shrink-0" /> <Lock className="w-3 h-3 text-fg-muted flex-shrink-0" />
<span className="truncate">{props.resumeSession.cwd}</span> <span className="truncate">{props.resumeSession.cwd}</span>
</div> </div>
) : ( ) : (
@@ -223,7 +222,7 @@ export function RunSetup(props: RunSetupProps) {
</div> </div>
{props.permissionMode === "bypassPermissions" && ( {props.permissionMode === "bypassPermissions" && (
<div className="mx-4 mb-3 rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-[11px] text-red-200 flex items-start gap-2"> <div className="mx-4 mb-3 rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-[11px] text-status-danger flex items-start gap-2">
<ShieldAlert className="w-3.5 h-3.5 flex-shrink-0 mt-0.5" /> <ShieldAlert className="w-3.5 h-3.5 flex-shrink-0 mt-0.5" />
<span>{t("hint.permissionWarning")}</span> <span>{t("hint.permissionWarning")}</span>
</div> </div>
@@ -233,13 +232,13 @@ export function RunSetup(props: RunSetupProps) {
<div className="border-t border-border px-4 py-3 flex items-center justify-between gap-3 flex-wrap"> <div className="border-t border-border px-4 py-3 flex items-center justify-between gap-3 flex-wrap">
<div className="flex items-center gap-3 text-[11px] min-w-0"> <div className="flex items-center gap-3 text-[11px] min-w-0">
{atCap ? ( {atCap ? (
<span className="inline-flex items-center gap-1.5 text-amber-300"> <span className="inline-flex items-center gap-1.5 text-status-warning">
<AlertCircle className="w-3.5 h-3.5" /> <AlertCircle className="w-3.5 h-3.5" />
{t("concurrency.atCap", { max: props.activeRuns?.maxConcurrent ?? 0 })} {t("concurrency.atCap", { max: props.activeRuns?.maxConcurrent ?? 0 })}
</span> </span>
) : props.activeRuns && props.activeRuns.activeCount > 0 ? ( ) : props.activeRuns && props.activeRuns.activeCount > 0 ? (
<span className="inline-flex items-center gap-1.5 text-gray-400"> <span className="inline-flex items-center gap-1.5 text-fg-secondary">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" /> <span className="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse" />
{t("concurrency.active", { count: props.activeRuns.activeCount })} {t("concurrency.active", { count: props.activeRuns.activeCount })}
</span> </span>
) : null} ) : null}
@@ -291,7 +290,7 @@ function Seg({
title={title} title={title}
aria-pressed={active} aria-pressed={active}
className={`rounded px-2 py-0.5 font-medium transition-colors ${ className={`rounded px-2 py-0.5 font-medium transition-colors ${
active ? "bg-accent/20 text-accent" : "text-gray-400 hover:text-gray-200" active ? "bg-accent/20 text-accent" : "text-fg-secondary hover:text-fg-primary"
}`} }`}
> >
{label} {label}
@@ -302,7 +301,7 @@ function Seg({
function Field({ label, children }: { label: string; children: React.ReactNode }) { function Field({ label, children }: { label: string; children: React.ReactNode }) {
return ( return (
<div> <div>
<label className="block text-[10px] font-semibold uppercase tracking-wider text-gray-500 mb-1"> <label className="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1">
{label} {label}
</label> </label>
{children} {children}
@@ -397,7 +396,7 @@ export function CwdAutocomplete({
return ( return (
<div ref={containerRef} className="relative"> <div ref={containerRef} className="relative">
<div className="relative"> <div className="relative">
<FolderOpen className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-500 pointer-events-none" /> <FolderOpen className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-fg-muted pointer-events-none" />
<input <input
ref={inputRef} ref={inputRef}
id={inputId} id={inputId}
@@ -413,17 +412,17 @@ export function CwdAutocomplete({
placeholder={t("fields.cwdPlaceholder")} placeholder={t("fields.cwdPlaceholder")}
autoComplete="off" autoComplete="off"
spellCheck={false} spellCheck={false}
className="w-full bg-surface-2 border border-border rounded-md pl-7 pr-3 py-1.5 text-[11px] font-mono text-gray-100 placeholder:text-gray-500 focus:outline-none focus:border-accent/50" className="w-full bg-surface-2 border border-border rounded-md pl-7 pr-3 py-1.5 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50"
/> />
</div> </div>
{open && ( {open && (
<div className="absolute z-30 left-0 right-0 mt-1 rounded-md border border-border bg-surface-1 shadow-lg shadow-black/40 max-h-72 overflow-auto py-1"> <div className="absolute z-30 left-0 right-0 mt-1 rounded-md border border-border bg-surface-1 shadow-lg shadow-black/40 max-h-72 overflow-auto py-1">
{groups.length === 0 ? ( {groups.length === 0 ? (
<div className="px-3 py-2 text-[11px] text-gray-500">{t("fields.cwdNoMatches")}</div> <div className="px-3 py-2 text-[11px] text-fg-muted">{t("fields.cwdNoMatches")}</div>
) : ( ) : (
groups.map((g) => ( groups.map((g) => (
<div key={g.kind}> <div key={g.kind}>
<div className="px-3 pt-1.5 pb-0.5 text-[10px] font-semibold uppercase tracking-wider text-gray-500 flex items-center gap-1.5"> <div className="px-3 pt-1.5 pb-0.5 text-[10px] font-semibold uppercase tracking-wider text-fg-muted flex items-center gap-1.5">
{g.kind === "dashboard" ? ( {g.kind === "dashboard" ? (
<FolderOpen className="w-3 h-3" /> <FolderOpen className="w-3 h-3" />
) : g.kind === "home" ? ( ) : g.kind === "home" ? (
@@ -447,8 +446,8 @@ export function CwdAutocomplete({
isActive ? "bg-accent/15" : "hover:bg-surface-3" isActive ? "bg-accent/15" : "hover:bg-surface-3"
}`} }`}
> >
<div className="text-[11px] text-gray-200 truncate">{s.label}</div> <div className="text-[11px] text-fg-secondary truncate">{s.label}</div>
<div className="font-mono text-[10px] text-gray-500 truncate">{s.path}</div> <div className="font-mono text-[10px] text-fg-muted truncate">{s.path}</div>
</button> </button>
); );
})} })}
@@ -527,13 +526,13 @@ function SessionPicker({
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-accent/15 text-accent border border-accent/30"> <span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-accent/15 text-accent border border-accent/30">
{t("resume.selectedBadge")} {t("resume.selectedBadge")}
</span> </span>
<span className="font-mono text-[11px] text-gray-200 truncate">{selected.id}</span> <span className="font-mono text-[11px] text-fg-secondary truncate">{selected.id}</span>
</div> </div>
<div className="font-mono text-[10px] text-gray-500 truncate mt-0.5">{selected.cwd}</div> <div className="font-mono text-[10px] text-fg-muted truncate mt-0.5">{selected.cwd}</div>
</div> </div>
<button <button
onClick={() => onSelect(null)} onClick={() => onSelect(null)}
className="text-[10px] font-medium px-2 py-0.5 rounded border border-border bg-surface-2 hover:bg-surface-3 text-gray-300 inline-flex items-center gap-1 flex-shrink-0" className="text-[10px] font-medium px-2 py-0.5 rounded border border-border bg-surface-2 hover:bg-surface-3 text-fg-secondary inline-flex items-center gap-1 flex-shrink-0"
> >
<X className="w-3 h-3" /> <X className="w-3 h-3" />
{t("resume.clear")} {t("resume.clear")}
@@ -546,7 +545,7 @@ function SessionPicker({
<div ref={containerRef} className="relative mt-2"> <div ref={containerRef} className="relative mt-2">
<button <button
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((v) => !v)}
className="w-full text-left rounded-md border border-dashed border-border bg-surface-2 hover:bg-surface-3 px-3 py-2 text-[11px] text-gray-400 inline-flex items-center gap-2" className="w-full text-left rounded-md border border-dashed border-border bg-surface-2 hover:bg-surface-3 px-3 py-2 text-[11px] text-fg-secondary inline-flex items-center gap-2"
> >
<RotateCcw className="w-3.5 h-3.5" /> <RotateCcw className="w-3.5 h-3.5" />
{t("resume.pickSession")} {t("resume.pickSession")}
@@ -555,20 +554,20 @@ function SessionPicker({
{open && ( {open && (
<div className="absolute z-30 left-0 right-0 mt-1 rounded-md border border-border bg-surface-1 shadow-lg shadow-black/40 overflow-hidden"> <div className="absolute z-30 left-0 right-0 mt-1 rounded-md border border-border bg-surface-1 shadow-lg shadow-black/40 overflow-hidden">
<div className="px-3 py-2 border-b border-border flex items-center gap-2"> <div className="px-3 py-2 border-b border-border flex items-center gap-2">
<Search className="w-3.5 h-3.5 text-gray-500 flex-shrink-0" /> <Search className="w-3.5 h-3.5 text-fg-muted flex-shrink-0" />
<input <input
autoFocus autoFocus
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
placeholder={t("resume.search")} placeholder={t("resume.search")}
className="bg-transparent text-[11px] text-gray-100 placeholder:text-gray-500 focus:outline-none w-full" className="bg-transparent text-[11px] text-fg-primary placeholder:text-fg-muted focus:outline-none w-full"
/> />
</div> </div>
<div className="max-h-72 overflow-auto py-1"> <div className="max-h-72 overflow-auto py-1">
{sessions === null ? ( {sessions === null ? (
<div className="px-3 py-2 text-[11px] text-gray-500"></div> <div className="px-3 py-2 text-[11px] text-fg-muted"></div>
) : filtered.length === 0 ? ( ) : filtered.length === 0 ? (
<div className="px-3 py-2 text-[11px] text-gray-500">{t("resume.noSessions")}</div> <div className="px-3 py-2 text-[11px] text-fg-muted">{t("resume.noSessions")}</div>
) : ( ) : (
filtered.map((s) => ( filtered.map((s) => (
<button <button
@@ -584,27 +583,29 @@ function SessionPicker({
<span <span
className={`text-[10px] font-mono px-1.5 py-0.5 rounded border ${ className={`text-[10px] font-mono px-1.5 py-0.5 rounded border ${
s.status === "active" s.status === "active"
? "bg-emerald-500/10 text-emerald-300 border-emerald-500/30" ? "bg-status-success/10 text-status-success border-status-success/30"
: s.status === "completed" : s.status === "completed"
? "bg-sky-500/10 text-sky-300 border-sky-500/30" ? "bg-sky-500/10 text-sky-300 border-sky-500/30"
: s.status === "error" : s.status === "error"
? "bg-red-500/10 text-red-300 border-red-500/30" ? "bg-status-danger/10 text-status-danger border-status-danger/30"
: "bg-surface-3 text-gray-400 border-border" : "bg-surface-3 text-fg-secondary border-border"
}`} }`}
> >
{s.status} {s.status}
</span> </span>
{s.name?.trim() && ( {s.name?.trim() && (
<span className="text-[11px] text-gray-200 truncate">{s.name.trim()}</span> <span className="text-[11px] text-fg-secondary truncate">
{s.name.trim()}
</span>
)} )}
<span className="font-mono text-[11px] text-gray-400 truncate flex-shrink-0"> <span className="font-mono text-[11px] text-fg-secondary truncate flex-shrink-0">
{s.id.slice(0, 12)} {s.id.slice(0, 12)}
</span> </span>
<span className="text-[10px] text-gray-600 ml-auto flex-shrink-0"> <span className="text-[10px] text-fg-muted ml-auto flex-shrink-0">
{new Date(s.started_at).toLocaleString()} {new Date(s.started_at).toLocaleString()}
</span> </span>
</div> </div>
<div className="font-mono text-[10px] text-gray-500 truncate">{s.cwd}</div> <div className="font-mono text-[10px] text-fg-muted truncate">{s.cwd}</div>
</button> </button>
)) ))
)} )}
@@ -666,7 +667,7 @@ function ModelPicker({ value, onChange }: { value: string; onChange: (s: string)
placeholder={t("fields.modelCustomPlaceholder")} placeholder={t("fields.modelCustomPlaceholder")}
autoComplete="off" autoComplete="off"
spellCheck={false} spellCheck={false}
className="w-full bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] font-mono text-gray-100 placeholder:text-gray-500 focus:outline-none focus:border-accent/50" className="w-full bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50"
/> />
)} )}
</div> </div>
@@ -581,8 +581,8 @@ export function AgentCollaborationNetwork({
if (isEmpty) { if (isEmpty) {
return ( return (
<div className="flex flex-col items-center justify-center py-16 text-center"> <div className="flex flex-col items-center justify-center py-16 text-center">
<p className="text-sm font-medium text-gray-400">{t("pipeline.noData")}</p> <p className="text-sm font-medium text-fg-secondary">{t("pipeline.noData")}</p>
<p className="text-xs text-gray-600 mt-1">{t("pipeline.noDataDesc")}</p> <p className="text-xs text-fg-muted mt-1">{t("pipeline.noDataDesc")}</p>
</div> </div>
); );
} }
@@ -615,7 +615,7 @@ export function AgentCollaborationNetwork({
}} }}
/> />
<div className="flex flex-wrap items-center gap-3 mt-3 px-1"> <div className="flex flex-wrap items-center gap-3 mt-3 px-1">
<span className="text-[10px] text-gray-600 uppercase tracking-widest font-medium"> <span className="text-[10px] text-fg-muted uppercase tracking-widest font-medium">
{t("pipeline.legend")} {t("pipeline.legend")}
</span> </span>
{nodes.map((n) => ( {nodes.map((n) => (
@@ -627,7 +627,7 @@ export function AgentCollaborationNetwork({
border: `1.5px solid ${STROKE_PALETTE[n.colorIndex] ?? STROKE_PALETTE[0]}`, border: `1.5px solid ${STROKE_PALETTE[n.colorIndex] ?? STROKE_PALETTE[0]}`,
}} }}
/> />
<span className="text-[11px] text-gray-500">{n.id}</span> <span className="text-[11px] text-fg-muted">{n.id}</span>
</div> </div>
))} ))}
<div className="flex items-center gap-1.5 ml-2"> <div className="flex items-center gap-1.5 ml-2">
@@ -635,7 +635,7 @@ export function AgentCollaborationNetwork({
<line x1="0" y1="4" x2="14" y2="4" stroke="#64748b" strokeWidth="1.5" /> <line x1="0" y1="4" x2="14" y2="4" stroke="#64748b" strokeWidth="1.5" />
<polygon points="14,1 20,4 14,7" fill="#64748b" /> <polygon points="14,1 20,4 14,7" fill="#64748b" />
</svg> </svg>
<span className="text-[11px] text-gray-500">{t("pipeline.legendDesc")}</span> <span className="text-[11px] text-fg-muted">{t("pipeline.legendDesc")}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -284,10 +284,10 @@ function StatBox({ label, value, sub, accent = "text-accent" }: StatBoxProps) {
return ( return (
<div className="flex flex-col gap-1 bg-surface-3 border border-border rounded-xl px-4 py-3.5 flex-1 min-w-0"> <div className="flex flex-col gap-1 bg-surface-3 border border-border rounded-xl px-4 py-3.5 flex-1 min-w-0">
<span className={`text-2xl font-semibold tabular-nums ${accent}`}>{value}</span> <span className={`text-2xl font-semibold tabular-nums ${accent}`}>{value}</span>
<span className="text-[11px] font-medium text-gray-500 uppercase tracking-wider leading-tight"> <span className="text-[11px] font-medium text-fg-muted uppercase tracking-wider leading-tight">
{label} {label}
</span> </span>
{sub && <span className="text-[11px] text-gray-600 tabular-nums">{sub}</span>} {sub && <span className="text-[11px] text-fg-muted tabular-nums">{sub}</span>}
</div> </div>
); );
} }
@@ -334,7 +334,7 @@ export function CompactionImpact({ data }: CompactionImpactProps) {
if (!hasData) { if (!hasData) {
return ( return (
<div className="flex flex-col items-center justify-center py-16 gap-3 text-gray-500"> <div className="flex flex-col items-center justify-center py-16 gap-3 text-fg-muted">
<svg <svg
width="40" width="40"
height="40" height="40"
@@ -357,7 +357,7 @@ export function CompactionImpact({ data }: CompactionImpactProps) {
return ( return (
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
{/* What compaction is - one line so the numbers below make sense */} {/* What compaction is - one line so the numbers below make sense */}
<p className="text-xs text-gray-500 leading-relaxed">{t("compaction.help")}</p> <p className="text-xs text-fg-muted leading-relaxed">{t("compaction.help")}</p>
{/* Stat tiles */} {/* Stat tiles */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3"> <div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
@@ -375,18 +375,18 @@ export function CompactionImpact({ data }: CompactionImpactProps) {
<StatBox <StatBox
label={t("compaction.avgPerSession")} label={t("compaction.avgPerSession")}
value={avgPerSession.toFixed(1)} value={avgPerSession.toFixed(1)}
accent="text-blue-300" accent="text-blue-400"
/> />
<StatBox <StatBox
label={t("compaction.peakSession")} label={t("compaction.peakSession")}
value={peak.toLocaleString()} value={peak.toLocaleString()}
accent="text-emerald-400" accent="text-status-success"
/> />
</div> </div>
{/* Histogram: sessions by compaction count */} {/* Histogram: sessions by compaction count */}
<div className="w-full overflow-hidden"> <div className="w-full overflow-hidden">
<p className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-2"> <p className="text-xs font-medium text-fg-muted uppercase tracking-wider mb-2">
{t("compaction.distribution")} {t("compaction.distribution")}
</p> </p>
<svg <svg
@@ -399,7 +399,7 @@ export function CompactionImpact({ data }: CompactionImpactProps) {
</div> </div>
{/* Plain-English summary + (when present) tokens freed */} {/* Plain-English summary + (when present) tokens freed */}
<p className="text-xs text-gray-500 leading-relaxed"> <p className="text-xs text-fg-muted leading-relaxed">
{t("compaction.summary", { {t("compaction.summary", {
affected: affected.toLocaleString(), affected: affected.toLocaleString(),
total: data.totalSessions.toLocaleString(), total: data.totalSessions.toLocaleString(),
@@ -420,8 +420,8 @@ export function CompactionImpact({ data }: CompactionImpactProps) {
transform: tip.x > window.innerWidth - 220 ? "translateX(-100%)" : undefined, transform: tip.x > window.innerWidth - 220 ? "translateX(-100%)" : undefined,
}} }}
> >
<div className="font-medium text-gray-100">{tip.title}</div> <div className="font-medium text-fg-primary">{tip.title}</div>
<div className="mt-0.5 text-gray-400">{tip.detail}</div> <div className="mt-0.5 text-fg-secondary">{tip.detail}</div>
</div> </div>
)} )}
</div> </div>
@@ -121,7 +121,7 @@ function LaneRow({ lane, color, maxCount, onShowTip, onHideTip }: LaneRowProps)
<div className="flex items-center gap-3 py-1.5 group"> <div className="flex items-center gap-3 py-1.5 group">
{/* Label column */} {/* Label column */}
<div className="flex-shrink-0 w-[140px] text-right" title={displayName}> <div className="flex-shrink-0 w-[140px] text-right" title={displayName}>
<span className="text-xs font-medium text-gray-400 truncate block group-hover:text-gray-200 transition-colors"> <span className="text-xs font-medium text-fg-secondary truncate block group-hover:text-fg-secondary transition-colors">
{displayName} {displayName}
</span> </span>
</div> </div>
@@ -154,7 +154,7 @@ function LaneRow({ lane, color, maxCount, onShowTip, onHideTip }: LaneRowProps)
</div> </div>
{/* Timing range */} {/* Timing range */}
<div className="flex-shrink-0 w-[72px] text-[11px] text-gray-600 tabular-nums"> <div className="flex-shrink-0 w-[72px] text-[11px] text-fg-muted tabular-nums">
{startPct}%&ndash;{endPct}% {startPct}%&ndash;{endPct}%
</div> </div>
</div> </div>
@@ -229,7 +229,7 @@ function EmptyState() {
<div className="flex flex-col items-center justify-center py-12 text-center"> <div className="flex flex-col items-center justify-center py-12 text-center">
<div className="w-10 h-10 rounded-xl bg-surface-4 flex items-center justify-center mb-3"> <div className="w-10 h-10 rounded-xl bg-surface-4 flex items-center justify-center mb-3">
<svg <svg
className="w-5 h-5 text-gray-600" className="w-5 h-5 text-fg-muted"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
@@ -240,8 +240,8 @@ function EmptyState() {
<rect x="3" y="16" width="15" height="4" rx="1" /> <rect x="3" y="16" width="15" height="4" rx="1" />
</svg> </svg>
</div> </div>
<p className="text-sm font-medium text-gray-400">{t("concurrency.noData")}</p> <p className="text-sm font-medium text-fg-secondary">{t("concurrency.noData")}</p>
<p className="text-xs text-gray-600 mt-1">{t("concurrency.noDataDesc")}</p> <p className="text-xs text-fg-muted mt-1">{t("concurrency.noDataDesc")}</p>
</div> </div>
); );
} }
@@ -314,15 +314,15 @@ export function ConcurrencyTimeline({ data }: ConcurrencyTimelineProps) {
<div className="flex items-center gap-3 mb-2"> <div className="flex items-center gap-3 mb-2">
<div className="flex-shrink-0 w-[140px]" /> <div className="flex-shrink-0 w-[140px]" />
<div className="flex-1 flex items-center justify-between"> <div className="flex-1 flex items-center justify-between">
<span className="text-[10px] text-gray-600 uppercase tracking-wider"> <span className="text-[10px] text-fg-muted uppercase tracking-wider">
{t("concurrency.sessions")} {t("concurrency.sessions")}
</span> </span>
<span className="text-[10px] text-gray-600 tabular-nums"> <span className="text-[10px] text-fg-muted tabular-nums">
{maxCount} {maxCount}
{t("concurrency.max")} {t("concurrency.max")}
</span> </span>
</div> </div>
<div className="flex-shrink-0 w-[72px] text-[10px] text-gray-600 uppercase tracking-wider"> <div className="flex-shrink-0 w-[72px] text-[10px] text-fg-muted uppercase tracking-wider">
{t("concurrency.timing")} {t("concurrency.timing")}
</div> </div>
</div> </div>
@@ -92,7 +92,7 @@ export function ErrorPropagationMap({ data }: ErrorPropagationMapProps) {
if (!hasErrors) { if (!hasErrors) {
return ( return (
<div className="flex flex-col items-center justify-center py-16 gap-3"> <div className="flex flex-col items-center justify-center py-16 gap-3">
<div className="w-12 h-12 rounded-full bg-emerald-500/10 border border-emerald-500/20 flex items-center justify-center"> <div className="w-12 h-12 rounded-full bg-status-success/10 border border-status-success/20 flex items-center justify-center">
<svg <svg
width="24" width="24"
height="24" height="24"
@@ -107,10 +107,10 @@ export function ErrorPropagationMap({ data }: ErrorPropagationMapProps) {
<polyline points="22 4 12 14.01 9 11.01" /> <polyline points="22 4 12 14.01 9 11.01" />
</svg> </svg>
</div> </div>
<span className="text-sm text-emerald-400 font-medium"> <span className="text-sm text-status-success font-medium">
{t("errorPropagation.noErrors")} {t("errorPropagation.noErrors")}
</span> </span>
<span className="text-xs text-gray-600">{t("errorPropagation.allSuccess")}</span> <span className="text-xs text-fg-muted">{t("errorPropagation.allSuccess")}</span>
</div> </div>
); );
} }
@@ -124,20 +124,20 @@ export function ErrorPropagationMap({ data }: ErrorPropagationMapProps) {
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{/* Error rate summary bar */} {/* Error rate summary bar */}
<div className="flex items-center gap-3 p-3 rounded-xl bg-red-500/5 border border-red-500/15"> <div className="flex items-center gap-3 p-3 rounded-xl bg-status-danger/5 border border-status-danger/15">
<div className="flex-shrink-0 min-w-[2.75rem] h-10 px-2 rounded-lg bg-red-500/10 border border-red-500/20 flex items-center justify-center"> <div className="flex-shrink-0 min-w-[2.75rem] h-10 px-2 rounded-lg bg-status-danger/10 border border-status-danger/20 flex items-center justify-center">
<span className="text-[13px] font-bold text-red-400 tabular-nums whitespace-nowrap"> <span className="text-[13px] font-bold text-status-danger tabular-nums whitespace-nowrap">
{errorRatePct}% {errorRatePct}%
</span> </span>
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="text-xs font-medium text-red-300"> <p className="text-xs font-medium text-status-danger">
{t("errorPropagation.sessionsErrorSummary", { {t("errorPropagation.sessionsErrorSummary", {
errorSessions: data.sessionsWithErrors, errorSessions: data.sessionsWithErrors,
totalSessions: data.totalSessions, totalSessions: data.totalSessions,
})} })}
</p> </p>
<p className="text-[11px] text-gray-500 mt-0.5"> <p className="text-[11px] text-fg-muted mt-0.5">
{totalErrors > 0 {totalErrors > 0
? `${totalErrors}${t("errorPropagation.agentErrors")}` ? `${totalErrors}${t("errorPropagation.agentErrors")}`
: t("errorPropagation.sessionErrorsOnly")} : t("errorPropagation.sessionErrorsOnly")}
@@ -148,7 +148,7 @@ export function ErrorPropagationMap({ data }: ErrorPropagationMapProps) {
{/* Errors by depth - horizontal bars */} {/* Errors by depth - horizontal bars */}
{hasDepthData && ( {hasDepthData && (
<div> <div>
<p className="text-[10px] font-medium text-gray-500 uppercase tracking-wider mb-2.5"> <p className="text-[10px] font-medium text-fg-muted uppercase tracking-wider mb-2.5">
{t("errorPropagation.errorsByDepth")} {t("errorPropagation.errorsByDepth")}
</p> </p>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
@@ -165,7 +165,7 @@ export function ErrorPropagationMap({ data }: ErrorPropagationMapProps) {
onMouseEnter={() => setHoveredDepth(d.depth)} onMouseEnter={() => setHoveredDepth(d.depth)}
onMouseLeave={() => setHoveredDepth(null)} onMouseLeave={() => setHoveredDepth(null)}
> >
<span className="text-[11px] text-gray-500 w-24 flex-shrink-0 text-right truncate"> <span className="text-[11px] text-fg-muted w-24 flex-shrink-0 text-right truncate">
{depthLabel(d.depth)} {depthLabel(d.depth)}
</span> </span>
<div className="flex-1 h-5 bg-surface-3 rounded overflow-hidden relative"> <div className="flex-1 h-5 bg-surface-3 rounded overflow-hidden relative">
@@ -194,7 +194,7 @@ export function ErrorPropagationMap({ data }: ErrorPropagationMapProps) {
{/* Error-prone agent types */} {/* Error-prone agent types */}
{topTypes.length > 0 && ( {topTypes.length > 0 && (
<div> <div>
<p className="text-[10px] font-medium text-gray-500 uppercase tracking-wider mb-2.5"> <p className="text-[10px] font-medium text-fg-muted uppercase tracking-wider mb-2.5">
{t("errorPropagation.errorProneTypes")} {t("errorPropagation.errorProneTypes")}
</p> </p>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
@@ -210,16 +210,16 @@ export function ErrorPropagationMap({ data }: ErrorPropagationMapProps) {
className="w-1.5 h-1.5 rounded-full flex-shrink-0" className="w-1.5 h-1.5 rounded-full flex-shrink-0"
style={{ backgroundColor: DEPTH_COLORS[Math.min(i, DEPTH_COLORS.length - 1)] }} style={{ backgroundColor: DEPTH_COLORS[Math.min(i, DEPTH_COLORS.length - 1)] }}
/> />
<span className="text-xs text-gray-300 truncate flex-1 min-w-0"> <span className="text-xs text-fg-secondary truncate flex-1 min-w-0">
{t.subagent_type} {t.subagent_type}
</span> </span>
<div className="w-16 h-1.5 bg-surface-4 rounded-full overflow-hidden flex-shrink-0"> <div className="w-16 h-1.5 bg-surface-4 rounded-full overflow-hidden flex-shrink-0">
<div <div
className="h-full rounded-full bg-red-400/60" className="h-full rounded-full bg-status-danger/60"
style={{ width: `${Math.max(pct, 8)}%` }} style={{ width: `${Math.max(pct, 8)}%` }}
/> />
</div> </div>
<span className="text-[11px] font-semibold text-red-300 tabular-nums w-5 text-right flex-shrink-0"> <span className="text-[11px] font-semibold text-status-danger tabular-nums w-5 text-right flex-shrink-0">
{t.count} {t.count}
</span> </span>
</div> </div>
@@ -232,14 +232,14 @@ export function ErrorPropagationMap({ data }: ErrorPropagationMapProps) {
{/* API & session errors */} {/* API & session errors */}
{data.eventErrors && data.eventErrors.length > 0 && ( {data.eventErrors && data.eventErrors.length > 0 && (
<div> <div>
<p className="text-[10px] font-medium text-gray-500 uppercase tracking-wider mb-2.5"> <p className="text-[10px] font-medium text-fg-muted uppercase tracking-wider mb-2.5">
{t("errorPropagation.apiSessionErrors")} {t("errorPropagation.apiSessionErrors")}
</p> </p>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{data.eventErrors.map((e) => ( {data.eventErrors.map((e) => (
<div <div
key={e.summary} key={e.summary}
className="flex items-center gap-2.5 px-2.5 py-2 rounded-lg bg-amber-500/5 border border-amber-500/10 hover:border-amber-500/20 transition-colors" className="flex items-center gap-2.5 px-2.5 py-2 rounded-lg bg-status-warning/5 border border-status-warning/10 hover:border-status-warning/20 transition-colors"
> >
<svg <svg
width="14" width="14"
@@ -256,10 +256,13 @@ export function ErrorPropagationMap({ data }: ErrorPropagationMapProps) {
<line x1="12" y1="9" x2="12" y2="13" /> <line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" /> <line x1="12" y1="17" x2="12.01" y2="17" />
</svg> </svg>
<span className="text-xs text-gray-300 truncate flex-1 min-w-0" title={e.summary}> <span
className="text-xs text-fg-secondary truncate flex-1 min-w-0"
title={e.summary}
>
{e.summary} {e.summary}
</span> </span>
<span className="flex-shrink-0 text-[11px] font-semibold text-amber-400 tabular-nums"> <span className="flex-shrink-0 text-[11px] font-semibold text-status-warning tabular-nums">
{e.count}x {e.count}x
</span> </span>
</div> </div>
@@ -402,7 +402,7 @@ export function ModelDelegationFlow({ data }: ModelDelegationFlowProps) {
if (!hasData) { if (!hasData) {
return ( return (
<div className="flex flex-col items-center justify-center py-16 gap-3 text-gray-500"> <div className="flex flex-col items-center justify-center py-16 gap-3 text-fg-muted">
<svg <svg
width="40" width="40"
height="40" height="40"
@@ -787,7 +787,7 @@ export function OrchestrationDAG({ data, onNodeClick, selectedNode }: Orchestrat
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
strokeWidth={1.5} strokeWidth={1.5}
className="w-6 h-6 text-gray-500" className="w-6 h-6 text-fg-muted"
> >
<circle cx="6" cy="12" r="2" /> <circle cx="6" cy="12" r="2" />
<circle cx="18" cy="6" r="2" /> <circle cx="18" cy="6" r="2" />
@@ -796,8 +796,10 @@ export function OrchestrationDAG({ data, onNodeClick, selectedNode }: Orchestrat
<line x1="8" y1="13" x2="16" y2="17" /> <line x1="8" y1="13" x2="16" y2="17" />
</svg> </svg>
</div> </div>
<h3 className="text-base font-medium text-gray-300 mb-2">{t("orchestration.noData")}</h3> <h3 className="text-base font-medium text-fg-secondary mb-2">
<p className="text-sm text-gray-500 max-w-sm">{t("orchestration.noDataDesc")}</p> {t("orchestration.noData")}
</h3>
<p className="text-sm text-fg-muted max-w-sm">{t("orchestration.noDataDesc")}</p>
</div> </div>
); );
} }
@@ -831,7 +833,7 @@ export function OrchestrationDAG({ data, onNodeClick, selectedNode }: Orchestrat
{/* Legend */} {/* Legend */}
<div className="flex flex-wrap items-center gap-x-5 gap-y-2 px-1 mt-4"> <div className="flex flex-wrap items-center gap-x-5 gap-y-2 px-1 mt-4">
<span className="text-[10px] text-gray-600 uppercase tracking-widest font-medium mr-1"> <span className="text-[10px] text-fg-muted uppercase tracking-widest font-medium mr-1">
{t("orchestration.legend")} {t("orchestration.legend")}
</span> </span>
{LEGEND_ITEMS.map((item) => ( {LEGEND_ITEMS.map((item) => (
@@ -840,7 +842,7 @@ export function OrchestrationDAG({ data, onNodeClick, selectedNode }: Orchestrat
className="inline-block w-3 h-3 rounded-sm flex-shrink-0" className="inline-block w-3 h-3 rounded-sm flex-shrink-0"
style={{ background: item.color, border: `1px solid ${item.border}` }} style={{ background: item.color, border: `1px solid ${item.border}` }}
/> />
<span className="text-[11px] text-gray-500">{item.label}</span> <span className="text-[11px] text-fg-muted">{item.label}</span>
</div> </div>
))} ))}
<div className="flex items-center gap-1.5 ml-2"> <div className="flex items-center gap-1.5 ml-2">
@@ -848,7 +850,7 @@ export function OrchestrationDAG({ data, onNodeClick, selectedNode }: Orchestrat
className="inline-block h-[2px] w-8 rounded flex-shrink-0" className="inline-block h-[2px] w-8 rounded flex-shrink-0"
style={{ background: "linear-gradient(to right, #312e81, #4f46e5)" }} style={{ background: "linear-gradient(to right, #312e81, #4f46e5)" }}
/> />
<span className="text-[11px] text-gray-500">{t("orchestration.edgeWeight")}</span> <span className="text-[11px] text-fg-muted">{t("orchestration.edgeWeight")}</span>
</div> </div>
</div> </div>
@@ -882,12 +884,12 @@ function buildDAGTooltipContent(el: HTMLDivElement, node: DAGNode, t: TFn) {
const meta = describeNode(node, t); const meta = describeNode(node, t);
const title = document.createElement("p"); const title = document.createElement("p");
title.className = "text-xs font-semibold text-gray-200"; title.className = "text-xs font-semibold text-fg-secondary";
title.textContent = node.label; title.textContent = node.label;
el.appendChild(title); el.appendChild(title);
const subtitle = document.createElement("p"); const subtitle = document.createElement("p");
subtitle.className = "text-[10px] uppercase tracking-wider text-gray-600 mt-0.5 mb-2"; subtitle.className = "text-[10px] uppercase tracking-wider text-fg-muted mt-0.5 mb-2";
subtitle.textContent = meta.layer; subtitle.textContent = meta.layer;
el.appendChild(subtitle); el.appendChild(subtitle);
@@ -121,17 +121,17 @@ function Tooltip({ state }: { state: TooltipState }) {
return ( return (
<div <div
className="fixed z-50 px-3 py-2 text-xs bg-[#12121f] border border-[#2a2a4a] rounded-lg shadow-xl text-gray-200 pointer-events-none whitespace-nowrap" className="fixed z-50 px-3 py-2 text-xs bg-[#12121f] border border-[#2a2a4a] rounded-lg shadow-xl text-fg-secondary pointer-events-none whitespace-nowrap"
style={{ style={{
left: nearRight ? state.x - 12 : state.x + 12, left: nearRight ? state.x - 12 : state.x + 12,
top: state.y - 10, top: state.y - 10,
transform: nearRight ? "translateX(-100%)" : undefined, transform: nearRight ? "translateX(-100%)" : undefined,
}} }}
> >
<p className="font-semibold text-gray-100 mb-1 truncate max-w-[180px]"> <p className="font-semibold text-fg-primary mb-1 truncate max-w-[180px]">
{state.item.name ?? state.item.id.slice(0, 12)} {state.item.name ?? state.item.id.slice(0, 12)}
</p> </p>
<div className="flex flex-col gap-0.5 text-gray-400"> <div className="flex flex-col gap-0.5 text-fg-secondary">
<span> <span>
{t("complexity.tooltip.duration")} {formatDurationSec(state.item.duration)} {t("complexity.tooltip.duration")} {formatDurationSec(state.item.duration)}
</span> </span>
@@ -173,7 +173,7 @@ function Legend() {
className="w-3 h-3 rounded-full flex-shrink-0" className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: statusColor(s) }} style={{ backgroundColor: statusColor(s) }}
/> />
<span className="text-xs text-gray-500"> <span className="text-xs text-fg-muted">
{t(`common:status.${s}`, { defaultValue: s })} {t(`common:status.${s}`, { defaultValue: s })}
</span> </span>
</div> </div>
@@ -190,7 +190,7 @@ function EmptyState() {
<div className="flex flex-col items-center justify-center py-16 text-center"> <div className="flex flex-col items-center justify-center py-16 text-center">
<div className="w-10 h-10 rounded-xl bg-surface-4 flex items-center justify-center mb-3"> <div className="w-10 h-10 rounded-xl bg-surface-4 flex items-center justify-center mb-3">
<svg <svg
className="w-5 h-5 text-gray-600" className="w-5 h-5 text-fg-muted"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
@@ -201,8 +201,8 @@ function EmptyState() {
<circle cx="14" cy="17" r="4" /> <circle cx="14" cy="17" r="4" />
</svg> </svg>
</div> </div>
<p className="text-sm font-medium text-gray-400">{t("complexity.noData")}</p> <p className="text-sm font-medium text-fg-secondary">{t("complexity.noData")}</p>
<p className="text-xs text-gray-600 mt-1">{t("complexity.noDataDesc")}</p> <p className="text-xs text-fg-muted mt-1">{t("complexity.noDataDesc")}</p>
</div> </div>
); );
} }
@@ -83,15 +83,15 @@ function statusColor(status: string): string {
case "completed": case "completed":
return "text-violet-400 bg-violet-500/10 border-violet-500/20"; return "text-violet-400 bg-violet-500/10 border-violet-500/20";
case "working": case "working":
return "text-emerald-400 bg-emerald-500/10 border-emerald-500/20"; return "text-status-success bg-status-success/10 border-status-success/20";
case "error": case "error":
return "text-red-400 bg-red-500/10 border-red-500/20"; return "text-status-danger bg-status-danger/10 border-status-danger/20";
case "active": case "active":
return "text-emerald-400 bg-emerald-500/10 border-emerald-500/20"; return "text-status-success bg-status-success/10 border-status-success/20";
case "waiting": case "waiting":
return "text-yellow-400 bg-yellow-500/10 border-yellow-500/20"; return "text-yellow-400 bg-yellow-500/10 border-yellow-500/20";
default: default:
return "text-gray-400 bg-gray-500/10 border-gray-500/20"; return "text-fg-secondary bg-surface-4/10 border-border-light/20";
} }
} }
@@ -140,8 +140,8 @@ function TabBar({ active, onChange }: TabBarProps) {
className={[ className={[
"flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors duration-150", "flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors duration-150",
active === tab.id active === tab.id
? "bg-surface-5 text-gray-100 shadow-sm" ? "bg-surface-5 text-fg-primary shadow-sm"
: "text-gray-500 hover:text-gray-300", : "text-fg-muted hover:text-fg-secondary",
].join(" ")} ].join(" ")}
> >
{tab.icon} {tab.icon}
@@ -192,20 +192,20 @@ function TreeNode({ node, depth }: TreeNodeProps) {
{/* Name */} {/* Name */}
<span <span
className={`text-sm font-medium truncate ${isMain ? "text-indigo-300" : "text-gray-200"}`} className={`text-sm font-medium truncate ${isMain ? "text-indigo-300" : "text-fg-secondary"}`}
> >
{node.name} {node.name}
</span> </span>
{/* Subagent type */} {/* Subagent type */}
{node.subagent_type && ( {node.subagent_type && (
<span className="text-xs text-gray-500 truncate flex-shrink-0"> <span className="text-xs text-fg-muted truncate flex-shrink-0">
[{node.subagent_type}] [{node.subagent_type}]
</span> </span>
)} )}
{/* Duration */} {/* Duration */}
<span className="ml-auto flex-shrink-0 text-xs text-gray-600 tabular-nums">{dur}</span> <span className="ml-auto flex-shrink-0 text-xs text-fg-muted tabular-nums">{dur}</span>
</div> </div>
{node.children.length > 0 && ( {node.children.length > 0 && (
@@ -226,7 +226,7 @@ interface AgentTreeProps {
function AgentTree({ tree }: AgentTreeProps) { function AgentTree({ tree }: AgentTreeProps) {
const { t } = useTranslation("workflows"); const { t } = useTranslation("workflows");
if (tree.length === 0) { if (tree.length === 0) {
return <p className="text-sm text-gray-500 text-center py-8">{t("drillIn.noAgentTree")}</p>; return <p className="text-sm text-fg-muted text-center py-8">{t("drillIn.noAgentTree")}</p>;
} }
return ( return (
@@ -249,7 +249,7 @@ interface ToolTimelineProps {
function ToolTimeline({ events }: ToolTimelineProps) { function ToolTimeline({ events }: ToolTimelineProps) {
const { t } = useTranslation("workflows"); const { t } = useTranslation("workflows");
if (events.length === 0) { if (events.length === 0) {
return <p className="text-sm text-gray-500 text-center py-8">{t("drillIn.noToolEvents")}</p>; return <p className="text-sm text-fg-muted text-center py-8">{t("drillIn.noToolEvents")}</p>;
} }
return ( return (
@@ -267,11 +267,13 @@ function ToolTimeline({ events }: ToolTimelineProps) {
{/* Summary */} {/* Summary */}
{ev.summary && ( {ev.summary && (
<span className="text-xs text-gray-400 truncate flex-1 min-w-0">{ev.summary}</span> <span className="text-xs text-fg-secondary truncate flex-1 min-w-0">
{ev.summary}
</span>
)} )}
{/* Timestamp */} {/* Timestamp */}
<span className="flex-shrink-0 text-[10px] text-gray-600 tabular-nums ml-auto"> <span className="flex-shrink-0 text-[10px] text-fg-muted tabular-nums ml-auto">
{safeTimestamp(ev.created_at)} {safeTimestamp(ev.created_at)}
</span> </span>
</div> </div>
@@ -288,22 +290,22 @@ interface EventSequenceProps {
} }
const EVENT_TYPE_COLOR: Record<string, string> = { const EVENT_TYPE_COLOR: Record<string, string> = {
tool_use: "text-blue-400", tool_use: "text-blue-500",
tool_result: "text-emerald-400", tool_result: "text-status-success",
agent_start: "text-indigo-400", agent_start: "text-indigo-400",
agent_stop: "text-violet-400", agent_stop: "text-violet-400",
compaction: "text-amber-400", compaction: "text-status-warning",
error: "text-red-400", error: "text-status-danger",
}; };
function eventTypeColor(type: string): string { function eventTypeColor(type: string): string {
return EVENT_TYPE_COLOR[type] ?? "text-gray-400"; return EVENT_TYPE_COLOR[type] ?? "text-fg-secondary";
} }
function EventSequence({ events }: EventSequenceProps) { function EventSequence({ events }: EventSequenceProps) {
const { t } = useTranslation("workflows"); const { t } = useTranslation("workflows");
if (events.length === 0) { if (events.length === 0) {
return <p className="text-sm text-gray-500 text-center py-8">{t("drillIn.noEvents")}</p>; return <p className="text-sm text-fg-muted text-center py-8">{t("drillIn.noEvents")}</p>;
} }
const recent = events.slice(0, 100); const recent = events.slice(0, 100);
@@ -325,18 +327,18 @@ function EventSequence({ events }: EventSequenceProps) {
</span> </span>
{/* Summary */} {/* Summary */}
<span className="text-xs text-gray-400 flex-1 min-w-0 truncate"> <span className="text-xs text-fg-secondary flex-1 min-w-0 truncate">
{ev.summary ?? ev.tool_name ?? "-"} {ev.summary ?? ev.tool_name ?? "-"}
</span> </span>
{/* Timestamp */} {/* Timestamp */}
<span className="flex-shrink-0 text-[10px] text-gray-600 tabular-nums opacity-0 group-hover:opacity-100 transition-opacity"> <span className="flex-shrink-0 text-[10px] text-fg-muted tabular-nums opacity-0 group-hover:opacity-100 transition-opacity">
{safeTimestamp(ev.created_at)} {safeTimestamp(ev.created_at)}
</span> </span>
</div> </div>
))} ))}
{events.length > 100 && ( {events.length > 100 && (
<p className="text-xs text-gray-600 text-center py-2"> <p className="text-xs text-fg-muted text-center py-2">
{t("drillIn.showingOf", { total: events.length })} {t("drillIn.showingOf", { total: events.length })}
</p> </p>
)} )}
@@ -365,11 +367,11 @@ function ErrorState({ message }: ErrorStateProps) {
const { t } = useTranslation("workflows"); const { t } = useTranslation("workflows");
return ( return (
<div className="flex flex-col items-center justify-center py-10 text-center px-4"> <div className="flex flex-col items-center justify-center py-10 text-center px-4">
<div className="w-9 h-9 rounded-xl bg-red-500/10 border border-red-500/20 flex items-center justify-center mb-3"> <div className="w-9 h-9 rounded-xl bg-status-danger/10 border border-status-danger/20 flex items-center justify-center mb-3">
<X className="w-4 h-4 text-red-400" /> <X className="w-4 h-4 text-status-danger" />
</div> </div>
<p className="text-sm font-medium text-red-400">{t("drillIn.failedLoad")}</p> <p className="text-sm font-medium text-status-danger">{t("drillIn.failedLoad")}</p>
<p className="text-xs text-gray-600 mt-1 max-w-xs">{message}</p> <p className="text-xs text-fg-muted mt-1 max-w-xs">{message}</p>
</div> </div>
); );
} }
@@ -405,17 +407,19 @@ function NoSessionState({ onSelectSession }: NoSessionStateProps) {
<div className="flex flex-col items-center text-center mt-2"> <div className="flex flex-col items-center text-center mt-2">
<div className="w-10 h-10 rounded-xl bg-surface-4 flex items-center justify-center mb-4"> <div className="w-10 h-10 rounded-xl bg-surface-4 flex items-center justify-center mb-4">
<GitFork className="w-5 h-5 text-gray-600" /> <GitFork className="w-5 h-5 text-fg-muted" />
</div> </div>
<p className="text-sm font-medium text-gray-400 mb-1">{t("drillIn.noSessionSelected")}</p> <p className="text-sm font-medium text-fg-secondary mb-1">
<p className="text-xs text-gray-600 max-w-xs">{t("drillIn.noSessionDesc")}</p> {t("drillIn.noSessionSelected")}
</p>
<p className="text-xs text-fg-muted max-w-xs">{t("drillIn.noSessionDesc")}</p>
{/* Preview tab pills */} {/* Preview tab pills */}
<div className="flex gap-2 mt-5"> <div className="flex gap-2 mt-5">
{tabs.map((tab) => ( {tabs.map((tab) => (
<div <div
key={tab.id} key={tab.id}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium text-gray-600 bg-surface-3 border border-border" className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium text-fg-muted bg-surface-3 border border-border"
> >
{tab.icon} {tab.icon}
{tab.label} {tab.label}
@@ -444,10 +448,10 @@ function SessionHeader({ drillIn, onClose, activeTab, onTabChange }: SessionHead
<div className="flex flex-col gap-3 mb-4"> <div className="flex flex-col gap-3 mb-4">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm font-semibold text-gray-100 truncate"> <p className="text-sm font-semibold text-fg-primary truncate">
{session.name ?? session.id} {session.name ?? session.id}
</p> </p>
<p className="text-xs text-gray-500 mt-0.5 truncate"> <p className="text-xs text-fg-muted mt-0.5 truncate">
{formatModelName(session.model) ?? t("drillIn.unknownModel")} &middot;{" "} {formatModelName(session.model) ?? t("drillIn.unknownModel")} &middot;{" "}
{t(`common:status.${session.status}`, { defaultValue: session.status })} {t(`common:status.${session.status}`, { defaultValue: session.status })}
{session.started_at && ` \u00b7 ${safeTimestamp(session.started_at)}`} {session.started_at && ` \u00b7 ${safeTimestamp(session.started_at)}`}
@@ -456,7 +460,7 @@ function SessionHeader({ drillIn, onClose, activeTab, onTabChange }: SessionHead
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
className="flex-shrink-0 w-7 h-7 rounded-md flex items-center justify-center text-gray-500 hover:text-gray-200 hover:bg-white/10 transition-colors" className="flex-shrink-0 w-7 h-7 rounded-md flex items-center justify-center text-fg-muted hover:text-fg-secondary hover:bg-white/10 transition-colors"
aria-label={t("drillIn.closePanel")} aria-label={t("drillIn.closePanel")}
> >
<X className="w-4 h-4" /> <X className="w-4 h-4" />
@@ -569,13 +573,13 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) {
inputRef.current?.focus(); inputRef.current?.focus();
}} }}
> >
<Search className="w-3.5 h-3.5 text-gray-500 flex-shrink-0" /> <Search className="w-3.5 h-3.5 text-fg-muted flex-shrink-0" />
<input <input
ref={inputRef} ref={inputRef}
type="text" type="text"
value={search} value={search}
placeholder={t("drillIn.searchPlaceholder")} placeholder={t("drillIn.searchPlaceholder")}
className="flex-1 bg-transparent text-xs text-gray-200 placeholder-gray-600 outline-none min-w-0" className="flex-1 bg-transparent text-xs text-fg-secondary placeholder-fg-muted outline-none min-w-0"
onFocus={() => setOpen(true)} onFocus={() => setOpen(true)}
onChange={(e) => { onChange={(e) => {
setSearch(e.target.value); setSearch(e.target.value);
@@ -584,7 +588,7 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) {
/> />
<ChevronDown <ChevronDown
className={[ className={[
"w-3.5 h-3.5 text-gray-600 flex-shrink-0 transition-transform duration-150", "w-3.5 h-3.5 text-fg-muted flex-shrink-0 transition-transform duration-150",
open ? "rotate-180" : "", open ? "rotate-180" : "",
].join(" ")} ].join(" ")}
/> />
@@ -605,7 +609,7 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) {
))} ))}
</div> </div>
) : filtered.length === 0 ? ( ) : filtered.length === 0 ? (
<p className="text-xs text-gray-600 text-center py-6 px-3"> <p className="text-xs text-fg-muted text-center py-6 px-3">
{search.trim() ? t("drillIn.noMatch") : t("drillIn.notFound")} {search.trim() ? t("drillIn.noMatch") : t("drillIn.notFound")}
</p> </p>
) : ( ) : (
@@ -625,22 +629,22 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) {
{s.status} {s.status}
</span> </span>
<span className="flex-1 min-w-0"> <span className="flex-1 min-w-0">
<span className="block text-xs font-medium text-gray-200 truncate"> <span className="block text-xs font-medium text-fg-secondary truncate">
{s.name ?? s.id} {s.name ?? s.id}
</span> </span>
{s.name && ( {s.name && (
<span className="block text-[10px] text-gray-600 font-mono truncate"> <span className="block text-[10px] text-fg-muted font-mono truncate">
{s.id} {s.id}
</span> </span>
)} )}
</span> </span>
{s.model && ( {s.model && (
<span className="flex-shrink-0 text-[10px] text-gray-500 truncate max-w-[80px]"> <span className="flex-shrink-0 text-[10px] text-fg-muted truncate max-w-[80px]">
{formatModelName(s.model)} {formatModelName(s.model)}
</span> </span>
)} )}
{s.started_at && ( {s.started_at && (
<span className="flex-shrink-0 text-[10px] text-gray-600 tabular-nums"> <span className="flex-shrink-0 text-[10px] text-fg-muted tabular-nums">
{safeTimestamp(s.started_at)} {safeTimestamp(s.started_at)}
</span> </span>
)} )}
@@ -656,7 +660,7 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) {
type="button" type="button"
onClick={handleLoadMore} onClick={handleLoadMore}
disabled={loading} disabled={loading}
className="w-full px-3 py-2 text-xs text-gray-500 hover:text-gray-300 hover:bg-white/5 transition-colors border-t border-border/50 disabled:opacity-50" className="w-full px-3 py-2 text-xs text-fg-muted hover:text-fg-secondary hover:bg-white/5 transition-colors border-t border-border/50 disabled:opacity-50"
> >
{loading ? t("drillIn.loading") : t("drillIn.loadMore")} {loading ? t("drillIn.loading") : t("drillIn.loadMore")}
</button> </button>
@@ -737,11 +741,11 @@ export function SessionDrillIn({ sessionId, onClose, onSelectSession }: SessionD
<div className="bg-surface-2 border border-border rounded-xl p-4"> <div className="bg-surface-2 border border-border rounded-xl p-4">
<SessionSelector onSelectSession={onSelectSession} /> <SessionSelector onSelectSession={onSelectSession} />
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<p className="text-xs text-gray-600 font-mono truncate">{sessionId}</p> <p className="text-xs text-fg-muted font-mono truncate">{sessionId}</p>
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
className="w-6 h-6 flex items-center justify-center rounded text-gray-600 hover:text-gray-300 hover:bg-white/10 transition-colors" className="w-6 h-6 flex items-center justify-center rounded text-fg-muted hover:text-fg-secondary hover:bg-white/10 transition-colors"
aria-label={t("drillIn.close")} aria-label={t("drillIn.close")}
> >
<X className="w-3.5 h-3.5" /> <X className="w-3.5 h-3.5" />
@@ -146,7 +146,7 @@ function SuccessRing({ rate, color }: SuccessRingProps) {
{clampedRate.toFixed(0)}% {clampedRate.toFixed(0)}%
</text> </text>
</svg> </svg>
<span className="text-[10px] font-medium text-gray-500 uppercase tracking-wider"> <span className="text-[10px] font-medium text-fg-muted uppercase tracking-wider">
{t("effectiveness.success")} {t("effectiveness.success")}
</span> </span>
</div> </div>
@@ -213,7 +213,7 @@ function Sparkline({ data, color }: SparklineProps) {
{bars.map((_, i) => ( {bars.map((_, i) => (
<span <span
key={i} key={i}
className="flex-1 text-center text-[8px] text-gray-600 leading-none select-none" className="flex-1 text-center text-[8px] text-fg-muted leading-none select-none"
> >
{dayLabels[i % dayLabels.length] ?? ""} {dayLabels[i % dayLabels.length] ?? ""}
</span> </span>
@@ -281,11 +281,11 @@ function SparklineTooltip({
<div <div
ref={ref} ref={ref}
role="tooltip" role="tooltip"
className="fixed z-[60] px-2 py-1 bg-[#12121f] border border-[#2a2a4a] rounded-md shadow-xl text-[10px] text-gray-200 whitespace-nowrap pointer-events-none" className="fixed z-[60] px-2 py-1 bg-[#12121f] border border-[#2a2a4a] rounded-md shadow-xl text-[10px] text-fg-secondary whitespace-nowrap pointer-events-none"
style={{ left: pos.left, top: pos.top }} style={{ left: pos.left, top: pos.top }}
> >
<span className="font-medium">{label}</span> <span className="font-medium">{label}</span>
<span className="text-gray-400 mx-1">·</span> <span className="text-fg-secondary mx-1">·</span>
<span className="tabular-nums" style={{ color }}> <span className="tabular-nums" style={{ color }}>
{t("effectiveness.sessionCount", { count: value })} {t("effectiveness.sessionCount", { count: value })}
</span> </span>
@@ -302,10 +302,10 @@ interface MetricBoxProps {
function MetricBox({ label, value }: MetricBoxProps) { function MetricBox({ label, value }: MetricBoxProps) {
return ( return (
<div className="flex flex-col items-center gap-0.5 bg-surface-3 rounded-lg px-2 py-2 flex-1 min-w-0 overflow-hidden"> <div className="flex flex-col items-center gap-0.5 bg-surface-3 rounded-lg px-2 py-2 flex-1 min-w-0 overflow-hidden">
<span className="text-xs font-semibold text-gray-200 tabular-nums truncate w-full text-center"> <span className="text-xs font-semibold text-fg-secondary tabular-nums truncate w-full text-center">
{value} {value}
</span> </span>
<span className="text-[9px] text-gray-500 uppercase tracking-wider truncate w-full text-center"> <span className="text-[9px] text-fg-muted uppercase tracking-wider truncate w-full text-center">
{label} {label}
</span> </span>
</div> </div>
@@ -337,7 +337,7 @@ function ScoreCard({ item, colorIndex }: ScoreCardProps) {
style={{ backgroundColor: color }} style={{ backgroundColor: color }}
aria-hidden="true" aria-hidden="true"
/> />
<span className="text-sm font-medium text-gray-200 truncate" title={item.subagent_type}> <span className="text-sm font-medium text-fg-secondary truncate" title={item.subagent_type}>
{item.subagent_type} {item.subagent_type}
</span> </span>
</div> </div>
@@ -358,7 +358,7 @@ function ScoreCard({ item, colorIndex }: ScoreCardProps) {
{/* Sparkline */} {/* Sparkline */}
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span className="text-[10px] text-gray-500 uppercase tracking-wider"> <span className="text-[10px] text-fg-muted uppercase tracking-wider">
{t("effectiveness.weeklyActivity")} {t("effectiveness.weeklyActivity")}
</span> </span>
<Sparkline data={item.trend} color={color} /> <Sparkline data={item.trend} color={color} />
@@ -375,7 +375,7 @@ export function SubagentEffectiveness({ data }: SubagentEffectivenessProps) {
const { t } = useTranslation("workflows"); const { t } = useTranslation("workflows");
if (data.length === 0) { if (data.length === 0) {
return ( return (
<div className="flex items-center justify-center py-16 text-gray-500 text-sm"> <div className="flex items-center justify-center py-16 text-fg-muted text-sm">
{t("effectiveness.noData")} {t("effectiveness.noData")}
</div> </div>
); );
@@ -507,7 +507,7 @@ export function ToolExecutionFlow({
<div className="relative" ref={containerRef} onMouseLeave={hideTip}> <div className="relative" ref={containerRef} onMouseLeave={hideTip}>
{isEmpty ? ( {isEmpty ? (
<div className="flex items-center justify-center" style={{ height: dimensions.height }}> <div className="flex items-center justify-center" style={{ height: dimensions.height }}>
<span className="text-sm text-gray-500">{t("toolFlow.noData")}</span> <span className="text-sm text-fg-muted">{t("toolFlow.noData")}</span>
</div> </div>
) : ( ) : (
<svg <svg
@@ -663,7 +663,7 @@ function Legend() {
style={{ background: color, opacity: 0.9 }} style={{ background: color, opacity: 0.9 }}
className="inline-block w-2.5 h-2.5 rounded-sm flex-shrink-0" className="inline-block w-2.5 h-2.5 rounded-sm flex-shrink-0"
/> />
<span className="text-xs text-gray-400">{t(`toolLegend.${key}`)}</span> <span className="text-xs text-fg-secondary">{t(`toolLegend.${key}`)}</span>
</div> </div>
))} ))}
</div> </div>
@@ -159,12 +159,12 @@ function StepFlow({ steps }: { steps: string[] }) {
<span key={idx} className="flex items-center gap-1"> <span key={idx} className="flex items-center gap-1">
<StepPill label={step} /> <StepPill label={step} />
{(idx < visible.length - 1 || overflow > 0) && ( {(idx < visible.length - 1 || overflow > 0) && (
<ChevronRight className="w-3.5 h-3.5 flex-shrink-0 text-gray-600" /> <ChevronRight className="w-3.5 h-3.5 flex-shrink-0 text-fg-muted" />
)} )}
</span> </span>
))} ))}
{overflow > 0 && ( {overflow > 0 && (
<span className="inline-flex items-center px-2 py-1 rounded-lg text-xs font-medium bg-gray-700/50 text-gray-400 border border-gray-600/20 whitespace-nowrap"> <span className="inline-flex items-center px-2 py-1 rounded-lg text-xs font-medium bg-surface-3/50 text-fg-secondary border border-border-light/20 whitespace-nowrap">
{t("common:plusMore", { count: overflow })} {t("common:plusMore", { count: overflow })}
</span> </span>
)} )}
@@ -176,8 +176,8 @@ function PatternFrequency({ count, percentage }: { count: number; percentage: nu
const { t } = useTranslation("workflows"); const { t } = useTranslation("workflows");
return ( return (
<div className="flex-shrink-0 text-right"> <div className="flex-shrink-0 text-right">
<p className="text-sm font-semibold text-gray-100">{count.toLocaleString()}</p> <p className="text-sm font-semibold text-fg-primary">{count.toLocaleString()}</p>
<p className="text-xs text-gray-500"> <p className="text-xs text-fg-muted">
{percentage.toFixed(1)}% {t("common:ofSessions", { defaultValue: "of sessions" })} {percentage.toFixed(1)}% {t("common:ofSessions", { defaultValue: "of sessions" })}
</p> </p>
</div> </div>
@@ -230,7 +230,7 @@ function PatternItem({ pattern, rank, isSelected, onClick }: PatternItemProps) {
{/* Click affordance - visible only when not yet expanded so users know the row is interactive. */} {/* Click affordance - visible only when not yet expanded so users know the row is interactive. */}
{!isSelected && ( {!isSelected && (
<Info className="hidden sm:block w-3.5 h-3.5 text-gray-600 flex-shrink-0" /> <Info className="hidden sm:block w-3.5 h-3.5 text-fg-muted flex-shrink-0" />
)} )}
</button> </button>
@@ -249,7 +249,7 @@ function PatternDetail({ pattern }: { pattern: WorkflowPattern }) {
<div className="border-t border-indigo-500/20 bg-surface-1/40 px-4 py-3.5 space-y-3.5"> <div className="border-t border-indigo-500/20 bg-surface-1/40 px-4 py-3.5 space-y-3.5">
{/* Full step sequence (no truncation) */} {/* Full step sequence (no truncation) */}
<div> <div>
<p className="text-[10px] font-semibold text-gray-500 uppercase tracking-wider mb-2"> <p className="text-[10px] font-semibold text-fg-muted uppercase tracking-wider mb-2">
{t("patterns.detail.stepsHeading")} {t("patterns.detail.stepsHeading")}
</p> </p>
<div className="flex items-center flex-wrap gap-1.5"> <div className="flex items-center flex-wrap gap-1.5">
@@ -260,7 +260,7 @@ function PatternDetail({ pattern }: { pattern: WorkflowPattern }) {
{step} {step}
</span> </span>
{i < pattern.steps.length - 1 && ( {i < pattern.steps.length - 1 && (
<ChevronRight className="w-3.5 h-3.5 flex-shrink-0 text-gray-600" /> <ChevronRight className="w-3.5 h-3.5 flex-shrink-0 text-fg-muted" />
)} )}
</span> </span>
))} ))}
@@ -286,11 +286,11 @@ function PatternDetail({ pattern }: { pattern: WorkflowPattern }) {
{/* Narrative - what this means */} {/* Narrative - what this means */}
<div> <div>
<p className="text-[10px] font-semibold text-gray-500 uppercase tracking-wider mb-1.5 flex items-center gap-1.5"> <p className="text-[10px] font-semibold text-fg-muted uppercase tracking-wider mb-1.5 flex items-center gap-1.5">
<Info className="w-3 h-3 text-indigo-400" /> <Info className="w-3 h-3 text-indigo-400" />
{t("patterns.detail.narrativeHeading")} {t("patterns.detail.narrativeHeading")}
</p> </p>
<p className="text-xs text-gray-300 leading-relaxed">{narrative}</p> <p className="text-xs text-fg-secondary leading-relaxed">{narrative}</p>
</div> </div>
{/* Suggestion */} {/* Suggestion */}
@@ -299,7 +299,7 @@ function PatternDetail({ pattern }: { pattern: WorkflowPattern }) {
<Lightbulb className="w-3 h-3" /> <Lightbulb className="w-3 h-3" />
{t("patterns.detail.suggestionHeading")} {t("patterns.detail.suggestionHeading")}
</p> </p>
<p className="text-xs text-gray-300 leading-relaxed">{suggestion}</p> <p className="text-xs text-fg-secondary leading-relaxed">{suggestion}</p>
</div> </div>
</div> </div>
); );
@@ -308,8 +308,8 @@ function PatternDetail({ pattern }: { pattern: WorkflowPattern }) {
function DetailStat({ label, value }: { label: string; value: string }) { function DetailStat({ label, value }: { label: string; value: string }) {
return ( return (
<div className="bg-surface-2 border border-border rounded-md px-2.5 py-2"> <div className="bg-surface-2 border border-border rounded-md px-2.5 py-2">
<p className="text-sm font-semibold text-gray-100 tabular-nums">{value}</p> <p className="text-sm font-semibold text-fg-primary tabular-nums">{value}</p>
<p className="text-[10px] text-gray-500 uppercase tracking-wider mt-0.5 truncate">{label}</p> <p className="text-[10px] text-fg-muted uppercase tracking-wider mt-0.5 truncate">{label}</p>
</div> </div>
); );
} }
@@ -327,8 +327,8 @@ function SoloSessionItem({ count, percentage }: { count: number; percentage: num
</span> </span>
</div> </div>
<div className="flex-shrink-0 text-right"> <div className="flex-shrink-0 text-right">
<p className="text-sm font-semibold text-gray-100">{count.toLocaleString()}</p> <p className="text-sm font-semibold text-fg-primary">{count.toLocaleString()}</p>
<p className="text-xs text-gray-500"> <p className="text-xs text-fg-muted">
{percentage.toFixed(1)}% {t("common:ofSessions", { defaultValue: "of sessions" })} {percentage.toFixed(1)}% {t("common:ofSessions", { defaultValue: "of sessions" })}
</p> </p>
</div> </div>
@@ -341,10 +341,10 @@ function EmptyPatterns() {
return ( return (
<div className="flex flex-col items-center justify-center py-12 text-center"> <div className="flex flex-col items-center justify-center py-12 text-center">
<div className="w-10 h-10 rounded-xl bg-surface-4 flex items-center justify-center mb-3"> <div className="w-10 h-10 rounded-xl bg-surface-4 flex items-center justify-center mb-3">
<Zap className="w-5 h-5 text-gray-600" /> <Zap className="w-5 h-5 text-fg-muted" />
</div> </div>
<p className="text-sm font-medium text-gray-400">{t("patterns.noData")}</p> <p className="text-sm font-medium text-fg-secondary">{t("patterns.noData")}</p>
<p className="text-xs text-gray-600 mt-1">{t("patterns.noDataDesc")}</p> <p className="text-xs text-fg-muted mt-1">{t("patterns.noDataDesc")}</p>
</div> </div>
); );
} }
@@ -372,7 +372,7 @@ export function WorkflowPatterns({ data, onPatternClick }: WorkflowPatternsProps
return ( return (
<div className="card p-5"> <div className="card p-5">
<h2 className="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-4"> <h2 className="text-sm font-semibold text-fg-secondary uppercase tracking-wider mb-4">
{t("patterns.label")} {t("patterns.label")}
</h2> </h2>
@@ -109,18 +109,18 @@ interface Props {
} }
const STATUS_STYLES: Record<string, string> = { const STATUS_STYLES: Record<string, string> = {
running: "bg-amber-500/15 text-amber-400 border-amber-500/30", running: "bg-status-warning/15 text-status-warning border-status-warning/30",
working: "bg-amber-500/15 text-amber-400 border-amber-500/30", working: "bg-status-warning/15 text-status-warning border-status-warning/30",
queued: "bg-gray-500/15 text-gray-400 border-gray-500/30", queued: "bg-surface-4/15 text-fg-secondary border-border-light/30",
completed: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30", completed: "bg-status-success/15 text-status-success border-status-success/30",
done: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30", done: "bg-status-success/15 text-status-success border-status-success/30",
success: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30", success: "bg-status-success/15 text-status-success border-status-success/30",
error: "bg-red-500/15 text-red-400 border-red-500/30", error: "bg-status-danger/15 text-status-danger border-status-danger/30",
failed: "bg-red-500/15 text-red-400 border-red-500/30", failed: "bg-status-danger/15 text-status-danger border-status-danger/30",
}; };
function statusClass(status: string): string { function statusClass(status: string): string {
return STATUS_STYLES[status] || "bg-gray-500/15 text-gray-400 border-gray-500/30"; return STATUS_STYLES[status] || "bg-surface-4/15 text-fg-secondary border-border-light/30";
} }
// Distinct per-phase chip colors, cycled by phase index so every phase // Distinct per-phase chip colors, cycled by phase index so every phase
@@ -129,15 +129,15 @@ function statusClass(status: string): string {
const PHASE_PALETTE = [ const PHASE_PALETTE = [
"bg-violet-500/15 text-violet-300 border-violet-500/40", "bg-violet-500/15 text-violet-300 border-violet-500/40",
"bg-sky-500/15 text-sky-300 border-sky-500/40", "bg-sky-500/15 text-sky-300 border-sky-500/40",
"bg-amber-500/15 text-amber-300 border-amber-500/40", "bg-status-warning/15 text-status-warning border-status-warning/40",
"bg-emerald-500/15 text-emerald-300 border-emerald-500/40", "bg-status-success/15 text-status-success border-status-success/40",
"bg-rose-500/15 text-rose-300 border-rose-500/40", "bg-rose-500/15 text-rose-300 border-rose-500/40",
"bg-cyan-500/15 text-cyan-300 border-cyan-500/40", "bg-cyan-500/15 text-cyan-300 border-cyan-500/40",
"bg-fuchsia-500/15 text-fuchsia-300 border-fuchsia-500/40", "bg-fuchsia-500/15 text-fuchsia-300 border-fuchsia-500/40",
]; ];
function phaseColor(phaseTitles: string[], title: string | null | undefined): string { function phaseColor(phaseTitles: string[], title: string | null | undefined): string {
if (!title) return "bg-gray-500/15 text-gray-300 border-gray-500/40"; if (!title) return "bg-surface-4/15 text-fg-secondary border-border-light/40";
const i = phaseTitles.indexOf(title); const i = phaseTitles.indexOf(title);
const idx = i >= 0 ? i : Math.abs(hashStr(title)) % PHASE_PALETTE.length; const idx = i >= 0 ? i : Math.abs(hashStr(title)) % PHASE_PALETTE.length;
return PHASE_PALETTE[idx % PHASE_PALETTE.length] as string; return PHASE_PALETTE[idx % PHASE_PALETTE.length] as string;
@@ -326,7 +326,7 @@ export function WorkflowRunsPanel({
if (!controlled && loading) { if (!controlled && loading) {
return ( return (
<div className="flex items-center justify-center gap-2 py-8 text-sm text-gray-500"> <div className="flex items-center justify-center gap-2 py-8 text-sm text-fg-muted">
<Loader2 className="w-4 h-4 animate-spin text-violet-400" /> <Loader2 className="w-4 h-4 animate-spin text-violet-400" />
<span className="animate-pulse">{t("runs.loading")}</span> <span className="animate-pulse">{t("runs.loading")}</span>
</div> </div>
@@ -334,8 +334,8 @@ export function WorkflowRunsPanel({
} }
if (runs.length === 0) { if (runs.length === 0) {
return ( return (
<div className="text-sm text-gray-500 flex items-center gap-2"> <div className="text-sm text-fg-muted flex items-center gap-2">
<Workflow className="w-4 h-4 text-gray-600" /> <Workflow className="w-4 h-4 text-fg-muted" />
{t("runs.empty")} {t("runs.empty")}
</div> </div>
); );
@@ -356,36 +356,36 @@ export function WorkflowRunsPanel({
return ( return (
<div <div
key={run.run_id} key={run.run_id}
className="rounded-lg border border-gray-800 bg-card/40 overflow-hidden" className="rounded-lg border border-border bg-card/40 overflow-hidden"
> >
<button <button
onClick={() => toggle(run.run_id)} onClick={() => toggle(run.run_id)}
className="w-full flex items-center gap-3 px-3 py-2.5 text-left hover:bg-gray-800/30 transition-colors" className="w-full flex items-center gap-3 px-3 py-2.5 text-left hover:bg-surface-2/30 transition-colors"
aria-expanded={isOpen} aria-expanded={isOpen}
> >
{isOpen ? ( {isOpen ? (
<ChevronDown className="w-4 h-4 text-gray-500 flex-shrink-0" /> <ChevronDown className="w-4 h-4 text-fg-muted flex-shrink-0" />
) : ( ) : (
<ChevronRight className="w-4 h-4 text-gray-500 flex-shrink-0" /> <ChevronRight className="w-4 h-4 text-fg-muted flex-shrink-0" />
)} )}
{running ? ( {running ? (
<Loader2 className="w-4 h-4 text-amber-400 flex-shrink-0 animate-spin" /> <Loader2 className="w-4 h-4 text-status-warning flex-shrink-0 animate-spin" />
) : ( ) : (
<Workflow className="w-4 h-4 text-violet-400 flex-shrink-0" /> <Workflow className="w-4 h-4 text-violet-400 flex-shrink-0" />
)} )}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-gray-200 truncate"> <span className="text-sm font-medium text-fg-secondary truncate">
{run.name || run.run_id} {run.name || run.run_id}
</span> </span>
<span className={`badge text-[10px] border ${statusClass(run.status)}`}> <span className={`badge text-[10px] border ${statusClass(run.status)}`}>
{t(`runs.status.${run.status}`, run.status)} {t(`runs.status.${run.status}`, run.status)}
</span> </span>
{run.default_model && ( {run.default_model && (
<span className="text-[10px] font-mono text-gray-500">{run.default_model}</span> <span className="text-[10px] font-mono text-fg-muted">{run.default_model}</span>
)} )}
</div> </div>
<div className="flex items-center gap-3 mt-0.5 text-[11px] text-gray-500 flex-wrap"> <div className="flex items-center gap-3 mt-0.5 text-[11px] text-fg-muted flex-wrap">
<span>{t("runs.agents", { count: run.agent_count })}</span> <span>{t("runs.agents", { count: run.agent_count })}</span>
<span>{t("runs.tools", { count: run.total_tool_calls })}</span> <span>{t("runs.tools", { count: run.total_tool_calls })}</span>
<span> <span>
@@ -399,7 +399,7 @@ export function WorkflowRunsPanel({
<Link <Link
to={`/sessions/${encodeURIComponent(run.session_id)}`} to={`/sessions/${encodeURIComponent(run.session_id)}`}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
className="text-gray-500 hover:text-violet-400 transition-colors flex-shrink-0" className="text-fg-muted hover:text-violet-400 transition-colors flex-shrink-0"
title={t("runs.openSession")} title={t("runs.openSession")}
> >
<ExternalLink className="w-3.5 h-3.5" /> <ExternalLink className="w-3.5 h-3.5" />
@@ -408,11 +408,11 @@ export function WorkflowRunsPanel({
</button> </button>
{isOpen && ( {isOpen && (
<div className="px-3 pb-3 pt-3 border-t border-gray-800/60 space-y-3"> <div className="px-3 pb-3 pt-3 border-t border-border/60 space-y-3">
{/* Clickable, colored phase filters */} {/* Clickable, colored phase filters */}
{phaseTitles.length > 0 && ( {phaseTitles.length > 0 && (
<div className="flex items-center gap-1.5 flex-wrap"> <div className="flex items-center gap-1.5 flex-wrap">
<Layers className="w-3.5 h-3.5 text-gray-500" /> <Layers className="w-3.5 h-3.5 text-fg-muted" />
{phaseTitles.map((title, i) => { {phaseTitles.map((title, i) => {
const active = sel === title; const active = sel === title;
return ( return (
@@ -435,7 +435,7 @@ export function WorkflowRunsPanel({
{sel && ( {sel && (
<button <button
onClick={() => setPhase(run.run_id, sel)} onClick={() => setPhase(run.run_id, sel)}
className="text-[10px] text-gray-500 hover:text-gray-300 underline" className="text-[10px] text-fg-muted hover:text-fg-secondary underline"
> >
{t("runs.clearFilter")} {t("runs.clearFilter")}
</button> </button>
@@ -447,7 +447,7 @@ export function WorkflowRunsPanel({
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-[11px]"> <table className="w-full text-[11px]">
<thead> <thead>
<tr className="text-gray-500 text-left border-b border-gray-800"> <tr className="text-fg-muted text-left border-b border-border">
<th className="py-1 pr-3 font-medium">{t("runs.col.agent")}</th> <th className="py-1 pr-3 font-medium">{t("runs.col.agent")}</th>
<th className="py-1 pr-3 font-medium">{t("runs.col.phase")}</th> <th className="py-1 pr-3 font-medium">{t("runs.col.phase")}</th>
<th className="py-1 pr-3 font-medium">{t("runs.col.state")}</th> <th className="py-1 pr-3 font-medium">{t("runs.col.state")}</th>
@@ -464,11 +464,11 @@ export function WorkflowRunsPanel({
</thead> </thead>
<tbody> <tbody>
{shown.map((a: WorkflowProgressEntry, i) => ( {shown.map((a: WorkflowProgressEntry, i) => (
<tr key={a.agentId || i} className="border-b border-gray-800/40"> <tr key={a.agentId || i} className="border-b border-border/40">
<td className="py-1 pr-3 text-gray-300"> <td className="py-1 pr-3 text-fg-secondary">
{a.label || a.agentType || a.agentId} {a.label || a.agentType || a.agentId}
{a.lastToolName && ( {a.lastToolName && (
<span className="text-gray-600 font-mono ml-1"> <span className="text-fg-muted font-mono ml-1">
· {a.lastToolName} · {a.lastToolName}
</span> </span>
)} )}
@@ -487,13 +487,13 @@ export function WorkflowRunsPanel({
{t(`runs.status.${a.state}`, String(a.state || "-"))} {t(`runs.status.${a.state}`, String(a.state || "-"))}
</span> </span>
</td> </td>
<td className="py-1 pr-3 text-right text-gray-400"> <td className="py-1 pr-3 text-right text-fg-secondary">
{fmt(a.tokens || 0)} {fmt(a.tokens || 0)}
</td> </td>
<td className="py-1 pr-3 text-right text-gray-400"> <td className="py-1 pr-3 text-right text-fg-secondary">
{a.toolCalls || 0} {a.toolCalls || 0}
</td> </td>
<td className="py-1 pr-3 text-right text-gray-400"> <td className="py-1 pr-3 text-right text-fg-secondary">
{a.durationMs != null ? formatMs(a.durationMs) : "-"} {a.durationMs != null ? formatMs(a.durationMs) : "-"}
</td> </td>
</tr> </tr>
@@ -502,15 +502,15 @@ export function WorkflowRunsPanel({
</table> </table>
</div> </div>
) : ( ) : (
<p className="text-[11px] text-gray-600">{t("runs.noAgents")}</p> <p className="text-[11px] text-fg-muted">{t("runs.noAgents")}</p>
)} )}
{/* Clickable, colored, expandable results - full content on click */} {/* Clickable, colored, expandable results - full content on click */}
{resultRows.length > 0 && ( {resultRows.length > 0 && (
<div className="space-y-1.5"> <div className="space-y-1.5">
<div className="text-[10px] font-medium uppercase tracking-wider text-gray-600"> <div className="text-[10px] font-medium uppercase tracking-wider text-fg-muted">
{t("runs.resultsLabel")} {t("runs.resultsLabel")}
<span className="ml-1 text-gray-700">· {resultRows.length}</span> <span className="ml-1 text-fg-muted">· {resultRows.length}</span>
</div> </div>
{resultRows.map((a, i) => { {resultRows.map((a, i) => {
const key = `${run.run_id}::${a.agentId || i}`; const key = `${run.run_id}::${a.agentId || i}`;
@@ -528,7 +528,7 @@ export function WorkflowRunsPanel({
return ( return (
<div <div
key={key} key={key}
className="rounded border border-gray-800/70 bg-gray-900/30 overflow-hidden" className="rounded border border-border/70 bg-surface-0/30 overflow-hidden"
> >
<button <button
onClick={() => { onClick={() => {
@@ -537,13 +537,13 @@ export function WorkflowRunsPanel({
} }
toggleResult(key); toggleResult(key);
}} }}
className="w-full flex items-center gap-2 px-2 py-1.5 text-left hover:bg-gray-800/40 transition-colors" className="w-full flex items-center gap-2 px-2 py-1.5 text-left hover:bg-surface-2/40 transition-colors"
aria-expanded={open} aria-expanded={open}
> >
{open ? ( {open ? (
<ChevronDown className="w-3.5 h-3.5 text-gray-500 flex-shrink-0" /> <ChevronDown className="w-3.5 h-3.5 text-fg-muted flex-shrink-0" />
) : ( ) : (
<ChevronRight className="w-3.5 h-3.5 text-gray-500 flex-shrink-0" /> <ChevronRight className="w-3.5 h-3.5 text-fg-muted flex-shrink-0" />
)} )}
<span <span
className={`badge text-[10px] border flex-shrink-0 ${phaseColor(phaseTitles, a.phaseTitle)}`} className={`badge text-[10px] border flex-shrink-0 ${phaseColor(phaseTitles, a.phaseTitle)}`}
@@ -551,14 +551,14 @@ export function WorkflowRunsPanel({
{a.label || a.agentType || a.agentId} {a.label || a.agentType || a.agentId}
</span> </span>
{!open && ( {!open && (
<span className="text-[11px] text-gray-500 leading-snug min-w-0"> <span className="text-[11px] text-fg-muted leading-snug min-w-0">
{truncate(friendlyPreview(a.resultPreview), 160)} {truncate(friendlyPreview(a.resultPreview), 160)}
</span> </span>
)} )}
</button> </button>
{open && ( {open && (
<div className="px-2.5 pb-2.5 pt-0.5 space-y-2"> <div className="px-2.5 pb-2.5 pt-0.5 space-y-2">
<div className="flex flex-wrap items-center gap-2 text-[10px] text-gray-500"> <div className="flex flex-wrap items-center gap-2 text-[10px] text-fg-muted">
{a.model && <span className="font-mono">{a.model}</span>} {a.model && <span className="font-mono">{a.model}</span>}
<span <span
className={`badge border ${statusClass(String(a.state || ""))}`} className={`badge border ${statusClass(String(a.state || ""))}`}
@@ -579,19 +579,19 @@ export function WorkflowRunsPanel({
</div> </div>
{fullPrompt && ( {fullPrompt && (
<div> <div>
<div className="text-[10px] uppercase tracking-wider text-gray-600 mb-0.5"> <div className="text-[10px] uppercase tracking-wider text-fg-muted mb-0.5">
{t("runs.promptLabel")} {t("runs.promptLabel")}
</div> </div>
<pre className="text-[11px] text-gray-400 whitespace-pre-wrap break-words bg-black/30 rounded p-2 max-h-48 overflow-auto"> <pre className="text-[11px] text-fg-secondary whitespace-pre-wrap break-words bg-black/30 rounded p-2 max-h-48 overflow-auto">
{fullPrompt} {fullPrompt}
</pre> </pre>
</div> </div>
)} )}
<div> <div>
<div className="text-[10px] uppercase tracking-wider text-gray-600 mb-0.5"> <div className="text-[10px] uppercase tracking-wider text-fg-muted mb-0.5">
{t("runs.resultLabel")} {t("runs.resultLabel")}
</div> </div>
<pre className="text-[11px] text-gray-300 whitespace-pre-wrap break-words bg-black/30 rounded p-2 max-h-96 overflow-auto"> <pre className="text-[11px] text-fg-secondary whitespace-pre-wrap break-words bg-black/30 rounded p-2 max-h-96 overflow-auto">
{fullResult} {fullResult}
</pre> </pre>
</div> </div>
@@ -76,9 +76,9 @@ function formatDurationSec(sec: number): string {
} }
function successRateColor(rate: number): string { function successRateColor(rate: number): string {
if (rate > 90) return "text-emerald-400"; if (rate > 90) return "text-status-success";
if (rate > 70) return "text-yellow-400"; if (rate > 70) return "text-yellow-400";
return "text-red-400"; return "text-status-danger";
} }
// ── Deterministic interpreters - return an i18n key + params ───────────────── // ── Deterministic interpreters - return an i18n key + params ─────────────────
@@ -228,7 +228,7 @@ function InfoPopover({ calculationKey, interp, valueDisplay, metricPhraseKey }:
onMouseLeave={() => setOpen(false)} onMouseLeave={() => setOpen(false)}
onFocus={() => setOpen(true)} onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)} onBlur={() => setOpen(false)}
className="flex items-center justify-center rounded-full p-0.5 -m-0.5 text-gray-600 hover:text-gray-300 focus:outline-none focus:ring-1 focus:ring-accent/40" className="flex items-center justify-center rounded-full p-0.5 -m-0.5 text-fg-muted hover:text-fg-secondary focus:outline-none focus:ring-1 focus:ring-accent/40"
> >
<Info className="w-4 h-4" /> <Info className="w-4 h-4" />
</button> </button>
@@ -236,27 +236,27 @@ function InfoPopover({ calculationKey, interp, valueDisplay, metricPhraseKey }:
<div <div
ref={popoverRef} ref={popoverRef}
role="tooltip" role="tooltip"
className="fixed z-50 p-3 bg-[#12121f] border border-[#2a2a4a] rounded-lg shadow-2xl text-[11px] text-gray-300 pointer-events-none" className="fixed z-50 p-3 bg-[#12121f] border border-[#2a2a4a] rounded-lg shadow-2xl text-[11px] text-fg-secondary pointer-events-none"
style={{ left: coords.left, top: coords.top, width: POPOVER_W }} style={{ left: coords.left, top: coords.top, width: POPOVER_W }}
> >
<div className="flex items-baseline gap-2 mb-2 pb-2 border-b border-[#2a2a4a]"> <div className="flex items-baseline gap-2 mb-2 pb-2 border-b border-[#2a2a4a]">
<span className="text-base font-semibold text-gray-100 tabular-nums"> <span className="text-base font-semibold text-fg-primary tabular-nums">
{valueDisplay} {valueDisplay}
</span> </span>
<span className="text-[10px] uppercase tracking-wider text-gray-500"> <span className="text-[10px] uppercase tracking-wider text-fg-muted">
{metricPhrase} {metricPhrase}
</span> </span>
</div> </div>
<p className="font-semibold text-gray-200 uppercase tracking-wider text-[9px] mb-1"> <p className="font-semibold text-fg-secondary uppercase tracking-wider text-[9px] mb-1">
{t("stats.tooltip.howCalc")} {t("stats.tooltip.howCalc")}
</p> </p>
<p className="text-gray-400 leading-snug mb-2.5">{t(calculationKey)}</p> <p className="text-fg-secondary leading-snug mb-2.5">{t(calculationKey)}</p>
<p className="font-semibold text-gray-200 uppercase tracking-wider text-[9px] mb-1"> <p className="font-semibold text-fg-secondary uppercase tracking-wider text-[9px] mb-1">
{t("stats.tooltip.whatItMeans")} {t("stats.tooltip.whatItMeans")}
</p> </p>
<p className="text-gray-400 leading-snug">{valueMeans}</p> <p className="text-fg-secondary leading-snug">{valueMeans}</p>
</div> </div>
)} )}
</> </>
@@ -287,7 +287,7 @@ function StatCard({
return ( return (
<div className="bg-surface-2 border border-border rounded-xl p-4 flex flex-col gap-3"> <div className="bg-surface-2 border border-border rounded-xl p-4 flex flex-col gap-3">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<span className="text-[10px] font-semibold text-gray-500 uppercase tracking-wider leading-none"> <span className="text-[10px] font-semibold text-fg-muted uppercase tracking-wider leading-none">
{label} {label}
</span> </span>
<Icon className={`w-4 h-4 flex-shrink-0 ${accentClass}`} /> <Icon className={`w-4 h-4 flex-shrink-0 ${accentClass}`} />
@@ -340,7 +340,7 @@ export function WorkflowStats({ stats }: WorkflowStatsProps) {
label={t("stats.avgSubagentsPerSession")} label={t("stats.avgSubagentsPerSession")}
value={stats.avgSubagents.toFixed(1)} value={stats.avgSubagents.toFixed(1)}
icon={Users} icon={Users}
accentClass="text-blue-400" accentClass="text-blue-500"
calculationKey="stats.tooltip.calc.subagents" calculationKey="stats.tooltip.calc.subagents"
interp={interpAvgSubagents(stats.avgSubagents)} interp={interpAvgSubagents(stats.avgSubagents)}
metricPhraseKey="stats.tooltip.phrase.subagents" metricPhraseKey="stats.tooltip.phrase.subagents"
@@ -376,7 +376,7 @@ export function WorkflowStats({ stats }: WorkflowStatsProps) {
label={t("stats.avgDuration")} label={t("stats.avgDuration")}
value={formatDurationSec(stats.avgDurationSec)} value={formatDurationSec(stats.avgDurationSec)}
icon={Clock} icon={Clock}
accentClass="text-amber-400" accentClass="text-status-warning"
calculationKey="stats.tooltip.calc.duration" calculationKey="stats.tooltip.calc.duration"
interp={interpAvgDuration(stats.avgDurationSec)} interp={interpAvgDuration(stats.avgDurationSec)}
metricPhraseKey="stats.tooltip.phrase.duration" metricPhraseKey="stats.tooltip.phrase.duration"
@@ -0,0 +1,49 @@
/**
* @file useTheme.test.ts
* @description Tests for useTheme the dark/light mode hook.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useTheme } from "../useTheme";
describe("useTheme", () => {
beforeEach(() => {
localStorage.clear();
document.documentElement.classList.remove("dark");
});
afterEach(() => {
document.documentElement.classList.remove("dark");
});
it("defaults to dark when localStorage is empty", () => {
const { result } = renderHook(() => useTheme());
expect(result.current.theme).toBe("dark");
expect(document.documentElement.classList.contains("dark")).toBe(true);
});
it("reads a persisted light theme on mount", () => {
localStorage.setItem("theme", "light");
const { result } = renderHook(() => useTheme());
expect(result.current.theme).toBe("light");
expect(document.documentElement.classList.contains("dark")).toBe(false);
});
it("toggleTheme flips the theme, the DOM class, and persists it", () => {
const { result } = renderHook(() => useTheme());
act(() => result.current.toggleTheme());
expect(result.current.theme).toBe("light");
expect(document.documentElement.classList.contains("dark")).toBe(false);
expect(localStorage.getItem("theme")).toBe("light");
});
it("setTheme sets an explicit value", () => {
const { result } = renderHook(() => useTheme());
act(() => result.current.setTheme("light"));
expect(result.current.theme).toBe("light");
act(() => result.current.setTheme("dark"));
expect(result.current.theme).toBe("dark");
expect(document.documentElement.classList.contains("dark")).toBe(true);
});
});
+60
View File
@@ -0,0 +1,60 @@
/**
* @file useTheme.ts
* @description Dark/light mode state, backed by `localStorage` and the `dark`
* class on `<html>` that Tailwind's `darkMode: "class"` reads. Default is
* dark; there is no `prefers-color-scheme` fallback.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState } from "react";
export type Theme = "dark" | "light";
const STORAGE_KEY = "theme";
function readStoredTheme(): Theme {
try {
return localStorage.getItem(STORAGE_KEY) === "light" ? "light" : "dark";
} catch {
return "dark";
}
}
function writeStoredTheme(theme: Theme): void {
try {
localStorage.setItem(STORAGE_KEY, theme);
} catch {
/* ignore quota / disabled storage */
}
}
function applyTheme(theme: Theme): void {
document.documentElement.classList.toggle("dark", theme === "dark");
}
/**
* Read/write the dashboard's active color theme.
* @returns the current theme, a setter for an explicit value, and a toggle.
*/
export function useTheme(): {
theme: Theme;
setTheme: (theme: Theme) => void;
toggleTheme: () => void;
} {
const [theme, setThemeState] = useState<Theme>(() => readStoredTheme());
useEffect(() => {
applyTheme(theme);
}, [theme]);
const setTheme = useCallback((next: Theme) => {
writeStoredTheme(next);
setThemeState(next);
}, []);
const toggleTheme = useCallback(() => {
setTheme(theme === "dark" ? "light" : "dark");
}, [theme, setTheme]);
return { theme, setTheme, toggleTheme };
}
+5 -4
View File
@@ -1,8 +1,8 @@
{ {
"action.clear": "clear", "action.clear": "clear state",
"action.forget": "forget lane", "action.forget": "delete lane",
"action.purge": "purge history", "action.purge": "delete history",
"action.remove": "remove", "action.remove": "delete lane + worktree",
"action.reset": "reset worktree", "action.reset": "reset worktree",
"action.start": "start", "action.start": "start",
"action.stop": "stop", "action.stop": "stop",
@@ -19,6 +19,7 @@
"addLaneTitlePlaceholder": "Optional", "addLaneTitlePlaceholder": "Optional",
"autoStage": "auto: {{stage}}", "autoStage": "auto: {{stage}}",
"cardId": "Lane {{id}}", "cardId": "Lane {{id}}",
"stageUndeclared": "not declared",
"confirmRemoveCancel": "Cancel", "confirmRemoveCancel": "Cancel",
"confirmRemoveConfirm": "Remove", "confirmRemoveConfirm": "Remove",
"confirmRemoveMessage": "This action cannot be undone.", "confirmRemoveMessage": "This action cannot be undone.",
+6
View File
@@ -50,6 +50,12 @@
"skipToContent": "Skip to content", "skipToContent": "Skip to content",
"statsPersisted": "Stats persist across reloads", "statsPersisted": "Stats persist across reloads",
"switchLanguage": "Switch to {{language}}", "switchLanguage": "Switch to {{language}}",
"switchTheme": "Switch to {{theme}}",
"theme": "Theme",
"themeNames": {
"dark": "Dark",
"light": "Light"
},
"throughput60s": "Throughput · last 60s", "throughput60s": "Throughput · last 60s",
"topEventTypes": "Top event types", "topEventTypes": "Top event types",
"unitEvents": "events", "unitEvents": "events",
+4 -3
View File
@@ -1,8 +1,8 @@
{ {
"action.clear": "xóa", "action.clear": "dọn trạng thái",
"action.forget": "quên làn đường", "action.forget": "xóa làn",
"action.purge": "xóa lịch sử", "action.purge": "xóa lịch sử",
"action.remove": "xóa bỏ", "action.remove": "xóa làn + worktree",
"action.reset": "đặt lại worktree", "action.reset": "đặt lại worktree",
"action.start": "bắt đầu", "action.start": "bắt đầu",
"action.stop": "dừng", "action.stop": "dừng",
@@ -19,6 +19,7 @@
"addLaneTitlePlaceholder": "Không bắt buộc", "addLaneTitlePlaceholder": "Không bắt buộc",
"autoStage": "tự động: {{stage}}", "autoStage": "tự động: {{stage}}",
"cardId": "Làn đường {{id}}", "cardId": "Làn đường {{id}}",
"stageUndeclared": "chưa khai báo",
"confirmRemoveCancel": "Hủy", "confirmRemoveCancel": "Hủy",
"confirmRemoveConfirm": "Xóa", "confirmRemoveConfirm": "Xóa",
"confirmRemoveMessage": "Hành động này không thể hoàn tác.", "confirmRemoveMessage": "Hành động này không thể hoàn tác.",
+6
View File
@@ -50,6 +50,12 @@
"skipToContent": "Bỏ qua đến nội dung", "skipToContent": "Bỏ qua đến nội dung",
"statsPersisted": "Số liệu được lưu sau khi tải lại", "statsPersisted": "Số liệu được lưu sau khi tải lại",
"switchLanguage": "Chuyển sang {{language}}", "switchLanguage": "Chuyển sang {{language}}",
"switchTheme": "Chuyển sang {{theme}}",
"theme": "Giao diện",
"themeNames": {
"dark": "Tối",
"light": "Sáng"
},
"throughput60s": "Lưu lượng · 60s qua", "throughput60s": "Lưu lượng · 60s qua",
"topEventTypes": "Loại sự kiện phổ biến", "topEventTypes": "Loại sự kiện phổ biến",
"unitEvents": "sự kiện", "unitEvents": "sự kiện",
+77 -12
View File
@@ -8,10 +8,74 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
/* Palette sourced from Radix Colors (radix-ui.com/colors), an accessible
* 12-step system built for UI, contrast-checked with APCA chosen after
* three rounds of hand-picked values that kept overshooting (too flat, then
* too dark, then too glaring). Radix's own role guidance for a 12-step scale:
* 1-2 app/chrome background 6-8 borders (6 subtle, 7-8 stronger)
* 3-5 component background 9-10 solid (buttons, vibrant fills)
* 11-12 text (11 secondary, 12 primary)
* `slate` supplies surface/border/fg here; `blue` supplies accent; `green`/
* `red`/`amber` step 11 supplies the status tokens the same step used for
* fg-secondary, since 11 is Radix's guaranteed-readable-text step. Values are
* the literal Radix hex constants (steps 1/2/3/4/5/6/7/9/11/12), converted to
* RGB triplets for Tailwind's `<alpha-value>` opacity support.
*/
:root {
/* Light theme the CSS default, so it applies with no class on <html>.
Radix `slate` (light), steps 1/2/3/4/5/6/7. */
--surface-0: 252 252 253; /* slate-1 #fcfcfd */
--surface-1: 249 249 251; /* slate-2 #f9f9fb */
--surface-2: 240 240 243; /* slate-3 #f0f0f3 */
--surface-3: 232 232 236; /* slate-4 #e8e8ec */
--surface-4: 224 225 230; /* slate-5 #e0e1e6 */
--surface-5: 217 217 224; /* slate-6 #d9d9e0 */
--border: 217 217 224; /* slate-6 #d9d9e0 */
--border-light: 205 206 214; /* slate-7 #cdced6 */
--accent: 0 144 255; /* blue-9 #0090ff */
--accent-hover: 5 136 240; /* blue-10 #0588f0 */
--fg-primary: 28 32 36; /* slate-12 #1c2024 */
--fg-secondary: 96 100 108; /* slate-11 #60646c */
--fg-muted: 139 141 152; /* slate-9 #8b8d98 */
--shadow-card: 0 1px 2px rgb(15 23 42 / 0.06), 0 1px 3px rgb(15 23 42 / 0.08);
/* Status: green/red/amber step 11 (light) the same "readable text" step
as fg-secondary, so success/danger/warning sit at the same visual weight
as ordinary secondary text, not shouting over it. */
--status-success: 33 131 88; /* green-11 #218358 */
--status-danger: 206 44 49; /* red-11 #ce2c31 */
--status-warning: 171 100 0; /* amber-11 #ab6400 */
}
.dark {
/* Radix `slate` (dark), steps 1/2/3/4/5/6/7. The scale is built in the
opposite direction on purpose step 6 is LIGHTER than step 3/4 here,
which is what makes borders rise off a panel instead of sinking into it
(the bug in the previous, hand-picked dark border). */
--surface-0: 17 17 19; /* slate-1 #111113 */
--surface-1: 24 25 27; /* slate-2 #18191b */
--surface-2: 33 34 37; /* slate-3 #212225 */
--surface-3: 39 42 45; /* slate-4 #272a2d */
--surface-4: 46 49 53; /* slate-5 #2e3135 */
--surface-5: 54 58 63; /* slate-6 #363a3f */
--border: 54 58 63; /* slate-6 #363a3f */
--border-light: 67 72 78; /* slate-7 #43484e */
--accent: 0 144 255; /* blue-9 #0090ff — identical value both themes, Radix's brand anchor */
--accent-hover: 59 158 255; /* blue-10 #3b9eff */
--fg-primary: 237 238 240; /* slate-12 #edeef0 */
--fg-secondary: 176 180 186; /* slate-11 #b0b4ba */
--fg-muted: 105 110 119; /* slate-9 #696e77 */
/* Dark surfaces already separate by lightness step; a shadow adds nothing
visible against a dark page and reads as a stray dark smudge. */
--shadow-card: none;
--status-success: 61 214 140; /* green-11 dark #3dd68c */
--status-danger: 255 149 146; /* red-11 dark #ff9592 */
--status-warning: 255 202 22; /* amber-11 dark #ffca16 */
}
@layer base { @layer base {
* { * {
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: #2a2a3d #0c0c14; scrollbar-color: rgb(var(--border)) rgb(var(--surface-0));
} }
*::-webkit-scrollbar { *::-webkit-scrollbar {
@@ -20,21 +84,21 @@
} }
*::-webkit-scrollbar-track { *::-webkit-scrollbar-track {
background: #0c0c14; background: rgb(var(--surface-0));
} }
*::-webkit-scrollbar-thumb { *::-webkit-scrollbar-thumb {
background: #2a2a3d; background: rgb(var(--border));
border-radius: 3px; border-radius: 3px;
} }
*::-webkit-scrollbar-thumb:hover { *::-webkit-scrollbar-thumb:hover {
background: #363650; background: rgb(var(--border-light));
} }
::selection { ::selection {
background: rgba(99, 102, 241, 0.3); background: rgb(var(--accent) / 0.3);
color: #e4e4ed; color: rgb(var(--fg-primary));
} }
::-webkit-calendar-picker-indicator { ::-webkit-calendar-picker-indicator {
@@ -57,6 +121,7 @@
@layer components { @layer components {
.card { .card {
@apply bg-surface-3 border border-border rounded-xl; @apply bg-surface-3 border border-border rounded-xl;
box-shadow: var(--shadow-card);
} }
.card-hover { .card-hover {
@@ -68,7 +133,7 @@
} }
.btn-ghost { .btn-ghost {
@apply inline-flex items-center gap-2 px-3 py-1.5 text-sm text-gray-400 hover:text-gray-200 hover:bg-surface-4 rounded-lg transition-colors duration-150; @apply inline-flex items-center gap-2 px-3 py-1.5 text-sm text-fg-secondary hover:text-fg-primary hover:bg-surface-4 rounded-lg transition-colors duration-150;
} }
.badge { .badge {
@@ -76,7 +141,7 @@
} }
.input { .input {
@apply bg-surface-2 border border-border rounded-lg px-3 py-2 text-sm text-gray-200 placeholder-gray-500 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30 transition-colors; @apply bg-surface-2 border border-border rounded-lg px-3 py-2 text-sm text-fg-primary placeholder-fg-muted focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30 transition-colors;
} }
} }
@@ -98,9 +163,9 @@
z-index: 100; z-index: 100;
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
border-radius: 0.5rem; border-radius: 0.5rem;
border: 1px solid #363650; border: 1px solid rgb(var(--border-light));
background: #15151f; background: rgb(var(--surface-2));
color: #e4e4ed; color: rgb(var(--fg-primary));
font-size: 0.8125rem; font-size: 0.8125rem;
font-weight: 500; font-weight: 500;
text-decoration: none; text-decoration: none;
@@ -109,7 +174,7 @@
} }
.skip-to-content:focus { .skip-to-content:focus {
transform: translateY(0); transform: translateY(0);
outline: 2px solid #6366f1; outline: 2px solid rgb(var(--accent));
outline-offset: 2px; outline-offset: 2px;
} }
} }
+21 -21
View File
@@ -331,20 +331,20 @@ export function ActivityFeed() {
</div> </div>
<div> <div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h1 className="text-lg font-semibold text-gray-100">{t("title")}</h1> <h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1>
{wsConnected ? ( {wsConnected ? (
<span className="flex items-center gap-1.5 text-[11px] text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 px-2 py-0.5 rounded-full"> <span className="flex items-center gap-1.5 text-[11px] text-status-success bg-status-success/10 border border-status-success/20 px-2 py-0.5 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse-dot" /> <span className="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse-dot" />
{t("common:live")} {t("common:live")}
</span> </span>
) : ( ) : (
<span className="flex items-center gap-1.5 text-[11px] text-gray-400 bg-gray-500/10 border border-gray-500/20 px-2 py-0.5 rounded-full"> <span className="flex items-center gap-1.5 text-[11px] text-fg-secondary bg-surface-4/10 border border-border-light/20 px-2 py-0.5 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-gray-400" /> <span className="w-1.5 h-1.5 rounded-full bg-surface-4" />
{t("common:offline")} {t("common:offline")}
</span> </span>
)} )}
</div> </div>
<p className="text-xs text-gray-500"> <p className="text-xs text-fg-muted">
{t("subtitle")} {t("subtitle")}
{paused && ( {paused && (
<span className="ml-2 text-yellow-400">{t("paused", { count: bufferCount })}</span> <span className="ml-2 text-yellow-400">{t("paused", { count: bufferCount })}</span>
@@ -430,14 +430,14 @@ export function ActivityFeed() {
className="flex items-center px-5 py-3.5 gap-4 hover:bg-surface-4 transition-colors cursor-pointer select-none" className="flex items-center px-5 py-3.5 gap-4 hover:bg-surface-4 transition-colors cursor-pointer select-none"
> >
<ChevronRight <ChevronRight
className={`w-3.5 h-3.5 text-gray-500 transition-transform flex-shrink-0 -mr-1.5 ${isOpen ? "rotate-90" : ""}`} className={`w-3.5 h-3.5 text-fg-muted transition-transform flex-shrink-0 -mr-1.5 ${isOpen ? "rotate-90" : ""}`}
/> />
<div className="w-16 flex-shrink-0 text-right font-mono leading-tight"> <div className="w-16 flex-shrink-0 text-right font-mono leading-tight">
<div className="text-[11px] text-gray-500"> <div className="text-[11px] text-fg-muted">
{formatTime(event.created_at)} {formatTime(event.created_at)}
</div> </div>
<div className="text-[9px] text-gray-600"> <div className="text-[9px] text-fg-muted">
{formatDateShort(event.created_at)} {formatDateShort(event.created_at)}
</div> </div>
</div> </div>
@@ -457,10 +457,10 @@ export function ActivityFeed() {
); );
return ( return (
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm text-gray-300 truncate"> <p className="text-sm text-fg-secondary truncate">
{origin && ( {origin && (
<span <span
className="text-gray-500 mr-1" className="text-fg-muted mr-1"
title={`${event.session_id} · ${event.agent_id ?? ""}`} title={`${event.session_id} · ${event.agent_id ?? ""}`}
> >
{origin} · {origin} ·
@@ -473,12 +473,12 @@ export function ActivityFeed() {
})()} })()}
{event.tool_name && ( {event.tool_name && (
<span className="text-[11px] px-2 py-0.5 bg-surface-2 rounded text-gray-500 font-mono flex-shrink-0"> <span className="text-[11px] px-2 py-0.5 bg-surface-2 rounded text-fg-muted font-mono flex-shrink-0">
{event.tool_name} {event.tool_name}
</span> </span>
)} )}
<span className="text-[11px] text-gray-600 flex-shrink-0 w-16 text-right"> <span className="text-[11px] text-fg-muted flex-shrink-0 w-16 text-right">
{timeAgo(event.created_at)} {timeAgo(event.created_at)}
</span> </span>
@@ -486,7 +486,7 @@ export function ActivityFeed() {
to={`/sessions/${event.session_id}`} to={`/sessions/${event.session_id}`}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
title={t("viewSession")} title={t("viewSession")}
className="flex items-center gap-1 text-[11px] px-2.5 py-1 rounded-md bg-surface-2 text-gray-400 hover:text-accent hover:bg-accent/10 border border-border hover:border-accent/30 transition-colors flex-shrink-0 font-medium" className="flex items-center gap-1 text-[11px] px-2.5 py-1 rounded-md bg-surface-2 text-fg-secondary hover:text-accent hover:bg-accent/10 border border-border hover:border-accent/30 transition-colors flex-shrink-0 font-medium"
> >
{t("viewSession")} {t("viewSession")}
<ExternalLink className="w-3 h-3" /> <ExternalLink className="w-3 h-3" />
@@ -500,7 +500,7 @@ export function ActivityFeed() {
</div> </div>
{total > 0 && ( {total > 0 && (
<div className="flex items-center justify-between mt-4 px-1"> <div className="flex items-center justify-between mt-4 px-1">
<span className="text-xs text-gray-500"> <span className="text-xs text-fg-muted">
{t("common:pagination.showing", { {t("common:pagination.showing", {
from: page * PAGE_SIZE + 1, from: page * PAGE_SIZE + 1,
to: Math.min((page + 1) * PAGE_SIZE, total), to: Math.min((page + 1) * PAGE_SIZE, total),
@@ -511,7 +511,7 @@ export function ActivityFeed() {
<button <button
onClick={() => setPage(0)} onClick={() => setPage(0)}
disabled={page === 0} disabled={page === 0}
className="px-2 py-1.5 text-xs font-medium rounded-md bg-surface-2 text-gray-400 hover:text-gray-200 disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer" className="px-2 py-1.5 text-xs font-medium rounded-md bg-surface-2 text-fg-secondary hover:text-fg-primary disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer"
aria-label="First page" aria-label="First page"
> >
« «
@@ -519,7 +519,7 @@ export function ActivityFeed() {
<button <button
onClick={() => setPage((p) => Math.max(0, p - 1))} onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0} disabled={page === 0}
className="px-3 py-1.5 text-xs font-medium rounded-md bg-surface-2 text-gray-400 hover:text-gray-200 disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer" className="px-3 py-1.5 text-xs font-medium rounded-md bg-surface-2 text-fg-secondary hover:text-fg-primary disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer"
> >
{t("common:pagination.previous")} {t("common:pagination.previous")}
</button> </button>
@@ -544,7 +544,7 @@ export function ActivityFeed() {
p === "..." ? ( p === "..." ? (
<span <span
key={`ellipsis-${idx}`} key={`ellipsis-${idx}`}
className="px-2 py-1.5 text-xs text-gray-600 select-none" className="px-2 py-1.5 text-xs text-fg-muted select-none"
> >
... ...
</span> </span>
@@ -556,7 +556,7 @@ export function ActivityFeed() {
className={`min-w-[32px] px-2.5 py-1.5 text-xs font-medium rounded-md cursor-pointer transition-colors ${ className={`min-w-[32px] px-2.5 py-1.5 text-xs font-medium rounded-md cursor-pointer transition-colors ${
p === page p === page
? "bg-accent/20 text-accent border border-accent/30" ? "bg-accent/20 text-accent border border-accent/30"
: "bg-surface-2 text-gray-400 hover:text-gray-200" : "bg-surface-2 text-fg-secondary hover:text-fg-primary"
}`} }`}
> >
{p + 1} {p + 1}
@@ -567,14 +567,14 @@ export function ActivityFeed() {
<button <button
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))} onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1} disabled={page >= totalPages - 1}
className="px-3 py-1.5 text-xs font-medium rounded-md bg-surface-2 text-gray-400 hover:text-gray-200 disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer" className="px-3 py-1.5 text-xs font-medium rounded-md bg-surface-2 text-fg-secondary hover:text-fg-primary disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer"
> >
{t("common:pagination.next")} {t("common:pagination.next")}
</button> </button>
<button <button
onClick={() => setPage(totalPages - 1)} onClick={() => setPage(totalPages - 1)}
disabled={page >= totalPages - 1} disabled={page >= totalPages - 1}
className="px-2 py-1.5 text-xs font-medium rounded-md bg-surface-2 text-gray-400 hover:text-gray-200 disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer" className="px-2 py-1.5 text-xs font-medium rounded-md bg-surface-2 text-fg-secondary hover:text-fg-primary disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer"
aria-label="Last page" aria-label="Last page"
> >
» »
+107 -97
View File
@@ -85,7 +85,7 @@ function ChartTooltip({ x, y, children }: { x: number; y: number; children: Reac
const nearRight = x > window.innerWidth - 200; const nearRight = x > window.innerWidth - 200;
return ( return (
<div <div
className="fixed z-50 px-2 py-1.5 text-xs bg-[#12121f] border border-[#2a2a4a] rounded shadow-xl text-gray-200 pointer-events-none whitespace-nowrap" className="fixed z-50 px-2 py-1.5 text-xs bg-[#12121f] border border-[#2a2a4a] rounded shadow-xl text-fg-secondary pointer-events-none whitespace-nowrap"
style={{ style={{
left: nearRight ? x - 14 : x + 14, left: nearRight ? x - 14 : x + 14,
top: y - 10, top: y - 10,
@@ -211,7 +211,7 @@ function Heatmap({ weeks }: { weeks: Array<Array<{ date: string; count: number }
{monthPositions.map((mp, i) => ( {monthPositions.map((mp, i) => (
<div <div
key={i} key={i}
className="absolute text-[10px] text-gray-600 font-medium whitespace-nowrap" className="absolute text-[10px] text-fg-muted font-medium whitespace-nowrap"
style={{ left: mp.col * 16 }} style={{ left: mp.col * 16 }}
> >
{mp.label} {mp.label}
@@ -224,7 +224,7 @@ function Heatmap({ weeks }: { weeks: Array<Array<{ date: string; count: number }
{dayLabels.map((d, i) => ( {dayLabels.map((d, i) => (
<div <div
key={i} key={i}
className="text-[9px] text-gray-700 flex items-center justify-end pr-1.5" className="text-[9px] text-fg-muted flex items-center justify-end pr-1.5"
style={{ height: 13 }} style={{ height: 13 }}
> >
{d} {d}
@@ -247,7 +247,7 @@ function Heatmap({ weeks }: { weeks: Array<Array<{ date: string; count: number }
show( show(
e, e,
<> <>
<span className="text-gray-400"> <span className="text-fg-secondary">
{dayNames[dow] ?? ""}, {cell.date} {dayNames[dow] ?? ""}, {cell.date}
</span> </span>
<span className="ml-2 font-medium"> <span className="ml-2 font-medium">
@@ -273,7 +273,7 @@ function Heatmap({ weeks }: { weeks: Array<Array<{ date: string; count: number }
))} ))}
</div> </div>
{/* Legend */} {/* Legend */}
<div className="flex items-center gap-2 mt-3 text-[11px] text-gray-600"> <div className="flex items-center gap-2 mt-3 text-[11px] text-fg-muted">
<span>{t("less")}</span> <span>{t("less")}</span>
{[0, 0.25, 0.5, 0.75, 1].map((f) => { {[0, 0.25, 0.5, 0.75, 1].map((f) => {
const v = Math.round(f * maxCount); const v = Math.round(f * maxCount);
@@ -324,7 +324,7 @@ function Sparkline({
show( show(
e, e,
<> <>
<span className="text-gray-400">{date}</span> <span className="text-fg-secondary">{date}</span>
<span className="ml-2 font-medium">{t("eventCountLabel", { count })}</span> <span className="ml-2 font-medium">{t("eventCountLabel", { count })}</span>
</> </>
) )
@@ -404,7 +404,7 @@ function CostTrendLine({
show( show(
e, e,
<> <>
<span className="text-gray-400">{point.date}</span> <span className="text-fg-secondary">{point.date}</span>
<span className="ml-2 font-medium">{fmtCostFull(point.cost)}</span> <span className="ml-2 font-medium">{fmtCostFull(point.cost)}</span>
</> </>
) )
@@ -437,7 +437,7 @@ function BarRow({
const width = pct !== undefined ? pct : max > 0 ? Math.round((count / max) * 100) : 0; const width = pct !== undefined ? pct : max > 0 ? Math.round((count / max) * 100) : 0;
return ( return (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="text-xs text-gray-400 w-28 truncate flex-shrink-0" title={label}> <span className="text-xs text-fg-secondary w-28 truncate flex-shrink-0" title={label}>
{label} {label}
</span> </span>
<div className="flex-1 bg-surface-3 rounded-full h-2"> <div className="flex-1 bg-surface-3 rounded-full h-2">
@@ -447,7 +447,7 @@ function BarRow({
/> />
</div> </div>
<Tip raw={count.toLocaleString()}> <Tip raw={count.toLocaleString()}>
<span className="text-xs text-gray-500 w-10 text-right flex-shrink-0">{fmt(count)}</span> <span className="text-xs text-fg-muted w-10 text-right flex-shrink-0">{fmt(count)}</span>
</Tip> </Tip>
</div> </div>
); );
@@ -457,7 +457,7 @@ function CostBarRow({
label, label,
cost, cost,
max, max,
color = "bg-emerald-400", color = "bg-status-success",
}: { }: {
label: string; label: string;
cost: number; cost: number;
@@ -467,7 +467,7 @@ function CostBarRow({
const width = max > 0 ? Math.max(2, Math.round((cost / max) * 100)) : 0; const width = max > 0 ? Math.max(2, Math.round((cost / max) * 100)) : 0;
return ( return (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="text-xs text-gray-400 w-24 truncate flex-shrink-0" title={label}> <span className="text-xs text-fg-secondary w-24 truncate flex-shrink-0" title={label}>
{label} {label}
</span> </span>
<div className="flex-1 bg-surface-3 rounded-full h-2"> <div className="flex-1 bg-surface-3 rounded-full h-2">
@@ -476,7 +476,7 @@ function CostBarRow({
style={{ width: `${width}%` }} style={{ width: `${width}%` }}
/> />
</div> </div>
<span className="text-xs text-emerald-400 font-mono w-16 text-right flex-shrink-0"> <span className="text-xs text-status-success font-mono w-16 text-right flex-shrink-0">
<Tip raw={fmtCostFull(cost)}>{fmtCost(cost)}</Tip> <Tip raw={fmtCostFull(cost)}>{fmtCost(cost)}</Tip>
</span> </span>
</div> </div>
@@ -495,7 +495,7 @@ function DonutChart({
const { t } = useTranslation(["analytics", "common"]); const { t } = useTranslation(["analytics", "common"]);
const { show, move, hide, node } = useTooltip(); const { show, move, hide, node } = useTooltip();
const total = segments.reduce((s, g) => s + g.value, 0); const total = segments.reduce((s, g) => s + g.value, 0);
if (total === 0) return <div className="text-xs text-gray-500">{t("common:noData")}</div>; if (total === 0) return <div className="text-xs text-fg-muted">{t("common:noData")}</div>;
const r = 52; const r = 52;
const cx = 64; const cx = 64;
@@ -543,10 +543,10 @@ function DonutChart({
/> />
); );
})} })}
<text x={cx} y={cy - 6} textAnchor="middle" className="fill-gray-300" fontSize={11}> <text x={cx} y={cy - 6} textAnchor="middle" className="fill-fg-secondary" fontSize={11}>
{(formatTotal ?? fmt)(total)} {(formatTotal ?? fmt)(total)}
</text> </text>
<text x={cx} y={cy + 10} textAnchor="middle" className="fill-gray-600" fontSize={9}> <text x={cx} y={cy + 10} textAnchor="middle" className="fill-fg-muted" fontSize={9}>
{t("common:total_lower")} {t("common:total_lower")}
</text> </text>
</svg> </svg>
@@ -557,8 +557,8 @@ function DonutChart({
className="w-2.5 h-2.5 rounded-sm flex-shrink-0" className="w-2.5 h-2.5 rounded-sm flex-shrink-0"
style={{ backgroundColor: color }} style={{ backgroundColor: color }}
/> />
<span className="text-gray-400">{label}</span> <span className="text-fg-secondary">{label}</span>
<span className="text-gray-500 ml-auto pl-4">{Math.round((value / total) * 100)}%</span> <span className="text-fg-muted ml-auto pl-4">{Math.round((value / total) * 100)}%</span>
</div> </div>
))} ))}
</div> </div>
@@ -588,7 +588,7 @@ function StatPill({
return ( return (
<div className="card p-5 flex flex-col gap-2"> <div className="card p-5 flex flex-col gap-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-xs text-gray-500 uppercase tracking-wider">{label}</span> <span className="text-xs text-fg-muted uppercase tracking-wider">{label}</span>
<Icon className={`w-4 h-4 ${color}`} /> <Icon className={`w-4 h-4 ${color}`} />
</div> </div>
{loading ? ( {loading ? (
@@ -601,7 +601,7 @@ function StatPill({
{loading ? ( {loading ? (
<TextSkeleton width="w-20" /> <TextSkeleton width="w-20" />
) : ( ) : (
sub && <p className="text-[11px] text-gray-500">{sub}</p> sub && <p className="text-[11px] text-fg-muted">{sub}</p>
)} )}
</div> </div>
); );
@@ -881,8 +881,8 @@ export function Analytics() {
].filter((s) => s.value > 0); ].filter((s) => s.value > 0);
const EVENT_TYPE_COLORS: Record<string, string> = { const EVENT_TYPE_COLORS: Record<string, string> = {
PreToolUse: "bg-emerald-400", PreToolUse: "bg-status-success",
PostToolUse: "bg-blue-400", PostToolUse: "bg-blue-500",
Stop: "bg-violet-400", Stop: "bg-violet-400",
SubagentStop: "bg-yellow-400", SubagentStop: "bg-yellow-400",
Notification: "bg-orange-400", Notification: "bg-orange-400",
@@ -899,22 +899,22 @@ export function Analytics() {
</div> </div>
<div> <div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h1 className="text-lg font-semibold text-gray-100">{t("title")}</h1> <h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1>
{wsConnected ? ( {wsConnected ? (
<span className="flex items-center gap-1.5 text-[11px] text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 px-2 py-0.5 rounded-full"> <span className="flex items-center gap-1.5 text-[11px] text-status-success bg-status-success/10 border border-status-success/20 px-2 py-0.5 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse-dot" /> <span className="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse-dot" />
{t("common:live")} {t("common:live")}
</span> </span>
) : ( ) : (
<span className="flex items-center gap-1.5 text-[11px] text-gray-400 bg-gray-500/10 border border-gray-500/20 px-2 py-0.5 rounded-full"> <span className="flex items-center gap-1.5 text-[11px] text-fg-secondary bg-surface-4/10 border border-border-light/20 px-2 py-0.5 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-gray-400" /> <span className="w-1.5 h-1.5 rounded-full bg-surface-4" />
{t("common:offline")} {t("common:offline")}
</span> </span>
)} )}
</div> </div>
<p className="text-xs text-gray-500 flex items-center gap-2"> <p className="text-xs text-fg-muted flex items-center gap-2">
{t("subtitle")} {t("subtitle")}
<span className="inline-flex items-center gap-1.5 text-[11px] text-gray-500 bg-surface-2 border border-border px-2 py-0.5 rounded-md font-mono ml-2"> <span className="inline-flex items-center gap-1.5 text-[11px] text-fg-muted bg-surface-2 border border-border px-2 py-0.5 rounded-md font-mono ml-2">
<Clock className="w-3 h-3" /> <Clock className="w-3 h-3" />
{lastUpdate.toLocaleTimeString()} {lastUpdate.toLocaleTimeString()}
</span> </span>
@@ -942,7 +942,7 @@ export function Analytics() {
raw={data ? data.overview.total_sessions.toLocaleString() : undefined} raw={data ? data.overview.total_sessions.toLocaleString() : undefined}
sub={data ? `${data.overview.active_sessions} ${t("common:active")}` : undefined} sub={data ? `${data.overview.active_sessions} ${t("common:active")}` : undefined}
icon={FolderOpen} icon={FolderOpen}
color="text-blue-400" color="text-blue-500"
loading={!data} loading={!data}
/> />
<StatPill <StatPill
@@ -951,7 +951,7 @@ export function Analytics() {
raw={data ? data.overview.total_agents.toLocaleString() : undefined} raw={data ? data.overview.total_agents.toLocaleString() : undefined}
sub={data ? `${data.overview.active_agents} ${t("common:active")}` : undefined} sub={data ? `${data.overview.active_agents} ${t("common:active")}` : undefined}
icon={Bot} icon={Bot}
color="text-emerald-400" color="text-status-success"
loading={!data} loading={!data}
/> />
<StatPill <StatPill
@@ -973,7 +973,7 @@ export function Analytics() {
: undefined : undefined
} }
icon={DollarSign} icon={DollarSign}
color="text-emerald-400" color="text-status-success"
loading={!costData} loading={!costData}
/> />
<StatPill <StatPill
@@ -994,7 +994,7 @@ export function Analytics() {
{/* Activity heatmap + 30-day sparkline */} {/* Activity heatmap + 30-day sparkline */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="card p-5 lg:col-span-2"> <div className="card p-5 lg:col-span-2">
<h3 className="text-sm font-medium text-gray-300 mb-4">{t("eventActivity")}</h3> <h3 className="text-sm font-medium text-fg-secondary mb-4">{t("eventActivity")}</h3>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<div className="w-fit min-w-max mx-auto"> <div className="w-fit min-w-max mx-auto">
<Heatmap weeks={weeks} /> <Heatmap weeks={weeks} />
@@ -1002,17 +1002,17 @@ export function Analytics() {
</div> </div>
</div> </div>
<div className="card p-5"> <div className="card p-5">
<h3 className="text-sm font-medium text-gray-300 mb-1">{t("last30Days")}</h3> <h3 className="text-sm font-medium text-fg-secondary mb-1">{t("last30Days")}</h3>
<p className="text-[11px] text-gray-600 mb-4">{t("dailyEventCount")}</p> <p className="text-[11px] text-fg-muted mb-4">{t("dailyEventCount")}</p>
<Sparkline data={last30} /> <Sparkline data={last30} />
<div className="flex justify-between text-[11px] text-gray-600 mt-2"> <div className="flex justify-between text-[11px] text-fg-muted mt-2">
<span>{last30[0]?.date?.slice(5)}</span> <span>{last30[0]?.date?.slice(5)}</span>
<span>{last30[last30.length - 1]?.date?.slice(5)}</span> <span>{last30[last30.length - 1]?.date?.slice(5)}</span>
</div> </div>
<div className="mt-4 pt-4 border-t border-border space-y-1"> <div className="mt-4 pt-4 border-t border-border space-y-1">
<div className="flex justify-between text-xs"> <div className="flex justify-between text-xs">
<span className="text-gray-500">{t("peakDay")}</span> <span className="text-fg-muted">{t("peakDay")}</span>
<span className="text-gray-300 font-mono"> <span className="text-fg-secondary font-mono">
<Tip raw={Math.max(...last30.map((d) => d.count)).toLocaleString()}> <Tip raw={Math.max(...last30.map((d) => d.count)).toLocaleString()}>
{fmt(Math.max(...last30.map((d) => d.count)))} {fmt(Math.max(...last30.map((d) => d.count)))}
</Tip>{" "} </Tip>{" "}
@@ -1020,8 +1020,8 @@ export function Analytics() {
</span> </span>
</div> </div>
<div className="flex justify-between text-xs"> <div className="flex justify-between text-xs">
<span className="text-gray-500">{t("total30d")}</span> <span className="text-fg-muted">{t("total30d")}</span>
<span className="text-gray-300 font-mono"> <span className="text-fg-secondary font-mono">
<Tip raw={last30.reduce((s, d) => s + d.count, 0).toLocaleString()}> <Tip raw={last30.reduce((s, d) => s + d.count, 0).toLocaleString()}>
{fmt(last30.reduce((s, d) => s + d.count, 0))} {fmt(last30.reduce((s, d) => s + d.count, 0))}
</Tip>{" "} </Tip>{" "}
@@ -1048,8 +1048,8 @@ export function Analytics() {
onClick={() => setActiveTab(key)} onClick={() => setActiveTab(key)}
className={`px-4 py-1.5 text-xs font-medium rounded-md transition-colors ${ className={`px-4 py-1.5 text-xs font-medium rounded-md transition-colors ${
activeTab === key activeTab === key
? "bg-surface-4 text-gray-200" ? "bg-surface-4 text-fg-secondary"
: "text-gray-500 hover:text-gray-300" : "text-fg-muted hover:text-fg-secondary"
}`} }`}
> >
{label} {label}
@@ -1061,7 +1061,7 @@ export function Analytics() {
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{/* Token bars */} {/* Token bars */}
<div className="card p-5"> <div className="card p-5">
<h3 className="text-sm font-medium text-gray-300 mb-5"> <h3 className="text-sm font-medium text-fg-secondary mb-5">
{t("tokenDistribution")} {t("tokenDistribution")}
</h3> </h3>
<div className="space-y-4"> <div className="space-y-4">
@@ -1069,12 +1069,12 @@ export function Analytics() {
{ {
label: t("common:token.input"), label: t("common:token.input"),
value: data?.tokens.total_input ?? 0, value: data?.tokens.total_input ?? 0,
color: "bg-blue-400", color: "bg-blue-500",
}, },
{ {
label: t("common:token.output"), label: t("common:token.output"),
value: data?.tokens.total_output ?? 0, value: data?.tokens.total_output ?? 0,
color: "bg-emerald-400", color: "bg-status-success",
}, },
{ {
label: t("common:token.cacheRead"), label: t("common:token.cacheRead"),
@@ -1097,13 +1097,13 @@ export function Analytics() {
))} ))}
</div> </div>
<div className="mt-6 pt-4 border-t border-border space-y-1.5"> <div className="mt-6 pt-4 border-t border-border space-y-1.5">
<div className="flex justify-between text-xs text-gray-500"> <div className="flex justify-between text-xs text-fg-muted">
<span>{t("common:token.totalTokens")}</span> <span>{t("common:token.totalTokens")}</span>
<Tip raw={totalTokens.toLocaleString()}> <Tip raw={totalTokens.toLocaleString()}>
<span className="text-gray-300 font-mono">{fmt(totalTokens)}</span> <span className="text-fg-secondary font-mono">{fmt(totalTokens)}</span>
</Tip> </Tip>
</div> </div>
<div className="flex justify-between text-xs text-gray-500"> <div className="flex justify-between text-xs text-fg-muted">
<span>{t("cacheEfficiency")}</span> <span>{t("cacheEfficiency")}</span>
<span className="text-violet-400 font-mono">{cacheHitPct}%</span> <span className="text-violet-400 font-mono">{cacheHitPct}%</span>
</div> </div>
@@ -1112,18 +1112,20 @@ export function Analytics() {
{/* Token summary */} {/* Token summary */}
<div className="card p-5"> <div className="card p-5">
<h3 className="text-sm font-medium text-gray-300 mb-5">{t("tokenBreakdown")}</h3> <h3 className="text-sm font-medium text-fg-secondary mb-5">
{t("tokenBreakdown")}
</h3>
<div className="space-y-3"> <div className="space-y-3">
{[ {[
{ {
label: t("common:token.input"), label: t("common:token.input"),
value: data?.tokens.total_input ?? 0, value: data?.tokens.total_input ?? 0,
color: "text-blue-400", color: "text-blue-500",
}, },
{ {
label: t("common:token.output"), label: t("common:token.output"),
value: data?.tokens.total_output ?? 0, value: data?.tokens.total_output ?? 0,
color: "text-emerald-400", color: "text-status-success",
}, },
{ {
label: t("common:token.cacheRead"), label: t("common:token.cacheRead"),
@@ -1135,13 +1137,13 @@ export function Analytics() {
value: data?.tokens.total_cache_write ?? 0, value: data?.tokens.total_cache_write ?? 0,
color: "text-yellow-400", color: "text-yellow-400",
}, },
{ label: t("common:total"), value: totalTokens, color: "text-gray-100" }, { label: t("common:total"), value: totalTokens, color: "text-fg-primary" },
].map(({ label, value, color }) => ( ].map(({ label, value, color }) => (
<div <div
key={label} key={label}
className="flex justify-between items-center py-2 border-b border-border last:border-0" className="flex justify-between items-center py-2 border-b border-border last:border-0"
> >
<span className="text-xs text-gray-400">{label}</span> <span className="text-xs text-fg-secondary">{label}</span>
<span className={`text-sm font-mono font-medium ${color}`}> <span className={`text-sm font-mono font-medium ${color}`}>
{value.toLocaleString()} {value.toLocaleString()}
</span> </span>
@@ -1149,23 +1151,23 @@ export function Analytics() {
))} ))}
</div> </div>
{totalTokens === 0 && ( {totalTokens === 0 && (
<p className="text-[11px] text-gray-600 mt-4">{t("tokenInfo")}</p> <p className="text-[11px] text-fg-muted mt-4">{t("tokenInfo")}</p>
)} )}
</div> </div>
{/* Token mix donut */} {/* Token mix donut */}
<div className="card p-5"> <div className="card p-5">
<h3 className="text-sm font-medium text-gray-300 mb-5">{t("tokenMix")}</h3> <h3 className="text-sm font-medium text-fg-secondary mb-5">{t("tokenMix")}</h3>
{tokenMixSegments.length === 0 ? ( {tokenMixSegments.length === 0 ? (
<p className="text-sm text-gray-500">{t("common:noData")}</p> <p className="text-sm text-fg-muted">{t("common:noData")}</p>
) : ( ) : (
<> <>
<DonutChart segments={tokenMixSegments} formatTotal={(total) => fmt(total)} /> <DonutChart segments={tokenMixSegments} formatTotal={(total) => fmt(total)} />
<div className="mt-4 pt-4 border-t border-border space-y-2"> <div className="mt-4 pt-4 border-t border-border space-y-2">
{tokenMixSegments.map((segment) => ( {tokenMixSegments.map((segment) => (
<div key={segment.label} className="flex justify-between text-xs"> <div key={segment.label} className="flex justify-between text-xs">
<span className="text-gray-400">{segment.label}</span> <span className="text-fg-secondary">{segment.label}</span>
<span className="text-gray-300 font-mono"> <span className="text-fg-secondary font-mono">
<Tip raw={segment.value.toLocaleString()}>{fmt(segment.value)}</Tip> <Tip raw={segment.value.toLocaleString()}>{fmt(segment.value)}</Tip>
</span> </span>
</div> </div>
@@ -1181,29 +1183,31 @@ export function Analytics() {
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{/* Daily cost trends */} {/* Daily cost trends */}
<div className="card p-5"> <div className="card p-5">
<h3 className="text-sm font-medium text-gray-300 mb-1">{t("dailyCostTrends")}</h3> <h3 className="text-sm font-medium text-fg-secondary mb-1">
{t("dailyCostTrends")}
</h3>
{dailyCostsLocal.length === 0 ? ( {dailyCostsLocal.length === 0 ? (
<p className="text-sm text-gray-500">{t("noDailyCostData")}</p> <p className="text-sm text-fg-muted">{t("noDailyCostData")}</p>
) : ( ) : (
<> <>
<p className="text-[11px] text-gray-600 mb-4">{t("costPerDay")}</p> <p className="text-[11px] text-fg-muted mb-4">{t("costPerDay")}</p>
<CostTrendLine data={dailyCostLast30} /> <CostTrendLine data={dailyCostLast30} />
<div className="flex justify-between text-[11px] text-gray-600 mt-2"> <div className="flex justify-between text-[11px] text-fg-muted mt-2">
<span>{dailyCostLast30[0]?.date?.slice(5)}</span> <span>{dailyCostLast30[0]?.date?.slice(5)}</span>
<span>{dailyCostLast30[dailyCostLast30.length - 1]?.date?.slice(5)}</span> <span>{dailyCostLast30[dailyCostLast30.length - 1]?.date?.slice(5)}</span>
</div> </div>
<div className="mt-4 pt-4 border-t border-border space-y-1"> <div className="mt-4 pt-4 border-t border-border space-y-1">
<div className="flex justify-between text-xs"> <div className="flex justify-between text-xs">
<span className="text-gray-500">{t("peakCostDay")}</span> <span className="text-fg-muted">{t("peakCostDay")}</span>
<span className="text-emerald-400 font-mono"> <span className="text-status-success font-mono">
<Tip raw={`${peakCostDay.date}${fmtCostFull(peakCostDay.cost)}`}> <Tip raw={`${peakCostDay.date}${fmtCostFull(peakCostDay.cost)}`}>
{fmtCost(peakCostDay.cost)} {fmtCost(peakCostDay.cost)}
</Tip> </Tip>
</span> </span>
</div> </div>
<div className="flex justify-between text-xs"> <div className="flex justify-between text-xs">
<span className="text-gray-500">{t("totalCost30d")}</span> <span className="text-fg-muted">{t("totalCost30d")}</span>
<span className="text-emerald-400 font-mono"> <span className="text-status-success font-mono">
<Tip raw={fmtCostFull(totalCost30d)}>{fmtCost(totalCost30d)}</Tip> <Tip raw={fmtCostFull(totalCost30d)}>{fmtCost(totalCost30d)}</Tip>
</span> </span>
</div> </div>
@@ -1214,7 +1218,7 @@ export function Analytics() {
{/* Cost by model */} {/* Cost by model */}
<div className="card p-5"> <div className="card p-5">
<h3 className="text-sm font-medium text-gray-300 mb-5">{t("costByModel")}</h3> <h3 className="text-sm font-medium text-fg-secondary mb-5">{t("costByModel")}</h3>
{costBreakdown.length > 0 ? ( {costBreakdown.length > 0 ? (
<> <>
<DonutChart <DonutChart
@@ -1231,17 +1235,17 @@ export function Analytics() {
<div className="mt-4 pt-4 border-t border-border space-y-2"> <div className="mt-4 pt-4 border-t border-border space-y-2">
{costBreakdown.map((b) => ( {costBreakdown.map((b) => (
<div key={b.model} className="flex justify-between text-xs"> <div key={b.model} className="flex justify-between text-xs">
<span className="text-gray-400 font-mono truncate"> <span className="text-fg-secondary font-mono truncate">
{formatModelName(b.model)} {formatModelName(b.model)}
</span> </span>
<span className="text-emerald-400 font-mono font-medium ml-2"> <span className="text-status-success font-mono font-medium ml-2">
<Tip raw={fmtCostFull(b.cost)}>{fmtCost(b.cost)}</Tip> <Tip raw={fmtCostFull(b.cost)}>{fmtCost(b.cost)}</Tip>
</span> </span>
</div> </div>
))} ))}
<div className="flex justify-between text-xs pt-2 border-t border-border"> <div className="flex justify-between text-xs pt-2 border-t border-border">
<span className="text-gray-300 font-medium">{t("common:total")}</span> <span className="text-fg-secondary font-medium">{t("common:total")}</span>
<span className="text-emerald-400 font-mono font-semibold"> <span className="text-status-success font-mono font-semibold">
<Tip raw={fmtCostFull(costData?.total_cost ?? 0)}> <Tip raw={fmtCostFull(costData?.total_cost ?? 0)}>
{fmtCost(costData?.total_cost ?? 0)} {fmtCost(costData?.total_cost ?? 0)}
</Tip> </Tip>
@@ -1250,18 +1254,20 @@ export function Analytics() {
</div> </div>
</> </>
) : ( ) : (
<p className="text-sm text-gray-500">{t("noCostData")}</p> <p className="text-sm text-fg-muted">{t("noCostData")}</p>
)} )}
</div> </div>
{/* Cost by weekday */} {/* Cost by weekday */}
<div className="card p-5"> <div className="card p-5">
<h3 className="text-sm font-medium text-gray-300 mb-1">{t("costByWeekday")}</h3> <h3 className="text-sm font-medium text-fg-secondary mb-1">
{t("costByWeekday")}
</h3>
{dailyCostsLocal.length === 0 ? ( {dailyCostsLocal.length === 0 ? (
<p className="text-sm text-gray-500">{t("noDailyCostData")}</p> <p className="text-sm text-fg-muted">{t("noDailyCostData")}</p>
) : ( ) : (
<> <>
<p className="text-[11px] text-gray-600 mb-4">{t("last30Days")}</p> <p className="text-[11px] text-fg-muted mb-4">{t("last30Days")}</p>
<div className="space-y-3"> <div className="space-y-3">
{weekdayCosts.map(({ label, cost }) => ( {weekdayCosts.map(({ label, cost }) => (
<CostBarRow <CostBarRow
@@ -1274,7 +1280,7 @@ export function Analytics() {
))} ))}
</div> </div>
<div className="mt-4 pt-4 border-t border-border text-xs flex justify-between"> <div className="mt-4 pt-4 border-t border-border text-xs flex justify-between">
<span className="text-gray-500">{t("common:total")}</span> <span className="text-fg-muted">{t("common:total")}</span>
<span className="text-cyan-400 font-mono"> <span className="text-cyan-400 font-mono">
<Tip raw={fmtCostFull(totalCost30d)}>{fmtCost(totalCost30d)}</Tip> <Tip raw={fmtCostFull(totalCost30d)}>{fmtCost(totalCost30d)}</Tip>
</span> </span>
@@ -1289,9 +1295,11 @@ export function Analytics() {
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{/* Agent type distribution */} {/* Agent type distribution */}
<div className="card p-5"> <div className="card p-5">
<h3 className="text-sm font-medium text-gray-300 mb-5">{t("subagentTypes")}</h3> <h3 className="text-sm font-medium text-fg-secondary mb-5">
{t("subagentTypes")}
</h3>
{(data?.agent_types ?? []).length === 0 ? ( {(data?.agent_types ?? []).length === 0 ? (
<p className="text-sm text-gray-500">{t("noSubagentData")}</p> <p className="text-sm text-fg-muted">{t("noSubagentData")}</p>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{(data?.agent_types ?? []).slice(0, 10).map(({ subagent_type, count }) => ( {(data?.agent_types ?? []).slice(0, 10).map(({ subagent_type, count }) => (
@@ -1309,13 +1317,13 @@ export function Analytics() {
{/* Agent status donut */} {/* Agent status donut */}
<div className="card p-5"> <div className="card p-5">
<h3 className="text-sm font-medium text-gray-300 mb-5">{t("agentStatus")}</h3> <h3 className="text-sm font-medium text-fg-secondary mb-5">{t("agentStatus")}</h3>
<DonutChart segments={agentStatusSegments} /> <DonutChart segments={agentStatusSegments} />
<div className="mt-4 pt-4 border-t border-border space-y-1.5"> <div className="mt-4 pt-4 border-t border-border space-y-1.5">
<div className="flex justify-between text-xs text-gray-500"> <div className="flex justify-between text-xs text-fg-muted">
<span>{t("totalAgentsLabel")}</span> <span>{t("totalAgentsLabel")}</span>
<Tip raw={(data?.overview.total_agents ?? 0).toLocaleString()}> <Tip raw={(data?.overview.total_agents ?? 0).toLocaleString()}>
<span className="text-gray-300 font-mono"> <span className="text-fg-secondary font-mono">
{fmt(data?.overview.total_agents ?? 0)} {fmt(data?.overview.total_agents ?? 0)}
</span> </span>
</Tip> </Tip>
@@ -1323,7 +1331,7 @@ export function Analytics() {
{agentStatusSegments.map((s) => ( {agentStatusSegments.map((s) => (
<div <div
key={s.label} key={s.label}
className="flex items-center justify-between text-xs text-gray-500" className="flex items-center justify-between text-xs text-fg-muted"
> >
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<span <span
@@ -1333,7 +1341,7 @@ export function Analytics() {
{s.label} {s.label}
</span> </span>
<Tip raw={s.value.toLocaleString()}> <Tip raw={s.value.toLocaleString()}>
<span className="text-gray-400 font-mono">{fmt(s.value)}</span> <span className="text-fg-secondary font-mono">{fmt(s.value)}</span>
</Tip> </Tip>
</div> </div>
))} ))}
@@ -1342,9 +1350,9 @@ export function Analytics() {
{/* Event type breakdown */} {/* Event type breakdown */}
<div className="card p-5"> <div className="card p-5">
<h3 className="text-sm font-medium text-gray-300 mb-5">{t("eventTypes")}</h3> <h3 className="text-sm font-medium text-fg-secondary mb-5">{t("eventTypes")}</h3>
{(data?.event_types ?? []).length === 0 ? ( {(data?.event_types ?? []).length === 0 ? (
<p className="text-sm text-gray-500">{t("noEventData")}</p> <p className="text-sm text-fg-muted">{t("noEventData")}</p>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{(data?.event_types ?? []).map(({ event_type, count }) => ( {(data?.event_types ?? []).map(({ event_type, count }) => (
@@ -1353,7 +1361,7 @@ export function Analytics() {
label={event_type} label={event_type}
count={count} count={count}
max={maxEventTypeCount} max={maxEventTypeCount}
color={EVENT_TYPE_COLORS[event_type] ?? "bg-gray-400"} color={EVENT_TYPE_COLORS[event_type] ?? "bg-surface-4"}
/> />
))} ))}
</div> </div>
@@ -1366,9 +1374,9 @@ export function Analytics() {
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{/* Top tools */} {/* Top tools */}
<div className="card p-5"> <div className="card p-5">
<h3 className="text-sm font-medium text-gray-300 mb-5">{t("toolUsage")}</h3> <h3 className="text-sm font-medium text-fg-secondary mb-5">{t("toolUsage")}</h3>
{(data?.tool_usage ?? []).length === 0 ? ( {(data?.tool_usage ?? []).length === 0 ? (
<p className="text-sm text-gray-500">{t("noToolData")}</p> <p className="text-sm text-fg-muted">{t("noToolData")}</p>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{(data?.tool_usage ?? []).slice(0, 12).map(({ tool_name, count }) => ( {(data?.tool_usage ?? []).slice(0, 12).map(({ tool_name, count }) => (
@@ -1386,13 +1394,15 @@ export function Analytics() {
{/* Session outcomes donut */} {/* Session outcomes donut */}
<div className="card p-5"> <div className="card p-5">
<h3 className="text-sm font-medium text-gray-300 mb-5">{t("sessionOutcomes")}</h3> <h3 className="text-sm font-medium text-fg-secondary mb-5">
{t("sessionOutcomes")}
</h3>
<DonutChart segments={sessionOutcomeSegments} /> <DonutChart segments={sessionOutcomeSegments} />
<div className="mt-4 pt-4 border-t border-border space-y-1.5"> <div className="mt-4 pt-4 border-t border-border space-y-1.5">
<div className="flex justify-between text-xs text-gray-500"> <div className="flex justify-between text-xs text-fg-muted">
<span>{t("totalSessionsLabel")}</span> <span>{t("totalSessionsLabel")}</span>
<Tip raw={(data?.overview.total_sessions ?? 0).toLocaleString()}> <Tip raw={(data?.overview.total_sessions ?? 0).toLocaleString()}>
<span className="text-gray-300 font-mono"> <span className="text-fg-secondary font-mono">
{fmt(data?.overview.total_sessions ?? 0)} {fmt(data?.overview.total_sessions ?? 0)}
</span> </span>
</Tip> </Tip>
@@ -1400,7 +1410,7 @@ export function Analytics() {
{sessionOutcomeSegments.map((s) => ( {sessionOutcomeSegments.map((s) => (
<div <div
key={s.label} key={s.label}
className="flex items-center justify-between text-xs text-gray-500" className="flex items-center justify-between text-xs text-fg-muted"
> >
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<span <span
@@ -1410,7 +1420,7 @@ export function Analytics() {
{s.label} {s.label}
</span> </span>
<Tip raw={s.value.toLocaleString()}> <Tip raw={s.value.toLocaleString()}>
<span className="text-gray-400 font-mono">{fmt(s.value)}</span> <span className="text-fg-secondary font-mono">{fmt(s.value)}</span>
</Tip> </Tip>
</div> </div>
))} ))}
@@ -1419,11 +1429,11 @@ export function Analytics() {
{/* Daily session trends */} {/* Daily session trends */}
<div className="card p-5"> <div className="card p-5">
<h3 className="text-sm font-medium text-gray-300 mb-5"> <h3 className="text-sm font-medium text-fg-secondary mb-5">
{t("dailySessionTrends")} {t("dailySessionTrends")}
</h3> </h3>
{dailySessionsLocal.length === 0 ? ( {dailySessionsLocal.length === 0 ? (
<p className="text-sm text-gray-500">{t("noSessionTrendData")}</p> <p className="text-sm text-fg-muted">{t("noSessionTrendData")}</p>
) : ( ) : (
<> <>
<Sparkline data={dailySessionsLocal.slice(-30)} color="#6366f1" /> <Sparkline data={dailySessionsLocal.slice(-30)} color="#6366f1" />
@@ -1440,7 +1450,7 @@ export function Analytics() {
); );
return ( return (
<div key={date} className="flex items-center gap-3"> <div key={date} className="flex items-center gap-3">
<span className="text-[11px] text-gray-500 font-mono w-20 flex-shrink-0"> <span className="text-[11px] text-fg-muted font-mono w-20 flex-shrink-0">
{date.slice(5)} {date.slice(5)}
</span> </span>
<div className="flex-1 bg-surface-3 rounded-full h-1.5"> <div className="flex-1 bg-surface-3 rounded-full h-1.5">
@@ -1451,14 +1461,14 @@ export function Analytics() {
}} }}
/> />
</div> </div>
<span className="text-[11px] text-gray-500 w-4 text-right"> <span className="text-[11px] text-fg-muted w-4 text-right">
{count} {count}
</span> </span>
</div> </div>
); );
})} })}
</div> </div>
<p className="text-[11px] text-gray-600 mt-3">{t("last7Days")}</p> <p className="text-[11px] text-fg-muted mt-3">{t("last7Days")}</p>
</> </>
)} )}
</div> </div>
File diff suppressed because it is too large Load Diff
+112 -108
View File
@@ -278,28 +278,28 @@ function SystemHealthTab() {
<div className="card p-5 flex flex-col gap-4"> <div className="card p-5 flex flex-col gap-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Server className="w-4 h-4 text-emerald-400" /> <Server className="w-4 h-4 text-status-success" />
<span className="text-xs text-gray-500 uppercase tracking-wider">Runtime</span> <span className="text-xs text-fg-muted uppercase tracking-wider">Runtime</span>
</div> </div>
<span className="text-[10px] font-mono text-gray-500"> <span className="text-[10px] font-mono text-fg-muted">
{info.server.cpus} cores · {info.server.arch} {info.server.cpus} cores · {info.server.arch}
</span> </span>
</div> </div>
<div className="space-y-2.5"> <div className="space-y-2.5">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="text-xs text-gray-400 w-28 flex-shrink-0">Uptime</span> <span className="text-xs text-fg-secondary w-28 flex-shrink-0">Uptime</span>
<span className="text-xs text-gray-200 font-mono ml-auto"> <span className="text-xs text-fg-secondary font-mono ml-auto">
{formatUptime(info.server.uptime)} {formatUptime(info.server.uptime)}
</span> </span>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="text-xs text-gray-400 w-28 flex-shrink-0">CPU (1/5/15m)</span> <span className="text-xs text-fg-secondary w-28 flex-shrink-0">CPU (1/5/15m)</span>
<div className="flex gap-1 ml-auto"> <div className="flex gap-1 ml-auto">
{(info.server.cpu_load || []).slice(0, 3).map((load, i) => ( {(info.server.cpu_load || []).slice(0, 3).map((load, i) => (
<span <span
key={i} key={i}
className={`text-[10px] font-mono px-1.5 py-0.5 rounded ${i === 0 && load > info.server.cpus ? "bg-red-500/20 text-red-400" : "bg-surface-3 text-gray-300"}`} className={`text-[10px] font-mono px-1.5 py-0.5 rounded ${i === 0 && load > info.server.cpus ? "bg-status-danger/20 text-status-danger" : "bg-surface-3 text-fg-secondary"}`}
> >
{load.toFixed(2)} {load.toFixed(2)}
</span> </span>
@@ -307,8 +307,8 @@ function SystemHealthTab() {
</div> </div>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="text-xs text-gray-400 w-28 flex-shrink-0">Node RSS</span> <span className="text-xs text-fg-secondary w-28 flex-shrink-0">Node RSS</span>
<span className="text-xs text-gray-200 font-mono ml-auto"> <span className="text-xs text-fg-secondary font-mono ml-auto">
{formatBytes(info.server.memory.rss)} {formatBytes(info.server.memory.rss)}
</span> </span>
</div> </div>
@@ -321,12 +321,12 @@ function SystemHealthTab() {
> >
<div className="space-y-1"> <div className="space-y-1">
<div className="flex justify-between text-[10px]"> <div className="flex justify-between text-[10px]">
<span className="text-gray-500">Host Memory</span> <span className="text-fg-muted">Host Memory</span>
<span className="text-gray-400 font-mono">{memUsedPct.toFixed(0)}%</span> <span className="text-fg-secondary font-mono">{memUsedPct.toFixed(0)}%</span>
</div> </div>
<div className="w-full bg-surface-3 rounded-full h-2"> <div className="w-full bg-surface-3 rounded-full h-2">
<div <div
className={`h-2 rounded-full transition-all duration-700 ${memUsedPct > 90 ? "bg-red-500" : memUsedPct > 70 ? "bg-amber-500" : "bg-emerald-500"}`} className={`h-2 rounded-full transition-all duration-700 ${memUsedPct > 90 ? "bg-status-danger" : memUsedPct > 70 ? "bg-status-warning" : "bg-status-success"}`}
style={{ width: `${memUsedPct}%` }} style={{ width: `${memUsedPct}%` }}
/> />
</div> </div>
@@ -338,12 +338,12 @@ function SystemHealthTab() {
> >
<div className="space-y-1"> <div className="space-y-1">
<div className="flex justify-between text-[10px]"> <div className="flex justify-between text-[10px]">
<span className="text-gray-500">V8 Heap</span> <span className="text-fg-muted">V8 Heap</span>
<span className="text-gray-400 font-mono">{heapUsedPct.toFixed(0)}%</span> <span className="text-fg-secondary font-mono">{heapUsedPct.toFixed(0)}%</span>
</div> </div>
<div className="w-full bg-surface-3 rounded-full h-2"> <div className="w-full bg-surface-3 rounded-full h-2">
<div <div
className={`h-2 rounded-full transition-all duration-700 ${heapUsedPct > 85 ? "bg-red-500" : heapUsedPct > 60 ? "bg-amber-500" : "bg-blue-500"}`} className={`h-2 rounded-full transition-all duration-700 ${heapUsedPct > 85 ? "bg-status-danger" : heapUsedPct > 60 ? "bg-status-warning" : "bg-blue-600"}`}
style={{ width: `${heapUsedPct}%` }} style={{ width: `${heapUsedPct}%` }}
/> />
</div> </div>
@@ -356,13 +356,13 @@ function SystemHealthTab() {
<div className="card p-5 flex flex-col gap-4"> <div className="card p-5 flex flex-col gap-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Database className="w-4 h-4 text-blue-400" /> <Database className="w-4 h-4 text-blue-500" />
<span className="text-xs text-gray-500 uppercase tracking-wider">Storage</span> <span className="text-xs text-fg-muted uppercase tracking-wider">Storage</span>
</div> </div>
<Tip <Tip
raw={`Write velocity (events):\n5 min: ${info.db.load_stats?.m5 ?? 0}\n15 min: ${info.db.load_stats?.m15 ?? 0}\n1 hr: ${info.db.load_stats?.h1 ?? 0}`} raw={`Write velocity (events):\n5 min: ${info.db.load_stats?.m5 ?? 0}\n15 min: ${info.db.load_stats?.m15 ?? 0}\n1 hr: ${info.db.load_stats?.h1 ?? 0}`}
> >
<span className="text-[10px] font-mono text-emerald-400 bg-emerald-500/5 px-2 py-0.5 rounded border border-emerald-500/10 cursor-default"> <span className="text-[10px] font-mono text-status-success bg-status-success/5 px-2 py-0.5 rounded border border-status-success/10 cursor-default">
{info.db.load_stats?.m5 ?? 0}/{info.db.load_stats?.m15 ?? 0}/ {info.db.load_stats?.m5 ?? 0}/{info.db.load_stats?.m15 ?? 0}/
{info.db.load_stats?.h1 ?? 0} {info.db.load_stats?.h1 ?? 0}
</span> </span>
@@ -370,8 +370,8 @@ function SystemHealthTab() {
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="text-xs text-gray-400 w-28 flex-shrink-0">Database</span> <span className="text-xs text-fg-secondary w-28 flex-shrink-0">Database</span>
<span className="text-xs text-gray-200 font-mono ml-auto"> <span className="text-xs text-fg-secondary font-mono ml-auto">
{formatBytes(info.db.size)} · {info.db.pragmas?.journal_mode?.toUpperCase() || "WAL"} {formatBytes(info.db.size)} · {info.db.pragmas?.journal_mode?.toUpperCase() || "WAL"}
</span> </span>
</div> </div>
@@ -425,7 +425,7 @@ function SystemHealthTab() {
y="46" y="46"
textAnchor="middle" textAnchor="middle"
dominantBaseline="middle" dominantBaseline="middle"
className="fill-gray-300" className="fill-fg-secondary"
fontSize="12" fontSize="12"
fontWeight="700" fontWeight="700"
fontFamily="monospace" fontFamily="monospace"
@@ -437,7 +437,7 @@ function SystemHealthTab() {
y="60" y="60"
textAnchor="middle" textAnchor="middle"
dominantBaseline="middle" dominantBaseline="middle"
className="fill-gray-600" className="fill-fg-muted"
fontSize="8" fontSize="8"
> >
total total
@@ -475,8 +475,8 @@ function SystemHealthTab() {
className="w-2.5 h-2.5 rounded-sm flex-shrink-0" className="w-2.5 h-2.5 rounded-sm flex-shrink-0"
style={{ backgroundColor: item.color }} style={{ backgroundColor: item.color }}
/> />
<span className="text-gray-400">{item.label}</span> <span className="text-fg-secondary">{item.label}</span>
<span className="text-gray-500 ml-auto pl-3 font-mono"> <span className="text-fg-muted ml-auto pl-3 font-mono">
{Math.round(item.pct)}% {Math.round(item.pct)}%
</span> </span>
</div> </div>
@@ -490,13 +490,13 @@ function SystemHealthTab() {
<div className="card p-5 flex flex-col gap-4"> <div className="card p-5 flex flex-col gap-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<ShieldCheck className="w-4 h-4 text-emerald-400" /> <ShieldCheck className="w-4 h-4 text-status-success" />
<span className="text-xs text-gray-500 uppercase tracking-wider">Health Score</span> <span className="text-xs text-fg-muted uppercase tracking-wider">Health Score</span>
</div> </div>
<Tip <Tip
raw={`Composite Index formula:\n0.4 × Success Rate (${successRate.toFixed(1)}%)\n+ 0.25 × Cache Hit (${cacheHitRate.toFixed(1)}%)\n+ 0.25 × (100 Error Rate) (${(100 - errorRate).toFixed(1)}%)\n+ 0.10 × (100 Heap%) (${(100 - heapUsedPct).toFixed(1)}%)\n= ${healthScore.toFixed(1)}`} raw={`Composite Index formula:\n0.4 × Success Rate (${successRate.toFixed(1)}%)\n+ 0.25 × Cache Hit (${cacheHitRate.toFixed(1)}%)\n+ 0.25 × (100 Error Rate) (${(100 - errorRate).toFixed(1)}%)\n+ 0.10 × (100 Heap%) (${(100 - heapUsedPct).toFixed(1)}%)\n= ${healthScore.toFixed(1)}`}
> >
<span className="text-[10px] text-gray-500 cursor-help border-b border-dashed border-gray-700"> <span className="text-[10px] text-fg-muted cursor-help border-b border-dashed border-border-light">
Formula Formula
</span> </span>
</Tip> </Tip>
@@ -526,7 +526,7 @@ function SystemHealthTab() {
y="57" y="57"
textAnchor="middle" textAnchor="middle"
dominantBaseline="middle" dominantBaseline="middle"
className="fill-gray-100" className="fill-fg-primary"
fontSize="24" fontSize="24"
fontWeight="800" fontWeight="800"
fontFamily="monospace" fontFamily="monospace"
@@ -538,7 +538,7 @@ function SystemHealthTab() {
y="76" y="76"
textAnchor="middle" textAnchor="middle"
dominantBaseline="middle" dominantBaseline="middle"
className="fill-gray-600" className="fill-fg-muted"
fontSize="9" fontSize="9"
fontWeight="500" fontWeight="500"
> >
@@ -555,8 +555,8 @@ function SystemHealthTab() {
raw={`Cache Hit Rate: ${cacheHitRate.toFixed(1)}%\nHits: ${info.transcript_cache?.hits ?? 0}\nMisses: ${info.transcript_cache?.misses ?? 0}`} raw={`Cache Hit Rate: ${cacheHitRate.toFixed(1)}%\nHits: ${info.transcript_cache?.hits ?? 0}\nMisses: ${info.transcript_cache?.misses ?? 0}`}
> >
<div className="text-center cursor-default"> <div className="text-center cursor-default">
<p className="text-[9px] text-gray-600 uppercase">Cache</p> <p className="text-[9px] text-fg-muted uppercase">Cache</p>
<p className="text-xs font-mono font-bold text-blue-400"> <p className="text-xs font-mono font-bold text-blue-500">
{cacheHitRate.toFixed(0)}% {cacheHitRate.toFixed(0)}%
</p> </p>
</div> </div>
@@ -566,9 +566,9 @@ function SystemHealthTab() {
raw={`Error Rate: ${errorRate.toFixed(2)}%\n<5% = healthy, 5-15% = warning, >15% = critical`} raw={`Error Rate: ${errorRate.toFixed(2)}%\n<5% = healthy, 5-15% = warning, >15% = critical`}
> >
<div className="text-center cursor-default"> <div className="text-center cursor-default">
<p className="text-[9px] text-gray-600 uppercase">Errors</p> <p className="text-[9px] text-fg-muted uppercase">Errors</p>
<p <p
className={`text-xs font-mono font-bold ${errorRate < 5 ? "text-emerald-400" : errorRate < 15 ? "text-amber-400" : "text-red-400"}`} className={`text-xs font-mono font-bold ${errorRate < 5 ? "text-status-success" : errorRate < 15 ? "text-status-warning" : "text-status-danger"}`}
> >
{errorRate.toFixed(1)}% {errorRate.toFixed(1)}%
</p> </p>
@@ -579,7 +579,7 @@ function SystemHealthTab() {
raw={`Transcript compactions: ${workflow.compaction?.totalCompactions ?? 0}\nReduces context window by summarizing turns.`} raw={`Transcript compactions: ${workflow.compaction?.totalCompactions ?? 0}\nReduces context window by summarizing turns.`}
> >
<div className="text-center cursor-default"> <div className="text-center cursor-default">
<p className="text-[9px] text-gray-600 uppercase">Compact</p> <p className="text-[9px] text-fg-muted uppercase">Compact</p>
<p className="text-xs font-mono font-bold text-violet-400"> <p className="text-xs font-mono font-bold text-violet-400">
{workflow.compaction?.totalCompactions ?? 0} {workflow.compaction?.totalCompactions ?? 0}
</p> </p>
@@ -590,8 +590,8 @@ function SystemHealthTab() {
raw={`Tokens recovered: ${(workflow.compaction?.tokensRecovered ?? 0).toLocaleString()}\nFreed by compaction.`} raw={`Tokens recovered: ${(workflow.compaction?.tokensRecovered ?? 0).toLocaleString()}\nFreed by compaction.`}
> >
<div className="text-center cursor-default"> <div className="text-center cursor-default">
<p className="text-[9px] text-gray-600 uppercase">Saved</p> <p className="text-[9px] text-fg-muted uppercase">Saved</p>
<p className="text-xs font-mono font-bold text-emerald-400"> <p className="text-xs font-mono font-bold text-status-success">
{((workflow.compaction?.tokensRecovered ?? 0) / 1000).toFixed(1)}K {((workflow.compaction?.tokensRecovered ?? 0) / 1000).toFixed(1)}K
</p> </p>
</div> </div>
@@ -606,10 +606,10 @@ function SystemHealthTab() {
<div className="card p-5 flex flex-col gap-4"> <div className="card p-5 flex flex-col gap-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Bot className="w-4 h-4 text-blue-400" /> <Bot className="w-4 h-4 text-blue-500" />
<span className="text-xs text-gray-500 uppercase tracking-wider">Token Usage</span> <span className="text-xs text-fg-muted uppercase tracking-wider">Token Usage</span>
</div> </div>
<span className="text-[10px] font-mono text-gray-500"> <span className="text-[10px] font-mono text-fg-muted">
{(totalTokens / 1000).toFixed(1)}K total {(totalTokens / 1000).toFixed(1)}K total
</span> </span>
</div> </div>
@@ -619,10 +619,10 @@ function SystemHealthTab() {
const pct = const pct =
totalTokens > 0 ? ((m.input_tokens + m.output_tokens) / totalTokens) * 100 : 0; totalTokens > 0 ? ((m.input_tokens + m.output_tokens) / totalTokens) * 100 : 0;
const colors = [ const colors = [
"bg-blue-400", "bg-blue-500",
"bg-violet-400", "bg-violet-400",
"bg-emerald-400", "bg-status-success",
"bg-amber-400", "bg-status-warning",
"bg-pink-400", "bg-pink-400",
"bg-cyan-400", "bg-cyan-400",
]; ];
@@ -634,7 +634,7 @@ function SystemHealthTab() {
> >
<div className="flex items-center gap-3 cursor-default"> <div className="flex items-center gap-3 cursor-default">
<span <span
className="text-xs text-gray-400 w-28 truncate flex-shrink-0" className="text-xs text-fg-secondary w-28 truncate flex-shrink-0"
title={formatModelName(m.model) ?? m.model} title={formatModelName(m.model) ?? m.model}
> >
{formatModelName(m.model) ?? m.model} {formatModelName(m.model) ?? m.model}
@@ -645,7 +645,7 @@ function SystemHealthTab() {
style={{ width: `${pct}%` }} style={{ width: `${pct}%` }}
/> />
</div> </div>
<span className="text-xs text-gray-500 w-12 text-right flex-shrink-0 font-mono"> <span className="text-xs text-fg-muted w-12 text-right flex-shrink-0 font-mono">
{pct.toFixed(1)}% {pct.toFixed(1)}%
</span> </span>
</div> </div>
@@ -653,7 +653,7 @@ function SystemHealthTab() {
); );
})} })}
{modelStats.length === 0 && ( {modelStats.length === 0 && (
<p className="text-xs text-gray-600 text-center py-4">No model data</p> <p className="text-xs text-fg-muted text-center py-4">No model data</p>
)} )}
</div> </div>
</div> </div>
@@ -663,9 +663,9 @@ function SystemHealthTab() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<BarChart3 className="w-4 h-4 text-violet-400" /> <BarChart3 className="w-4 h-4 text-violet-400" />
<span className="text-xs text-gray-500 uppercase tracking-wider">Concurrency</span> <span className="text-xs text-fg-muted uppercase tracking-wider">Concurrency</span>
</div> </div>
<span className="text-[10px] font-mono text-gray-500">{lanes.length} intervals</span> <span className="text-[10px] font-mono text-fg-muted">{lanes.length} intervals</span>
</div> </div>
{/* Sparkline-style bar chart matching Analytics sparkline */} {/* Sparkline-style bar chart matching Analytics sparkline */}
@@ -698,7 +698,7 @@ function SystemHealthTab() {
); );
})} })}
{lanes.length === 0 && ( {lanes.length === 0 && (
<p className="text-xs text-gray-600 text-center w-full self-center"> <p className="text-xs text-fg-muted text-center w-full self-center">
No concurrency data No concurrency data
</p> </p>
)} )}
@@ -708,8 +708,8 @@ function SystemHealthTab() {
<div className="grid grid-cols-3 gap-3 border-t border-border/40 pt-3"> <div className="grid grid-cols-3 gap-3 border-t border-border/40 pt-3">
<Tip block raw={`Peak: ${maxLaneCount} sessions running simultaneously.`}> <Tip block raw={`Peak: ${maxLaneCount} sessions running simultaneously.`}>
<div className="text-center cursor-default"> <div className="text-center cursor-default">
<p className="text-[9px] text-gray-600 uppercase">Peak</p> <p className="text-[9px] text-fg-muted uppercase">Peak</p>
<p className="text-sm font-mono font-bold text-gray-200">{maxLaneCount}</p> <p className="text-sm font-mono font-bold text-fg-secondary">{maxLaneCount}</p>
</div> </div>
</Tip> </Tip>
<Tip <Tip
@@ -717,16 +717,16 @@ function SystemHealthTab() {
raw={`${lanes.filter((l) => l.count > 0).length} of ${lanes.length} intervals have active sessions.`} raw={`${lanes.filter((l) => l.count > 0).length} of ${lanes.length} intervals have active sessions.`}
> >
<div className="text-center cursor-default"> <div className="text-center cursor-default">
<p className="text-[9px] text-gray-600 uppercase">Active</p> <p className="text-[9px] text-fg-muted uppercase">Active</p>
<p className="text-sm font-mono font-bold text-emerald-400"> <p className="text-sm font-mono font-bold text-status-success">
{lanes.filter((l) => l.count > 0).length} {lanes.filter((l) => l.count > 0).length}
</p> </p>
</div> </div>
</Tip> </Tip>
<Tip block raw={`Average concurrency across all intervals.`}> <Tip block raw={`Average concurrency across all intervals.`}>
<div className="text-center cursor-default"> <div className="text-center cursor-default">
<p className="text-[9px] text-gray-600 uppercase">Avg</p> <p className="text-[9px] text-fg-muted uppercase">Avg</p>
<p className="text-sm font-mono font-bold text-blue-400"> <p className="text-sm font-mono font-bold text-blue-500">
{lanes.length > 0 {lanes.length > 0
? (lanes.reduce((s, l) => s + l.count, 0) / lanes.length).toFixed(1) ? (lanes.reduce((s, l) => s + l.count, 0) / lanes.length).toFixed(1)
: "0"} : "0"}
@@ -743,23 +743,23 @@ function SystemHealthTab() {
<div className="card p-5 flex flex-col gap-4"> <div className="card p-5 flex flex-col gap-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Zap className="w-4 h-4 text-amber-400" /> <Zap className="w-4 h-4 text-status-warning" />
<span className="text-xs text-gray-500 uppercase tracking-wider">Tool Usage</span> <span className="text-xs text-fg-muted uppercase tracking-wider">Tool Usage</span>
</div> </div>
<span className="text-[10px] font-mono text-gray-500">top {topTools.length}</span> <span className="text-[10px] font-mono text-fg-muted">top {topTools.length}</span>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
{topTools.map((tool, i) => { {topTools.map((tool, i) => {
const pct = maxToolCount > 0 ? Math.round((tool.count / maxToolCount) * 100) : 0; const pct = maxToolCount > 0 ? Math.round((tool.count / maxToolCount) * 100) : 0;
const colors = [ const colors = [
"bg-amber-400", "bg-status-warning",
"bg-blue-400", "bg-blue-500",
"bg-emerald-400", "bg-status-success",
"bg-violet-400", "bg-violet-400",
"bg-pink-400", "bg-pink-400",
"bg-cyan-400", "bg-cyan-400",
"bg-red-400", "bg-status-danger",
"bg-indigo-400", "bg-indigo-400",
]; ];
return ( return (
@@ -770,7 +770,7 @@ function SystemHealthTab() {
> >
<div className="flex items-center gap-3 cursor-default"> <div className="flex items-center gap-3 cursor-default">
<span <span
className="text-xs text-gray-400 w-28 truncate flex-shrink-0" className="text-xs text-fg-secondary w-28 truncate flex-shrink-0"
title={tool.tool_name} title={tool.tool_name}
> >
{tool.tool_name} {tool.tool_name}
@@ -781,7 +781,7 @@ function SystemHealthTab() {
style={{ width: `${pct}%` }} style={{ width: `${pct}%` }}
/> />
</div> </div>
<span className="text-xs text-gray-500 w-10 text-right flex-shrink-0 font-mono"> <span className="text-xs text-fg-muted w-10 text-right flex-shrink-0 font-mono">
{tool.count > 999 ? `${(tool.count / 1000).toFixed(1)}K` : tool.count} {tool.count > 999 ? `${(tool.count / 1000).toFixed(1)}K` : tool.count}
</span> </span>
</div> </div>
@@ -789,7 +789,7 @@ function SystemHealthTab() {
); );
})} })}
{topTools.length === 0 && ( {topTools.length === 0 && (
<p className="text-xs text-gray-600 text-center py-6">No tool data yet</p> <p className="text-xs text-fg-muted text-center py-6">No tool data yet</p>
)} )}
</div> </div>
</div> </div>
@@ -799,7 +799,7 @@ function SystemHealthTab() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<GitBranch className="w-4 h-4 text-violet-400" /> <GitBranch className="w-4 h-4 text-violet-400" />
<span className="text-xs text-gray-500 uppercase tracking-wider"> <span className="text-xs text-fg-muted uppercase tracking-wider">
Subagent Effectiveness Subagent Effectiveness
</span> </span>
</div> </div>
@@ -809,10 +809,10 @@ function SystemHealthTab() {
{effectiveness.map((item, i) => { {effectiveness.map((item, i) => {
const color = const color =
item.successRate >= 90 item.successRate >= 90
? "bg-emerald-400" ? "bg-status-success"
: item.successRate >= 70 : item.successRate >= 70
? "bg-amber-400" ? "bg-status-warning"
: "bg-red-400"; : "bg-status-danger";
return ( return (
<Tip <Tip
block block
@@ -820,7 +820,7 @@ function SystemHealthTab() {
raw={`${item.subagent_type || "default"}\nSuccess: ${item.successRate.toFixed(1)}%\nTotal: ${item.total} · OK: ${item.completed} · Errors: ${item.errors}`} raw={`${item.subagent_type || "default"}\nSuccess: ${item.successRate.toFixed(1)}%\nTotal: ${item.total} · OK: ${item.completed} · Errors: ${item.errors}`}
> >
<div className="flex items-center gap-3 cursor-default"> <div className="flex items-center gap-3 cursor-default">
<span className="text-xs text-gray-400 w-28 truncate flex-shrink-0"> <span className="text-xs text-fg-secondary w-28 truncate flex-shrink-0">
{item.subagent_type || "default"} {item.subagent_type || "default"}
</span> </span>
<div className="flex-1 bg-surface-3 rounded-full h-2"> <div className="flex-1 bg-surface-3 rounded-full h-2">
@@ -830,7 +830,7 @@ function SystemHealthTab() {
/> />
</div> </div>
<span <span
className={`text-xs w-12 text-right flex-shrink-0 font-mono ${item.successRate >= 90 ? "text-emerald-400" : item.successRate >= 70 ? "text-amber-400" : "text-red-400"}`} className={`text-xs w-12 text-right flex-shrink-0 font-mono ${item.successRate >= 90 ? "text-status-success" : item.successRate >= 70 ? "text-status-warning" : "text-status-danger"}`}
> >
{item.successRate.toFixed(0)}% {item.successRate.toFixed(0)}%
</span> </span>
@@ -839,7 +839,7 @@ function SystemHealthTab() {
); );
})} })}
{effectiveness.length === 0 && ( {effectiveness.length === 0 && (
<p className="text-xs text-gray-600 text-center py-6">No subagent data yet</p> <p className="text-xs text-fg-muted text-center py-6">No subagent data yet</p>
)} )}
</div> </div>
</div> </div>
@@ -851,11 +851,11 @@ function SystemHealthTab() {
<div className="card p-5 flex flex-col gap-4"> <div className="card p-5 flex flex-col gap-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Plug className="w-4 h-4 text-amber-400" /> <Plug className="w-4 h-4 text-status-warning" />
<span className="text-xs text-gray-500 uppercase tracking-wider">Integration</span> <span className="text-xs text-fg-muted uppercase tracking-wider">Integration</span>
</div> </div>
<span <span
className={`text-[10px] font-mono px-2 py-0.5 rounded ${info.hooks.installed ? "bg-emerald-500/10 text-emerald-400 border border-emerald-500/20" : "bg-surface-3 text-gray-500 border border-border"}`} className={`text-[10px] font-mono px-2 py-0.5 rounded ${info.hooks.installed ? "bg-status-success/10 text-status-success border border-status-success/20" : "bg-surface-3 text-fg-muted border border-border"}`}
> >
{info.hooks.installed ? "Active" : "Offline"} {info.hooks.installed ? "Active" : "Offline"}
</span> </span>
@@ -871,9 +871,9 @@ function SystemHealthTab() {
> >
<div className="flex items-center gap-3 bg-surface-2/50 px-3 py-2 rounded-lg border border-border/30 cursor-default"> <div className="flex items-center gap-3 bg-surface-2/50 px-3 py-2 rounded-lg border border-border/30 cursor-default">
<div <div
className={`w-2 h-2 rounded-full flex-shrink-0 ${active ? "bg-emerald-400" : "bg-gray-600"}`} className={`w-2 h-2 rounded-full flex-shrink-0 ${active ? "bg-status-success" : "bg-surface-4"}`}
/> />
<span className="text-xs text-gray-300 truncate font-mono"> <span className="text-xs text-fg-secondary truncate font-mono">
{cwd.split("/").pop() || cwd} {cwd.split("/").pop() || cwd}
</span> </span>
</div> </div>
@@ -882,8 +882,8 @@ function SystemHealthTab() {
</div> </div>
) : ( ) : (
<div className="flex flex-col items-center justify-center py-6 border border-dashed border-border/40 rounded-lg"> <div className="flex flex-col items-center justify-center py-6 border border-dashed border-border/40 rounded-lg">
<Search className="w-4 h-4 text-gray-600 mb-2" /> <Search className="w-4 h-4 text-fg-muted mb-2" />
<p className="text-xs text-gray-500">No project hooks registered</p> <p className="text-xs text-fg-muted">No project hooks registered</p>
</div> </div>
)} )}
@@ -891,11 +891,11 @@ function SystemHealthTab() {
block block
raw={`WebSocket connections: ${info.server.ws_connections}\nProtocol: RFC 6455`} raw={`WebSocket connections: ${info.server.ws_connections}\nProtocol: RFC 6455`}
> >
<div className="flex items-center gap-3 bg-emerald-500/5 px-3 py-2.5 rounded-lg border border-emerald-500/10 cursor-default"> <div className="flex items-center gap-3 bg-status-success/5 px-3 py-2.5 rounded-lg border border-status-success/10 cursor-default">
<Activity className="w-3.5 h-3.5 text-emerald-400 animate-pulse" /> <Activity className="w-3.5 h-3.5 text-status-success animate-pulse" />
<div> <div>
<p className="text-[10px] text-emerald-400 font-medium">WebSocket Active</p> <p className="text-[10px] text-status-success font-medium">WebSocket Active</p>
<p className="text-[10px] text-gray-500"> <p className="text-[10px] text-fg-muted">
{info.server.ws_connections} connection {info.server.ws_connections} connection
{info.server.ws_connections !== 1 ? "s" : ""} {info.server.ws_connections !== 1 ? "s" : ""}
</p> </p>
@@ -909,9 +909,9 @@ function SystemHealthTab() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Cpu className="w-4 h-4 text-cyan-400" /> <Cpu className="w-4 h-4 text-cyan-400" />
<span className="text-xs text-gray-500 uppercase tracking-wider">Platform</span> <span className="text-xs text-fg-muted uppercase tracking-wider">Platform</span>
</div> </div>
<span className="text-[10px] font-mono text-gray-500">{info.server.node_version}</span> <span className="text-[10px] font-mono text-fg-muted">{info.server.node_version}</span>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@@ -935,16 +935,18 @@ function SystemHealthTab() {
{ label: "Platform", value: `${info.server.platform} / ${info.server.arch}` }, { label: "Platform", value: `${info.server.platform} / ${info.server.arch}` },
].map((row) => ( ].map((row) => (
<div key={row.label} className="flex items-center gap-3"> <div key={row.label} className="flex items-center gap-3">
<span className="text-xs text-gray-400 w-28 flex-shrink-0">{row.label}</span> <span className="text-xs text-fg-secondary w-28 flex-shrink-0">{row.label}</span>
<span className="text-xs text-gray-200 font-mono ml-auto">{row.value}</span> <span className="text-xs text-fg-secondary font-mono ml-auto">{row.value}</span>
</div> </div>
))} ))}
</div> </div>
<Tip block raw={info.db.path}> <Tip block raw={info.db.path}>
<div className="flex items-center gap-2 bg-surface-2/50 px-3 py-2 rounded-lg border border-border/30 cursor-default"> <div className="flex items-center gap-2 bg-surface-2/50 px-3 py-2 rounded-lg border border-border/30 cursor-default">
<HardDrive className="w-3 h-3 text-gray-500 flex-shrink-0" /> <HardDrive className="w-3 h-3 text-fg-muted flex-shrink-0" />
<span className="text-[10px] text-gray-400 font-mono truncate">{info.db.path}</span> <span className="text-[10px] text-fg-secondary font-mono truncate">
{info.db.path}
</span>
</div> </div>
</Tip> </Tip>
</div> </div>
@@ -1145,8 +1147,8 @@ export function Dashboard() {
if (error) { if (error) {
return ( return (
<div className="text-center py-20"> <div className="text-center py-20">
<p className="text-red-400 mb-2">{t("failedConnect")}</p> <p className="text-status-danger mb-2">{t("failedConnect")}</p>
<p className="text-sm text-gray-500">{error}</p> <p className="text-sm text-fg-muted">{error}</p>
<button onClick={load} className="btn-primary mt-4"> <button onClick={load} className="btn-primary mt-4">
{t("common:retry")} {t("common:retry")}
</button> </button>
@@ -1163,20 +1165,20 @@ export function Dashboard() {
</div> </div>
<div> <div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h1 className="text-lg font-semibold text-gray-100">{t("title")}</h1> <h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1>
{wsConnected ? ( {wsConnected ? (
<span className="flex items-center gap-1.5 text-[11px] text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 px-2 py-0.5 rounded-full"> <span className="flex items-center gap-1.5 text-[11px] text-status-success bg-status-success/10 border border-status-success/20 px-2 py-0.5 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse-dot" /> <span className="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse-dot" />
{t("common:live")} {t("common:live")}
</span> </span>
) : ( ) : (
<span className="flex items-center gap-1.5 text-[11px] text-gray-400 bg-gray-500/10 border border-gray-500/20 px-2 py-0.5 rounded-full"> <span className="flex items-center gap-1.5 text-[11px] text-fg-secondary bg-surface-4/10 border border-border-light/20 px-2 py-0.5 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-gray-400" /> <span className="w-1.5 h-1.5 rounded-full bg-surface-4" />
{t("common:offline")} {t("common:offline")}
</span> </span>
)} )}
</div> </div>
<p className="text-xs text-gray-500">{t("subtitle")}</p> <p className="text-xs text-fg-muted">{t("subtitle")}</p>
</div> </div>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -1187,7 +1189,7 @@ export function Dashboard() {
className={`px-2.5 py-1.5 rounded-md text-xs font-medium transition-all flex items-center gap-2 ${ className={`px-2.5 py-1.5 rounded-md text-xs font-medium transition-all flex items-center gap-2 ${
activeTab === "monitor" activeTab === "monitor"
? "bg-accent/15 text-accent shadow-sm" ? "bg-accent/15 text-accent shadow-sm"
: "text-gray-500 hover:text-gray-300" : "text-fg-muted hover:text-fg-secondary"
}`} }`}
> >
<Activity className="w-3.5 h-3.5" /> Monitor <Activity className="w-3.5 h-3.5" /> Monitor
@@ -1197,7 +1199,7 @@ export function Dashboard() {
className={`px-2.5 py-1.5 rounded-md text-xs font-medium transition-all flex items-center gap-2 ${ className={`px-2.5 py-1.5 rounded-md text-xs font-medium transition-all flex items-center gap-2 ${
activeTab === "health" activeTab === "health"
? "bg-accent/15 text-accent shadow-sm" ? "bg-accent/15 text-accent shadow-sm"
: "text-gray-500 hover:text-gray-300" : "text-fg-muted hover:text-fg-secondary"
}`} }`}
> >
<Server className="w-3.5 h-3.5" /> Health <Server className="w-3.5 h-3.5" /> Health
@@ -1225,7 +1227,7 @@ export function Dashboard() {
label={t("activeAgents")} label={t("activeAgents")}
value={stats?.active_agents ?? ""} value={stats?.active_agents ?? ""}
icon={Bot} icon={Bot}
accentColor="text-emerald-400" accentColor="text-status-success"
loading={!stats} loading={!stats}
/> />
<StatCard <StatCard
@@ -1261,7 +1263,7 @@ export function Dashboard() {
: undefined : undefined
} }
icon={DollarSign} icon={DollarSign}
accentColor="text-emerald-400" accentColor="text-status-success"
loading={totalCost === null} loading={totalCost === null}
/> />
</div> </div>
@@ -1270,7 +1272,9 @@ export function Dashboard() {
{/* Active agents */} {/* Active agents */}
<div ref={agentsContainerRef} className="min-w-0 overflow-y-auto pr-6"> <div ref={agentsContainerRef} className="min-w-0 overflow-y-auto pr-6">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium text-gray-300">{t("activeAgentsSection")}</h3> <h3 className="text-sm font-medium text-fg-secondary">
{t("activeAgentsSection")}
</h3>
<button onClick={() => navigate("/kanban")} className="btn-ghost text-xs"> <button onClick={() => navigate("/kanban")} className="btn-ghost text-xs">
{t("viewBoard")} <ArrowRight className="w-3 h-3" /> {t("viewBoard")} <ArrowRight className="w-3 h-3" />
</button> </button>
@@ -1312,7 +1316,7 @@ export function Dashboard() {
{hasChildren && ( {hasChildren && (
<button <button
onClick={toggleExpanded} onClick={toggleExpanded}
className="p-1 text-gray-500 hover:text-gray-300 transition-colors flex-shrink-0" className="p-1 text-fg-muted hover:text-fg-secondary transition-colors flex-shrink-0"
aria-label={isExpanded ? "Collapse subagents" : "Expand subagents"} aria-label={isExpanded ? "Collapse subagents" : "Expand subagents"}
aria-expanded={isExpanded} aria-expanded={isExpanded}
> >
@@ -1375,7 +1379,7 @@ export function Dashboard() {
> >
{t("common:subagent_label", { count: totalDesc })} {t("common:subagent_label", { count: totalDesc })}
{activeDesc > 0 && ( {activeDesc > 0 && (
<span className="text-emerald-400 ml-1"> <span className="text-status-success ml-1">
({activeDesc} {t("common:active")}) ({activeDesc} {t("common:active")})
</span> </span>
)} )}
@@ -1436,7 +1440,7 @@ export function Dashboard() {
{/* Recent activity */} {/* Recent activity */}
<div ref={activityContainerRef} className="min-w-0 overflow-y-auto pl-6"> <div ref={activityContainerRef} className="min-w-0 overflow-y-auto pl-6">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium text-gray-300">{t("recentActivity")}</h3> <h3 className="text-sm font-medium text-fg-secondary">{t("recentActivity")}</h3>
<button onClick={() => navigate("/activity")} className="btn-ghost text-xs"> <button onClick={() => navigate("/activity")} className="btn-ghost text-xs">
{t("viewAll")} <ArrowRight className="w-3 h-3" /> {t("viewAll")} <ArrowRight className="w-3 h-3" />
</button> </button>
@@ -1469,7 +1473,7 @@ export function Dashboard() {
: "waiting" : "waiting"
} }
/> />
<span className="text-sm text-gray-300 truncate flex-1"> <span className="text-sm text-fg-secondary truncate flex-1">
{event.summary || event.event_type} {event.summary || event.event_type}
</span> </span>
{(() => { {(() => {
@@ -1479,7 +1483,7 @@ export function Dashboard() {
const isAuto = /^Session [0-9a-f]{8}$/i.test(sname); const isAuto = /^Session [0-9a-f]{8}$/i.test(sname);
return ( return (
<span <span
className="text-[11px] text-gray-500 truncate max-w-[9rem] flex-shrink-0" className="text-[11px] text-fg-muted truncate max-w-[9rem] flex-shrink-0"
title={event.session_id} title={event.session_id}
> >
{sname && !isAuto ? ( {sname && !isAuto ? (
@@ -1491,11 +1495,11 @@ export function Dashboard() {
); );
})()} })()}
{event.tool_name && ( {event.tool_name && (
<span className="text-[11px] text-gray-500 font-mono"> <span className="text-[11px] text-fg-muted font-mono">
{event.tool_name} {event.tool_name}
</span> </span>
)} )}
<span className="text-[11px] text-gray-600 flex-shrink-0"> <span className="text-[11px] text-fg-muted flex-shrink-0">
{timeAgo(event.created_at)} {timeAgo(event.created_at)}
</span> </span>
</div> </div>
+12 -12
View File
@@ -256,20 +256,20 @@ export function KanbanBoard() {
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h1 className="text-lg font-semibold text-gray-100 truncate">{t("title")}</h1> <h1 className="text-lg font-semibold text-fg-primary truncate">{t("title")}</h1>
{wsConnected ? ( {wsConnected ? (
<span className="flex items-center gap-1.5 text-[11px] text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 px-2 py-0.5 rounded-full"> <span className="flex items-center gap-1.5 text-[11px] text-status-success bg-status-success/10 border border-status-success/20 px-2 py-0.5 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse-dot" /> <span className="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse-dot" />
{t("common:live")} {t("common:live")}
</span> </span>
) : ( ) : (
<span className="flex items-center gap-1.5 text-[11px] text-gray-400 bg-gray-500/10 border border-gray-500/20 px-2 py-0.5 rounded-full"> <span className="flex items-center gap-1.5 text-[11px] text-fg-secondary bg-surface-4/10 border border-border-light/20 px-2 py-0.5 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-gray-400" /> <span className="w-1.5 h-1.5 rounded-full bg-surface-4" />
{t("common:offline")} {t("common:offline")}
</span> </span>
)} )}
</div> </div>
<p className="text-xs text-gray-500 truncate">{subtitle}</p> <p className="text-xs text-fg-muted truncate">{subtitle}</p>
</div> </div>
</div> </div>
<div className="flex items-center gap-2 flex-shrink-0"> <div className="flex items-center gap-2 flex-shrink-0">
@@ -392,7 +392,7 @@ function ViewToggle({ view, onChange }: ViewToggleProps) {
const baseClass = const baseClass =
"px-3 py-1.5 text-xs font-medium transition-colors first:rounded-l-lg last:rounded-r-lg"; "px-3 py-1.5 text-xs font-medium transition-colors first:rounded-l-lg last:rounded-r-lg";
const activeClass = "bg-accent/15 text-accent"; const activeClass = "bg-accent/15 text-accent";
const inactiveClass = "text-gray-400 hover:text-gray-200 hover:bg-surface-3"; const inactiveClass = "text-fg-secondary hover:text-fg-primary hover:bg-surface-3";
return ( return (
<div <div
@@ -463,7 +463,7 @@ function Column({
{t(labelKey)} {t(labelKey)}
</span> </span>
{tooltip && <ColumnHelp text={tooltip} />} {tooltip && <ColumnHelp text={tooltip} />}
<span className="ml-auto text-[11px] text-gray-600 bg-surface-3 px-2 py-0.5 rounded-full"> <span className="ml-auto text-[11px] text-fg-muted bg-surface-3 px-2 py-0.5 rounded-full">
{count} {count}
</span> </span>
</div> </div>
@@ -475,7 +475,7 @@ function Column({
{remaining > 0 && ( {remaining > 0 && (
<button <button
onClick={onShowMore} onClick={onShowMore}
className="w-full py-2 text-[11px] text-gray-500 hover:text-gray-300 flex items-center justify-center gap-1 transition-colors" className="w-full py-2 text-[11px] text-fg-muted hover:text-fg-secondary flex items-center justify-center gap-1 transition-colors"
> >
<ChevronDown className="w-3 h-3" /> <ChevronDown className="w-3 h-3" />
{t("common:showMore", { count: remaining })} {t("common:showMore", { count: remaining })}
@@ -483,7 +483,7 @@ function Column({
)} )}
</> </>
) : ( ) : (
<div className="flex items-center justify-center h-24 text-xs text-gray-600"> <div className="flex items-center justify-center h-24 text-xs text-fg-muted">
{emptyLabel} {emptyLabel}
</div> </div>
)} )}
@@ -516,11 +516,11 @@ function ColumnHelp({ text }: { text: string }) {
onFocus={() => setShow(true)} onFocus={() => setShow(true)}
onBlur={() => setShow(false)} onBlur={() => setShow(false)}
> >
<HelpCircle className="w-3 h-3 text-gray-500 hover:text-gray-300 transition-colors" /> <HelpCircle className="w-3 h-3 text-fg-muted hover:text-fg-secondary transition-colors" />
{show && ( {show && (
<span <span
role="tooltip" role="tooltip"
className="absolute left-0 top-full mt-1.5 w-64 px-3 py-2 text-[11px] leading-relaxed text-gray-200 bg-surface-3 border border-border rounded-md shadow-xl z-50 pointer-events-none whitespace-pre-line" className="absolute left-0 top-full mt-1.5 w-64 px-3 py-2 text-[11px] leading-relaxed text-fg-secondary bg-surface-3 border border-border rounded-md shadow-xl z-50 pointer-events-none whitespace-pre-line"
> >
{text} {text}
</span> </span>
+3 -3
View File
@@ -73,11 +73,11 @@ export function NotFound() {
<AlertTriangle className="w-7 h-7 text-accent" /> <AlertTriangle className="w-7 h-7 text-accent" />
</div> </div>
<p className="text-xs uppercase tracking-[0.18em] text-gray-500 mb-2"> <p className="text-xs uppercase tracking-[0.18em] text-fg-muted mb-2">
{t("notFound.code")} {t("notFound.code")}
</p> </p>
<h2 className="text-2xl font-semibold text-gray-100 mb-2">{t("notFound.title")}</h2> <h2 className="text-2xl font-semibold text-fg-primary mb-2">{t("notFound.title")}</h2>
<p className="text-sm text-gray-400 mb-8">{t("notFound.description")}</p> <p className="text-sm text-fg-secondary mb-8">{t("notFound.description")}</p>
<div className="flex flex-col sm:flex-row gap-3 justify-center"> <div className="flex flex-col sm:flex-row gap-3 justify-center">
<button className="btn-primary" onClick={() => navigate("/")}> <button className="btn-primary" onClick={() => navigate("/")}>
+58 -58
View File
@@ -577,7 +577,7 @@ export function SessionDetail() {
if (error || !session) { if (error || !session) {
return ( return (
<div className="text-center py-20"> <div className="text-center py-20">
<p className="text-red-400 mb-2">{error || t("detail.notFound")}</p> <p className="text-status-danger mb-2">{error || t("detail.notFound")}</p>
<button onClick={goBack} className="btn-ghost mt-4"> <button onClick={goBack} className="btn-ghost mt-4">
<ArrowLeft className="w-4 h-4" /> {t("detail.backToSessions")} <ArrowLeft className="w-4 h-4" /> {t("detail.backToSessions")}
</button> </button>
@@ -594,7 +594,7 @@ export function SessionDetail() {
</button> </button>
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center gap-3 mb-2"> <div className="flex items-center gap-3 mb-2">
<h2 className="text-xl font-semibold text-gray-100"> <h2 className="text-xl font-semibold text-fg-primary">
{session.name || `${t("defaultName")}${session.id.slice(0, 8)}`} {session.name || `${t("defaultName")}${session.id.slice(0, 8)}`}
</h2> </h2>
<SessionStatusBadge <SessionStatusBadge
@@ -603,34 +603,34 @@ export function SessionDetail() {
/> />
</div> </div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 mt-1"> <div className="flex flex-wrap items-center gap-x-4 gap-y-1 mt-1">
<span className="inline-flex items-center gap-1 text-xs text-gray-500 font-mono bg-surface-2 px-2 py-1 rounded"> <span className="inline-flex items-center gap-1 text-xs text-fg-muted font-mono bg-surface-2 px-2 py-1 rounded">
<span title={session.id}>{session.id.slice(0, 16)}</span> <span title={session.id}>{session.id.slice(0, 16)}</span>
<CopyButton text={session.id} /> <CopyButton text={session.id} />
</span> </span>
{session.model && ( {session.model && (
<span className="inline-flex items-center gap-1.5 text-xs text-gray-400 bg-surface-2 px-2 py-1 rounded"> <span className="inline-flex items-center gap-1.5 text-xs text-fg-secondary bg-surface-2 px-2 py-1 rounded">
<Cpu className="w-3 h-3 text-gray-500" /> <Cpu className="w-3 h-3 text-fg-muted" />
{formatModelName(session.model)} {formatModelName(session.model)}
</span> </span>
)} )}
<span className="inline-flex items-center gap-1.5 text-xs text-gray-400 bg-surface-2 px-2 py-1 rounded"> <span className="inline-flex items-center gap-1.5 text-xs text-fg-secondary bg-surface-2 px-2 py-1 rounded">
<Clock className="w-3 h-3 text-gray-500" /> <Clock className="w-3 h-3 text-fg-muted" />
{formatDateTime(session.started_at)} {formatDateTime(session.started_at)}
{session.ended_at && ( {session.ended_at && (
<span className="text-gray-500 ml-1"> <span className="text-fg-muted ml-1">
({formatDuration(session.started_at, session.ended_at)}) ({formatDuration(session.started_at, session.ended_at)})
</span> </span>
)} )}
</span> </span>
{cost && cost.total_cost > 0 && ( {cost && cost.total_cost > 0 && (
<span className="inline-flex items-center gap-1.5 text-xs font-medium text-emerald-400 bg-emerald-500/10 px-2 py-1 rounded"> <span className="inline-flex items-center gap-1.5 text-xs font-medium text-status-success bg-status-success/10 px-2 py-1 rounded">
<DollarSign className="w-3 h-3" /> <DollarSign className="w-3 h-3" />
{fmtCostFull(cost.total_cost).slice(1)} {fmtCostFull(cost.total_cost).slice(1)}
</span> </span>
)} )}
</div> </div>
{session.cwd && ( {session.cwd && (
<div className="flex items-center gap-1.5 text-xs text-gray-500 mt-2 min-w-0"> <div className="flex items-center gap-1.5 text-xs text-fg-muted mt-2 min-w-0">
<FolderOpen className="w-3 h-3 flex-shrink-0" /> <FolderOpen className="w-3 h-3 flex-shrink-0" />
<span className="font-mono truncate" title={session.cwd}> <span className="font-mono truncate" title={session.cwd}>
{session.cwd} {session.cwd}
@@ -660,35 +660,35 @@ export function SessionDetail() {
<div <div
className={`flex items-center gap-3 rounded-lg border px-4 py-2.5 ${ className={`flex items-center gap-3 rounded-lg border px-4 py-2.5 ${
urgent urgent
? "border-amber-500/40 bg-amber-500/[0.08]" ? "border-status-warning/40 bg-status-warning/[0.08]"
: "border-yellow-500/25 bg-yellow-500/[0.05]" : "border-yellow-500/25 bg-yellow-500/[0.05]"
}`} }`}
> >
<span <span
className={`w-7 h-7 rounded-md inline-flex items-center justify-center flex-shrink-0 border ${ className={`w-7 h-7 rounded-md inline-flex items-center justify-center flex-shrink-0 border ${
urgent urgent
? "bg-amber-500/15 border-amber-500/30" ? "bg-status-warning/15 border-status-warning/30"
: "bg-yellow-500/10 border-yellow-500/25" : "bg-yellow-500/10 border-yellow-500/25"
}`} }`}
> >
<ReasonIcon <ReasonIcon
className={`w-3.5 h-3.5 ${urgent ? "text-amber-300" : "text-yellow-300"}`} className={`w-3.5 h-3.5 ${urgent ? "text-status-warning" : "text-yellow-300"}`}
/> />
</span> </span>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div <div
className={`text-sm font-medium ${urgent ? "text-amber-200" : "text-yellow-200"}`} className={`text-sm font-medium ${urgent ? "text-status-warning" : "text-yellow-200"}`}
> >
{t("detail.waitingBanner.title")} {t("detail.waitingBanner.title")}
{cfg && ( {cfg && (
<span className={urgent ? "text-amber-300/90" : "text-yellow-300/80"}> <span className={urgent ? "text-status-warning/90" : "text-yellow-300/80"}>
{" · "} {" · "}
{t(cfg.labelKey)} {t(cfg.labelKey)}
</span> </span>
)} )}
</div> </div>
<div <div
className={`text-[11px] ${urgent ? "text-amber-400/70" : "text-yellow-400/60"}`} className={`text-[11px] ${urgent ? "text-status-warning/70" : "text-yellow-400/60"}`}
> >
{cfg ? t(cfg.descKey) : t("detail.waitingBanner.generic")} {cfg ? t(cfg.descKey) : t("detail.waitingBanner.generic")}
</div> </div>
@@ -696,12 +696,12 @@ export function SessionDetail() {
{session.awaiting_input_since && ( {session.awaiting_input_since && (
<span <span
className={`text-[11px] flex-shrink-0 flex items-center gap-1.5 ${ className={`text-[11px] flex-shrink-0 flex items-center gap-1.5 ${
urgent ? "text-amber-300/80" : "text-yellow-400/70" urgent ? "text-status-warning/80" : "text-yellow-400/70"
}`} }`}
> >
<span <span
className={`w-1.5 h-1.5 rounded-full animate-pulse-dot ${ className={`w-1.5 h-1.5 rounded-full animate-pulse-dot ${
urgent ? "bg-amber-400" : "bg-yellow-400" urgent ? "bg-status-warning" : "bg-yellow-400"
}`} }`}
aria-hidden="true" aria-hidden="true"
/> />
@@ -715,23 +715,23 @@ export function SessionDetail() {
{isDashboardRun && ( {isDashboardRun && (
<Link <Link
to={`/run?session=${encodeURIComponent(id || "")}`} to={`/run?session=${encodeURIComponent(id || "")}`}
className="flex items-center gap-3 rounded-lg border border-emerald-500/30 bg-emerald-500/[0.06] hover:bg-emerald-500/[0.12] hover:border-emerald-500/50 px-4 py-2.5 transition-colors group" className="flex items-center gap-3 rounded-lg border border-status-success/30 bg-status-success/[0.06] hover:bg-status-success/[0.12] hover:border-status-success/50 px-4 py-2.5 transition-colors group"
> >
<span className="w-7 h-7 rounded-md bg-emerald-500/15 border border-emerald-500/30 inline-flex items-center justify-center flex-shrink-0"> <span className="w-7 h-7 rounded-md bg-status-success/15 border border-status-success/30 inline-flex items-center justify-center flex-shrink-0">
<Play className="w-3.5 h-3.5 text-emerald-300" /> <Play className="w-3.5 h-3.5 text-status-success" />
</span> </span>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="text-sm font-medium text-emerald-200"> <div className="text-sm font-medium text-status-success">
{t("detail.dashboardRun.title", "This session is being driven from the Run page")} {t("detail.dashboardRun.title", "This session is being driven from the Run page")}
</div> </div>
<div className="text-[11px] text-emerald-400/70"> <div className="text-[11px] text-status-success/70">
{t( {t(
"detail.dashboardRun.body", "detail.dashboardRun.body",
"Send follow-ups, watch streaming output, or stop the run from there." "Send follow-ups, watch streaming output, or stop the run from there."
)} )}
</div> </div>
</div> </div>
<ExternalLink className="w-4 h-4 text-emerald-300/70 group-hover:text-emerald-200 flex-shrink-0" /> <ExternalLink className="w-4 h-4 text-status-success/70 group-hover:text-status-success flex-shrink-0" />
</Link> </Link>
)} )}
@@ -745,7 +745,7 @@ export function SessionDetail() {
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${ className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${
activeTab === "agents" activeTab === "agents"
? "border-violet-500 text-violet-400" ? "border-violet-500 text-violet-400"
: "border-transparent text-gray-500 hover:text-gray-300" : "border-transparent text-fg-muted hover:text-fg-secondary"
}`} }`}
> >
<Bot className="w-4 h-4" /> <Bot className="w-4 h-4" />
@@ -759,7 +759,7 @@ export function SessionDetail() {
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${ className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${
activeTab === "conversation" activeTab === "conversation"
? "border-violet-500 text-violet-400" ? "border-violet-500 text-violet-400"
: "border-transparent text-gray-500 hover:text-gray-300" : "border-transparent text-fg-muted hover:text-fg-secondary"
}`} }`}
> >
<MessageSquare className="w-4 h-4" /> <MessageSquare className="w-4 h-4" />
@@ -773,7 +773,7 @@ export function SessionDetail() {
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${ className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${
activeTab === "timeline" activeTab === "timeline"
? "border-violet-500 text-violet-400" ? "border-violet-500 text-violet-400"
: "border-transparent text-gray-500 hover:text-gray-300" : "border-transparent text-fg-muted hover:text-fg-secondary"
}`} }`}
> >
<List className="w-4 h-4" /> <List className="w-4 h-4" />
@@ -783,12 +783,12 @@ export function SessionDetail() {
{/* Tab Content */} {/* Tab Content */}
{transcriptNotFound && ( {transcriptNotFound && (
<div className="flex items-center gap-2 px-4 py-2.5 mb-3 text-sm text-amber-400 bg-amber-500/10 border border-amber-500/20 rounded-lg"> <div className="flex items-center gap-2 px-4 py-2.5 mb-3 text-sm text-status-warning bg-status-warning/10 border border-status-warning/20 rounded-lg">
<AlertCircle className="w-4 h-4 flex-shrink-0" /> <AlertCircle className="w-4 h-4 flex-shrink-0" />
<span>{t("detail.transcriptNotFound")}</span> <span>{t("detail.transcriptNotFound")}</span>
<button <button
onClick={() => setTranscriptNotFound(false)} onClick={() => setTranscriptNotFound(false)}
className="ml-auto text-amber-400/60 hover:text-amber-400 transition-colors" className="ml-auto text-status-warning/60 hover:text-status-warning transition-colors"
> >
<List className="w-3.5 h-3.5" /> <List className="w-3.5 h-3.5" />
</button> </button>
@@ -801,23 +801,23 @@ export function SessionDetail() {
{workflows.length > 0 && ( {workflows.length > 0 && (
<div className="mb-4"> <div className="mb-4">
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2 flex items-center gap-1.5"> <h3 className="text-xs font-semibold text-fg-secondary uppercase tracking-wider mb-2 flex items-center gap-1.5">
<Workflow className="w-3.5 h-3.5 text-violet-400" /> <Workflow className="w-3.5 h-3.5 text-violet-400" />
{wfT("runs.sessionTitle")} {wfT("runs.sessionTitle")}
<span className="text-gray-600 font-mono">· {workflows.length}</span> <span className="text-fg-muted font-mono">· {workflows.length}</span>
</h3> </h3>
<WorkflowRunsPanel runs={workflows} hideSessionLink /> <WorkflowRunsPanel runs={workflows} hideSessionLink />
</div> </div>
)} )}
{agents.length === 0 ? ( {agents.length === 0 ? (
<p className="text-sm text-gray-500">{t("detail.noAgents")}</p> <p className="text-sm text-fg-muted">{t("detail.noAgents")}</p>
) : ( ) : (
<> <>
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3 flex items-center gap-1.5"> <h3 className="text-xs font-semibold text-fg-secondary uppercase tracking-wider mb-3 flex items-center gap-1.5">
<Bot className="w-3.5 h-3.5 text-violet-400" /> <Bot className="w-3.5 h-3.5 text-violet-400" />
{t("detail.agents")} {t("detail.agents")}
<span className="text-gray-600 font-mono">· {agents.length}</span> <span className="text-fg-muted font-mono">· {agents.length}</span>
</h3> </h3>
<div className="space-y-2" data-testid="agent-tree"> <div className="space-y-2" data-testid="agent-tree">
{(() => { {(() => {
@@ -880,7 +880,7 @@ export function SessionDetail() {
{hasChildren && ( {hasChildren && (
<button <button
onClick={toggleExpanded} onClick={toggleExpanded}
className="p-1 text-gray-500 hover:text-gray-300 transition-colors flex-shrink-0" className="p-1 text-fg-muted hover:text-fg-secondary transition-colors flex-shrink-0"
aria-label={isExpanded ? "Collapse subagents" : "Expand subagents"} aria-label={isExpanded ? "Collapse subagents" : "Expand subagents"}
aria-expanded={isExpanded} aria-expanded={isExpanded}
> >
@@ -966,7 +966,7 @@ export function SessionDetail() {
{/* Orphaned subagents */} {/* Orphaned subagents */}
{orphans.length > 0 && ( {orphans.length > 0 && (
<div className="mt-4"> <div className="mt-4">
<p className="text-[11px] text-gray-500 mb-2 uppercase tracking-wider"> <p className="text-[11px] text-fg-muted mb-2 uppercase tracking-wider">
{t("detail.unparented")} {t("detail.unparented")}
</p> </p>
<div className="space-y-1"> <div className="space-y-1">
@@ -984,7 +984,7 @@ export function SessionDetail() {
{/* Cost Breakdown - shown under Agents tab */} {/* Cost Breakdown - shown under Agents tab */}
{cost && cost.breakdown.length > 0 && cost.total_cost > 0 && ( {cost && cost.breakdown.length > 0 && cost.total_cost > 0 && (
<div className="mt-8"> <div className="mt-8">
<h3 className="text-sm font-medium text-gray-300 mb-4 flex items-center gap-2"> <h3 className="text-sm font-medium text-fg-secondary mb-4 flex items-center gap-2">
<DollarSign className="w-4 h-4" /> <DollarSign className="w-4 h-4" />
{t("detail.costBreakdown")} {t("detail.costBreakdown")}
</h3> </h3>
@@ -992,22 +992,22 @@ export function SessionDetail() {
<table className="w-full min-w-[600px]"> <table className="w-full min-w-[600px]">
<thead> <thead>
<tr className="border-b border-border text-left"> <tr className="border-b border-border text-left">
<th className="px-5 py-2.5 text-[11px] font-semibold text-gray-500 uppercase tracking-wider"> <th className="px-5 py-2.5 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
{t("common:cost.model")} {t("common:cost.model")}
</th> </th>
<th className="px-5 py-2.5 text-[11px] font-semibold text-gray-500 uppercase tracking-wider text-right"> <th className="px-5 py-2.5 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
{t("common:token.input")} {t("common:token.input")}
</th> </th>
<th className="px-5 py-2.5 text-[11px] font-semibold text-gray-500 uppercase tracking-wider text-right"> <th className="px-5 py-2.5 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
{t("common:token.output")} {t("common:token.output")}
</th> </th>
<th className="px-5 py-2.5 text-[11px] font-semibold text-gray-500 uppercase tracking-wider text-right"> <th className="px-5 py-2.5 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
{t("common:token.cacheRead")} {t("common:token.cacheRead")}
</th> </th>
<th className="px-5 py-2.5 text-[11px] font-semibold text-gray-500 uppercase tracking-wider text-right"> <th className="px-5 py-2.5 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
{t("common:token.cacheWrite")} {t("common:token.cacheWrite")}
</th> </th>
<th className="px-5 py-2.5 text-[11px] font-semibold text-gray-500 uppercase tracking-wider text-right"> <th className="px-5 py-2.5 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
{t("common:cost.cost")} {t("common:cost.cost")}
</th> </th>
</tr> </tr>
@@ -1015,31 +1015,31 @@ export function SessionDetail() {
<tbody className="divide-y divide-border"> <tbody className="divide-y divide-border">
{cost.breakdown.map((row) => ( {cost.breakdown.map((row) => (
<tr key={row.model} className="hover:bg-surface-4 transition-colors"> <tr key={row.model} className="hover:bg-surface-4 transition-colors">
<td className="px-5 py-2.5 text-sm font-mono text-gray-300"> <td className="px-5 py-2.5 text-sm font-mono text-fg-secondary">
{formatModelName(row.model)} {formatModelName(row.model)}
</td> </td>
<td className="px-5 py-2.5 text-sm text-gray-400 text-right font-mono"> <td className="px-5 py-2.5 text-sm text-fg-secondary text-right font-mono">
{row.input_tokens.toLocaleString()} {row.input_tokens.toLocaleString()}
</td> </td>
<td className="px-5 py-2.5 text-sm text-gray-400 text-right font-mono"> <td className="px-5 py-2.5 text-sm text-fg-secondary text-right font-mono">
{row.output_tokens.toLocaleString()} {row.output_tokens.toLocaleString()}
</td> </td>
<td className="px-5 py-2.5 text-sm text-gray-400 text-right font-mono"> <td className="px-5 py-2.5 text-sm text-fg-secondary text-right font-mono">
{row.cache_read_tokens.toLocaleString()} {row.cache_read_tokens.toLocaleString()}
</td> </td>
<td className="px-5 py-2.5 text-sm text-gray-400 text-right font-mono"> <td className="px-5 py-2.5 text-sm text-fg-secondary text-right font-mono">
{row.cache_write_tokens.toLocaleString()} {row.cache_write_tokens.toLocaleString()}
</td> </td>
<td className="px-5 py-2.5 text-sm text-emerald-400 text-right font-mono font-medium"> <td className="px-5 py-2.5 text-sm text-status-success text-right font-mono font-medium">
{fmtCostFull(row.cost, 4)} {fmtCostFull(row.cost, 4)}
</td> </td>
</tr> </tr>
))} ))}
<tr className="bg-surface-2"> <tr className="bg-surface-2">
<td className="px-5 py-2.5 text-sm font-medium text-gray-200" colSpan={5}> <td className="px-5 py-2.5 text-sm font-medium text-fg-secondary" colSpan={5}>
{t("common:total")} {t("common:total")}
</td> </td>
<td className="px-5 py-2.5 text-sm text-emerald-400 text-right font-mono font-semibold"> <td className="px-5 py-2.5 text-sm text-status-success text-right font-mono font-semibold">
{fmtCostFull(cost.total_cost, 4)} {fmtCostFull(cost.total_cost, 4)}
</td> </td>
</tr> </tr>
@@ -1071,7 +1071,7 @@ export function SessionDetail() {
/> />
</div> </div>
{events.length === 0 ? ( {events.length === 0 ? (
<p className="text-sm text-gray-500"> <p className="text-sm text-fg-muted">
{isEmptyFilters(filters) ? t("detail.noEvents") : t("common:eventFilters.noResults")} {isEmptyFilters(filters) ? t("detail.noEvents") : t("common:eventFilters.noResults")}
</p> </p>
) : ( ) : (
@@ -1092,12 +1092,12 @@ export function SessionDetail() {
className="w-full text-left px-5 py-3 flex items-center gap-4 hover:bg-surface-4 transition-colors min-w-0 cursor-pointer" className="w-full text-left px-5 py-3 flex items-center gap-4 hover:bg-surface-4 transition-colors min-w-0 cursor-pointer"
> >
<span <span
className={`text-gray-500 text-[10px] w-3 flex-shrink-0 transition-transform ${isOpen ? "rotate-90" : ""}`} className={`text-fg-muted text-[10px] w-3 flex-shrink-0 transition-transform ${isOpen ? "rotate-90" : ""}`}
aria-hidden="true" aria-hidden="true"
> >
</span> </span>
<div className="w-16 text-[11px] text-gray-600 font-mono flex-shrink-0"> <div className="w-16 text-[11px] text-fg-muted font-mono flex-shrink-0">
{timeAgo(event.created_at)} {timeAgo(event.created_at)}
</div> </div>
<AgentStatusBadge status={statusFromEventType(event.event_type)} /> <AgentStatusBadge status={statusFromEventType(event.event_type)} />
@@ -1115,10 +1115,10 @@ export function SessionDetail() {
agentOriginLabel(event.agent_id, agentInfoById) agentOriginLabel(event.agent_id, agentInfoById)
); );
return ( return (
<span className="text-sm text-gray-300 flex-1 truncate"> <span className="text-sm text-fg-secondary flex-1 truncate">
{origin && ( {origin && (
<span <span
className="text-gray-500 mr-1" className="text-fg-muted mr-1"
title={event.agent_id ?? undefined} title={event.agent_id ?? undefined}
> >
{origin} · {origin} ·
@@ -1129,7 +1129,7 @@ export function SessionDetail() {
); );
})()} })()}
{event.tool_name && ( {event.tool_name && (
<span className="text-[11px] px-2 py-0.5 bg-surface-2 rounded text-gray-500 font-mono"> <span className="text-[11px] px-2 py-0.5 bg-surface-2 rounded text-fg-muted font-mono">
{event.tool_name} {event.tool_name}
</span> </span>
)} )}
@@ -1149,7 +1149,7 @@ export function SessionDetail() {
)} )}
{events.length < eventsTotal && ( {events.length < eventsTotal && (
<div className="flex items-center justify-between mt-3 px-1"> <div className="flex items-center justify-between mt-3 px-1">
<span className="text-xs text-gray-500"> <span className="text-xs text-fg-muted">
{t("common:eventFilters.showing", { shown: events.length, total: eventsTotal })} {t("common:eventFilters.showing", { shown: events.length, total: eventsTotal })}
</span> </span>
<button <button
+33 -33
View File
@@ -283,20 +283,20 @@ export function Sessions() {
</div> </div>
<div> <div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h1 className="text-lg font-semibold text-gray-100">{t("title")}</h1> <h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1>
{wsConnected ? ( {wsConnected ? (
<span className="flex items-center gap-1.5 text-[11px] text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 px-2 py-0.5 rounded-full"> <span className="flex items-center gap-1.5 text-[11px] text-status-success bg-status-success/10 border border-status-success/20 px-2 py-0.5 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse-dot" /> <span className="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse-dot" />
{t("common:live")} {t("common:live")}
</span> </span>
) : ( ) : (
<span className="flex items-center gap-1.5 text-[11px] text-gray-400 bg-gray-500/10 border border-gray-500/20 px-2 py-0.5 rounded-full"> <span className="flex items-center gap-1.5 text-[11px] text-fg-secondary bg-surface-4/10 border border-border-light/20 px-2 py-0.5 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-gray-400" /> <span className="w-1.5 h-1.5 rounded-full bg-surface-4" />
{t("common:offline")} {t("common:offline")}
</span> </span>
)} )}
</div> </div>
<p className="text-xs text-gray-500"> <p className="text-xs text-fg-muted">
{t("sessionCount", { count: total })} {t("sessionCount", { count: total })}
{filter ? ` ${filter}` : ""} {filter ? ` ${filter}` : ""}
</p> </p>
@@ -311,7 +311,7 @@ export function Sessions() {
<div className="flex flex-wrap lg:flex-nowrap items-center gap-3 mb-6 bg-surface-2/40 p-2 rounded-xl border border-border w-full"> <div className="flex flex-wrap lg:flex-nowrap items-center gap-3 mb-6 bg-surface-2/40 p-2 rounded-xl border border-border w-full">
{/* Search */} {/* Search */}
<div className="relative flex-1 min-w-[180px] max-w-[340px]"> <div className="relative flex-1 min-w-[180px] max-w-[340px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-fg-muted" />
<input <input
type="text" type="text"
placeholder={t("searchPlaceholder")} placeholder={t("searchPlaceholder")}
@@ -335,7 +335,7 @@ export function Sessions() {
</option> </option>
))} ))}
</select> </select>
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-500 pointer-events-none" /> <ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-fg-muted pointer-events-none" />
</div> </div>
{/* Sort Controls */} {/* Sort Controls */}
@@ -344,7 +344,7 @@ export function Sessions() {
<select <select
value={sortBy} value={sortBy}
onChange={(e) => setSortBy(e.target.value)} onChange={(e) => setSortBy(e.target.value)}
className="bg-transparent w-full text-sm text-gray-200 outline-none pl-3 pr-8 appearance-none cursor-pointer whitespace-nowrap" className="bg-transparent w-full text-sm text-fg-secondary outline-none pl-3 pr-8 appearance-none cursor-pointer whitespace-nowrap"
> >
<option value="time">Sort by Time ({sortDesc ? "Newest" : "Oldest"})</option> <option value="time">Sort by Time ({sortDesc ? "Newest" : "Oldest"})</option>
<option value="duration"> <option value="duration">
@@ -352,12 +352,12 @@ export function Sessions() {
</option> </option>
<option value="price">Sort by Price ({sortDesc ? "Highest" : "Lowest"})</option> <option value="price">Sort by Price ({sortDesc ? "Highest" : "Lowest"})</option>
</select> </select>
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 w-3 h-3 text-gray-500 pointer-events-none" /> <ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 w-3 h-3 text-fg-muted pointer-events-none" />
</div> </div>
<div className="w-px h-4 bg-border mx-1" /> <div className="w-px h-4 bg-border mx-1" />
<button <button
onClick={() => setSortDesc(!sortDesc)} onClick={() => setSortDesc(!sortDesc)}
className="p-1.5 rounded hover:bg-surface-3 text-gray-400 hover:text-gray-200 transition-colors shrink-0" className="p-1.5 rounded hover:bg-surface-3 text-fg-secondary hover:text-fg-primary transition-colors shrink-0"
title={sortDesc ? "Descending" : "Ascending"} title={sortDesc ? "Descending" : "Ascending"}
> >
{sortDesc ? <SortDesc className="w-4 h-4" /> : <SortAsc className="w-4 h-4" />} {sortDesc ? <SortDesc className="w-4 h-4" /> : <SortAsc className="w-4 h-4" />}
@@ -372,8 +372,8 @@ export function Sessions() {
onClick={() => setFilter(opt.value)} onClick={() => setFilter(opt.value)}
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors whitespace-nowrap ${ className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors whitespace-nowrap ${
filter === opt.value filter === opt.value
? "bg-surface-4 text-gray-200" ? "bg-surface-4 text-fg-secondary"
: "text-gray-500 hover:text-gray-300" : "text-fg-muted hover:text-fg-secondary"
}`} }`}
> >
{opt.label} {opt.label}
@@ -394,25 +394,25 @@ export function Sessions() {
<table className="w-full min-w-[800px]"> <table className="w-full min-w-[800px]">
<thead> <thead>
<tr className="border-b border-border text-left"> <tr className="border-b border-border text-left">
<th className="px-5 py-3 text-[11px] font-semibold text-gray-500 uppercase tracking-wider"> <th className="px-5 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
{t("tableSession")} {t("tableSession")}
</th> </th>
<th className="px-5 py-3 text-[11px] font-semibold text-gray-500 uppercase tracking-wider"> <th className="px-5 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
{t("tableStatus")} {t("tableStatus")}
</th> </th>
<th className="px-5 py-3 text-[11px] font-semibold text-gray-500 uppercase tracking-wider"> <th className="px-5 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
{t("tableLastActive")} {t("tableLastActive")}
</th> </th>
<th className="px-5 py-3 text-[11px] font-semibold text-gray-500 uppercase tracking-wider"> <th className="px-5 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
{t("tableDuration")} {t("tableDuration")}
</th> </th>
<th className="px-5 py-3 text-[11px] font-semibold text-gray-500 uppercase tracking-wider"> <th className="px-5 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
{t("tableAgents")} {t("tableAgents")}
</th> </th>
<th className="px-5 py-3 text-[11px] font-semibold text-gray-500 uppercase tracking-wider"> <th className="px-5 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
{t("tableCost")} {t("tableCost")}
</th> </th>
<th className="px-5 py-3 text-[11px] font-semibold text-gray-500 uppercase tracking-wider"> <th className="px-5 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
{t("tableDirectory")} {t("tableDirectory")}
</th> </th>
<th className="w-10"></th> <th className="w-10"></th>
@@ -437,7 +437,7 @@ export function Sessions() {
<td className="px-5 py-4"> <td className="px-5 py-4">
<div> <div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<p className="text-sm font-medium text-gray-200"> <p className="text-sm font-medium text-fg-secondary">
{session.name || `${t("defaultName")}${session.id.slice(0, 8)}`} {session.name || `${t("defaultName")}${session.id.slice(0, 8)}`}
</p> </p>
{session.source && session.source !== "local" && ( {session.source && session.source !== "local" && (
@@ -453,7 +453,7 @@ export function Sessions() {
<Link <Link
to={`/run?session=${encodeURIComponent(session.id)}`} to={`/run?session=${encodeURIComponent(session.id)}`}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
className="inline-flex items-center gap-1 text-[10px] font-semibold text-emerald-300 bg-emerald-500/10 border border-emerald-500/25 hover:bg-emerald-500/20 hover:text-emerald-200 px-1.5 py-0.5 rounded-full transition-colors" className="inline-flex items-center gap-1 text-[10px] font-semibold text-status-success bg-status-success/10 border border-status-success/25 hover:bg-status-success/20 hover:text-status-success px-1.5 py-0.5 rounded-full transition-colors"
title={t("dashboardRunBadge", "Driven by Run page · click to open")} title={t("dashboardRunBadge", "Driven by Run page · click to open")}
> >
<Play className="w-2.5 h-2.5" /> <Play className="w-2.5 h-2.5" />
@@ -461,7 +461,7 @@ export function Sessions() {
</Link> </Link>
)} )}
</div> </div>
<p className="text-[11px] text-gray-600 font-mono"> <p className="text-[11px] text-fg-muted font-mono">
{session.id.slice(0, 12)} {session.id.slice(0, 12)}
</p> </p>
</div> </div>
@@ -472,28 +472,28 @@ export function Sessions() {
reason={sessionAwaitingReason(session)} reason={sessionAwaitingReason(session)}
/> />
</td> </td>
<td className="px-5 py-4 text-sm text-gray-400"> <td className="px-5 py-4 text-sm text-fg-secondary">
{formatDateTime(session.last_activity || session.started_at)} {formatDateTime(session.last_activity || session.started_at)}
</td> </td>
<td className="px-5 py-4 text-sm text-gray-400 font-mono"> <td className="px-5 py-4 text-sm text-fg-secondary font-mono">
{session.ended_at {session.ended_at
? formatDuration(session.started_at, session.ended_at) ? formatDuration(session.started_at, session.ended_at)
: t("common:running")} : t("common:running")}
</td> </td>
<td className="px-5 py-4 text-sm text-gray-400"> <td className="px-5 py-4 text-sm text-fg-secondary">
{session.agent_count ?? "-"} {session.agent_count ?? "-"}
</td> </td>
<td className="px-5 py-4 text-sm text-gray-400 font-mono"> <td className="px-5 py-4 text-sm text-fg-secondary font-mono">
{session.cost != null && session.cost > 0 ? fmtCost(session.cost) : "-"} {session.cost != null && session.cost > 0 ? fmtCost(session.cost) : "-"}
</td> </td>
<td <td
className="px-5 py-4 text-[11px] text-gray-500 font-mono" className="px-5 py-4 text-[11px] text-fg-muted font-mono"
title={session.cwd || undefined} title={session.cwd || undefined}
> >
{session.cwd ? truncate(session.cwd, 30) : "-"} {session.cwd ? truncate(session.cwd, 30) : "-"}
</td> </td>
<td className="px-3 py-4"> <td className="px-3 py-4">
<ChevronRight className="w-4 h-4 text-gray-600 group-hover:text-gray-400 transition-colors" /> <ChevronRight className="w-4 h-4 text-fg-muted group-hover:text-fg-secondary transition-colors" />
</td> </td>
</tr> </tr>
))} ))}
@@ -502,7 +502,7 @@ export function Sessions() {
</div> </div>
{totalPages > 1 && ( {totalPages > 1 && (
<div className="flex items-center justify-between mt-4 px-1"> <div className="flex items-center justify-between mt-4 px-1">
<span className="text-xs text-gray-500"> <span className="text-xs text-fg-muted">
{t("common:pagination.showing", { {t("common:pagination.showing", {
from: page * PAGE_SIZE + 1, from: page * PAGE_SIZE + 1,
to: Math.min((page + 1) * PAGE_SIZE, total), to: Math.min((page + 1) * PAGE_SIZE, total),
@@ -513,17 +513,17 @@ export function Sessions() {
<button <button
onClick={() => setPage((p) => Math.max(0, p - 1))} onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0} disabled={page === 0}
className="px-3 py-1.5 text-xs font-medium rounded-md bg-surface-2 text-gray-400 hover:text-gray-200 disabled:opacity-40 disabled:cursor-not-allowed" className="px-3 py-1.5 text-xs font-medium rounded-md bg-surface-2 text-fg-secondary hover:text-fg-primary disabled:opacity-40 disabled:cursor-not-allowed"
> >
{t("common:pagination.previous")} {t("common:pagination.previous")}
</button> </button>
<span className="px-3 py-1.5 text-xs text-gray-500"> <span className="px-3 py-1.5 text-xs text-fg-muted">
{page + 1} / {totalPages} {page + 1} / {totalPages}
</span> </span>
<button <button
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))} onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1} disabled={page >= totalPages - 1}
className="px-3 py-1.5 text-xs font-medium rounded-md bg-surface-2 text-gray-400 hover:text-gray-200 disabled:opacity-40 disabled:cursor-not-allowed" className="px-3 py-1.5 text-xs font-medium rounded-md bg-surface-2 text-fg-secondary hover:text-fg-primary disabled:opacity-40 disabled:cursor-not-allowed"
> >
{t("common:pagination.next")} {t("common:pagination.next")}
</button> </button>
+167 -157
View File
@@ -311,8 +311,10 @@ function Toggle({
return ( return (
<label className="flex items-center justify-between gap-3 cursor-pointer group"> <label className="flex items-center justify-between gap-3 cursor-pointer group">
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm text-gray-300 group-hover:text-gray-200 transition-colors">{label}</p> <p className="text-sm text-fg-secondary group-hover:text-fg-secondary transition-colors">
{description && <p className="text-xs text-gray-500 mt-0.5">{description}</p>} {label}
</p>
{description && <p className="text-xs text-fg-muted mt-0.5">{description}</p>}
</div> </div>
<button <button
type="button" type="button"
@@ -320,7 +322,7 @@ function Toggle({
aria-checked={checked} aria-checked={checked}
onClick={() => onChange(!checked)} onClick={() => onChange(!checked)}
className={`relative inline-flex h-5 w-9 flex-shrink-0 rounded-full border-2 border-transparent transition-colors duration-200 ${ className={`relative inline-flex h-5 w-9 flex-shrink-0 rounded-full border-2 border-transparent transition-colors duration-200 ${
checked ? "bg-blue-500" : "bg-surface-4" checked ? "bg-blue-600" : "bg-surface-4"
}`} }`}
> >
<span <span
@@ -394,7 +396,7 @@ function PricingInfoTooltip() {
onMouseLeave={() => setOpen(false)} onMouseLeave={() => setOpen(false)}
onFocus={() => setOpen(true)} onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)} onBlur={() => setOpen(false)}
className="inline-flex items-center justify-center rounded-full p-0.5 text-gray-500 hover:text-gray-300 focus:outline-none focus:ring-1 focus:ring-accent/40" className="inline-flex items-center justify-center rounded-full p-0.5 text-fg-muted hover:text-fg-secondary focus:outline-none focus:ring-1 focus:ring-accent/40"
> >
<Info className="w-3.5 h-3.5" /> <Info className="w-3.5 h-3.5" />
</button> </button>
@@ -402,32 +404,36 @@ function PricingInfoTooltip() {
<div <div
ref={popoverRef} ref={popoverRef}
role="tooltip" role="tooltip"
className="fixed z-50 p-3 bg-[#12121f] border border-[#2a2a4a] rounded-lg shadow-2xl text-[11px] text-gray-300 pointer-events-none" className="fixed z-50 p-3 bg-[#12121f] border border-[#2a2a4a] rounded-lg shadow-2xl text-[11px] text-fg-secondary pointer-events-none"
style={{ left: pos.left, top: pos.top, width: 320 }} style={{ left: pos.left, top: pos.top, width: 320 }}
> >
<p className="text-xs font-semibold text-gray-100 mb-2">{t("pricing.tooltip.title")}</p> <p className="text-xs font-semibold text-fg-primary mb-2">{t("pricing.tooltip.title")}</p>
<p className="font-semibold text-gray-200 uppercase tracking-wider text-[9px] mb-1"> <p className="font-semibold text-fg-secondary uppercase tracking-wider text-[9px] mb-1">
{t("pricing.tooltip.howItWorks")} {t("pricing.tooltip.howItWorks")}
</p> </p>
<p className="text-gray-400 leading-snug mb-2.5">{t("pricing.tooltip.howItWorksBody")}</p> <p className="text-fg-secondary leading-snug mb-2.5">
{t("pricing.tooltip.howItWorksBody")}
</p>
<p className="font-semibold text-gray-200 uppercase tracking-wider text-[9px] mb-1"> <p className="font-semibold text-fg-secondary uppercase tracking-wider text-[9px] mb-1">
{t("pricing.tooltip.patternsTitle")} {t("pricing.tooltip.patternsTitle")}
</p> </p>
<p className="text-gray-400 leading-snug mb-2.5">{t("pricing.tooltip.patternsBody")}</p> <p className="text-fg-secondary leading-snug mb-2.5">
{t("pricing.tooltip.patternsBody")}
</p>
<p className="font-semibold text-amber-300 uppercase tracking-wider text-[9px] mb-1"> <p className="font-semibold text-status-warning uppercase tracking-wider text-[9px] mb-1">
{t("pricing.tooltip.manualUpdates")} {t("pricing.tooltip.manualUpdates")}
</p> </p>
<p className="text-gray-400 leading-snug mb-2.5"> <p className="text-fg-secondary leading-snug mb-2.5">
{t("pricing.tooltip.manualUpdatesBody")} {t("pricing.tooltip.manualUpdatesBody")}
</p> </p>
<p className="font-semibold text-amber-300 uppercase tracking-wider text-[9px] mb-1"> <p className="font-semibold text-status-warning uppercase tracking-wider text-[9px] mb-1">
{t("pricing.tooltip.apiPricing")} {t("pricing.tooltip.apiPricing")}
</p> </p>
<p className="text-gray-400 leading-snug">{t("pricing.tooltip.apiPricingBody")}</p> <p className="text-fg-secondary leading-snug">{t("pricing.tooltip.apiPricingBody")}</p>
</div> </div>
)} )}
</> </>
@@ -866,14 +872,14 @@ export function Settings() {
<button <button
onClick={saveEdit} onClick={saveEdit}
disabled={saving} disabled={saving}
className="p-1.5 rounded-md text-emerald-400 hover:bg-emerald-500/10 transition-colors disabled:opacity-50" className="p-1.5 rounded-md text-status-success hover:bg-status-success/10 transition-colors disabled:opacity-50"
title={t("common:save")} title={t("common:save")}
> >
<Check className="w-4 h-4" /> <Check className="w-4 h-4" />
</button> </button>
<button <button
onClick={cancelEdit} onClick={cancelEdit}
className="p-1.5 rounded-md text-gray-400 hover:bg-surface-4 transition-colors" className="p-1.5 rounded-md text-fg-secondary hover:bg-surface-4 transition-colors"
title={t("common:cancel")} title={t("common:cancel")}
> >
<X className="w-4 h-4" /> <X className="w-4 h-4" />
@@ -909,7 +915,7 @@ export function Settings() {
<span className="text-[11px] font-semibold text-violet-300 uppercase tracking-wider"> <span className="text-[11px] font-semibold text-violet-300 uppercase tracking-wider">
{t("pricing.introRatesTitle")} {t("pricing.introRatesTitle")}
</span> </span>
<span className="text-[11px] text-gray-500">{t("pricing.introRatesHint")}</span> <span className="text-[11px] text-fg-muted">{t("pricing.introRatesHint")}</span>
</div> </div>
<div className="flex flex-wrap items-end gap-3"> <div className="flex flex-wrap items-end gap-3">
{introField("intro_until", "pricing.introUntil", { date: true })} {introField("intro_until", "pricing.introUntil", { date: true })}
@@ -931,8 +937,8 @@ export function Settings() {
<div <div
className={`px-3 py-2 rounded-lg text-xs ${ className={`px-3 py-2 rounded-lg text-xs ${
match.isError match.isError
? "bg-red-500/10 border border-red-500/20 text-red-400" ? "bg-status-danger/10 border border-status-danger/20 text-status-danger"
: "bg-emerald-500/10 border border-emerald-500/20 text-emerald-400" : "bg-status-success/10 border border-status-success/20 text-status-success"
}`} }`}
> >
{match.message} {match.message}
@@ -981,27 +987,27 @@ export function Settings() {
</div> </div>
<div> <div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h1 className="text-lg font-semibold text-gray-100">{t("title")}</h1> <h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1>
{wsConnected ? ( {wsConnected ? (
<span className="flex items-center gap-1.5 text-[11px] text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 px-2 py-0.5 rounded-full"> <span className="flex items-center gap-1.5 text-[11px] text-status-success bg-status-success/10 border border-status-success/20 px-2 py-0.5 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse-dot" /> <span className="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse-dot" />
{t("common:live")} {t("common:live")}
</span> </span>
) : ( ) : (
<span className="flex items-center gap-1.5 text-[11px] text-gray-400 bg-gray-500/10 border border-gray-500/20 px-2 py-0.5 rounded-full"> <span className="flex items-center gap-1.5 text-[11px] text-fg-secondary bg-surface-4/10 border border-border-light/20 px-2 py-0.5 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-gray-400" /> <span className="w-1.5 h-1.5 rounded-full bg-surface-4" />
{t("common:offline")} {t("common:offline")}
</span> </span>
)} )}
</div> </div>
<p className="text-xs text-gray-500">{t("subtitle")}</p> <p className="text-xs text-fg-muted">{t("subtitle")}</p>
</div> </div>
</div> </div>
<div className="flex items-center gap-2 flex-shrink-0"> <div className="flex items-center gap-2 flex-shrink-0">
<a <a
href={api.settings.exportData()} href={api.settings.exportData()}
download download
className="inline-flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-md border border-border text-gray-400 hover:text-gray-200 hover:border-gray-500 transition-colors" className="inline-flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-md border border-border text-fg-secondary hover:text-fg-primary hover:border-border-light transition-colors"
> >
<FileDown className="w-3.5 h-3.5" /> <FileDown className="w-3.5 h-3.5" />
{t("exportData")} {t("exportData")}
@@ -1015,7 +1021,7 @@ export function Settings() {
{/* In-page section navigation - Settings is dense, so this TOC jumps to {/* In-page section navigation - Settings is dense, so this TOC jumps to
and scroll-spies each section. */} and scroll-spies each section. */}
<nav className="sticky top-0 z-20 -mx-1 !mt-2 px-1 py-2 bg-surface-0/85 backdrop-blur border-b border-border/60 flex items-center gap-1.5"> <nav className="sticky top-0 z-20 -mx-1 !mt-2 px-1 py-2 bg-surface-0/85 backdrop-blur border-b border-border/60 flex items-center gap-1.5">
<span className="flex-shrink-0 pl-1 text-[10px] font-semibold uppercase tracking-wider text-gray-500"> <span className="flex-shrink-0 pl-1 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
{t("jumpTo", "Jump to")} {t("jumpTo", "Jump to")}
</span> </span>
{tocOverflow.left && ( {tocOverflow.left && (
@@ -1023,7 +1029,7 @@ export function Settings() {
type="button" type="button"
onClick={() => scrollTocBy(-180)} onClick={() => scrollTocBy(-180)}
aria-label="Scroll left" aria-label="Scroll left"
className="flex-shrink-0 flex items-center justify-center w-6 h-7 rounded-md border border-border text-gray-400 hover:text-gray-200 hover:bg-surface-3 transition-colors" className="flex-shrink-0 flex items-center justify-center w-6 h-7 rounded-md border border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3 transition-colors"
> >
<ChevronLeft className="w-3.5 h-3.5" /> <ChevronLeft className="w-3.5 h-3.5" />
</button> </button>
@@ -1043,7 +1049,7 @@ export function Settings() {
className={`inline-flex items-center gap-1.5 text-xs whitespace-nowrap px-2.5 py-1.5 rounded-lg border transition-colors flex-shrink-0 ${ className={`inline-flex items-center gap-1.5 text-xs whitespace-nowrap px-2.5 py-1.5 rounded-lg border transition-colors flex-shrink-0 ${
active active
? "bg-accent/15 border-accent/30 text-accent" ? "bg-accent/15 border-accent/30 text-accent"
: "border-border text-gray-400 hover:text-gray-200 hover:bg-surface-3" : "border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
}`} }`}
> >
<Icon className="w-3.5 h-3.5" /> <Icon className="w-3.5 h-3.5" />
@@ -1057,7 +1063,7 @@ export function Settings() {
type="button" type="button"
onClick={() => scrollTocBy(180)} onClick={() => scrollTocBy(180)}
aria-label="Scroll right" aria-label="Scroll right"
className="flex-shrink-0 flex items-center justify-center w-6 h-7 rounded-md border border-border text-gray-400 hover:text-gray-200 hover:bg-surface-3 transition-colors" className="flex-shrink-0 flex items-center justify-center w-6 h-7 rounded-md border border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3 transition-colors"
> >
<ChevronRight className="w-3.5 h-3.5" /> <ChevronRight className="w-3.5 h-3.5" />
</button> </button>
@@ -1068,12 +1074,12 @@ export function Settings() {
<div className="card p-6"> <div className="card p-6">
<div className="flex items-center justify-between flex-wrap gap-4"> <div className="flex items-center justify-between flex-wrap gap-4">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-emerald-500/10 border border-emerald-500/20 flex items-center justify-center"> <div className="w-12 h-12 rounded-xl bg-status-success/10 border border-status-success/20 flex items-center justify-center">
<DollarSign className="w-6 h-6 text-emerald-400" /> <DollarSign className="w-6 h-6 text-status-success" />
</div> </div>
<div> <div>
<p className="text-sm text-gray-500">{t("common:cost.totalEstimatedCost")}</p> <p className="text-sm text-fg-muted">{t("common:cost.totalEstimatedCost")}</p>
<p className="text-2xl font-semibold text-gray-100"> <p className="text-2xl font-semibold text-fg-primary">
<Tip <Tip
raw={ raw={
totalCost !== null totalCost !== null
@@ -1086,7 +1092,7 @@ export function Settings() {
</p> </p>
</div> </div>
</div> </div>
<div className="text-right text-xs text-gray-500"> <div className="text-right text-xs text-fg-muted">
<p>{t("acrossSessions")}</p> <p>{t("acrossSessions")}</p>
<p>{t("basedOnUsage")}</p> <p>{t("basedOnUsage")}</p>
</div> </div>
@@ -1097,12 +1103,12 @@ export function Settings() {
<section id="pricing" className="scroll-mt-24"> <section id="pricing" className="scroll-mt-24">
<div className="flex flex-wrap items-center justify-between gap-3 mb-4"> <div className="flex flex-wrap items-center justify-between gap-3 mb-4">
<div> <div>
<h3 className="text-sm font-medium text-gray-300 flex items-center gap-2"> <h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2">
<DollarSign className="w-4 h-4 text-gray-500" /> <DollarSign className="w-4 h-4 text-fg-muted" />
{t("pricing.title")} {t("pricing.title")}
<PricingInfoTooltip /> <PricingInfoTooltip />
</h3> </h3>
<p className="text-xs text-gray-500 mt-0.5">{t("pricing.description")}</p> <p className="text-xs text-fg-muted mt-0.5">{t("pricing.description")}</p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
@@ -1114,8 +1120,8 @@ export function Settings() {
disabled={isEditing || actionLoading !== null} disabled={isEditing || actionLoading !== null}
className={`text-xs px-3 py-1.5 rounded-md transition-colors disabled:opacity-50 inline-flex items-center gap-1.5 ${ className={`text-xs px-3 py-1.5 rounded-md transition-colors disabled:opacity-50 inline-flex items-center gap-1.5 ${
confirmAction === "reset-pricing" confirmAction === "reset-pricing"
? "bg-amber-500/20 text-amber-400 border border-amber-500/30" ? "bg-status-warning/20 text-status-warning border border-status-warning/30"
: "text-gray-400 hover:text-gray-300 hover:bg-surface-4" : "text-fg-secondary hover:text-fg-primary hover:bg-surface-4"
}`} }`}
> >
<RotateCcw className="w-3 h-3" /> <RotateCcw className="w-3 h-3" />
@@ -1134,7 +1140,7 @@ export function Settings() {
</div> </div>
{error && ( {error && (
<div className="mb-4 px-4 py-2.5 bg-red-500/10 border border-red-500/20 rounded-lg text-sm text-red-400"> <div className="mb-4 px-4 py-2.5 bg-status-danger/10 border border-status-danger/20 rounded-lg text-sm text-status-danger">
{error} {error}
</div> </div>
)} )}
@@ -1145,34 +1151,34 @@ export function Settings() {
<table className="w-full min-w-[1000px]"> <table className="w-full min-w-[1000px]">
<thead> <thead>
<tr className="border-b border-border text-left"> <tr className="border-b border-border text-left">
<th className="px-4 py-3 text-[11px] font-semibold text-gray-500 uppercase tracking-wider"> <th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
{t("pricing.pattern")} {t("pricing.pattern")}
</th> </th>
<th className="px-4 py-3 text-[11px] font-semibold text-gray-500 uppercase tracking-wider"> <th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
{t("common:cost.model")} {t("common:cost.model")}
</th> </th>
<th className="px-4 py-3 text-[11px] font-semibold text-gray-500 uppercase tracking-wider text-right"> <th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
{t("common:token.input")} {t("common:token.input")}
</th> </th>
<th className="px-4 py-3 text-[11px] font-semibold text-gray-500 uppercase tracking-wider text-right"> <th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
{t("common:token.output")} {t("common:token.output")}
</th> </th>
<th className="px-4 py-3 text-[11px] font-semibold text-gray-500 uppercase tracking-wider text-right"> <th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
{t("common:token.cacheRead")} {t("common:token.cacheRead")}
</th> </th>
<th className="px-4 py-3 text-[11px] font-semibold text-gray-500 uppercase tracking-wider text-right"> <th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
{t("pricing.cacheWrite5m")} {t("pricing.cacheWrite5m")}
</th> </th>
<th className="px-4 py-3 text-[11px] font-semibold text-gray-500 uppercase tracking-wider text-right"> <th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
{t("pricing.cacheWrite1h")} {t("pricing.cacheWrite1h")}
</th> </th>
<th className="px-4 py-3 text-[11px] font-semibold text-gray-500 uppercase tracking-wider text-right"> <th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
{t("pricing.fastInput")} {t("pricing.fastInput")}
</th> </th>
<th className="px-4 py-3 text-[11px] font-semibold text-gray-500 uppercase tracking-wider text-right"> <th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
{t("pricing.fastOutput")} {t("pricing.fastOutput")}
</th> </th>
<th className="w-24 px-4 py-3 text-[11px] font-semibold text-gray-500 uppercase tracking-wider"> <th className="w-24 px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
{t("common:actions")} {t("common:actions")}
</th> </th>
</tr> </tr>
@@ -1189,10 +1195,10 @@ export function Settings() {
key={rule.model_pattern} key={rule.model_pattern}
className="hover:bg-surface-4 transition-colors group" className="hover:bg-surface-4 transition-colors group"
> >
<td className="px-4 py-3 text-sm font-mono text-gray-300"> <td className="px-4 py-3 text-sm font-mono text-fg-secondary">
{rule.model_pattern} {rule.model_pattern}
</td> </td>
<td className="px-4 py-3 text-sm text-gray-300"> <td className="px-4 py-3 text-sm text-fg-secondary">
{rule.display_name} {rule.display_name}
{rule.intro_until && ( {rule.intro_until && (
<div className="text-[11px] font-normal text-violet-400/80 mt-0.5"> <div className="text-[11px] font-normal text-violet-400/80 mt-0.5">
@@ -1204,7 +1210,7 @@ export function Settings() {
</div> </div>
)} )}
</td> </td>
<td className="px-4 py-3 text-sm text-gray-400 text-right font-mono"> <td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
${rule.input_per_mtok} ${rule.input_per_mtok}
{rule.intro_until && ( {rule.intro_until && (
<span className="block text-[11px] text-violet-400/80"> <span className="block text-[11px] text-violet-400/80">
@@ -1212,7 +1218,7 @@ export function Settings() {
</span> </span>
)} )}
</td> </td>
<td className="px-4 py-3 text-sm text-gray-400 text-right font-mono"> <td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
${rule.output_per_mtok} ${rule.output_per_mtok}
{rule.intro_until && ( {rule.intro_until && (
<span className="block text-[11px] text-violet-400/80"> <span className="block text-[11px] text-violet-400/80">
@@ -1220,7 +1226,7 @@ export function Settings() {
</span> </span>
)} )}
</td> </td>
<td className="px-4 py-3 text-sm text-gray-400 text-right font-mono"> <td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
${rule.cache_read_per_mtok} ${rule.cache_read_per_mtok}
{rule.intro_until && ( {rule.intro_until && (
<span className="block text-[11px] text-violet-400/80"> <span className="block text-[11px] text-violet-400/80">
@@ -1228,7 +1234,7 @@ export function Settings() {
</span> </span>
)} )}
</td> </td>
<td className="px-4 py-3 text-sm text-gray-400 text-right font-mono"> <td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
${rule.cache_write_per_mtok} ${rule.cache_write_per_mtok}
{rule.intro_until && ( {rule.intro_until && (
<span className="block text-[11px] text-violet-400/80"> <span className="block text-[11px] text-violet-400/80">
@@ -1236,7 +1242,7 @@ export function Settings() {
</span> </span>
)} )}
</td> </td>
<td className="px-4 py-3 text-sm text-gray-400 text-right font-mono"> <td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
${rule.cache_write_1h_per_mtok} ${rule.cache_write_1h_per_mtok}
{rule.intro_until && ( {rule.intro_until && (
<span className="block text-[11px] text-violet-400/80"> <span className="block text-[11px] text-violet-400/80">
@@ -1244,10 +1250,10 @@ export function Settings() {
</span> </span>
)} )}
</td> </td>
<td className="px-4 py-3 text-sm text-gray-400 text-right font-mono"> <td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
{rule.fast_input_per_mtok ? `$${rule.fast_input_per_mtok}` : "-"} {rule.fast_input_per_mtok ? `$${rule.fast_input_per_mtok}` : "-"}
</td> </td>
<td className="px-4 py-3 text-sm text-gray-400 text-right font-mono"> <td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
{rule.fast_output_per_mtok ? `$${rule.fast_output_per_mtok}` : "-"} {rule.fast_output_per_mtok ? `$${rule.fast_output_per_mtok}` : "-"}
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
@@ -1255,7 +1261,7 @@ export function Settings() {
<button <button
onClick={() => startEdit(rule)} onClick={() => startEdit(rule)}
disabled={isEditing} disabled={isEditing}
className="p-1.5 rounded-md text-gray-400 hover:text-blue-400 hover:bg-blue-500/10 transition-colors disabled:opacity-30" className="p-1.5 rounded-md text-fg-secondary hover:text-blue-500 hover:bg-blue-600/10 transition-colors disabled:opacity-30"
title={t("common:edit")} title={t("common:edit")}
> >
<Pencil className="w-3.5 h-3.5" /> <Pencil className="w-3.5 h-3.5" />
@@ -1263,7 +1269,7 @@ export function Settings() {
<button <button
onClick={() => deleteRule(rule.model_pattern)} onClick={() => deleteRule(rule.model_pattern)}
disabled={isEditing} disabled={isEditing}
className="p-1.5 rounded-md text-gray-400 hover:text-red-400 hover:bg-red-500/10 transition-colors disabled:opacity-30" className="p-1.5 rounded-md text-fg-secondary hover:text-status-danger hover:bg-status-danger/10 transition-colors disabled:opacity-30"
title={t("common:delete")} title={t("common:delete")}
> >
<Trash2 className="w-3.5 h-3.5" /> <Trash2 className="w-3.5 h-3.5" />
@@ -1284,7 +1290,7 @@ export function Settings() {
</div> </div>
{lastUpdated && ( {lastUpdated && (
<p className="text-xs text-gray-600 mt-3"> <p className="text-xs text-fg-muted mt-3">
{t("pricing.lastUpdated")} {t("pricing.lastUpdated")}
{formatTimestamp(lastUpdated)} {formatTimestamp(lastUpdated)}
</p> </p>
@@ -1293,21 +1299,21 @@ export function Settings() {
{/* ─── HOOK CONFIGURATION ─── */} {/* ─── HOOK CONFIGURATION ─── */}
<section id="hooks" className="scroll-mt-24"> <section id="hooks" className="scroll-mt-24">
<h3 className="text-sm font-medium text-gray-300 flex items-center gap-2 mb-1"> <h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
<Plug className="w-4 h-4 text-gray-500" /> <Plug className="w-4 h-4 text-fg-muted" />
{t("hooks.title")} {t("hooks.title")}
</h3> </h3>
<p className="text-xs text-gray-500 mb-4">{t("hooks.description")}</p> <p className="text-xs text-fg-muted mb-4">{t("hooks.description")}</p>
<div className="card p-5 space-y-4"> <div className="card p-5 space-y-4">
<div className="flex items-center justify-between flex-wrap gap-3"> <div className="flex items-center justify-between flex-wrap gap-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{sysInfo?.hooks.installed ? ( {sysInfo?.hooks.installed ? (
<span className="inline-flex items-center gap-1.5 text-xs font-medium text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 px-2.5 py-1 rounded-full"> <span className="inline-flex items-center gap-1.5 text-xs font-medium text-status-success bg-status-success/10 border border-status-success/20 px-2.5 py-1 rounded-full">
<CheckCircle className="w-3.5 h-3.5" /> {t("hooks.allInstalled")} <CheckCircle className="w-3.5 h-3.5" /> {t("hooks.allInstalled")}
</span> </span>
) : ( ) : (
<span className="inline-flex items-center gap-1.5 text-xs font-medium text-amber-400 bg-amber-500/10 border border-amber-500/20 px-2.5 py-1 rounded-full"> <span className="inline-flex items-center gap-1.5 text-xs font-medium text-status-warning bg-status-warning/10 border border-status-warning/20 px-2.5 py-1 rounded-full">
<AlertTriangle className="w-3.5 h-3.5" /> {t("hooks.incomplete")} <AlertTriangle className="w-3.5 h-3.5" /> {t("hooks.incomplete")}
</span> </span>
)} )}
@@ -1337,15 +1343,15 @@ export function Settings() {
className="flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-md bg-surface-2" className="flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-md bg-surface-2"
> >
{active ? ( {active ? (
<CheckCircle className="w-3 h-3 text-emerald-400 flex-shrink-0" /> <CheckCircle className="w-3 h-3 text-status-success flex-shrink-0" />
) : ( ) : (
<XCircle className="w-3 h-3 text-red-400 flex-shrink-0" /> <XCircle className="w-3 h-3 text-status-danger flex-shrink-0" />
)} )}
<span className="text-gray-400 truncate">{hook}</span> <span className="text-fg-secondary truncate">{hook}</span>
</div> </div>
))} ))}
</div> </div>
<p className="text-[11px] text-gray-600 font-mono truncate">{sysInfo.hooks.path}</p> <p className="text-[11px] text-fg-muted font-mono truncate">{sysInfo.hooks.path}</p>
</> </>
)} )}
</div> </div>
@@ -1353,12 +1359,12 @@ export function Settings() {
{/* ─── CLAUDE HOME ─── */} {/* ─── CLAUDE HOME ─── */}
<section id="claude-home" className="scroll-mt-24"> <section id="claude-home" className="scroll-mt-24">
<h3 className="text-sm font-medium text-gray-300 flex items-center gap-2 mb-1"> <h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
<FolderOpen className="w-4 h-4 text-gray-500" /> <FolderOpen className="w-4 h-4 text-fg-muted" />
{t("claudeHome.title")} {t("claudeHome.title")}
</h3> </h3>
<p className="text-xs text-gray-500 mb-1">{t("claudeHome.description")}</p> <p className="text-xs text-fg-muted mb-1">{t("claudeHome.description")}</p>
<p className="text-[11px] text-gray-600 italic mb-4 leading-snug">{t("cursorPathsNote")}</p> <p className="text-[11px] text-fg-muted italic mb-4 leading-snug">{t("cursorPathsNote")}</p>
<div className="card p-5 space-y-4"> <div className="card p-5 space-y-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -1369,7 +1375,7 @@ export function Settings() {
setClaudeHomeInput(e.target.value); setClaudeHomeInput(e.target.value);
setClaudeHomeError(null); setClaudeHomeError(null);
}} }}
className="flex-1 bg-surface-4 border border-surface-3 rounded-lg px-3 py-2 text-sm text-gray-200 font-mono focus:outline-none focus:border-violet-500/50" className="flex-1 bg-surface-4 border border-surface-3 rounded-lg px-3 py-2 text-sm text-fg-secondary font-mono focus:outline-none focus:border-violet-500/50"
placeholder={t("claudeHome.placeholder")} placeholder={t("claudeHome.placeholder")}
/> />
<button <button
@@ -1380,10 +1386,10 @@ export function Settings() {
{claudeHomeSaving ? t("claudeHome.saving") : t("claudeHome.save")} {claudeHomeSaving ? t("claudeHome.saving") : t("claudeHome.save")}
</button> </button>
</div> </div>
{claudeHomeError && <p className="text-xs text-red-400">{claudeHomeError}</p>} {claudeHomeError && <p className="text-xs text-status-danger">{claudeHomeError}</p>}
{claudeHome && ( {claudeHome && (
<p className="text-xs text-gray-500"> <p className="text-xs text-fg-muted">
{t("claudeHome.current")} <code className="text-gray-400">{claudeHome}</code> {t("claudeHome.current")} <code className="text-fg-secondary">{claudeHome}</code>
</p> </p>
)} )}
</div> </div>
@@ -1401,13 +1407,13 @@ export function Settings() {
{/* ─── TABBY COMPANION ─── */} {/* ─── TABBY COMPANION ─── */}
<section id="tabby" className="scroll-mt-24"> <section id="tabby" className="scroll-mt-24">
<h3 className="text-sm font-medium text-gray-300 flex items-center gap-2 mb-1"> <h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
<span className="text-base leading-none" aria-hidden> <span className="text-base leading-none" aria-hidden>
🐾 🐾
</span> </span>
{t("tabby.title", "Tabby companion")} {t("tabby.title", "Tabby companion")}
</h3> </h3>
<p className="text-xs text-gray-500 mb-4"> <p className="text-xs text-fg-muted mb-4">
{t("tabby.description", "A floating cat that reacts to your live sessions.")} {t("tabby.description", "A floating cat that reacts to your live sessions.")}
</p> </p>
@@ -1416,7 +1422,7 @@ export function Settings() {
<div <div
className={`w-10 h-10 rounded-xl flex items-center justify-center transition-colors ${ className={`w-10 h-10 rounded-xl flex items-center justify-center transition-colors ${
tabbyEnabled tabbyEnabled
? "bg-blue-500/10 border border-blue-500/20" ? "bg-blue-600/10 border border-blue-600/20"
: "bg-surface-2 border border-border" : "bg-surface-2 border border-border"
}`} }`}
> >
@@ -1439,11 +1445,11 @@ export function Settings() {
{/* ─── NOTIFICATIONS ─── */} {/* ─── NOTIFICATIONS ─── */}
<section id="notifications" className="scroll-mt-24"> <section id="notifications" className="scroll-mt-24">
<h3 className="text-sm font-medium text-gray-300 flex items-center gap-2 mb-1"> <h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
<Bell className="w-4 h-4 text-gray-500" /> <Bell className="w-4 h-4 text-fg-muted" />
{t("notifications.title")} {t("notifications.title")}
</h3> </h3>
<p className="text-xs text-gray-500 mb-4">{t("notifications.description")}</p> <p className="text-xs text-fg-muted mb-4">{t("notifications.description")}</p>
<div className="card p-5 space-y-5"> <div className="card p-5 space-y-5">
<div className="flex items-center justify-between flex-wrap gap-3"> <div className="flex items-center justify-between flex-wrap gap-3">
@@ -1451,14 +1457,14 @@ export function Settings() {
<div <div
className={`w-10 h-10 rounded-xl flex items-center justify-center transition-colors ${ className={`w-10 h-10 rounded-xl flex items-center justify-center transition-colors ${
notifPrefs.enabled notifPrefs.enabled
? "bg-blue-500/10 border border-blue-500/20" ? "bg-blue-600/10 border border-blue-600/20"
: "bg-surface-2 border border-border" : "bg-surface-2 border border-border"
}`} }`}
> >
{notifPrefs.enabled ? ( {notifPrefs.enabled ? (
<BellRing className="w-5 h-5 text-blue-400" /> <BellRing className="w-5 h-5 text-blue-500" />
) : ( ) : (
<BellOff className="w-5 h-5 text-gray-500" /> <BellOff className="w-5 h-5 text-fg-muted" />
)} )}
</div> </div>
<Toggle <Toggle
@@ -1483,10 +1489,10 @@ export function Settings() {
<span <span
className={`inline-flex items-center gap-1.5 text-xs font-medium px-2.5 py-1 rounded-full ${ className={`inline-flex items-center gap-1.5 text-xs font-medium px-2.5 py-1 rounded-full ${
Notification.permission === "granted" Notification.permission === "granted"
? "text-emerald-400 bg-emerald-500/10 border border-emerald-500/20" ? "text-status-success bg-status-success/10 border border-status-success/20"
: Notification.permission === "denied" : Notification.permission === "denied"
? "text-red-400 bg-red-500/10 border border-red-500/20" ? "text-status-danger bg-status-danger/10 border border-status-danger/20"
: "text-amber-400 bg-amber-500/10 border border-amber-500/20" : "text-status-warning bg-status-warning/10 border border-status-warning/20"
}`} }`}
> >
{Notification.permission === "granted" ? ( {Notification.permission === "granted" ? (
@@ -1507,12 +1513,12 @@ export function Settings() {
{notifPrefs.enabled && ( {notifPrefs.enabled && (
<div className="space-y-3 pt-4 border-t border-border"> <div className="space-y-3 pt-4 border-t border-border">
<p className="text-xs text-gray-500 uppercase tracking-wider font-semibold"> <p className="text-xs text-fg-muted uppercase tracking-wider font-semibold">
{t("notifications.notifyWhen")} {t("notifications.notifyWhen")}
</p> </p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<div className="flex items-center gap-3 bg-surface-2 rounded-lg px-3.5 py-3"> <div className="flex items-center gap-3 bg-surface-2 rounded-lg px-3.5 py-3">
<Play className="w-4 h-4 text-emerald-400 flex-shrink-0" /> <Play className="w-4 h-4 text-status-success flex-shrink-0" />
<Toggle <Toggle
checked={notifPrefs.onNewSession} checked={notifPrefs.onNewSession}
onChange={(v) => updateNotifPrefs({ onNewSession: v })} onChange={(v) => updateNotifPrefs({ onNewSession: v })}
@@ -1528,7 +1534,7 @@ export function Settings() {
/> />
</div> </div>
<div className="flex items-center gap-3 bg-surface-2 rounded-lg px-3.5 py-3"> <div className="flex items-center gap-3 bg-surface-2 rounded-lg px-3.5 py-3">
<AlertCircle className="w-4 h-4 text-red-400 flex-shrink-0" /> <AlertCircle className="w-4 h-4 text-status-danger flex-shrink-0" />
<Toggle <Toggle
checked={notifPrefs.onSessionError} checked={notifPrefs.onSessionError}
onChange={(v) => updateNotifPrefs({ onSessionError: v })} onChange={(v) => updateNotifPrefs({ onSessionError: v })}
@@ -1536,7 +1542,7 @@ export function Settings() {
/> />
</div> </div>
<div className="flex items-center gap-3 bg-surface-2 rounded-lg px-3.5 py-3"> <div className="flex items-center gap-3 bg-surface-2 rounded-lg px-3.5 py-3">
<GitBranch className="w-4 h-4 text-blue-400 flex-shrink-0" /> <GitBranch className="w-4 h-4 text-blue-500 flex-shrink-0" />
<Toggle <Toggle
checked={notifPrefs.onSubagentSpawn} checked={notifPrefs.onSubagentSpawn}
onChange={(v) => updateNotifPrefs({ onSubagentSpawn: v })} onChange={(v) => updateNotifPrefs({ onSubagentSpawn: v })}
@@ -1559,7 +1565,7 @@ export function Settings() {
}), }),
}); });
}} }}
className="inline-flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-md text-gray-400 hover:text-gray-200 hover:bg-surface-4 border border-border transition-colors" className="inline-flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-md text-fg-secondary hover:text-fg-primary hover:bg-surface-4 border border-border transition-colors"
> >
<Zap className="w-3 h-3" /> <Zap className="w-3 h-3" />
{t("notifications.sendTest")} {t("notifications.sendTest")}
@@ -1569,7 +1575,7 @@ export function Settings() {
)} )}
{!notifPrefs.enabled && ( {!notifPrefs.enabled && (
<div className="flex items-center gap-2 text-xs text-gray-500"> <div className="flex items-center gap-2 text-xs text-fg-muted">
<BellOff className="w-3.5 h-3.5" /> <BellOff className="w-3.5 h-3.5" />
{t("notifications.disabledInfo")} {t("notifications.disabledInfo")}
</div> </div>
@@ -1579,30 +1585,30 @@ export function Settings() {
{/* ─── ALERTS ─── */} {/* ─── ALERTS ─── */}
<section id="alerts" className="scroll-mt-24"> <section id="alerts" className="scroll-mt-24">
<h3 className="text-sm font-medium text-gray-300 flex items-center gap-2 mb-1"> <h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
<BellRing className="w-4 h-4 text-gray-500" /> <BellRing className="w-4 h-4 text-fg-muted" />
{t("alertsHub.title")} {t("alertsHub.title")}
</h3> </h3>
<p className="text-xs text-gray-500 mb-4">{t("alertsHub.description")}</p> <p className="text-xs text-fg-muted mb-4">{t("alertsHub.description")}</p>
<AlertsNotifications /> <AlertsNotifications />
</section> </section>
{/* ─── DATA MANAGEMENT ─── */} {/* ─── DATA MANAGEMENT ─── */}
<section id="data" className="scroll-mt-24"> <section id="data" className="scroll-mt-24">
<h3 className="text-sm font-medium text-gray-300 flex items-center gap-2 mb-1"> <h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
<Database className="w-4 h-4 text-gray-500" /> <Database className="w-4 h-4 text-fg-muted" />
{t("data.title")} {t("data.title")}
</h3> </h3>
<p className="text-xs text-gray-500 mb-4">{t("data.description")}</p> <p className="text-xs text-fg-muted mb-4">{t("data.description")}</p>
<div className="space-y-4"> <div className="space-y-4">
<div className="card p-5 space-y-4"> <div className="card p-5 space-y-4">
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-xs text-gray-500 uppercase tracking-wider font-semibold flex-shrink-0"> <p className="text-xs text-fg-muted uppercase tracking-wider font-semibold flex-shrink-0">
{t("data.dbOverview")} {t("data.dbOverview")}
</p> </p>
{sysInfo && ( {sysInfo && (
<div className="flex items-center gap-1.5 text-[11px] text-gray-600 font-mono bg-surface-2 px-2.5 py-1 rounded-md min-w-0"> <div className="flex items-center gap-1.5 text-[11px] text-fg-muted font-mono bg-surface-2 px-2.5 py-1 rounded-md min-w-0">
<HardDrive className="w-3 h-3 flex-shrink-0" /> <HardDrive className="w-3 h-3 flex-shrink-0" />
<span className="truncate">{sysInfo.db.path}</span> <span className="truncate">{sysInfo.db.path}</span>
</div> </div>
@@ -1613,10 +1619,10 @@ export function Settings() {
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-2"> <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-2">
{(() => { {(() => {
const tableIcons: Record<string, React.ReactNode> = { const tableIcons: Record<string, React.ReactNode> = {
sessions: <Layers className="w-4 h-4 text-blue-400" />, sessions: <Layers className="w-4 h-4 text-blue-500" />,
agents: <Users className="w-4 h-4 text-emerald-400" />, agents: <Users className="w-4 h-4 text-status-success" />,
events: <Activity className="w-4 h-4 text-violet-400" />, events: <Activity className="w-4 h-4 text-violet-400" />,
token_usage: <Coins className="w-4 h-4 text-amber-400" />, token_usage: <Coins className="w-4 h-4 text-status-warning" />,
model_pricing: <BarChart3 className="w-4 h-4 text-cyan-400" />, model_pricing: <BarChart3 className="w-4 h-4 text-cyan-400" />,
}; };
const tableLabels: Record<string, string> = { const tableLabels: Record<string, string> = {
@@ -1627,24 +1633,24 @@ export function Settings() {
model_pricing: t("tables.pricingRules"), model_pricing: t("tables.pricingRules"),
}; };
const tableColors: Record<string, string> = { const tableColors: Record<string, string> = {
sessions: "border-blue-500/20", sessions: "border-blue-600/20",
agents: "border-emerald-500/20", agents: "border-status-success/20",
events: "border-violet-500/20", events: "border-violet-500/20",
token_usage: "border-amber-500/20", token_usage: "border-status-warning/20",
model_pricing: "border-cyan-500/20", model_pricing: "border-cyan-500/20",
}; };
return Object.entries(sysInfo.db.counts).map(([table, count]) => ( return Object.entries(sysInfo.db.counts).map(([table, count]) => (
<div <div
key={table} key={table}
className={`bg-surface-2 rounded-lg px-3 py-3 border-l-2 ${tableColors[table] || "border-gray-500/20"}`} className={`bg-surface-2 rounded-lg px-3 py-3 border-l-2 ${tableColors[table] || "border-border-light/20"}`}
> >
<div className="flex items-center gap-2 mb-1.5"> <div className="flex items-center gap-2 mb-1.5">
{tableIcons[table] || <Database className="w-4 h-4 text-gray-500" />} {tableIcons[table] || <Database className="w-4 h-4 text-fg-muted" />}
<p className="text-[11px] text-gray-500 uppercase tracking-wider"> <p className="text-[11px] text-fg-muted uppercase tracking-wider">
{tableLabels[table] || table.replace(/_/g, " ")} {tableLabels[table] || table.replace(/_/g, " ")}
</p> </p>
</div> </div>
<p className="text-xl font-semibold text-gray-200"> <p className="text-xl font-semibold text-fg-secondary">
<Tip raw={count.toLocaleString()}>{fmt(count)}</Tip> <Tip raw={count.toLocaleString()}>{fmt(count)}</Tip>
</p> </p>
</div> </div>
@@ -1653,35 +1659,37 @@ export function Settings() {
<div className="bg-surface-2 rounded-lg px-3 py-3 border-l-2 border-indigo-500/20"> <div className="bg-surface-2 rounded-lg px-3 py-3 border-l-2 border-indigo-500/20">
<div className="flex items-center gap-2 mb-1.5"> <div className="flex items-center gap-2 mb-1.5">
<HardDrive className="w-4 h-4 text-indigo-400" /> <HardDrive className="w-4 h-4 text-indigo-400" />
<p className="text-[11px] text-gray-500 uppercase tracking-wider"> <p className="text-[11px] text-fg-muted uppercase tracking-wider">
{t("data.dbSize")} {t("data.dbSize")}
</p> </p>
</div> </div>
<p className="text-xl font-semibold text-gray-200"> <p className="text-xl font-semibold text-fg-secondary">
{formatBytes(sysInfo.db.size)} {formatBytes(sysInfo.db.size)}
</p> </p>
</div> </div>
</div> </div>
) : ( ) : (
<p className="text-xs text-gray-500">{t("data.loadingDb")}</p> <p className="text-xs text-fg-muted">{t("data.loadingDb")}</p>
)} )}
</div> </div>
{/* Session Cleanup */} {/* Session Cleanup */}
<div className="card p-5 space-y-4"> <div className="card p-5 space-y-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-lg bg-amber-500/10 border border-amber-500/20 flex items-center justify-center"> <div className="w-9 h-9 rounded-lg bg-status-warning/10 border border-status-warning/20 flex items-center justify-center">
<Eraser className="w-4 h-4 text-amber-400" /> <Eraser className="w-4 h-4 text-status-warning" />
</div> </div>
<div> <div>
<p className="text-sm font-medium text-gray-300">{t("data.sessionCleanup")}</p> <p className="text-sm font-medium text-fg-secondary">{t("data.sessionCleanup")}</p>
<p className="text-xs text-gray-500">{t("data.cleanupDesc")}</p> <p className="text-xs text-fg-muted">{t("data.cleanupDesc")}</p>
</div> </div>
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="bg-surface-2 rounded-lg px-4 py-3"> <div className="bg-surface-2 rounded-lg px-4 py-3">
<label className="text-xs text-gray-400 block mb-2">{t("data.abandonAfter")}</label> <label className="text-xs text-fg-secondary block mb-2">
{t("data.abandonAfter")}
</label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <input
type="number" type="number"
@@ -1690,11 +1698,13 @@ export function Settings() {
onChange={(e) => setAbandonHours(e.target.value)} onChange={(e) => setAbandonHours(e.target.value)}
className="input w-20 text-sm text-right font-mono" className="input w-20 text-sm text-right font-mono"
/> />
<span className="text-xs text-gray-500">{t("common:hours")}</span> <span className="text-xs text-fg-muted">{t("common:hours")}</span>
</div> </div>
</div> </div>
<div className="bg-surface-2 rounded-lg px-4 py-3"> <div className="bg-surface-2 rounded-lg px-4 py-3">
<label className="text-xs text-gray-400 block mb-2">{t("data.purgeAfter")}</label> <label className="text-xs text-fg-secondary block mb-2">
{t("data.purgeAfter")}
</label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <input
type="number" type="number"
@@ -1703,7 +1713,7 @@ export function Settings() {
onChange={(e) => setPurgeDays(e.target.value)} onChange={(e) => setPurgeDays(e.target.value)}
className="input w-20 text-sm text-right font-mono" className="input w-20 text-sm text-right font-mono"
/> />
<span className="text-xs text-gray-500">{t("common:days")}</span> <span className="text-xs text-fg-muted">{t("common:days")}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -1715,8 +1725,8 @@ export function Settings() {
disabled={actionLoading !== null} disabled={actionLoading !== null}
className={`text-xs px-3 py-1.5 rounded-md transition-colors disabled:opacity-50 ${ className={`text-xs px-3 py-1.5 rounded-md transition-colors disabled:opacity-50 ${
confirmAction === "cleanup" confirmAction === "cleanup"
? "bg-amber-500/20 text-amber-400 border border-amber-500/30" ? "bg-status-warning/20 text-status-warning border border-status-warning/30"
: "text-gray-400 hover:text-gray-300 hover:bg-surface-4 border border-border" : "text-fg-secondary hover:text-fg-primary hover:bg-surface-4 border border-border"
}`} }`}
> >
{actionLoading === "cleanup" ? ( {actionLoading === "cleanup" ? (
@@ -1731,25 +1741,25 @@ export function Settings() {
</div> </div>
{/* Danger zone */} {/* Danger zone */}
<div className="card p-5 space-y-4 border-red-500/10"> <div className="card p-5 space-y-4 border-status-danger/10">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-lg bg-red-500/10 border border-red-500/20 flex items-center justify-center"> <div className="w-9 h-9 rounded-lg bg-status-danger/10 border border-status-danger/20 flex items-center justify-center">
<AlertTriangle className="w-4 h-4 text-red-400" /> <AlertTriangle className="w-4 h-4 text-status-danger" />
</div> </div>
<div> <div>
<p className="text-sm font-medium text-red-400">{t("danger.title")}</p> <p className="text-sm font-medium text-status-danger">{t("danger.title")}</p>
<p className="text-xs text-gray-500">{t("danger.description")}</p> <p className="text-xs text-fg-muted">{t("danger.description")}</p>
</div> </div>
</div> </div>
{confirmAction === "clear" ? ( {confirmAction === "clear" ? (
<div className="bg-red-500/5 border border-red-500/20 rounded-lg px-4 py-3 flex items-center justify-between flex-wrap gap-3"> <div className="bg-status-danger/5 border border-status-danger/20 rounded-lg px-4 py-3 flex items-center justify-between flex-wrap gap-3">
<span className="text-xs text-amber-400">{t("danger.warning")}</span> <span className="text-xs text-status-warning">{t("danger.warning")}</span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
onClick={handleClearData} onClick={handleClearData}
disabled={actionLoading !== null} disabled={actionLoading !== null}
className="text-xs px-3 py-1.5 rounded-md bg-red-500/20 text-red-400 border border-red-500/30 hover:bg-red-500/30 transition-colors disabled:opacity-50" className="text-xs px-3 py-1.5 rounded-md bg-status-danger/20 text-status-danger border border-status-danger/30 hover:bg-status-danger/30 transition-colors disabled:opacity-50"
> >
{actionLoading === "clear" ? ( {actionLoading === "clear" ? (
<RefreshCw className="w-3.5 h-3.5 animate-spin inline mr-1" /> <RefreshCw className="w-3.5 h-3.5 animate-spin inline mr-1" />
@@ -1758,7 +1768,7 @@ export function Settings() {
</button> </button>
<button <button
onClick={() => setConfirmAction(null)} onClick={() => setConfirmAction(null)}
className="text-xs px-3 py-1.5 rounded-md text-gray-400 hover:bg-surface-4 transition-colors" className="text-xs px-3 py-1.5 rounded-md text-fg-secondary hover:bg-surface-4 transition-colors"
> >
{t("common:cancel")} {t("common:cancel")}
</button> </button>
@@ -1768,7 +1778,7 @@ export function Settings() {
<button <button
onClick={() => setConfirmAction("clear")} onClick={() => setConfirmAction("clear")}
disabled={actionLoading !== null} disabled={actionLoading !== null}
className="text-xs px-3 py-1.5 rounded-md text-red-400 hover:bg-red-500/10 border border-red-500/20 transition-colors disabled:opacity-50" className="text-xs px-3 py-1.5 rounded-md text-status-danger hover:bg-status-danger/10 border border-status-danger/20 transition-colors disabled:opacity-50"
> >
<AlertTriangle className="w-3.5 h-3.5 inline mr-1" /> <AlertTriangle className="w-3.5 h-3.5 inline mr-1" />
{t("danger.clearAllData")} {t("danger.clearAllData")}
@@ -1782,11 +1792,11 @@ export function Settings() {
{/* ─── ABOUT ─── */} {/* ─── ABOUT ─── */}
<section id="about" className="scroll-mt-24"> <section id="about" className="scroll-mt-24">
<h3 className="text-sm font-medium text-gray-300 flex items-center gap-2 mb-1"> <h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
<Server className="w-4 h-4 text-gray-500" /> <Server className="w-4 h-4 text-fg-muted" />
{t("about.title")} {t("about.title")}
</h3> </h3>
<p className="text-xs text-gray-500 mb-4">{t("about.description")}</p> <p className="text-xs text-fg-muted mb-4">{t("about.description")}</p>
{sysInfo ? ( {sysInfo ? (
<div className="card p-5"> <div className="card p-5">
@@ -1794,65 +1804,65 @@ export function Settings() {
<div className="bg-surface-2 rounded-lg px-4 py-3"> <div className="bg-surface-2 rounded-lg px-4 py-3">
<div className="flex items-center gap-2 mb-1.5"> <div className="flex items-center gap-2 mb-1.5">
<Server className="w-4 h-4 text-indigo-400" /> <Server className="w-4 h-4 text-indigo-400" />
<p className="text-[11px] text-gray-500 uppercase tracking-wider"> <p className="text-[11px] text-fg-muted uppercase tracking-wider">
{t("about.release")} {t("about.release")}
</p> </p>
</div> </div>
<p className="text-sm font-semibold text-gray-200 font-mono"> <p className="text-sm font-semibold text-fg-secondary font-mono">
v{sysInfo.server.version} v{sysInfo.server.version}
</p> </p>
{sysInfo.server.version !== __APP_VERSION__ && ( {sysInfo.server.version !== __APP_VERSION__ && (
<p className="text-[10px] text-amber-400/90 mt-1"> <p className="text-[10px] text-status-warning/90 mt-1">
{t("about.uiBuild", { version: __APP_VERSION__ })} {t("about.uiBuild", { version: __APP_VERSION__ })}
</p> </p>
)} )}
</div> </div>
<div className="bg-surface-2 rounded-lg px-4 py-3"> <div className="bg-surface-2 rounded-lg px-4 py-3">
<div className="flex items-center gap-2 mb-1.5"> <div className="flex items-center gap-2 mb-1.5">
<Clock className="w-4 h-4 text-blue-400" /> <Clock className="w-4 h-4 text-blue-500" />
<p className="text-[11px] text-gray-500 uppercase tracking-wider"> <p className="text-[11px] text-fg-muted uppercase tracking-wider">
{t("about.uptime")} {t("about.uptime")}
</p> </p>
</div> </div>
<p className="text-sm font-semibold text-gray-200"> <p className="text-sm font-semibold text-fg-secondary">
{formatUptime(sysInfo.server.uptime)} {formatUptime(sysInfo.server.uptime)}
</p> </p>
</div> </div>
<div className="bg-surface-2 rounded-lg px-4 py-3"> <div className="bg-surface-2 rounded-lg px-4 py-3">
<div className="flex items-center gap-2 mb-1.5"> <div className="flex items-center gap-2 mb-1.5">
<Cpu className="w-4 h-4 text-emerald-400" /> <Cpu className="w-4 h-4 text-status-success" />
<p className="text-[11px] text-gray-500 uppercase tracking-wider"> <p className="text-[11px] text-fg-muted uppercase tracking-wider">
{t("about.nodejs")} {t("about.nodejs")}
</p> </p>
</div> </div>
<p className="text-sm font-semibold text-gray-200 font-mono"> <p className="text-sm font-semibold text-fg-secondary font-mono">
{sysInfo.server.node_version} {sysInfo.server.node_version}
</p> </p>
</div> </div>
<div className="bg-surface-2 rounded-lg px-4 py-3"> <div className="bg-surface-2 rounded-lg px-4 py-3">
<div className="flex items-center gap-2 mb-1.5"> <div className="flex items-center gap-2 mb-1.5">
<Globe className="w-4 h-4 text-violet-400" /> <Globe className="w-4 h-4 text-violet-400" />
<p className="text-[11px] text-gray-500 uppercase tracking-wider"> <p className="text-[11px] text-fg-muted uppercase tracking-wider">
{t("about.platform")} {t("about.platform")}
</p> </p>
</div> </div>
<p className="text-sm font-semibold text-gray-200">{sysInfo.server.platform}</p> <p className="text-sm font-semibold text-fg-secondary">{sysInfo.server.platform}</p>
</div> </div>
<div className="bg-surface-2 rounded-lg px-4 py-3"> <div className="bg-surface-2 rounded-lg px-4 py-3">
<div className="flex items-center gap-2 mb-1.5"> <div className="flex items-center gap-2 mb-1.5">
<Wifi className="w-4 h-4 text-amber-400" /> <Wifi className="w-4 h-4 text-status-warning" />
<p className="text-[11px] text-gray-500 uppercase tracking-wider"> <p className="text-[11px] text-fg-muted uppercase tracking-wider">
{t("about.wsClients")} {t("about.wsClients")}
</p> </p>
</div> </div>
<p className="text-sm font-semibold text-gray-200"> <p className="text-sm font-semibold text-fg-secondary">
{sysInfo.server.ws_connections} {sysInfo.server.ws_connections}
</p> </p>
</div> </div>
</div> </div>
</div> </div>
) : ( ) : (
<p className="text-xs text-gray-500">{t("about.loadingInfo")}</p> <p className="text-xs text-fg-muted">{t("about.loadingInfo")}</p>
)} )}
</section> </section>
</div> </div>
+29 -27
View File
@@ -182,8 +182,8 @@ export function Workflows() {
lastUpdated={null} lastUpdated={null}
/> />
<div className="card flex flex-col items-center justify-center py-16 gap-4"> <div className="card flex flex-col items-center justify-center py-16 gap-4">
<AlertCircle className="w-10 h-10 text-red-400" /> <AlertCircle className="w-10 h-10 text-status-danger" />
<p className="text-red-400 text-sm">{error}</p> <p className="text-status-danger text-sm">{error}</p>
<button onClick={handleRefresh} className="btn-primary text-sm"> <button onClick={handleRefresh} className="btn-primary text-sm">
{t("common:retry")} {t("common:retry")}
</button> </button>
@@ -211,11 +211,11 @@ export function Workflows() {
{/* Workflow-tool runs (issue #167) - fleets ingested from on-disk journals */} {/* Workflow-tool runs (issue #167) - fleets ingested from on-disk journals */}
<div className="card p-4 space-y-3"> <div className="card p-4 space-y-3">
<div> <div>
<h2 className="text-sm font-semibold text-gray-200 flex items-center gap-2"> <h2 className="text-sm font-semibold text-fg-secondary flex items-center gap-2">
<Workflow className="w-4 h-4 text-violet-400" /> <Workflow className="w-4 h-4 text-violet-400" />
{t("runs.title")} {t("runs.title")}
</h2> </h2>
<p className="text-xs text-gray-500 mt-0.5">{t("runs.subtitle")}</p> <p className="text-xs text-fg-muted mt-0.5">{t("runs.subtitle")}</p>
</div> </div>
<WorkflowRunsPanel statusFilter={statusFilter} /> <WorkflowRunsPanel statusFilter={statusFilter} />
</div> </div>
@@ -234,13 +234,13 @@ export function Workflows() {
/> />
{selectedNode && ( {selectedNode && (
<div className="mt-3 flex items-center gap-2"> <div className="mt-3 flex items-center gap-2">
<span className="text-xs text-gray-500">{t("filteredBy")}</span> <span className="text-xs text-fg-muted">{t("filteredBy")}</span>
<span className="badge bg-accent/15 text-accent border border-accent/20 text-xs"> <span className="badge bg-accent/15 text-accent border border-accent/20 text-xs">
{selectedNode} {selectedNode}
</span> </span>
<button <button
onClick={() => setSelectedNode(null)} onClick={() => setSelectedNode(null)}
className="text-xs text-gray-500 hover:text-gray-300 underline" className="text-xs text-fg-muted hover:text-fg-secondary underline"
> >
{t("clearFilter")} {t("clearFilter")}
</button> </button>
@@ -384,14 +384,14 @@ function Section({
<span className="w-5 h-5 rounded-md bg-accent/15 text-accent text-[11px] font-bold flex items-center justify-center flex-shrink-0"> <span className="w-5 h-5 rounded-md bg-accent/15 text-accent text-[11px] font-bold flex items-center justify-center flex-shrink-0">
{number} {number}
</span> </span>
<h2 className="text-sm font-semibold text-gray-100">{title}</h2> <h2 className="text-sm font-semibold text-fg-primary">{title}</h2>
<ChartInfoPopover infoKey={infoKey} title={title} /> <ChartInfoPopover infoKey={infoKey} title={title} />
</div> </div>
{/* Quick descriptor; the full explanation lives in the popover, so we {/* Quick descriptor; the full explanation lives in the popover, so we
keep this to a single clamped line (ellipsis + hover title) so a long keep this to a single clamped line (ellipsis + hover title) so a long
translation never wraps and unbalances the header row. */} translation never wraps and unbalances the header row. */}
<span <span
className="hidden lg:block flex-shrink-0 max-w-[20rem] xl:max-w-sm truncate text-right text-[11px] text-gray-600" className="hidden lg:block flex-shrink-0 max-w-[20rem] xl:max-w-sm truncate text-right text-[11px] text-fg-muted"
title={subtitle} title={subtitle}
> >
{subtitle} {subtitle}
@@ -477,7 +477,7 @@ function ChartInfoPopover({ infoKey, title }: { infoKey: string; title: string }
onMouseLeave={() => setOpen(false)} onMouseLeave={() => setOpen(false)}
onFocus={() => setOpen(true)} onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)} onBlur={() => setOpen(false)}
className="flex items-center justify-center rounded-full p-0.5 -m-0.5 text-gray-600 hover:text-gray-400 transition-colors focus:outline-none focus:ring-1 focus:ring-accent/40" className="flex items-center justify-center rounded-full p-0.5 -m-0.5 text-fg-muted hover:text-fg-secondary transition-colors focus:outline-none focus:ring-1 focus:ring-accent/40"
> >
<Info className="w-3.5 h-3.5" /> <Info className="w-3.5 h-3.5" />
</button> </button>
@@ -485,27 +485,29 @@ function ChartInfoPopover({ infoKey, title }: { infoKey: string; title: string }
<div <div
ref={popoverRef} ref={popoverRef}
role="tooltip" role="tooltip"
className="fixed z-50 p-3.5 bg-[#12121f] border border-[#2a2a4a] rounded-lg shadow-2xl text-[11px] text-gray-300 pointer-events-none" className="fixed z-50 p-3.5 bg-[#12121f] border border-[#2a2a4a] rounded-lg shadow-2xl text-[11px] text-fg-secondary pointer-events-none"
style={{ left: coords.left, top: coords.top, width: POPOVER_W }} style={{ left: coords.left, top: coords.top, width: POPOVER_W }}
> >
<p className="text-xs font-semibold text-gray-100 mb-2.5 pb-2 border-b border-[#2a2a4a]"> <p className="text-xs font-semibold text-fg-primary mb-2.5 pb-2 border-b border-[#2a2a4a]">
{title} {title}
</p> </p>
<p className="font-semibold text-gray-200 uppercase tracking-wider text-[9px] mb-1"> <p className="font-semibold text-fg-secondary uppercase tracking-wider text-[9px] mb-1">
{t("chartInfo.labels.what")} {t("chartInfo.labels.what")}
</p> </p>
<p className="text-gray-400 leading-snug mb-2.5">{t(`chartInfo.${infoKey}.what`)}</p> <p className="text-fg-secondary leading-snug mb-2.5">{t(`chartInfo.${infoKey}.what`)}</p>
<p className="font-semibold text-gray-200 uppercase tracking-wider text-[9px] mb-1"> <p className="font-semibold text-fg-secondary uppercase tracking-wider text-[9px] mb-1">
{t("chartInfo.labels.howToRead")} {t("chartInfo.labels.howToRead")}
</p> </p>
<p className="text-gray-400 leading-snug mb-2.5">{t(`chartInfo.${infoKey}.howToRead`)}</p> <p className="text-fg-secondary leading-snug mb-2.5">
{t(`chartInfo.${infoKey}.howToRead`)}
</p>
<p className="font-semibold text-gray-200 uppercase tracking-wider text-[9px] mb-1"> <p className="font-semibold text-fg-secondary uppercase tracking-wider text-[9px] mb-1">
{t("chartInfo.labels.why")} {t("chartInfo.labels.why")}
</p> </p>
<p className="text-gray-400 leading-snug">{t(`chartInfo.${infoKey}.why`)}</p> <p className="text-fg-secondary leading-snug">{t(`chartInfo.${infoKey}.why`)}</p>
</div> </div>
)} )}
</> </>
@@ -542,20 +544,20 @@ function PageHeader({
</div> </div>
<div> <div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h1 className="text-lg font-semibold text-gray-100">{t("title")}</h1> <h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1>
{wsConnected ? ( {wsConnected ? (
<span className="flex items-center gap-1.5 text-[11px] text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 px-2 py-0.5 rounded-full"> <span className="flex items-center gap-1.5 text-[11px] text-status-success bg-status-success/10 border border-status-success/20 px-2 py-0.5 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse-dot" /> <span className="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse-dot" />
{t("common:live")} {t("common:live")}
</span> </span>
) : ( ) : (
<span className="flex items-center gap-1.5 text-[11px] text-gray-400 bg-gray-500/10 border border-gray-500/20 px-2 py-0.5 rounded-full"> <span className="flex items-center gap-1.5 text-[11px] text-fg-secondary bg-surface-4/10 border border-border-light/20 px-2 py-0.5 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-gray-400" /> <span className="w-1.5 h-1.5 rounded-full bg-surface-4" />
{t("common:offline")} {t("common:offline")}
</span> </span>
)} )}
</div> </div>
<p className="text-xs text-gray-500">{t("subtitle")}</p> <p className="text-xs text-fg-muted">{t("subtitle")}</p>
</div> </div>
</div> </div>
@@ -569,7 +571,7 @@ function PageHeader({
className={`px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${ className={`px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
statusFilter === f.value statusFilter === f.value
? "bg-accent/15 text-accent" ? "bg-accent/15 text-accent"
: "text-gray-500 hover:text-gray-300" : "text-fg-muted hover:text-fg-secondary"
}`} }`}
> >
{f.label} {f.label}
@@ -580,21 +582,21 @@ function PageHeader({
{/* Actions */} {/* Actions */}
<button <button
onClick={onRefresh} onClick={onRefresh}
className="p-2 rounded-lg text-gray-500 hover:text-gray-300 hover:bg-surface-3 transition-colors" className="p-2 rounded-lg text-fg-muted hover:text-fg-secondary hover:bg-surface-3 transition-colors"
title={t("refreshData")} title={t("refreshData")}
> >
<RefreshCw className="w-4 h-4" /> <RefreshCw className="w-4 h-4" />
</button> </button>
<button <button
onClick={onExport} onClick={onExport}
className="p-2 rounded-lg text-gray-500 hover:text-gray-300 hover:bg-surface-3 transition-colors" className="p-2 rounded-lg text-fg-muted hover:text-fg-secondary hover:bg-surface-3 transition-colors"
title={t("exportJson")} title={t("exportJson")}
> >
<Download className="w-4 h-4" /> <Download className="w-4 h-4" />
</button> </button>
{lastUpdated && ( {lastUpdated && (
<span className="text-[10px] text-gray-600 ml-1"> <span className="text-[10px] text-fg-muted ml-1">
{t("common:updated")} {t("common:updated")}
{lastUpdated.toLocaleTimeString()} {lastUpdated.toLocaleTimeString()}
</span> </span>
+130 -116
View File
@@ -267,6 +267,19 @@ export function Workspace() {
}); });
}, [refreshLanes]); }, [refreshLanes]);
// A reconnect (e.g. the dashboard server restarting) resumes the WS but does
// not replay missed lane_update diffs, so a lane whose fields changed while
// disconnected — status, needs_action — would keep showing its pre-restart
// badges forever with no further server-side change to broadcast. Refetch
// the full list whenever the socket comes back up.
useEffect(
() =>
eventBus.onConnection((isConnected) => {
if (isConnected) void refreshLanes();
}),
[refreshLanes]
);
const refreshList = useCallback(() => { const refreshList = useCallback(() => {
api.run api.run
.list() .list()
@@ -782,96 +795,95 @@ export function Workspace() {
const consoleSection = ( const consoleSection = (
<> <>
{/* Always attached under the pipeline - no header, no collapse. The {/* Always attached under the pipeline - no header, no collapse. The
pipeline panel above already names the lane; unmounting RunConsole pipeline panel above already names the lane; unmounting RunConsole
would throw away a live run's rendered history and scroll would throw away a live run's rendered history and scroll
position, so this stays mounted for the page's whole life. */} position, so this stays mounted for the page's whole life. */}
<div data-testid="console-body" className="flex min-h-0 flex-1 flex-col gap-5"> <div data-testid="console-body" className="flex min-h-0 flex-1 flex-col gap-5">
<Header <Header
activeRuns={activeRuns} activeRuns={activeRuns}
currentHandleId={handle?.id || null} currentHandleId={handle?.id || null}
onAttach={attachToRun} onAttach={attachToRun}
wsConnected={wsConnected} wsConnected={wsConnected}
runHistory={runHistory} runHistory={runHistory}
onResumeFromHistory={onResumeFromHistory} onResumeFromHistory={onResumeFromHistory}
onViewFromHistory={onViewFromHistory} onViewFromHistory={onViewFromHistory}
onRefresh={refreshList} onRefresh={refreshList}
/> />
{binaryStatus && !binaryStatus.found && ( {binaryStatus && !binaryStatus.found && (
<div className="rounded-lg border border-red-500/40 bg-red-500/10 px-4 py-3 text-sm text-red-200 flex items-center gap-2"> <div className="rounded-lg border border-status-danger/40 bg-status-danger/10 px-4 py-3 text-sm text-status-danger flex items-center gap-2">
<AlertCircle className="w-4 h-4 flex-shrink-0" /> <AlertCircle className="w-4 h-4 flex-shrink-0" />
<span>{t("binary.missing")}</span> <span>{t("binary.missing")}</span>
</div>
)}
{error && (
<div className="rounded-lg border border-red-500/40 bg-red-500/10 px-4 py-3 text-sm text-red-200 flex items-center gap-2">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
<span className="flex-1 break-all">{error}</span>
<button
onClick={() => setError(null)}
className="text-red-200/70 hover:text-red-100 p-0.5"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
)}
{!handle ? (
// Config card uses normal page flow - page scrolls if needed.
<RunSetup
mode={mode}
onModeChange={(m) => {
setMode(m);
// Headless can't resume - clearing keeps the UI honest if the
// user had a session pinned and then switched mode.
if (m === "headless") setResumeSession(null);
}}
prompt={prompt}
onPromptChange={setPrompt}
cwd={cwd}
onCwdChange={setCwd}
cwdSuggestions={cwdSuggestions}
model={model}
onModelChange={setModel}
permissionMode={permissionMode}
onPermissionModeChange={setPermissionMode}
effort={effort}
onEffortChange={setEffort}
binaryFound={binaryStatus?.found ?? true}
busy={busy === "start"}
onStart={start}
activeRuns={activeRuns}
laneCwd={currentLane?.cwd}
resumeSession={resumeSession}
onResumeSessionChange={setResumeSession}
slashCommands={slashCommands}
runHistory={runHistory}
onResumeFromHistory={onResumeFromHistory}
/>
) : (
// Run session is wrapped in a flex container so its inner chat panel
// can take all remaining viewport height; long chats scroll inside.
<div className="flex-1 min-h-0 flex flex-col">
<RunConsole
handle={handle}
envelopes={displayEnvelopes}
mode={handle.mode}
isLive={isLive}
hasFinished={hasFinished}
followUp={followUp}
onFollowUpChange={setFollowUp}
busy={busy}
onSend={send}
onStop={stop}
onNewRun={newRun}
slashCommands={slashCommands}
/>
</div>
)}
</div> </div>
)}
{error && (
<div className="rounded-lg border border-status-danger/40 bg-status-danger/10 px-4 py-3 text-sm text-status-danger flex items-center gap-2">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
<span className="flex-1 break-all">{error}</span>
<button
onClick={() => setError(null)}
className="text-status-danger/70 hover:text-status-danger p-0.5"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
)}
{!handle ? (
// Config card uses normal page flow - page scrolls if needed.
<RunSetup
mode={mode}
onModeChange={(m) => {
setMode(m);
// Headless can't resume - clearing keeps the UI honest if the
// user had a session pinned and then switched mode.
if (m === "headless") setResumeSession(null);
}}
prompt={prompt}
onPromptChange={setPrompt}
cwd={cwd}
onCwdChange={setCwd}
cwdSuggestions={cwdSuggestions}
model={model}
onModelChange={setModel}
permissionMode={permissionMode}
onPermissionModeChange={setPermissionMode}
effort={effort}
onEffortChange={setEffort}
binaryFound={binaryStatus?.found ?? true}
busy={busy === "start"}
onStart={start}
activeRuns={activeRuns}
laneCwd={currentLane?.cwd}
resumeSession={resumeSession}
onResumeSessionChange={setResumeSession}
slashCommands={slashCommands}
runHistory={runHistory}
onResumeFromHistory={onResumeFromHistory}
/>
) : (
// Run session is wrapped in a flex container so its inner chat panel
// can take all remaining viewport height; long chats scroll inside.
<div className="flex-1 min-h-0 flex flex-col">
<RunConsole
handle={handle}
envelopes={displayEnvelopes}
mode={handle.mode}
isLive={isLive}
hasFinished={hasFinished}
followUp={followUp}
onFollowUpChange={setFollowUp}
busy={busy}
onSend={send}
onStop={stop}
onNewRun={newRun}
slashCommands={slashCommands}
/>
</div>
)}
</div>
</> </>
); );
@@ -885,27 +897,27 @@ export function Workspace() {
> >
{/* Page header: what this screen is, and the four numbers that say {/* Page header: what this screen is, and the four numbers that say
whether anything needs a human right now. */} whether anything needs a human right now. */}
<div className="flex flex-wrap items-center gap-3 border-b border-neutral-800 pb-3"> <div className="flex flex-wrap items-center gap-3 border-b border-border pb-3">
<h2 className="text-base font-semibold tracking-tight text-neutral-100"> <h2 className="text-base font-semibold tracking-tight text-fg-primary">
{tLanes("title")} {tLanes("title")}
</h2> </h2>
<div className="flex flex-wrap items-center gap-1.5 text-xs"> <div className="flex flex-wrap items-center gap-1.5 text-xs">
<span <span
data-testid="count-total" data-testid="count-total"
className="rounded-full bg-neutral-800 px-2.5 py-0.5 text-neutral-300" className="rounded-full bg-surface-2 px-2.5 py-0.5 text-fg-secondary"
> >
{counts.total} {tLanes("countTotal")} {counts.total} {tLanes("countTotal")}
</span> </span>
<span <span
data-testid="count-running" data-testid="count-running"
className="rounded-full bg-blue-500/20 px-2.5 py-0.5 text-blue-300" className="rounded-full bg-blue-600/20 px-2.5 py-0.5 text-blue-400"
> >
{counts.running} {tLanes("countRunning")} {counts.running} {tLanes("countRunning")}
</span> </span>
{counts.needs_you > 0 && ( {counts.needs_you > 0 && (
<span <span
data-testid="count-needs-you" data-testid="count-needs-you"
className="rounded-full bg-amber-500/20 px-2.5 py-0.5 text-amber-300" className="rounded-full bg-status-warning/20 px-2.5 py-0.5 text-status-warning"
> >
{counts.needs_you} {tLanes("countNeedsYou")} {counts.needs_you} {tLanes("countNeedsYou")}
</span> </span>
@@ -913,7 +925,7 @@ export function Workspace() {
{counts.dead > 0 && ( {counts.dead > 0 && (
<span <span
data-testid="count-dead" data-testid="count-dead"
className="rounded-full bg-red-500/20 px-2.5 py-0.5 text-red-300" className="rounded-full bg-status-danger/20 px-2.5 py-0.5 text-status-danger"
> >
{counts.dead} {tLanes("countDead")} {counts.dead} {tLanes("countDead")}
</span> </span>
@@ -921,7 +933,7 @@ export function Workspace() {
</div> </div>
<button <button
onClick={() => setAddLaneOpen(true)} onClick={() => setAddLaneOpen(true)}
className="ml-auto flex items-center gap-1.5 rounded border border-neutral-700 px-3 py-1 text-xs text-neutral-300 transition-colors hover:border-neutral-500 hover:text-neutral-100" className="ml-auto flex items-center gap-1.5 rounded border border-border-light px-3 py-1 text-xs text-fg-secondary transition-colors hover:border-border-light hover:text-fg-primary"
title={tLanes("addLane")} title={tLanes("addLane")}
> >
<Plus className="h-3.5 w-3.5" /> <Plus className="h-3.5 w-3.5" />
@@ -944,7 +956,7 @@ export function Workspace() {
/> />
))} ))}
{!lanes.length && ( {!lanes.length && (
<p className="text-sm text-neutral-500"> <p className="text-sm text-fg-muted">
{tLanes("emptyState")} <code>ccam lanes add --cwd $(pwd)</code> {tLanes("emptyState")} <code>ccam lanes add --cwd $(pwd)</code>
</p> </p>
)} )}
@@ -953,26 +965,32 @@ export function Workspace() {
{/* The selected lane's pipeline, full width the thing you actually {/* The selected lane's pipeline, full width the thing you actually
come to this page to read. */} come to this page to read. */}
{currentLane && ( {currentLane && (
<section <section data-testid="lane-detail" className="card p-4">
data-testid="lane-detail"
className="rounded-xl border border-neutral-800 bg-neutral-900/40 p-4"
>
<div className="mb-3 flex flex-wrap items-baseline gap-2"> <div className="mb-3 flex flex-wrap items-baseline gap-2">
<span className="text-[11px] font-semibold uppercase tracking-widest text-neutral-500"> <span className="text-[11px] font-semibold uppercase tracking-widest text-fg-muted">
{tLanes("cardId", { id: currentLane.id })} {tLanes("cardId", { id: currentLane.id })}
</span> </span>
<span className="truncate text-sm font-semibold text-neutral-100"> <span className="truncate text-sm font-semibold text-fg-primary">
{currentLane.title || currentLane.cwd} {currentLane.title || currentLane.cwd}
</span> </span>
<span className="text-[11px] text-neutral-600">{currentLane.pipeline_name}</span> <span className="text-[11px] text-fg-muted">{currentLane.pipeline_name}</span>
<span className="rounded bg-neutral-800 px-2 py-0.5 text-xs text-neutral-200"> {/* `stage` defaults to the DB sentinel "idle" until the driving
{currentLane.stage} session ever calls `ccam stage` that string collides with
`status`'s own "idle"/"running" vocabulary, so a lane that is
actively running but has never declared a stage read as if it
were sitting idle. Show a distinct label instead of the raw
sentinel whenever it doesn't match any node this pipeline
actually has. */}
<span className="rounded bg-surface-2 px-2 py-0.5 text-xs text-fg-secondary">
{currentLane.pipeline_nodes.some((n) => n.id === currentLane.stage)
? currentLane.stage
: tLanes("stageUndeclared")}
</span> </span>
{currentLane.detected_stage && ( {currentLane.detected_stage && (
<span <span
data-testid="detail-auto-stage" data-testid="detail-auto-stage"
title={currentLane.detected_signal || undefined} title={currentLane.detected_signal || undefined}
className="rounded border border-dashed border-amber-500 px-2 py-0.5 text-xs text-amber-300" className="rounded border border-dashed border-status-warning px-2 py-0.5 text-xs text-status-warning"
> >
{tLanes("autoStage", { stage: currentLane.detected_stage })} {tLanes("autoStage", { stage: currentLane.detected_stage })}
</span> </span>
@@ -990,7 +1008,7 @@ export function Workspace() {
detectedSignal={currentLane.detected_signal} detectedSignal={currentLane.detected_signal}
/> />
</div> </div>
<div className="flex min-h-0 flex-col gap-2 border-t border-neutral-800 pt-3"> <div className="flex min-h-0 flex-col gap-2 border-t border-border pt-3">
{consoleSection} {consoleSection}
</div> </div>
</section> </section>
@@ -1010,20 +1028,16 @@ export function Workspace() {
{laneActionError && ( {laneActionError && (
<p <p
role="alert" role="alert"
className="rounded border border-red-800 bg-red-950/40 px-3 py-2 text-sm text-red-300" className="rounded border border-status-danger bg-status-danger/40 px-3 py-2 text-sm text-status-danger"
> >
{tLanes("actionError", { message: laneActionError })} {tLanes("actionError", { message: laneActionError })}
</p> </p>
)} )}
{/* No lane selected (none exist, or nothing picked yet): the console has {/* No lane selected (none exist, or nothing picked yet): the console has
nowhere to attach, so it falls back to page level. Without this the nowhere to attach, so it falls back to page level. Without this the
start form would be unreachable on a fresh install. */} start form would be unreachable on a fresh install. */}
{!currentLane && ( {!currentLane && <div className="flex min-h-0 flex-col gap-2">{consoleSection}</div>}
<div className="flex min-h-0 flex-col gap-2">{consoleSection}</div>
)}
</div> </div>
); );
} }
@@ -1087,20 +1101,20 @@ function Header({
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h1 className="text-lg font-semibold text-gray-100">{t("title")}</h1> <h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1>
{wsConnected ? ( {wsConnected ? (
<span className="flex items-center gap-1.5 text-[11px] text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 px-2 py-0.5 rounded-full"> <span className="flex items-center gap-1.5 text-[11px] text-status-success bg-status-success/10 border border-status-success/20 px-2 py-0.5 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse-dot" /> <span className="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse-dot" />
{tCommon("live")} {tCommon("live")}
</span> </span>
) : ( ) : (
<span className="flex items-center gap-1.5 text-[11px] text-gray-400 bg-gray-500/10 border border-gray-500/20 px-2 py-0.5 rounded-full"> <span className="flex items-center gap-1.5 text-[11px] text-fg-secondary bg-surface-4/10 border border-border-light/20 px-2 py-0.5 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-gray-400" /> <span className="w-1.5 h-1.5 rounded-full bg-surface-4" />
{tCommon("offline")} {tCommon("offline")}
</span> </span>
)} )}
</div> </div>
<p className="text-xs text-gray-500 max-w-3xl">{t("subtitle")}</p> <p className="text-xs text-fg-muted max-w-3xl">{t("subtitle")}</p>
</div> </div>
<ActiveRunsSwitcher <ActiveRunsSwitcher
activeRuns={activeRuns} activeRuns={activeRuns}
File diff suppressed because it is too large Load Diff
+27 -12
View File
@@ -1,31 +1,46 @@
/** /**
* @file tailwind.config.js * @file tailwind.config.js
* @description Tailwind CSS configuration content globs and the dashboard's dark-theme design tokens. * @description Tailwind CSS configuration content globs and the dashboard's
* dark/light color tokens. Token values live in `src/index.css` as CSS
* variables (`:root` = light, `.dark` = dark); this file only wires Tailwind
* class names to them. RGB-triplet vars (not hex) so `<alpha-value>` keeps
* opacity modifiers like `bg-surface-2/70` working under either theme.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/ */
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
export default { export default {
darkMode: "class",
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
theme: { theme: {
extend: { extend: {
colors: { colors: {
surface: { surface: {
0: "#06060a", 0: "rgb(var(--surface-0) / <alpha-value>)",
1: "#0c0c14", 1: "rgb(var(--surface-1) / <alpha-value>)",
2: "#13131e", 2: "rgb(var(--surface-2) / <alpha-value>)",
3: "#1a1a28", 3: "rgb(var(--surface-3) / <alpha-value>)",
4: "#222233", 4: "rgb(var(--surface-4) / <alpha-value>)",
5: "#2a2a3d", 5: "rgb(var(--surface-5) / <alpha-value>)",
}, },
border: { border: {
DEFAULT: "#2a2a3d", DEFAULT: "rgb(var(--border) / <alpha-value>)",
light: "#363650", light: "rgb(var(--border-light) / <alpha-value>)",
}, },
accent: { accent: {
DEFAULT: "#6366f1", DEFAULT: "rgb(var(--accent) / <alpha-value>)",
hover: "#818cf8", hover: "rgb(var(--accent-hover) / <alpha-value>)",
muted: "rgba(99, 102, 241, 0.15)", muted: "rgb(var(--accent) / 0.15)",
},
fg: {
primary: "rgb(var(--fg-primary) / <alpha-value>)",
secondary: "rgb(var(--fg-secondary) / <alpha-value>)",
muted: "rgb(var(--fg-muted) / <alpha-value>)",
},
status: {
success: "rgb(var(--status-success) / <alpha-value>)",
danger: "rgb(var(--status-danger) / <alpha-value>)",
warning: "rgb(var(--status-warning) / <alpha-value>)",
}, },
}, },
fontFamily: { fontFamily: {
@@ -0,0 +1,489 @@
# Color redesign + dark/light mode — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Ship a working Dark/Light toggle (Azure accent) that actually re-themes the whole dashboard, not just the components already on semantic tokens.
**Architecture:** Tailwind `darkMode: "class"` + CSS custom properties (RGB triplets, so Tailwind opacity modifiers like `/70` keep working) for `surface.0-5`, `border`, `border-light`, `accent`, `accent-hover`, `accent-muted`, `fg.primary/secondary/muted`. A `useTheme()` hook toggles the `dark` class on `<html>` and persists to `localStorage`. A scripted, table-driven find/replace converts every raw gray-scale utility (`neutral-*`/`gray-*`/`slate-*`/`zinc-*`) across `client/src` to the new tokens — that scale is unambiguously UI chrome. Status colors (emerald/red/amber) already carry real meaning (live/dead/needs-you) and are handled per-usage, not by blind substitution.
**Tech Stack:** Tailwind CSS 3 (`darkMode: "class"`), React, i18next, `localStorage`.
## Global Constraints
- Accent stays `#2563eb` / hover `#4c8bf5` in both themes (per design doc).
- Dark surfaces: page `#1F2533`, sidebar `#232A3B`, card `#252E42`, border `#343F57`.
- Light surfaces: page `#f4f7fd`, sidebar/card `#ffffff`, border `#dde6f5`.
- Default theme: dark. No `prefers-color-scheme` fallback — `localStorage` only.
- Toggle lives in `client/src/components/Sidebar.tsx`, same row as the EN/VI language buttons.
- Every `.js/.ts/.tsx/.css` file touched or created must carry the project's file header (`@author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>`) — already present on every file this plan modifies, so no new headers needed unless a new file is created.
- Scope descoped from the design doc, stated explicitly here rather than silently: the categorical/decorative hue palette (violet, indigo, cyan, teal, sky, rose, pink, orange, yellow — used for tags, subagent-type badges, chart legends) is NOT touched by this plan. Recoloring it per-theme requires per-usage contrast review that the "mechanical, table-driven" approach this plan relies on cannot safely automate. Flagged as follow-up in the final task.
---
### Task 1: Theme tokens (Tailwind config + CSS variables)
**Files:**
- Modify: `client/tailwind.config.js`
- Modify: `client/src/index.css`
**Interfaces:**
- Produces: Tailwind color tokens `surface.0-5`, `border`/`border.light`, `accent`/`accent.hover`/`accent.muted`, `fg.primary`/`fg.secondary`/`fg.muted` — every later task's class names (`bg-surface-1`, `text-fg-secondary`, etc.) resolve through these.
- [x] **Step 1: Add CSS variables for both themes**
Replace the top of `client/src/index.css` (before the existing `@layer base` block) with:
```css
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
/* light theme — default (no class needed) */
--surface-0: 244 247 253; /* #f4f7fd */
--surface-1: 255 255 255; /* #ffffff */
--surface-2: 255 255 255;
--surface-3: 255 255 255;
--surface-4: 238 242 250;
--surface-5: 221 230 245; /* #dde6f5 */
--border: 221 230 245; /* #dde6f5 */
--border-light: 200 212 235;
--accent: 37 99 235; /* #2563eb */
--accent-hover: 76 139 245; /* #4c8bf5 */
--accent-muted: 37 99 235 / 0.12;
--fg-primary: 27 37 54; /* #1b2536 */
--fg-secondary: 91 107 140; /* #5b6b8c */
--fg-muted: 130 150 184;
}
.dark {
--surface-0: 31 37 51; /* #1F2533 */
--surface-1: 35 42 59; /* #232A3B */
--surface-2: 37 46 66; /* #252E42 */
--surface-3: 42 51 73;
--surface-4: 52 63 87; /* #343F57 */
--surface-5: 61 73 100;
--border: 52 63 87; /* #343F57 */
--border-light: 74 87 115;
--accent: 37 99 235;
--accent-hover: 76 139 245;
--accent-muted: 37 99 235 / 0.15;
--fg-primary: 226 233 245; /* #e2e9f5 */
--fg-secondary: 150 165 200; /* #96a5c8 */
--fg-muted: 110 126 163;
}
```
- [x] **Step 2: Point Tailwind's tokens at the variables**
In `client/tailwind.config.js`, replace the `colors` block under `theme.extend`:
```js
colors: {
surface: {
0: "rgb(var(--surface-0) / <alpha-value>)",
1: "rgb(var(--surface-1) / <alpha-value>)",
2: "rgb(var(--surface-2) / <alpha-value>)",
3: "rgb(var(--surface-3) / <alpha-value>)",
4: "rgb(var(--surface-4) / <alpha-value>)",
5: "rgb(var(--surface-5) / <alpha-value>)",
},
border: {
DEFAULT: "rgb(var(--border) / <alpha-value>)",
light: "rgb(var(--border-light) / <alpha-value>)",
},
accent: {
DEFAULT: "rgb(var(--accent) / <alpha-value>)",
hover: "rgb(var(--accent-hover) / <alpha-value>)",
muted: "rgb(var(--accent) / 0.15)",
},
fg: {
primary: "rgb(var(--fg-primary) / <alpha-value>)",
secondary: "rgb(var(--fg-secondary) / <alpha-value>)",
muted: "rgb(var(--fg-muted) / <alpha-value>)",
},
},
```
Also add `darkMode: "class",` as a top-level key in the exported config object (next to `content`).
- [x] **Step 3: Verify the build picks up the new tokens**
Run: `cd client && npx tailwindcss -i ./src/index.css -o /tmp/tw-check.css --content "./src/**/*.tsx"`
Expected: exits 0, and `grep -c "surface-1" /tmp/tw-check.css` is non-zero (confirms the token compiled into utility classes somewhere it's used).
- [x] **Step 4: Commit**
```bash
git add client/tailwind.config.js client/src/index.css
git commit -m "feat(theme): CSS-variable-backed color tokens for dark/light mode"
```
---
### Task 2: `useTheme` hook + Sidebar toggle
**Files:**
- Create: `client/src/hooks/useTheme.ts`
- Create: `client/src/hooks/__tests__/useTheme.test.ts`
- Modify: `client/src/components/Sidebar.tsx`
- Modify: `client/src/i18n/locales/en/lanes.json` → actually `client/src/i18n/locales/en/nav.json` and `client/src/i18n/locales/vi/nav.json` (the `nav:` namespace Sidebar already uses for `language`/`languageNames`/`switchLanguage`)
**Interfaces:**
- Consumes: nothing new (Sidebar already imports `useTranslation(["nav", ...])`, confirm the exact import list in the file before editing).
- Produces: `useTheme(): { theme: "dark" | "light"; setTheme: (t: "dark" | "light") => void; toggleTheme: () => void }`, exported from `client/src/hooks/useTheme.ts`. Later tasks do not depend on this, but any future screen wanting to read the active theme imports this hook.
- [x] **Step 1: Write the failing test**
```ts
/**
* @file Tests for useTheme — the dark/light mode hook.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useTheme } from "../useTheme";
describe("useTheme", () => {
beforeEach(() => {
localStorage.clear();
document.documentElement.classList.remove("dark");
});
afterEach(() => {
document.documentElement.classList.remove("dark");
});
it("defaults to dark when localStorage is empty", () => {
const { result } = renderHook(() => useTheme());
expect(result.current.theme).toBe("dark");
expect(document.documentElement.classList.contains("dark")).toBe(true);
});
it("reads a persisted light theme on mount", () => {
localStorage.setItem("theme", "light");
const { result } = renderHook(() => useTheme());
expect(result.current.theme).toBe("light");
expect(document.documentElement.classList.contains("dark")).toBe(false);
});
it("toggleTheme flips the theme, the DOM class, and persists it", () => {
const { result } = renderHook(() => useTheme());
act(() => result.current.toggleTheme());
expect(result.current.theme).toBe("light");
expect(document.documentElement.classList.contains("dark")).toBe(false);
expect(localStorage.getItem("theme")).toBe("light");
});
it("setTheme sets an explicit value", () => {
const { result } = renderHook(() => useTheme());
act(() => result.current.setTheme("light"));
expect(result.current.theme).toBe("light");
act(() => result.current.setTheme("dark"));
expect(result.current.theme).toBe("dark");
expect(document.documentElement.classList.contains("dark")).toBe(true);
});
});
```
- [x] **Step 2: Run test to verify it fails**
Run: `cd client && npx vitest run src/hooks/__tests__/useTheme.test.ts`
Expected: FAIL — `useTheme` module does not exist.
- [x] **Step 3: Write the implementation**
```ts
/**
* @file useTheme — dark/light mode state, backed by localStorage and the
* `dark` class on <html> that Tailwind's `darkMode: "class"` reads.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState } from "react";
export type Theme = "dark" | "light";
const STORAGE_KEY = "theme";
function readStoredTheme(): Theme {
return localStorage.getItem(STORAGE_KEY) === "light" ? "light" : "dark";
}
function applyTheme(theme: Theme) {
document.documentElement.classList.toggle("dark", theme === "dark");
}
export function useTheme(): {
theme: Theme;
setTheme: (theme: Theme) => void;
toggleTheme: () => void;
} {
const [theme, setThemeState] = useState<Theme>(() => readStoredTheme());
useEffect(() => {
applyTheme(theme);
}, [theme]);
const setTheme = useCallback((next: Theme) => {
localStorage.setItem(STORAGE_KEY, next);
setThemeState(next);
}, []);
const toggleTheme = useCallback(() => {
setTheme(theme === "dark" ? "light" : "dark");
}, [theme, setTheme]);
return { theme, setTheme, toggleTheme };
}
```
- [x] **Step 4: Run test to verify it passes**
Run: `cd client && npx vitest run src/hooks/__tests__/useTheme.test.ts`
Expected: PASS, 4/4.
- [x] **Step 5: Add i18n keys**
In `client/src/i18n/locales/en/nav.json`, add next to the existing `language`/`languageNames`/`switchLanguage` keys:
```json
"theme": "Theme",
"themeNames": { "dark": "Dark", "light": "Light" },
"switchTheme": "Switch to {{theme}}"
```
In `client/src/i18n/locales/vi/nav.json`:
```json
"theme": "Giao diện",
"themeNames": { "dark": "Tối", "light": "Sáng" },
"switchTheme": "Chuyển sang {{theme}}"
```
(Read the existing file first — insert alongside the current `language`/`languageNames` keys rather than duplicating the object.)
- [x] **Step 6: Wire the toggle into Sidebar, same row as EN/VI**
Read `client/src/components/Sidebar.tsx` fully before editing — it already has `SUPPORTED_LANGUAGES.map(...)` rendering the EN/VI grid (search for `nav:language` and `languageShort`). Add, in the same row-container as that language grid (the `<div className="mt-2 grid grid-cols-4 gap-1">` block for the expanded state, and the collapsed-state single button above it):
1. Import `useTheme` from `../hooks/useTheme`.
2. Call `const { theme, toggleTheme } = useTheme();` alongside the existing `i18n`/`currentLanguage` locals.
3. In the collapsed-state single button (the one showing `languageShort.${currentLanguage}`), add a second icon button right after it, same size/classes, that calls `toggleTheme()` and shows a sun/moon glyph (reuse whatever icon import convention the file already uses — check the top `import { ... } from "lucide-react"` line for `Sun`/`Moon`, add them if missing).
4. In the expanded-state block (the `rounded-lg border border-border bg-surface-2 p-2` panel with the language grid), add a second 2-button row below the language grid — same `grid grid-cols-2 gap-1` shape as the language grid but 2 columns instead of 4 — with `Dark`/`Light` buttons calling `() => setTheme("dark")` / `() => setTheme("light")`, `aria-pressed={theme === "dark"}` etc., mirroring the exact `active ? ... : ...` className ternary the language buttons already use.
5. Labels via `t("theme")` (small header, same style as the existing `t("nav:language")` label) and `t(\`themeNames.${t}\`)` per button.
- [x] **Step 7: Run the existing Sidebar tests**
Run: `cd client && npx vitest run src/components/__tests__/Sidebar.test.tsx` (adjust path if the test file lives elsewhere — `find client/src -iname "*Sidebar*test*"` first)
Expected: PASS. If the file doesn't exist yet, skip this step (no regression to protect).
- [x] **Step 8: Commit**
```bash
git add client/src/hooks/useTheme.ts client/src/hooks/__tests__/useTheme.test.ts client/src/components/Sidebar.tsx client/src/i18n/locales/en/nav.json client/src/i18n/locales/vi/nav.json
git commit -m "feat(theme): dark/light toggle next to the language switcher"
```
---
### Task 3: Mechanical gray-scale → semantic token migration
**Files:**
- Modify: every `client/src/**/*.tsx` file matching raw `neutral-*`/`gray-*`/`slate-*`/`zinc-*`/`stone-*` color utilities (64 files at design time — re-run the grep below to get the current, exact list; do not hand-pick a subset).
- Create: `scripts/migrate-color-tokens.mjs` (one-off, deleted after use in the final commit of this task — or left in `scripts/` if the user wants it kept for a future pass; ask nothing, just note it in the commit message).
**Interfaces:**
- Consumes: the tokens from Task 1 (`surface.0-5`, `border`, `border.light`, `fg.primary/secondary/muted`).
- Produces: no new interface — this task only changes class strings.
- [x] **Step 1: Enumerate the exact strings to replace**
Run:
```bash
cd client/src && grep -rohE "(bg|text|border|placeholder|fill|ring)-(neutral|gray|slate|zinc|stone)-[0-9]+(/[0-9]+)?" --include=*.tsx . | sort -u
```
This is the authoritative input list for the mapping table in Step 2 — if it has grown or shrunk since this plan was written, update the table to match rather than silently ignoring new entries.
- [x] **Step 2: Write the mapping table and apply it**
Create `scripts/migrate-color-tokens.mjs`:
```js
#!/usr/bin/env node
/**
* @file One-off codemod: rewrites raw Tailwind gray-scale utility classes
* (neutral-*/gray-*/slate-*/zinc-*/stone-*) across client/src to the
* semantic surface/border/fg tokens introduced for dark/light mode.
* Status colors (emerald/red/amber) and the categorical hue palette
* (violet/indigo/cyan/teal/sky/rose/pink/orange/yellow) are deliberately
* out of scope — see docs/superpowers/plans/2026-07-31-color-redesign-dark-light-mode.md.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { readdirSync, readFileSync, writeFileSync, statSync } from "node:fs";
import { join } from "node:path";
// prop: bg | text | border | placeholder | fill | ring
// Ordered longest-shade-string-first within each prop so e.g. "gray-900" is
// matched before a hypothetical "gray-90" prefix collision (none exist today,
// kept for safety).
const MAP = {
text: {
"gray-100": "fg-primary",
"gray-50": "fg-primary",
"neutral-50": "fg-primary",
"neutral-100": "fg-primary",
"gray-200": "fg-secondary",
"gray-300": "fg-secondary",
"gray-400": "fg-secondary",
"neutral-300": "fg-secondary",
"neutral-400": "fg-secondary",
"slate-300": "fg-secondary",
"gray-500": "fg-muted",
"gray-600": "fg-muted",
"gray-700": "fg-muted",
"neutral-500": "fg-muted",
"neutral-600": "fg-muted",
},
placeholder: {
"gray-500": "fg-muted",
"gray-600": "fg-muted",
},
fill: {
"gray-600": "fg-muted",
"gray-300": "fg-secondary",
"gray-100": "fg-primary",
},
bg: {
"neutral-900": "surface-0",
"gray-900": "surface-0",
"neutral-800": "surface-2",
"gray-800": "surface-2",
"neutral-700": "surface-3",
"gray-700": "surface-3",
"neutral-500": "surface-4",
"gray-500": "surface-4",
"gray-400": "surface-4",
"gray-600": "surface-4",
},
border: {
"neutral-800": "border",
"gray-800": "border",
"neutral-700": "border-light",
"gray-700": "border-light",
"neutral-500": "border-light",
"gray-500": "border-light",
},
ring: {
"slate-500": "border-light",
"slate-400": "border-light",
},
};
function rewriteLine(text) {
let out = text;
for (const [prop, shadeMap] of Object.entries(MAP)) {
for (const [rawShade, token] of Object.entries(shadeMap)) {
// Matches `bg-gray-500`, `bg-gray-500/10`, `bg-gray-500/50` etc. — the
// opacity suffix is preserved verbatim since the token's CSS var
// already supports `<alpha-value>`.
const re = new RegExp(`\\b${prop}-${rawShade}(\\/[0-9]+)?\\b`, "g");
out = out.replace(re, (_m, opacity) => `${prop}-${token}${opacity || ""}`);
}
}
return out;
}
function walk(dir, files = []) {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
const st = statSync(full);
if (st.isDirectory()) {
if (entry === "node_modules" || entry === "__tests__") continue;
walk(full, files);
} else if (entry.endsWith(".tsx")) {
files.push(full);
}
}
return files;
}
const root = join(process.cwd(), "src");
const files = walk(root);
let changed = 0;
for (const file of files) {
const before = readFileSync(file, "utf8");
const after = rewriteLine(before);
if (after !== before) {
writeFileSync(file, after);
changed++;
}
}
console.log(`rewrote ${changed} file(s)`);
```
Run: `cd client && node ../scripts/migrate-color-tokens.mjs` (adjust the relative path so it runs from `client/` and walks `client/src`).
- [x] **Step 3: Confirm no raw gray-scale utility survives**
Run:
```bash
cd client/src && grep -rlE "(bg|text|border|placeholder|fill|ring)-(neutral|gray|slate|zinc|stone)-[0-9]+" --include=*.tsx .
```
Expected: no output. If any file still matches, its exact string is missing from `MAP` in Step 2 — add it and re-run Step 2, don't hand-patch the file directly (keeps the table authoritative for the next person who re-runs this).
- [x] **Step 4: Typecheck**
Run: `cd client && npx tsc --noEmit`
Expected: `TypeScript: No errors found` (class-string rewrites cannot introduce type errors, but a botched regex could corrupt a `.tsx` file's syntax — this is the safety net for that).
- [x] **Step 5: Run the client test suite and review the snapshot diff**
Run: `cd client && npx vitest run`
Expected: only `src/pages/__tests__/screens.snapshot.test.tsx` shows diffs (every other test is behavior, not color, so it must still pass unchanged). Read the diff — confirm it is exactly the token renames (e.g. `bg-neutral-900``bg-surface-0`) and nothing structural. Then regenerate: `npx vitest run -u`.
- [x] **Step 6: Manual visual pass**
Start the app (`npm run dev` from repo root, or rely on whatever dev server the user already has running), toggle Dark ↔ Light from the Sidebar, and check: Dashboard, Workspace (lane strip + detail + console), Sidebar itself. Confirm no screen still reads hardcoded dark in light mode. This cannot be scripted — say explicitly which screens were checked.
- [x] **Step 7: Commit**
```bash
git add -A
git commit -m "refactor(theme): migrate raw gray-scale utilities to semantic tokens"
```
---
### Task 4: Descope note for categorical/status colors (no code — documentation only)
**Files:**
- Modify: `docs/superpowers/specs/2026-07-31-color-redesign-dark-light-mode-design.md` (append, don't rewrite)
**Interfaces:** none.
- [x] **Step 1: Append a "Follow-up" section to the design doc**
```markdown
## Follow-up (not built in this pass)
Task 3 of the implementation plan migrated only the gray-scale chrome
(`neutral-*`/`gray-*`/`slate-*`/`zinc-*`) — unambiguously UI structure, safe
to blanket-replace. Two color families were deliberately left untouched:
- **Status colors** (`emerald-*`/`red-*`/`amber-*` used for liveness dots,
destructive buttons, the "needs you" banner). These carry real meaning and
read fine on the new light background at a glance, but were not checked
pixel-by-pixel for contrast — a future pass should audit each usage against
WCAG AA on `#f4f7fd`/`#ffffff`, not just eyeball it.
- **The categorical/decorative hue palette** (violet, indigo, cyan, teal,
sky, rose, pink, orange, yellow — tags, subagent-type badges, chart
legends). These are chosen for visual *distinction between categories*,
not for theme-appropriateness, and there is no single correct light-mode
remap — each usage would need its own review. Left as-is.
```
- [x] **Step 2: Commit**
```bash
git add docs/superpowers/specs/2026-07-31-color-redesign-dark-light-mode-design.md
git commit -m "docs: note descoped status/categorical color follow-up"
```
@@ -0,0 +1,149 @@
# Color redesign + dark/light mode — design
**Status:** approved 2026-07-31 (color, architecture, and scope all confirmed in chat; user asked to plan and implement without further review gates).
## Problem
The dashboard has exactly one theme (dark), and it is not even applied consistently: `tailwind.config.js` defines semantic tokens (`surface-*`, `border`, `accent`) but most components (e.g. `LaneCard.tsx`) bypass them and use raw Tailwind scale colors directly — `bg-neutral-900/70`, `text-gray-500`, `border-neutral-800`. A grep across `client/src/**/*.tsx` finds raw `neutral-*`/`gray-*`/`slate-*`/`zinc-*`/`stone-*` color utilities in 64 files, ~1,400 occurrences. Redefining the token *values* alone would only re-theme the handful of components that use the tokens — the rest would stay hard-dark regardless of the toggle.
## Goal
A working Dark/Light toggle, next to the language switcher, same row as the EN/VI buttons, that actually changes every screen — not just the ones already using semantic tokens.
## Decisions already taken (chat)
- **Accent: Azure, darkened after initial ship.** `#1d4ed8` (hover `#2563eb`, the original base value), unchanged between themes. `border`/`border-light` darkened the same pass, both themes — `fg-*` (text) intentionally untouched: darkening text in dark mode would cut its contrast against the dark background.
- **Dark surfaces (page/sidebar/card):** `#1F2533` / `#232A3B` / `#252E42` — the original dark direction lightened ~20% total, per user's two rounds of feedback on the visual mockup. **Dark border/border-light:** `#2a3246` / `#3b4660` (darkened from the surface-matched `#343F57` / lighter).
- **Light surfaces:** page `#f4f7fd`, sidebar/card `#ffffff`. **Light border/border-light:** `#becde6` / `#a5b6d7` (darkened from `#dde6f5` / lighter, for visibility against white).
- **2 states only** (Dark/Light) — no "System" option.
- **Default: dark**, matching current behavior. Persisted in `localStorage`; no `prefers-color-scheme` fallback.
- **Toggle placement:** `client/src/components/Sidebar.tsx`, in the language block, same row as the EN/VI buttons (mirrors `toggleLang`'s click-to-flip pattern for the collapsed state).
- **Full rewrite**: every raw-color usage across `client/src` migrates to semantic tokens. No screen is left on hardcoded dark.
## Architecture
**Mechanism: Tailwind `darkMode: "class"` + CSS custom properties.** A `dark` class on `<html>` selects which variable set is active; Tailwind's color tokens resolve to `var(--token-name)`. This is a values-only flip (one class toggle, no per-element `dark:` variant pairs to maintain), which is what makes "full rewrite" tractable: every component only ever needs ONE semantic class name; the theme decides what color that resolves to.
**Token set** (`tailwind.config.js` `theme.extend.colors`, each backed by a CSS var):
| Token | Purpose | Replaces |
|---|---|---|
| `surface.0..5` | page/panel/card backgrounds (already exists, redefined as vars) | `bg-neutral-900/950/800`, `bg-gray-900` |
| `border` / `border-light` | (already exists, redefined as vars) | `border-neutral-700/800`, `border-gray-700/800` |
| `accent` / `accent-hover` / `accent-muted` | (already exists, unchanged value both themes) | `bg-blue-500/600`, `text-blue-300/400` |
| `fg.primary` / `fg.secondary` / `fg.muted` | body text, 3 weights | `text-gray-100/200/50`, `text-gray-400/500/600`, `text-neutral-300/400/500` |
| `status.success` / `status.danger` / `status.warning` | liveness dot, destructive buttons, "needs you" banner — each needs a DIFFERENT shade per theme for contrast (e.g. `emerald-400` on `#252E42` reads fine; the same hex on `#ffffff` is too light) | `emerald-400/500`, `red-400/500/600`, `amber-300/400/500/600` |
CSS vars live in `client/src/index.css`, one block under `:root` (light — since `class` strategy needs a class-free default; light is the CSS default, `.dark` overrides it) and one under `.dark` (dark, and default at runtime via the toggle setting `document.documentElement.classList`).
**Migration is mechanical, not creative.** Every raw-scale usage in scope maps to exactly one semantic token via a fixed lookup table (below); there is no "reconsider this component's palette" step. A script applies the table across all 64 files; a human (me) spot-checks the diff and the screens snapshot rather than hand-editing each file.
### Lookup table (raw → semantic, illustrative — full table lives in the implementation)
| Raw | Semantic |
|---|---|
| `text-gray-100`, `text-neutral-50/100` | `text-fg-primary` |
| `text-gray-300/400`, `text-neutral-300/400` | `text-fg-secondary` |
| `text-gray-500/600`, `text-neutral-500` | `text-fg-muted` |
| `bg-neutral-900/950`, `bg-gray-900` | `bg-surface-0` / `bg-surface-1` (by role — page vs. panel) |
| `bg-neutral-800`, `bg-gray-800` | `bg-surface-2` / `bg-surface-3` |
| `border-neutral-700/800`, `border-gray-700/800` | `border-border` / `border-border-light` |
| `text-emerald-400`, `bg-emerald-*` | `text-status-success` / `bg-status-success` |
| `text-red-400/500`, `bg-red-*` | `text-status-danger` / `bg-status-danger` |
| `text-amber-300/400`, `border-amber-*` | `text-status-warning` / `border-status-warning` |
## Toggle component
Reuses `Sidebar.tsx`'s existing language-switcher shape: a 2-button row (`Dark` / `Light`) in the expanded state, a single icon button that flips on click in the collapsed state — same interaction as `toggleLang`/`changeLanguage`. New `useTheme()` hook: reads `localStorage.getItem("theme")` on mount (default `"dark"`), applies/removes the `dark` class on `document.documentElement`, and exposes `theme`/`setTheme`. i18n keys added under `nav:` (`theme`, `themeNames.dark`, `themeNames.light`, `switchTheme`), mirroring the existing `language`/`languageNames`/`switchLanguage` keys.
## Risks and how they are contained
- **Scale (64 files, ~1,400 occurrences).** Contained by the lookup table being fixed and mechanical — a scripted replace, not a rewrite of each file's markup. Anything the table doesn't cover is left untouched and flagged rather than guessed at.
- **Screens snapshot test** (`client/src/pages/__tests__/screens.snapshot.test.tsx`) will diff on every visual change. Per project policy, snapshots are reviewed and regenerated deliberately (`npx vitest run -u`), never blindly accepted.
- **Contrast regressions in light mode**, especially status colors and the accent-on-white combination. Checked by eye against the approved mockup values; no automated contrast gate exists in this repo, so this is a manual pass, not a new CI check (not asked for).
- **The Workspace `lane-detail` header** and `LaneCard` were already touched in this session (chip cleanup) — the migration must not reintroduce the chips that were deliberately removed.
## Testing
- `npm run test:client` after the token/config change and again after the mechanical migration; screenshot diffs reviewed, not rubber-stamped.
- `tsc --noEmit` (Tailwind class strings are not type-checked, but the new `useTheme` hook and Sidebar changes are).
- Manual pass: toggle Dark ↔ Light on the Workspace, Dashboard, and Sidebar screens, confirm no screen is left hardcoded dark and no light-mode contrast failure on status colors.
## Follow-up (not built in this pass)
The mechanical migration (`scripts/migrate-color-tokens.mjs`) rewrote only the
gray-scale chrome (`neutral-*`/`gray-*`/`slate-*`) — unambiguously UI
structure, safe to blanket-replace. Two color families were deliberately left
untouched:
- **Status colors** (`emerald-*`/`red-*`/`amber-*` used for liveness dots,
destructive buttons, the "needs you" banner). These carry real meaning and
read fine on the new light background at a glance, but were not checked
pixel-by-pixel for contrast — a future pass should audit each usage against
WCAG AA on `#f4f7fd`/`#ffffff`, not just eyeball it.
- **The categorical/decorative hue palette** (violet, indigo, cyan, teal,
sky, rose, pink, orange, yellow — tags, subagent-type badges, chart
legends). These are chosen for visual *distinction between categories*,
not for theme-appropriateness, and there is no single correct light-mode
remap — each usage would need its own review. Left as-is.
**2026-07-31, second darkening pass:** the accent/border darkening above only
touched the tokenized CSS-variable colors. The raw `blue-*` (PipelineMap's
`current` node, "info" accents scattered across ~30 files) and `amber-*`
(the dashed "auto: <stage>" chip, warning banners, badges — same ~30 files)
were still untokenized and unaffected. `scripts/darken-status-colors.mjs`
shifts every raw `blue`/`amber` utility one Tailwind shade darker (matching
the accent's own 100→200 ... 700→800 step; `950` left alone, already
darkest), same treatment in both themes since these colors had no dark/light
split before this pass either. Text (`fg-*`) still untouched, per the same
readability reasoning as the first pass.
One mapping bug found and fixed during migration, worth recording: the first
pass grouped `gray-200` into `fg-secondary` alongside `gray-300`/`gray-400`,
which collapsed the common `text-gray-400 hover:text-gray-200` pattern into a
no-op hover (`text-fg-secondary hover:text-fg-secondary` — same color before
and after). Fixed by moving `gray-200` into `fg-primary` (closer to its
actual brightness) and patching the 16 already-migrated files where the
no-op had landed.
**2026-07-31, third pass — real contrast bug + status tokens.** The second
darkening pass had a real bug, not just a taste call: `--border` in dark mode
was set DARKER than the surfaces it outlines, so a card's border landed
almost indistinguishable from its own background (dark-mode borders need to
be lighter than the surface, not darker — the opposite of the light-mode
rule). Fixed by relighting `--border`/`--border-light` in `.dark` back above
the surface scale. `amber-600` text/border on dark surfaces was also flagged
as dull; folded into the fix below rather than patched standalone.
Also added `status-success`/`status-danger`/`status-warning` CSS-variable
tokens (mirroring `fg-*`) and ran `scripts/tokenize-status-colors.mjs` to
collapse every raw `emerald-*`/`red-*`/`amber-*` shade across `client/src`
(41 files) onto them — every badge/button/component that means
success/danger/warning now pulls the same shade per theme instead of each
picking its own. `PipelineMap`'s `done`/`failed` also dropped their solid
white-text fills in favor of the same border+text+translucent-wash language
every other state uses; `current` stays the one solid (accent-colored)
exception, since it alone needs to look bolder ("you are here"). The
categorical/decorative hue palette (violet, indigo, cyan, teal, sky, rose,
pink, orange, yellow, and `blue` where it plays a categorical role e.g.
message-bubble coloring) is still explicitly out of scope — collapsing those
would erase the distinction between different *kinds* of thing, not a status.
**2026-07-31, fourth pass — stopped hand-picking, adopted Radix Colors.**
Three rounds of manually-tuned values (flat → too dark → glaring) without
ever rendering the app is what caused each regression; user asked to research
an established palette instead of continuing to guess. Adopted
[Radix Colors](https://www.radix-ui.com/colors) (`@radix-ui/colors` package,
fetched directly): a 12-step accessible scale (1-2 app background, 3-5
component background, 6-8 borders, 9-10 solid/vibrant, 11-12 text),
contrast-checked with APCA, with the scale direction inverted between light
and dark so both themes share the same role mapping. `slate` → surface
0-5/border/border-light (steps 1-7), `blue` → accent/accent-hover (step
9/10 — `blue-9` is `#0090ff` in BOTH themes, Radix's own vibrant-solid
anchor), `green`/`red`/`amber` step 11 → status-success/danger/warning (the
same "readable text" step used for `fg-secondary`, so status colors sit at
ordinary text weight rather than shouting). Every value below is the literal
Radix hex constant, not a hand-tuned guess. Because every component already
routes through the `surface-*`/`border`/`accent`/`fg-*`/`status-*` token
names (no raw Tailwind color classes for these), this pass only touched
`client/src/index.css` — no component files needed changes.
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env node
/**
* @file One-off codemod: shifts every raw `blue-*`/`amber-*` Tailwind utility
* one shade darker (100200 ... 700800), mirroring the 1-step darkening
* already applied to the accent token (`#2563eb` blue-600 `#1d4ed8`
* blue-700). `blue` here is the "current stage" / info color (e.g.
* PipelineMap's `current` state, unrelated to the `accent` CSS-variable
* token); `amber` is the warning/pending/auto-detected color used across
* badges, banners, and the pipeline's dashed "auto: <stage>" chip. `950` is
* left alone already the darkest step available.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
import { readdirSync, readFileSync, writeFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
const SHIFT = {
50: 100,
100: 200,
200: 300,
300: 400,
400: 500,
500: 600,
600: 700,
700: 800,
800: 900,
};
const COLORS = ["blue", "amber"];
const PROPS = ["text", "bg", "border", "ring", "from", "to", "via", "fill"];
// Descending by `from` — JS enumerates integer-like object keys ascending
// regardless of source order, and shifting low-to-high would let an already
// -shifted "200" (from 100) get caught and shifted AGAIN by the 200 rule.
// Highest-first guarantees each original shade is only ever matched once.
const SHIFTS_DESC = Object.entries(SHIFT)
.map(([from, to]) => [Number(from), to])
.sort((a, b) => b[0] - a[0]);
function rewrite(text) {
let out = text;
for (const prop of PROPS) {
for (const color of COLORS) {
for (const [from, to] of SHIFTS_DESC) {
const re = new RegExp(`\\b${prop}-${color}-${from}(\\/[0-9]+)?\\b`, "g");
out = out.replace(re, (_m, opacity) => `${prop}-${color}-${to}${opacity || ""}`);
}
}
}
return out;
}
function walk(dir, files = []) {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
const st = statSync(full);
if (st.isDirectory()) {
if (entry === "node_modules") continue;
walk(full, files);
} else if (entry.endsWith(".tsx")) {
files.push(full);
}
}
return files;
}
const scriptDir = fileURLToPath(new URL(".", import.meta.url));
const root = join(scriptDir, "..", "client", "src");
const files = walk(root);
let changed = 0;
for (const file of files) {
const before = readFileSync(file, "utf8");
const after = rewrite(before);
if (after !== before) {
writeFileSync(file, after);
changed++;
}
}
console.log(`rewrote ${changed} file(s)`);
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env node
/**
* @file One-off codemod: rewrites raw Tailwind gray-scale utility classes
* (neutral, gray, slate, zinc, stone shades) across client/src to the
* semantic surface/border/fg tokens introduced for dark/light mode.
* Status colors (emerald/red/amber) and the categorical hue palette
* (violet/indigo/cyan/teal/sky/rose/pink/orange/yellow) are deliberately
* out of scope see docs/superpowers/plans/2026-07-31-color-redesign-dark-light-mode.md.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
import { readdirSync, readFileSync, writeFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
const MAP = {
text: {
"gray-50": "fg-primary",
"gray-100": "fg-primary",
"gray-200": "fg-primary",
"neutral-50": "fg-primary",
"neutral-100": "fg-primary",
"gray-300": "fg-secondary",
"gray-400": "fg-secondary",
"neutral-200": "fg-secondary",
"neutral-300": "fg-secondary",
"neutral-400": "fg-secondary",
"slate-300": "fg-secondary",
"gray-500": "fg-muted",
"gray-600": "fg-muted",
"gray-700": "fg-muted",
"neutral-500": "fg-muted",
"neutral-600": "fg-muted",
},
placeholder: {
"gray-500": "fg-muted",
"gray-600": "fg-muted",
},
fill: {
"gray-100": "fg-primary",
"gray-300": "fg-secondary",
"gray-600": "fg-muted",
},
bg: {
"neutral-900": "surface-0",
"gray-900": "surface-0",
"neutral-800": "surface-2",
"gray-800": "surface-2",
"neutral-700": "surface-3",
"gray-700": "surface-3",
"neutral-500": "surface-4",
"gray-500": "surface-4",
"gray-400": "surface-4",
"gray-600": "surface-4",
"slate-500": "surface-4",
},
border: {
"neutral-800": "border",
"gray-800": "border",
"neutral-700": "border-light",
"gray-700": "border-light",
"neutral-500": "border-light",
"gray-500": "border-light",
"gray-600": "border-light",
},
ring: {
"slate-500": "border-light",
"slate-400": "border-light",
},
};
function rewrite(text) {
let out = text;
for (const [prop, shadeMap] of Object.entries(MAP)) {
for (const [rawShade, token] of Object.entries(shadeMap)) {
// Matches `bg-gray-500`, `bg-gray-500/10`, `bg-gray-500/50` etc. — the
// opacity suffix is preserved verbatim since the token's CSS var
// already supports `<alpha-value>`.
const re = new RegExp(`\\b${prop}-${rawShade}(\\/[0-9]+)?\\b`, "g");
out = out.replace(re, (_m, opacity) => `${prop}-${token}${opacity || ""}`);
}
}
return out;
}
function walk(dir, files = []) {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
const st = statSync(full);
if (st.isDirectory()) {
if (entry === "node_modules") continue;
walk(full, files);
} else if (entry.endsWith(".tsx")) {
files.push(full);
}
}
return files;
}
const scriptDir = fileURLToPath(new URL(".", import.meta.url));
const root = join(scriptDir, "..", "client", "src");
const files = walk(root);
let changed = 0;
for (const file of files) {
const before = readFileSync(file, "utf8");
const after = rewrite(before);
if (after !== before) {
writeFileSync(file, after);
changed++;
}
}
console.log(`rewrote ${changed} file(s)`);
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env node
/**
* @file One-off codemod: rewrites raw `emerald-*`/`red-*`/`amber-*` Tailwind
* utilities across client/src to the shared `status-success`/`status-danger`/
* `status-warning` CSS-variable tokens (see `src/index.css`), so every
* badge/button/component that means "success"/"danger"/"warning" uses the
* SAME shade per theme instead of each usage picking its own. The
* categorical/decorative hue palette (violet, indigo, cyan, teal, sky, rose,
* pink, orange, yellow, and `blue` which doubles as both "info" and a
* categorical role color in places like message bubbles) is deliberately
* left alone: those distinguish between different *kinds* of thing, not a
* status, and collapsing them would erase that distinction.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
import { readdirSync, readFileSync, writeFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
const COLOR_TO_STATUS = {
emerald: "status-success",
red: "status-danger",
amber: "status-warning",
};
const PROPS = ["text", "bg", "border", "ring", "from", "to", "via", "fill", "placeholder"];
function rewrite(text) {
let out = text;
for (const prop of PROPS) {
for (const [color, token] of Object.entries(COLOR_TO_STATUS)) {
// Shade number is dropped entirely — the token carries its own
// per-theme value, so `emerald-400`, `emerald-500`, `emerald-600` all
// collapse onto the one `status-success` (that collapse IS the fix:
// no more per-usage shade picking). Opacity suffix (`/10`, `/60`) is
// preserved verbatim.
const re = new RegExp(`\\b${prop}-${color}-[0-9]+(\\/\\[[0-9.]+\\]|\\/[0-9]+)?`, "g");
out = out.replace(re, (_m, opacity) => `${prop}-${token}${opacity || ""}`);
}
}
return out;
}
function walk(dir, files = []) {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
const st = statSync(full);
if (st.isDirectory()) {
if (entry === "node_modules") continue;
walk(full, files);
} else if (entry.endsWith(".tsx")) {
files.push(full);
}
}
return files;
}
const scriptDir = fileURLToPath(new URL(".", import.meta.url));
const root = join(scriptDir, "..", "client", "src");
const files = walk(root);
let changed = 0;
for (const file of files) {
const before = readFileSync(file, "utf8");
const after = rewrite(before);
if (after !== before) {
writeFileSync(file, after);
changed++;
}
}
console.log(`rewrote ${changed} file(s)`);