1710 lines
70 KiB
HTML
1710 lines
70 KiB
HTML
<!doctype html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>Intent-Board</title>
|
||
<style>
|
||
:root {
|
||
color-scheme: light dark;
|
||
--bg-page: #f2f3f5;
|
||
--text-primary: #1a1a1a;
|
||
--header-bg: #20242c;
|
||
--header-text: #fff;
|
||
--header-subtext: #aab0bd;
|
||
--accent: #4a7dfc;
|
||
--accent-strong: #244fb5;
|
||
--smoke-text: #cdd8ff;
|
||
--smoke-danger-border: #d98a80;
|
||
--smoke-danger-text: #ffd9d2;
|
||
--column-bg: #fff;
|
||
--column-shadow: rgba(0, 0, 0, 0.08);
|
||
--column-heading: #555;
|
||
--agent-column-bg: #eef1f6;
|
||
--agent-column-heading: #666;
|
||
--config-label: #666;
|
||
--card-bg: #fafafa;
|
||
--card-border: #e2e2e2;
|
||
--card-actions-border: #aeb7c5;
|
||
--card-actions-bg: #fff;
|
||
--card-actions-text: #253044;
|
||
--card-actions-danger-border: #cf8a82;
|
||
--card-actions-danger-text: #8f2117;
|
||
--children-border: #d8d8d8;
|
||
--family-lightness: 95%;
|
||
--family-border-lightness: 70%;
|
||
--intent-border: #4a7dfc;
|
||
--research-border: #8b5cf6;
|
||
--idea-border: #999;
|
||
--draft-border: #e67e22;
|
||
--badge-border: #bbb;
|
||
--badge-bg: rgba(255, 255, 255, 0.78);
|
||
--badge-text: #333;
|
||
--failed: #c0392b;
|
||
--failed-strong: #8f2117;
|
||
--done: #2a8a4a;
|
||
--done-strong: #176b35;
|
||
--queued: #9a6f00;
|
||
--queued-strong: #765500;
|
||
--queued-status: #b8860b;
|
||
--status-text: #777;
|
||
--source-missing: #b05a00;
|
||
--muted-text: #999;
|
||
--dialog-bg: #fff;
|
||
--dialog-shadow: rgba(0, 0, 0, 0.3);
|
||
--dialog-backdrop: rgba(0, 0, 0, 0.55);
|
||
--review-border: #ddd;
|
||
--review-path-text: #666;
|
||
}
|
||
@media (prefers-color-scheme: dark) {
|
||
:root {
|
||
--bg-page: #16181d;
|
||
--text-primary: #e7e9ee;
|
||
--header-bg: #0e1015;
|
||
--header-text: #f2f3f5;
|
||
--header-subtext: #8b93a3;
|
||
--accent: #7d9fff;
|
||
--accent-strong: #aec2ff;
|
||
--smoke-text: #cdd8ff;
|
||
--smoke-danger-border: #d98a80;
|
||
--smoke-danger-text: #ffd9d2;
|
||
--column-bg: #21242b;
|
||
--column-shadow: rgba(0, 0, 0, 0.4);
|
||
--column-heading: #9aa1b0;
|
||
--agent-column-bg: #262a33;
|
||
--agent-column-heading: #9aa1b0;
|
||
--config-label: #9aa1b0;
|
||
--card-bg: #292d36;
|
||
--card-border: #3a3f4b;
|
||
--card-actions-border: #4b5261;
|
||
--card-actions-bg: #2f333d;
|
||
--card-actions-text: #cfd6e4;
|
||
--card-actions-danger-border: #a4574c;
|
||
--card-actions-danger-text: #ff9d8f;
|
||
--children-border: #454b58;
|
||
--family-lightness: 22%;
|
||
--family-border-lightness: 45%;
|
||
--intent-border: #7d9fff;
|
||
--research-border: #a988ff;
|
||
--idea-border: #7d8494;
|
||
--draft-border: #f0994d;
|
||
--badge-border: #4b5261;
|
||
--badge-bg: rgba(255, 255, 255, 0.06);
|
||
--badge-text: #cfd6e4;
|
||
--failed: #ff6b57;
|
||
--failed-strong: #ff9d8f;
|
||
--done: #4bc678;
|
||
--done-strong: #7fdba0;
|
||
--queued: #e0b84d;
|
||
--queued-strong: #f0cf7a;
|
||
--queued-status: #e0b84d;
|
||
--status-text: #9aa1b0;
|
||
--source-missing: #e0a35a;
|
||
--muted-text: #7d8494;
|
||
--dialog-bg: #21242b;
|
||
--dialog-shadow: rgba(0, 0, 0, 0.6);
|
||
--dialog-backdrop: rgba(0, 0, 0, 0.7);
|
||
--review-border: #3a3f4b;
|
||
--review-path-text: #9aa1b0;
|
||
}
|
||
}
|
||
* { box-sizing: border-box; }
|
||
body {
|
||
margin: 0;
|
||
font-family: system-ui, sans-serif;
|
||
background: var(--bg-page);
|
||
color: var(--text-primary);
|
||
}
|
||
header {
|
||
padding: 1rem 1.5rem;
|
||
background: var(--header-bg);
|
||
color: var(--header-text);
|
||
}
|
||
header h1 { margin: 0; font-size: 1.1rem; font-weight: 600; }
|
||
header p { margin: 0.2rem 0 0; font-size: 0.8rem; color: var(--header-subtext); }
|
||
.smoke-test-button {
|
||
margin: 0.5rem 0.5rem 0 0;
|
||
padding: 0.3rem 0.6rem;
|
||
border: 1px solid var(--accent);
|
||
border-radius: 6px;
|
||
background: none;
|
||
color: var(--smoke-text);
|
||
cursor: pointer;
|
||
font-size: 0.8rem;
|
||
}
|
||
.smoke-test-button.danger { border-color: var(--smoke-danger-border); color: var(--smoke-danger-text); }
|
||
main {
|
||
display: grid;
|
||
grid-template-columns: 260px repeat(7, minmax(230px, 1fr));
|
||
gap: 1rem;
|
||
padding: 1rem;
|
||
overflow-x: auto;
|
||
align-items: flex-start;
|
||
}
|
||
.column {
|
||
background: var(--column-bg);
|
||
border-radius: 8px;
|
||
min-width: 230px;
|
||
flex: 1 0 220px;
|
||
padding: 0.75rem;
|
||
box-shadow: 0 1px 2px var(--column-shadow);
|
||
}
|
||
.column h2 {
|
||
font-size: 0.8rem;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.04em;
|
||
color: var(--column-heading);
|
||
margin: 0 0 0.5rem;
|
||
}
|
||
.agent-column {
|
||
position: sticky;
|
||
top: 1rem;
|
||
background: var(--agent-column-bg);
|
||
}
|
||
.agent-column h3 { margin: 0.7rem 0 0.25rem; font-size: 0.7rem; color: var(--agent-column-heading); }
|
||
.agent-column .column-config { margin: 0 0 0.5rem; }
|
||
.column-config {
|
||
display: grid;
|
||
gap: 0.3rem;
|
||
margin: 0 0 0.75rem;
|
||
font-size: 0.72rem;
|
||
}
|
||
.column-config label {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 0.4rem;
|
||
color: var(--config-label);
|
||
}
|
||
.column-config select,
|
||
.column-config input {
|
||
min-width: 0;
|
||
width: 9rem;
|
||
padding: 0.15rem;
|
||
font: inherit;
|
||
}
|
||
.card {
|
||
position: relative;
|
||
background: var(--card-bg);
|
||
border: 1px solid var(--card-border);
|
||
border-radius: 6px;
|
||
padding: 0.5rem 1.4rem 0.5rem 0.6rem;
|
||
margin-bottom: 0.5rem;
|
||
font-size: 0.9rem;
|
||
}
|
||
.card-actions { display: flex; flex-wrap: wrap; gap: 0.35rem; margin-top: 0.55rem; }
|
||
.card-actions button {
|
||
border: 1px solid var(--card-actions-border); border-radius: 5px; padding: 0.28rem 0.45rem;
|
||
background: var(--card-actions-bg); color: var(--card-actions-text); cursor: pointer; font-size: 0.72rem;
|
||
}
|
||
.card-actions button.primary { border-color: var(--accent); color: var(--accent-strong); }
|
||
.card-actions button.danger { border-color: var(--card-actions-danger-border); color: var(--card-actions-danger-text); }
|
||
.child-navigation { display: flex; flex-wrap: wrap; gap: 0.25rem; margin-top: 0.35rem; }
|
||
.child-navigation button { font-size: 0.68rem; }
|
||
.card .children {
|
||
margin-top: 0.5rem;
|
||
padding-left: 0.6rem;
|
||
border-left: 2px solid var(--children-border);
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.4rem;
|
||
}
|
||
.card .children .card {
|
||
margin-bottom: 0;
|
||
font-size: 0.82rem;
|
||
}
|
||
.card.family {
|
||
background: hsl(var(--family-hue) 55% var(--family-lightness));
|
||
border-color: hsl(var(--family-hue) 42% var(--family-border-lightness));
|
||
color: var(--text-primary);
|
||
}
|
||
.card.intent { border-left: 3px solid var(--intent-border); }
|
||
.card.research { border-left: 3px solid var(--research-border); }
|
||
.card.idea { border-left: 3px solid var(--idea-border); }
|
||
.card.draft { border-left: 3px solid var(--draft-border); }
|
||
.draft-transcript {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.2rem;
|
||
margin: 0.35rem 0;
|
||
}
|
||
.draft-transcript .status { margin: 0; }
|
||
.card-badges {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 0.25rem;
|
||
margin-top: 0.35rem;
|
||
}
|
||
.card-badge {
|
||
display: inline-block;
|
||
max-width: 100%;
|
||
padding: 0.12rem 0.35rem;
|
||
border: 1px solid var(--badge-border);
|
||
border-radius: 999px;
|
||
background: var(--badge-bg);
|
||
color: var(--badge-text);
|
||
font-size: 0.68rem;
|
||
line-height: 1.35;
|
||
overflow-wrap: anywhere;
|
||
}
|
||
.card-badge.failed { border-color: var(--failed); color: var(--failed-strong); }
|
||
.card-badge.done { border-color: var(--done); color: var(--done-strong); }
|
||
.card-badge.queued { border-color: var(--queued); color: var(--queued-strong); }
|
||
.card .intent-link,
|
||
.card .review-link {
|
||
display: inline-block;
|
||
margin-top: 0.25rem;
|
||
margin-right: 0.6rem;
|
||
font-size: 0.75rem;
|
||
color: var(--accent);
|
||
cursor: pointer;
|
||
text-decoration: underline;
|
||
}
|
||
.card .intent-link:focus-visible,
|
||
.card .review-link:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||
.card .status {
|
||
display: block;
|
||
margin-top: 0.25rem;
|
||
font-size: 0.75rem;
|
||
color: var(--status-text);
|
||
}
|
||
.card .status.failed { color: var(--failed); }
|
||
.card .status.needs-human { color: var(--queued-status); font-weight: 600; }
|
||
.card .status.done { color: var(--done); }
|
||
.card .status.queued { color: var(--queued-status); }
|
||
.card .source-missing { color: var(--source-missing); }
|
||
.card .commit-hash {
|
||
display: block;
|
||
margin-top: 0.25rem;
|
||
font: 0.72rem/1.3 ui-monospace, monospace;
|
||
color: var(--muted-text);
|
||
}
|
||
.card button.delete {
|
||
position: absolute;
|
||
top: 0.3rem;
|
||
right: 0.3rem;
|
||
border: none;
|
||
background: none;
|
||
color: var(--muted-text);
|
||
cursor: pointer;
|
||
font-size: 0.9rem;
|
||
line-height: 1;
|
||
}
|
||
.add-idea {
|
||
display: block;
|
||
width: 100%;
|
||
margin-top: 0.4rem;
|
||
padding: 0.35rem;
|
||
border: 1px dashed var(--badge-border);
|
||
border-radius: 6px;
|
||
background: none;
|
||
cursor: pointer;
|
||
color: var(--config-label);
|
||
font-size: 0.85rem;
|
||
}
|
||
dialog {
|
||
width: min(52rem, calc(100vw - 2rem));
|
||
max-height: calc(100vh - 2rem);
|
||
border: 0;
|
||
border-radius: 8px;
|
||
padding: 0;
|
||
color: inherit;
|
||
background: var(--dialog-bg);
|
||
box-shadow: 0 12px 40px var(--dialog-shadow);
|
||
}
|
||
dialog::backdrop { background: var(--dialog-backdrop); }
|
||
.review-header {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
gap: 1rem;
|
||
padding: 1rem 1rem 0.75rem;
|
||
border-bottom: 1px solid var(--review-border);
|
||
}
|
||
.review-header h2 { margin: 0; font-size: 1.1rem; }
|
||
.review-path { margin: 0.25rem 0 0; color: var(--review-path-text); font-size: 0.8rem; }
|
||
.review-close { font: inherit; cursor: pointer; }
|
||
.review-content {
|
||
margin: 0;
|
||
padding: 1rem;
|
||
overflow: auto;
|
||
white-space: pre-wrap;
|
||
overflow-wrap: anywhere;
|
||
font: 0.88rem/1.45 ui-monospace, monospace;
|
||
}
|
||
header .toolbar { display:flex; align-items:center; gap:.7rem; flex-wrap:wrap; margin-top:.8rem; }
|
||
button, input, select { font:inherit; }
|
||
button { cursor:pointer; }
|
||
button:focus-visible, [role=button]:focus-visible, input:focus-visible, select:focus-visible { outline:3px solid var(--accent); outline-offset:3px; }
|
||
.toolbar input { flex:1; min-width:150px; padding:.55rem; border-radius:6px; border:1px solid var(--card-border); }
|
||
.toolbar button, .toolbar select { padding:.5rem .7rem; border-radius:6px; }
|
||
#connection { margin:.5rem 0 0; font-size:.8rem; }
|
||
#connection.stale { color:var(--failed); font-weight:600; }
|
||
#summary { display:flex; gap:.6rem; flex-wrap:wrap; margin:.7rem 0 0; font-size:.85rem; }
|
||
#summary button { background:transparent; color:inherit; border:1px solid var(--config-label); border-radius:6px; padding:.35rem .6rem; }
|
||
main { grid-template-columns:repeat(auto-fit,minmax(280px,1fr)); overflow:visible; }
|
||
.column { min-width:0; }
|
||
.column h2 { display:flex; justify-content:space-between; }
|
||
.card { padding:.9rem; margin-bottom:.8rem; overflow-wrap:anywhere; }
|
||
.card-title { display:block; font-size:.96rem; font-weight:600; line-height:1.4; }
|
||
.card-summary { margin:.6rem 0; font-size:.82rem; line-height:1.45; }
|
||
.card-state { display:block; font-weight:600; color:var(--accent-strong); }
|
||
.card-state.attention { color:var(--failed-strong); }
|
||
.card-state.done { color:var(--done-strong); }
|
||
.card-reason { display:-webkit-box; -webkit-line-clamp:3; -webkit-box-orient:vertical; overflow:hidden; margin:.3rem 0; }
|
||
.card-meta { color:var(--status-text); font-size:.75rem; margin:.3rem 0; }
|
||
.card-actions { display:flex; flex-wrap:wrap; gap:.35rem; margin-top:.7rem; }
|
||
.children { margin-top:.5rem; padding-left:.5rem; }
|
||
details > summary { cursor:pointer; padding:.4rem 0; font-size:.82rem; }
|
||
.empty { color:var(--status-text); font-size:.85rem; padding:.5rem 0; }
|
||
.agent-column { position:static; width:100%; box-shadow:none; }
|
||
#settings-dialog { width:min(650px,95vw); }
|
||
#settings-content { padding:1rem; overflow:auto; }
|
||
#card-dialog { width:min(980px,95vw); }
|
||
.detail-tabs { display:flex; gap:.4rem; padding:.8rem 1rem; flex-wrap:wrap; border-bottom:1px solid var(--review-border); }
|
||
.detail-tabs button { padding:.45rem .65rem; }
|
||
#card-content { padding:1rem; overflow:auto; line-height:1.6; overflow-wrap:anywhere; }
|
||
#card-content h3 { margin:1rem 0 .4rem; }
|
||
#card-content pre { white-space:pre-wrap; font:inherit; }
|
||
#card-content dl { margin:.4rem 0; padding:.6rem; border-left:2px solid var(--card-border); }
|
||
#card-content dt { font-size:.8rem; color:var(--status-text); }
|
||
#card-content dd { margin:0 0 .7rem; }
|
||
.card button { border:1px solid var(--card-actions-border); border-radius:5px; padding:.3rem .5rem; background:var(--card-actions-bg); color:var(--card-actions-text); cursor:pointer; }
|
||
.card-actions details { font-size:.8rem; }
|
||
.card-actions details button { margin:.3rem .3rem 0 0; }
|
||
input::placeholder { color:var(--status-text); opacity:1; }
|
||
main.list-view { display:block; max-width:1050px; margin:auto; }
|
||
@media(max-width:700px) {
|
||
main { display:block; padding:.6rem; }
|
||
header { padding:.8rem; }
|
||
.column { margin-bottom:.8rem; }
|
||
.toolbar input { width:100%; }
|
||
dialog { max-height:94vh; }
|
||
.children { padding-left:.25rem; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header>
|
||
<h1>Intent-Board</h1>
|
||
<p>Goals, evidence, and the next action · approved changes deploy automatically</p>
|
||
<div class="toolbar">
|
||
<input id="search" type="search" placeholder="Search goals, results, and blockers" aria-label="Search cards">
|
||
<select id="view" aria-label="Board view"><option value="all">Active goals</option><option value="attention">Needs my attention</option><option value="running">Running</option><option value="ready">Ready</option><option value="deploy">Deploying</option><option value="done">Done</option><option value="archived">Archive</option></select>
|
||
<button id="settings-open" type="button">Agent settings</button>
|
||
<button id="refresh" type="button">Refresh</button>
|
||
</div>
|
||
<div id="summary" aria-label="Goal counts"></div>
|
||
<p id="connection" role="status" aria-live="polite">Connecting…</p>
|
||
</header>
|
||
<main id="board"></main>
|
||
<dialog id="settings-dialog" aria-labelledby="settings-title">
|
||
<div class="review-header"><h2 id="settings-title">Agent settings</h2><form method="dialog"><button>Close</button></form></div>
|
||
<div id="settings-content"></div>
|
||
<details style="padding:1rem"><summary>Pipeline test tools</summary> <button id="smoke-test-direct" class="smoke-test-button" type="button">+ Smoke-Test (klein)</button>
|
||
<button id="smoke-test-split" class="smoke-test-button" type="button">+ Smoke-Test (groß)</button>
|
||
<button id="smoke-test-remove-direct" class="smoke-test-button" type="button">+ Deinstall-Test (klein)</button>
|
||
<button id="smoke-test-remove-split" class="smoke-test-button" type="button">+ Deinstall-Test (groß)</button>
|
||
<button id="smoke-test-cleanup" class="smoke-test-button danger" type="button">🧹 Smoke-Tests entfernen</button>
|
||
</details>
|
||
</dialog>
|
||
<dialog id="card-dialog" aria-labelledby="card-title">
|
||
<div class="review-header"><div><h2 id="card-title"></h2><p id="card-id" class="review-path"></p></div><form method="dialog"><button>Close</button></form></div>
|
||
<nav id="card-tabs" class="detail-tabs" aria-label="Card details"></nav>
|
||
<div id="card-content"></div>
|
||
</dialog>
|
||
<dialog id="review-dialog" aria-labelledby="review-title">
|
||
<div class="review-header">
|
||
<div>
|
||
<h2 id="review-title">Review-Bericht</h2>
|
||
<p id="review-path" class="review-path"></p>
|
||
</div>
|
||
<form method="dialog">
|
||
<button class="review-close" aria-label="Review-Bericht schließen">Schließen</button>
|
||
</form>
|
||
</div>
|
||
<pre id="review-content" class="review-content"></pre>
|
||
</dialog>
|
||
<dialog id="intent-dialog" aria-labelledby="intent-title">
|
||
<div class="review-header">
|
||
<div>
|
||
<h2 id="intent-title">Intent</h2>
|
||
<p id="intent-path" class="review-path"></p>
|
||
</div>
|
||
<form method="dialog">
|
||
<button class="review-close" aria-label="Intent schließen">Schließen</button>
|
||
</form>
|
||
</div>
|
||
<pre id="intent-content" class="review-content"></pre>
|
||
</dialog>
|
||
<dialog id="metrics-dialog" aria-labelledby="metrics-title">
|
||
<div class="review-header">
|
||
<div>
|
||
<h2 id="metrics-title">Laufmetriken</h2>
|
||
<p id="metrics-path" class="review-path"></p>
|
||
</div>
|
||
<form method="dialog">
|
||
<button class="review-close" aria-label="Laufmetriken schließen">Schließen</button>
|
||
</form>
|
||
</div>
|
||
<pre id="metrics-content" class="review-content"></pre>
|
||
</dialog>
|
||
<script>
|
||
const LIFECYCLE_GROUPS = [
|
||
{ id: "attention", label: "Needs attention" },
|
||
{ id: "running", label: "Running" },
|
||
{ id: "ready", label: "Ready" },
|
||
{ id: "backlog", label: "Backlog" },
|
||
{ id: "deploy", label: "Deploying" },
|
||
{ id: "done", label: "Done" },
|
||
{ id: "archived", label: "Archived" },
|
||
];
|
||
|
||
const JOB_COLUMNS = new Set(["operator_angefordert", "in_umsetzung", "in_umsetzung_leicht", "review", "recherche_laeuft", "klaerung_reparatur"]);
|
||
const RUNTIME_OPTIONS = [
|
||
["codex", "Codex"],
|
||
["claude", "Claude"],
|
||
["openrouter", "OpenRouter"],
|
||
];
|
||
// Kept in sync by hand with orchestration.py's MODEL_POOL, which is the
|
||
// server-side allowlist an Operator-authored escalation step's model is
|
||
// validated against (docs/intent/board-operator-escalation-ladder.md).
|
||
// This list additionally carries a "Default" ("") entry per runtime that
|
||
// MODEL_POOL deliberately does not -- an escalation step always names a
|
||
// concrete model, never "use the column default".
|
||
const MODEL_OPTIONS = {
|
||
codex: [["", "Default"], ["gpt-6-astra", "Astra"], ["gpt-5.6-sol", "Sol"], ["gpt-5.6-terra", "Terra"], ["gpt-5.6-luna", "Luna"]],
|
||
claude: [["", "Default"], ["sonnet", "Sonnet"], ["opus", "Opus"], ["haiku", "Haiku"]],
|
||
openrouter: [
|
||
["", "Default"],
|
||
["moonshotai/kimi-k2-thinking", "moonshotai/kimi-k2-thinking"],
|
||
["deepseek/deepseek-v3.2", "deepseek/deepseek-v3.2"],
|
||
["cohere/north-mini-code:free", "cohere/north-mini-code:free"],
|
||
["nvidia/nemotron-3-super-120b-a12b:free", "nvidia/nemotron-3-super-120b-a12b:free"],
|
||
],
|
||
};
|
||
const REASONING_OPTIONS = [["", "Default"], ["low", "low"], ["medium", "medium"], ["high", "high"]];
|
||
let columnConfig = {};
|
||
|
||
function addOptions(select, options) {
|
||
for (const [value, label] of options) {
|
||
const option = document.createElement("option");
|
||
option.value = value;
|
||
option.textContent = label;
|
||
select.appendChild(option);
|
||
}
|
||
}
|
||
|
||
function modelOptionsForRuntime(runtime) {
|
||
return MODEL_OPTIONS[runtime] || MODEL_OPTIONS.codex;
|
||
}
|
||
|
||
function syncModelOptions(modelSelect, runtime, value) {
|
||
const options = modelOptionsForRuntime(runtime);
|
||
modelSelect.replaceChildren();
|
||
addOptions(modelSelect, options);
|
||
const allowed = new Set(options.map(([optionValue]) => optionValue));
|
||
modelSelect.value = allowed.has(value) ? value : "";
|
||
modelSelect.dataset.savedValue = modelSelect.value;
|
||
}
|
||
|
||
function addColumnConfig(columnEl, phase) {
|
||
if (!JOB_COLUMNS.has(phase)) return;
|
||
const config = columnConfig[phase] || {};
|
||
const runtime = RUNTIME_OPTIONS.some(([value]) => value === config.runtime)
|
||
? config.runtime
|
||
: "codex";
|
||
const controls = document.createElement("div");
|
||
controls.className = "column-config";
|
||
|
||
const fields = [
|
||
["Runtime", "runtime", RUNTIME_OPTIONS, "select"],
|
||
["Modell", "model", modelOptionsForRuntime(runtime), "select"],
|
||
["Reasoning", "reasoning_effort", REASONING_OPTIONS, "select"],
|
||
];
|
||
if (phase === "in_umsetzung" || phase === "in_umsetzung_leicht" || phase === "klaerung_reparatur") {
|
||
fields.push(["Iterationen", "iteration_limit", null, "number"]);
|
||
}
|
||
|
||
for (const [labelText, field, options, type] of fields) {
|
||
const label = document.createElement("label");
|
||
label.textContent = `${labelText}: `;
|
||
const input = document.createElement(type === "select" ? "select" : "input");
|
||
input.dataset.field = field;
|
||
input.dataset.savedValue = String(field === "runtime" ? runtime : config[field] ?? "");
|
||
if (type === "select") addOptions(input, options);
|
||
else {
|
||
input.type = "number";
|
||
input.min = "5";
|
||
input.max = "40";
|
||
}
|
||
input.value = String(field === "runtime" ? runtime : config[field] ?? "");
|
||
if (field === "model") {
|
||
syncModelOptions(input, runtime, input.value);
|
||
}
|
||
input.addEventListener("change", () => {
|
||
if (field === "runtime") {
|
||
const modelSelect = controls.querySelector('select[data-field="model"]');
|
||
syncModelOptions(modelSelect, input.value, "");
|
||
columnConfig[phase] = { ...(columnConfig[phase] || {}), runtime: input.value, model: "" };
|
||
saveColumnConfig(phase, input);
|
||
saveColumnConfig(phase, modelSelect);
|
||
return;
|
||
}
|
||
saveColumnConfig(phase, input);
|
||
});
|
||
label.appendChild(input);
|
||
controls.appendChild(label);
|
||
}
|
||
columnEl.appendChild(controls);
|
||
}
|
||
|
||
// Applies one runtime to every job-triggering phase at once by driving each
|
||
// phase's own runtime <select> and its existing "change" listener (added in
|
||
// addColumnConfig) -- that listener already resets the model to "Default"
|
||
// and persists via saveColumnConfig, so this never duplicates that logic.
|
||
function addGlobalRuntimeControl(columnEl) {
|
||
const wrapper = document.createElement("div");
|
||
wrapper.className = "column-config";
|
||
const label = document.createElement("label");
|
||
label.textContent = "Alle Agents: Runtime: ";
|
||
const select = document.createElement("select");
|
||
addOptions(select, [["", "-- wählen --"], ...RUNTIME_OPTIONS]);
|
||
select.addEventListener("change", () => {
|
||
const runtime = select.value;
|
||
if (!runtime) return;
|
||
for (const runtimeSelect of columnEl.querySelectorAll('select[data-field="runtime"]')) {
|
||
if (runtimeSelect.value !== runtime) {
|
||
runtimeSelect.value = runtime;
|
||
runtimeSelect.dispatchEvent(new Event("change"));
|
||
}
|
||
}
|
||
select.value = "";
|
||
});
|
||
label.appendChild(select);
|
||
wrapper.appendChild(label);
|
||
columnEl.appendChild(wrapper);
|
||
}
|
||
|
||
// Kept in sync by hand with AGENT_ROLE_FILES in trigger.py -- which role(s)
|
||
// a phase's job actually runs. klaerung_reparatur covers two distinct roles
|
||
// depending on the requested task_type (Diagnose uses Inspector, Repair
|
||
// uses Fixer), so it lists both.
|
||
const PHASE_AGENT_ROLES = {
|
||
operator_angefordert: [["operator", "Operator"]],
|
||
in_umsetzung: [["builder", "Builder"]],
|
||
in_umsetzung_leicht: [["builder", "Builder"]],
|
||
review: [["reviewer", "Reviewer"]],
|
||
recherche_laeuft: [["researcher", "Researcher"]],
|
||
klaerung_reparatur: [["inspector", "Inspector"], ["fixer", "Fixer"]],
|
||
};
|
||
|
||
// Same dialog + fetch-on-open mechanism as showIntent()/showReview() above,
|
||
// just pointed at a fixed agents/<role>.md file instead of a card's intent.
|
||
async function showAgentPrompt(role, label) {
|
||
const dialog = document.getElementById("intent-dialog");
|
||
const title = document.getElementById("intent-title");
|
||
const path = document.getElementById("intent-path");
|
||
const content = document.getElementById("intent-content");
|
||
title.textContent = `Agent-Prompt: ${label}`;
|
||
path.textContent = "Wird geladen…";
|
||
content.textContent = "";
|
||
dialog.showModal();
|
||
|
||
try {
|
||
const res = await fetch(`/api/board/agents/${encodeURIComponent(role)}`);
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
const prompt = await res.json();
|
||
path.textContent = prompt.path;
|
||
content.textContent = prompt.content;
|
||
} catch (error) {
|
||
path.textContent = "Prompt konnte nicht geladen werden.";
|
||
content.textContent = error.message;
|
||
}
|
||
}
|
||
|
||
function appendAgentPromptLinks(columnEl, phase) {
|
||
const roles = PHASE_AGENT_ROLES[phase];
|
||
if (!roles) return;
|
||
const links = document.createElement("div");
|
||
links.className = "child-navigation";
|
||
for (const [role, label] of roles) {
|
||
const link = document.createElement("span");
|
||
link.className = "intent-link";
|
||
link.textContent = label;
|
||
link.tabIndex = 0;
|
||
link.setAttribute("role", "button");
|
||
link.setAttribute("aria-haspopup", "dialog");
|
||
link.setAttribute("aria-label", `${label}-Rollenprompt öffnen`);
|
||
link.addEventListener("click", () => showAgentPrompt(role, label));
|
||
link.addEventListener("keydown", (event) => {
|
||
if (event.key === "Enter" || event.key === " ") {
|
||
event.preventDefault();
|
||
showAgentPrompt(role, label);
|
||
}
|
||
});
|
||
links.appendChild(link);
|
||
}
|
||
columnEl.appendChild(links);
|
||
}
|
||
|
||
// A dedicated, always-visible column instead of the earlier collapsible
|
||
// <details> panel tucked inside "Ready" -- that panel re-rendered closed on
|
||
// every 5s refresh (render() clears and rebuilds the whole board), so an
|
||
// operator had to keep re-opening it. Fixed on the left, not a lifecycle
|
||
// group: it never holds cards and is not a PATCH target.
|
||
function buildAgentColumn() {
|
||
const columnEl = document.createElement("section");
|
||
columnEl.className = "column agent-column";
|
||
const heading = document.createElement("h2");
|
||
heading.textContent = "Agents";
|
||
columnEl.appendChild(heading);
|
||
addGlobalRuntimeControl(columnEl);
|
||
for (const phase of JOB_COLUMNS) {
|
||
const phaseHeading = document.createElement("h3");
|
||
phaseHeading.textContent = phase;
|
||
columnEl.appendChild(phaseHeading);
|
||
appendAgentPromptLinks(columnEl, phase);
|
||
addColumnConfig(columnEl, phase);
|
||
}
|
||
return columnEl;
|
||
}
|
||
|
||
async function saveColumnConfig(column, input) {
|
||
const field = input.dataset.field;
|
||
const previous = input.dataset.savedValue;
|
||
const value = input.type === "number" ? Number(input.value) : input.value;
|
||
input.disabled = true;
|
||
try {
|
||
const res = await fetch(`/api/board/column-config/${encodeURIComponent(column)}`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ [field]: value }),
|
||
});
|
||
if (!res.ok) throw new Error("column config update failed");
|
||
const saved = await res.json();
|
||
columnConfig[column] = { ...(columnConfig[column] || {}), ...saved };
|
||
input.dataset.savedValue = String(saved[field]);
|
||
} catch (_error) {
|
||
input.value = previous;
|
||
window.alert("Spaltenkonfiguration konnte nicht gespeichert werden.");
|
||
} finally {
|
||
input.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function fetchBoard() {
|
||
const res = await fetch("/api/board");
|
||
if (!res.ok) throw new Error("board fetch failed");
|
||
const data = await res.json();
|
||
columnConfig = data.column_config || {};
|
||
return data.cards || [];
|
||
}
|
||
|
||
async function patchPhase(id, phase, taskType = null) {
|
||
const res = await fetch(`/api/board/cards/${encodeURIComponent(id)}`, {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ phase, ...(taskType ? { task_type: taskType } : {}) }),
|
||
});
|
||
if (!res.ok) {
|
||
const body = await res.json().catch(() => ({}));
|
||
throw new Error(body.error || `HTTP ${res.status}`);
|
||
}
|
||
}
|
||
|
||
async function applyRouting(id) {
|
||
const res = await fetch(`/api/board/cards/${encodeURIComponent(id)}/apply-routing`, { method: "POST" });
|
||
if (!res.ok) {
|
||
const body = await res.json().catch(() => ({}));
|
||
throw new Error(body.error || `HTTP ${res.status}`);
|
||
}
|
||
}
|
||
|
||
async function retryReview(id) {
|
||
const res = await fetch(`/api/board/cards/${encodeURIComponent(id)}/retry-review`, { method: "POST" });
|
||
if (!res.ok) {
|
||
const body = await res.json().catch(() => ({}));
|
||
throw new Error(body.error || `HTTP ${res.status}`);
|
||
}
|
||
}
|
||
|
||
async function submitFixerAnswer(id, answer) {
|
||
const res = await fetch(`/api/board/cards/${encodeURIComponent(id)}/fixer-answer`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ answer }),
|
||
});
|
||
if (!res.ok) {
|
||
const body = await res.json().catch(() => ({}));
|
||
throw new Error(body.error || `HTTP ${res.status}`);
|
||
}
|
||
}
|
||
|
||
async function createIdea(title) {
|
||
const res = await fetch("/api/board/ideas", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ title }),
|
||
});
|
||
if (!res.ok) throw new Error("create failed");
|
||
}
|
||
|
||
async function createDraft(topic) {
|
||
const res = await fetch("/api/board/drafts", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ topic }),
|
||
});
|
||
if (!res.ok) {
|
||
const body = await res.json().catch(() => ({}));
|
||
throw new Error(body.error || "create failed");
|
||
}
|
||
}
|
||
|
||
async function answerDraft(id, answer) {
|
||
const res = await fetch(`/api/board/cards/${encodeURIComponent(id)}/draft-answer`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ answer }),
|
||
});
|
||
if (!res.ok) {
|
||
const body = await res.json().catch(() => ({}));
|
||
throw new Error(body.error || `HTTP ${res.status}`);
|
||
}
|
||
}
|
||
|
||
async function retryDraft(id) {
|
||
const res = await fetch(`/api/board/cards/${encodeURIComponent(id)}/draft-retry`, { method: "POST" });
|
||
if (!res.ok) {
|
||
const body = await res.json().catch(() => ({}));
|
||
throw new Error(body.error || `HTTP ${res.status}`);
|
||
}
|
||
}
|
||
|
||
async function createSmokeTest(kind) {
|
||
const res = await fetch(`/api/board/smoke-tests/${kind}`, { method: "POST" });
|
||
if (!res.ok) throw new Error("smoke test create failed");
|
||
}
|
||
|
||
async function deleteSmokeTestCards() {
|
||
const res = await fetch("/api/board/smoke-tests", { method: "DELETE" });
|
||
if (!res.ok) {
|
||
const body = await res.json().catch(() => ({}));
|
||
throw new Error(body.error || `HTTP ${res.status}`);
|
||
}
|
||
return res.json();
|
||
}
|
||
|
||
async function deleteCard(id) {
|
||
const response=await fetch(`/api/board/cards/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||
if(!response.ok)throw new Error((await response.json()).error || "HTTP "+response.status);
|
||
}
|
||
|
||
async function showReview(card) {
|
||
const dialog = document.getElementById("review-dialog");
|
||
const title = document.getElementById("review-title");
|
||
const path = document.getElementById("review-path");
|
||
const content = document.getElementById("review-content");
|
||
title.textContent = `Review: ${card.title}`;
|
||
path.textContent = "Bericht wird geladen…";
|
||
content.textContent = "";
|
||
dialog.showModal();
|
||
|
||
try {
|
||
const res = await fetch(`/api/board/cards/${encodeURIComponent(card.id)}/review`);
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
const report = await res.json();
|
||
path.textContent = report.path;
|
||
content.textContent = report.content;
|
||
} catch (error) {
|
||
path.textContent = "Bericht konnte nicht geladen werden.";
|
||
content.textContent = error.message;
|
||
}
|
||
}
|
||
|
||
async function showResearchReport(card) {
|
||
const dialog = document.getElementById("review-dialog");
|
||
const title = document.getElementById("review-title");
|
||
const path = document.getElementById("review-path");
|
||
const content = document.getElementById("review-content");
|
||
title.textContent = `Recherche-Bericht: ${card.title}`;
|
||
path.textContent = "Bericht wird geladen…";
|
||
content.textContent = "";
|
||
dialog.showModal();
|
||
|
||
try {
|
||
const res = await fetch(`/api/board/cards/${encodeURIComponent(card.id)}/research`);
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
const report = await res.json();
|
||
path.textContent = report.path;
|
||
content.textContent = report.content;
|
||
} catch (error) {
|
||
path.textContent = "Bericht konnte nicht geladen werden.";
|
||
content.textContent = error.message;
|
||
}
|
||
}
|
||
|
||
function showBuilderReport(card) {
|
||
// Unlike showReview(), the text is already part of the /api/board
|
||
// payload (see merged_board()'s builder_report field) -- no commit
|
||
// means no docs/review/*.md file to fetch, so this reuses the review
|
||
// dialog markup but fills it in directly instead of via a second request.
|
||
const dialog = document.getElementById("review-dialog");
|
||
const title = document.getElementById("review-title");
|
||
const path = document.getElementById("review-path");
|
||
const content = document.getElementById("review-content");
|
||
title.textContent = `Builder-Bericht: ${card.title}`;
|
||
path.textContent = "Kein Commit -- Antwort des Builders";
|
||
content.textContent = card.builder_report || "";
|
||
dialog.showModal();
|
||
}
|
||
|
||
async function showIntent(card) {
|
||
const dialog = document.getElementById("intent-dialog");
|
||
const title = document.getElementById("intent-title");
|
||
const path = document.getElementById("intent-path");
|
||
const content = document.getElementById("intent-content");
|
||
title.textContent = `Intent: ${card.title}`;
|
||
path.textContent = "Intent wird geladen…";
|
||
content.textContent = "";
|
||
dialog.showModal();
|
||
|
||
try {
|
||
const res = await fetch(`/api/board/cards/${encodeURIComponent(card.id)}/intent`);
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
const intent = await res.json();
|
||
path.textContent = intent.path;
|
||
content.textContent = intent.content;
|
||
} catch (error) {
|
||
path.textContent = "Intent konnte nicht geladen werden.";
|
||
content.textContent = error.message;
|
||
}
|
||
}
|
||
|
||
function statusLabel(card, children = []) {
|
||
if (card.display) return card.display.label;
|
||
if (!["intent", "research"].includes(card.source) || !card.last_run_status) return "";
|
||
if (!card.parent_id && children.length) {
|
||
if (children.some((child) => ["queued", "running", "queued_builder"].includes(child.last_run_status)
|
||
|| ["in_umsetzung", "in_umsetzung_leicht", "review"].includes(child.phase))) return "Kinder laufen";
|
||
if (children.some((child) => child.last_run_status === "failed" || child.phase === "planung_mit_feedback")) return "Durch Kind blockiert";
|
||
if (children.every((child) => ["bereit_fuer_deployment", "deployed"].includes(child.phase))) return "Kinder abgeschlossen";
|
||
}
|
||
const map = {
|
||
queued: "läuft…",
|
||
done: "abgeschlossen",
|
||
failed: "fehlgeschlagen",
|
||
restore_failed: "Quelldatei konnte nicht wiederhergestellt werden",
|
||
};
|
||
return map[card.last_run_status] || card.last_run_status;
|
||
}
|
||
|
||
function showFailure(card) {
|
||
const dialog = document.getElementById("intent-dialog");
|
||
document.getElementById("intent-title").textContent = `Fehlerdetails: ${card.title}`;
|
||
document.getElementById("intent-path").textContent = card.failure_stage || "Worker";
|
||
document.getElementById("intent-content").textContent = card.failure_detail || "Unbekannter Fehler";
|
||
dialog.showModal();
|
||
}
|
||
|
||
function uniqueStrings(value) {
|
||
if (!Array.isArray(value)) return [];
|
||
return [...new Set(value.filter((item) => typeof item === "string" && item.trim()))];
|
||
}
|
||
|
||
function cardFamilyId(card) {
|
||
if (typeof card.parent_id === "string" && card.parent_id) return card.parent_id;
|
||
if (uniqueStrings(card.depends_on).length || uniqueStrings(card.child_slugs).length) return card.id;
|
||
return "";
|
||
}
|
||
|
||
function familyHue(familyId) {
|
||
let hash = 2166136261;
|
||
for (const character of familyId) {
|
||
hash ^= character.charCodeAt(0);
|
||
hash = Math.imul(hash, 16777619);
|
||
}
|
||
return Math.abs(hash >>> 0) % 360;
|
||
}
|
||
|
||
function addBadge(container, label, value, className = "") {
|
||
if (value === undefined || value === null || value === "") return;
|
||
const badge = document.createElement("span");
|
||
badge.className = `card-badge ${className}`.trim();
|
||
badge.textContent = `${label}: ${value}`;
|
||
container.appendChild(badge);
|
||
}
|
||
|
||
function profileLabel(runtime, model, reasoning) {
|
||
const parts = [runtime, model, reasoning ? `Reasoning ${reasoning}` : ""]
|
||
.filter((value) => typeof value === "string" && value.trim());
|
||
return parts.join(" / ");
|
||
}
|
||
|
||
// Always visible regardless of family/children -- appendOrchestrationBadges
|
||
// bails out entirely for a card with no parent/children, which is exactly
|
||
// the "operator_klaerung_noetig" case (Operator decided needs_human, or
|
||
// crashed, before ever producing children), so that badge row is not a
|
||
// reliable place to surface why this card stopped.
|
||
function appendOperatorReason(el, card) {
|
||
if (!card.operator_reason) return;
|
||
const reason = document.createElement("p");
|
||
reason.className = card.operator_decision === "needs_human" ? "status needs-human" : "status";
|
||
reason.textContent = `Operator (${card.operator_decision}): ${card.operator_reason}`;
|
||
el.appendChild(reason);
|
||
if (card.operator_decision === "needs_human" && card.human_action) {
|
||
const action = document.createElement("p");
|
||
action.className = "status needs-human";
|
||
action.textContent = `Nächster Schritt: ${card.human_action}`;
|
||
el.appendChild(action);
|
||
}
|
||
}
|
||
|
||
// docs/intent/board-operator-escalation-ladder.md: auto_retry_attempts now
|
||
// counts steps of a per-card escalation ladder (0-2 Operator-authored
|
||
// steps, or a single synthetic fallback step for a card that never went
|
||
// through the Operator -- see orchestration.escalation_ladder_for()) rather
|
||
// than a flat 0/1 flag, so a bare "queued_builder" status no longer says
|
||
// whether this is attempt 1 or a later, differently-configured one.
|
||
function appendEscalationBadge(el, card) {
|
||
if (!card.auto_retry_attempts) return;
|
||
const ladderLength = (card.escalation_attempts && card.escalation_attempts.length) || 1;
|
||
const totalAttempts = 1 + ladderLength;
|
||
const current = Math.min(1 + card.auto_retry_attempts, totalAttempts);
|
||
const badge = document.createElement("p");
|
||
const exhausted = card.last_run_status === "limit_reached" && card.auto_retry_attempts >= ladderLength;
|
||
const running = ["queued", "running", "queued_builder"].includes(card.last_run_status);
|
||
badge.className = `status ${exhausted ? "failed" : running ? "queued" : "done"}`;
|
||
const profile = card.model ? ` (${card.model}, ${card.iteration_limit ?? "?"} Iterationen)` : "";
|
||
const outcome = exhausted ? "terminal erschöpft" : running ? "Eskalation läuft" : "Eskalation aufgefangen";
|
||
badge.textContent = `${outcome}: Versuch ${current} von ${totalAttempts}${profile}`;
|
||
el.appendChild(badge);
|
||
const configured = uniqueStrings((card.escalation_attempts || []).map((step) =>
|
||
step && `${step.model || "Default"}/${step.reasoning_effort || "Default"}/${step.iteration_limit || "?"}`
|
||
));
|
||
if (configured.length) {
|
||
const history = document.createElement("p");
|
||
history.className = "status";
|
||
history.textContent = `Eskalationsplan: ${configured.join(" → ")}`;
|
||
el.appendChild(history);
|
||
}
|
||
}
|
||
|
||
function lifecycleGroup(card) {
|
||
if (card.display) return card.display.group;
|
||
if (card.phase === "archiviert") return "archived";
|
||
if (["bereit_fuer_deployment"].includes(card.phase)) return "deploy";
|
||
if (["deployed", "recherche_fertig", "aufgeteilt"].includes(card.phase)) return "done";
|
||
if (card.source === "idea" || card.phase === "idea") return "backlog";
|
||
const attentionPhases = new Set([
|
||
"durch_kinder_blockiert", "operator_klaerung_noetig", "planung_mit_feedback",
|
||
"recherche_fehlgeschlagen", "fixer_rueckfrage", "fixer_fehlgeschlagen",
|
||
"inspector_rueckfrage", "inspector_fehlgeschlagen",
|
||
"entwurf_frage", "entwurf_fehlgeschlagen",
|
||
]);
|
||
if (attentionPhases.has(card.phase) || ["failed", "no_changes", "check_failed", "deploy_failed", "limit_reached"].includes(card.last_run_status)) {
|
||
return "attention";
|
||
}
|
||
if (["geplant", "recherche_angefordert"].includes(card.phase)) return "ready";
|
||
return "running";
|
||
}
|
||
|
||
function appendCardActions(el, card) {
|
||
const actions = Array.isArray(card.available_actions) ? card.available_actions : [];
|
||
if (!actions.length) return;
|
||
const container = document.createElement("div");
|
||
container.className = "card-actions";
|
||
const more = document.createElement("details");
|
||
const moreLabel = document.createElement("summary");moreLabel.textContent = "More actions";more.appendChild(moreLabel);
|
||
for (const [index, action] of actions.entries()) {
|
||
const button = document.createElement("button");
|
||
button.type = "button";
|
||
button.textContent = action.label;
|
||
if (["operator", "builder", "research", "diagnose"].includes(action.id)) button.classList.add("primary");
|
||
if (action.id === "repair") button.classList.add("danger");
|
||
button.addEventListener("click", async (event) => {
|
||
event.stopPropagation();
|
||
button.disabled = true;
|
||
try {
|
||
if (action.endpoint) {
|
||
const response = await fetch("/api/board/cards/"+encodeURIComponent(card.id)+"/"+action.endpoint, {method:"POST"});
|
||
if (!response.ok) throw new Error((await response.json()).error || "HTTP "+response.status);
|
||
}
|
||
else await patchPhase(card.id, action.phase, action.task_type || null);
|
||
await render();
|
||
} catch (error) {
|
||
window.alert(`${action.label} fehlgeschlagen: ${error.message}`);
|
||
button.disabled = false;
|
||
}
|
||
});
|
||
(index === 0 ? container : more).appendChild(button);
|
||
}
|
||
if(actions.length > 1)container.appendChild(more);
|
||
el.appendChild(container);
|
||
}
|
||
|
||
function appendChildNavigation(el, card) {
|
||
const childIds = uniqueStrings(card.blocking_children).length
|
||
? uniqueStrings(card.blocking_children)
|
||
: uniqueStrings([...(card.depends_on || []), ...(card.child_slugs || [])]);
|
||
if (!childIds.length) return;
|
||
const navigation = document.createElement("div");
|
||
navigation.className = "child-navigation";
|
||
for (const childId of childIds) {
|
||
const button = document.createElement("button");
|
||
button.type = "button";
|
||
button.textContent = `Zum Kind: ${childId}`;
|
||
button.addEventListener("click", () => {
|
||
const child = document.querySelector(`.card[data-id="${CSS.escape(childId)}"]`);
|
||
if (child) { child.scrollIntoView({ behavior: "smooth", block: "center" }); child.focus(); }
|
||
});
|
||
navigation.appendChild(button);
|
||
}
|
||
el.appendChild(navigation);
|
||
}
|
||
|
||
function reviewedSuccessfully(card) {
|
||
return card.review_decision === "bereit_fuer_deployment"
|
||
|| ["bereit_fuer_deployment", "deployed"].includes(card.phase);
|
||
}
|
||
|
||
function orchestrationChildren(card, cards) {
|
||
const declared = new Set([
|
||
...uniqueStrings(card.depends_on),
|
||
...uniqueStrings(card.child_slugs),
|
||
]);
|
||
return cards.filter((candidate) => candidate.parent_id === card.id || declared.has(candidate.id));
|
||
}
|
||
|
||
function progressLabel(card, children) {
|
||
const progress = card.child_progress;
|
||
if (typeof progress === "string" && progress.trim()) return progress;
|
||
if (progress && typeof progress === "object" && !Array.isArray(progress)) {
|
||
const successful = Number(progress.successful);
|
||
const total = Number(progress.total);
|
||
if (Number.isFinite(successful) && Number.isFinite(total)) {
|
||
return `${successful}/${total} Kinder erfolgreich`;
|
||
}
|
||
}
|
||
if (!children.length) return "";
|
||
return `${children.filter(reviewedSuccessfully).length}/${children.length} Kinder erfolgreich`;
|
||
}
|
||
|
||
function appendOrchestrationBadges(el, card, cards) {
|
||
const familyId = cardFamilyId(card);
|
||
if (!familyId) return;
|
||
|
||
el.classList.add("family");
|
||
el.style.setProperty("--family-hue", String(familyHue(familyId)));
|
||
const badges = document.createElement("div");
|
||
badges.className = "card-badges";
|
||
|
||
if (card.parent_id) addBadge(badges, "Parent", card.parent_id);
|
||
else addBadge(badges, "Familie", familyId);
|
||
|
||
const children = orchestrationChildren(card, cards);
|
||
if (!card.parent_id) {
|
||
addBadge(badges, "Fortschritt", progressLabel(card, children));
|
||
const activeWaves = [...new Set(children
|
||
.filter((child) => child.last_run_status === "queued"
|
||
|| ["in_umsetzung", "in_umsetzung_leicht", "review"].includes(child.phase))
|
||
.map((child) => child.wave)
|
||
.filter((wave) => wave !== undefined && wave !== null && wave !== ""))]
|
||
.sort((left, right) => Number(left) - Number(right));
|
||
if (activeWaves.length) addBadge(badges, "Aktive Wellen", activeWaves.map((wave) => `W${wave}`).join(", "));
|
||
|
||
const derivedBlocking = children
|
||
.filter((child) => child.last_run_status === "failed" || child.phase === "planung_mit_feedback")
|
||
.map((child) => child.id);
|
||
const blocking = uniqueStrings(card.blocking_children).length
|
||
? uniqueStrings(card.blocking_children)
|
||
: derivedBlocking;
|
||
if (blocking.length) addBadge(badges, "Blockierende Kinder", blocking.join(", "), "failed");
|
||
}
|
||
|
||
if (card.wave !== undefined && card.wave !== null && card.wave !== "") {
|
||
addBadge(badges, "Welle", `W${card.wave}`);
|
||
}
|
||
const blockedBy = uniqueStrings(card.blocked_by);
|
||
if (card.parent_id) addBadge(badges, "Blockiert durch", blockedBy.length ? blockedBy.join(", ") : "niemanden");
|
||
addBadge(badges, "Empfohlene Lane", card.recommended_lane);
|
||
const recommendedProfile = card.recommended_profile && typeof card.recommended_profile === "object"
|
||
? card.recommended_profile
|
||
: {};
|
||
addBadge(
|
||
badges,
|
||
"Empfohlenes Profil",
|
||
profileLabel(
|
||
card.recommended_runtime || recommendedProfile.runtime,
|
||
card.recommended_model || recommendedProfile.model,
|
||
card.recommended_reasoning_effort || recommendedProfile.reasoning_effort,
|
||
),
|
||
);
|
||
addBadge(badges, "Aktuelle Lane", card.lane);
|
||
addBadge(badges, "Aktuelles Profil", profileLabel(card.runtime, card.model, card.reasoning_effort));
|
||
|
||
const label = statusLabel(card, children) || card.phase;
|
||
addBadge(badges, "Status", label, card.last_run_status);
|
||
el.appendChild(badges);
|
||
if (!card.parent_id) {
|
||
const reviewed = children.filter((child) => child.review_report);
|
||
if (reviewed.length) {
|
||
const links = document.createElement("div");
|
||
links.className = "child-navigation";
|
||
const heading = document.createElement("span");
|
||
heading.textContent = "Familien-Reviews:";
|
||
links.appendChild(heading);
|
||
reviewed.forEach((child) => {
|
||
const button = document.createElement("button");
|
||
button.type = "button";
|
||
button.textContent = child.id;
|
||
button.addEventListener("click", () => showReview(child));
|
||
links.appendChild(button);
|
||
});
|
||
el.appendChild(links);
|
||
}
|
||
}
|
||
}
|
||
|
||
// docs/intent/board-intent-run-metrics-baseline.md: a compact rollup of
|
||
// this card's run_history, present only for the small, explicitly
|
||
// enrolled BOARD_METRICS_SLUGS pilot set -- absent (undefined) for every
|
||
// other card, in which case this renders nothing at all.
|
||
function appendMetricsBadge(el, card) {
|
||
const summary = card.metrics_summary;
|
||
if (!summary || typeof summary !== "object") return;
|
||
const badges = document.createElement("div");
|
||
badges.className = "card-badges";
|
||
addBadge(badges, "Läufe", `${summary.total} gesamt (${summary.board} Board / ${summary.baseline} Baseline)`);
|
||
addBadge(badges, "Erfolgreich", `${summary.successful}/${summary.total}`);
|
||
el.appendChild(badges);
|
||
}
|
||
|
||
function metricNumber(value) {
|
||
return Number.isFinite(Number(value)) ? Number(value).toLocaleString("de-DE") : "–";
|
||
}
|
||
|
||
function metricValue(source, key) {
|
||
if (!source || typeof source !== "object") return null;
|
||
if (source[key] !== undefined) return source[key];
|
||
return source.usage && source.usage[key] !== undefined ? source.usage[key] : null;
|
||
}
|
||
|
||
function formatPipelineMetrics(pipeline) {
|
||
if (!pipeline || typeof pipeline !== "object") return "Für diesen Lauf liegen noch keine Messwerte vor.";
|
||
const totals = pipeline.totals || {};
|
||
const lines = [
|
||
`Pipeline: ${pipeline.pipeline_run_id || "–"}`,
|
||
`Jobs: ${metricNumber(totals.job_count ?? (pipeline.jobs || []).length)}`,
|
||
`Input-Tokens: ${metricNumber(metricValue(totals, "input_tokens"))}`,
|
||
`Davon Cache: ${metricNumber(metricValue(totals, "cached_input_tokens"))}`,
|
||
`Output-Tokens: ${metricNumber(metricValue(totals, "output_tokens"))}`,
|
||
`Reasoning-Tokens: ${metricNumber(metricValue(totals, "reasoning_output_tokens"))}`,
|
||
`Gesamt-Tokens: ${metricNumber(metricValue(totals, "total_tokens"))}`,
|
||
`Reported cost: ${metricNumber(totals.cost_usd)} USD (${totals.cost_jobs ?? "?"}/${totals.job_count ?? "?"} jobs measured)`,
|
||
`Summierte Job-Laufzeit: ${metricNumber(metricValue(totals, "duration_seconds"))} s`,
|
||
"",
|
||
"Phasen:",
|
||
];
|
||
for (const job of pipeline.jobs || []) {
|
||
const actual = job.actual_profile || {};
|
||
const configured = job.configured_profile || {};
|
||
const usage = job.usage || {};
|
||
const timing = job.timing || {};
|
||
const profile = [
|
||
actual.runtime || configured.runtime,
|
||
actual.model || configured.model,
|
||
actual.reasoning_effort || configured.reasoning_effort,
|
||
].filter(Boolean).join(" / ");
|
||
lines.push(
|
||
`- ${job.role || "Job"}: ${metricNumber(usage.total_tokens)} Tokens` +
|
||
` · ${metricNumber(timing.duration_seconds)} s` +
|
||
` · ${profile || "Profil unbekannt"}` +
|
||
` · ${(job.outcome && job.outcome.status) || "läuft"}`
|
||
);
|
||
}
|
||
return lines.join("\n");
|
||
}
|
||
|
||
async function showTelemetry(card) {
|
||
const dialog = document.getElementById("metrics-dialog");
|
||
const path = document.getElementById("metrics-path");
|
||
const content = document.getElementById("metrics-content");
|
||
path.textContent = card.pipeline_run_id || card.id;
|
||
content.textContent = "Lade Messwerte …";
|
||
dialog.showModal();
|
||
try {
|
||
const res = await fetch(`/api/board/cards/${encodeURIComponent(card.id)}/telemetry`);
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
const payload = await res.json();
|
||
content.textContent = formatPipelineMetrics(payload.current_pipeline);
|
||
} catch (error) {
|
||
content.textContent = `Laufmetriken konnten nicht geladen werden: ${error.message}`;
|
||
}
|
||
}
|
||
|
||
// The board is refreshed periodically. Keep an unsent Fixer answer outside
|
||
// the recreated DOM so a refresh cannot erase text the operator is typing.
|
||
const fixerAnswerDrafts = new Map(Object.entries(JSON.parse(sessionStorage.getItem("boardClarificationDrafts") || "{}")));
|
||
const fixerAnswerFocus = {
|
||
cardId: null,
|
||
selectionStart: null,
|
||
selectionEnd: null,
|
||
scrollTop: 0,
|
||
};
|
||
|
||
function rememberFixerAnswerFocus() {
|
||
const active = document.activeElement;
|
||
if (!active || active.tagName !== "TEXTAREA" || !active.dataset.fixerCardId) return;
|
||
fixerAnswerFocus.cardId = active.dataset.fixerCardId;
|
||
fixerAnswerFocus.selectionStart = active.selectionStart;
|
||
fixerAnswerFocus.selectionEnd = active.selectionEnd;
|
||
fixerAnswerFocus.scrollTop = active.scrollTop;
|
||
}
|
||
|
||
function restoreFixerAnswerFocus() {
|
||
if (!fixerAnswerFocus.cardId) return;
|
||
const answer = Array.from(document.querySelectorAll("textarea[data-fixer-card-id]"))
|
||
.find((candidate) => candidate.dataset.fixerCardId === fixerAnswerFocus.cardId);
|
||
if (!answer) return;
|
||
answer.focus({ preventScroll: true });
|
||
try {
|
||
answer.setSelectionRange(fixerAnswerFocus.selectionStart, fixerAnswerFocus.selectionEnd);
|
||
} catch (_error) {
|
||
// A changed draft can make an old selection invalid; retaining focus is
|
||
// still preferable to interrupting the operator's input.
|
||
}
|
||
answer.scrollTop = fixerAnswerFocus.scrollTop;
|
||
}
|
||
|
||
// docs/intent/board-interactive-planner-intake.md: renders the running
|
||
// transcript of an interactive Planner intake dialogue plus whatever the
|
||
// current phase needs next -- a "thinking" status, the latest question
|
||
// (with quick-reply buttons for its suggested options, plus a free-text
|
||
// answer), or a failure with a retry button. Refresh no longer steals
|
||
// focus out of the free-text answer mid-keystroke -- see
|
||
// boardEditInProgress() below, a general fix, not specific to this field.
|
||
function appendDraftDialogue(el, card) {
|
||
const conversation = Array.isArray(card.draft_conversation) ? card.draft_conversation : [];
|
||
const transcript = document.createElement("div");
|
||
transcript.className = "draft-transcript";
|
||
for (const turn of conversation) {
|
||
const line = document.createElement("p");
|
||
line.className = "status";
|
||
line.textContent = `${turn.role === "user" ? "Du" : "Planner"}: ${turn.text || ""}`;
|
||
transcript.appendChild(line);
|
||
}
|
||
el.appendChild(transcript);
|
||
|
||
if (card.phase === "entwurf_laeuft") {
|
||
const thinking = document.createElement("p");
|
||
thinking.className = "status queued";
|
||
thinking.textContent = "Planner denkt nach…";
|
||
el.appendChild(thinking);
|
||
return;
|
||
}
|
||
|
||
if (card.phase === "entwurf_frage") {
|
||
const lastTurn = conversation[conversation.length - 1] || {};
|
||
const options = Array.isArray(lastTurn.options) ? lastTurn.options : [];
|
||
if (options.length) {
|
||
const quickReplies = document.createElement("div");
|
||
quickReplies.className = "card-actions";
|
||
for (const option of options) {
|
||
const button = document.createElement("button");
|
||
button.type = "button";
|
||
button.textContent = option;
|
||
button.addEventListener("click", async (event) => {
|
||
event.stopPropagation();
|
||
button.disabled = true;
|
||
try {
|
||
await answerDraft(card.id, option);
|
||
await render();
|
||
} catch (error) {
|
||
window.alert(`Antwort fehlgeschlagen: ${error.message}`);
|
||
button.disabled = false;
|
||
}
|
||
});
|
||
quickReplies.appendChild(button);
|
||
}
|
||
el.appendChild(quickReplies);
|
||
}
|
||
const answer = document.createElement("textarea");
|
||
answer.rows = 2;
|
||
answer.maxLength = 4000;
|
||
answer.placeholder = "Eigene Antwort";
|
||
answer.addEventListener("mousedown", (event) => event.stopPropagation());
|
||
el.appendChild(answer);
|
||
const send = document.createElement("button");
|
||
send.type = "button";
|
||
send.textContent = "Antwort senden";
|
||
send.addEventListener("click", async (event) => {
|
||
event.stopPropagation();
|
||
send.disabled = true;
|
||
try {
|
||
await answerDraft(card.id, answer.value);
|
||
await render();
|
||
} catch (error) {
|
||
window.alert(`Antwort fehlgeschlagen: ${error.message}`);
|
||
send.disabled = false;
|
||
}
|
||
});
|
||
el.appendChild(send);
|
||
return;
|
||
}
|
||
|
||
if (card.phase === "entwurf_fehlgeschlagen") {
|
||
const failure = document.createElement("p");
|
||
failure.className = "status failed";
|
||
failure.textContent = card.failure_detail || "Unbekannter Fehler";
|
||
el.appendChild(failure);
|
||
const retry = document.createElement("button");
|
||
retry.type = "button";
|
||
retry.textContent = "Erneut versuchen";
|
||
retry.addEventListener("click", async (event) => {
|
||
event.stopPropagation();
|
||
retry.disabled = true;
|
||
try {
|
||
await retryDraft(card.id);
|
||
await render();
|
||
} catch (error) {
|
||
window.alert(`Erneuter Versuch fehlgeschlagen: ${error.message}`);
|
||
retry.disabled = false;
|
||
}
|
||
});
|
||
el.appendChild(retry);
|
||
}
|
||
}
|
||
|
||
function renderCard(card, cards, ancestry = new Set()) {
|
||
const el = document.createElement("article");
|
||
el.className = "card " + card.source;
|
||
el.dataset.id = card.id;
|
||
el.tabIndex = -1;
|
||
const title = document.createElement("span");
|
||
title.className = "card-title";
|
||
title.textContent = card.title.replace(/^Intent:\s*/i, "");
|
||
el.appendChild(title);
|
||
const summary = document.createElement("div");
|
||
summary.className = "card-summary";
|
||
const state = document.createElement("span");
|
||
state.className = "card-state " + lifecycleGroup(card);
|
||
state.textContent = card.display?.label || statusLabel(card) || card.phase;
|
||
summary.appendChild(state);
|
||
const reason = document.createElement("p");
|
||
reason.className = "card-reason";
|
||
reason.textContent = card.display?.reason || card.failure_detail || "";
|
||
summary.appendChild(reason);
|
||
const meta = document.createElement("p");
|
||
meta.className = "card-meta";
|
||
const when = card.last_run_at || card.updated_at;
|
||
meta.textContent = [
|
||
card.display?.next_actor && "Next: " + card.display.next_actor,
|
||
when && new Date(when).toLocaleString(),
|
||
card.commit_hash && "Commit " + card.commit_hash.slice(0, 7),
|
||
].filter(Boolean).join(" · ");
|
||
summary.appendChild(meta);
|
||
if (card.parent_id) {
|
||
const parent = document.createElement("p");
|
||
parent.className = "card-meta";
|
||
parent.textContent = "Part of " + card.parent_id;
|
||
summary.appendChild(parent);
|
||
}
|
||
el.appendChild(summary);
|
||
appendOperatorReason(el, card);
|
||
if (card.source_missing) textSection(el, "Source missing", "Restore the intent file before starting another attempt.");
|
||
if (card.source === "draft") appendDraftDialogue(el, card);
|
||
const clarificationQuestion = card.clarification_question || card.fixer_question;
|
||
if (["fixer_rueckfrage", "inspector_rueckfrage"].includes(card.phase) && clarificationQuestion) {
|
||
const question = document.createElement("p");
|
||
question.className = "status";
|
||
question.textContent = `Rückfrage (${card.clarification_task_type || "repair"}): ${clarificationQuestion}`;
|
||
el.appendChild(question);
|
||
const answer = document.createElement("textarea");
|
||
answer.rows = 3;
|
||
answer.maxLength = 4000;
|
||
answer.placeholder = "Your answer";
|
||
answer.dataset.fixerCardId = card.id;
|
||
answer.value = fixerAnswerDrafts.get(card.id) || "";
|
||
answer.addEventListener("input", () => {
|
||
fixerAnswerDrafts.set(card.id, answer.value);
|
||
sessionStorage.setItem("boardClarificationDrafts", JSON.stringify(Object.fromEntries(fixerAnswerDrafts)));
|
||
rememberFixerAnswerFocus();
|
||
});
|
||
answer.addEventListener("focus", rememberFixerAnswerFocus);
|
||
answer.addEventListener("click", rememberFixerAnswerFocus);
|
||
answer.addEventListener("keyup", rememberFixerAnswerFocus);
|
||
answer.addEventListener("mousedown", (event) => event.stopPropagation());
|
||
el.appendChild(answer);
|
||
const send = document.createElement("button");
|
||
send.type = "button";
|
||
send.textContent = "Send answer";
|
||
send.addEventListener("click", async (event) => {
|
||
event.stopPropagation();
|
||
try {
|
||
await submitFixerAnswer(card.id, answer.value);
|
||
fixerAnswerDrafts.delete(card.id);
|
||
sessionStorage.setItem("boardClarificationDrafts", JSON.stringify(Object.fromEntries(fixerAnswerDrafts)));
|
||
if (fixerAnswerFocus.cardId === card.id) fixerAnswerFocus.cardId = null;
|
||
await render();
|
||
} catch (error) {
|
||
window.alert(error.message);
|
||
}
|
||
});
|
||
el.appendChild(send);
|
||
}
|
||
|
||
if (card.routing_pending && card.routing_recommendation) {
|
||
const recommendation = document.createElement("p");
|
||
recommendation.className = "status";
|
||
recommendation.textContent = `Empfehlung: ${card.routing_recommendation} — ${card.routing_reason || ""}`;
|
||
el.appendChild(recommendation);
|
||
const apply = document.createElement("button");
|
||
apply.type = "button";
|
||
apply.textContent = "Empfehlung anwenden";
|
||
apply.addEventListener("click", async (event) => {
|
||
event.stopPropagation();
|
||
try { await applyRouting(card.id); await render(); }
|
||
catch (error) { window.alert(error.message); }
|
||
});
|
||
el.appendChild(apply);
|
||
}
|
||
|
||
|
||
const detail = document.createElement("button");
|
||
detail.type = "button";
|
||
detail.textContent = "Details & evidence";
|
||
detail.dataset.action = "details";
|
||
detail.setAttribute("aria-haspopup", "dialog");
|
||
detail.addEventListener("click", () => showCard(card));
|
||
el.appendChild(detail);
|
||
if (["idea", "draft"].includes(card.source)) {
|
||
const remove=document.createElement("button");remove.textContent="Delete";
|
||
remove.addEventListener("click",async()=>{
|
||
if(!window.confirm("Delete this card and its planning conversation?"))return;
|
||
try{await deleteCard(card.id);await render(true);}catch(error){window.alert(error.message);}
|
||
});el.appendChild(remove);
|
||
}
|
||
appendCardActions(el, card);
|
||
const directChildren = ancestry.has(card.id) ? [] : cards.filter(c => c.parent_id === card.id);
|
||
if (directChildren.length && document.getElementById("view").value === "all") {
|
||
const family = document.createElement("details");
|
||
family.dataset.family = card.id;
|
||
family.open = expandedFamilies.has(card.id);
|
||
family.addEventListener("toggle", () => family.open ? expandedFamilies.add(card.id) : expandedFamilies.delete(card.id));
|
||
const heading = document.createElement("summary");
|
||
heading.textContent = directChildren.length + " children · " + (card.display?.children_successful || 0) + " completed";
|
||
family.appendChild(heading);
|
||
const children = document.createElement("div");
|
||
children.className = "children";
|
||
const chain = new Set(ancestry); chain.add(card.id);
|
||
for (const child of directChildren) children.appendChild(renderCard(child, cards, chain));
|
||
family.appendChild(children); el.appendChild(family);
|
||
}
|
||
return el;
|
||
}
|
||
|
||
const expandedFamilies = new Set();
|
||
let cachedCards = [], lastFingerprint = "", loading = false, lastRefresh = null;
|
||
function drawBoard() {
|
||
const board = document.getElementById("board");
|
||
const focused = document.activeElement;
|
||
const focusId = focused?.closest("[data-id]")?.dataset.id;
|
||
const focusText = focused?.textContent;
|
||
rememberFixerAnswerFocus();
|
||
board.innerHTML = "";
|
||
const query = document.getElementById("search").value.toLowerCase().trim();
|
||
const view = document.getElementById("view").value;
|
||
board.classList.toggle("list-view", view !== "all");
|
||
const ids = new Set(cachedCards.map(c => c.id));
|
||
const matching = cachedCards.filter(c => !query || JSON.stringify([c.title,c.id,c.display,c.failure_detail]).toLowerCase().includes(query));
|
||
const selected = matching.filter(c => (view !== "all" ? lifecycleGroup(c) === view : lifecycleGroup(c) !== "archived")
|
||
&& (view !== "all" || query || !c.parent_id || !ids.has(c.parent_id)));
|
||
const groups = view === "all" ? LIFECYCLE_GROUPS.filter(g => g.id !== "archived") : LIFECYCLE_GROUPS.filter(g => g.id === view);
|
||
for (const group of groups) {
|
||
const cards = selected.filter(c => lifecycleGroup(c) === group.id)
|
||
.sort((a,b) => String(a.updated_at || a.last_run_at || "").localeCompare(String(b.updated_at || b.last_run_at || "")));
|
||
if (!cards.length && !["ready","attention","backlog"].includes(group.id) && view === "all") continue;
|
||
const column = document.createElement("section"); column.className = "column"; column.dataset.lifecycle = group.id;
|
||
const heading = document.createElement("h2"); heading.textContent = group.label + " · " + cards.length; column.appendChild(heading);
|
||
for (const card of cards) column.appendChild(renderCard(card, cachedCards));
|
||
if (!cards.length) { const empty=document.createElement("p");empty.className="empty";empty.textContent="No cards here.";column.appendChild(empty); }
|
||
if (group.id === "backlog") {
|
||
for (const [label, create] of [["+ Idea", createIdea], ["+ Plan a goal", createDraft]]) {
|
||
const button=document.createElement("button");button.textContent=label;
|
||
button.addEventListener("click",async()=>{ const text=window.prompt("What would you like to achieve?");if(text?.trim()){try{await create(text.trim());await render(true);}catch(e){window.alert(e.message);}} });
|
||
column.appendChild(button);
|
||
}
|
||
}
|
||
board.appendChild(column);
|
||
}
|
||
const summary=document.getElementById("summary");summary.replaceChildren();
|
||
for(const group of LIFECYCLE_GROUPS){
|
||
const cards=cachedCards.filter(c=>lifecycleGroup(c)===group.id);
|
||
if(!cards.length)continue;
|
||
const button=document.createElement("button");button.textContent=group.label+": "+cards.length;
|
||
button.addEventListener("click",()=>{document.getElementById("view").value=group.id;drawBoard();});summary.appendChild(button);
|
||
}
|
||
if (focusId) {
|
||
const card=board.querySelector('[data-id="'+CSS.escape(focusId)+'"]');
|
||
const target=card && [...card.querySelectorAll("button,[role=button]")].find(e=>e.textContent===focusText);
|
||
target?.focus({preventScroll:true});
|
||
}
|
||
restoreFixerAnswerFocus();
|
||
}
|
||
async function render(force=false) {
|
||
if(loading)return;
|
||
loading=true;
|
||
try {
|
||
const cards=await fetchBoard();
|
||
lastRefresh=new Date();
|
||
const connection=document.getElementById("connection");
|
||
connection.className="";
|
||
connection.textContent="Updated "+lastRefresh.toLocaleTimeString()+" · Counts include child tasks";
|
||
const fingerprint=JSON.stringify(cards);
|
||
cachedCards=cards;
|
||
if(force || fingerprint!==lastFingerprint){lastFingerprint=fingerprint;drawBoard();}
|
||
if(!document.getElementById("settings-dialog").open){
|
||
document.getElementById("settings-content").replaceChildren(buildAgentColumn());
|
||
}
|
||
} catch(error) {
|
||
const connection=document.getElementById("connection");
|
||
connection.className="stale";
|
||
connection.textContent="Board unavailable — showing "+(lastRefresh?"data from "+lastRefresh.toLocaleTimeString():"no data")+". Retrying automatically.";
|
||
console.error(error);
|
||
} finally { loading=false; }
|
||
}
|
||
function textSection(container, title, value) {
|
||
if(value===undefined || value===null || value==="")return;
|
||
const heading=document.createElement("h3");heading.textContent=title;container.appendChild(heading);
|
||
function appendValue(parent,value) {
|
||
if(Array.isArray(value)) {
|
||
if(!value.length){const empty=document.createElement("p");empty.textContent="No records yet.";parent.appendChild(empty);return;}
|
||
const list=document.createElement("ol");
|
||
for(const item of value){const entry=document.createElement("li");appendValue(entry,item);list.appendChild(entry);}parent.appendChild(list);
|
||
} else if(value && typeof value==="object") {
|
||
const list=document.createElement("dl");
|
||
for(const [key,item] of Object.entries(value)){
|
||
if(item===null || item===undefined)continue;
|
||
const label=document.createElement("dt");label.textContent=key.replaceAll("_"," ");list.appendChild(label);
|
||
const detail=document.createElement("dd");appendValue(detail,item);list.appendChild(detail);
|
||
}
|
||
if(list.children.length)parent.appendChild(list);
|
||
else {const empty=document.createElement("p");empty.textContent="Not recorded.";parent.appendChild(empty);}
|
||
} else {const body=document.createElement("pre");body.textContent=String(value ?? "Not recorded");parent.appendChild(body);}
|
||
}
|
||
appendValue(container,value);
|
||
}
|
||
async function showCard(card, tab="Overview") {
|
||
const dialog=document.getElementById("card-dialog");
|
||
document.getElementById("card-title").textContent=card.title.replace(/^Intent:\s*/i,"");
|
||
document.getElementById("card-id").textContent=card.id;
|
||
const nav=document.getElementById("card-tabs");nav.replaceChildren();
|
||
for(const name of ["Overview","Attempts","Evidence","Conversation","Changes"]){
|
||
const button=document.createElement("button");button.textContent=name;button.setAttribute("aria-pressed",String(name===tab));
|
||
button.addEventListener("click",()=>showCard(card,name));nav.appendChild(button);
|
||
}
|
||
const host=document.getElementById("card-content");
|
||
const content=document.createElement("div");host.replaceChildren(content);
|
||
if(!dialog.open)dialog.showModal();
|
||
if(tab==="Overview"){
|
||
textSection(content,"Goal",card.display?.label || card.phase);
|
||
textSection(content,"Next action",card.display?.reason);
|
||
textSection(content,"Responsible",card.display?.next_actor);
|
||
textSection(content,"Execution",card.display?.job_state);
|
||
textSection(content,"Last outcome",card.last_run_status);
|
||
textSection(content,"Acceptance criteria",card.acceptance_criteria);
|
||
textSection(content,"Dependencies",card.blocked_by || card.depends_on);
|
||
textSection(content,"Operator decision",card.operator_reason);
|
||
textSection(content,"Profile",profileLabel(card.runtime,card.model,card.reasoning_effort));
|
||
} else if(tab==="Evidence"){
|
||
textSection(content,"Diagnosis",card.inspector_result || {repository:card.repo_status,evidence:card.repo_evidence});
|
||
textSection(content,"Activation receipt",{status:card.deployment_status,revision:card.deployed_commit_hash,system:card.activated_system});
|
||
textSection(content,"Failure",card.failure_detail);
|
||
textSection(content,"Preserved unpublished worktree",card.preserved_worktree);
|
||
textSection(content,"Builder report",card.builder_report);
|
||
if(card.review_report)await loadDetailReport(content,card,"review","Review");
|
||
if(card.research_report)await loadDetailReport(content,card,"research","Research findings");
|
||
} else if(tab==="Conversation"){
|
||
textSection(content,"Planning conversation",card.draft_conversation || []);
|
||
textSection(content,"Clarifications",card.clarification_qa || card.fixer_qa || []);
|
||
textSection(content,"Current question",card.clarification_question || card.fixer_question);
|
||
} else if(tab==="Changes"){
|
||
textSection(content,"Reviewed commit",card.commit_hash);
|
||
textSection(content,"Review verdict",card.review_decision);
|
||
textSection(content,"Write scope",card.write_scope);
|
||
if(["intent","research"].includes(card.source))await loadDetailReport(content,card,card.source==="research"?"research-intent":"intent","Source intent");
|
||
} else {
|
||
textSection(content,"Previous attempts",card.attempt_history || []);
|
||
textSection(content,"Review history",card.review_history || []);
|
||
try{
|
||
const response=await fetch("/api/board/cards/"+encodeURIComponent(card.id)+"/telemetry");
|
||
if(!response.ok)throw new Error("HTTP "+response.status);
|
||
const telemetry=await response.json();
|
||
textSection(content,"Current pipeline",formatPipelineMetrics(telemetry.current_pipeline));
|
||
textSection(content,"Recorded runs",telemetry.runs);
|
||
}catch(error){textSection(content,"Telemetry",error.message);}
|
||
}
|
||
}
|
||
async function loadDetailReport(content,card,kind,label){
|
||
try{
|
||
const response=await fetch("/api/board/cards/"+encodeURIComponent(card.id)+"/"+kind);
|
||
if(!response.ok)throw new Error("HTTP "+response.status);
|
||
const report=await response.json();textSection(content,label,report.content || report.report || report);
|
||
}catch(error){textSection(content,label,error.message);}
|
||
}
|
||
document.getElementById("search").addEventListener("input",drawBoard);
|
||
document.getElementById("view").addEventListener("change",drawBoard);
|
||
document.getElementById("refresh").addEventListener("click",()=>render(true));
|
||
document.getElementById("settings-open").addEventListener("click",()=>document.getElementById("settings-dialog").showModal());
|
||
|
||
for (const [id, kind] of [
|
||
["smoke-test-direct", "direct"],
|
||
["smoke-test-split", "split"],
|
||
["smoke-test-remove-direct", "remove-direct"],
|
||
["smoke-test-remove-split", "remove-split"],
|
||
]) {
|
||
document.getElementById(id).addEventListener("click", async () => {
|
||
try {
|
||
await createSmokeTest(kind);
|
||
} catch (error) {
|
||
window.alert(`Smoke-Test konnte nicht angelegt werden: ${error.message}`);
|
||
}
|
||
render();
|
||
});
|
||
}
|
||
|
||
document.getElementById("smoke-test-cleanup").addEventListener("click", async () => {
|
||
if (!window.confirm("Alle Smoke-Test-Karten (inkl. Split-/Fan-out-Kinder und Synthese) vom gesamten Board entfernen?")) return;
|
||
try {
|
||
const result = await deleteSmokeTestCards();
|
||
window.alert(`Entfernt: ${result.removed_cards} Karte(n), ${result.removed_files} Datei(en).`);
|
||
} catch (error) {
|
||
window.alert(`Smoke-Tests konnten nicht entfernt werden: ${error.message}`);
|
||
}
|
||
render();
|
||
});
|
||
|
||
render();
|
||
// render() clears and rebuilds the whole board (see its own comment above),
|
||
// which throws away focus and in-progress keystrokes on any form control --
|
||
// jarring if the periodic tick fires while someone is mid-edit. Pausing the
|
||
// tick while a board input/select/textarea is focused is a plain skip, not
|
||
// a reschedule: the very next explicit action-triggered render() (a submit,
|
||
// a column-config save, ...) still shows the latest state, and the next
|
||
// 5s tick after the field loses focus catches up normally.
|
||
function boardEditInProgress() {
|
||
const active = document.activeElement;
|
||
const board = document.getElementById("board");
|
||
if (!active || !board || !board.contains(active)) return false;
|
||
return ["INPUT", "TEXTAREA", "SELECT"].includes(active.tagName);
|
||
}
|
||
|
||
setInterval(() => {
|
||
if (boardEditInProgress()) return;
|
||
render();
|
||
}, 5000);
|
||
</script>
|
||
</body>
|
||
</html>
|