feat(dashboard): redesign autopilot kanban with React + dark cyberpunk theme

Replace the inline HTML template with a Vite + React + TypeScript app
in autopilot-dashboard/. The new design follows the AnyFS dark theme
aesthetic with animated SVG hero background (data streams, constellation
nodes, binary rain, scanline), pipeline visualization, glass cards with
status-colored glow effects, and JetBrains Mono typography throughout.

- autopilot-dashboard/: new Vite+React project, builds to docs/autopilot/
- HeroBackground.tsx: multi-layer SMIL SVG animation
- PipelineAnimation.tsx: QUEUE→PREP→RUN→CHECK→MERGE traveling glow
- CI workflow updated to build React app before deploying to Pages
- Python _render_dashboard_html simplified to a minimal fallback page

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
tjb-tech
2026-04-16 13:13:35 +00:00
parent 2a91f66a3c
commit dea9ee8386
20 changed files with 3280 additions and 510 deletions
+16
View File
@@ -6,6 +6,7 @@ on:
- main
paths:
- "docs/autopilot/**"
- "autopilot-dashboard/**"
- ".github/workflows/autopilot-pages.yml"
workflow_dispatch:
@@ -28,6 +29,21 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
cache-dependency-path: autopilot-dashboard/package-lock.json
- name: Install dependencies
working-directory: autopilot-dashboard
run: npm ci
- name: Build dashboard
working-directory: autopilot-dashboard
run: npm run build
- name: Configure Pages
uses: actions/configure-pages@v5
with:
+2
View File
@@ -0,0 +1,2 @@
node_modules
dist
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Autopilot Kanban</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
{
"name": "autopilot-dashboard",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"@vitejs/plugin-react": "^4.4.1",
"typescript": "~5.8.3",
"vite": "^6.3.2"
}
}
+59
View File
@@ -0,0 +1,59 @@
{
"generated_at": 1776338766.0050209,
"repo_name": "OpenHarness-new",
"repo_path": "/home/tangjiabin/OpenHarness-new",
"focus": null,
"counts": {
"queued": 0,
"accepted": 0,
"preparing": 0,
"running": 0,
"verifying": 0,
"pr_open": 0,
"waiting_ci": 0,
"repairing": 0,
"completed": 0,
"merged": 0,
"failed": 0,
"rejected": 0,
"superseded": 0
},
"status_order": [
"queued",
"accepted",
"preparing",
"running",
"verifying",
"pr_open",
"waiting_ci",
"repairing",
"completed",
"merged",
"failed",
"rejected",
"superseded"
],
"columns": {
"queued": [],
"accepted": [],
"preparing": [],
"running": [],
"verifying": [],
"pr_open": [],
"waiting_ci": [],
"repairing": [],
"completed": [],
"merged": [],
"failed": [],
"rejected": [],
"superseded": []
},
"cards": [],
"journal": [],
"policies": {
"autopilot": "/home/tangjiabin/OpenHarness-new/.openharness/autopilot/autopilot_policy.yaml",
"verification": "/home/tangjiabin/OpenHarness-new/.openharness/autopilot/verification_policy.yaml",
"release": "/home/tangjiabin/OpenHarness-new/.openharness/autopilot/release_policy.yaml"
},
"active_context": "# Active Repo Context\n\nGenerated at: 2026-04-16 11:05:49 UTC\n\n## Current Task Focus\n- No active repo task focus yet.\n\n## In Progress\n- None.\n\n## Next Up\n- No queued items.\n\n## Recently Completed\n- None yet.\n\n## Recent Failures\n- None.\n\n## Recent Repo Journal\n- Journal is empty.\n\n## Policies\n- Autopilot: /home/tangjiabin/OpenHarness-new/.openharness/autopilot/autopilot_policy.yaml\n- Verification: /home/tangjiabin/OpenHarness-new/.openharness/autopilot/verification_policy.yaml\n- Release: /home/tangjiabin/OpenHarness-new/.openharness/autopilot/release_policy.yaml"
}
+278
View File
@@ -0,0 +1,278 @@
import { useEffect, useState } from "react";
import { HeroBackground } from "./components/HeroBackground";
import { PipelineAnimation } from "./components/PipelineAnimation";
import type { Snapshot, TaskCard, JournalEntry } from "./types";
import { STATUS_LABELS, STATUS_COLORS } from "./types";
/* ── Helpers ─────────────────────────────────── */
function fmtAgo(ts?: number): string {
if (!ts) return "-";
const delta = Math.max(0, Math.floor(Date.now() / 1000 - ts));
if (delta < 60) return `${delta}s ago`;
if (delta < 3600) return `${Math.floor(delta / 60)}m ago`;
if (delta < 86400) return `${Math.floor(delta / 3600)}h ago`;
return `${Math.floor(delta / 86400)}d ago`;
}
function statusBadgeClass(status: string): string {
if (["running", "completed", "merged", "preparing"].includes(status)) return "badge-teal";
if (["repairing"].includes(status)) return "badge-orange";
if (["accepted", "pr_open"].includes(status)) return "badge-violet";
if (["failed", "rejected"].includes(status)) return "badge-red";
if (["verifying", "waiting_ci"].includes(status)) return "badge-blue";
if (["superseded"].includes(status)) return "badge-amber";
return "badge-gray";
}
/* ── Card Component ──────────────────────────── */
function CardView({ card }: { card: TaskCard }) {
const labels = [...(card.labels || []), card.source_kind].filter(Boolean);
const verification = (card.metadata?.verification_steps || [])
.map((step) => `${step.status} · ${step.command}`)
.slice(0, 2)
.join(" | ");
const borderColor = STATUS_COLORS[card.status] || "#333";
return (
<article
className="card"
style={{ "--card-accent": borderColor } as React.CSSProperties}
>
<div className="card-meta">
<span>{card.id}</span>
<span className={`badge ${statusBadgeClass(card.status)}`}>
{card.score}
</span>
</div>
<h3>{card.title}</h3>
{card.body && (
<p className="card-body">{card.body.slice(0, 260)}</p>
)}
{labels.length > 0 && (
<div className="card-tags">
{labels.map((tag, i) => (
<span key={i} className="tag">{tag}</span>
))}
</div>
)}
<div className="card-footer">
<div>
updated {fmtAgo(card.updated_at)}
{card.source_ref ? ` · ref ${card.source_ref}` : ""}
</div>
<div>{card.metadata?.last_note || "no status note yet"}</div>
{(verification || card.metadata?.last_ci_summary || card.metadata?.last_failure_summary || card.metadata?.human_gate_pending) && (
<div>
{verification || card.metadata?.last_ci_summary || card.metadata?.last_failure_summary ||
(card.metadata?.human_gate_pending ? "verification passed; human gate pending" : "")}
</div>
)}
</div>
</article>
);
}
/* ── Column Component ────────────────────────── */
function ColumnView({ status, cards }: { status: string; cards: TaskCard[] }) {
const color = STATUS_COLORS[status] || "#666";
return (
<section className="column">
<div className="column-header">
<h2 style={{ color }}>{STATUS_LABELS[status] || status}</h2>
<span className="column-count">{cards.length}</span>
</div>
<div className="cards">
{cards.length > 0
? cards.map((card) => <CardView key={card.id} card={card} />)
: <div className="empty">No cards in this column.</div>
}
</div>
</section>
);
}
/* ── Journal Component ───────────────────────── */
function JournalView({ entries }: { entries: JournalEntry[] }) {
return (
<section className="journal">
<div className="journal-header">
<span style={{ color: "var(--accent)", fontSize: 10, letterSpacing: 2, fontWeight: 700 }}>
//
</span>
<h2>RECENT JOURNAL</h2>
</div>
<div className="journal-list">
{entries.length > 0
? entries.slice().reverse().map((entry, i) => (
<article key={i} className="journal-item">
<time>
{new Date(entry.timestamp * 1000)
.toISOString()
.replace("T", " ")
.replace(".000Z", " UTC")}
</time>
<div>
<span className="kind">{entry.kind}</span>
{entry.task_id && <span className="task-ref"> [{entry.task_id}]</span>}
</div>
<div className="summary">{entry.summary}</div>
</article>
))
: <div className="empty">Journal is empty.</div>
}
</div>
</section>
);
}
/* ── Main App ────────────────────────────────── */
export function App() {
const [snapshot, setSnapshot] = useState<Snapshot | null>(null);
const [filter, setFilter] = useState("");
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetch("./snapshot.json", { cache: "no-store" })
.then((r) => r.json())
.then(setSnapshot)
.catch((e) => setError(String(e)));
}, []);
if (error) {
return (
<div className="shell" style={{ paddingTop: 80 }}>
<div className="empty">Failed to load snapshot.json: {error}</div>
</div>
);
}
if (!snapshot) {
return (
<div className="shell" style={{ paddingTop: 80, textAlign: "center" }}>
<div style={{ color: "var(--accent)", fontSize: 12, letterSpacing: 2 }}>
LOADING SNAPSHOT...
</div>
</div>
);
}
const counts = snapshot.counts || {};
const order = snapshot.status_order || [];
const normalizedFilter = filter.trim().toLowerCase();
const filteredColumns = order.map((status) => {
const cards = (snapshot.columns?.[status] || []).filter((card) => {
if (!normalizedFilter) return true;
const haystack = [
card.id, card.title, card.body, card.source_kind, card.source_ref,
...(card.labels || []), ...(card.score_reasons || []),
].join(" ").toLowerCase();
return haystack.includes(normalizedFilter);
});
return { status, cards };
}).filter(({ cards }) => cards.length > 0 || !normalizedFilter);
const inProgress = (counts.preparing || 0) + (counts.running || 0) +
(counts.verifying || 0) + (counts.waiting_ci || 0) +
(counts.repairing || 0) + (counts.accepted || 0) + (counts.pr_open || 0);
const completed = (counts.completed || 0) + (counts.merged || 0);
const failed = (counts.failed || 0) + (counts.rejected || 0);
const generated = new Date((snapshot.generated_at || 0) * 1000)
.toISOString().replace("T", " ").replace(".000Z", " UTC");
return (
<>
{/* ── Hero ─────────────────────────── */}
<section className="hero">
<div className="hero-bg">
<HeroBackground />
</div>
<div className="hero-content">
<div className="hero-main">
<div className="eyebrow">// AUTOPILOT_KANBAN</div>
<h1>
{snapshot.repo_name || "OpenHarness"}<br />
<span className="accent">SELF-EVOLUTION</span>
</h1>
<p className="hero-sub">
A static GitHub Pages view of the repo-local autopilot queue.
Cards come from ohmo requests, GitHub issues and PRs, and
claude-code alignment candidates.
</p>
<div className="focus-box">
<div className="focus-label">// CURRENT_FOCUS</div>
<div className="focus-text">
{snapshot.focus
? `[${snapshot.focus.status}] ${snapshot.focus.title} · score=${snapshot.focus.score} · ${snapshot.focus.source_kind}`
: "No active task focus yet."}
</div>
</div>
</div>
<div className="hero-side">
<div className="hero-timestamp">
Generated from repo state at {generated}
</div>
<div className="pipeline-viz">
<PipelineAnimation />
</div>
</div>
</div>
</section>
<div className="shell">
{/* ── Stats Bar ──────────────────── */}
<section className="stats-bar">
<div className="stat">
<div className="stat-label teal">QUEUED</div>
<div className="stat-value">{counts.queued || 0}</div>
<div className="stat-sub">waiting</div>
</div>
<div className="stat">
<div className="stat-label orange">IN PROGRESS</div>
<div className="stat-value">{inProgress}</div>
<div className="stat-sub">active pipeline</div>
</div>
<div className="stat">
<div className="stat-label teal">COMPLETED</div>
<div className="stat-value">{completed}</div>
<div className="stat-sub">merged + done</div>
</div>
<div className="stat">
<div className="stat-label" style={{ color: "var(--error)" }}>FAILED</div>
<div className="stat-value">{failed}</div>
<div className="stat-sub">rejected + failed</div>
</div>
</section>
{/* ── Toolbar ────────────────────── */}
<section className="toolbar">
<input
type="search"
placeholder="Filter by title, body, source, label, or task id..."
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
<div className="hint">
Reads <code>snapshot.json</code> no backend required
</div>
</section>
{/* ── Kanban Board ───────────────── */}
<section className="board">
{filteredColumns.map(({ status, cards }) => (
<ColumnView key={status} status={status} cards={cards} />
))}
</section>
{/* ── Journal ────────────────────── */}
<JournalView entries={snapshot.journal || []} />
</div>
</>
);
}
@@ -0,0 +1,181 @@
/**
* CyberHeroBackground — full-bleed animated SVG background for the kanban hero.
*
* Layers (back → front):
* 1. Radial teal glow
* 2. Perspective grid floor
* 3. Horizontal data-stream lines
* 4. Constellation nodes + edges
* 5. Data fragment segments
* 6. Binary / hex rain
* 7. Horizontal scan-line
*
* All SMIL-based — no JS animation loops needed.
*/
export function HeroBackground() {
const glyphs = [
{ x: 45, ch: "0", sz: 11, dur: 14, d: 0 },
{ x: 120, ch: "1", sz: 9, dur: 18, d: 3 },
{ x: 195, ch: "A", sz: 10, dur: 12, d: 7 },
{ x: 290, ch: "F", sz: 8, dur: 16, d: 1 },
{ x: 365, ch: "0", sz: 12, dur: 20, d: 5 },
{ x: 430, ch: "1", sz: 9, dur: 13, d: 9 },
{ x: 510, ch: "D", sz: 10, dur: 17, d: 2 },
{ x: 580, ch: "0", sz: 11, dur: 15, d: 6 },
{ x: 650, ch: "1", sz: 8, dur: 11, d: 4 },
{ x: 720, ch: "B", sz: 10, dur: 19, d: 8 },
{ x: 790, ch: "0", sz: 9, dur: 14, d: 1 },
{ x: 860, ch: "E", sz: 11, dur: 16, d: 10 },
{ x: 930, ch: "1", sz: 10, dur: 12, d: 3 },
{ x: 1000, ch: "C", sz: 8, dur: 18, d: 7 },
{ x: 1070, ch: "0", sz: 12, dur: 15, d: 0 },
{ x: 1140, ch: "7", sz: 9, dur: 13, d: 5 },
{ x: 260, ch: "3", sz: 8, dur: 22, d: 11 },
{ x: 475, ch: "F", sz: 10, dur: 16, d: 4 },
{ x: 690, ch: "8", sz: 9, dur: 20, d: 6 },
{ x: 1020, ch: "1", sz: 11, dur: 14, d: 8 },
];
const cn = [
{ x: 80, y: 55 },
{ x: 210, y: 30 },
{ x: 355, y: 80 },
{ x: 500, y: 45 },
{ x: 700, y: 68 },
{ x: 850, y: 28 },
{ x: 980, y: 60 },
{ x: 1120, y: 42 },
{ x: 145, y: 140 },
{ x: 420, y: 155 },
{ x: 780, y: 145 },
{ x: 1050, y: 130 },
];
const ce: [number, number][] = [
[0, 1], [1, 2], [2, 3], [3, 4], [4, 5], [5, 6], [6, 7],
[8, 9], [9, 10], [10, 11],
[0, 8], [3, 9], [4, 10], [7, 11],
];
const streams = [
{ y: 90, dash: "4 18", tot: 22, spd: 2.0, op: 0.07 },
{ y: 160, dash: "2 22", tot: 24, spd: 2.8, op: 0.05 },
{ y: 230, dash: "6 14", tot: 20, spd: 1.5, op: 0.08 },
{ y: 300, dash: "3 20", tot: 23, spd: 2.2, op: 0.06 },
{ y: 370, dash: "5 15", tot: 20, spd: 1.8, op: 0.07 },
];
const frags = [
{ x: 95, y: 120, w: 40 },
{ x: 310, y: 200, w: 30 },
{ x: 540, y: 280, w: 50 },
{ x: 760, y: 130, w: 35 },
{ x: 990, y: 250, w: 45 },
{ x: 180, y: 350, w: 30 },
{ x: 640, y: 380, w: 40 },
{ x: 1080, y: 330, w: 35 },
];
const gridY = [300, 325, 355, 390, 430];
const vx = 600;
const vy = 240;
const radials = [-5, -4, -3, -2, -1, 0, 1, 2, 3, 4, 5];
return (
<svg
viewBox="0 0 1200 460"
preserveAspectRatio="xMidYMid slice"
style={{ width: "100%", height: "100%" }}
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<defs>
<radialGradient id="bg-glow" cx="50%" cy="35%" r="45%">
<stop offset="0%" stopColor="#00d4aa" stopOpacity="0.12" />
<stop offset="50%" stopColor="#00d4aa" stopOpacity="0.03" />
<stop offset="100%" stopColor="#00d4aa" stopOpacity="0" />
</radialGradient>
<linearGradient id="bg-vfade" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="white" stopOpacity="0" />
<stop offset="8%" stopColor="white" stopOpacity="1" />
<stop offset="88%" stopColor="white" stopOpacity="1" />
<stop offset="100%" stopColor="white" stopOpacity="0" />
</linearGradient>
<mask id="bg-mask">
<rect width="1200" height="460" fill="url(#bg-vfade)" />
</mask>
</defs>
{/* Central glow */}
<rect width="1200" height="460" fill="url(#bg-glow)" />
<g mask="url(#bg-mask)">
{/* 1. Perspective grid */}
{gridY.map((y, i) => (
<line key={`hg${i}`} x1="50" y1={y} x2="1150" y2={y}
stroke="#00d4aa" strokeOpacity={0.025 + i * 0.012} strokeWidth="1" />
))}
{radials.map((n) => (
<line key={`vg${n}`} x1={vx} y1={vy} x2={vx + n * 130} y2="460"
stroke="#00d4aa" strokeOpacity="0.025" strokeWidth="1" />
))}
{/* 2. Data streams */}
{streams.map((s, i) => (
<line key={`ds${i}`} x1="0" y1={s.y} x2="1200" y2={s.y}
stroke="#00d4aa" strokeOpacity={s.op} strokeWidth="1" strokeDasharray={s.dash}>
<animate attributeName="stroke-dashoffset" from="0" to={`-${s.tot}`}
dur={`${s.spd}s`} repeatCount="indefinite" />
</line>
))}
{/* 3. Constellation edges */}
{ce.map(([a, b], i) => (
<line key={`ce${i}`} x1={cn[a].x} y1={cn[a].y} x2={cn[b].x} y2={cn[b].y}
stroke="#00d4aa" strokeOpacity="0.06" strokeWidth="1" />
))}
{/* 4. Constellation nodes */}
{cn.map((n, i) => (
<circle key={`cn${i}`} cx={n.x} cy={n.y} r="2" fill="#00d4aa">
<animate attributeName="fill-opacity" values="0.1;0.35;0.1"
dur={`${2 + (i % 4) * 0.5}s`} begin={`${i * 0.3}s`} repeatCount="indefinite" />
<animate attributeName="r" values="1.5;3.5;1.5"
dur={`${2 + (i % 4) * 0.5}s`} begin={`${i * 0.3}s`} repeatCount="indefinite" />
</circle>
))}
{/* 5. Data fragments */}
{frags.map((f, i) => (
<line key={`fr${i}`} x1={f.x} y1={f.y} x2={f.x + f.w} y2={f.y}
stroke={i % 3 === 0 ? "#8b5cf6" : "#00d4aa"} strokeOpacity="0.08"
strokeWidth="1" strokeLinecap="round">
<animate attributeName="stroke-opacity" values="0.04;0.16;0.04"
dur={`${2.5 + (i % 3) * 0.7}s`} begin={`${i * 0.4}s`} repeatCount="indefinite" />
</line>
))}
{/* 6. Binary / hex rain */}
{glyphs.map((g, i) => (
<text key={`gl${i}`} x={g.x} y={-10} fontSize={g.sz}
fill={i % 7 === 0 ? "#8b5cf6" : i % 11 === 0 ? "#ff6b35" : "#00d4aa"}
fillOpacity="0" fontFamily="JetBrains Mono, monospace" fontWeight="600" letterSpacing="0.6">
{g.ch}
<animateTransform attributeName="transform" type="translate"
from="0 0" to="0 480" dur={`${g.dur}s`} begin={`${g.d}s`} repeatCount="indefinite" />
<animate attributeName="fill-opacity" values="0;0.2;0.2;0"
keyTimes="0;0.08;0.85;1" dur={`${g.dur}s`} begin={`${g.d}s`} repeatCount="indefinite" />
</text>
))}
{/* 7. Scan-line */}
<line x1="0" y1="0" x2="1200" y2="0" stroke="#00d4aa" strokeOpacity="0" strokeWidth="1">
<animateTransform attributeName="transform" type="translate"
from="0 0" to="0 460" dur="6s" repeatCount="indefinite" />
<animate attributeName="stroke-opacity" values="0;0.16;0.16;0"
keyTimes="0;0.04;0.96;1" dur="6s" repeatCount="indefinite" />
</line>
</g>
</svg>
);
}
@@ -0,0 +1,99 @@
/**
* PipelineAnimation — visualizes the autopilot pipeline stages.
* A data packet travels through: QUEUE → PREPARE → RUN → VERIFY → MERGE
* Communicates the "self-evolution pipeline" idea.
* Uses SMIL animations — no JS loops needed.
*/
export function PipelineAnimation() {
const stages = [
{ x: 30, label: "QUEUE" },
{ x: 95, label: "PREP" },
{ x: 160, label: "RUN" },
{ x: 225, label: "CHECK" },
{ x: 290, label: "MERGE" },
];
const motionPath = "M 30 50 L 95 50 L 160 50 L 225 50 L 290 50";
return (
<svg
viewBox="0 0 320 100"
xmlns="http://www.w3.org/2000/svg"
aria-label="Autopilot pipeline"
style={{ width: "100%", height: "auto" }}
>
<defs>
<radialGradient id="pipe-glow" cx="50%" cy="50%" r="50%">
<stop offset="0%" stopColor="#00d4aa" stopOpacity="0.8" />
<stop offset="50%" stopColor="#00d4aa" stopOpacity="0.3" />
<stop offset="100%" stopColor="#00d4aa" stopOpacity="0" />
</radialGradient>
</defs>
{/* Rail */}
<line x1="30" y1="50" x2="290" y2="50"
stroke="#00d4aa" strokeOpacity="0.15" strokeWidth="1" strokeDasharray="3 5">
<animate attributeName="stroke-dashoffset" values="0;-16" dur="1.5s" repeatCount="indefinite" />
</line>
{/* Stage nodes */}
{stages.map((stage, i) => (
<g key={stage.label} transform={`translate(${stage.x}, 50)`}>
{/* Node circle */}
<circle r="14" fill="#0a0a0a" stroke="#00d4aa" strokeOpacity="0.4" strokeWidth="1" />
{/* Corner ticks */}
<line x1="-10" y1="-10" x2="-7" y2="-10" stroke="#00d4aa" strokeOpacity="0.6" strokeWidth="1" />
<line x1="-10" y1="-10" x2="-10" y2="-7" stroke="#00d4aa" strokeOpacity="0.6" strokeWidth="1" />
<line x1="10" y1="-10" x2="7" y2="-10" stroke="#00d4aa" strokeOpacity="0.6" strokeWidth="1" />
<line x1="10" y1="-10" x2="10" y2="-7" stroke="#00d4aa" strokeOpacity="0.6" strokeWidth="1" />
<line x1="-10" y1="10" x2="-7" y2="10" stroke="#00d4aa" strokeOpacity="0.6" strokeWidth="1" />
<line x1="-10" y1="10" x2="-10" y2="7" stroke="#00d4aa" strokeOpacity="0.6" strokeWidth="1" />
<line x1="10" y1="10" x2="7" y2="10" stroke="#00d4aa" strokeOpacity="0.6" strokeWidth="1" />
<line x1="10" y1="10" x2="10" y2="7" stroke="#00d4aa" strokeOpacity="0.6" strokeWidth="1" />
{/* Inner dot */}
<circle r="3" fill="#00d4aa">
<animate attributeName="r" values="3;4.5;3" dur="2.4s"
begin={`${i * 0.5}s`} repeatCount="indefinite" />
<animate attributeName="opacity" values="0.4;1;0.4" dur="2.4s"
begin={`${i * 0.5}s`} repeatCount="indefinite" />
</circle>
{/* Arrival pulse */}
<circle r="14" fill="none" stroke="#00d4aa" strokeWidth="1">
<animate attributeName="r" values="14;24" dur="3s"
begin={`${i * 0.8}s`} repeatCount="indefinite" />
<animate attributeName="opacity" values="0.5;0" dur="3s"
begin={`${i * 0.8}s`} repeatCount="indefinite" />
</circle>
{/* Label */}
<text x="0" y="30" fontSize="7" fill="#00d4aa" fillOpacity="0.6"
textAnchor="middle" fontFamily="JetBrains Mono, monospace"
letterSpacing="1.5" fontWeight="600">
{stage.label}
</text>
</g>
))}
{/* Traveling glow */}
<circle r="14" fill="url(#pipe-glow)" opacity="0.7">
<animateMotion dur="4s" repeatCount="indefinite" path={motionPath}
keyPoints="0;0.24;0.25;0.49;0.5;0.74;0.75;0.99;1"
keyTimes="0;0.18;0.22;0.38;0.42;0.58;0.62;0.78;1"
calcMode="spline"
keySplines="0.4 0 0.6 1;0 0 1 1;0.4 0 0.6 1;0 0 1 1;0.4 0 0.6 1;0 0 1 1;0.4 0 0.6 1;0 0 1 1" />
</circle>
<circle r="4" fill="#00d4aa">
<animateMotion dur="4s" repeatCount="indefinite" path={motionPath}
keyPoints="0;0.24;0.25;0.49;0.5;0.74;0.75;0.99;1"
keyTimes="0;0.18;0.22;0.38;0.42;0.58;0.62;0.78;1"
calcMode="spline"
keySplines="0.4 0 0.6 1;0 0 1 1;0.4 0 0.6 1;0 0 1 1;0.4 0 0.6 1;0 0 1 1;0.4 0 0.6 1;0 0 1 1" />
</circle>
{/* Caption */}
<text x="160" y="93" fontSize="7" fill="#00d4aa" fillOpacity="0.45"
textAnchor="middle" fontFamily="JetBrains Mono, monospace" letterSpacing="2.5">
AUTOPILOT · PIPELINE
</text>
</svg>
);
}
+532
View File
@@ -0,0 +1,532 @@
/* ─── AnyFS-inspired dark cyberpunk theme ─────────── */
:root {
--bg: #0a0a0a;
--bg-surface: #111111;
--bg-elevated: #1a1a1a;
--ink: #ffffff;
--ink-secondary: #888888;
--accent: #00d4aa;
--accent-light: #00f0c0;
--accent-orange: #ff6b35;
--accent-violet: #8b5cf6;
--muted: #666666;
--line: #222222;
--line-bright: #333333;
--success: #00d4aa;
--error: #ff4444;
--warning: #ffaa00;
--mono: "JetBrains Mono", "Fira Code", ui-monospace, "Cascadia Code", monospace;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { scroll-behavior: smooth; }
body {
background: var(--bg);
color: var(--ink);
font-family: var(--mono);
font-size: 13px;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
min-height: 100vh;
}
::selection {
background: rgba(0, 212, 170, 0.25);
color: #fff;
}
/* ─── Scrollbar ──────────────────────────────── */
::-webkit-scrollbar { width: 4px; height: 4px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #333; border-radius: 2px; }
::-webkit-scrollbar-thumb:hover { background: #555; }
/* ─── Shell ──────────────────────────────────── */
.shell {
max-width: 1600px;
margin: 0 auto;
padding: 0 20px 56px;
}
/* ─── Hero Section ───────────────────────────── */
.hero {
position: relative;
overflow: hidden;
margin: 0 -20px;
padding: 60px 20px 40px;
min-height: 340px;
}
.hero-bg {
position: absolute;
inset: 0;
pointer-events: none;
}
.hero-content {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: 1.4fr 1fr;
gap: 24px;
max-width: 1600px;
margin: 0 auto;
}
.hero-main { padding: 8px; }
.eyebrow {
display: inline-flex;
gap: 8px;
align-items: center;
font-size: 10px;
letter-spacing: 3px;
text-transform: uppercase;
color: var(--accent);
font-weight: 700;
margin-bottom: 20px;
}
.hero h1 {
font-size: clamp(32px, 4.5vw, 56px);
font-weight: 700;
letter-spacing: 2px;
line-height: 1.1;
margin-bottom: 16px;
}
.hero h1 .accent { color: var(--accent); }
.hero-sub {
color: var(--muted);
font-size: 12px;
line-height: 1.8;
max-width: 52ch;
margin-bottom: 24px;
}
.focus-box {
padding: 16px;
border-radius: 6px;
background: rgba(0, 212, 170, 0.06);
border: 1px solid rgba(0, 212, 170, 0.15);
}
.focus-box .focus-label {
font-size: 10px;
letter-spacing: 2px;
text-transform: uppercase;
color: var(--accent);
font-weight: 700;
margin-bottom: 8px;
}
.focus-box .focus-text {
font-size: 12px;
color: var(--ink-secondary);
line-height: 1.6;
}
.hero-side {
display: flex;
flex-direction: column;
gap: 16px;
padding: 8px;
}
.hero-timestamp {
font-size: 10px;
letter-spacing: 1px;
color: var(--muted);
}
/* ─── Stats Bar ──────────────────────────────── */
.stats-bar {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1px;
background: var(--line);
border-radius: 6px;
overflow: hidden;
margin-bottom: 24px;
}
.stat {
background: var(--bg-surface);
padding: 20px;
text-align: center;
transition: background 0.2s ease;
}
.stat:hover {
background: var(--bg-elevated);
}
.stat-label {
font-size: 10px;
letter-spacing: 2px;
text-transform: uppercase;
font-weight: 700;
margin-bottom: 8px;
}
.stat-label.teal { color: var(--accent); }
.stat-label.orange { color: var(--accent-orange); }
.stat-label.violet { color: var(--accent-violet); }
.stat-value {
font-size: 32px;
font-weight: 700;
letter-spacing: -1px;
line-height: 1;
}
.stat-sub {
font-size: 10px;
color: #444;
margin-top: 6px;
letter-spacing: 1px;
}
/* ─── Toolbar ────────────────────────────────── */
.toolbar {
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
margin-bottom: 20px;
background: var(--bg-elevated);
border: 1px solid var(--line);
border-radius: 6px;
flex-wrap: wrap;
}
.toolbar input {
flex: 1;
min-width: min(400px, 100%);
height: 2.5rem;
padding: 0 14px;
background: var(--bg);
border: 1px solid var(--line-bright);
border-radius: 4px;
font-family: var(--mono);
font-size: 12px;
color: var(--ink);
transition: all 0.2s ease;
}
.toolbar input::placeholder { color: #444; }
.toolbar input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 2px rgba(0, 212, 170, 0.1);
}
.toolbar .hint {
font-size: 11px;
color: var(--muted);
letter-spacing: 0.5px;
}
.toolbar .hint code {
color: var(--accent);
font-size: 11px;
}
/* ─── Board ──────────────────────────────────── */
.board {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
/* ─── Column ─────────────────────────────────── */
.column {
background: var(--bg-elevated);
border: 1px solid var(--line);
border-radius: 6px;
padding: 16px;
min-height: 200px;
}
.column-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 14px;
padding-bottom: 12px;
border-bottom: 1px solid var(--line);
}
.column-header h2 {
font-size: 12px;
font-weight: 700;
letter-spacing: 1.5px;
text-transform: uppercase;
}
.column-count {
font-size: 10px;
font-weight: 600;
letter-spacing: 1px;
padding: 2px 8px;
border-radius: 3px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--line);
color: var(--ink-secondary);
}
.cards { display: grid; gap: 10px; }
/* ─── Card ───────────────────────────────────── */
.card {
background: var(--bg-surface);
border: 1px solid var(--line);
border-radius: 6px;
padding: 14px;
transition: all 0.2s ease;
position: relative;
overflow: hidden;
}
.card::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 3px;
height: 100%;
background: var(--card-accent, var(--line-bright));
}
.card:hover {
border-color: var(--line-bright);
box-shadow: 0 0 20px color-mix(in srgb, var(--card-accent, var(--accent)) 12%, transparent);
transform: translateY(-1px);
}
.card-meta {
display: flex;
justify-content: space-between;
gap: 8px;
font-size: 10px;
color: var(--muted);
margin-bottom: 8px;
letter-spacing: 0.5px;
}
.card h3 {
font-size: 13px;
font-weight: 600;
line-height: 1.4;
margin-bottom: 8px;
letter-spacing: 0.3px;
}
.card-body {
font-size: 11px;
color: var(--muted);
line-height: 1.6;
margin-bottom: 10px;
white-space: pre-wrap;
word-break: break-word;
}
.card-tags {
display: flex;
gap: 4px;
flex-wrap: wrap;
margin-bottom: 10px;
}
.tag {
display: inline-flex;
align-items: center;
padding: 2px 8px;
font-size: 9px;
font-weight: 600;
letter-spacing: 1px;
text-transform: uppercase;
border-radius: 3px;
background: rgba(255, 255, 255, 0.04);
color: var(--ink-secondary);
border: 1px solid var(--line);
}
.card-footer {
border-top: 1px solid var(--line);
padding-top: 10px;
display: grid;
gap: 4px;
font-size: 10px;
color: #555;
letter-spacing: 0.3px;
}
/* ─── Status badges for column headers ────── */
.badge {
display: inline-flex;
align-items: center;
padding: 2px 10px;
font-size: 10px;
font-weight: 600;
letter-spacing: 1px;
text-transform: uppercase;
border-radius: 3px;
}
.badge-teal {
background: rgba(0, 212, 170, 0.1);
color: var(--accent);
border: 1px solid rgba(0, 212, 170, 0.2);
}
.badge-orange {
background: rgba(255, 107, 53, 0.1);
color: var(--accent-orange);
border: 1px solid rgba(255, 107, 53, 0.2);
}
.badge-violet {
background: rgba(139, 92, 246, 0.1);
color: var(--accent-violet);
border: 1px solid rgba(139, 92, 246, 0.2);
}
.badge-red {
background: rgba(255, 68, 68, 0.1);
color: #ff6666;
border: 1px solid rgba(255, 68, 68, 0.2);
}
.badge-blue {
background: rgba(59, 130, 246, 0.1);
color: #60a5fa;
border: 1px solid rgba(59, 130, 246, 0.2);
}
.badge-amber {
background: rgba(255, 170, 0, 0.1);
color: #ffaa00;
border: 1px solid rgba(255, 170, 0, 0.2);
}
.badge-gray {
background: rgba(255, 255, 255, 0.04);
color: #888;
border: 1px solid var(--line);
}
/* ─── Journal ────────────────────────────────── */
.journal {
background: var(--bg-elevated);
border: 1px solid var(--line);
border-radius: 6px;
padding: 20px;
}
.journal-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
padding-bottom: 14px;
border-bottom: 1px solid var(--line);
}
.journal-header h2 {
font-size: 12px;
font-weight: 700;
letter-spacing: 2px;
text-transform: uppercase;
}
.journal-list { display: grid; gap: 8px; }
.journal-item {
padding: 12px 14px;
border-radius: 4px;
border: 1px solid var(--line);
background: var(--bg-surface);
transition: background 0.15s ease;
}
.journal-item:hover { background: var(--bg-elevated); }
.journal-item time {
font-size: 10px;
color: var(--muted);
letter-spacing: 0.5px;
display: inline-block;
margin-bottom: 4px;
}
.journal-item .kind {
font-weight: 700;
color: var(--accent);
font-size: 11px;
letter-spacing: 0.5px;
}
.journal-item .task-ref {
color: var(--accent-violet);
font-size: 11px;
}
.journal-item .summary {
font-size: 12px;
color: var(--ink-secondary);
line-height: 1.5;
}
.empty {
color: var(--muted);
font-style: italic;
padding: 16px 4px;
font-size: 12px;
letter-spacing: 0.5px;
}
/* ─── Pipeline Animation (hero side) ─────────── */
.pipeline-viz {
background: var(--bg-elevated);
border: 1px solid var(--line);
border-radius: 6px;
overflow: hidden;
flex: 1;
}
/* ─── Animations ─────────────────────────────── */
@keyframes fade-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes glow-pulse {
0%, 100% { box-shadow: 0 0 12px rgba(0, 212, 170, 0.08); }
50% { box-shadow: 0 0 24px rgba(0, 212, 170, 0.16); }
}
.animate-fade-in {
animation: fade-in 0.4s ease-out forwards;
}
/* ─── Responsive ─────────────────────────────── */
@media (max-width: 1100px) {
.hero-content { grid-template-columns: 1fr; }
.stats-bar { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 760px) {
.shell { padding: 0 12px 36px; }
.hero { padding: 40px 12px 28px; margin: 0 -12px; }
.board { grid-template-columns: 1fr; }
.stats-bar { grid-template-columns: 1fr 1fr; }
.toolbar input { min-width: 100%; }
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import { App } from "./App";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);
+69
View File
@@ -0,0 +1,69 @@
export interface TaskCard {
id: string;
title: string;
body?: string;
status: string;
score: number;
score_reasons?: string[];
source_kind?: string;
source_ref?: string;
labels?: string[];
updated_at?: number;
metadata?: {
last_note?: string;
last_ci_summary?: string;
last_failure_summary?: string;
human_gate_pending?: boolean;
verification_steps?: { status: string; command: string }[];
};
}
export interface JournalEntry {
timestamp: number;
kind: string;
task_id?: string;
summary: string;
}
export interface Snapshot {
generated_at: number;
repo_name: string;
focus?: TaskCard;
counts: Record<string, number>;
status_order: string[];
columns: Record<string, TaskCard[]>;
cards: TaskCard[];
journal: JournalEntry[];
}
export const STATUS_LABELS: Record<string, string> = {
queued: "Queued",
accepted: "Accepted",
preparing: "Preparing",
running: "Running",
verifying: "Verifying",
pr_open: "PR Open",
waiting_ci: "Waiting CI",
repairing: "Repairing",
completed: "Completed",
merged: "Merged",
failed: "Failed",
rejected: "Rejected",
superseded: "Superseded",
};
export const STATUS_COLORS: Record<string, string> = {
queued: "#64748b",
accepted: "#8b5cf6",
preparing: "#0f766e",
running: "#00d4aa",
verifying: "#3b82f6",
pr_open: "#8b5cf6",
waiting_ci: "#3b82f6",
repairing: "#ff6b35",
completed: "#00d4aa",
merged: "#00d4aa",
failed: "#ff4444",
rejected: "#ff4444",
superseded: "#ffaa00",
};
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsBuildInfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}
+4
View File
@@ -0,0 +1,4 @@
{
"files": [],
"references": [{ "path": "./tsconfig.app.json" }]
}
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
base: "./",
build: {
outDir: "../docs/autopilot",
emptyOutDir: false,
},
});
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+14 -262
View File
@@ -1,264 +1,16 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>OpenHarness-new Autopilot Kanban</title>
<style>
:root {
--bg: #f4efe7;
--panel: rgba(255,255,255,0.82);
--ink: #1b2430;
--muted: #5b6875;
--line: rgba(27,36,48,0.12);
--accent: #0f7b6c;
--accent-soft: rgba(15,123,108,0.12);
--danger: #bb3e28;
--warn: #b7791f;
--shadow: 0 20px 40px rgba(27,36,48,0.08);
--radius: 18px;
--mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
--sans: "IBM Plex Sans", "Segoe UI", sans-serif;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: var(--sans);
color: var(--ink);
background:
radial-gradient(circle at top left, rgba(15,123,108,0.14), transparent 30%),
radial-gradient(circle at right, rgba(187,62,40,0.12), transparent 28%),
linear-gradient(180deg, #f9f6ef 0%, var(--bg) 100%);
}
.shell { max-width: 1600px; margin: 0 auto; padding: 32px 20px 56px; }
.hero { display: grid; gap: 18px; grid-template-columns: 1.5fr 1fr; margin-bottom: 20px; }
.panel {
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
backdrop-filter: blur(10px);
}
.hero-main { padding: 28px; min-height: 220px; }
.eyebrow {
display: inline-flex; gap: 8px; align-items: center; font-size: 12px; letter-spacing: 0.14em;
text-transform: uppercase; color: var(--accent); font-weight: 700; margin-bottom: 18px;
}
h1 { margin: 0 0 12px; font-size: clamp(32px, 4vw, 52px); line-height: 0.96; }
.sub { margin: 0; color: var(--muted); font-size: 16px; line-height: 1.6; max-width: 58ch; }
.focus {
margin-top: 20px; padding: 16px; border-radius: 14px; background: var(--accent-soft);
border: 1px solid rgba(15,123,108,0.14);
}
.focus strong { display: block; margin-bottom: 6px; }
.hero-side { padding: 22px; display: grid; gap: 12px; align-content: start; }
.stat-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.stat { padding: 14px; border-radius: 14px; border: 1px solid var(--line); background: rgba(255,255,255,0.72); }
.stat .label { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; }
.stat .value { margin-top: 6px; font-size: 26px; font-weight: 700; }
.toolbar {
display: flex; gap: 12px; align-items: center; justify-content: space-between;
padding: 18px 20px; margin-bottom: 18px; flex-wrap: wrap;
}
.toolbar input {
min-width: min(420px, 100%); padding: 12px 14px; border-radius: 999px;
border: 1px solid var(--line); background: rgba(255,255,255,0.84); font: inherit;
}
.toolbar .hint { color: var(--muted); font-size: 14px; }
.board {
display: grid; grid-template-columns: repeat(6, minmax(260px, 1fr)); gap: 14px;
overflow-x: auto; padding-bottom: 4px;
}
.column { min-height: 280px; padding: 14px; }
.column-header { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 12px; gap: 8px; }
.column-header h2 { margin: 0; font-size: 16px; }
.count { color: var(--muted); font-size: 12px; font-family: var(--mono); }
.cards { display: grid; gap: 12px; }
.card {
border-radius: 16px; border: 1px solid var(--line); background: rgba(255,255,255,0.9);
padding: 14px; transition: transform 160ms ease, box-shadow 160ms ease;
}
.card:hover { transform: translateY(-2px); box-shadow: 0 14px 30px rgba(27,36,48,0.10); }
.meta {
display: flex; justify-content: space-between; gap: 8px; font-size: 12px;
color: var(--muted); margin-bottom: 8px; font-family: var(--mono);
}
.card h3 { margin: 0 0 8px; font-size: 15px; line-height: 1.35; }
.body {
margin: 0 0 10px; color: var(--muted); font-size: 14px;
line-height: 1.5; white-space: pre-wrap;
}
.tags { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 10px; }
.tag { font-size: 11px; padding: 4px 8px; border-radius: 999px; border: 1px solid var(--line); background: #fff; }
.footer { border-top: 1px dashed var(--line); padding-top: 10px; display: grid; gap: 6px; font-size: 12px; color: var(--muted); }
.journal { margin-top: 20px; padding: 22px; }
.journal h2 { margin: 0 0 14px; font-size: 18px; }
.journal-list { display: grid; gap: 10px; }
.journal-item { padding: 12px 14px; border-radius: 14px; border: 1px solid var(--line); background: rgba(255,255,255,0.72); }
.journal-item time { font-family: var(--mono); color: var(--muted); font-size: 12px; display: inline-block; margin-bottom: 4px; }
.empty { color: var(--muted); font-style: italic; padding: 12px 4px; }
.status-repairing { border-left: 4px solid #c05621; }
.status-waiting_ci { border-left: 4px solid #2563eb; }
.status-running { border-left: 4px solid var(--accent); }
.status-verifying { border-left: 4px solid #1d4ed8; }
.status-preparing { border-left: 4px solid #0f766e; }
.status-accepted, .status-pr_open { border-left: 4px solid #7c3aed; }
.status-queued { border-left: 4px solid #64748b; }
.status-completed, .status-merged { border-left: 4px solid #15803d; }
.status-failed, .status-rejected { border-left: 4px solid var(--danger); }
.status-superseded { border-left: 4px solid var(--warn); }
@media (max-width: 1100px) {
.hero { grid-template-columns: 1fr; }
.board { grid-template-columns: repeat(3, minmax(250px, 1fr)); }
}
@media (max-width: 760px) {
.shell { padding: 18px 12px 36px; }
.board { grid-template-columns: 1fr; }
.toolbar input { min-width: 100%; }
}
</style>
</head>
<body>
<div class="shell">
<section class="hero">
<div class="hero-main panel">
<div class="eyebrow">OpenHarness Autopilot</div>
<h1>OpenHarness-new self-evolution board</h1>
<p class="sub">
A static GitHub Pages view of the repo-local autopilot queue. Cards come from ohmo requests,
GitHub issues and PRs, and claude-code alignment candidates. Service-level verification remains the source of truth.
</p>
<div class="focus" id="focus-box">
<strong>Current focus</strong>
<div id="focus-text">Loading latest snapshot…</div>
</div>
</div>
<aside class="hero-side panel">
<div class="hint">Generated from repo state at 2026-04-16 11:26:06 UTC</div>
<div class="stat-grid" id="stats"></div>
</aside>
</section>
<section class="toolbar panel">
<input id="search" type="search" placeholder="Filter by title, body, source, label, or task id" />
<div class="hint">This page reads <code>snapshot.json</code> and renders a kanban without any backend.</div>
</section>
<section class="board" id="board"></section>
<section class="journal panel">
<h2>Recent repo journal</h2>
<div class="journal-list" id="journal"></div>
</section>
</div>
<script>
const STATUS_LABELS = {
queued: "Queued",
accepted: "Accepted",
preparing: "Preparing",
running: "Running",
verifying: "Verifying",
pr_open: "PR Open",
waiting_ci: "Waiting CI",
repairing: "Repairing",
completed: "Completed",
merged: "Merged",
failed: "Failed",
rejected: "Rejected",
superseded: "Superseded",
};
const fmtAgo = (ts) => {
if (!ts) return "-";
const delta = Math.max(0, Math.floor(Date.now() / 1000 - ts));
if (delta < 60) return `${delta}s ago`;
if (delta < 3600) return `${Math.floor(delta / 60)}m ago`;
if (delta < 86400) return `${Math.floor(delta / 3600)}h ago`;
return `${Math.floor(delta / 86400)}d ago`;
};
const renderCard = (card) => {
const labels = [...(card.labels || []), card.source_kind].filter(Boolean);
const verification = (card.metadata?.verification_steps || [])
.map((step) => `${step.status} · ${step.command}`)
.slice(0, 2)
.join(" | ");
return `
<article class="card status-${card.status}">
<div class="meta"><span>${card.id}</span><span>score ${card.score}</span></div>
<h3>${card.title}</h3>
<p class="body">${(card.body || "").slice(0, 260) || "No extra detail."}</p>
<div class="tags">${labels.map((tag) => `<span class="tag">${tag}</span>`).join("")}</div>
<div class="footer">
<div>updated ${fmtAgo(card.updated_at)}${card.source_ref ? ` · ref ${card.source_ref}` : ""}</div>
<div>${card.metadata?.last_note || "no status note yet"}</div>
<div>${verification || card.metadata?.last_ci_summary || card.metadata?.last_failure_summary || (card.metadata?.human_gate_pending ? "verification passed; human gate pending" : "")}</div>
</div>
</article>
`;
};
const render = (snapshot, filterText = "") => {
const counts = snapshot.counts || {};
const order = snapshot.status_order || [];
const board = document.getElementById("board");
const stats = document.getElementById("stats");
const journal = document.getElementById("journal");
const focusText = document.getElementById("focus-text");
const normalizedFilter = filterText.trim().toLowerCase();
stats.innerHTML = [
["Queued", counts.queued || 0],
["In progress", (counts.preparing || 0) + (counts.running || 0) + (counts.verifying || 0) + (counts.waiting_ci || 0) + (counts.repairing || 0) + (counts.accepted || 0) + (counts.pr_open || 0)],
["Completed", (counts.completed || 0) + (counts.merged || 0)],
["Failed", (counts.failed || 0) + (counts.rejected || 0)]
].map(([label, value]) => `
<div class="stat">
<div class="label">${label}</div>
<div class="value">${value}</div>
</div>
`).join("");
focusText.textContent = snapshot.focus
? `[${snapshot.focus.status}] ${snapshot.focus.title} · score=${snapshot.focus.score} · ${snapshot.focus.source_kind}`
: "No active task focus yet.";
board.innerHTML = order.map((status) => {
const cards = (snapshot.columns?.[status] || []).filter((card) => {
if (!normalizedFilter) return true;
const haystack = [
card.id,
card.title,
card.body,
card.source_kind,
card.source_ref,
...(card.labels || []),
...(card.score_reasons || []),
].join(" ").toLowerCase();
return haystack.includes(normalizedFilter);
});
return `
<section class="column panel">
<div class="column-header">
<h2>${STATUS_LABELS[status] || status}</h2>
<span class="count">${cards.length}</span>
</div>
<div class="cards">
${cards.length ? cards.map(renderCard).join("") : '<div class="empty">No cards in this column.</div>'}
</div>
</section>
`;
}).join("");
const entries = snapshot.journal || [];
journal.innerHTML = entries.length ? entries.slice().reverse().map((entry) => `
<article class="journal-item">
<time>${new Date(entry.timestamp * 1000).toISOString().replace("T", " ").replace(".000Z", " UTC")}</time>
<div><strong>${entry.kind}</strong>${entry.task_id ? ` [${entry.task_id}]` : ""}</div>
<div>${entry.summary}</div>
</article>
`).join("") : '<div class="empty">Journal is empty.</div>';
};
fetch("./snapshot.json", { cache: "no-store" })
.then((response) => response.json())
.then((snapshot) => {
render(snapshot);
const search = document.getElementById("search");
search.addEventListener("input", () => render(snapshot, search.value));
})
.catch((error) => {
document.getElementById("board").innerHTML = `<div class="empty">Failed to load snapshot.json: ${error}</div>`;
});
</script>
</body>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Autopilot Kanban</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet" />
<script type="module" crossorigin src="./assets/index-DbmnWBut.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-CUSUttvC.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
+36 -248
View File
@@ -1758,6 +1758,14 @@ class RepoAutopilotStore:
return order.get(status, 99)
def _render_dashboard_html(self, snapshot: dict[str, Any]) -> str:
"""Return a minimal fallback HTML page.
The primary dashboard is now a React + Vite app built from
``autopilot-dashboard/``. This fallback is only written when
no pre-built ``index.html`` already exists in the output
directory, so local ``snapshot.json`` generation still works
without a Node.js toolchain.
"""
repo_name = escape(_safe_text(snapshot.get("repo_name")) or "OpenHarness")
generated = time.strftime(
"%Y-%m-%d %H:%M:%S UTC",
@@ -1769,262 +1777,42 @@ class RepoAutopilotStore:
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{repo_name} Autopilot Kanban</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&display=swap" rel="stylesheet" />
<style>
:root {{
--bg: #f4efe7;
--panel: rgba(255,255,255,0.82);
--ink: #1b2430;
--muted: #5b6875;
--line: rgba(27,36,48,0.12);
--accent: #0f7b6c;
--accent-soft: rgba(15,123,108,0.12);
--danger: #bb3e28;
--warn: #b7791f;
--shadow: 0 20px 40px rgba(27,36,48,0.08);
--radius: 18px;
--mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
--sans: "IBM Plex Sans", "Segoe UI", sans-serif;
}}
* {{ box-sizing: border-box; }}
body {{
margin: 0;
font-family: var(--sans);
color: var(--ink);
background:
radial-gradient(circle at top left, rgba(15,123,108,0.14), transparent 30%),
radial-gradient(circle at right, rgba(187,62,40,0.12), transparent 28%),
linear-gradient(180deg, #f9f6ef 0%, var(--bg) 100%);
}}
.shell {{ max-width: 1600px; margin: 0 auto; padding: 32px 20px 56px; }}
.hero {{ display: grid; gap: 18px; grid-template-columns: 1.5fr 1fr; margin-bottom: 20px; }}
.panel {{
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
backdrop-filter: blur(10px);
}}
.hero-main {{ padding: 28px; min-height: 220px; }}
.eyebrow {{
display: inline-flex; gap: 8px; align-items: center; font-size: 12px; letter-spacing: 0.14em;
text-transform: uppercase; color: var(--accent); font-weight: 700; margin-bottom: 18px;
}}
h1 {{ margin: 0 0 12px; font-size: clamp(32px, 4vw, 52px); line-height: 0.96; }}
.sub {{ margin: 0; color: var(--muted); font-size: 16px; line-height: 1.6; max-width: 58ch; }}
.focus {{
margin-top: 20px; padding: 16px; border-radius: 14px; background: var(--accent-soft);
border: 1px solid rgba(15,123,108,0.14);
}}
.focus strong {{ display: block; margin-bottom: 6px; }}
.hero-side {{ padding: 22px; display: grid; gap: 12px; align-content: start; }}
.stat-grid {{ display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }}
.stat {{ padding: 14px; border-radius: 14px; border: 1px solid var(--line); background: rgba(255,255,255,0.72); }}
.stat .label {{ color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; }}
.stat .value {{ margin-top: 6px; font-size: 26px; font-weight: 700; }}
.toolbar {{
display: flex; gap: 12px; align-items: center; justify-content: space-between;
padding: 18px 20px; margin-bottom: 18px; flex-wrap: wrap;
}}
.toolbar input {{
min-width: min(420px, 100%); padding: 12px 14px; border-radius: 999px;
border: 1px solid var(--line); background: rgba(255,255,255,0.84); font: inherit;
}}
.toolbar .hint {{ color: var(--muted); font-size: 14px; }}
.board {{
display: grid; grid-template-columns: repeat(6, minmax(260px, 1fr)); gap: 14px;
overflow-x: auto; padding-bottom: 4px;
}}
.column {{ min-height: 280px; padding: 14px; }}
.column-header {{ display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 12px; gap: 8px; }}
.column-header h2 {{ margin: 0; font-size: 16px; }}
.count {{ color: var(--muted); font-size: 12px; font-family: var(--mono); }}
.cards {{ display: grid; gap: 12px; }}
.card {{
border-radius: 16px; border: 1px solid var(--line); background: rgba(255,255,255,0.9);
padding: 14px; transition: transform 160ms ease, box-shadow 160ms ease;
}}
.card:hover {{ transform: translateY(-2px); box-shadow: 0 14px 30px rgba(27,36,48,0.10); }}
.meta {{
display: flex; justify-content: space-between; gap: 8px; font-size: 12px;
color: var(--muted); margin-bottom: 8px; font-family: var(--mono);
}}
.card h3 {{ margin: 0 0 8px; font-size: 15px; line-height: 1.35; }}
.body {{
margin: 0 0 10px; color: var(--muted); font-size: 14px;
line-height: 1.5; white-space: pre-wrap;
}}
.tags {{ display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 10px; }}
.tag {{ font-size: 11px; padding: 4px 8px; border-radius: 999px; border: 1px solid var(--line); background: #fff; }}
.footer {{ border-top: 1px dashed var(--line); padding-top: 10px; display: grid; gap: 6px; font-size: 12px; color: var(--muted); }}
.journal {{ margin-top: 20px; padding: 22px; }}
.journal h2 {{ margin: 0 0 14px; font-size: 18px; }}
.journal-list {{ display: grid; gap: 10px; }}
.journal-item {{ padding: 12px 14px; border-radius: 14px; border: 1px solid var(--line); background: rgba(255,255,255,0.72); }}
.journal-item time {{ font-family: var(--mono); color: var(--muted); font-size: 12px; display: inline-block; margin-bottom: 4px; }}
.empty {{ color: var(--muted); font-style: italic; padding: 12px 4px; }}
.status-repairing {{ border-left: 4px solid #c05621; }}
.status-waiting_ci {{ border-left: 4px solid #2563eb; }}
.status-running {{ border-left: 4px solid var(--accent); }}
.status-verifying {{ border-left: 4px solid #1d4ed8; }}
.status-preparing {{ border-left: 4px solid #0f766e; }}
.status-accepted, .status-pr_open {{ border-left: 4px solid #7c3aed; }}
.status-queued {{ border-left: 4px solid #64748b; }}
.status-completed, .status-merged {{ border-left: 4px solid #15803d; }}
.status-failed, .status-rejected {{ border-left: 4px solid var(--danger); }}
.status-superseded {{ border-left: 4px solid var(--warn); }}
@media (max-width: 1100px) {{
.hero {{ grid-template-columns: 1fr; }}
.board {{ grid-template-columns: repeat(3, minmax(250px, 1fr)); }}
}}
@media (max-width: 760px) {{
.shell {{ padding: 18px 12px 36px; }}
.board {{ grid-template-columns: 1fr; }}
.toolbar input {{ min-width: 100%; }}
--bg: #0a0a0a; --bg-elevated: #1a1a1a; --ink: #fff;
--accent: #00d4aa; --muted: #666; --line: #222;
--mono: "JetBrains Mono", ui-monospace, monospace;
}}
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{ background: var(--bg); color: var(--ink); font-family: var(--mono); font-size: 13px; }}
.shell {{ max-width: 960px; margin: 80px auto; padding: 0 20px; text-align: center; }}
h1 {{ font-size: 32px; letter-spacing: 2px; margin-bottom: 16px; }}
h1 span {{ color: var(--accent); }}
.sub {{ color: var(--muted); font-size: 12px; line-height: 1.8; margin-bottom: 32px; }}
.info {{ background: var(--bg-elevated); border: 1px solid var(--line); border-radius: 6px; padding: 24px; text-align: left; }}
.info p {{ color: #888; font-size: 12px; line-height: 1.7; margin-bottom: 12px; }}
.info code {{ color: var(--accent); }}
.ts {{ color: var(--muted); font-size: 10px; letter-spacing: 1px; margin-top: 20px; }}
</style>
</head>
<body>
<div class="shell">
<section class="hero">
<div class="hero-main panel">
<div class="eyebrow">OpenHarness Autopilot</div>
<h1>{repo_name} self-evolution board</h1>
<p class="sub">
A static GitHub Pages view of the repo-local autopilot queue. Cards come from ohmo requests,
GitHub issues and PRs, and claude-code alignment candidates. Service-level verification remains the source of truth.
</p>
<div class="focus" id="focus-box">
<strong>Current focus</strong>
<div id="focus-text">Loading latest snapshot…</div>
</div>
</div>
<aside class="hero-side panel">
<div class="hint">Generated from repo state at {escape(generated)}</div>
<div class="stat-grid" id="stats"></div>
</aside>
</section>
<section class="toolbar panel">
<input id="search" type="search" placeholder="Filter by title, body, source, label, or task id" />
<div class="hint">This page reads <code>snapshot.json</code> and renders a kanban without any backend.</div>
</section>
<section class="board" id="board"></section>
<section class="journal panel">
<h2>Recent repo journal</h2>
<div class="journal-list" id="journal"></div>
</section>
<h1>{repo_name} <span>AUTOPILOT</span></h1>
<p class="sub">
This is a fallback page. The full React dashboard is built via CI
from <code>autopilot-dashboard/</code>.
</p>
<div class="info">
<p>To view the full dashboard locally, build the React app:</p>
<p><code>cd autopilot-dashboard &amp;&amp; npm install &amp;&amp; npm run build</code></p>
<p>Then open <code>docs/autopilot/index.html</code> in a browser.</p>
<p>Snapshot data: <code>snapshot.json</code> (generated {escape(generated)})</p>
</div>
<div class="ts">Generated at {escape(generated)}</div>
</div>
<script>
const STATUS_LABELS = {{
queued: "Queued",
accepted: "Accepted",
preparing: "Preparing",
running: "Running",
verifying: "Verifying",
pr_open: "PR Open",
waiting_ci: "Waiting CI",
repairing: "Repairing",
completed: "Completed",
merged: "Merged",
failed: "Failed",
rejected: "Rejected",
superseded: "Superseded",
}};
const fmtAgo = (ts) => {{
if (!ts) return "-";
const delta = Math.max(0, Math.floor(Date.now() / 1000 - ts));
if (delta < 60) return `${{delta}}s ago`;
if (delta < 3600) return `${{Math.floor(delta / 60)}}m ago`;
if (delta < 86400) return `${{Math.floor(delta / 3600)}}h ago`;
return `${{Math.floor(delta / 86400)}}d ago`;
}};
const renderCard = (card) => {{
const labels = [...(card.labels || []), card.source_kind].filter(Boolean);
const verification = (card.metadata?.verification_steps || [])
.map((step) => `${{step.status}} · ${{step.command}}`)
.slice(0, 2)
.join(" | ");
return `
<article class="card status-${{card.status}}">
<div class="meta"><span>${{card.id}}</span><span>score ${{card.score}}</span></div>
<h3>${{card.title}}</h3>
<p class="body">${{(card.body || "").slice(0, 260) || "No extra detail."}}</p>
<div class="tags">${{labels.map((tag) => `<span class="tag">${{tag}}</span>`).join("")}}</div>
<div class="footer">
<div>updated ${{fmtAgo(card.updated_at)}}${{card.source_ref ? ` · ref ${{card.source_ref}}` : ""}}</div>
<div>${{card.metadata?.last_note || "no status note yet"}}</div>
<div>${{verification || card.metadata?.last_ci_summary || card.metadata?.last_failure_summary || (card.metadata?.human_gate_pending ? "verification passed; human gate pending" : "")}}</div>
</div>
</article>
`;
}};
const render = (snapshot, filterText = "") => {{
const counts = snapshot.counts || {{}};
const order = snapshot.status_order || [];
const board = document.getElementById("board");
const stats = document.getElementById("stats");
const journal = document.getElementById("journal");
const focusText = document.getElementById("focus-text");
const normalizedFilter = filterText.trim().toLowerCase();
stats.innerHTML = [
["Queued", counts.queued || 0],
["In progress", (counts.preparing || 0) + (counts.running || 0) + (counts.verifying || 0) + (counts.waiting_ci || 0) + (counts.repairing || 0) + (counts.accepted || 0) + (counts.pr_open || 0)],
["Completed", (counts.completed || 0) + (counts.merged || 0)],
["Failed", (counts.failed || 0) + (counts.rejected || 0)]
].map(([label, value]) => `
<div class="stat">
<div class="label">${{label}}</div>
<div class="value">${{value}}</div>
</div>
`).join("");
focusText.textContent = snapshot.focus
? `[${{snapshot.focus.status}}] ${{snapshot.focus.title}} · score=${{snapshot.focus.score}} · ${{snapshot.focus.source_kind}}`
: "No active task focus yet.";
board.innerHTML = order.map((status) => {{
const cards = (snapshot.columns?.[status] || []).filter((card) => {{
if (!normalizedFilter) return true;
const haystack = [
card.id,
card.title,
card.body,
card.source_kind,
card.source_ref,
...(card.labels || []),
...(card.score_reasons || []),
].join(" ").toLowerCase();
return haystack.includes(normalizedFilter);
}});
return `
<section class="column panel">
<div class="column-header">
<h2>${{STATUS_LABELS[status] || status}}</h2>
<span class="count">${{cards.length}}</span>
</div>
<div class="cards">
${{cards.length ? cards.map(renderCard).join("") : '<div class="empty">No cards in this column.</div>'}}
</div>
</section>
`;
}}).join("");
const entries = snapshot.journal || [];
journal.innerHTML = entries.length ? entries.slice().reverse().map((entry) => `
<article class="journal-item">
<time>${{new Date(entry.timestamp * 1000).toISOString().replace("T", " ").replace(".000Z", " UTC")}}</time>
<div><strong>${{entry.kind}}</strong>${{entry.task_id ? ` [${{entry.task_id}}]` : ""}}</div>
<div>${{entry.summary}}</div>
</article>
`).join("") : '<div class="empty">Journal is empty.</div>';
}};
fetch("./snapshot.json", {{ cache: "no-store" }})
.then((response) => response.json())
.then((snapshot) => {{
render(snapshot);
const search = document.getElementById("search");
search.addEventListener("input", () => render(snapshot, search.value));
}})
.catch((error) => {{
document.getElementById("board").innerHTML = `<div class="empty">Failed to load snapshot.json: ${{error}}</div>`;
}});
</script>
</body>
</html>
"""