feat(theme): dark/light mode with a Radix Colors-based palette
Adds a working Dark/Light toggle (next to the language switcher, same row
as EN/VI) and re-themes the whole dashboard, not just the handful of
components that already used semantic tokens.
- Tailwind darkMode:"class" + CSS-variable color tokens (client/src/index.css,
tailwind.config.js): surface.0-5, border/border-light, accent/accent-hover,
fg.primary/secondary/muted, status.success/danger/warning. One class flip
on <html> re-themes everything — no per-element dark: variant pairs.
- useTheme() hook: localStorage-persisted, defaults to dark, no
prefers-color-scheme fallback (client/src/hooks/useTheme.ts).
- Mechanical, table-driven migration (scripts/migrate-color-tokens.mjs,
scripts/tokenize-status-colors.mjs, scripts/darken-status-colors.mjs) of
every raw neutral/gray/slate + emerald/red/amber Tailwind utility across
client/src onto the new tokens, so every badge/button/component pulls the
same shade per status/role instead of each picking its own.
- Palette values are the literal Radix Colors (radix-ui.com/colors) scale
constants — slate/blue/green/red/amber steps 1-12 — adopted after three
rounds of hand-picked values that kept overshooting (flat, then too dark,
then glaring); see docs/superpowers/specs/2026-07-31-color-redesign-
dark-light-mode-design.md for the full history and role mapping.
- PipelineMap: done/current/failed/passed-no-evidence/detected share one
visual language (border + text + translucent wash of the same status
color); `current` alone stays a solid accent fill, the one state that
gets to look bolder ("you are here").
- LaneCard: removed the stage/kind/auto-stage chips that duplicated the
Workspace lane-detail header already showing them.
Categorical/decorative hues (violet, indigo, cyan, teal, sky, rose, pink,
orange, yellow, and blue where it plays a role-coloring part e.g. message
bubbles) are deliberately out of scope — collapsing those onto shared
tokens would erase the distinction between different kinds of thing, not
a status.
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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"
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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í Vĩ <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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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í Vĩ <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", () => {
|
||||
|
||||
@@ -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>
|
||||
>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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}%–{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")} ·{" "}
|
||||
{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í 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);
|
||||
});
|
||||
});
|
||||
@@ -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í Vĩ <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 };
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
+246
-242
File diff suppressed because it is too large
Load Diff
+112
-108
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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("/")}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user