Compare commits

...

4 Commits

Author SHA1 Message Date
nntrivi2001 72f74f9104 Merge pull request 'fix(lanes): stop the stuck "waiting for your input" banner and idle status' (#1) from fix/lane-waiting-and-running-status into main
Reviewed-on: #1
2026-07-31 04:13:03 +00:00
nntrivi2001 b673363351 feat(theme): dark/light mode with a Radix Colors-based palette
Adds a working Dark/Light toggle (next to the language switcher, same row
as EN/VI) and re-themes the whole dashboard, not just the handful of
components that already used semantic tokens.

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

Categorical/decorative hues (violet, indigo, cyan, teal, sky, rose, pink,
orange, yellow, and blue where it plays a role-coloring part e.g. message
bubbles) are deliberately out of scope — collapsing those onto shared
tokens would erase the distinction between different kinds of thing, not
a status.
2026-07-31 10:54:31 +07:00
nntrivi2001 4905d63b97 fix(lanes): clear stale detection on real stage transitions, exclude scratch dirs from implement detect
setStage() left detected_stage/signal/at untouched across a real transition,
so a prior task's leftover inference (e.g. `tests` from earlier work) both
misrepresented a fresh task's progress and — because recordDetection is
forward-only — silently rejected every real detection behind it until the
old one aged past DETECTION_TTL_MS. A real stage change now clears the
detection columns; a same-stage heartbeat leaves a live detection alone.

Also excludes `.superpowers/` from the `implement` node's Write detect rule
- brainstorm-companion scratch files were being counted as implementation
work.

fix(client): disable Node's default --experimental-webstorage in tests

Node 25 enables --experimental-webstorage by default, which defines a
broken global `localStorage` (no backing file configured) ahead of jsdom's
own full polyfill. Every test touching real localStorage failed with
"localStorage.clear/getItem is not a function" — not flaky, not test-specific,
just this one Node default. NODE_OPTIONS=--no-experimental-webstorage on the
test scripts lets jsdom's polyfill take over as before.
2026-07-31 10:53:07 +07:00
nntrivi2001 9b1fa67385 fix(lanes): stop the stuck "waiting for your input" banner and idle status
Two lane-card symptoms, one source: touchLaneFromHook.

needs_action was stamped on every Notification hook, including Claude
Code's bare idle nudge ("Claude is waiting for your input"), which fires
~60s AFTER Stop. Nothing later arrives to clear it, so the card kept a
permanent amber "needs you" while the user was simply not typing. The
nudge now clears the flag instead of raising it - it proves the CLI is
parked at an idle prompt. Permission/AskUserQuestion messages are
unchanged.

lane.status only moved through the dashboard run lifecycle (run_id), so
a lane driven by `claude` in a terminal read "idle" for the whole time
Claude was working in it. For lanes the dashboard did not launch, the
turn hooks now mirror it: UserPromptSubmit/PreToolUse/PostToolUse ->
running, Stop/SessionEnd/idle-nudge -> idle. SubagentStop is excluded (a
subagent finishing is not the end of the turn) and provisioning/failed
are never stomped.

Side effect: classifyLiveness treats status=running as "expect live", so
a turn with no hook for more than LANE_DEAD_SEC (300s) now shows the dead
dot. That is the detector working as designed; raise LANE_DEAD_SEC if a
long single Bash trips it.
2026-07-30 15:27:26 +07:00
88 changed files with 3950 additions and 2593 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 |
| **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 |
| **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 |
| **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 |
+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
- **TypeScript 5.7** - Full type safety across components, utilities, and API contracts
- **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
- **WebSocket** - Real-time event streaming from server
- **Lucide Icons** - Modern, consistent icon set
@@ -203,17 +203,18 @@ client/
│ │
│ ├── hooks/
│ │ ├── 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)
│ ├── App.tsx # Root component + router setup
│ ├── 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)
├── index.html # HTML template
├── 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
└── package.json
```
+2 -2
View File
@@ -7,8 +7,8 @@
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
"test": "NODE_OPTIONS=--no-experimental-webstorage vitest run",
"test:watch": "NODE_OPTIONS=--no-experimental-webstorage vitest"
},
"dependencies": {
"@fontsource/inter": "^5.2.8",
+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" />}
</div>
<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
229d93fd" or "Main Agent - work - e3f8e613") swap the
placeholder for the real session name when one exists; custom
(sub)agent names are left untouched. */}
{isMain ? mainAgentDisplayName(agent.name, realSessionName) : agent.name}
</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>
{/* compact: cards are narrow inline reason chip would squeeze the
@@ -210,10 +210,10 @@ export function AgentCard({ agent, session, label, onClick }: AgentCardProps) {
</div>
{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 && (
<span className="flex items-center gap-1 flex-shrink-0">
<Wrench className="w-3 h-3" />
@@ -243,7 +243,7 @@ export function AgentCard({ agent, session, label, onClick }: AgentCardProps) {
{t("ran")}
{formatDuration(agent.started_at, agent.ended_at)}
</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">
+33 -33
View File
@@ -403,8 +403,8 @@ export function AlertsNotifications() {
onClick={() => setTab(tb.key)}
className={`inline-flex items-center gap-2 text-xs font-medium px-3.5 py-2 rounded-lg transition-colors ${
active
? "bg-surface-4 text-gray-100 shadow-sm"
: "text-gray-500 hover:text-gray-300 hover:bg-surface-3"
? "bg-surface-4 text-fg-primary shadow-sm"
: "text-fg-muted hover:text-fg-secondary hover:bg-surface-3"
}`}
>
<Icon className="w-3.5 h-3.5" />
@@ -413,10 +413,10 @@ export function AlertsNotifications() {
<span
className={`text-[10px] font-semibold rounded-full px-1.5 min-w-[18px] text-center ${
tb.key === "activity"
? "text-amber-300 bg-amber-500/15"
? "text-status-warning bg-status-warning/15"
: active
? "text-accent bg-accent/15"
: "text-gray-400 bg-surface-2"
: "text-fg-secondary bg-surface-2"
}`}
>
{tb.badge}
@@ -432,8 +432,8 @@ export function AlertsNotifications() {
<div className="card p-4">
<div className="flex items-center justify-between gap-3 mb-3">
<div>
<h4 className="text-sm font-semibold text-gray-200">{t("rules.title")}</h4>
<p className="text-xs text-gray-500 mt-0.5">{ts("alertsHub.rulesHint")}</p>
<h4 className="text-sm font-semibold text-fg-secondary">{t("rules.title")}</h4>
<p className="text-xs text-fg-muted mt-0.5">{ts("alertsHub.rulesHint")}</p>
</div>
<button
onClick={() => {
@@ -450,7 +450,7 @@ export function AlertsNotifications() {
{formOpen && (
<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">
<label className="block text-xs text-gray-400">
<label className="block text-xs text-fg-secondary">
<span className="inline-flex items-center gap-1">
{t("rules.form.name")}
<FieldHelp description={t("rules.help.name")} />
@@ -463,7 +463,7 @@ export function AlertsNotifications() {
className="input mt-1 w-full"
/>
</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">
{t("rules.form.type")}
<FieldHelp title={t("rules.form.type")} description={t("rules.help.type")} />
@@ -480,16 +480,16 @@ export function AlertsNotifications() {
</option>
))}
</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>
</label>
</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" && (
<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">
{t("rules.form.eventType")}
<FieldHelp
@@ -506,7 +506,7 @@ export function AlertsNotifications() {
className="input mt-1 w-full"
/>
</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">
{t("rules.form.toolName")}
<FieldHelp
@@ -523,7 +523,7 @@ export function AlertsNotifications() {
className="input mt-1 w-full"
/>
</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">
{t("rules.form.summaryContains")}
<FieldHelp
@@ -540,7 +540,7 @@ export function AlertsNotifications() {
className="input mt-1 w-full"
/>
</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">
{t("rules.form.count")}
<FieldHelp description={t("rules.help.count")} />
@@ -554,7 +554,7 @@ export function AlertsNotifications() {
/>
</label>
{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">
{t("rules.form.windowMinutes")}
<FieldHelp description={t("rules.help.window")} />
@@ -574,7 +574,7 @@ export function AlertsNotifications() {
{(form.rule_type === "inactivity" || form.rule_type === "status_duration") && (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
{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">
{t("rules.form.agentStatus")}
<FieldHelp description={t("rules.help.status")} />
@@ -588,11 +588,11 @@ export function AlertsNotifications() {
<option value="working">working</option>
<option value="waiting">waiting</option>
</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>
</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">
{t("rules.form.minutes")}
<FieldHelp
@@ -616,7 +616,7 @@ export function AlertsNotifications() {
{form.rule_type === "token_threshold" && (
<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">
{t("rules.form.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">
<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">
{t("rules.form.cooldown")}
<FieldHelp description={t("rules.help.cooldown")} />
@@ -655,7 +655,7 @@ export function AlertsNotifications() {
{saving ? t("rules.saving") : t("rules.create")}
</button>
</div>
{formError && <p className="text-xs text-red-400">{formError}</p>}
{formError && <p className="text-xs text-status-danger">{formError}</p>}
</div>
)}
@@ -680,7 +680,7 @@ export function AlertsNotifications() {
<div className="min-w-0">
<div className="flex items-center gap-2">
<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}
</span>
@@ -688,7 +688,7 @@ export function AlertsNotifications() {
{t(`ruleTypes.${rule.rule_type}`)}
</span>
</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)} ·{" "}
{t("rules.cooldown", { seconds: rule.cooldown_seconds })}
</p>
@@ -698,8 +698,8 @@ export function AlertsNotifications() {
onClick={() => onToggleRule(rule)}
className={`text-xs px-2.5 py-1.5 rounded-md border transition-colors ${
rule.enabled
? "border-emerald-500/30 text-emerald-400 hover:bg-emerald-500/10"
: "border-border text-gray-500 hover:text-gray-300 hover:bg-surface-3"
? "border-status-success/30 text-status-success hover:bg-status-success/10"
: "border-border text-fg-muted hover:text-fg-secondary hover:bg-surface-3"
}`}
title={rule.enabled ? t("rules.disable") : t("rules.enable")}
>
@@ -707,7 +707,7 @@ export function AlertsNotifications() {
</button>
<button
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")}
aria-label={t("rules.delete")}
>
@@ -728,10 +728,10 @@ export function AlertsNotifications() {
{tab === "activity" && (
<div className="card p-4">
<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")}
{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 })}
</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 ${
alert.acknowledged_at
? "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="flex items-center gap-2">
<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>
<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}
{alert.session_id && (
<>
@@ -810,7 +810,7 @@ export function AlertsNotifications() {
{!alert.acknowledged_at && (
<button
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" />
{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} />}
</span>
{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}
</span>
)}
+6 -6
View File
@@ -133,16 +133,16 @@ export function ConfirmModal({
>
<div className="flex items-start gap-3 p-5">
{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">
<AlertTriangle className="w-4.5 h-4.5 text-red-400" />
<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-status-danger" />
</div>
)}
<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}
</h3>
{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}
</p>
)}
@@ -151,7 +151,7 @@ export function ConfirmModal({
<button
type="button"
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}
>
<X className="w-4 h-4" />
@@ -172,7 +172,7 @@ export function ConfirmModal({
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 ${
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"
}`}
>
+10 -10
View File
@@ -180,7 +180,7 @@ export function DateTimePicker({
? "bg-accent text-white font-medium"
: isToday
? "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}
@@ -202,12 +202,12 @@ export function DateTimePicker({
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`}
>
<Calendar className="w-3.5 h-3.5 text-gray-400 shrink-0" />
<span className={`flex-1 truncate ${!dateObj ? "text-gray-500" : "text-gray-200"}`}>
<Calendar className="w-3.5 h-3.5 text-fg-secondary shrink-0" />
<span className={`flex-1 truncate ${!dateObj ? "text-fg-muted" : "text-fg-secondary"}`}>
{dateObj ? formatDisplay(dateObj) : placeholder}
</span>
{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>
@@ -222,11 +222,11 @@ export function DateTimePicker({
onClick={() =>
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" />
</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" })}
</span>
<button
@@ -234,7 +234,7 @@ export function DateTimePicker({
onClick={() =>
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" />
</button>
@@ -244,7 +244,7 @@ export function DateTimePicker({
<div>
<div className="grid grid-cols-7 gap-1 mb-1">
{["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}
</div>
))}
@@ -254,7 +254,7 @@ export function DateTimePicker({
{/* Time Picker */}
<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" />
<span className="text-[11px] font-medium">Time</span>
</div>
@@ -262,7 +262,7 @@ export function DateTimePicker({
type="time"
value={timeValue}
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>
+3 -3
View File
@@ -91,10 +91,10 @@ export function EmptyState({ icon: Icon, title, description, action }: EmptyStat
return (
<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">
<Icon className="w-6 h-6 text-gray-500" />
<Icon className="w-6 h-6 text-fg-muted" />
</div>
<h3 className="text-base font-medium text-gray-300 mb-2">{title}</h3>
<p className="text-sm text-gray-500 max-w-md mb-6">{description}</p>
<h3 className="text-base font-medium text-fg-secondary mb-2">{title}</h3>
<p className="text-sm text-fg-muted max-w-md mb-6">{description}</p>
{action}
</div>
);
+13 -13
View File
@@ -284,7 +284,7 @@ function SummaryBlock({
return (
<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">
<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")}
</span>
</div>
@@ -293,19 +293,19 @@ function SummaryBlock({
<span className="text-base leading-none" aria-hidden="true">
{summary.icon}
</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}
</span>
</div>
{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) => (
<li key={i}>{b}</li>
))}
</ul>
)}
{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}
</div>
)}
@@ -337,7 +337,7 @@ function FieldRow({
if (view) {
return (
<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>
);
@@ -348,7 +348,7 @@ function FieldRow({
if (view) {
return (
<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>
);
@@ -358,8 +358,8 @@ function FieldRow({
if (isInlineScalar(value)) {
return (
<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-gray-300 font-mono break-all">
<div className="text-fg-muted font-mono pt-0.5">{label}</div>
<div className="text-fg-secondary font-mono break-all">
<ScalarValue value={value} />
</div>
</div>
@@ -368,7 +368,7 @@ function FieldRow({
return (
<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} />
</div>
);
@@ -382,11 +382,11 @@ function isInlineScalar(value: unknown): boolean {
}
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") {
const color = value
? "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 (
<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 (
<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">
<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"}
</span>
<CopyButton text={text} />
</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}
</pre>
</div>
+8 -8
View File
@@ -232,16 +232,16 @@ export function EventFilters({
return (
<div className="card p-3 space-y-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]">
<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
type="text"
value={searchDraft}
onChange={(e) => setSearchDraft(e.target.value)}
placeholder={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>
<DateTimePicker
@@ -251,7 +251,7 @@ export function EventFilters({
title={t("eventFilters.from")}
placeholder={t("eventFilters.from")}
/>
<span className="text-xs text-gray-600"></span>
<span className="text-xs text-fg-muted"></span>
<DateTimePicker
value={value.to}
onChange={(val: string) => onChange({ ...value, to: val })}
@@ -263,7 +263,7 @@ export function EventFilters({
<button
type="button"
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")}
>
<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 ${
selectedCount > 0
? "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>
@@ -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"
>
{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")}
</p>
) : (
@@ -384,7 +384,7 @@ function ChipGroup({
return (
<label
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
type="checkbox"
+14 -12
View File
@@ -71,17 +71,17 @@ export function EventFiltersInfo() {
const { t } = useTranslation("common");
return (
<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" />
<span className="font-semibold uppercase tracking-wide mr-1.5">
{t("eventFilters.help.title")}
</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>
<div className="divide-y divide-border">
<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]">
<dt>
<AgentStatusBadge status="working" />
@@ -103,17 +103,19 @@ export function EventFiltersInfo() {
</Section>
<Section title={t("eventFilters.help.lifecycleTitle")}>
<p className="text-[11px] text-gray-400 mb-2">{t("eventFilters.help.lifecycleDesc")}</p>
<code className="block bg-black/40 border border-border rounded p-2 text-[11px] font-mono text-gray-300 whitespace-pre-wrap">
<p className="text-[11px] text-fg-secondary mb-2">
{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")}
</code>
</Section>
<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.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.filterTip4")}</li>
</ul>
@@ -157,9 +159,9 @@ export function EventFiltersInfo() {
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<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">
<span className="text-gray-500 transition-transform group-open:rotate-90"></span>
<span className="font-semibold uppercase tracking-wide text-gray-400">{title}</span>
<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-fg-muted transition-transform group-open:rotate-90"></span>
<span className="font-semibold uppercase tracking-wide text-fg-secondary">{title}</span>
</summary>
<div className="px-3 pb-3 pt-1">{children}</div>
</details>
@@ -170,8 +172,8 @@ function Section({ title, children }: { title: string; children: React.ReactNode
function Field({ label, desc }: { label: string; desc: string }) {
return (
<>
<dt className="font-semibold text-gray-300 whitespace-nowrap">{label}</dt>
<dd className="text-gray-400">{desc}</dd>
<dt className="font-semibold text-fg-secondary whitespace-nowrap">{label}</dt>
<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();
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" />
</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"
>
{title && <p className="text-xs font-semibold text-gray-200 mb-1">{title}</p>}
<p className="text-[11px] leading-relaxed text-gray-400">{description}</p>
{title && <p className="text-xs font-semibold text-fg-secondary mb-1">{title}</p>}
<p className="text-[11px] leading-relaxed text-fg-secondary">{description}</p>
{examples && examples.length > 0 && (
<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")}
</p>
<div className="flex flex-wrap gap-1">
@@ -173,7 +173,7 @@ export function FieldHelp({ title, description, examples, note }: FieldHelpProps
</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>,
document.body
)}
+61 -61
View File
@@ -285,12 +285,12 @@ export function ImportHistory() {
return (
<section>
<h3 className="text-sm font-medium text-gray-300 flex items-center gap-2 mb-1">
<History className="w-4 h-4 text-gray-500" />
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
<History className="w-4 h-4 text-fg-muted" />
{t("import.title")}
</h3>
<p className="text-xs text-gray-500 mb-1">{t("import.description")}</p>
<p className="text-[11px] text-gray-600 italic mb-4 leading-snug">{t("cursorPathsNote")}</p>
<p className="text-xs text-fg-muted mb-1">{t("import.description")}</p>
<p className="text-[11px] text-fg-muted italic mb-4 leading-snug">{t("cursorPathsNote")}</p>
<div className="card p-5 space-y-5">
{/* Step-by-step instructions */}
@@ -299,33 +299,33 @@ export function ImportHistory() {
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"
>
<span className="flex items-center gap-2 text-xs font-semibold text-gray-300 uppercase tracking-wider">
<ListChecks className="w-3.5 h-3.5 text-blue-400" />
<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-500" />
{t("import.instructions")}
</span>
<span className="text-[11px] text-gray-500">{instructionsOpen ? "▾" : "▸"}</span>
<span className="text-[11px] text-fg-muted">{instructionsOpen ? "▾" : "▸"}</span>
</button>
{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 */}
{guide && (
<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" />
<span className="text-gray-400">{t("import.defaultLocation")}:</span>
<code className="font-mono text-gray-200 truncate">
<HardDrive className="w-3.5 h-3.5 text-fg-muted flex-shrink-0" />
<span className="text-fg-secondary">{t("import.defaultLocation")}:</span>
<code className="font-mono text-fg-secondary truncate">
{guide.default_projects_dir_display}
</code>
{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" />
{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.jsonl_files} {t("import.jsonlLabel")}
</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" />
{t("import.locationMissing")}
</span>
@@ -339,13 +339,13 @@ export function ImportHistory() {
<Step title={t("import.stepArchive")} body={t("import.stepArchiveBody")}>
{guide && (
<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" />
<code className="flex-1 text-xs font-mono text-gray-200 truncate">
<Terminal className="w-3.5 h-3.5 text-fg-muted flex-shrink-0" />
<code className="flex-1 text-xs font-mono text-fg-secondary truncate">
{guide.archive_command}
</code>
<button
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 ? (
<>
@@ -364,7 +364,7 @@ export function ImportHistory() {
<Step title={t("import.stepVerify")} body={t("import.stepVerifyBody")} />
</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" />
<span>{t("import.accuracyNote")}</span>
</div>
@@ -409,8 +409,8 @@ export function ImportHistory() {
{mode === "rescan" && (
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3 min-w-0">
<FolderOpen className="w-4 h-4 text-gray-500 flex-shrink-0" />
<code className="font-mono text-xs text-gray-300 truncate">
<FolderOpen className="w-4 h-4 text-fg-muted flex-shrink-0" />
<code className="font-mono text-xs text-fg-secondary truncate">
{guide?.default_projects_dir_display || "~/.claude/projects"}
</code>
</div>
@@ -440,7 +440,7 @@ export function ImportHistory() {
className="input w-full text-sm font-mono"
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 className="flex justify-end">
<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 ${
dragging
? "border-blue-400 bg-blue-500/5"
: "border-border hover:border-gray-500 bg-surface-1"
? "border-blue-500 bg-blue-600/5"
: "border-border hover:border-border-light bg-surface-1"
}`}
>
<UploadCloud className="w-6 h-6 text-gray-500 mx-auto mb-2" />
<p className="text-sm text-gray-300">{t("import.dropzoneHint")}</p>
<p className="text-[11px] text-gray-500 mt-1">{t("import.dropzoneSub")}</p>
<UploadCloud className="w-6 h-6 text-fg-muted mx-auto mb-2" />
<p className="text-sm text-fg-secondary">{t("import.dropzoneHint")}</p>
<p className="text-[11px] text-fg-muted mt-1">{t("import.dropzoneSub")}</p>
<input
ref={fileInputRef}
type="file"
@@ -493,17 +493,17 @@ export function ImportHistory() {
</div>
{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">
<span className="text-gray-400">
<FileArchive className="w-3.5 h-3.5 inline mr-1.5 text-gray-500" />
<span className="text-fg-secondary">
<FileArchive className="w-3.5 h-3.5 inline mr-1.5 text-fg-muted" />
{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>
<button
onClick={() => {
setFiles([]);
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")}
</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 ${
dragging
? "border-blue-400 bg-blue-500/5"
: "border-border hover:border-gray-500 bg-surface-1"
? "border-blue-500 bg-blue-600/5"
: "border-border hover:border-border-light bg-surface-1"
}`}
>
<DatabaseBackup className="w-6 h-6 text-gray-500 mx-auto mb-2" />
<p className="text-sm text-gray-300">{t("import.backupHint")}</p>
<p className="text-[11px] text-gray-500 mt-1">{t("import.backupSub")}</p>
<DatabaseBackup className="w-6 h-6 text-fg-muted mx-auto mb-2" />
<p className="text-sm text-fg-secondary">{t("import.backupHint")}</p>
<p className="text-[11px] text-fg-muted mt-1">{t("import.backupSub")}</p>
<input
ref={backupInputRef}
type="file"
@@ -560,17 +560,17 @@ export function ImportHistory() {
</div>
{backupFile && (
<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">
<FileArchive className="w-3.5 h-3.5 inline mr-1.5 text-gray-500" />
<span className="text-fg-secondary min-w-0">
<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="text-gray-600 ml-2">({formatBytes(backupFile.size)})</span>
<span className="text-fg-muted ml-2">({formatBytes(backupFile.size)})</span>
</span>
<button
onClick={() => {
setBackupFile(null);
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")}
</button>
@@ -596,11 +596,11 @@ export function ImportHistory() {
{/* In-flight progress */}
{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">
<Loader2 className="w-3.5 h-3.5 animate-spin text-blue-400 flex-shrink-0" />
<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-500 flex-shrink-0" />
<span className="truncate">{progressText}</span>
{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("/")}
</code>
)}
@@ -609,7 +609,7 @@ export function ImportHistory() {
{/* Errors */}
{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" />
<span>{errorMsg}</span>
</div>
@@ -617,8 +617,8 @@ export function ImportHistory() {
{/* Result summary */}
{result && !running && (
<div className="border border-emerald-500/20 bg-emerald-500/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="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-status-success uppercase tracking-wider">
<CheckCircle2 className="w-3.5 h-3.5" />
{t("import.result.title")}
</div>
@@ -626,26 +626,26 @@ export function ImportHistory() {
<ResultStat
label={t("import.result.imported", { count: result.imported })}
value={result.imported}
color="text-emerald-300"
color="text-status-success"
/>
<ResultStat
label={t("import.result.backfilled", { count: result.backfilled ?? 0 })}
value={result.backfilled ?? 0}
color="text-blue-300"
color="text-blue-400"
/>
<ResultStat
label={t("import.result.skipped", { count: result.skipped })}
value={result.skipped}
color="text-gray-400"
color="text-fg-secondary"
/>
<ResultStat
label={t("import.result.errors", { count: 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>
{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 })}
{result.path ? ` · ${result.path}` : ""}
</p>
@@ -655,8 +655,8 @@ export function ImportHistory() {
{/* Restore-from-backup result summary */}
{backupResult && !running && (
<div className="border border-emerald-500/20 bg-emerald-500/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="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-status-success uppercase tracking-wider">
<CheckCircle2 className="w-3.5 h-3.5" />
{t("import.backupResult.title")}
</div>
@@ -666,14 +666,14 @@ export function ImportHistory() {
count: backupResult.sessions_imported,
})}
value={backupResult.sessions_imported}
color="text-emerald-300"
color="text-status-success"
/>
<ResultStat
label={t("import.backupResult.sessionsSkipped", {
count: backupResult.sessions_skipped,
})}
value={backupResult.sessions_skipped}
color="text-gray-400"
color="text-fg-secondary"
/>
<ResultStat
label={t("import.backupResult.events", { count: backupResult.events })}
@@ -686,7 +686,7 @@ export function ImportHistory() {
color="text-cyan-300"
/>
</div>
<p className="text-[11px] text-gray-500">
<p className="text-[11px] text-fg-muted">
{t("import.backupResult.detail", {
agents: backupResult.agents,
workflows: backupResult.workflows,
@@ -712,8 +712,8 @@ function Step({
}) {
return (
<div>
<p className="text-sm font-medium text-gray-200">{title}</p>
<p className="text-xs text-gray-400 mt-1 whitespace-pre-line">{body}</p>
<p className="text-sm font-medium text-fg-secondary">{title}</p>
<p className="text-xs text-fg-secondary mt-1 whitespace-pre-line">{body}</p>
{children}
</div>
);
@@ -737,19 +737,19 @@ function ModeButton({
onClick={onClick}
className={`text-left p-3 rounded-lg border transition-colors ${
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"
}`}
>
<div
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}
{title}
</div>
<p className="text-[11px] text-gray-500 leading-snug">{desc}</p>
<p className="text-[11px] text-fg-muted leading-snug">{desc}</p>
</button>
);
}
@@ -758,7 +758,7 @@ function ResultStat({ label, value, color }: { label: string; value: number; col
return (
<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-[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>
);
}
+43 -40
View File
@@ -101,14 +101,17 @@ const EMPTY_FORM: RemoteSourceInput = {
/** Compact status pill for a source's last-known sync state. */
function StatusPill({ status }: { status: RemoteSource["status"] }) {
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: {
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",
pulse: true,
},
ok: { cls: "text-emerald-300 bg-emerald-500/10 border-emerald-500/25", label: "OK" },
error: { cls: "text-red-300 bg-red-500/10 border-red-500/25", label: "Error" },
ok: { cls: "text-status-success bg-status-success/10 border-status-success/25", label: "OK" },
error: {
cls: "text-status-danger bg-status-danger/10 border-status-danger/25",
label: "Error",
},
};
const s = map[status] || map.idle;
return (
@@ -318,17 +321,17 @@ export function RemoteSources() {
return (
<div>
<h3 className="text-sm font-medium text-gray-300 flex items-center gap-2 mb-1">
<Cloud className="w-4 h-4 text-gray-500" />
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
<Cloud className="w-4 h-4 text-fg-muted" />
{t("remoteSources.title", "Remote Data Sources")}
</h3>
<p className="text-xs text-gray-500 mb-4">
<p className="text-xs text-fg-muted mb-4">
{t(
"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."
)}
</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(
"cursorPathsNote",
"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="flex items-center gap-2 mb-1">
<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")}
</span>
</div>
<p className="text-xs text-gray-500 mb-3">
<p className="text-xs text-fg-muted mb-3">
{t(
"remoteSources.scopeDesc",
"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 && (
<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
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}
</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" && (
<div className="text-[11px] text-accent mt-1">
{t("remoteSources.scopeSelectedCount", "{{n}} of {{total}} selected", {
@@ -420,7 +423,7 @@ export function RemoteSources() {
</div>
{scope.mode === "selected" && (
<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")}
</div>
<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 ${
on
? "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" />}
@@ -449,7 +452,7 @@ export function RemoteSources() {
{/* Sources list header + add button */}
<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")}
</span>
<div className="flex items-center gap-2">
@@ -473,14 +476,14 @@ export function RemoteSources() {
{/* Add/Edit form */}
{showForm && (
<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
? t("remoteSources.editTitle", "Edit source")
: t("remoteSources.addTitle", "Add a remote source")}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<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")} *
</label>
<input
@@ -491,7 +494,7 @@ export function RemoteSources() {
/>
</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")} *
</label>
<input
@@ -502,7 +505,7 @@ export function RemoteSources() {
/>
</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)")}
</label>
<input
@@ -519,7 +522,7 @@ export function RemoteSources() {
/>
</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)")}
</label>
<input
@@ -530,7 +533,7 @@ export function RemoteSources() {
/>
</div>
<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)")}
</label>
<input
@@ -539,7 +542,7 @@ export function RemoteSources() {
value={form.remote_home ?? ""}
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(
"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."
@@ -554,12 +557,12 @@ export function RemoteSources() {
checked={!!form.enabled}
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")}
</span>
</label>
{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}
</div>
)}
@@ -585,14 +588,14 @@ export function RemoteSources() {
{/* Sources list */}
{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 ? (
<div className="card p-6 text-center">
<Server className="w-6 h-6 text-gray-600 mx-auto mb-2" />
<p className="text-sm text-gray-400">
<Server className="w-6 h-6 text-fg-muted mx-auto mb-2" />
<p className="text-sm text-fg-secondary">
{t("remoteSources.empty", "No remote sources yet.")}
</p>
<p className="text-xs text-gray-600 mt-1">
<p className="text-xs text-fg-muted mt-1">
{t(
"remoteSources.emptyHint",
"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="flex items-center gap-2 flex-wrap">
<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} />
{!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")}
</span>
)}
{s.session_count != null && s.session_count > 0 && (
<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(
"remoteSources.sessionCountHint",
"Sessions linked to this source (not every session visible when data scope is All)"
@@ -631,12 +634,12 @@ export function RemoteSources() {
</span>
)}
</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.ssh_port ? `:${s.ssh_port}` : ""}
{s.remote_home ? ` · ${s.remote_home}` : ""}
</div>
<div className="text-[11px] text-gray-600 mt-1">
<div className="text-[11px] text-fg-muted mt-1">
{s.last_sync_at
? t("remoteSources.lastSync", "Last sync: {{when}}", {
when: new Date(s.last_sync_at).toLocaleString(),
@@ -652,14 +655,14 @@ export function RemoteSources() {
})}`}
</div>
{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}
</div>
)}
{test && test.message && (
<div
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 ? (
@@ -712,7 +715,7 @@ export function RemoteSources() {
</button>
<button
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")}
>
<Trash2 className="w-3.5 h-3.5" />
@@ -723,7 +726,7 @@ export function RemoteSources() {
{/* Inline delete confirmation */}
{confirmDelete?.id === s.id && (
<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?")}
</p>
<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 })
}
/>
<span className="text-xs text-gray-400">
<span className="text-xs text-fg-secondary">
{t(
"remoteSources.purgeData",
"Also delete the sessions imported from this source (cannot be undone)"
@@ -746,7 +749,7 @@ export function RemoteSources() {
<button
onClick={doDelete}
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 ? (
<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}
onClick={() => setOpen((v) => !v)}
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>
<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>
{open && (
<div
@@ -207,7 +207,7 @@ export function Select<T extends string>({ value, onChange, options, disabled }:
<div className="flex items-center gap-2">
<span
className={`text-[11px] flex-1 truncate ${
isSelected ? "text-accent font-medium" : "text-gray-200"
isSelected ? "text-accent font-medium" : "text-fg-secondary"
}`}
>
{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" />}
</div>
{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>
);
+5 -5
View File
@@ -113,8 +113,8 @@ export function SessionCard({ session, onClick }: SessionCardProps) {
<FolderOpen className="w-3.5 h-3.5" />
</div>
<div className="min-w-0 overflow-hidden">
<p className="text-sm font-medium text-gray-200 truncate">{title}</p>
<p className="text-[11px] text-gray-500 font-mono truncate">
<p className="text-sm font-medium text-fg-secondary truncate">{title}</p>
<p className="text-[11px] text-fg-muted font-mono truncate">
{session.id.slice(0, 12)}
</p>
</div>
@@ -125,12 +125,12 @@ export function SessionCard({ session, onClick }: SessionCardProps) {
</div>
{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}
</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">
<Bot className="w-3 h-3" />
{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("running")}${formatDuration(session.started_at, new Date().toISOString())}`}
</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)}
</span>
</div>
+45 -42
View File
@@ -100,35 +100,35 @@ function StatTile({
tone?: "default" | "violet" | "emerald" | "amber" | "rose" | "cyan" | "blue";
}) {
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",
emerald: "border-emerald-500/20 bg-emerald-500/5 text-emerald-200",
amber: "border-amber-500/20 bg-amber-500/5 text-amber-200",
emerald: "border-status-success/20 bg-status-success/5 text-status-success",
amber: "border-status-warning/20 bg-status-warning/5 text-status-warning",
rose: "border-rose-500/20 bg-rose-500/5 text-rose-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];
const iconTone = {
default: "text-gray-500",
default: "text-fg-muted",
violet: "text-violet-400",
emerald: "text-emerald-400",
amber: "text-amber-400",
emerald: "text-status-success",
amber: "text-status-warning",
rose: "text-rose-400",
cyan: "text-cyan-400",
blue: "text-blue-400",
blue: "text-blue-500",
}[tone];
return (
<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>
{label}
</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}
</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>
);
}
@@ -156,7 +156,7 @@ function ToolUsageRow({ toolName, count, max }: { toolName: string; count: numbe
style={{ width: `${pct}%` }}
/>
</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()}
</span>
</div>
@@ -286,23 +286,26 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
<div className="space-y-5 mb-6">
{/* Active-agent banner - only shows when session is running */}
{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="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-60" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-emerald-400" />
<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-status-success" />
</span>
<Bot className="w-3.5 h-3.5 text-emerald-300 flex-shrink-0" />
<span className="text-xs text-emerald-200 font-medium flex-shrink-0">
<Bot className="w-3.5 h-3.5 text-status-success flex-shrink-0" />
<span className="text-xs text-status-success font-medium flex-shrink-0">
{activeAgent.name || "Agent"}
</span>
{activeAgent.current_tool && (
<span className="text-[11px] text-gray-400 font-mono inline-flex items-center gap-1">
<span className="text-gray-600">running</span>
<span className="text-emerald-300">{activeAgent.current_tool}</span>
<span className="text-[11px] text-fg-secondary font-mono inline-flex items-center gap-1">
<span className="text-fg-muted">running</span>
<span className="text-status-success">{activeAgent.current_tool}</span>
</span>
)}
{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}
</span>
)}
@@ -361,16 +364,16 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
{/* Tool usage */}
<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">
<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" />
Top tools
</h3>
<span className="text-[10px] text-gray-500 font-mono">
<span className="text-[10px] text-fg-muted font-mono">
{stats.tools_used.length} total
</span>
</div>
{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">
{stats.tools_used.slice(0, 8).map((t) => (
@@ -420,26 +423,26 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
return (
<>
<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" />
Subagents
</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>
{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.
</div>
) : (
<div className="space-y-1.5">
{rows.slice(0, 8).map((r) => {
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 (
<div key={r.key} className="flex items-center gap-2">
<span
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}
>
@@ -451,7 +454,7 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
style={{ width: `${pct}%` }}
/>
</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}
</span>
</div>
@@ -469,11 +472,11 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
{totalTokens > 0 && (
<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">
<h3 className="text-xs font-semibold text-gray-300 uppercase tracking-wider flex items-center gap-1.5">
<Coins className="w-3.5 h-3.5 text-amber-400" />
<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-status-warning" />
Token flow
</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>
<TokenFlowBar tokens={tokens} total={totalTokens} />
</div>
@@ -482,8 +485,8 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
{/* Event-type breakdown - secondary, only top 6 */}
{stats.events_by_type.length > 0 && (
<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">
<Activity className="w-3.5 h-3.5 text-gray-400" />
<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-fg-secondary" />
Event mix
</h3>
<div className="flex flex-wrap gap-1.5">
@@ -492,9 +495,9 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
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"
>
<span className="text-gray-400">{e.event_type}</span>
<span className="text-gray-500">·</span>
<span className="text-gray-200">{e.count.toLocaleString()}</span>
<span className="text-fg-secondary">{e.event_type}</span>
<span className="text-fg-muted">·</span>
<span className="text-fg-secondary">{e.count.toLocaleString()}</span>
</span>
))}
</div>
@@ -524,8 +527,8 @@ function TokenFlowBar({ tokens, total }: { tokens: SessionStats["tokens"]; total
key: "input",
label: "Input",
value: tokens.input_tokens,
cls: "bg-emerald-500",
text: "text-emerald-300",
cls: "bg-status-success",
text: "text-status-success",
},
{
key: "output",
@@ -558,11 +561,11 @@ function TokenFlowBar({ tokens, total }: { tokens: SessionStats["tokens"]; total
return (
<div key={s.key} className="flex items-center gap-2">
<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}`}>
{fmt(s.value)}
{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)}%
</span>
)}
+135 -75
View File
@@ -79,10 +79,13 @@ import {
Gauge,
ChevronUp,
ChevronDown,
Sun,
Moon,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { api } from "../lib/api";
import { eventBus } from "../lib/eventBus";
import { useTheme } from "../hooks/useTheme";
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
function isUpdatePayload(x: unknown): x is UpdateStatusPayload {
@@ -180,6 +183,7 @@ interface SidebarProps {
export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
const { t, i18n } = useTranslation();
const { theme, setTheme, toggleTheme } = useTheme();
// Track whether nav items are clipped by overflow so we can render
// chevron affordances pointing toward the hidden items. Recomputed on
// 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", {
language: t(`nav:languageNames.${nextLanguage}`),
});
const nextTheme = theme === "dark" ? "light" : "dark";
const switchThemeTitle = t("nav:switchTheme", { theme: t(`nav:themeNames.${nextTheme}`) });
const toggleLang = () => {
i18n.changeLanguage(nextLanguage);
@@ -401,8 +407,8 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
</div>
{!collapsed && (
<div className="min-w-0">
<h1 className="text-sm font-semibold text-gray-100 truncate">{t("nav:brand")}</h1>
<p className="text-[11px] text-gray-500">{t("nav:brandSub")}</p>
<h1 className="text-sm font-semibold text-fg-primary truncate">{t("nav:brand")}</h1>
<p className="text-[11px] text-fg-muted">{t("nav:brandSub")}</p>
</div>
)}
</div>
@@ -428,7 +434,7 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
} ${
isActive
? "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)}
aria-label={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 />
</button>
@@ -455,52 +461,106 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
onClick={() => scrollNavBy(160)}
aria-label={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 />
</button>
)}
</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">
{collapsed ? (
<button
onClick={toggleLang}
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"
title={switchLanguageTitle}
aria-label={switchLanguageTitle}
>
<Languages className="w-3.5 h-3.5" />
<span className="text-[10px] font-semibold leading-none">
{t(`nav:languageShort.${currentLanguage}`)}
</span>
</button>
<div className="flex flex-col gap-1">
<button
onClick={toggleLang}
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={switchLanguageTitle}
aria-label={switchLanguageTitle}
>
<Languages className="w-3.5 h-3.5" />
<span className="text-[10px] font-semibold leading-none">
{t(`nav:languageShort.${currentLanguage}`)}
</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">
<p className="px-1 text-[10px] font-semibold uppercase tracking-wider text-gray-500">
{t("nav:language")}
</p>
<div className="mt-2 grid grid-cols-4 gap-1">
{SUPPORTED_LANGUAGES.map((language) => {
const active = language === currentLanguage;
return (
<button
key={language}
onClick={() => changeLanguage(language)}
aria-pressed={active}
aria-label={t(`nav:languageNames.${language}`)}
title={t(`nav:languageNames.${language}`)}
className={`rounded-md px-2 py-1.5 text-[11px] font-semibold transition-colors ${
active
? "bg-accent/20 text-accent border border-accent/30"
: "bg-surface-1 text-gray-400 border border-border hover:bg-surface-3 hover:text-gray-200"
}`}
>
{t(`nav:languageShort.${language}`)}
</button>
);
})}
<div className="rounded-lg border border-border bg-surface-2 p-2 space-y-2">
<div className="flex items-center justify-between gap-2">
<div className="flex-1">
<p className="px-1 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
{t("nav:language")}
</p>
<div className="mt-2 grid grid-cols-4 gap-1">
{SUPPORTED_LANGUAGES.map((language) => {
const active = language === currentLanguage;
return (
<button
key={language}
onClick={() => changeLanguage(language)}
aria-pressed={active}
aria-label={t(`nav:languageNames.${language}`)}
title={t(`nav:languageNames.${language}`)}
className={`rounded-md px-2 py-1.5 text-[11px] font-semibold transition-colors ${
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>
);
})}
</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>
)}
@@ -512,8 +572,8 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
onClick={onToggle}
className={`w-full h-10 rounded-lg border border-border bg-surface-2 transition-colors ${
collapsed
? "flex items-center justify-center text-gray-400 hover:text-gray-200 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 justify-center text-fg-secondary hover:text-fg-primary 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")}
aria-label={collapsed ? t("nav:expand") : t("nav:collapse")}
@@ -551,7 +611,7 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
>
<span
className={`inline-flex items-center gap-2 ${
wsConnected ? "text-emerald-400" : "text-gray-500"
wsConnected ? "text-status-success" : "text-fg-muted"
}`}
>
{wsConnected ? (
@@ -566,7 +626,7 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
)}
</span>
{!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>
</button>
@@ -579,15 +639,15 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
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 ${
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
? "border-amber-500/40 text-amber-300 hover:bg-amber-500/10"
: "border-border text-gray-400 hover:text-gray-200 hover:bg-surface-3"
? "border-status-warning/40 text-status-warning hover:bg-status-warning/10"
: "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 />
{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>
) : (
@@ -598,10 +658,10 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
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 ${
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
? "border-amber-500/40 text-amber-300 hover:bg-amber-500/10"
: "border-border text-gray-300 hover:text-gray-100 hover:bg-surface-3"
? "border-status-warning/40 text-status-warning hover:bg-status-warning/10"
: "border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
}`}
>
<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>
{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>
)}
@@ -741,8 +801,8 @@ function ConnectionStatusModal({
<div
className={`w-9 h-9 rounded-lg border flex items-center justify-center flex-shrink-0 ${
wsConnected
? "bg-emerald-500/10 border-emerald-500/30 text-emerald-400"
: "bg-surface-3 border-border text-gray-400"
? "bg-status-success/10 border-status-success/30 text-status-success"
: "bg-surface-3 border-border text-fg-secondary"
}`}
>
{wsConnected ? (
@@ -754,19 +814,19 @@ function ConnectionStatusModal({
<div className="min-w-0">
<h2
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")}
</h2>
<p
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 && (
<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="relative inline-flex rounded-full h-1.5 w-1.5 bg-emerald-400" />
<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-status-success" />
</span>
)}
{wsConnected ? t("nav:live") : t("nav:disconnected")}
@@ -777,7 +837,7 @@ function ConnectionStatusModal({
type="button"
onClick={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" />
</button>
@@ -835,7 +895,7 @@ function ConnectionStatusModal({
{/* Top event types */}
<Section title={t("nav:topEventTypes")} icon={BarChart3}>
{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">
{topTypes.map(([type, count]) => (
@@ -848,7 +908,7 @@ function ConnectionStatusModal({
{/* Recent activity */}
<Section title={t("nav:recentActivity")} icon={Clock}>
{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">
{recentEvents.map((evt, i) => (
@@ -856,8 +916,8 @@ function ConnectionStatusModal({
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"
>
<span className="text-gray-200 truncate">{evt.type}</span>
<span className="text-gray-500 flex-shrink-0">{formatRelative(evt.at, t)}</span>
<span className="text-fg-secondary truncate">{evt.type}</span>
<span className="text-fg-muted flex-shrink-0">{formatRelative(evt.at, t)}</span>
</li>
))}
</ul>
@@ -866,11 +926,11 @@ function ConnectionStatusModal({
</div>
<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
type="button"
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")}
</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">
<Icon className="w-3 h-3 text-accent" aria-hidden />
</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>
{children}
</section>
@@ -906,12 +966,12 @@ function Section({
function KpiTile({ label, value, unit }: { label: string; value: string; unit: string }) {
return (
<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}
</div>
<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-[10px] font-medium text-gray-500 truncate">{unit}</span>
<span className="text-base font-semibold text-fg-primary font-mono">{value}</span>
<span className="text-[10px] font-medium text-fg-muted truncate">{unit}</span>
</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 }) {
return (
<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}
</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}
</span>
</div>
@@ -946,8 +1006,8 @@ function TypeBar({
return (
<div className="text-[11px]">
<div className="flex items-center justify-between gap-2 mb-0.5">
<span className="font-mono text-gray-200 truncate">{type}</span>
<span className="text-gray-500 flex-shrink-0 font-mono">
<span className="font-mono text-fg-secondary truncate">{type}</span>
<span className="text-fg-muted flex-shrink-0 font-mono">
{count} · {sharePct}%
</span>
</div>
@@ -1005,7 +1065,7 @@ function Sparkline({
/>
)}
</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>{avgLabel}</span>
<span>{"now"}</span>
+3 -3
View File
@@ -98,7 +98,7 @@ export function StatCard({
return (
<div className="card p-5">
<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}
</span>
<Icon className={`w-5 h-5 flex-shrink-0 ${accentColor}`} />
@@ -108,11 +108,11 @@ export function StatCard({
<StatValueSkeleton />
) : (
<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>
)}
{!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>
+1 -1
View File
@@ -96,7 +96,7 @@ function ReasonChip({ reason }: { reason: AwaitingReason }) {
<span
className={`inline-flex items-center gap-1 rounded-full border px-1.5 text-[10px] font-medium leading-4 ${
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"
}`}
>
+21 -15
View File
@@ -113,15 +113,17 @@ export function TabbyPanel({
<span className="text-base leading-none" aria-hidden>
🐾
</span>
<span className="text-sm font-semibold text-gray-100">Tabby</span>
<span className="text-sm font-semibold text-fg-primary">Tabby</span>
<span
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
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
/>
@@ -129,7 +131,7 @@ export function TabbyPanel({
</span>
</div>
<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}
aria-label="Close Tabby"
>
@@ -191,7 +193,7 @@ export function TabbyPanel({
{/* ask */}
<form onSubmit={submit} className="flex items-center gap-1.5">
<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?)"
value={query}
onChange={(e) => setQuery(e.target.value)}
@@ -206,7 +208,7 @@ export function TabbyPanel({
</button>
</form>
{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}
</p>
)}
@@ -223,18 +225,22 @@ interface Tone {
const TONE_MUTED: Tone = {
wrap: "border-border bg-surface-1",
value: "text-gray-300",
icon: "text-gray-500",
value: "text-fg-secondary",
icon: "text-fg-muted",
};
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: {
wrap: "border-amber-500/30 bg-amber-500/10",
value: "text-amber-200",
icon: "text-amber-400",
wrap: "border-status-warning/30 bg-status-warning/10",
value: "text-status-warning",
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,
};
@@ -256,7 +262,7 @@ function StatChip({
<span className={`text-base font-semibold leading-none tabular-nums ${t.value}`}>
{value}
</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>
);
}
@@ -274,7 +280,7 @@ function ActionButton({
}) {
return (
<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}
disabled={disabled}
>
+1 -1
View File
@@ -144,7 +144,7 @@ export function Tip({ raw, children, maxWidth = 320, block = false }: TipProps)
<div
ref={tipRef}
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}
</div>,
+10 -10
View File
@@ -213,11 +213,11 @@ export function UpdateNotifier() {
<div className="min-w-0">
<h2
id="update-notifier-title"
className="text-sm font-semibold text-gray-100 truncate"
className="text-sm font-semibold text-fg-primary truncate"
>
{t("title")}
</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 })}
</p>
</div>
@@ -226,7 +226,7 @@ export function UpdateNotifier() {
type="button"
onClick={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" />
</button>
@@ -234,29 +234,29 @@ export function UpdateNotifier() {
{/* Body */}
<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 ? (
<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")}
</div>
) : null}
{!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")}
</div>
) : null}
{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}
</div>
) : null}
{status.manual_command ? (
<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")}
>
{status.manual_command}
@@ -268,11 +268,11 @@ export function UpdateNotifier() {
* are fetch-only - restarting the dashboard would change nothing. */}
{status.situation === "tracking_canonical" ||
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}
{error ? (
<p className="text-xs text-red-400" role="alert">
<p className="text-xs text-status-danger" role="alert">
{error}
</p>
) : 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",
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 {
key: string;
@@ -155,7 +155,7 @@ function Toggle({
aria-label={label}
onClick={() => onChange(!checked)}
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
@@ -390,7 +390,7 @@ export function WebhookSettings() {
return (
<div className="card p-5 space-y-4">
<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" />
{t("webhooks.count", { count: targets.length })}
</div>
@@ -408,9 +408,9 @@ export function WebhookSettings() {
{/* Target list */}
{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 ? (
<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" />
{t("webhooks.empty")}
</div>
@@ -431,12 +431,12 @@ export function WebhookSettings() {
>
{labelOf(target.type)}
</span>
<span className="text-sm text-gray-200 font-medium">{target.name}</span>
<code className="text-[11px] text-gray-500 font-mono truncate max-w-[220px]">
<span className="text-sm text-fg-secondary font-medium">{target.name}</span>
<code className="text-[11px] text-fg-muted font-mono truncate max-w-[220px]">
{target.url_preview}
</code>
{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 })}
</span>
)}
@@ -446,8 +446,8 @@ export function WebhookSettings() {
title={target.last_delivery.error || undefined}
className={`inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded-full ${
target.last_delivery.status === "success"
? "text-emerald-400 bg-emerald-500/10"
: "text-red-400 bg-red-500/10"
? "text-status-success bg-status-success/10"
: "text-status-danger bg-status-danger/10"
}`}
>
{target.last_delivery.status === "success" ? (
@@ -470,7 +470,7 @@ export function WebhookSettings() {
<button
onClick={() => onTest(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 ? (
<Loader2 className="w-3 h-3 animate-spin" />
@@ -481,14 +481,14 @@ export function WebhookSettings() {
</button>
<button
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" />
{t("webhooks.edit")}
</button>
<button
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" />
{t("webhooks.delete")}
@@ -496,7 +496,7 @@ export function WebhookSettings() {
{result && (
<span
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 ? (
@@ -522,17 +522,17 @@ export function WebhookSettings() {
{formOpen && form && provider && (
<div className="border border-border rounded-lg p-4 space-y-3 bg-surface-1">
<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")}
</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" />
</button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<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
value={form.name}
onChange={(e) => set({ name: e.target.value })}
@@ -541,7 +541,7 @@ export function WebhookSettings() {
/>
</label>
<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">
<Select<WebhookType>
value={form.type}
@@ -564,12 +564,12 @@ export function WebhookSettings() {
{/* URL (hidden for providers that derive their own URL) */}
{showUrl && (
<label className="block">
<span className="text-[11px] text-gray-500">
<span className="text-[11px] text-fg-muted">
{t("webhooks.fieldUrl")}
{isEdit ? (
<span className="text-gray-600"> - {t("webhooks.urlKeepHint")}</span>
<span className="text-fg-muted"> - {t("webhooks.urlKeepHint")}</span>
) : urlOptional ? (
<span className="text-gray-600"> - {t("webhooks.urlOptional")}</span>
<span className="text-fg-muted"> - {t("webhooks.urlOptional")}</span>
) : null}
</span>
<input
@@ -583,7 +583,7 @@ export function WebhookSettings() {
</label>
)}
{!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" />
{t("webhooks.urlAuto")}
</p>
@@ -594,19 +594,19 @@ export function WebhookSettings() {
<button
type="button"
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">
<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 })}
</span>
<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>
{guideOpen && (
<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(
(s, i) => (
<li key={i}>{s}</li>
@@ -624,7 +624,7 @@ export function WebhookSettings() {
{t("webhooks.guideDocs", { provider: provider.label })}
</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" />
{t("webhooks.guideStaleNote")}
</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">
{provider.fields.map((f) => (
<label key={f.key} className="block">
<span className="text-[11px] text-gray-500">
<span className="text-[11px] text-fg-muted">
{f.label}
{f.required && <span className="text-red-400"> *</span>}
{f.required && <span className="text-status-danger"> *</span>}
</span>
{f.type === "enum" && f.options ? (
<div className="mt-1">
@@ -667,7 +667,7 @@ export function WebhookSettings() {
{provider.supports_secret && (
<div className="space-y-3 pt-1 border-t border-border">
<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
type="password"
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"
/>
<span className="text-[10px] text-gray-600">{t("webhooks.secretHint")}</span>
<span className="text-[10px] text-fg-muted">{t("webhooks.secretHint")}</span>
</label>
<div>
<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 && (
<Checkbox
checked={form.replaceHeaders}
onChange={(v) => set({ replaceHeaders: v })}
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>
@@ -724,7 +724,7 @@ export function WebhookSettings() {
onClick={() =>
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" />
</button>
@@ -734,7 +734,7 @@ export function WebhookSettings() {
onClick={() =>
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" />
{t("webhooks.addHeader")}
@@ -752,7 +752,7 @@ export function WebhookSettings() {
checked={form.scopeAll}
onChange={(v) => set({ scopeAll: v })}
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 && (
<div className="mt-2 grid grid-cols-1 sm:grid-cols-2 gap-1">
@@ -768,7 +768,7 @@ export function WebhookSettings() {
})
}
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>
@@ -777,14 +777,14 @@ export function WebhookSettings() {
)}
<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 })} />
{t("webhooks.enabledOnSave")}
</label>
</div>
{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" />
{formError}
</div>
@@ -206,7 +206,7 @@ describe("AgentCard", () => {
it("should not render subagent_type when null", () => {
const { container } = renderCard(<AgentCard agent={makeAgent({ subagent_type: null })} />);
// 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", () => {
@@ -44,10 +44,10 @@ describe("StatCard", () => {
it("should apply custom accent color", () => {
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");
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", () => {
@@ -136,9 +136,9 @@ describe("awaiting-reason suffix", () => {
it("marks urgent reasons with the hotter amber tint", () => {
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" />);
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", () => {
@@ -142,20 +142,20 @@ export function CodeBlock({
const palette =
tone === "danger"
? {
wrapper: "border-red-500/30 bg-red-500/5",
chrome: "bg-red-500/10 border-b border-red-500/20",
label: "text-red-300",
wrapper: "border-status-danger/30 bg-status-danger/5",
chrome: "bg-status-danger/10 border-b border-status-danger/20",
label: "text-status-danger",
}
: tone === "success"
? {
wrapper: "border-emerald-500/30 bg-emerald-500/5",
chrome: "bg-emerald-500/10 border-b border-emerald-500/20",
label: "text-emerald-300",
wrapper: "border-status-success/30 bg-status-success/5",
chrome: "bg-status-success/10 border-b border-status-success/20",
label: "text-status-success",
}
: {
wrapper: "border-surface-3 bg-surface-4/50",
chrome: "bg-surface-3/70 border-b border-surface-3",
label: "text-gray-400",
label: "text-fg-secondary",
};
const preStyle: React.CSSProperties = {};
@@ -177,7 +177,7 @@ export function CodeBlock({
{/* Filename + lang together when both are set */}
{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 && (
<span className={`font-mono uppercase tracking-wider ${palette.label}`}>· {label}</span>
@@ -186,7 +186,7 @@ export function CodeBlock({
{/* Right side: line count + copy */}
<div className="ml-auto flex items-center gap-3">
{totalLines > 1 && (
<span className="text-gray-600 font-mono">
<span className="text-fg-muted font-mono">
{totalLines} {totalLines === 1 ? "line" : "lines"}
</span>
)}
@@ -194,7 +194,7 @@ export function CodeBlock({
type="button"
onClick={handleCopy}
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"
>
@@ -221,7 +221,7 @@ export function CodeBlock({
{lineTokens.map((line, i) => (
<tr key={i} className="align-top">
<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" }}
>
{i + 1}
@@ -399,7 +399,7 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
<select
value={selectedTranscript || ""}
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) => (
<option key={t.id} value={t.id}>
@@ -407,10 +407,10 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
</option>
))}
</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>
)}
<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" />
{total} message{total !== 1 ? "s" : ""}
</span>
@@ -420,7 +420,7 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
disabled={refreshing || loading}
title="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" : ""}`} />
Refresh
@@ -430,7 +430,7 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
{/* Error alert */}
{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}
</div>
)}
@@ -445,31 +445,31 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
{/* History loading indicator */}
{loadingHistory && (
<div className="flex justify-center py-3">
<Loader2 className="w-4 h-4 text-gray-500 animate-spin" />
<span className="text-xs text-gray-500 ml-2">Loading history...</span>
<Loader2 className="w-4 h-4 text-fg-muted animate-spin" />
<span className="text-xs text-fg-muted ml-2">Loading history...</span>
</div>
)}
{/* Scroll-up for history hint */}
{hasMore && !loadingHistory && !loading && (
<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>
)}
{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...
</div>
) : messages.length === 0 ? (
<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="mt-2 text-xs leading-relaxed text-gray-500">
<p className="text-sm text-fg-secondary">No conversation records found.</p>
<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.
Claude Code automatically deletes inactive session transcripts after a retention
period (<code className="text-gray-400">cleanupPeriodDays</code>, default 30 days), so
older conversations may already be gone. Sessions imported from now on are snapshotted
and kept even after Claude Code prunes the originals.
period (<code className="text-fg-secondary">cleanupPeriodDays</code>, default 30
days), so older conversations may already be gone. Sessions imported from now on are
snapshotted and kept even after Claude Code prunes the originals.
</p>
</div>
) : (
@@ -274,7 +274,7 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
const codeM = rest.match(/^`([^`\n]+)`/);
if (codeM) {
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]}
</code>
);
@@ -286,7 +286,7 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
const boldM = rest.match(/^(\*\*|__)(.+?)\1/);
if (boldM) {
push(
<strong className="font-semibold text-gray-50">
<strong className="font-semibold text-fg-primary">
{renderInline(boldM[2]!, `${baseKey}-b${n}`)}
</strong>
);
@@ -298,7 +298,9 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
const italicM = rest.match(/^(\*|_)([^*_\n]+?)\1/);
if (italicM) {
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;
continue;
@@ -308,7 +310,7 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
const strikeM = rest.match(/^~~(.+?)~~/);
if (strikeM) {
push(
<span className="line-through text-gray-500">
<span className="line-through text-fg-muted">
{renderInline(strikeM[1]!, `${baseKey}-s${n}`)}
</span>
);
@@ -366,11 +368,13 @@ function renderListItem(item: string, key: string): React.ReactNode {
<span className="inline-flex items-baseline gap-2">
<span
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"
/>
<span className={checked ? "text-gray-500 line-through" : ""}>
<span className={checked ? "text-fg-muted line-through" : ""}>
{renderInline(taskMatch[2]!, key)}
</span>
</span>
@@ -386,12 +390,12 @@ interface MarkdownContentProps {
}
const HEADING_STYLES = [
"text-[18px] font-semibold text-gray-50 mt-2 pb-1 border-b border-surface-3",
"text-[16px] font-semibold text-gray-50 mt-2",
"text-[15px] font-semibold text-gray-100",
"text-sm font-semibold text-gray-100",
"text-sm font-medium text-gray-200",
"text-xs font-medium text-gray-300 uppercase tracking-wider",
"text-[18px] font-semibold text-fg-primary mt-2 pb-1 border-b border-surface-3",
"text-[16px] font-semibold text-fg-primary mt-2",
"text-[15px] font-semibold text-fg-primary",
"text-sm font-semibold text-fg-primary",
"text-sm font-medium text-fg-secondary",
"text-xs font-medium text-fg-secondary uppercase tracking-wider",
];
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";
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) => {
switch (b.kind) {
case "code":
@@ -419,10 +423,10 @@ export function MarkdownContent({ text, dense = false }: MarkdownContentProps) {
return (
<ol
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) => (
<li key={i} className="text-sm text-gray-300">
<li key={i} className="text-sm text-fg-secondary">
{renderListItem(item, `li${idx}-${i}`)}
</li>
))}
@@ -432,7 +436,7 @@ export function MarkdownContent({ text, dense = false }: MarkdownContentProps) {
return (
<ul key={idx} className="list-disc pl-5 space-y-1 marker:text-violet-400/60">
{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}`)}
</li>
))}
@@ -443,7 +447,7 @@ export function MarkdownContent({ text, dense = false }: MarkdownContentProps) {
return (
<blockquote
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}`)}
</blockquote>
@@ -471,7 +475,7 @@ export function MarkdownContent({ text, dense = false }: MarkdownContentProps) {
{b.header.map((cell, i) => (
<th
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}`)}
</th>
@@ -487,7 +491,7 @@ export function MarkdownContent({ text, dense = false }: MarkdownContentProps) {
{row.map((cell, ci) => (
<td
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}`)}
</td>
@@ -502,7 +506,7 @@ export function MarkdownContent({ text, dense = false }: MarkdownContentProps) {
case "para":
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}`)}
</p>
);
@@ -88,9 +88,9 @@ const SENDER_STYLES: Record<
label: "User",
icon: User,
avatarRing:
"bg-gradient-to-br from-blue-500/30 to-cyan-500/20 text-blue-200 ring-1 ring-blue-400/30",
accentBar: "before:bg-blue-500/40",
headerText: "text-blue-200",
"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-600/40",
headerText: "text-blue-300",
},
assistant: {
label: "Assistant",
@@ -104,7 +104,7 @@ const SENDER_STYLES: Record<
label: "Main agent",
icon: Workflow,
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",
headerText: "text-teal-200",
},
@@ -112,17 +112,17 @@ const SENDER_STYLES: Record<
label: "System",
icon: Cog,
avatarRing:
"bg-gradient-to-br from-slate-500/30 to-gray-500/20 text-gray-300 ring-1 ring-slate-400/30",
accentBar: "before:bg-slate-500/40",
headerText: "text-gray-300",
"bg-gradient-to-br from-slate-500/30 to-gray-500/20 text-fg-secondary ring-1 ring-border-light/30",
accentBar: "before:bg-surface-4/40",
headerText: "text-fg-secondary",
},
tool: {
label: "Tool",
icon: Terminal,
avatarRing:
"bg-gradient-to-br from-amber-500/30 to-orange-500/20 text-amber-200 ring-1 ring-amber-400/30",
accentBar: "before:bg-amber-500/40",
headerText: "text-amber-200",
"bg-gradient-to-br from-status-warning/30 to-orange-500/20 text-status-warning ring-1 ring-status-warning/30",
accentBar: "before:bg-status-warning/40",
headerText: "text-status-warning",
},
};
import { ToolCallBlock } from "./ToolCallBlock";
@@ -174,12 +174,12 @@ function formatLocalTime(iso: string): string {
function SessionEventRow({ title, timestamp }: { title?: string; timestamp: string | null }) {
return (
<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" />
<span className="text-gray-500">Renamed session </span>
<span className="text-gray-200 font-medium truncate">{title || "(untitled)"}</span>
<span className="text-fg-muted">Renamed session </span>
<span className="text-fg-secondary font-medium truncate">{title || "(untitled)"}</span>
{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)}
</span>
)}
@@ -191,8 +191,8 @@ function SessionEventRow({ title, timestamp }: { title?: string; timestamp: stri
/** Compact pill for /command invocations parsed out of TUI markup. */
function CommandPill({ display }: { display: string }) {
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">
<span className="text-emerald-500/70"></span>
<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-status-success/70"></span>
<span className="break-all">{display}</span>
</div>
);
@@ -203,9 +203,9 @@ function TerminalBlock({ text, stream }: { text: string; stream: "stdout" | "std
const cleaned = stripAnsi(text).replace(/^\n+|\n+$/g, "");
const isErr = stream === "stderr";
const accent = isErr
? "border-red-500/30 bg-red-950/30 text-red-200/90"
: "border-surface-3 bg-surface-4/60 text-gray-200";
const labelColor = isErr ? "text-red-300/80" : "text-gray-400";
? "border-status-danger/30 bg-status-danger/30 text-status-danger/90"
: "border-surface-3 bg-surface-4/60 text-fg-secondary";
const labelColor = isErr ? "text-status-danger/80" : "text-fg-secondary";
return (
<div className={`rounded-lg border ${accent} overflow-hidden`}>
<div
@@ -224,7 +224,7 @@ function TerminalBlock({ text, stream }: { text: string; stream: "stdout" | "std
/** Subtle inline note for the local-command-caveat banner. */
function CaveatBlock({ text }: { text: string }) {
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" />
<span className="leading-relaxed italic">{stripAnsi(text).trim()}</span>
</div>
@@ -247,11 +247,11 @@ function renderSegment(seg: TuiSegment, key: number): React.ReactNode {
<CollapsibleBlock
key={key}
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"
borderClass="border-amber-500/20"
bgClass="bg-amber-500/5"
textClass="text-amber-300/80"
borderClass="border-status-warning/20"
bgClass="bg-status-warning/5"
textClass="text-status-warning/80"
/>
);
case "persisted-output":
@@ -326,7 +326,7 @@ export function MessageList({ messages, loading }: MessageListProps) {
if (loading) {
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...
</div>
);
@@ -334,7 +334,7 @@ export function MessageList({ messages, loading }: MessageListProps) {
if (messages.length === 0) {
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}
</span>
{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)}
</span>
)}
{msg.usage && (
<span className="text-[10px] text-gray-500 font-mono inline-flex items-center gap-1">
<span className="text-emerald-300/70"> {fmt(msg.usage.input_tokens)}</span>
<span className="text-gray-700">·</span>
<span className="text-[10px] text-fg-muted font-mono inline-flex items-center gap-1">
<span className="text-status-success/70"> {fmt(msg.usage.input_tokens)}</span>
<span className="text-fg-muted">·</span>
<span className="text-orange-300/70"> {fmt(msg.usage.output_tokens)}</span>
</span>
)}
{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)}
</span>
)}
@@ -436,11 +436,11 @@ export function MessageList({ messages, loading }: MessageListProps) {
<CollapsibleBlock
key={bIdx}
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}
borderClass="border-blue-500/20"
bgClass="bg-blue-500/5"
textClass="text-blue-400/80"
borderClass="border-blue-600/20"
bgClass="bg-blue-600/5"
textClass="text-blue-500/80"
/>
);
}
@@ -470,7 +470,7 @@ export function MessageList({ messages, loading }: MessageListProps) {
return (
<div
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
onClick={() =>
@@ -481,23 +481,23 @@ export function MessageList({ messages, loading }: MessageListProps) {
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
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" : ""
}`}
/>
<Brain className="w-3.5 h-3.5 text-amber-400/80" />
<span className="text-xs text-amber-200/90 font-medium">Thinking</span>
<Brain className="w-3.5 h-3.5 text-status-warning/80" />
<span className="text-xs text-status-warning/90 font-medium">Thinking</span>
{!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
</span>
)}
</button>
{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 />
</div>
)}
@@ -138,7 +138,7 @@ function renderInput(toolUse: TranscriptContent) {
<div className="space-y-2">
<CodeBlock code={obj.command} lang="bash" label="Command" />
{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>
);
@@ -161,11 +161,11 @@ function renderInput(toolUse: TranscriptContent) {
const lang = langFromPath(obj.file_path);
return (
<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" />
<span className="font-mono">{obj.file_path}</span>
{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
</span>
)}
@@ -183,11 +183,11 @@ function renderInput(toolUse: TranscriptContent) {
// Read: just show the path with offset/limit
if (tool === "read" && typeof obj.file_path === "string") {
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" />
<span className="font-mono break-all">{obj.file_path}</span>
{(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.limit === "number" ? `+${obj.limit}` : ""}
</span>
@@ -201,7 +201,7 @@ function renderInput(toolUse: TranscriptContent) {
return (
<div className="space-y-1.5">
<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
</span>
<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>
{typeof obj.path === "string" && (
<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
</span>
<code className="font-mono text-gray-300">{obj.path}</code>
<code className="font-mono text-fg-secondary">{obj.path}</code>
</div>
)}
{typeof obj.glob === "string" && (
<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
</span>
<code className="font-mono text-gray-300">{obj.glob}</code>
<code className="font-mono text-fg-secondary">{obj.glob}</code>
</div>
)}
</div>
@@ -235,7 +235,7 @@ function renderInput(toolUse: TranscriptContent) {
/** Render the result pane: detect diff/json/text. */
function renderResult(toolResult: TranscriptContent, toolName: string) {
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 tool = toolName.toLowerCase();
@@ -269,8 +269,8 @@ export function ToolCallBlock({ toolUse, toolResult }: ToolCallBlockProps) {
const style = styleForTool(toolUse.name);
const Icon = style.Icon;
const wrapperBorder = isError ? "border-red-500/30" : style.border;
const wrapperBg = isError ? "bg-red-500/5" : "bg-surface-2/60";
const wrapperBorder = isError ? "border-status-danger/30" : style.border;
const wrapperBg = isError ? "bg-status-danger/5" : "bg-surface-2/60";
return (
<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"
>
<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" : ""
}`}
/>
@@ -295,23 +295,23 @@ export function ToolCallBlock({ toolUse, toolResult }: ToolCallBlockProps) {
{toolUse.name}
</span>
{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}
</span>
)}
<span className="ml-auto flex-shrink-0">
{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" />
error
</span>
) : 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" />
ok
</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
</span>
)}
@@ -130,7 +130,7 @@ export function CopyButton({ text }: { text: string }) {
<button
type="button"
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")}
>
{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 (
<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">
<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} />
</div>
<pre className="px-3 py-2 text-gray-200 whitespace-pre-wrap break-words">
{description && <div className="text-gray-500 mb-1"># {description}</div>}
<pre className="px-3 py-2 text-fg-secondary whitespace-pre-wrap break-words">
{description && <div className="text-fg-muted mb-1"># {description}</div>}
<div>
<span className="text-emerald-400 select-none">$ </span>
<span className="text-status-success select-none">$ </span>
{command}
</div>
</pre>
@@ -176,11 +176,14 @@ export function TerminalOutput({
const hasStderr = typeof stderr === "string" && stderr.length > 0;
const flag =
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
? {
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;
@@ -208,11 +211,11 @@ function OutputBlock({
text: string;
variant: "out" | "err";
}) {
const color = variant === "err" ? "text-red-300" : "text-gray-200";
const color = variant === "err" ? "text-status-danger" : "text-fg-secondary";
return (
<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">
<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} />
</div>
<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">
{label && (
<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} />
</div>
)}
@@ -249,10 +252,10 @@ export function LineNumberedCode({
<tbody>
{lines.map((line, 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}
</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>
))}
</tbody>
@@ -274,7 +277,7 @@ export type DiffHunk = {
export function UnifiedDiff({ hunks }: { hunks: DiffHunk[] }) {
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 (
<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"
? "bg-green-500/10 text-green-200"
: kind === "remove"
? "bg-red-500/10 text-red-200"
: "text-gray-300";
? "bg-status-danger/10 text-status-danger"
: "text-fg-secondary";
const oldCell = showOld ? oldLine++ : "";
const newCell = showNew ? newLine++ : "";
const sign = kind === "add" ? "+" : kind === "remove" ? "-" : " ";
return (
<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}
</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}
</td>
<td className="px-1 text-center select-none w-[16px]">{sign}</td>
@@ -347,7 +350,7 @@ export function KeyValueCard({
const ordered = [...priorityEntries, ...restEntries];
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 (
@@ -355,10 +358,10 @@ export function KeyValueCard({
<tbody>
{ordered.map(([k, v], i) => (
<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}
</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} />
</td>
</tr>
@@ -369,32 +372,33 @@ export function KeyValueCard({
}
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")
return (
<span
className={`inline-block px-2 py-0.5 rounded border text-[11px] font-mono ${
value
? "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)}
</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 (value.length > 120 || value.includes("\n")) {
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}
</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 (value.length === 0) return <span className="text-gray-500 italic">[]</span>;
if (value.length === 0) return <span className="text-fg-muted italic">[]</span>;
return (
<ol className="list-decimal pl-4 space-y-1">
{value.map((item, i) => (
@@ -406,7 +410,7 @@ function ValueCell({ value }: { value: unknown }) {
);
}
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)}
</pre>
);
@@ -423,11 +427,11 @@ function safeStringify(value: unknown): string {
// ───────────────────────── File list / match list ─────────────────────────
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 (
<ul className="divide-y divide-border border border-border rounded overflow-hidden text-[11px] max-h-80 overflow-y-auto">
{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}
</li>
))}
@@ -442,14 +446,14 @@ export type 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 (
<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) => (
<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.line != null && <span className="text-gray-500">:{m.line}</span>}
{m.text && <span className="text-gray-400">: {m.text}</span>}
{m.line != null && <span className="text-fg-muted">:{m.line}</span>}
{m.text && <span className="text-fg-secondary">: {m.text}</span>}
</li>
))}
</ul>
@@ -397,9 +397,9 @@ export function ToolResponseView({
{hunks.length > 0 && <UnifiedDiff hunks={hunks} />}
{originalFile && (
<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="text-gray-500 font-normal ml-2">
<span className="text-fg-muted font-normal ml-2">
({originalFile.split(/\r?\n/).length} lines)
</span>
</summary>
+9 -9
View File
@@ -130,7 +130,7 @@ export function AddLaneModal({
>
<div className="space-y-3">
<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")}
</label>
<CwdAutocomplete
@@ -139,11 +139,11 @@ export function AddLaneModal({
onChange={setSourceRepo}
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>
<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")}
</label>
<input
@@ -151,23 +151,23 @@ export function AddLaneModal({
value={title}
onChange={(e) => setTitle(e.target.value)}
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>
{branches && (
<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")}
</label>
{branches.length === 0 ? (
<p className="text-[10px] text-neutral-500">{t("addLaneNoBranches")}</p>
<p className="text-[10px] text-fg-muted">{t("addLaneNoBranches")}</p>
) : (
<select
id="add-lane-base"
value={base}
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) => (
<option key={b} value={b}>
@@ -179,11 +179,11 @@ export function AddLaneModal({
</div>
)}
{branchesError && !branches && (
<p className="text-[10px] text-amber-400">{branchesError}</p>
<p className="text-[10px] text-status-warning">{branchesError}</p>
)}
{error && (
<p role="alert" className="text-xs text-red-400">
<p role="alert" className="text-xs text-status-danger">
{error}
</p>
)}
@@ -153,18 +153,18 @@ export function DestructiveLaneModal({
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 && (
<p className="mt-3 text-xs text-red-300">
<p className="mt-3 text-xs text-status-danger">
{t("preflightErrorWithMessage", { message: error })}
</p>
)}
{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>
{facts.map(([name, value]) => (
<tr key={name} className="border-t border-neutral-800">
<th scope="row" className="py-1.5 font-medium text-neutral-400">
<tr key={name} className="border-t border-border">
<th scope="row" className="py-1.5 font-medium text-fg-secondary">
{t(`destructive.count.${name}`)}
</th>
<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. */}
{purge && (
<tr className="border-t border-neutral-800">
<th scope="row" className="py-1.5 font-medium text-neutral-400">
<tr className="border-t border-border">
<th scope="row" className="py-1.5 font-medium text-fg-secondary">
{t("destructive.count.bytesEstimate")}
</th>
<td className="py-1.5 text-right tabular-nums">
@@ -185,19 +185,19 @@ export function DestructiveLaneModal({
</table>
)}
{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}`)}
</p>
)}
{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")}
</p>
)}
{noticesFor(preflight).map((notice) => (
<p
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}`)}
</p>
@@ -205,13 +205,13 @@ export function DestructiveLaneModal({
{warningsFor(preflight).map((warning) => (
<p
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}`)}
</p>
))}
{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
type="checkbox"
checked={force}
@@ -222,7 +222,7 @@ export function DestructiveLaneModal({
</label>
)}
{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>
);
+96 -122
View File
@@ -1,11 +1,13 @@
/**
* @file One lane's card: title, stage badge with time-on-phase, progress bar,
* branch/CI/PR facts, the "needs you" banner sourced from Claude Code's
* Notification hook, and the control row. A dead lane (its driving session went
* silent while it should have been working) is called out loudly that is the
* failure this view exists to catch. An "auto: <stage>" chip appears only when
* the server's detected stage is ahead of the agent's own declaration when the
* declaration leads or matches, it stays the sole headline.
* @file One lane's card: title, progress bar with time-on-phase, branch/CI/PR
* facts, the "needs you" banner sourced from Claude Code's Notification hook,
* and the control row which ends in the two deletions (lane, history) as
* plain buttons, each gated by DestructiveLaneModal. A dead lane (its driving
* session went silent while it should have been working) is called out
* loudly that is the failure this view exists to catch. Stage, kind, and
* 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>
*/
@@ -54,9 +56,9 @@ function useLaneGitFacts(laneId: number): LaneGitFacts | null {
}
const LIVENESS_DOT: Record<Lane["liveness"], string> = {
active: "bg-emerald-400",
idle: "bg-neutral-500",
dead: "bg-red-500",
active: "bg-status-success",
idle: "bg-surface-4",
dead: "bg-status-danger",
};
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`;
}
/** 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({
lane,
onAction,
@@ -93,15 +86,15 @@ export default function LaneCard({
<>
<div
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
uppercase so a wall of cards can be scanned vertically. */}
<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 })}
</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]}`} />
{/* i18next returns the KEY on a miss, so `|| raw` never fires and a
non-standard status rendered as the literal "status.foo".
@@ -112,81 +105,57 @@ export default function LaneCard({
</span>
</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}
</h3>
{/* Stage line: the declared stage, how far through, and how long it has
been sitting there the three facts that say whether a lane is
moving. The inferred chip sits beside them, never instead of them. */}
{/* Progress line: how far through the pipeline and how long the
current stage has been sitting there. The stage's own name is
already in the Workspace header above; not repeated here. */}
<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
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
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}%` }}
/>
</div>
{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 className="mb-3 flex flex-wrap items-center gap-1.5 text-[11px]">
<span
className={`rounded px-1.5 py-0.5 ${
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">
{lane.ci_status && (
<div className="mb-3 flex flex-wrap items-center gap-1.5 text-[11px]">
<span className="rounded bg-surface-2 px-1.5 py-0.5 text-fg-secondary">
CI {lane.ci_status}
</span>
)}
</div>
</div>
)}
{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}
</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 && (
<div data-testid="lane-git" className="space-y-1">
<div className="truncate">
{git.branch}
<span className="ml-2 text-neutral-500">{git.head}</span>
<span className="ml-2 text-fg-muted">{git.head}</span>
</div>
<div className="truncate text-neutral-500" title={git.subject}>
<div className="truncate text-fg-muted" title={git.subject}>
{git.subject}
</div>
{(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 })}
</div>
)}
@@ -195,14 +164,14 @@ export default function LaneCard({
{/* The lane's own recorded branch, shown only when git could not be
read otherwise it duplicates the live branch above. */}
{!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}
</div>
</dl>
{/* mt-auto pins the controls to the bottom so cards of differing height
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) => (
<button
key={a}
@@ -214,77 +183,82 @@ export default function LaneCard({
}}
className={`rounded px-2 py-1 transition-colors ${
a === "start"
? "bg-blue-500/15 text-blue-300 hover:bg-blue-500/25"
: "text-neutral-400 hover:bg-neutral-800 hover:text-neutral-200"
? "bg-blue-600/15 text-blue-400 hover:bg-blue-600/25"
: "text-fg-secondary hover:bg-surface-2 hover:text-fg-secondary"
}`}
title={a === "start" ? t("tooltipStart") : undefined}
>
{t(`action.${a}`)}
</button>
))}
{/* Destructive verbs sit behind a menu so the card is not a wall of
red. Red is kept for the items inside, where it means something. */}
<div className="relative ml-auto">
{/* Deleting the lane and deleting its history are each their own
button, by request: hiding "delete" behind a made it unfindable,
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
type="button"
data-testid="lane-more"
aria-haspopup="menu"
aria-expanded={menuOpen}
data-testid="lane-action-purge"
onClick={(e) => {
e.stopPropagation();
setMenuOpen((v) => !v);
setDestructiveAction("purge");
}}
className="rounded px-2 py-1 text-neutral-500 hover:bg-neutral-800 hover:text-neutral-200"
title={t("moreActions")}
className="rounded px-2 py-1 text-status-danger/80 transition-colors hover:bg-status-danger/10 hover:text-status-danger"
>
{t("action.purge")}
</button>
{menuOpen && (
<div
role="menu"
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"
>
{lane.kind === "managed" && (
<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-amber-300 hover:bg-amber-500/10"
<button
type="button"
data-testid="lane-action-remove"
onClick={(e) => {
e.stopPropagation();
setDestructiveAction("remove");
}}
className="rounded px-2 py-1 text-status-danger transition-colors hover:bg-status-danger/15 hover:text-status-danger"
>
{lane.kind === "adopted" ? t("action.forget") : t("action.remove")}
</button>
{/* Adopted lanes have no worktree to reset, so the menu would be
empty it is not rendered at all rather than opening onto
nothing. */}
{lane.kind === "managed" && (
<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>
+14 -14
View File
@@ -11,9 +11,9 @@ import { useTranslation } from "react-i18next";
import type { Lane } from "../../lib/types";
const LIVENESS_DOT: Record<Lane["liveness"], string> = {
active: "bg-emerald-400",
idle: "bg-neutral-500",
dead: "bg-red-500",
active: "bg-status-success",
idle: "bg-surface-4",
dead: "bg-status-danger",
};
/** Whether the inferred stage sits ahead of the declared one in node order. */
@@ -42,49 +42,49 @@ export default function LaneStripCard({
aria-pressed={selected}
onClick={onSelect}
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
? "border-blue-400/70 bg-blue-500/[0.07]"
: "border-neutral-800 bg-neutral-900/60 hover:border-neutral-700"
? "border-accent bg-accent/10"
: "border-border bg-surface-2 hover:border-border-light hover:bg-surface-3"
}`}
>
<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="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 })}
</span>
{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>
)}
</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}
</div>
<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}
</span>
{detectionLeads(lane) && (
<span
data-testid={`lane-tile-auto-${lane.id}`}
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}
</span>
)}
{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>
{lane.progress > 0 && (
<div className="mt-1.5 h-0.5 overflow-hidden rounded-full bg-neutral-800">
<div className="h-full rounded-full bg-blue-500" style={{ width: `${lane.progress}%` }} />
<div className="mt-1.5 h-0.5 overflow-hidden rounded-full bg-surface-4">
<div className="h-full rounded-full bg-accent" style={{ width: `${lane.progress}%` }} />
</div>
)}
</button>
+21 -16
View File
@@ -2,28 +2,33 @@
* @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
* hardcoded coordinates, so a lane can use a longer or shorter template without
* touching this component. "passed without evidence" is deliberately 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
* dashed amber, overriding whatever `state` it carries because it must never
* be mistaken for the solid green of a real "done".
* touching this component. Every state but `current` shares one visual
* language coloured border, coloured text, a translucent wash of the same
* colour so status colour means the same thing everywhere in the app, not
* a different shade per component. `current` is the sole solid fill, in the
* app's own accent colour: the one state that gets to look bolder than the
* 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>
*/
import type { LaneNode } from "../../lib/types";
const STATE_CLASS: Record<LaneNode["state"], string> = {
done: "border-emerald-500 text-emerald-400 bg-emerald-500/10",
current: "border-blue-400 text-blue-300 bg-blue-500/20 ring-2 ring-blue-400/40",
"passed-no-evidence": "border-amber-500 text-amber-400 bg-amber-500/10",
failed: "border-red-500 text-red-400 bg-red-500/10",
pending: "border-neutral-700 text-neutral-500 bg-transparent",
done: "border-status-success/60 text-status-success bg-status-success/10",
current: "border-accent bg-accent text-white ring-2 ring-accent/30 shadow-sm",
"passed-no-evidence": "border-status-warning/60 text-status-warning bg-status-warning/10",
failed: "border-status-danger/60 text-status-danger bg-status-danger/10",
pending: "border-border-light text-fg-muted bg-transparent",
};
// Dashed border distinguishes an inferred stage from every other class above,
// including the solid amber of "passed-no-evidence" — never let it read as done.
const DETECTED_CLASS = "border-dashed border-amber-400 text-amber-300 bg-amber-500/5";
// Thin dashed border and no fill keep an inference visually lighter than
// every outlined state above — so it never reads as more certain than a claim.
const DETECTED_CLASS = "border-dashed border-status-warning/50 text-status-warning bg-transparent";
export default function PipelineMap({
nodes,
@@ -32,7 +37,7 @@ export default function PipelineMap({
nodes: LaneNode[];
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 (
// 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
@@ -54,7 +59,7 @@ export default function PipelineMap({
<span className="shrink-0 text-[13px] leading-none">{n.icon}</span>
<span className="truncate">{n.label}</span>
</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>
@@ -74,52 +74,6 @@ const pipelineNodes: Lane["pipeline_nodes"] = [
{ 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", () => {
const full = (over: Partial<Lane> = {}) =>
makeLane({
@@ -141,9 +95,8 @@ describe("LaneCard rebuilt layout", () => {
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()} />);
expect(screen.getByTestId("lane-stage").textContent).toBe("plan");
expect(screen.getByText("48%")).toBeInTheDocument();
expect(screen.getByText("2m 32s")).toBeInTheDocument();
});
@@ -165,16 +118,26 @@ describe("LaneCard rebuilt layout", () => {
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()} />);
// A wall of red buttons makes none of them read as the dangerous one, so
// reset/remove/purge live behind the ⋯ menu.
// Deleting the lane and deleting its history are the two the user goes
// 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-remove")).toBeNull();
await userEvent.setup().click(screen.getByTestId("lane-more"));
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 () => {
@@ -186,16 +149,18 @@ describe("LaneCard rebuilt layout", () => {
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 { unmount } = render(<LaneCard lane={full()} onAction={vi.fn()} />);
await user.click(screen.getByTestId("lane-more"));
expect(screen.getByTestId("lane-action-reset")).toBeInTheDocument();
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()} />);
await user.click(screen.getByTestId("lane-more"));
expect(screen.queryByTestId("lane-more")).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} />);
const done = screen.getByTestId("pipeline-node-plan").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.
expect(done).toContain("emerald");
expect(amber).toContain("amber");
// The done node must contain the success status token and the amber node
// must contain the warning status token.
expect(done).toContain("status-success");
expect(amber).toContain("status-warning");
expect(done).not.toEqual(amber);
});
@@ -94,8 +95,8 @@ describe("PipelineMap", () => {
render(<PipelineMap nodes={impossible} detectedSignal="npm run test:server" />);
const node = screen.getByTestId("pipeline-node-tests");
expect(node.className).toContain("border-dashed");
expect(node.className).toContain("amber");
expect(node.className).not.toContain("emerald");
expect(node.className).toContain("status-warning");
expect(node.className).not.toContain("status-success");
});
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));
// 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.
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 (
<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>
<span className={tone}>{`${formatNum(total)} / ${formatNum(cap)} (${pct}%)`}</span>
<span title={t("tokens.output")}>{formatNum(stats.outputTokens)}</span>
{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)}
</span>
)}
{stats.costUsd != null && (
<span className="text-gray-400">${stats.costUsd.toFixed(4)}</span>
<span className="text-fg-secondary">${stats.costUsd.toFixed(4)}</span>
)}
</div>
);
@@ -323,11 +324,11 @@ function commandSourceLabel(s: SlashCommand["source"]): string {
function commandSourceTone(s: SlashCommand["source"]): string {
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"
? "bg-sky-500/10 text-sky-300 border-sky-500/30"
: 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";
}
@@ -565,11 +566,11 @@ export function PromptEditor({
placeholder={placeholder}
rows={rows}
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 && (
<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" ? (
<>
<SlashIcon className="w-3 h-3" />
@@ -583,7 +584,7 @@ export function PromptEditor({
)}
</div>
{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" ? (
(items as SlashCommand[]).map((c, idx) => (
<button
@@ -597,7 +598,7 @@ export function PromptEditor({
}`}
>
<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
className={`text-[9px] font-mono px-1.5 py-0.5 rounded border ${commandSourceTone(c.source)}`}
>
@@ -605,7 +606,7 @@ export function PromptEditor({
</span>
</div>
{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>
))
@@ -621,8 +622,8 @@ export function PromptEditor({
idx === active ? "bg-accent/15" : "hover:bg-surface-3"
}`}
>
<FileCode className="w-3 h-3 text-gray-500 flex-shrink-0" />
<span className="font-mono text-[11px] text-gray-200 truncate">{p}</span>
<FileCode className="w-3 h-3 text-fg-muted flex-shrink-0" />
<span className="font-mono text-[11px] text-fg-secondary truncate">{p}</span>
</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]">
<StatusPill status={props.handle.status} />
<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 && (
<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" />
{props.isLive && (
<button
onClick={props.onStop}
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" />
{props.busy === "stop" ? t("actions.stopping") : t("actions.stop")}
@@ -711,7 +712,7 @@ export function RunConsole(props: RunConsoleProps) {
{props.handle.sessionId && (
<Link
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" />
{t("actions.viewSession")}
@@ -756,7 +757,7 @@ export function RunConsole(props: RunConsoleProps) {
fileCwd={props.handle.cwd}
/>
<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
onClick={props.onSend}
disabled={!props.followUp.trim() || props.busy === "send"}
@@ -775,7 +776,7 @@ function EmptyStream({ isLive }: { isLive: boolean }) {
const { t } = useTranslation("run");
if (isLive) {
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" />
<span className="text-xs">{t("status.spawning")}</span>
</div>
@@ -783,25 +784,37 @@ function EmptyStream({ isLive }: { isLive: boolean }) {
}
return (
<div className="text-center py-12 flex flex-col items-center gap-2">
<Sparkles className="w-6 h-6 text-gray-600" />
<div className="text-sm font-medium text-gray-400">{t("empty.title")}</div>
<div className="text-xs text-gray-500 max-w-md">{t("empty.body")}</div>
<Sparkles className="w-6 h-6 text-fg-muted" />
<div className="text-sm font-medium text-fg-secondary">{t("empty.title")}</div>
<div className="text-xs text-fg-muted max-w-md">{t("empty.body")}</div>
</div>
);
}
export function StatusPill({ status }: { status: string }) {
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 }> = {
spawning: { color: "bg-amber-500/15 text-amber-300 border-amber-500/30", icon: RefreshCw },
running: { color: "bg-emerald-500/15 text-emerald-300 border-emerald-500/30", icon: Sparkles },
spawning: {
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: {
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,
},
error: { color: "bg-red-500/15 text-red-300 border-red-500/30", icon: XCircle },
killed: { color: "bg-gray-500/15 text-gray-400 border-gray-500/30", icon: Square },
error: {
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: {
color: "bg-orange-500/10 text-orange-300 border-orange-500/30",
icon: Square,
@@ -826,7 +839,7 @@ export function StatusPill({ status }: { status: string }) {
export function ModeBadge({ mode }: { mode: RunMode }) {
const { t } = useTranslation("run");
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" ? (
<Terminal className="w-3 h-3" />
) : (
@@ -894,7 +907,7 @@ function UserTurn({ env }: { env: UserMessage }) {
<span className="select-none text-indigo-400" aria-hidden>
&gt;
</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 || "-"}
</span>
</div>
@@ -928,10 +941,13 @@ function AssistantTurn({ env }: { env: AssistantMessage }) {
// The gutter glyph is the only speaker marker - no avatar, no label
// row. Prose keeps its markdown rendering; only the chrome is gone.
<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>
<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} />
</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"
title={t("events.tool")}
>
<span className="select-none text-amber-400" aria-hidden>
<span className="select-none text-status-warning" aria-hidden>
</span>
<span className="font-medium text-amber-200">{toolUse.name}</span>
{summary && <span className="truncate text-gray-500">{summary}</span>}
<span className="font-medium text-status-warning">{toolUse.name}</span>
{summary && <span className="truncate text-fg-muted">{summary}</span>}
</button>
{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)}
</pre>
)}
@@ -1010,7 +1026,7 @@ function ToolResultBlock({ result }: { result: Extract<ContentBlock, { type: "to
.join("\n")
: JSON.stringify(result.content);
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
// collapsed summary instead of a generic "tool result" label.
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 }) {
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>
<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)}
</pre>
</details>
@@ -1061,21 +1077,21 @@ function ResultFooter({ result }: { result: ResultEnvelope }) {
const { t } = useTranslation("run");
const isError = result.is_error;
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.num_turns === "number") parts.push(t("footer.turns") + " " + result.num_turns);
return (
<div
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>
{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>
);
}
+27 -27
View File
@@ -148,20 +148,20 @@ export function ActiveRunsSwitcher({
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 ${
liveCount > 0
? "border-emerald-500/40 bg-emerald-500/10 text-emerald-200 hover:bg-emerald-500/15"
: "border-border bg-surface-2 text-gray-300 hover:bg-surface-3"
? "border-status-success/40 bg-status-success/10 text-status-success hover:bg-status-success/15"
: "border-border bg-surface-2 text-fg-secondary hover:bg-surface-3"
}`}
>
<ListOrdered className="w-3.5 h-3.5" />
{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.switcher")}
{totalCount > 0 && <span className="text-gray-500 font-mono">{totalCount}</span>}
{totalCount > 0 && <span className="text-fg-muted font-mono">{totalCount}</span>}
</>
)}
</button>
@@ -277,10 +277,10 @@ export function RunsModal({
<ListOrdered className="w-4 h-4 text-accent" />
</div>
<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")}
</h2>
<p className="text-[11px] text-gray-500">
<p className="text-[11px] text-fg-muted">
{t(
"runs.modalSubtitle",
"Every run started from this dashboard, regardless of status"
@@ -289,7 +289,7 @@ export function RunsModal({
</div>
<button
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")}
title={t("runs.refresh", "Refresh")}
>
@@ -304,7 +304,7 @@ export function RunsModal({
</Link>
<button
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")}
>
<X className="w-4 h-4" />
@@ -314,18 +314,18 @@ export function RunsModal({
{/* Filter bar */}
<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">
<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
autoFocus
value={search}
onChange={(e) => setSearch(e.target.value)}
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 && (
<button
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"
>
<X className="w-3 h-3" />
@@ -359,7 +359,7 @@ export function RunsModal({
{/* List */}
<div className="flex-1 min-h-0 overflow-auto divide-y divide-border">
{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
? t(
"runs.modalEmpty",
@@ -392,11 +392,11 @@ export function RunsModal({
{/* Footer */}
<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" />
<span className="text-[10.5px] text-gray-500 leading-relaxed flex-1">
<Info className="w-3 h-3 text-fg-muted flex-shrink-0" />
<span className="text-[10.5px] text-fg-muted leading-relaxed flex-1">
{t("runs.scopeNote")}
</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}
</span>
</div>
@@ -418,7 +418,7 @@ function FilterChipGroup<T extends string>({
}) {
return (
<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}
</span>
{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 ${
active
? "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}
<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>
);
})}
@@ -481,8 +481,8 @@ function UnifiedRunRowView({
<StatusPill status={row.status} />
<ModeBadge mode={row.mode} />
{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="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
<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-status-success animate-pulse" />
{t("runs.liveBadge", "live")}
</span>
)}
@@ -495,7 +495,7 @@ function UnifiedRunRowView({
{row.isLive && !isCurrent && (
<button
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" />
{t("runs.attachLabel", "Attach")}
@@ -513,7 +513,7 @@ function UnifiedRunRowView({
{canView && (
<button
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" />
{t("runs.viewLabel", "View")}
@@ -522,18 +522,18 @@ function UnifiedRunRowView({
</span>
</div>
{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}
</div>
)}
<div className="font-mono text-[10px] text-gray-500 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="font-mono text-[10px] text-fg-muted truncate mt-1">{row.cwd}</div>
<div className="text-[10px] text-fg-muted mt-0.5 flex items-center gap-2 flex-wrap">
<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 && (
<Link
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")}
>
<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) ────────────────────────
interface RunSetupProps {
mode: RunMode;
onModeChange: (m: RunMode) => void;
@@ -159,7 +158,7 @@ export function RunSetup(props: RunSetupProps) {
{/* Prompt */}
<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")}
</label>
<PromptEditor
@@ -171,7 +170,7 @@ export function RunSetup(props: RunSetupProps) {
slashCommands={props.slashCommands}
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
</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">
<Field label={t("fields.cwd")}>
{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">
<Lock className="w-3 h-3 text-gray-500 flex-shrink-0" />
<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-fg-muted flex-shrink-0" />
<span className="truncate">{props.resumeSession.cwd}</span>
</div>
) : (
@@ -223,7 +222,7 @@ export function RunSetup(props: RunSetupProps) {
</div>
{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" />
<span>{t("hint.permissionWarning")}</span>
</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="flex items-center gap-3 text-[11px] min-w-0">
{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" />
{t("concurrency.atCap", { max: props.activeRuns?.maxConcurrent ?? 0 })}
</span>
) : props.activeRuns && props.activeRuns.activeCount > 0 ? (
<span className="inline-flex items-center gap-1.5 text-gray-400">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
<span className="inline-flex items-center gap-1.5 text-fg-secondary">
<span className="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse" />
{t("concurrency.active", { count: props.activeRuns.activeCount })}
</span>
) : null}
@@ -291,7 +290,7 @@ function Seg({
title={title}
aria-pressed={active}
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}
@@ -302,7 +301,7 @@ function Seg({
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<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>
{children}
@@ -397,7 +396,7 @@ export function CwdAutocomplete({
return (
<div ref={containerRef} 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
ref={inputRef}
id={inputId}
@@ -413,17 +412,17 @@ export function CwdAutocomplete({
placeholder={t("fields.cwdPlaceholder")}
autoComplete="off"
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>
{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">
{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) => (
<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" ? (
<FolderOpen className="w-3 h-3" />
) : g.kind === "home" ? (
@@ -447,8 +446,8 @@ export function CwdAutocomplete({
isActive ? "bg-accent/15" : "hover:bg-surface-3"
}`}
>
<div className="text-[11px] text-gray-200 truncate">{s.label}</div>
<div className="font-mono text-[10px] text-gray-500 truncate">{s.path}</div>
<div className="text-[11px] text-fg-secondary truncate">{s.label}</div>
<div className="font-mono text-[10px] text-fg-muted truncate">{s.path}</div>
</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">
{t("resume.selectedBadge")}
</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 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>
<button
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" />
{t("resume.clear")}
@@ -546,7 +545,7 @@ function SessionPicker({
<div ref={containerRef} className="relative mt-2">
<button
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" />
{t("resume.pickSession")}
@@ -555,20 +554,20 @@ function SessionPicker({
{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="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
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
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 className="max-h-72 overflow-auto py-1">
{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 ? (
<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) => (
<button
@@ -584,27 +583,29 @@ function SessionPicker({
<span
className={`text-[10px] font-mono px-1.5 py-0.5 rounded border ${
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"
? "bg-sky-500/10 text-sky-300 border-sky-500/30"
: s.status === "error"
? "bg-red-500/10 text-red-300 border-red-500/30"
: "bg-surface-3 text-gray-400 border-border"
? "bg-status-danger/10 text-status-danger border-status-danger/30"
: "bg-surface-3 text-fg-secondary border-border"
}`}
>
{s.status}
</span>
{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)}
</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()}
</span>
</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>
))
)}
@@ -666,7 +667,7 @@ function ModelPicker({ value, onChange }: { value: string; onChange: (s: string)
placeholder={t("fields.modelCustomPlaceholder")}
autoComplete="off"
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>
@@ -581,8 +581,8 @@ export function AgentCollaborationNetwork({
if (isEmpty) {
return (
<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-xs text-gray-600 mt-1">{t("pipeline.noDataDesc")}</p>
<p className="text-sm font-medium text-fg-secondary">{t("pipeline.noData")}</p>
<p className="text-xs text-fg-muted mt-1">{t("pipeline.noDataDesc")}</p>
</div>
);
}
@@ -615,7 +615,7 @@ export function AgentCollaborationNetwork({
}}
/>
<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")}
</span>
{nodes.map((n) => (
@@ -627,7 +627,7 @@ export function AgentCollaborationNetwork({
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 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" />
<polygon points="14,1 20,4 14,7" fill="#64748b" />
</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>
@@ -284,10 +284,10 @@ function StatBox({ label, value, sub, accent = "text-accent" }: StatBoxProps) {
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">
<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}
</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>
);
}
@@ -334,7 +334,7 @@ export function CompactionImpact({ data }: CompactionImpactProps) {
if (!hasData) {
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
width="40"
height="40"
@@ -357,7 +357,7 @@ export function CompactionImpact({ data }: CompactionImpactProps) {
return (
<div className="flex flex-col gap-5">
{/* 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 */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
@@ -375,18 +375,18 @@ export function CompactionImpact({ data }: CompactionImpactProps) {
<StatBox
label={t("compaction.avgPerSession")}
value={avgPerSession.toFixed(1)}
accent="text-blue-300"
accent="text-blue-400"
/>
<StatBox
label={t("compaction.peakSession")}
value={peak.toLocaleString()}
accent="text-emerald-400"
accent="text-status-success"
/>
</div>
{/* Histogram: sessions by compaction count */}
<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")}
</p>
<svg
@@ -399,7 +399,7 @@ export function CompactionImpact({ data }: CompactionImpactProps) {
</div>
{/* 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", {
affected: affected.toLocaleString(),
total: data.totalSessions.toLocaleString(),
@@ -420,8 +420,8 @@ export function CompactionImpact({ data }: CompactionImpactProps) {
transform: tip.x > window.innerWidth - 220 ? "translateX(-100%)" : undefined,
}}
>
<div className="font-medium text-gray-100">{tip.title}</div>
<div className="mt-0.5 text-gray-400">{tip.detail}</div>
<div className="font-medium text-fg-primary">{tip.title}</div>
<div className="mt-0.5 text-fg-secondary">{tip.detail}</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">
{/* Label column */}
<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}
</span>
</div>
@@ -154,7 +154,7 @@ function LaneRow({ lane, color, maxCount, onShowTip, onHideTip }: LaneRowProps)
</div>
{/* 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}%
</div>
</div>
@@ -229,7 +229,7 @@ function EmptyState() {
<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">
<svg
className="w-5 h-5 text-gray-600"
className="w-5 h-5 text-fg-muted"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
@@ -240,8 +240,8 @@ function EmptyState() {
<rect x="3" y="16" width="15" height="4" rx="1" />
</svg>
</div>
<p className="text-sm font-medium text-gray-400">{t("concurrency.noData")}</p>
<p className="text-xs text-gray-600 mt-1">{t("concurrency.noDataDesc")}</p>
<p className="text-sm font-medium text-fg-secondary">{t("concurrency.noData")}</p>
<p className="text-xs text-fg-muted mt-1">{t("concurrency.noDataDesc")}</p>
</div>
);
}
@@ -314,15 +314,15 @@ export function ConcurrencyTimeline({ data }: ConcurrencyTimelineProps) {
<div className="flex items-center gap-3 mb-2">
<div className="flex-shrink-0 w-[140px]" />
<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")}
</span>
<span className="text-[10px] text-gray-600 tabular-nums">
<span className="text-[10px] text-fg-muted tabular-nums">
{maxCount}
{t("concurrency.max")}
</span>
</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")}
</div>
</div>
@@ -92,7 +92,7 @@ export function ErrorPropagationMap({ data }: ErrorPropagationMapProps) {
if (!hasErrors) {
return (
<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
width="24"
height="24"
@@ -107,10 +107,10 @@ export function ErrorPropagationMap({ data }: ErrorPropagationMapProps) {
<polyline points="22 4 12 14.01 9 11.01" />
</svg>
</div>
<span className="text-sm text-emerald-400 font-medium">
<span className="text-sm text-status-success font-medium">
{t("errorPropagation.noErrors")}
</span>
<span className="text-xs text-gray-600">{t("errorPropagation.allSuccess")}</span>
<span className="text-xs text-fg-muted">{t("errorPropagation.allSuccess")}</span>
</div>
);
}
@@ -124,20 +124,20 @@ export function ErrorPropagationMap({ data }: ErrorPropagationMapProps) {
return (
<div className="flex flex-col gap-4">
{/* 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-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">
<span className="text-[13px] font-bold text-red-400 tabular-nums whitespace-nowrap">
<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-status-danger/10 border border-status-danger/20 flex items-center justify-center">
<span className="text-[13px] font-bold text-status-danger tabular-nums whitespace-nowrap">
{errorRatePct}%
</span>
</div>
<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", {
errorSessions: data.sessionsWithErrors,
totalSessions: data.totalSessions,
})}
</p>
<p className="text-[11px] text-gray-500 mt-0.5">
<p className="text-[11px] text-fg-muted mt-0.5">
{totalErrors > 0
? `${totalErrors}${t("errorPropagation.agentErrors")}`
: t("errorPropagation.sessionErrorsOnly")}
@@ -148,7 +148,7 @@ export function ErrorPropagationMap({ data }: ErrorPropagationMapProps) {
{/* Errors by depth - horizontal bars */}
{hasDepthData && (
<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")}
</p>
<div className="flex flex-col gap-1.5">
@@ -165,7 +165,7 @@ export function ErrorPropagationMap({ data }: ErrorPropagationMapProps) {
onMouseEnter={() => setHoveredDepth(d.depth)}
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)}
</span>
<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 */}
{topTypes.length > 0 && (
<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")}
</p>
<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"
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}
</span>
<div className="w-16 h-1.5 bg-surface-4 rounded-full overflow-hidden flex-shrink-0">
<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)}%` }}
/>
</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}
</span>
</div>
@@ -232,14 +232,14 @@ export function ErrorPropagationMap({ data }: ErrorPropagationMapProps) {
{/* API & session errors */}
{data.eventErrors && data.eventErrors.length > 0 && (
<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")}
</p>
<div className="flex flex-col gap-1">
{data.eventErrors.map((e) => (
<div
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
width="14"
@@ -256,10 +256,13 @@ export function ErrorPropagationMap({ data }: ErrorPropagationMapProps) {
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</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}
</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
</span>
</div>
@@ -402,7 +402,7 @@ export function ModelDelegationFlow({ data }: ModelDelegationFlowProps) {
if (!hasData) {
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
width="40"
height="40"
@@ -787,7 +787,7 @@ export function OrchestrationDAG({ data, onNodeClick, selectedNode }: Orchestrat
fill="none"
stroke="currentColor"
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="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" />
</svg>
</div>
<h3 className="text-base font-medium text-gray-300 mb-2">{t("orchestration.noData")}</h3>
<p className="text-sm text-gray-500 max-w-sm">{t("orchestration.noDataDesc")}</p>
<h3 className="text-base font-medium text-fg-secondary mb-2">
{t("orchestration.noData")}
</h3>
<p className="text-sm text-fg-muted max-w-sm">{t("orchestration.noDataDesc")}</p>
</div>
);
}
@@ -831,7 +833,7 @@ export function OrchestrationDAG({ data, onNodeClick, selectedNode }: Orchestrat
{/* Legend */}
<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")}
</span>
{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"
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 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"
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>
@@ -882,12 +884,12 @@ function buildDAGTooltipContent(el: HTMLDivElement, node: DAGNode, t: TFn) {
const meta = describeNode(node, t);
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;
el.appendChild(title);
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;
el.appendChild(subtitle);
@@ -121,17 +121,17 @@ function Tooltip({ state }: { state: TooltipState }) {
return (
<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={{
left: nearRight ? state.x - 12 : state.x + 12,
top: state.y - 10,
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)}
</p>
<div className="flex flex-col gap-0.5 text-gray-400">
<div className="flex flex-col gap-0.5 text-fg-secondary">
<span>
{t("complexity.tooltip.duration")} {formatDurationSec(state.item.duration)}
</span>
@@ -173,7 +173,7 @@ function Legend() {
className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: statusColor(s) }}
/>
<span className="text-xs text-gray-500">
<span className="text-xs text-fg-muted">
{t(`common:status.${s}`, { defaultValue: s })}
</span>
</div>
@@ -190,7 +190,7 @@ function EmptyState() {
<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">
<svg
className="w-5 h-5 text-gray-600"
className="w-5 h-5 text-fg-muted"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
@@ -201,8 +201,8 @@ function EmptyState() {
<circle cx="14" cy="17" r="4" />
</svg>
</div>
<p className="text-sm font-medium text-gray-400">{t("complexity.noData")}</p>
<p className="text-xs text-gray-600 mt-1">{t("complexity.noDataDesc")}</p>
<p className="text-sm font-medium text-fg-secondary">{t("complexity.noData")}</p>
<p className="text-xs text-fg-muted mt-1">{t("complexity.noDataDesc")}</p>
</div>
);
}
@@ -83,15 +83,15 @@ function statusColor(status: string): string {
case "completed":
return "text-violet-400 bg-violet-500/10 border-violet-500/20";
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":
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":
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":
return "text-yellow-400 bg-yellow-500/10 border-yellow-500/20";
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={[
"flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors duration-150",
active === tab.id
? "bg-surface-5 text-gray-100 shadow-sm"
: "text-gray-500 hover:text-gray-300",
? "bg-surface-5 text-fg-primary shadow-sm"
: "text-fg-muted hover:text-fg-secondary",
].join(" ")}
>
{tab.icon}
@@ -192,20 +192,20 @@ function TreeNode({ node, depth }: TreeNodeProps) {
{/* Name */}
<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}
</span>
{/* 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}]
</span>
)}
{/* 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>
{node.children.length > 0 && (
@@ -226,7 +226,7 @@ interface AgentTreeProps {
function AgentTree({ tree }: AgentTreeProps) {
const { t } = useTranslation("workflows");
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 (
@@ -249,7 +249,7 @@ interface ToolTimelineProps {
function ToolTimeline({ events }: ToolTimelineProps) {
const { t } = useTranslation("workflows");
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 (
@@ -267,11 +267,13 @@ function ToolTimeline({ events }: ToolTimelineProps) {
{/* 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 */}
<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)}
</span>
</div>
@@ -288,22 +290,22 @@ interface EventSequenceProps {
}
const EVENT_TYPE_COLOR: Record<string, string> = {
tool_use: "text-blue-400",
tool_result: "text-emerald-400",
tool_use: "text-blue-500",
tool_result: "text-status-success",
agent_start: "text-indigo-400",
agent_stop: "text-violet-400",
compaction: "text-amber-400",
error: "text-red-400",
compaction: "text-status-warning",
error: "text-status-danger",
};
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) {
const { t } = useTranslation("workflows");
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);
@@ -325,18 +327,18 @@ function EventSequence({ events }: EventSequenceProps) {
</span>
{/* 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 ?? "-"}
</span>
{/* 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)}
</span>
</div>
))}
{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 })}
</p>
)}
@@ -365,11 +367,11 @@ function ErrorState({ message }: ErrorStateProps) {
const { t } = useTranslation("workflows");
return (
<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">
<X className="w-4 h-4 text-red-400" />
<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-status-danger" />
</div>
<p className="text-sm font-medium text-red-400">{t("drillIn.failedLoad")}</p>
<p className="text-xs text-gray-600 mt-1 max-w-xs">{message}</p>
<p className="text-sm font-medium text-status-danger">{t("drillIn.failedLoad")}</p>
<p className="text-xs text-fg-muted mt-1 max-w-xs">{message}</p>
</div>
);
}
@@ -405,17 +407,19 @@ function NoSessionState({ onSelectSession }: NoSessionStateProps) {
<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">
<GitFork className="w-5 h-5 text-gray-600" />
<GitFork className="w-5 h-5 text-fg-muted" />
</div>
<p className="text-sm font-medium text-gray-400 mb-1">{t("drillIn.noSessionSelected")}</p>
<p className="text-xs text-gray-600 max-w-xs">{t("drillIn.noSessionDesc")}</p>
<p className="text-sm font-medium text-fg-secondary mb-1">
{t("drillIn.noSessionSelected")}
</p>
<p className="text-xs text-fg-muted max-w-xs">{t("drillIn.noSessionDesc")}</p>
{/* Preview tab pills */}
<div className="flex gap-2 mt-5">
{tabs.map((tab) => (
<div
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.label}
@@ -444,10 +448,10 @@ function SessionHeader({ drillIn, onClose, activeTab, onTabChange }: SessionHead
<div className="flex flex-col gap-3 mb-4">
<div className="flex items-start justify-between gap-3">
<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}
</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;{" "}
{t(`common:status.${session.status}`, { defaultValue: session.status })}
{session.started_at && ` \u00b7 ${safeTimestamp(session.started_at)}`}
@@ -456,7 +460,7 @@ function SessionHeader({ drillIn, onClose, activeTab, onTabChange }: SessionHead
<button
type="button"
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")}
>
<X className="w-4 h-4" />
@@ -569,13 +573,13 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) {
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
ref={inputRef}
type="text"
value={search}
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)}
onChange={(e) => {
setSearch(e.target.value);
@@ -584,7 +588,7 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) {
/>
<ChevronDown
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" : "",
].join(" ")}
/>
@@ -605,7 +609,7 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) {
))}
</div>
) : 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")}
</p>
) : (
@@ -625,22 +629,22 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) {
{s.status}
</span>
<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}
</span>
{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}
</span>
)}
</span>
{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)}
</span>
)}
{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)}
</span>
)}
@@ -656,7 +660,7 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) {
type="button"
onClick={handleLoadMore}
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")}
</button>
@@ -737,11 +741,11 @@ export function SessionDrillIn({ sessionId, onClose, onSelectSession }: SessionD
<div className="bg-surface-2 border border-border rounded-xl p-4">
<SessionSelector onSelectSession={onSelectSession} />
<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
type="button"
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")}
>
<X className="w-3.5 h-3.5" />
@@ -146,7 +146,7 @@ function SuccessRing({ rate, color }: SuccessRingProps) {
{clampedRate.toFixed(0)}%
</text>
</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")}
</span>
</div>
@@ -213,7 +213,7 @@ function Sparkline({ data, color }: SparklineProps) {
{bars.map((_, i) => (
<span
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] ?? ""}
</span>
@@ -281,11 +281,11 @@ function SparklineTooltip({
<div
ref={ref}
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 }}
>
<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 }}>
{t("effectiveness.sessionCount", { count: value })}
</span>
@@ -302,10 +302,10 @@ interface MetricBoxProps {
function MetricBox({ label, value }: MetricBoxProps) {
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">
<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}
</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}
</span>
</div>
@@ -337,7 +337,7 @@ function ScoreCard({ item, colorIndex }: ScoreCardProps) {
style={{ backgroundColor: color }}
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}
</span>
</div>
@@ -358,7 +358,7 @@ function ScoreCard({ item, colorIndex }: ScoreCardProps) {
{/* Sparkline */}
<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")}
</span>
<Sparkline data={item.trend} color={color} />
@@ -375,7 +375,7 @@ export function SubagentEffectiveness({ data }: SubagentEffectivenessProps) {
const { t } = useTranslation("workflows");
if (data.length === 0) {
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")}
</div>
);
@@ -507,7 +507,7 @@ export function ToolExecutionFlow({
<div className="relative" ref={containerRef} onMouseLeave={hideTip}>
{isEmpty ? (
<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>
) : (
<svg
@@ -663,7 +663,7 @@ function Legend() {
style={{ background: color, opacity: 0.9 }}
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>
@@ -159,12 +159,12 @@ function StepFlow({ steps }: { steps: string[] }) {
<span key={idx} className="flex items-center gap-1">
<StepPill label={step} />
{(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>
))}
{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 })}
</span>
)}
@@ -176,8 +176,8 @@ function PatternFrequency({ count, percentage }: { count: number; percentage: nu
const { t } = useTranslation("workflows");
return (
<div className="flex-shrink-0 text-right">
<p className="text-sm font-semibold text-gray-100">{count.toLocaleString()}</p>
<p className="text-xs text-gray-500">
<p className="text-sm font-semibold text-fg-primary">{count.toLocaleString()}</p>
<p className="text-xs text-fg-muted">
{percentage.toFixed(1)}% {t("common:ofSessions", { defaultValue: "of sessions" })}
</p>
</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. */}
{!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>
@@ -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">
{/* Full step sequence (no truncation) */}
<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")}
</p>
<div className="flex items-center flex-wrap gap-1.5">
@@ -260,7 +260,7 @@ function PatternDetail({ pattern }: { pattern: WorkflowPattern }) {
{step}
</span>
{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>
))}
@@ -286,11 +286,11 @@ function PatternDetail({ pattern }: { pattern: WorkflowPattern }) {
{/* Narrative - what this means */}
<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" />
{t("patterns.detail.narrativeHeading")}
</p>
<p className="text-xs text-gray-300 leading-relaxed">{narrative}</p>
<p className="text-xs text-fg-secondary leading-relaxed">{narrative}</p>
</div>
{/* Suggestion */}
@@ -299,7 +299,7 @@ function PatternDetail({ pattern }: { pattern: WorkflowPattern }) {
<Lightbulb className="w-3 h-3" />
{t("patterns.detail.suggestionHeading")}
</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>
);
@@ -308,8 +308,8 @@ function PatternDetail({ pattern }: { pattern: WorkflowPattern }) {
function DetailStat({ label, value }: { label: string; value: string }) {
return (
<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-[10px] text-gray-500 uppercase tracking-wider mt-0.5 truncate">{label}</p>
<p className="text-sm font-semibold text-fg-primary tabular-nums">{value}</p>
<p className="text-[10px] text-fg-muted uppercase tracking-wider mt-0.5 truncate">{label}</p>
</div>
);
}
@@ -327,8 +327,8 @@ function SoloSessionItem({ count, percentage }: { count: number; percentage: num
</span>
</div>
<div className="flex-shrink-0 text-right">
<p className="text-sm font-semibold text-gray-100">{count.toLocaleString()}</p>
<p className="text-xs text-gray-500">
<p className="text-sm font-semibold text-fg-primary">{count.toLocaleString()}</p>
<p className="text-xs text-fg-muted">
{percentage.toFixed(1)}% {t("common:ofSessions", { defaultValue: "of sessions" })}
</p>
</div>
@@ -341,10 +341,10 @@ function EmptyPatterns() {
return (
<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">
<Zap className="w-5 h-5 text-gray-600" />
<Zap className="w-5 h-5 text-fg-muted" />
</div>
<p className="text-sm font-medium text-gray-400">{t("patterns.noData")}</p>
<p className="text-xs text-gray-600 mt-1">{t("patterns.noDataDesc")}</p>
<p className="text-sm font-medium text-fg-secondary">{t("patterns.noData")}</p>
<p className="text-xs text-fg-muted mt-1">{t("patterns.noDataDesc")}</p>
</div>
);
}
@@ -372,7 +372,7 @@ export function WorkflowPatterns({ data, onPatternClick }: WorkflowPatternsProps
return (
<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")}
</h2>
@@ -109,18 +109,18 @@ interface Props {
}
const STATUS_STYLES: Record<string, string> = {
running: "bg-amber-500/15 text-amber-400 border-amber-500/30",
working: "bg-amber-500/15 text-amber-400 border-amber-500/30",
queued: "bg-gray-500/15 text-gray-400 border-gray-500/30",
completed: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30",
done: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30",
success: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30",
error: "bg-red-500/15 text-red-400 border-red-500/30",
failed: "bg-red-500/15 text-red-400 border-red-500/30",
running: "bg-status-warning/15 text-status-warning border-status-warning/30",
working: "bg-status-warning/15 text-status-warning border-status-warning/30",
queued: "bg-surface-4/15 text-fg-secondary border-border-light/30",
completed: "bg-status-success/15 text-status-success border-status-success/30",
done: "bg-status-success/15 text-status-success border-status-success/30",
success: "bg-status-success/15 text-status-success border-status-success/30",
error: "bg-status-danger/15 text-status-danger border-status-danger/30",
failed: "bg-status-danger/15 text-status-danger border-status-danger/30",
};
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
@@ -129,15 +129,15 @@ function statusClass(status: string): string {
const PHASE_PALETTE = [
"bg-violet-500/15 text-violet-300 border-violet-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-emerald-500/15 text-emerald-300 border-emerald-500/40",
"bg-status-warning/15 text-status-warning border-status-warning/40",
"bg-status-success/15 text-status-success border-status-success/40",
"bg-rose-500/15 text-rose-300 border-rose-500/40",
"bg-cyan-500/15 text-cyan-300 border-cyan-500/40",
"bg-fuchsia-500/15 text-fuchsia-300 border-fuchsia-500/40",
];
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 idx = i >= 0 ? i : Math.abs(hashStr(title)) % PHASE_PALETTE.length;
return PHASE_PALETTE[idx % PHASE_PALETTE.length] as string;
@@ -326,7 +326,7 @@ export function WorkflowRunsPanel({
if (!controlled && loading) {
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" />
<span className="animate-pulse">{t("runs.loading")}</span>
</div>
@@ -334,8 +334,8 @@ export function WorkflowRunsPanel({
}
if (runs.length === 0) {
return (
<div className="text-sm text-gray-500 flex items-center gap-2">
<Workflow className="w-4 h-4 text-gray-600" />
<div className="text-sm text-fg-muted flex items-center gap-2">
<Workflow className="w-4 h-4 text-fg-muted" />
{t("runs.empty")}
</div>
);
@@ -356,36 +356,36 @@ export function WorkflowRunsPanel({
return (
<div
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
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}
>
{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 ? (
<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" />
)}
<div className="flex-1 min-w-0">
<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}
</span>
<span className={`badge text-[10px] border ${statusClass(run.status)}`}>
{t(`runs.status.${run.status}`, run.status)}
</span>
{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 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.tools", { count: run.total_tool_calls })}</span>
<span>
@@ -399,7 +399,7 @@ export function WorkflowRunsPanel({
<Link
to={`/sessions/${encodeURIComponent(run.session_id)}`}
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")}
>
<ExternalLink className="w-3.5 h-3.5" />
@@ -408,11 +408,11 @@ export function WorkflowRunsPanel({
</button>
{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 */}
{phaseTitles.length > 0 && (
<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) => {
const active = sel === title;
return (
@@ -435,7 +435,7 @@ export function WorkflowRunsPanel({
{sel && (
<button
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")}
</button>
@@ -447,7 +447,7 @@ export function WorkflowRunsPanel({
<div className="overflow-x-auto">
<table className="w-full text-[11px]">
<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.phase")}</th>
<th className="py-1 pr-3 font-medium">{t("runs.col.state")}</th>
@@ -464,11 +464,11 @@ export function WorkflowRunsPanel({
</thead>
<tbody>
{shown.map((a: WorkflowProgressEntry, i) => (
<tr key={a.agentId || i} className="border-b border-gray-800/40">
<td className="py-1 pr-3 text-gray-300">
<tr key={a.agentId || i} className="border-b border-border/40">
<td className="py-1 pr-3 text-fg-secondary">
{a.label || a.agentType || a.agentId}
{a.lastToolName && (
<span className="text-gray-600 font-mono ml-1">
<span className="text-fg-muted font-mono ml-1">
· {a.lastToolName}
</span>
)}
@@ -487,13 +487,13 @@ export function WorkflowRunsPanel({
{t(`runs.status.${a.state}`, String(a.state || "-"))}
</span>
</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)}
</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}
</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) : "-"}
</td>
</tr>
@@ -502,15 +502,15 @@ export function WorkflowRunsPanel({
</table>
</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 */}
{resultRows.length > 0 && (
<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")}
<span className="ml-1 text-gray-700">· {resultRows.length}</span>
<span className="ml-1 text-fg-muted">· {resultRows.length}</span>
</div>
{resultRows.map((a, i) => {
const key = `${run.run_id}::${a.agentId || i}`;
@@ -528,7 +528,7 @@ export function WorkflowRunsPanel({
return (
<div
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
onClick={() => {
@@ -537,13 +537,13 @@ export function WorkflowRunsPanel({
}
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}
>
{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
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}
</span>
{!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)}
</span>
)}
</button>
{open && (
<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>}
<span
className={`badge border ${statusClass(String(a.state || ""))}`}
@@ -579,19 +579,19 @@ export function WorkflowRunsPanel({
</div>
{fullPrompt && (
<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")}
</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}
</pre>
</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")}
</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}
</pre>
</div>
@@ -76,9 +76,9 @@ function formatDurationSec(sec: 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";
return "text-red-400";
return "text-status-danger";
}
// ── Deterministic interpreters - return an i18n key + params ─────────────────
@@ -228,7 +228,7 @@ function InfoPopover({ calculationKey, interp, valueDisplay, metricPhraseKey }:
onMouseLeave={() => setOpen(false)}
onFocus={() => setOpen(true)}
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" />
</button>
@@ -236,27 +236,27 @@ function InfoPopover({ calculationKey, interp, valueDisplay, metricPhraseKey }:
<div
ref={popoverRef}
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 }}
>
<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}
</span>
<span className="text-[10px] uppercase tracking-wider text-gray-500">
<span className="text-[10px] uppercase tracking-wider text-fg-muted">
{metricPhrase}
</span>
</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")}
</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")}
</p>
<p className="text-gray-400 leading-snug">{valueMeans}</p>
<p className="text-fg-secondary leading-snug">{valueMeans}</p>
</div>
)}
</>
@@ -287,7 +287,7 @@ function StatCard({
return (
<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">
<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}
</span>
<Icon className={`w-4 h-4 flex-shrink-0 ${accentClass}`} />
@@ -340,7 +340,7 @@ export function WorkflowStats({ stats }: WorkflowStatsProps) {
label={t("stats.avgSubagentsPerSession")}
value={stats.avgSubagents.toFixed(1)}
icon={Users}
accentClass="text-blue-400"
accentClass="text-blue-500"
calculationKey="stats.tooltip.calc.subagents"
interp={interpAvgSubagents(stats.avgSubagents)}
metricPhraseKey="stats.tooltip.phrase.subagents"
@@ -376,7 +376,7 @@ export function WorkflowStats({ stats }: WorkflowStatsProps) {
label={t("stats.avgDuration")}
value={formatDurationSec(stats.avgDurationSec)}
icon={Clock}
accentClass="text-amber-400"
accentClass="text-status-warning"
calculationKey="stats.tooltip.calc.duration"
interp={interpAvgDuration(stats.avgDurationSec)}
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.forget": "forget lane",
"action.purge": "purge history",
"action.remove": "remove",
"action.clear": "clear state",
"action.forget": "delete lane",
"action.purge": "delete history",
"action.remove": "delete lane + worktree",
"action.reset": "reset worktree",
"action.start": "start",
"action.stop": "stop",
@@ -19,6 +19,7 @@
"addLaneTitlePlaceholder": "Optional",
"autoStage": "auto: {{stage}}",
"cardId": "Lane {{id}}",
"stageUndeclared": "not declared",
"confirmRemoveCancel": "Cancel",
"confirmRemoveConfirm": "Remove",
"confirmRemoveMessage": "This action cannot be undone.",
+6
View File
@@ -50,6 +50,12 @@
"skipToContent": "Skip to content",
"statsPersisted": "Stats persist across reloads",
"switchLanguage": "Switch to {{language}}",
"switchTheme": "Switch to {{theme}}",
"theme": "Theme",
"themeNames": {
"dark": "Dark",
"light": "Light"
},
"throughput60s": "Throughput · last 60s",
"topEventTypes": "Top event types",
"unitEvents": "events",
+4 -3
View File
@@ -1,8 +1,8 @@
{
"action.clear": "xóa",
"action.forget": "quên làn đường",
"action.clear": "dọn trạng thái",
"action.forget": "xóa làn",
"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.start": "bắt đầu",
"action.stop": "dừng",
@@ -19,6 +19,7 @@
"addLaneTitlePlaceholder": "Không bắt buộc",
"autoStage": "tự động: {{stage}}",
"cardId": "Làn đường {{id}}",
"stageUndeclared": "chưa khai báo",
"confirmRemoveCancel": "Hủy",
"confirmRemoveConfirm": "Xóa",
"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",
"statsPersisted": "Số liệu được lưu sau khi tải lại",
"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",
"topEventTypes": "Loại sự kiện phổ biến",
"unitEvents": "sự kiện",
+77 -12
View File
@@ -8,10 +8,74 @@
@tailwind components;
@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 {
* {
scrollbar-width: thin;
scrollbar-color: #2a2a3d #0c0c14;
scrollbar-color: rgb(var(--border)) rgb(var(--surface-0));
}
*::-webkit-scrollbar {
@@ -20,21 +84,21 @@
}
*::-webkit-scrollbar-track {
background: #0c0c14;
background: rgb(var(--surface-0));
}
*::-webkit-scrollbar-thumb {
background: #2a2a3d;
background: rgb(var(--border));
border-radius: 3px;
}
*::-webkit-scrollbar-thumb:hover {
background: #363650;
background: rgb(var(--border-light));
}
::selection {
background: rgba(99, 102, 241, 0.3);
color: #e4e4ed;
background: rgb(var(--accent) / 0.3);
color: rgb(var(--fg-primary));
}
::-webkit-calendar-picker-indicator {
@@ -57,6 +121,7 @@
@layer components {
.card {
@apply bg-surface-3 border border-border rounded-xl;
box-shadow: var(--shadow-card);
}
.card-hover {
@@ -68,7 +133,7 @@
}
.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 {
@@ -76,7 +141,7 @@
}
.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;
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
border: 1px solid #363650;
background: #15151f;
color: #e4e4ed;
border: 1px solid rgb(var(--border-light));
background: rgb(var(--surface-2));
color: rgb(var(--fg-primary));
font-size: 0.8125rem;
font-weight: 500;
text-decoration: none;
@@ -109,7 +174,7 @@
}
.skip-to-content:focus {
transform: translateY(0);
outline: 2px solid #6366f1;
outline: 2px solid rgb(var(--accent));
outline-offset: 2px;
}
}
+21 -21
View File
@@ -331,20 +331,20 @@ export function ActivityFeed() {
</div>
<div>
<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 ? (
<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="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse-dot" />
<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-status-success animate-pulse-dot" />
{t("common:live")}
</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="w-1.5 h-1.5 rounded-full bg-gray-400" />
<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-surface-4" />
{t("common:offline")}
</span>
)}
</div>
<p className="text-xs text-gray-500">
<p className="text-xs text-fg-muted">
{t("subtitle")}
{paused && (
<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"
>
<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="text-[11px] text-gray-500">
<div className="text-[11px] text-fg-muted">
{formatTime(event.created_at)}
</div>
<div className="text-[9px] text-gray-600">
<div className="text-[9px] text-fg-muted">
{formatDateShort(event.created_at)}
</div>
</div>
@@ -457,10 +457,10 @@ export function ActivityFeed() {
);
return (
<div className="flex-1 min-w-0">
<p className="text-sm text-gray-300 truncate">
<p className="text-sm text-fg-secondary truncate">
{origin && (
<span
className="text-gray-500 mr-1"
className="text-fg-muted mr-1"
title={`${event.session_id} · ${event.agent_id ?? ""}`}
>
{origin} ·
@@ -473,12 +473,12 @@ export function ActivityFeed() {
})()}
{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}
</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)}
</span>
@@ -486,7 +486,7 @@ export function ActivityFeed() {
to={`/sessions/${event.session_id}`}
onClick={(e) => e.stopPropagation()}
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")}
<ExternalLink className="w-3 h-3" />
@@ -500,7 +500,7 @@ export function ActivityFeed() {
</div>
{total > 0 && (
<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", {
from: page * PAGE_SIZE + 1,
to: Math.min((page + 1) * PAGE_SIZE, total),
@@ -511,7 +511,7 @@ export function ActivityFeed() {
<button
onClick={() => setPage(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"
>
«
@@ -519,7 +519,7 @@ export function ActivityFeed() {
<button
onClick={() => setPage((p) => Math.max(0, p - 1))}
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")}
</button>
@@ -544,7 +544,7 @@ export function ActivityFeed() {
p === "..." ? (
<span
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>
@@ -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 ${
p === page
? "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}
@@ -567,14 +567,14 @@ export function ActivityFeed() {
<button
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 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")}
</button>
<button
onClick={() => setPage(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"
>
»
+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;
return (
<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={{
left: nearRight ? x - 14 : x + 14,
top: y - 10,
@@ -211,7 +211,7 @@ function Heatmap({ weeks }: { weeks: Array<Array<{ date: string; count: number }
{monthPositions.map((mp, i) => (
<div
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 }}
>
{mp.label}
@@ -224,7 +224,7 @@ function Heatmap({ weeks }: { weeks: Array<Array<{ date: string; count: number }
{dayLabels.map((d, i) => (
<div
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 }}
>
{d}
@@ -247,7 +247,7 @@ function Heatmap({ weeks }: { weeks: Array<Array<{ date: string; count: number }
show(
e,
<>
<span className="text-gray-400">
<span className="text-fg-secondary">
{dayNames[dow] ?? ""}, {cell.date}
</span>
<span className="ml-2 font-medium">
@@ -273,7 +273,7 @@ function Heatmap({ weeks }: { weeks: Array<Array<{ date: string; count: number }
))}
</div>
{/* 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>
{[0, 0.25, 0.5, 0.75, 1].map((f) => {
const v = Math.round(f * maxCount);
@@ -324,7 +324,7 @@ function Sparkline({
show(
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>
</>
)
@@ -404,7 +404,7 @@ function CostTrendLine({
show(
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>
</>
)
@@ -437,7 +437,7 @@ function BarRow({
const width = pct !== undefined ? pct : max > 0 ? Math.round((count / max) * 100) : 0;
return (
<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}
</span>
<div className="flex-1 bg-surface-3 rounded-full h-2">
@@ -447,7 +447,7 @@ function BarRow({
/>
</div>
<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>
</div>
);
@@ -457,7 +457,7 @@ function CostBarRow({
label,
cost,
max,
color = "bg-emerald-400",
color = "bg-status-success",
}: {
label: string;
cost: number;
@@ -467,7 +467,7 @@ function CostBarRow({
const width = max > 0 ? Math.max(2, Math.round((cost / max) * 100)) : 0;
return (
<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}
</span>
<div className="flex-1 bg-surface-3 rounded-full h-2">
@@ -476,7 +476,7 @@ function CostBarRow({
style={{ width: `${width}%` }}
/>
</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>
</span>
</div>
@@ -495,7 +495,7 @@ function DonutChart({
const { t } = useTranslation(["analytics", "common"]);
const { show, move, hide, node } = useTooltip();
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 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)}
</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")}
</text>
</svg>
@@ -557,8 +557,8 @@ function DonutChart({
className="w-2.5 h-2.5 rounded-sm flex-shrink-0"
style={{ backgroundColor: color }}
/>
<span className="text-gray-400">{label}</span>
<span className="text-gray-500 ml-auto pl-4">{Math.round((value / total) * 100)}%</span>
<span className="text-fg-secondary">{label}</span>
<span className="text-fg-muted ml-auto pl-4">{Math.round((value / total) * 100)}%</span>
</div>
))}
</div>
@@ -588,7 +588,7 @@ function StatPill({
return (
<div className="card p-5 flex flex-col gap-2">
<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}`} />
</div>
{loading ? (
@@ -601,7 +601,7 @@ function StatPill({
{loading ? (
<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>
);
@@ -881,8 +881,8 @@ export function Analytics() {
].filter((s) => s.value > 0);
const EVENT_TYPE_COLORS: Record<string, string> = {
PreToolUse: "bg-emerald-400",
PostToolUse: "bg-blue-400",
PreToolUse: "bg-status-success",
PostToolUse: "bg-blue-500",
Stop: "bg-violet-400",
SubagentStop: "bg-yellow-400",
Notification: "bg-orange-400",
@@ -899,22 +899,22 @@ export function Analytics() {
</div>
<div>
<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 ? (
<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="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse-dot" />
<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-status-success animate-pulse-dot" />
{t("common:live")}
</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="w-1.5 h-1.5 rounded-full bg-gray-400" />
<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-surface-4" />
{t("common:offline")}
</span>
)}
</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")}
<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" />
{lastUpdate.toLocaleTimeString()}
</span>
@@ -942,7 +942,7 @@ export function Analytics() {
raw={data ? data.overview.total_sessions.toLocaleString() : undefined}
sub={data ? `${data.overview.active_sessions} ${t("common:active")}` : undefined}
icon={FolderOpen}
color="text-blue-400"
color="text-blue-500"
loading={!data}
/>
<StatPill
@@ -951,7 +951,7 @@ export function Analytics() {
raw={data ? data.overview.total_agents.toLocaleString() : undefined}
sub={data ? `${data.overview.active_agents} ${t("common:active")}` : undefined}
icon={Bot}
color="text-emerald-400"
color="text-status-success"
loading={!data}
/>
<StatPill
@@ -973,7 +973,7 @@ export function Analytics() {
: undefined
}
icon={DollarSign}
color="text-emerald-400"
color="text-status-success"
loading={!costData}
/>
<StatPill
@@ -994,7 +994,7 @@ export function Analytics() {
{/* Activity heatmap + 30-day sparkline */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<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="w-fit min-w-max mx-auto">
<Heatmap weeks={weeks} />
@@ -1002,17 +1002,17 @@ export function Analytics() {
</div>
</div>
<div className="card p-5">
<h3 className="text-sm font-medium text-gray-300 mb-1">{t("last30Days")}</h3>
<p className="text-[11px] text-gray-600 mb-4">{t("dailyEventCount")}</p>
<h3 className="text-sm font-medium text-fg-secondary mb-1">{t("last30Days")}</h3>
<p className="text-[11px] text-fg-muted mb-4">{t("dailyEventCount")}</p>
<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[last30.length - 1]?.date?.slice(5)}</span>
</div>
<div className="mt-4 pt-4 border-t border-border space-y-1">
<div className="flex justify-between text-xs">
<span className="text-gray-500">{t("peakDay")}</span>
<span className="text-gray-300 font-mono">
<span className="text-fg-muted">{t("peakDay")}</span>
<span className="text-fg-secondary font-mono">
<Tip raw={Math.max(...last30.map((d) => d.count)).toLocaleString()}>
{fmt(Math.max(...last30.map((d) => d.count)))}
</Tip>{" "}
@@ -1020,8 +1020,8 @@ export function Analytics() {
</span>
</div>
<div className="flex justify-between text-xs">
<span className="text-gray-500">{t("total30d")}</span>
<span className="text-gray-300 font-mono">
<span className="text-fg-muted">{t("total30d")}</span>
<span className="text-fg-secondary font-mono">
<Tip raw={last30.reduce((s, d) => s + d.count, 0).toLocaleString()}>
{fmt(last30.reduce((s, d) => s + d.count, 0))}
</Tip>{" "}
@@ -1048,8 +1048,8 @@ export function Analytics() {
onClick={() => setActiveTab(key)}
className={`px-4 py-1.5 text-xs font-medium rounded-md transition-colors ${
activeTab === key
? "bg-surface-4 text-gray-200"
: "text-gray-500 hover:text-gray-300"
? "bg-surface-4 text-fg-secondary"
: "text-fg-muted hover:text-fg-secondary"
}`}
>
{label}
@@ -1061,7 +1061,7 @@ export function Analytics() {
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{/* Token bars */}
<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")}
</h3>
<div className="space-y-4">
@@ -1069,12 +1069,12 @@ export function Analytics() {
{
label: t("common:token.input"),
value: data?.tokens.total_input ?? 0,
color: "bg-blue-400",
color: "bg-blue-500",
},
{
label: t("common:token.output"),
value: data?.tokens.total_output ?? 0,
color: "bg-emerald-400",
color: "bg-status-success",
},
{
label: t("common:token.cacheRead"),
@@ -1097,13 +1097,13 @@ export function Analytics() {
))}
</div>
<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>
<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>
</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 className="text-violet-400 font-mono">{cacheHitPct}%</span>
</div>
@@ -1112,18 +1112,20 @@ export function Analytics() {
{/* Token summary */}
<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">
{[
{
label: t("common:token.input"),
value: data?.tokens.total_input ?? 0,
color: "text-blue-400",
color: "text-blue-500",
},
{
label: t("common:token.output"),
value: data?.tokens.total_output ?? 0,
color: "text-emerald-400",
color: "text-status-success",
},
{
label: t("common:token.cacheRead"),
@@ -1135,13 +1137,13 @@ export function Analytics() {
value: data?.tokens.total_cache_write ?? 0,
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 }) => (
<div
key={label}
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}`}>
{value.toLocaleString()}
</span>
@@ -1149,23 +1151,23 @@ export function Analytics() {
))}
</div>
{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>
{/* Token mix donut */}
<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 ? (
<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)} />
<div className="mt-4 pt-4 border-t border-border space-y-2">
{tokenMixSegments.map((segment) => (
<div key={segment.label} className="flex justify-between text-xs">
<span className="text-gray-400">{segment.label}</span>
<span className="text-gray-300 font-mono">
<span className="text-fg-secondary">{segment.label}</span>
<span className="text-fg-secondary font-mono">
<Tip raw={segment.value.toLocaleString()}>{fmt(segment.value)}</Tip>
</span>
</div>
@@ -1181,29 +1183,31 @@ export function Analytics() {
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{/* Daily cost trends */}
<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 ? (
<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} />
<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[dailyCostLast30.length - 1]?.date?.slice(5)}</span>
</div>
<div className="mt-4 pt-4 border-t border-border space-y-1">
<div className="flex justify-between text-xs">
<span className="text-gray-500">{t("peakCostDay")}</span>
<span className="text-emerald-400 font-mono">
<span className="text-fg-muted">{t("peakCostDay")}</span>
<span className="text-status-success font-mono">
<Tip raw={`${peakCostDay.date}${fmtCostFull(peakCostDay.cost)}`}>
{fmtCost(peakCostDay.cost)}
</Tip>
</span>
</div>
<div className="flex justify-between text-xs">
<span className="text-gray-500">{t("totalCost30d")}</span>
<span className="text-emerald-400 font-mono">
<span className="text-fg-muted">{t("totalCost30d")}</span>
<span className="text-status-success font-mono">
<Tip raw={fmtCostFull(totalCost30d)}>{fmtCost(totalCost30d)}</Tip>
</span>
</div>
@@ -1214,7 +1218,7 @@ export function Analytics() {
{/* Cost by model */}
<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 ? (
<>
<DonutChart
@@ -1231,17 +1235,17 @@ export function Analytics() {
<div className="mt-4 pt-4 border-t border-border space-y-2">
{costBreakdown.map((b) => (
<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)}
</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>
</span>
</div>
))}
<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-emerald-400 font-mono font-semibold">
<span className="text-fg-secondary font-medium">{t("common:total")}</span>
<span className="text-status-success font-mono font-semibold">
<Tip raw={fmtCostFull(costData?.total_cost ?? 0)}>
{fmtCost(costData?.total_cost ?? 0)}
</Tip>
@@ -1250,18 +1254,20 @@ export function Analytics() {
</div>
</>
) : (
<p className="text-sm text-gray-500">{t("noCostData")}</p>
<p className="text-sm text-fg-muted">{t("noCostData")}</p>
)}
</div>
{/* Cost by weekday */}
<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 ? (
<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">
{weekdayCosts.map(({ label, cost }) => (
<CostBarRow
@@ -1274,7 +1280,7 @@ export function Analytics() {
))}
</div>
<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">
<Tip raw={fmtCostFull(totalCost30d)}>{fmtCost(totalCost30d)}</Tip>
</span>
@@ -1289,9 +1295,11 @@ export function Analytics() {
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{/* Agent type distribution */}
<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 ? (
<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">
{(data?.agent_types ?? []).slice(0, 10).map(({ subagent_type, count }) => (
@@ -1309,13 +1317,13 @@ export function Analytics() {
{/* Agent status donut */}
<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} />
<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>
<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)}
</span>
</Tip>
@@ -1323,7 +1331,7 @@ export function Analytics() {
{agentStatusSegments.map((s) => (
<div
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
@@ -1333,7 +1341,7 @@ export function Analytics() {
{s.label}
</span>
<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>
</div>
))}
@@ -1342,9 +1350,9 @@ export function Analytics() {
{/* Event type breakdown */}
<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 ? (
<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">
{(data?.event_types ?? []).map(({ event_type, count }) => (
@@ -1353,7 +1361,7 @@ export function Analytics() {
label={event_type}
count={count}
max={maxEventTypeCount}
color={EVENT_TYPE_COLORS[event_type] ?? "bg-gray-400"}
color={EVENT_TYPE_COLORS[event_type] ?? "bg-surface-4"}
/>
))}
</div>
@@ -1366,9 +1374,9 @@ export function Analytics() {
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{/* Top tools */}
<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 ? (
<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">
{(data?.tool_usage ?? []).slice(0, 12).map(({ tool_name, count }) => (
@@ -1386,13 +1394,15 @@ export function Analytics() {
{/* Session outcomes donut */}
<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} />
<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>
<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)}
</span>
</Tip>
@@ -1400,7 +1410,7 @@ export function Analytics() {
{sessionOutcomeSegments.map((s) => (
<div
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
@@ -1410,7 +1420,7 @@ export function Analytics() {
{s.label}
</span>
<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>
</div>
))}
@@ -1419,11 +1429,11 @@ export function Analytics() {
{/* Daily session trends */}
<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")}
</h3>
{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" />
@@ -1440,7 +1450,7 @@ export function Analytics() {
);
return (
<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)}
</span>
<div className="flex-1 bg-surface-3 rounded-full h-1.5">
@@ -1451,14 +1461,14 @@ export function Analytics() {
}}
/>
</div>
<span className="text-[11px] text-gray-500 w-4 text-right">
<span className="text-[11px] text-fg-muted w-4 text-right">
{count}
</span>
</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>
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="flex items-center justify-between">
<div className="flex items-center gap-3">
<Server className="w-4 h-4 text-emerald-400" />
<span className="text-xs text-gray-500 uppercase tracking-wider">Runtime</span>
<Server className="w-4 h-4 text-status-success" />
<span className="text-xs text-fg-muted uppercase tracking-wider">Runtime</span>
</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}
</span>
</div>
<div className="space-y-2.5">
<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-gray-200 font-mono ml-auto">
<span className="text-xs text-fg-secondary w-28 flex-shrink-0">Uptime</span>
<span className="text-xs text-fg-secondary font-mono ml-auto">
{formatUptime(info.server.uptime)}
</span>
</div>
<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">
{(info.server.cpu_load || []).slice(0, 3).map((load, i) => (
<span
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)}
</span>
@@ -307,8 +307,8 @@ function SystemHealthTab() {
</div>
</div>
<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-gray-200 font-mono ml-auto">
<span className="text-xs text-fg-secondary w-28 flex-shrink-0">Node RSS</span>
<span className="text-xs text-fg-secondary font-mono ml-auto">
{formatBytes(info.server.memory.rss)}
</span>
</div>
@@ -321,12 +321,12 @@ function SystemHealthTab() {
>
<div className="space-y-1">
<div className="flex justify-between text-[10px]">
<span className="text-gray-500">Host Memory</span>
<span className="text-gray-400 font-mono">{memUsedPct.toFixed(0)}%</span>
<span className="text-fg-muted">Host Memory</span>
<span className="text-fg-secondary font-mono">{memUsedPct.toFixed(0)}%</span>
</div>
<div className="w-full bg-surface-3 rounded-full h-2">
<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}%` }}
/>
</div>
@@ -338,12 +338,12 @@ function SystemHealthTab() {
>
<div className="space-y-1">
<div className="flex justify-between text-[10px]">
<span className="text-gray-500">V8 Heap</span>
<span className="text-gray-400 font-mono">{heapUsedPct.toFixed(0)}%</span>
<span className="text-fg-muted">V8 Heap</span>
<span className="text-fg-secondary font-mono">{heapUsedPct.toFixed(0)}%</span>
</div>
<div className="w-full bg-surface-3 rounded-full h-2">
<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}%` }}
/>
</div>
@@ -356,13 +356,13 @@ function SystemHealthTab() {
<div className="card p-5 flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Database className="w-4 h-4 text-blue-400" />
<span className="text-xs text-gray-500 uppercase tracking-wider">Storage</span>
<Database className="w-4 h-4 text-blue-500" />
<span className="text-xs text-fg-muted uppercase tracking-wider">Storage</span>
</div>
<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}`}
>
<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?.h1 ?? 0}
</span>
@@ -370,8 +370,8 @@ function SystemHealthTab() {
</div>
<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-gray-200 font-mono ml-auto">
<span className="text-xs text-fg-secondary w-28 flex-shrink-0">Database</span>
<span className="text-xs text-fg-secondary font-mono ml-auto">
{formatBytes(info.db.size)} · {info.db.pragmas?.journal_mode?.toUpperCase() || "WAL"}
</span>
</div>
@@ -425,7 +425,7 @@ function SystemHealthTab() {
y="46"
textAnchor="middle"
dominantBaseline="middle"
className="fill-gray-300"
className="fill-fg-secondary"
fontSize="12"
fontWeight="700"
fontFamily="monospace"
@@ -437,7 +437,7 @@ function SystemHealthTab() {
y="60"
textAnchor="middle"
dominantBaseline="middle"
className="fill-gray-600"
className="fill-fg-muted"
fontSize="8"
>
total
@@ -475,8 +475,8 @@ function SystemHealthTab() {
className="w-2.5 h-2.5 rounded-sm flex-shrink-0"
style={{ backgroundColor: item.color }}
/>
<span className="text-gray-400">{item.label}</span>
<span className="text-gray-500 ml-auto pl-3 font-mono">
<span className="text-fg-secondary">{item.label}</span>
<span className="text-fg-muted ml-auto pl-3 font-mono">
{Math.round(item.pct)}%
</span>
</div>
@@ -490,13 +490,13 @@ function SystemHealthTab() {
<div className="card p-5 flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<ShieldCheck className="w-4 h-4 text-emerald-400" />
<span className="text-xs text-gray-500 uppercase tracking-wider">Health Score</span>
<ShieldCheck className="w-4 h-4 text-status-success" />
<span className="text-xs text-fg-muted uppercase tracking-wider">Health Score</span>
</div>
<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)}`}
>
<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
</span>
</Tip>
@@ -526,7 +526,7 @@ function SystemHealthTab() {
y="57"
textAnchor="middle"
dominantBaseline="middle"
className="fill-gray-100"
className="fill-fg-primary"
fontSize="24"
fontWeight="800"
fontFamily="monospace"
@@ -538,7 +538,7 @@ function SystemHealthTab() {
y="76"
textAnchor="middle"
dominantBaseline="middle"
className="fill-gray-600"
className="fill-fg-muted"
fontSize="9"
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}`}
>
<div className="text-center cursor-default">
<p className="text-[9px] text-gray-600 uppercase">Cache</p>
<p className="text-xs font-mono font-bold text-blue-400">
<p className="text-[9px] text-fg-muted uppercase">Cache</p>
<p className="text-xs font-mono font-bold text-blue-500">
{cacheHitRate.toFixed(0)}%
</p>
</div>
@@ -566,9 +566,9 @@ function SystemHealthTab() {
raw={`Error Rate: ${errorRate.toFixed(2)}%\n<5% = healthy, 5-15% = warning, >15% = critical`}
>
<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
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)}%
</p>
@@ -579,7 +579,7 @@ function SystemHealthTab() {
raw={`Transcript compactions: ${workflow.compaction?.totalCompactions ?? 0}\nReduces context window by summarizing turns.`}
>
<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">
{workflow.compaction?.totalCompactions ?? 0}
</p>
@@ -590,8 +590,8 @@ function SystemHealthTab() {
raw={`Tokens recovered: ${(workflow.compaction?.tokensRecovered ?? 0).toLocaleString()}\nFreed by compaction.`}
>
<div className="text-center cursor-default">
<p className="text-[9px] text-gray-600 uppercase">Saved</p>
<p className="text-xs font-mono font-bold text-emerald-400">
<p className="text-[9px] text-fg-muted uppercase">Saved</p>
<p className="text-xs font-mono font-bold text-status-success">
{((workflow.compaction?.tokensRecovered ?? 0) / 1000).toFixed(1)}K
</p>
</div>
@@ -606,10 +606,10 @@ function SystemHealthTab() {
<div className="card p-5 flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Bot className="w-4 h-4 text-blue-400" />
<span className="text-xs text-gray-500 uppercase tracking-wider">Token Usage</span>
<Bot className="w-4 h-4 text-blue-500" />
<span className="text-xs text-fg-muted uppercase tracking-wider">Token Usage</span>
</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
</span>
</div>
@@ -619,10 +619,10 @@ function SystemHealthTab() {
const pct =
totalTokens > 0 ? ((m.input_tokens + m.output_tokens) / totalTokens) * 100 : 0;
const colors = [
"bg-blue-400",
"bg-blue-500",
"bg-violet-400",
"bg-emerald-400",
"bg-amber-400",
"bg-status-success",
"bg-status-warning",
"bg-pink-400",
"bg-cyan-400",
];
@@ -634,7 +634,7 @@ function SystemHealthTab() {
>
<div className="flex items-center gap-3 cursor-default">
<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}
>
{formatModelName(m.model) ?? m.model}
@@ -645,7 +645,7 @@ function SystemHealthTab() {
style={{ width: `${pct}%` }}
/>
</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)}%
</span>
</div>
@@ -653,7 +653,7 @@ function SystemHealthTab() {
);
})}
{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>
@@ -663,9 +663,9 @@ function SystemHealthTab() {
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<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>
<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>
{/* Sparkline-style bar chart matching Analytics sparkline */}
@@ -698,7 +698,7 @@ function SystemHealthTab() {
);
})}
{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
</p>
)}
@@ -708,8 +708,8 @@ function SystemHealthTab() {
<div className="grid grid-cols-3 gap-3 border-t border-border/40 pt-3">
<Tip block raw={`Peak: ${maxLaneCount} sessions running simultaneously.`}>
<div className="text-center cursor-default">
<p className="text-[9px] text-gray-600 uppercase">Peak</p>
<p className="text-sm font-mono font-bold text-gray-200">{maxLaneCount}</p>
<p className="text-[9px] text-fg-muted uppercase">Peak</p>
<p className="text-sm font-mono font-bold text-fg-secondary">{maxLaneCount}</p>
</div>
</Tip>
<Tip
@@ -717,16 +717,16 @@ function SystemHealthTab() {
raw={`${lanes.filter((l) => l.count > 0).length} of ${lanes.length} intervals have active sessions.`}
>
<div className="text-center cursor-default">
<p className="text-[9px] text-gray-600 uppercase">Active</p>
<p className="text-sm font-mono font-bold text-emerald-400">
<p className="text-[9px] text-fg-muted uppercase">Active</p>
<p className="text-sm font-mono font-bold text-status-success">
{lanes.filter((l) => l.count > 0).length}
</p>
</div>
</Tip>
<Tip block raw={`Average concurrency across all intervals.`}>
<div className="text-center cursor-default">
<p className="text-[9px] text-gray-600 uppercase">Avg</p>
<p className="text-sm font-mono font-bold text-blue-400">
<p className="text-[9px] text-fg-muted uppercase">Avg</p>
<p className="text-sm font-mono font-bold text-blue-500">
{lanes.length > 0
? (lanes.reduce((s, l) => s + l.count, 0) / lanes.length).toFixed(1)
: "0"}
@@ -743,23 +743,23 @@ function SystemHealthTab() {
<div className="card p-5 flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Zap className="w-4 h-4 text-amber-400" />
<span className="text-xs text-gray-500 uppercase tracking-wider">Tool Usage</span>
<Zap className="w-4 h-4 text-status-warning" />
<span className="text-xs text-fg-muted uppercase tracking-wider">Tool Usage</span>
</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 className="space-y-2">
{topTools.map((tool, i) => {
const pct = maxToolCount > 0 ? Math.round((tool.count / maxToolCount) * 100) : 0;
const colors = [
"bg-amber-400",
"bg-blue-400",
"bg-emerald-400",
"bg-status-warning",
"bg-blue-500",
"bg-status-success",
"bg-violet-400",
"bg-pink-400",
"bg-cyan-400",
"bg-red-400",
"bg-status-danger",
"bg-indigo-400",
];
return (
@@ -770,7 +770,7 @@ function SystemHealthTab() {
>
<div className="flex items-center gap-3 cursor-default">
<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}
>
{tool.tool_name}
@@ -781,7 +781,7 @@ function SystemHealthTab() {
style={{ width: `${pct}%` }}
/>
</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}
</span>
</div>
@@ -789,7 +789,7 @@ function SystemHealthTab() {
);
})}
{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>
@@ -799,7 +799,7 @@ function SystemHealthTab() {
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<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
</span>
</div>
@@ -809,10 +809,10 @@ function SystemHealthTab() {
{effectiveness.map((item, i) => {
const color =
item.successRate >= 90
? "bg-emerald-400"
? "bg-status-success"
: item.successRate >= 70
? "bg-amber-400"
: "bg-red-400";
? "bg-status-warning"
: "bg-status-danger";
return (
<Tip
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}`}
>
<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"}
</span>
<div className="flex-1 bg-surface-3 rounded-full h-2">
@@ -830,7 +830,7 @@ function SystemHealthTab() {
/>
</div>
<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)}%
</span>
@@ -839,7 +839,7 @@ function SystemHealthTab() {
);
})}
{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>
@@ -851,11 +851,11 @@ function SystemHealthTab() {
<div className="card p-5 flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Plug className="w-4 h-4 text-amber-400" />
<span className="text-xs text-gray-500 uppercase tracking-wider">Integration</span>
<Plug className="w-4 h-4 text-status-warning" />
<span className="text-xs text-fg-muted uppercase tracking-wider">Integration</span>
</div>
<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"}
</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={`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}
</span>
</div>
@@ -882,8 +882,8 @@ function SystemHealthTab() {
</div>
) : (
<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" />
<p className="text-xs text-gray-500">No project hooks registered</p>
<Search className="w-4 h-4 text-fg-muted mb-2" />
<p className="text-xs text-fg-muted">No project hooks registered</p>
</div>
)}
@@ -891,11 +891,11 @@ function SystemHealthTab() {
block
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">
<Activity className="w-3.5 h-3.5 text-emerald-400 animate-pulse" />
<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-status-success animate-pulse" />
<div>
<p className="text-[10px] text-emerald-400 font-medium">WebSocket Active</p>
<p className="text-[10px] text-gray-500">
<p className="text-[10px] text-status-success font-medium">WebSocket Active</p>
<p className="text-[10px] text-fg-muted">
{info.server.ws_connections} connection
{info.server.ws_connections !== 1 ? "s" : ""}
</p>
@@ -909,9 +909,9 @@ function SystemHealthTab() {
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<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>
<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 className="space-y-2">
@@ -935,16 +935,18 @@ function SystemHealthTab() {
{ label: "Platform", value: `${info.server.platform} / ${info.server.arch}` },
].map((row) => (
<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-gray-200 font-mono ml-auto">{row.value}</span>
<span className="text-xs text-fg-secondary w-28 flex-shrink-0">{row.label}</span>
<span className="text-xs text-fg-secondary font-mono ml-auto">{row.value}</span>
</div>
))}
</div>
<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">
<HardDrive className="w-3 h-3 text-gray-500 flex-shrink-0" />
<span className="text-[10px] text-gray-400 font-mono truncate">{info.db.path}</span>
<HardDrive className="w-3 h-3 text-fg-muted flex-shrink-0" />
<span className="text-[10px] text-fg-secondary font-mono truncate">
{info.db.path}
</span>
</div>
</Tip>
</div>
@@ -1145,8 +1147,8 @@ export function Dashboard() {
if (error) {
return (
<div className="text-center py-20">
<p className="text-red-400 mb-2">{t("failedConnect")}</p>
<p className="text-sm text-gray-500">{error}</p>
<p className="text-status-danger mb-2">{t("failedConnect")}</p>
<p className="text-sm text-fg-muted">{error}</p>
<button onClick={load} className="btn-primary mt-4">
{t("common:retry")}
</button>
@@ -1163,20 +1165,20 @@ export function Dashboard() {
</div>
<div>
<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 ? (
<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="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse-dot" />
<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-status-success animate-pulse-dot" />
{t("common:live")}
</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="w-1.5 h-1.5 rounded-full bg-gray-400" />
<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-surface-4" />
{t("common:offline")}
</span>
)}
</div>
<p className="text-xs text-gray-500">{t("subtitle")}</p>
<p className="text-xs text-fg-muted">{t("subtitle")}</p>
</div>
</div>
<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 ${
activeTab === "monitor"
? "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
@@ -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 ${
activeTab === "health"
? "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
@@ -1225,7 +1227,7 @@ export function Dashboard() {
label={t("activeAgents")}
value={stats?.active_agents ?? ""}
icon={Bot}
accentColor="text-emerald-400"
accentColor="text-status-success"
loading={!stats}
/>
<StatCard
@@ -1261,7 +1263,7 @@ export function Dashboard() {
: undefined
}
icon={DollarSign}
accentColor="text-emerald-400"
accentColor="text-status-success"
loading={totalCost === null}
/>
</div>
@@ -1270,7 +1272,9 @@ export function Dashboard() {
{/* Active agents */}
<div ref={agentsContainerRef} className="min-w-0 overflow-y-auto pr-6">
<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">
{t("viewBoard")} <ArrowRight className="w-3 h-3" />
</button>
@@ -1312,7 +1316,7 @@ export function Dashboard() {
{hasChildren && (
<button
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-expanded={isExpanded}
>
@@ -1375,7 +1379,7 @@ export function Dashboard() {
>
{t("common:subagent_label", { count: totalDesc })}
{activeDesc > 0 && (
<span className="text-emerald-400 ml-1">
<span className="text-status-success ml-1">
({activeDesc} {t("common:active")})
</span>
)}
@@ -1436,7 +1440,7 @@ export function Dashboard() {
{/* Recent activity */}
<div ref={activityContainerRef} className="min-w-0 overflow-y-auto pl-6">
<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">
{t("viewAll")} <ArrowRight className="w-3 h-3" />
</button>
@@ -1469,7 +1473,7 @@ export function Dashboard() {
: "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}
</span>
{(() => {
@@ -1479,7 +1483,7 @@ export function Dashboard() {
const isAuto = /^Session [0-9a-f]{8}$/i.test(sname);
return (
<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}
>
{sname && !isAuto ? (
@@ -1491,11 +1495,11 @@ export function Dashboard() {
);
})()}
{event.tool_name && (
<span className="text-[11px] text-gray-500 font-mono">
<span className="text-[11px] text-fg-muted font-mono">
{event.tool_name}
</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)}
</span>
</div>
+12 -12
View File
@@ -256,20 +256,20 @@ export function KanbanBoard() {
</div>
<div className="min-w-0">
<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 ? (
<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="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse-dot" />
<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-status-success animate-pulse-dot" />
{t("common:live")}
</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="w-1.5 h-1.5 rounded-full bg-gray-400" />
<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-surface-4" />
{t("common:offline")}
</span>
)}
</div>
<p className="text-xs text-gray-500 truncate">{subtitle}</p>
<p className="text-xs text-fg-muted truncate">{subtitle}</p>
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
@@ -392,7 +392,7 @@ function ViewToggle({ view, onChange }: ViewToggleProps) {
const baseClass =
"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 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 (
<div
@@ -463,7 +463,7 @@ function Column({
{t(labelKey)}
</span>
{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}
</span>
</div>
@@ -475,7 +475,7 @@ function Column({
{remaining > 0 && (
<button
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" />
{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}
</div>
)}
@@ -516,11 +516,11 @@ function ColumnHelp({ text }: { text: string }) {
onFocus={() => setShow(true)}
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 && (
<span
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}
</span>
+3 -3
View File
@@ -73,11 +73,11 @@ export function NotFound() {
<AlertTriangle className="w-7 h-7 text-accent" />
</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")}
</p>
<h2 className="text-2xl font-semibold text-gray-100 mb-2">{t("notFound.title")}</h2>
<p className="text-sm text-gray-400 mb-8">{t("notFound.description")}</p>
<h2 className="text-2xl font-semibold text-fg-primary mb-2">{t("notFound.title")}</h2>
<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">
<button className="btn-primary" onClick={() => navigate("/")}>
+58 -58
View File
@@ -577,7 +577,7 @@ export function SessionDetail() {
if (error || !session) {
return (
<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">
<ArrowLeft className="w-4 h-4" /> {t("detail.backToSessions")}
</button>
@@ -594,7 +594,7 @@ export function SessionDetail() {
</button>
<div className="flex-1">
<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)}`}
</h2>
<SessionStatusBadge
@@ -603,34 +603,34 @@ export function SessionDetail() {
/>
</div>
<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>
<CopyButton text={session.id} />
</span>
{session.model && (
<span className="inline-flex items-center gap-1.5 text-xs text-gray-400 bg-surface-2 px-2 py-1 rounded">
<Cpu className="w-3 h-3 text-gray-500" />
<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-fg-muted" />
{formatModelName(session.model)}
</span>
)}
<span className="inline-flex items-center gap-1.5 text-xs text-gray-400 bg-surface-2 px-2 py-1 rounded">
<Clock className="w-3 h-3 text-gray-500" />
<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-fg-muted" />
{formatDateTime(session.started_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)})
</span>
)}
</span>
{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" />
{fmtCostFull(cost.total_cost).slice(1)}
</span>
)}
</div>
{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" />
<span className="font-mono truncate" title={session.cwd}>
{session.cwd}
@@ -660,35 +660,35 @@ export function SessionDetail() {
<div
className={`flex items-center gap-3 rounded-lg border px-4 py-2.5 ${
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]"
}`}
>
<span
className={`w-7 h-7 rounded-md inline-flex items-center justify-center flex-shrink-0 border ${
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"
}`}
>
<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>
<div className="flex-1 min-w-0">
<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")}
{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)}
</span>
)}
</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")}
</div>
@@ -696,12 +696,12 @@ export function SessionDetail() {
{session.awaiting_input_since && (
<span
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
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"
/>
@@ -715,23 +715,23 @@ export function SessionDetail() {
{isDashboardRun && (
<Link
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">
<Play className="w-3.5 h-3.5 text-emerald-300" />
<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-status-success" />
</span>
<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")}
</div>
<div className="text-[11px] text-emerald-400/70">
<div className="text-[11px] text-status-success/70">
{t(
"detail.dashboardRun.body",
"Send follow-ups, watch streaming output, or stop the run from there."
)}
</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>
)}
@@ -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 ${
activeTab === "agents"
? "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" />
@@ -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 ${
activeTab === "conversation"
? "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" />
@@ -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 ${
activeTab === "timeline"
? "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" />
@@ -783,12 +783,12 @@ export function SessionDetail() {
{/* Tab Content */}
{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" />
<span>{t("detail.transcriptNotFound")}</span>
<button
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" />
</button>
@@ -801,23 +801,23 @@ export function SessionDetail() {
{workflows.length > 0 && (
<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" />
{wfT("runs.sessionTitle")}
<span className="text-gray-600 font-mono">· {workflows.length}</span>
<span className="text-fg-muted font-mono">· {workflows.length}</span>
</h3>
<WorkflowRunsPanel runs={workflows} hideSessionLink />
</div>
)}
{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" />
{t("detail.agents")}
<span className="text-gray-600 font-mono">· {agents.length}</span>
<span className="text-fg-muted font-mono">· {agents.length}</span>
</h3>
<div className="space-y-2" data-testid="agent-tree">
{(() => {
@@ -880,7 +880,7 @@ export function SessionDetail() {
{hasChildren && (
<button
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-expanded={isExpanded}
>
@@ -966,7 +966,7 @@ export function SessionDetail() {
{/* Orphaned subagents */}
{orphans.length > 0 && (
<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")}
</p>
<div className="space-y-1">
@@ -984,7 +984,7 @@ export function SessionDetail() {
{/* Cost Breakdown - shown under Agents tab */}
{cost && cost.breakdown.length > 0 && cost.total_cost > 0 && (
<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" />
{t("detail.costBreakdown")}
</h3>
@@ -992,22 +992,22 @@ export function SessionDetail() {
<table className="w-full min-w-[600px]">
<thead>
<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")}
</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")}
</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")}
</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")}
</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")}
</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")}
</th>
</tr>
@@ -1015,31 +1015,31 @@ export function SessionDetail() {
<tbody className="divide-y divide-border">
{cost.breakdown.map((row) => (
<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)}
</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()}
</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()}
</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()}
</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()}
</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)}
</td>
</tr>
))}
<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")}
</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)}
</td>
</tr>
@@ -1071,7 +1071,7 @@ export function SessionDetail() {
/>
</div>
{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")}
</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"
>
<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"
>
</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)}
</div>
<AgentStatusBadge status={statusFromEventType(event.event_type)} />
@@ -1115,10 +1115,10 @@ export function SessionDetail() {
agentOriginLabel(event.agent_id, agentInfoById)
);
return (
<span className="text-sm text-gray-300 flex-1 truncate">
<span className="text-sm text-fg-secondary flex-1 truncate">
{origin && (
<span
className="text-gray-500 mr-1"
className="text-fg-muted mr-1"
title={event.agent_id ?? undefined}
>
{origin} ·
@@ -1129,7 +1129,7 @@ export function SessionDetail() {
);
})()}
{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}
</span>
)}
@@ -1149,7 +1149,7 @@ export function SessionDetail() {
)}
{events.length < eventsTotal && (
<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 })}
</span>
<button
+33 -33
View File
@@ -283,20 +283,20 @@ export function Sessions() {
</div>
<div>
<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 ? (
<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="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse-dot" />
<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-status-success animate-pulse-dot" />
{t("common:live")}
</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="w-1.5 h-1.5 rounded-full bg-gray-400" />
<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-surface-4" />
{t("common:offline")}
</span>
)}
</div>
<p className="text-xs text-gray-500">
<p className="text-xs text-fg-muted">
{t("sessionCount", { count: total })}
{filter ? ` ${filter}` : ""}
</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">
{/* Search */}
<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
type="text"
placeholder={t("searchPlaceholder")}
@@ -335,7 +335,7 @@ export function Sessions() {
</option>
))}
</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>
{/* Sort Controls */}
@@ -344,7 +344,7 @@ export function Sessions() {
<select
value={sortBy}
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="duration">
@@ -352,12 +352,12 @@ export function Sessions() {
</option>
<option value="price">Sort by Price ({sortDesc ? "Highest" : "Lowest"})</option>
</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 className="w-px h-4 bg-border mx-1" />
<button
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"}
>
{sortDesc ? <SortDesc className="w-4 h-4" /> : <SortAsc className="w-4 h-4" />}
@@ -372,8 +372,8 @@ export function Sessions() {
onClick={() => setFilter(opt.value)}
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors whitespace-nowrap ${
filter === opt.value
? "bg-surface-4 text-gray-200"
: "text-gray-500 hover:text-gray-300"
? "bg-surface-4 text-fg-secondary"
: "text-fg-muted hover:text-fg-secondary"
}`}
>
{opt.label}
@@ -394,25 +394,25 @@ export function Sessions() {
<table className="w-full min-w-[800px]">
<thead>
<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")}
</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")}
</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")}
</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")}
</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")}
</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")}
</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")}
</th>
<th className="w-10"></th>
@@ -437,7 +437,7 @@ export function Sessions() {
<td className="px-5 py-4">
<div>
<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)}`}
</p>
{session.source && session.source !== "local" && (
@@ -453,7 +453,7 @@ export function Sessions() {
<Link
to={`/run?session=${encodeURIComponent(session.id)}`}
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")}
>
<Play className="w-2.5 h-2.5" />
@@ -461,7 +461,7 @@ export function Sessions() {
</Link>
)}
</div>
<p className="text-[11px] text-gray-600 font-mono">
<p className="text-[11px] text-fg-muted font-mono">
{session.id.slice(0, 12)}
</p>
</div>
@@ -472,28 +472,28 @@ export function Sessions() {
reason={sessionAwaitingReason(session)}
/>
</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)}
</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
? formatDuration(session.started_at, session.ended_at)
: t("common:running")}
</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 ?? "-"}
</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) : "-"}
</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}
>
{session.cwd ? truncate(session.cwd, 30) : "-"}
</td>
<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>
</tr>
))}
@@ -502,7 +502,7 @@ export function Sessions() {
</div>
{totalPages > 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", {
from: page * PAGE_SIZE + 1,
to: Math.min((page + 1) * PAGE_SIZE, total),
@@ -513,17 +513,17 @@ export function Sessions() {
<button
onClick={() => setPage((p) => Math.max(0, p - 1))}
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")}
</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}
</span>
<button
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 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")}
</button>
+167 -157
View File
@@ -311,8 +311,10 @@ function Toggle({
return (
<label className="flex items-center justify-between gap-3 cursor-pointer group">
<div className="min-w-0">
<p className="text-sm text-gray-300 group-hover:text-gray-200 transition-colors">{label}</p>
{description && <p className="text-xs text-gray-500 mt-0.5">{description}</p>}
<p className="text-sm text-fg-secondary group-hover:text-fg-secondary transition-colors">
{label}
</p>
{description && <p className="text-xs text-fg-muted mt-0.5">{description}</p>}
</div>
<button
type="button"
@@ -320,7 +322,7 @@ function Toggle({
aria-checked={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 ${
checked ? "bg-blue-500" : "bg-surface-4"
checked ? "bg-blue-600" : "bg-surface-4"
}`}
>
<span
@@ -394,7 +396,7 @@ function PricingInfoTooltip() {
onMouseLeave={() => setOpen(false)}
onFocus={() => setOpen(true)}
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" />
</button>
@@ -402,32 +404,36 @@ function PricingInfoTooltip() {
<div
ref={popoverRef}
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 }}
>
<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")}
</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")}
</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")}
</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")}
</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")}
</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>
)}
</>
@@ -866,14 +872,14 @@ export function Settings() {
<button
onClick={saveEdit}
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")}
>
<Check className="w-4 h-4" />
</button>
<button
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")}
>
<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">
{t("pricing.introRatesTitle")}
</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 className="flex flex-wrap items-end gap-3">
{introField("intro_until", "pricing.introUntil", { date: true })}
@@ -931,8 +937,8 @@ export function Settings() {
<div
className={`px-3 py-2 rounded-lg text-xs ${
match.isError
? "bg-red-500/10 border border-red-500/20 text-red-400"
: "bg-emerald-500/10 border border-emerald-500/20 text-emerald-400"
? "bg-status-danger/10 border border-status-danger/20 text-status-danger"
: "bg-status-success/10 border border-status-success/20 text-status-success"
}`}
>
{match.message}
@@ -981,27 +987,27 @@ export function Settings() {
</div>
<div>
<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 ? (
<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="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse-dot" />
<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-status-success animate-pulse-dot" />
{t("common:live")}
</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="w-1.5 h-1.5 rounded-full bg-gray-400" />
<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-surface-4" />
{t("common:offline")}
</span>
)}
</div>
<p className="text-xs text-gray-500">{t("subtitle")}</p>
<p className="text-xs text-fg-muted">{t("subtitle")}</p>
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<a
href={api.settings.exportData()}
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" />
{t("exportData")}
@@ -1015,7 +1021,7 @@ export function Settings() {
{/* In-page section navigation - Settings is dense, so this TOC jumps to
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">
<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")}
</span>
{tocOverflow.left && (
@@ -1023,7 +1029,7 @@ export function Settings() {
type="button"
onClick={() => scrollTocBy(-180)}
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" />
</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 ${
active
? "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" />
@@ -1057,7 +1063,7 @@ export function Settings() {
type="button"
onClick={() => scrollTocBy(180)}
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" />
</button>
@@ -1068,12 +1074,12 @@ export function Settings() {
<div className="card p-6">
<div className="flex items-center justify-between flex-wrap 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">
<DollarSign className="w-6 h-6 text-emerald-400" />
<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-status-success" />
</div>
<div>
<p className="text-sm text-gray-500">{t("common:cost.totalEstimatedCost")}</p>
<p className="text-2xl font-semibold text-gray-100">
<p className="text-sm text-fg-muted">{t("common:cost.totalEstimatedCost")}</p>
<p className="text-2xl font-semibold text-fg-primary">
<Tip
raw={
totalCost !== null
@@ -1086,7 +1092,7 @@ export function Settings() {
</p>
</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("basedOnUsage")}</p>
</div>
@@ -1097,12 +1103,12 @@ export function Settings() {
<section id="pricing" className="scroll-mt-24">
<div className="flex flex-wrap items-center justify-between gap-3 mb-4">
<div>
<h3 className="text-sm font-medium text-gray-300 flex items-center gap-2">
<DollarSign className="w-4 h-4 text-gray-500" />
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2">
<DollarSign className="w-4 h-4 text-fg-muted" />
{t("pricing.title")}
<PricingInfoTooltip />
</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 className="flex items-center gap-2">
<button
@@ -1114,8 +1120,8 @@ export function Settings() {
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 ${
confirmAction === "reset-pricing"
? "bg-amber-500/20 text-amber-400 border border-amber-500/30"
: "text-gray-400 hover:text-gray-300 hover:bg-surface-4"
? "bg-status-warning/20 text-status-warning border border-status-warning/30"
: "text-fg-secondary hover:text-fg-primary hover:bg-surface-4"
}`}
>
<RotateCcw className="w-3 h-3" />
@@ -1134,7 +1140,7 @@ export function Settings() {
</div>
{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}
</div>
)}
@@ -1145,34 +1151,34 @@ export function Settings() {
<table className="w-full min-w-[1000px]">
<thead>
<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")}
</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")}
</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")}
</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")}
</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")}
</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")}
</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")}
</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")}
</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")}
</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")}
</th>
</tr>
@@ -1189,10 +1195,10 @@ export function Settings() {
key={rule.model_pattern}
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}
</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.intro_until && (
<div className="text-[11px] font-normal text-violet-400/80 mt-0.5">
@@ -1204,7 +1210,7 @@ export function Settings() {
</div>
)}
</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.intro_until && (
<span className="block text-[11px] text-violet-400/80">
@@ -1212,7 +1218,7 @@ export function Settings() {
</span>
)}
</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.intro_until && (
<span className="block text-[11px] text-violet-400/80">
@@ -1220,7 +1226,7 @@ export function Settings() {
</span>
)}
</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.intro_until && (
<span className="block text-[11px] text-violet-400/80">
@@ -1228,7 +1234,7 @@ export function Settings() {
</span>
)}
</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.intro_until && (
<span className="block text-[11px] text-violet-400/80">
@@ -1236,7 +1242,7 @@ export function Settings() {
</span>
)}
</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.intro_until && (
<span className="block text-[11px] text-violet-400/80">
@@ -1244,10 +1250,10 @@ export function Settings() {
</span>
)}
</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}` : "-"}
</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}` : "-"}
</td>
<td className="px-4 py-3">
@@ -1255,7 +1261,7 @@ export function Settings() {
<button
onClick={() => startEdit(rule)}
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")}
>
<Pencil className="w-3.5 h-3.5" />
@@ -1263,7 +1269,7 @@ export function Settings() {
<button
onClick={() => deleteRule(rule.model_pattern)}
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")}
>
<Trash2 className="w-3.5 h-3.5" />
@@ -1284,7 +1290,7 @@ export function Settings() {
</div>
{lastUpdated && (
<p className="text-xs text-gray-600 mt-3">
<p className="text-xs text-fg-muted mt-3">
{t("pricing.lastUpdated")}
{formatTimestamp(lastUpdated)}
</p>
@@ -1293,21 +1299,21 @@ export function Settings() {
{/* ─── HOOK CONFIGURATION ─── */}
<section id="hooks" className="scroll-mt-24">
<h3 className="text-sm font-medium text-gray-300 flex items-center gap-2 mb-1">
<Plug className="w-4 h-4 text-gray-500" />
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
<Plug className="w-4 h-4 text-fg-muted" />
{t("hooks.title")}
</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="flex items-center justify-between flex-wrap gap-3">
<div className="flex items-center gap-3">
{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")}
</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")}
</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"
>
{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>
<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>
@@ -1353,12 +1359,12 @@ export function Settings() {
{/* ─── CLAUDE HOME ─── */}
<section id="claude-home" className="scroll-mt-24">
<h3 className="text-sm font-medium text-gray-300 flex items-center gap-2 mb-1">
<FolderOpen className="w-4 h-4 text-gray-500" />
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
<FolderOpen className="w-4 h-4 text-fg-muted" />
{t("claudeHome.title")}
</h3>
<p className="text-xs text-gray-500 mb-1">{t("claudeHome.description")}</p>
<p className="text-[11px] text-gray-600 italic mb-4 leading-snug">{t("cursorPathsNote")}</p>
<p className="text-xs text-fg-muted mb-1">{t("claudeHome.description")}</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="flex items-center gap-3">
@@ -1369,7 +1375,7 @@ export function Settings() {
setClaudeHomeInput(e.target.value);
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")}
/>
<button
@@ -1380,10 +1386,10 @@ export function Settings() {
{claudeHomeSaving ? t("claudeHome.saving") : t("claudeHome.save")}
</button>
</div>
{claudeHomeError && <p className="text-xs text-red-400">{claudeHomeError}</p>}
{claudeHomeError && <p className="text-xs text-status-danger">{claudeHomeError}</p>}
{claudeHome && (
<p className="text-xs text-gray-500">
{t("claudeHome.current")} <code className="text-gray-400">{claudeHome}</code>
<p className="text-xs text-fg-muted">
{t("claudeHome.current")} <code className="text-fg-secondary">{claudeHome}</code>
</p>
)}
</div>
@@ -1401,13 +1407,13 @@ export function Settings() {
{/* ─── TABBY COMPANION ─── */}
<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>
{t("tabby.title", "Tabby companion")}
</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.")}
</p>
@@ -1416,7 +1422,7 @@ export function Settings() {
<div
className={`w-10 h-10 rounded-xl flex items-center justify-center transition-colors ${
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"
}`}
>
@@ -1439,11 +1445,11 @@ export function Settings() {
{/* ─── NOTIFICATIONS ─── */}
<section id="notifications" className="scroll-mt-24">
<h3 className="text-sm font-medium text-gray-300 flex items-center gap-2 mb-1">
<Bell className="w-4 h-4 text-gray-500" />
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
<Bell className="w-4 h-4 text-fg-muted" />
{t("notifications.title")}
</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="flex items-center justify-between flex-wrap gap-3">
@@ -1451,14 +1457,14 @@ export function Settings() {
<div
className={`w-10 h-10 rounded-xl flex items-center justify-center transition-colors ${
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"
}`}
>
{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>
<Toggle
@@ -1483,10 +1489,10 @@ export function Settings() {
<span
className={`inline-flex items-center gap-1.5 text-xs font-medium px-2.5 py-1 rounded-full ${
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"
? "text-red-400 bg-red-500/10 border border-red-500/20"
: "text-amber-400 bg-amber-500/10 border border-amber-500/20"
? "text-status-danger bg-status-danger/10 border border-status-danger/20"
: "text-status-warning bg-status-warning/10 border border-status-warning/20"
}`}
>
{Notification.permission === "granted" ? (
@@ -1507,12 +1513,12 @@ export function Settings() {
{notifPrefs.enabled && (
<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")}
</p>
<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">
<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
checked={notifPrefs.onNewSession}
onChange={(v) => updateNotifPrefs({ onNewSession: v })}
@@ -1528,7 +1534,7 @@ export function Settings() {
/>
</div>
<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
checked={notifPrefs.onSessionError}
onChange={(v) => updateNotifPrefs({ onSessionError: v })}
@@ -1536,7 +1542,7 @@ export function Settings() {
/>
</div>
<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
checked={notifPrefs.onSubagentSpawn}
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" />
{t("notifications.sendTest")}
@@ -1569,7 +1575,7 @@ export function Settings() {
)}
{!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" />
{t("notifications.disabledInfo")}
</div>
@@ -1579,30 +1585,30 @@ export function Settings() {
{/* ─── ALERTS ─── */}
<section id="alerts" className="scroll-mt-24">
<h3 className="text-sm font-medium text-gray-300 flex items-center gap-2 mb-1">
<BellRing className="w-4 h-4 text-gray-500" />
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
<BellRing className="w-4 h-4 text-fg-muted" />
{t("alertsHub.title")}
</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 />
</section>
{/* ─── DATA MANAGEMENT ─── */}
<section id="data" className="scroll-mt-24">
<h3 className="text-sm font-medium text-gray-300 flex items-center gap-2 mb-1">
<Database className="w-4 h-4 text-gray-500" />
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
<Database className="w-4 h-4 text-fg-muted" />
{t("data.title")}
</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="card p-5 space-y-4">
<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")}
</p>
{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" />
<span className="truncate">{sysInfo.db.path}</span>
</div>
@@ -1613,10 +1619,10 @@ export function Settings() {
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-2">
{(() => {
const tableIcons: Record<string, React.ReactNode> = {
sessions: <Layers className="w-4 h-4 text-blue-400" />,
agents: <Users className="w-4 h-4 text-emerald-400" />,
sessions: <Layers className="w-4 h-4 text-blue-500" />,
agents: <Users className="w-4 h-4 text-status-success" />,
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" />,
};
const tableLabels: Record<string, string> = {
@@ -1627,24 +1633,24 @@ export function Settings() {
model_pricing: t("tables.pricingRules"),
};
const tableColors: Record<string, string> = {
sessions: "border-blue-500/20",
agents: "border-emerald-500/20",
sessions: "border-blue-600/20",
agents: "border-status-success/20",
events: "border-violet-500/20",
token_usage: "border-amber-500/20",
token_usage: "border-status-warning/20",
model_pricing: "border-cyan-500/20",
};
return Object.entries(sysInfo.db.counts).map(([table, count]) => (
<div
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">
{tableIcons[table] || <Database className="w-4 h-4 text-gray-500" />}
<p className="text-[11px] text-gray-500 uppercase tracking-wider">
{tableIcons[table] || <Database className="w-4 h-4 text-fg-muted" />}
<p className="text-[11px] text-fg-muted uppercase tracking-wider">
{tableLabels[table] || table.replace(/_/g, " ")}
</p>
</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>
</p>
</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="flex items-center gap-2 mb-1.5">
<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")}
</p>
</div>
<p className="text-xl font-semibold text-gray-200">
<p className="text-xl font-semibold text-fg-secondary">
{formatBytes(sysInfo.db.size)}
</p>
</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>
{/* Session Cleanup */}
<div className="card p-5 space-y-4">
<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">
<Eraser className="w-4 h-4 text-amber-400" />
<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-status-warning" />
</div>
<div>
<p className="text-sm font-medium text-gray-300">{t("data.sessionCleanup")}</p>
<p className="text-xs text-gray-500">{t("data.cleanupDesc")}</p>
<p className="text-sm font-medium text-fg-secondary">{t("data.sessionCleanup")}</p>
<p className="text-xs text-fg-muted">{t("data.cleanupDesc")}</p>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-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">
<input
type="number"
@@ -1690,11 +1698,13 @@ export function Settings() {
onChange={(e) => setAbandonHours(e.target.value)}
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 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">
<input
type="number"
@@ -1703,7 +1713,7 @@ export function Settings() {
onChange={(e) => setPurgeDays(e.target.value)}
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>
@@ -1715,8 +1725,8 @@ export function Settings() {
disabled={actionLoading !== null}
className={`text-xs px-3 py-1.5 rounded-md transition-colors disabled:opacity-50 ${
confirmAction === "cleanup"
? "bg-amber-500/20 text-amber-400 border border-amber-500/30"
: "text-gray-400 hover:text-gray-300 hover:bg-surface-4 border border-border"
? "bg-status-warning/20 text-status-warning border border-status-warning/30"
: "text-fg-secondary hover:text-fg-primary hover:bg-surface-4 border border-border"
}`}
>
{actionLoading === "cleanup" ? (
@@ -1731,25 +1741,25 @@ export function Settings() {
</div>
{/* 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="w-9 h-9 rounded-lg bg-red-500/10 border border-red-500/20 flex items-center justify-center">
<AlertTriangle className="w-4 h-4 text-red-400" />
<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-status-danger" />
</div>
<div>
<p className="text-sm font-medium text-red-400">{t("danger.title")}</p>
<p className="text-xs text-gray-500">{t("danger.description")}</p>
<p className="text-sm font-medium text-status-danger">{t("danger.title")}</p>
<p className="text-xs text-fg-muted">{t("danger.description")}</p>
</div>
</div>
{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">
<span className="text-xs text-amber-400">{t("danger.warning")}</span>
<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-status-warning">{t("danger.warning")}</span>
<div className="flex items-center gap-2">
<button
onClick={handleClearData}
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" ? (
<RefreshCw className="w-3.5 h-3.5 animate-spin inline mr-1" />
@@ -1758,7 +1768,7 @@ export function Settings() {
</button>
<button
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")}
</button>
@@ -1768,7 +1778,7 @@ export function Settings() {
<button
onClick={() => setConfirmAction("clear")}
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" />
{t("danger.clearAllData")}
@@ -1782,11 +1792,11 @@ export function Settings() {
{/* ─── ABOUT ─── */}
<section id="about" className="scroll-mt-24">
<h3 className="text-sm font-medium text-gray-300 flex items-center gap-2 mb-1">
<Server className="w-4 h-4 text-gray-500" />
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
<Server className="w-4 h-4 text-fg-muted" />
{t("about.title")}
</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 ? (
<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="flex items-center gap-2 mb-1.5">
<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")}
</p>
</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}
</p>
{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__ })}
</p>
)}
</div>
<div className="bg-surface-2 rounded-lg px-4 py-3">
<div className="flex items-center gap-2 mb-1.5">
<Clock className="w-4 h-4 text-blue-400" />
<p className="text-[11px] text-gray-500 uppercase tracking-wider">
<Clock className="w-4 h-4 text-blue-500" />
<p className="text-[11px] text-fg-muted uppercase tracking-wider">
{t("about.uptime")}
</p>
</div>
<p className="text-sm font-semibold text-gray-200">
<p className="text-sm font-semibold text-fg-secondary">
{formatUptime(sysInfo.server.uptime)}
</p>
</div>
<div className="bg-surface-2 rounded-lg px-4 py-3">
<div className="flex items-center gap-2 mb-1.5">
<Cpu className="w-4 h-4 text-emerald-400" />
<p className="text-[11px] text-gray-500 uppercase tracking-wider">
<Cpu className="w-4 h-4 text-status-success" />
<p className="text-[11px] text-fg-muted uppercase tracking-wider">
{t("about.nodejs")}
</p>
</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}
</p>
</div>
<div className="bg-surface-2 rounded-lg px-4 py-3">
<div className="flex items-center gap-2 mb-1.5">
<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")}
</p>
</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 className="bg-surface-2 rounded-lg px-4 py-3">
<div className="flex items-center gap-2 mb-1.5">
<Wifi className="w-4 h-4 text-amber-400" />
<p className="text-[11px] text-gray-500 uppercase tracking-wider">
<Wifi className="w-4 h-4 text-status-warning" />
<p className="text-[11px] text-fg-muted uppercase tracking-wider">
{t("about.wsClients")}
</p>
</div>
<p className="text-sm font-semibold text-gray-200">
<p className="text-sm font-semibold text-fg-secondary">
{sysInfo.server.ws_connections}
</p>
</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>
</div>
+29 -27
View File
@@ -182,8 +182,8 @@ export function Workflows() {
lastUpdated={null}
/>
<div className="card flex flex-col items-center justify-center py-16 gap-4">
<AlertCircle className="w-10 h-10 text-red-400" />
<p className="text-red-400 text-sm">{error}</p>
<AlertCircle className="w-10 h-10 text-status-danger" />
<p className="text-status-danger text-sm">{error}</p>
<button onClick={handleRefresh} className="btn-primary text-sm">
{t("common:retry")}
</button>
@@ -211,11 +211,11 @@ export function Workflows() {
{/* Workflow-tool runs (issue #167) - fleets ingested from on-disk journals */}
<div className="card p-4 space-y-3">
<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" />
{t("runs.title")}
</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>
<WorkflowRunsPanel statusFilter={statusFilter} />
</div>
@@ -234,13 +234,13 @@ export function Workflows() {
/>
{selectedNode && (
<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">
{selectedNode}
</span>
<button
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")}
</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">
{number}
</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} />
</div>
{/* Quick descriptor; the full explanation lives in the popover, so we
keep this to a single clamped line (ellipsis + hover title) so a long
translation never wraps and unbalances the header row. */}
<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}
>
{subtitle}
@@ -477,7 +477,7 @@ function ChartInfoPopover({ infoKey, title }: { infoKey: string; title: string }
onMouseLeave={() => setOpen(false)}
onFocus={() => setOpen(true)}
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" />
</button>
@@ -485,27 +485,29 @@ function ChartInfoPopover({ infoKey, title }: { infoKey: string; title: string }
<div
ref={popoverRef}
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 }}
>
<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}
</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")}
</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")}
</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")}
</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>
)}
</>
@@ -542,20 +544,20 @@ function PageHeader({
</div>
<div>
<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 ? (
<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="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse-dot" />
<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-status-success animate-pulse-dot" />
{t("common:live")}
</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="w-1.5 h-1.5 rounded-full bg-gray-400" />
<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-surface-4" />
{t("common:offline")}
</span>
)}
</div>
<p className="text-xs text-gray-500">{t("subtitle")}</p>
<p className="text-xs text-fg-muted">{t("subtitle")}</p>
</div>
</div>
@@ -569,7 +571,7 @@ function PageHeader({
className={`px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
statusFilter === f.value
? "bg-accent/15 text-accent"
: "text-gray-500 hover:text-gray-300"
: "text-fg-muted hover:text-fg-secondary"
}`}
>
{f.label}
@@ -580,21 +582,21 @@ function PageHeader({
{/* Actions */}
<button
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")}
>
<RefreshCw className="w-4 h-4" />
</button>
<button
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")}
>
<Download className="w-4 h-4" />
</button>
{lastUpdated && (
<span className="text-[10px] text-gray-600 ml-1">
<span className="text-[10px] text-fg-muted ml-1">
{t("common:updated")}
{lastUpdated.toLocaleTimeString()}
</span>
+130 -116
View File
@@ -267,6 +267,19 @@ export function Workspace() {
});
}, [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(() => {
api.run
.list()
@@ -782,96 +795,95 @@ export function Workspace() {
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
would throw away a live run's rendered history and scroll
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">
<Header
activeRuns={activeRuns}
currentHandleId={handle?.id || null}
onAttach={attachToRun}
wsConnected={wsConnected}
runHistory={runHistory}
onResumeFromHistory={onResumeFromHistory}
onViewFromHistory={onViewFromHistory}
onRefresh={refreshList}
/>
<div data-testid="console-body" className="flex min-h-0 flex-1 flex-col gap-5">
<Header
activeRuns={activeRuns}
currentHandleId={handle?.id || null}
onAttach={attachToRun}
wsConnected={wsConnected}
runHistory={runHistory}
onResumeFromHistory={onResumeFromHistory}
onViewFromHistory={onViewFromHistory}
onRefresh={refreshList}
/>
{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">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
<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>
)}
{binaryStatus && !binaryStatus.found && (
<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>{t("binary.missing")}</span>
</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
whether anything needs a human right now. */}
<div className="flex flex-wrap items-center gap-3 border-b border-neutral-800 pb-3">
<h2 className="text-base font-semibold tracking-tight text-neutral-100">
<div className="flex flex-wrap items-center gap-3 border-b border-border pb-3">
<h2 className="text-base font-semibold tracking-tight text-fg-primary">
{tLanes("title")}
</h2>
<div className="flex flex-wrap items-center gap-1.5 text-xs">
<span
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")}
</span>
<span
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")}
</span>
{counts.needs_you > 0 && (
<span
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")}
</span>
@@ -913,7 +925,7 @@ export function Workspace() {
{counts.dead > 0 && (
<span
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")}
</span>
@@ -921,7 +933,7 @@ export function Workspace() {
</div>
<button
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")}
>
<Plus className="h-3.5 w-3.5" />
@@ -944,7 +956,7 @@ export function Workspace() {
/>
))}
{!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>
</p>
)}
@@ -953,26 +965,32 @@ export function Workspace() {
{/* The selected lane's pipeline, full width the thing you actually
come to this page to read. */}
{currentLane && (
<section
data-testid="lane-detail"
className="rounded-xl border border-neutral-800 bg-neutral-900/40 p-4"
>
<section data-testid="lane-detail" className="card p-4">
<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 })}
</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}
</span>
<span className="text-[11px] text-neutral-600">{currentLane.pipeline_name}</span>
<span className="rounded bg-neutral-800 px-2 py-0.5 text-xs text-neutral-200">
{currentLane.stage}
<span className="text-[11px] text-fg-muted">{currentLane.pipeline_name}</span>
{/* `stage` defaults to the DB sentinel "idle" until the driving
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>
{currentLane.detected_stage && (
<span
data-testid="detail-auto-stage"
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 })}
</span>
@@ -990,7 +1008,7 @@ export function Workspace() {
detectedSignal={currentLane.detected_signal}
/>
</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}
</div>
</section>
@@ -1010,20 +1028,16 @@ export function Workspace() {
{laneActionError && (
<p
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 })}
</p>
)}
{/* 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
start form would be unreachable on a fresh install. */}
{!currentLane && (
<div className="flex min-h-0 flex-col gap-2">{consoleSection}</div>
)}
{!currentLane && <div className="flex min-h-0 flex-col gap-2">{consoleSection}</div>}
</div>
);
}
@@ -1087,20 +1101,20 @@ function Header({
</div>
<div className="min-w-0 flex-1">
<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 ? (
<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="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse-dot" />
<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-status-success animate-pulse-dot" />
{tCommon("live")}
</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="w-1.5 h-1.5 rounded-full bg-gray-400" />
<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-surface-4" />
{tCommon("offline")}
</span>
)}
</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>
<ActiveRunsSwitcher
activeRuns={activeRuns}
File diff suppressed because it is too large Load Diff
+27 -12
View File
@@ -1,31 +1,46 @@
/**
* @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>
*/
/** @type {import('tailwindcss').Config} */
export default {
darkMode: "class",
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
theme: {
extend: {
colors: {
surface: {
0: "#06060a",
1: "#0c0c14",
2: "#13131e",
3: "#1a1a28",
4: "#222233",
5: "#2a2a3d",
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: "#2a2a3d",
light: "#363650",
DEFAULT: "rgb(var(--border) / <alpha-value>)",
light: "rgb(var(--border-light) / <alpha-value>)",
},
accent: {
DEFAULT: "#6366f1",
hover: "#818cf8",
muted: "rgba(99, 102, 241, 0.15)",
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>)",
},
status: {
success: "rgb(var(--status-success) / <alpha-value>)",
danger: "rgb(var(--status-danger) / <alpha-value>)",
warning: "rgb(var(--status-warning) / <alpha-value>)",
},
},
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)`);
+75 -1
View File
@@ -336,6 +336,77 @@ describe("hook → lane binding", () => {
await request("DELETE", `/api/lanes/${id}`);
});
it("the bare idle nudge never raises needs_action and clears a stale one", async () => {
const created = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-idle-nudge",
title: "Idle Nudge",
});
const id = created.body.lane.id;
await request("POST", "/api/hooks/event", {
hook_type: "SessionStart",
data: { session_id: "sess-nudge", cwd: "/tmp/lane-idle-nudge" },
});
await request("POST", "/api/hooks/event", {
hook_type: "Notification",
data: {
session_id: "sess-nudge",
cwd: "/tmp/lane-idle-nudge",
message: "Claude needs your permission to use Bash",
},
});
assert.equal(
(await request("GET", `/api/lanes/${id}`)).body.lane.needs_action,
"Claude needs your permission to use Bash"
);
// The 60s idle nudge fires after Stop: it proves the CLI is parked at an
// idle prompt, so it must clear rather than pin the banner.
await request("POST", "/api/hooks/event", {
hook_type: "Notification",
data: {
session_id: "sess-nudge",
cwd: "/tmp/lane-idle-nudge",
message: "Claude is waiting for your input",
},
});
const lane = (await request("GET", `/api/lanes/${id}`)).body.lane;
assert.equal(lane.needs_action, null);
assert.equal(lane.status, "idle");
await request("DELETE", `/api/lanes/${id}`);
});
it("mirrors CLI turn state onto lane.status for a lane the dashboard did not launch", async () => {
const created = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-turn-status",
title: "Turn Status",
});
const id = created.body.lane.id;
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.status, "idle");
await request("POST", "/api/hooks/event", {
hook_type: "UserPromptSubmit",
data: { session_id: "sess-turn", cwd: "/tmp/lane-turn-status" },
});
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.status, "running");
// A subagent finishing is not the end of the turn.
await request("POST", "/api/hooks/event", {
hook_type: "SubagentStop",
data: { session_id: "sess-turn", cwd: "/tmp/lane-turn-status" },
});
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.status, "running");
await request("POST", "/api/hooks/event", {
hook_type: "Stop",
data: { session_id: "sess-turn", cwd: "/tmp/lane-turn-status" },
});
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.status, "idle");
await request("DELETE", `/api/lanes/${id}`);
});
});
describe("hook → stage detection", () => {
@@ -705,7 +776,10 @@ describe("GET /api/lanes/branches", () => {
});
it("400s for a path that does not exist", async () => {
const r = await request("GET", `/api/lanes/branches?repo=${encodeURIComponent(path.join(ROOT, "nope"))}`);
const r = await request(
"GET",
`/api/lanes/branches?repo=${encodeURIComponent(path.join(ROOT, "nope"))}`
);
assert.equal(r.status, 400);
assert.equal(r.body.error.code, "EBADSOURCEREPO");
});
+43 -4
View File
@@ -587,6 +587,39 @@ describe("stage detection", () => {
lanes.deleteLane(l.id);
});
it("a real stage transition clears a stale detection and unblocks a fresh one behind it", () => {
const l = lanes.createLane({ cwd: "/tmp/wt-detect-stale-cross-task" });
// A prior, unrelated task's tool calls left `tests` as the detected stage.
lanes.recordDetection(l.id, { nodeId: "tests", signal: "`npm test`" });
assert.equal(lanes.getLane(l.id).detected_stage, "tests");
// A new task starts and the agent declares an EARLIER stage. Without the
// fix, the leftover `tests` detection both misrepresents the new task's
// progress and (via forward-only) rejects every real detection for it
// until `tests` ages past DETECTION_TTL_MS.
const after = lanes.setStage(l.id, { stage: "plan" });
assert.equal(after.detected_stage, null);
assert.equal(after.detected_signal, null);
assert.equal(after.detected_at, null);
assert.deepEqual(lanes.recordDetection(l.id, { nodeId: "implement", signal: "`Edit`" }), {
written: true,
});
assert.equal(lanes.getLane(l.id).detected_stage, "implement");
lanes.deleteLane(l.id);
});
it("re-declaring the SAME stage (a heartbeat) leaves a live detection alone", () => {
const l = lanes.createLane({ cwd: "/tmp/wt-detect-heartbeat-noop" });
lanes.setStage(l.id, { stage: "plan" });
assert.deepEqual(lanes.recordDetection(l.id, { nodeId: "implement", signal: "`Edit`" }), {
written: true,
});
lanes.setStage(l.id, { stage: "plan", note: "still planning" });
assert.equal(lanes.getLane(l.id).detected_stage, "implement");
lanes.deleteLane(l.id);
});
it("migration: detection columns are added to a database holding an old-schema lanes row", () => {
const tmpPath = pathMod.join(
os.tmpdir(),
@@ -744,12 +777,18 @@ describe("stage detection", () => {
it("detection expiry: stale detection behind declared stage still rejects", () => {
const { db } = require("../db");
const l = lanes.createLane({ cwd: "/tmp/wt-detect-expiry-declared" });
// Stand up the detection BEFORE declaring, otherwise declared-wins refuses
// it and there is no standing detection left to age.
lanes.recordDetection(l.id, { nodeId: "tests" });
lanes.setStage(l.id, { stage: "review" });
// setStage now clears detected_* on every real transition (a fresh
// declaration supersedes older inference), so a standing detection is
// written directly here, bypassing that, to isolate the rule this test
// is actually about: declared-wins is never relaxed by staleness, no
// matter how the stale detection got there.
const oldTime = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString();
db.prepare("UPDATE lanes SET detected_at = ? WHERE id = ?").run(oldTime, l.id);
db.prepare("UPDATE lanes SET detected_stage = ?, detected_at = ? WHERE id = ?").run(
"tests",
oldTime,
l.id
);
// Try backward detection to implement - should still be rejected because declared stage is ahead
const result = lanes.recordDetection(l.id, { nodeId: "implement" });
+1 -1
View File
@@ -50,7 +50,7 @@
},
{
"tool": "Write",
"match": "^(?!.*(?:^|/)docs/)"
"match": "^(?!.*(?:^|/)(?:docs|\\.superpowers)/)"
}
]
},
+15 -3
View File
@@ -189,24 +189,36 @@ function deleteLane(id) {
* Record a stage transition. `stage_since` moves ONLY when the stage value
* actually changes, so the UI's time-on-phase is real; a re-report of the same
* stage (a heartbeat, an added note) leaves it alone.
*
* A real transition also clears `detected_stage`/`detected_signal`/`detected_at`.
* Inference tracks progress relative to whatever the agent last declared; once
* the agent declares again, any older detection is either stale (a prior task's
* leftover, e.g. `tests` from earlier work bleeding into a fresh `plan`) or
* redundant (the agent's own claim now covers it). Left in place it would both
* paint stale progress in the UI AND because recordDetection is forward-only
* silently reject every real detection for the new stage until the old one
* ages past DETECTION_TTL_MS.
*/
function setStage(id, { stage, status, evidence, note, result } = {}) {
const lane = getLane(id);
if (!lane) throw Object.assign(new Error(`no lane ${id}`), { code: "ENOLANE" });
const next = stage || lane.stage;
const changed = next !== lane.stage;
const stages = { ...lane.stages };
const prev = stages[next] || {};
stages[next] = {
enteredAt: next === lane.stage && prev.enteredAt ? prev.enteredAt : nowIso(),
enteredAt: !changed && prev.enteredAt ? prev.enteredAt : nowIso(),
evidence: evidence !== undefined ? evidence : prev.evidence || null,
result: result !== undefined ? result : prev.result || null,
};
db.prepare(
`UPDATE lanes SET stage = ?, stage_since = ?, status = ?, stages = ?, notes = ?, updated_at = ?
`UPDATE lanes SET stage = ?, stage_since = ?, status = ?, stages = ?, notes = ?, updated_at = ?${
changed ? ", detected_stage = NULL, detected_signal = NULL, detected_at = NULL" : ""
}
WHERE id = ?`
).run(
next,
next === lane.stage ? lane.stage_since || nowIso() : nowIso(),
changed ? nowIso() : lane.stage_since || nowIso(),
status || lane.status,
JSON.stringify(stages),
note !== undefined ? note : lane.notes,
+38 -4
View File
@@ -136,14 +136,35 @@ function recoverInterruptedSession(sessionId, fullSess, mainAgentId, reasonSuffi
});
}
// Claude Code fires Notification for two unrelated things: a real block
// (permission prompt / AskUserQuestion) and a bare idle nudge ~60 s after a
// turn ended. The nudge arrives AFTER Stop, so no later hook is coming to
// clear it — a lane stamped from it sat on a permanent "⚠ Claude is waiting
// for your input" while the user was simply not typing. The nudge is not a
// blocking event: it proves the opposite (the CLI is parked at an idle
// prompt), so it clears the lane instead of stamping it.
const IDLE_NUDGE_RE = /^\s*claude is waiting for your input[.!]?\s*$/i;
// Hooks that prove the CLI is mid-turn / between turns for the lane's session.
// Only these move lane.status; anything else (SubagentStop, SessionStart,
// Notification) leaves it alone — a subagent finishing does not end the turn.
const LANE_WORKING_HOOKS = new Set(["UserPromptSubmit", "PreToolUse", "PostToolUse"]);
const LANE_DONE_HOOKS = new Set(["Stop", "SessionEnd"]);
/**
* Attach an incoming hook to the lane that owns its cwd. Lanes are optional and
* this is best-effort: the hook path must never fail because of lane
* bookkeeping, so everything here is inside one try/catch.
*
* `needs_action` mirrors Claude Code's Notification hook (a permission prompt or
* an idle nudge). The next non-Notification hook from the same session means the
* agent is moving again, so the flag clears itself no user click required.
* `needs_action` mirrors Claude Code's Notification hook, minus the idle nudge
* (see IDLE_NUDGE_RE). The next non-Notification hook from the same session
* means the agent is moving again, so the flag clears itself no user click
* required.
*
* `status` is mirrored the same way for lanes the dashboard did NOT launch
* (run_id null). Those never pass through the run lifecycle that sets
* running/idle, so without this an adopted lane read "idle" for the entire
* time Claude was working in it.
*/
function touchLaneFromHook(hookType, data) {
try {
@@ -183,14 +204,27 @@ function touchLaneFromHook(hookType, data) {
}
const patch = {};
const idleNudge = hookType === "Notification" && IDLE_NUDGE_RE.test(data.message || "");
if (data.session_id && lane.session_id !== data.session_id) patch.session_id = data.session_id;
if (hookType === "Notification") {
if (hookType === "Notification" && !idleNudge) {
patch.needs_action = data.message || "needs you";
} else if (lane.needs_action && data.session_id === lane.session_id) {
// Clear only when the hook comes from the session currently bound to the lane
// (evaluated before any rebinding, so a rebinding hook never clears in the same pass)
patch.needs_action = null;
}
// Only idle↔running is mirrored: provisioning and failed are lifecycle
// states this path must never stomp on.
if (!lane.run_id && (lane.status === "idle" || lane.status === "running")) {
const next = LANE_WORKING_HOOKS.has(hookType)
? "running"
: LANE_DONE_HOOKS.has(hookType) || idleNudge
? "idle"
: null;
if (next && next !== lane.status) patch.status = next;
}
if (!Object.keys(patch).length) return;
lanesLib.updateLane(lane.id, patch);
broadcastLane(lane.id);