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:
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user