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"
|
||||
|
||||
Reference in New Issue
Block a user