feat(orchestrator): Agents and Artifacts views with spawn tree, orbit, 3D and signed branch deletes

Phase 4 of requirement 001. Agents and Artifacts each offer Orbit, Chord, Swimlane, Tree and 3D: the spawn tree (page-spawn.js) and the artifacts orbit (page-artifacts.js, kvb_orchestrator/artifacts.py) replace the "folgt" placeholders, 3D runs on the vendored 3d-force-graph and three.js (MIT, LICENSE files kept, served from the image under the unchanged CSP), and a Bootstrap Icons sprite (icons.py, MIT) replaces the emoji glyphs, also in the meditation page.

Signed branch deletes: the Tree view offers a delete only for merged-clean branches without a gc skip reason; the operator signs the exact item with the passkey, the hub writes a job without any command (kvb_orchestrator/cleanup.py), and the host path unit runs client/cleanup.py, which verifies the signature, dry-runs pkgmgr branch cleanup for that exact branch, refuses patterns, option names, changed tips, other worktrees and plans naming other branches, deletes, and rewrites the branch tree. pkgmgr runs with core.hooksPath=/dev/null, core.fsmonitor=false and the config hook events disabled through GIT_CONFIG_COUNT, because the cwd is a repository agents can write and pkgmgr releases before 4c8b137 delete without scan mode (test with a positive control that both hook kinds fire without it).

3D labels go into the tooltip as a text node: the vendored tooltip renders a string label as HTML.

Tests: 503 OK, Chromium (CDP) and Firefox (BiDi) walks over every screen at three widths.

MEDUSA acceptances (kvb-think-criticize skeptic 2026-11-07): WEB-FLASK-004 view.py:135 (file template, autoescape on, state via tojson), MEDUSA-MM-SCAN-058 server.py:69/:85 (substring pla in --templates and Places), RH005 client/cleanup.py:199 (raise SystemExit(main())), B704 icons.py:45 (sprite baked into the image, names checked against it), EXA002 page-artifacts.js:221 (openAgent only opens the drawer), STG002 page-constants.js:64 (U+2212 in a static label), PL002 page-graph3d.js:44 (CSS class space-tools; the real tooltip sink fixed), B603 client/cleanup.py:75 (argv without shell; hook hole closed).

🎯 Fix: 92 %
💥 Break: 5 %
💸 Cost: 100 %
⏱️ Time: –
🛡️ Security: 100 %
🏆 Score: 87
🚦 Gate: ✅ 90/5, cost and time <= 100, security 100 %

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin Veen-BirkenbachandClaude Opus 5.5 committed 2026-11-07 15:03:14 +00:00
1 parent db3c5264f2
commit 90c56cb786
64 files changed
+82829 -451

No files matched your search

+55 -4
View File
@@ -46,7 +46,11 @@ h1 { margin: 0; font-size: var(--fs-lg); font-weight: 680; letter-spacing: -.01e
.subtabs { display: inline-flex; gap: 2px; padding-left: var(--sp-1); border-left: 1px solid var(--line); }
.subtabs button { font-size: var(--fs-sm); }
.tabs button { display: inline-flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) var(--sp-3); border: 0; border-radius: 10px; background: transparent; color: var(--muted); font-weight: 560; cursor: pointer; transition: background .15s, color .15s; }
.icon-btn { min-width: 34px; justify-content: center; font-size: var(--fs-lg); line-height: 1; }
.sprite { position: absolute; width: 0; height: 0; overflow: hidden; }
.bi { flex: none; width: 1em; height: 1em; fill: currentColor; vertical-align: -.125em; }
.icon-only { min-width: 34px; justify-content: center; font-size: var(--fs-lg); line-height: 1; }
.btn.small.icon-only { min-width: 30px; padding: 4px 8px; font-size: var(--fs-md); }
.bi.caret { width: .7em; height: .7em; opacity: .7; }
.btn[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent); }
.tabs button:hover { background: var(--hover); color: var(--text); }
.tabs button[aria-current="page"] { background: var(--accent-soft); color: var(--accent); }
@@ -256,6 +260,23 @@ svg .mark:hover { opacity: 1; }
svg .mark:hover line, svg .mark:hover path { stroke-width: 2.6; }
svg .pulse { fill: var(--c); filter: drop-shadow(0 0 4px var(--c)); }
svg .pulse-ring { fill: none; stroke: var(--c); stroke-width: 2.5; }
svg .node.a-repo circle, svg .node.sun circle { fill: color-mix(in srgb, var(--warn) 70%, var(--panel)); stroke: var(--warn); stroke-width: 3; }
svg .node.a-worktree circle { fill: var(--sunken); stroke: var(--muted); }
svg .node.a-worktree.dirty circle { fill: var(--warn-soft); stroke: var(--warn); }
svg .node.satellite circle { fill: var(--accent); stroke: none; }
svg .node.cargo circle { fill: var(--teal); stroke: none; }
svg .node.tone-ok circle { fill: var(--ok-soft); stroke: var(--ok); }
svg .node.tone-warn circle { fill: var(--warn-soft); stroke: var(--warn); }
svg .node.tone-bad circle { fill: var(--bad-soft); stroke: var(--bad); }
svg .node.tone-info circle { fill: var(--info-soft); stroke: var(--info); }
svg .node.tone-violet circle { fill: var(--violet-soft); stroke: var(--violet); }
svg .node.selected circle { stroke: var(--accent); stroke-width: 3.5; }
svg .edge.merge { stroke-dasharray: 5 5; }
svg .lane-mark { stroke-width: 1.2; }
svg .lane-mark.tone-ok { fill: var(--ok); stroke: var(--ok); }
svg .lane-mark.tone-bad { fill: var(--bad); stroke: var(--bad); }
svg .lane-mark.tone-info { fill: var(--info); stroke: var(--info); }
svg .lane-mark.tone-neutral { fill: var(--muted); stroke: var(--muted); }
input[type="datetime-local"] { padding: 3px 6px; border: 1px solid var(--line-strong); border-radius: 8px; background: var(--panel); font-size: var(--fs-sm); }
.custom { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; }
.avatar { display: inline-grid; place-items: center; flex: none; width: 26px; height: 26px; border-radius: 50%; background: var(--sunken); border: 1px solid var(--line); font-size: var(--fs-lg); line-height: 1; }
@@ -358,16 +379,40 @@ body.sky-look .sky:active { cursor: grabbing; }
.sky-panel { position: fixed; bottom: var(--sp-5); left: 50%; z-index: 8; display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 6px; max-width: calc(100vw - 24px); padding: 8px 12px; transform: translateX(-50%); background: var(--glass-card); border: 1px solid var(--line-strong); border-radius: var(--radius); box-shadow: var(--shadow-lift); }
.sky-panel[hidden] { display: none; }
.compass { min-width: 220px; font-weight: 620; font-variant-numeric: tabular-nums; text-align: center; }
.states { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; }
.state { display: inline-flex; align-items: center; gap: 4px; padding: 1px 8px; border-radius: 999px; background: var(--neutral-soft); color: var(--muted); font-size: var(--fs-xs); font-weight: 650; line-height: 20px; white-space: nowrap; }
.state[data-tone="ok"] { background: var(--ok-soft); color: var(--ok); }
.state[data-tone="bad"] { background: var(--bad-soft); color: var(--bad); }
.state[data-tone="warn"] { background: var(--warn-soft); color: var(--warn); }
.state[data-tone="info"] { background: var(--info-soft); color: var(--info); }
.state[data-tone="violet"] { background: var(--violet-soft); color: var(--violet); }
.wt-head { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; width: 100%; }
.leaves { display: grid; gap: 4px; width: 100%; margin: 0; padding: 0 0 0 18px; list-style: none; }
.leaf { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; font-size: var(--fs-sm); }
.hint { color: var(--muted); font-size: var(--fs-xs); }
.delete-all { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; }
.dry-run { margin: 4px 0 0; padding: 8px 10px; background: var(--sunken); border-radius: 8px; font-family: var(--mono); font-size: var(--fs-sm); white-space: pre-wrap; overflow-wrap: anywhere; }
pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radius: 8px; font-size: var(--fs-sm); white-space: pre-wrap; overflow-wrap: anywhere; }
.spawn-tree { display: grid; gap: 4px; margin: 0; padding: 0 0 0 18px; list-style: none; }
.spawn-tree.root { padding-left: 0; }
.spawn-row { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; padding: 5px 8px; border-radius: 8px; }
.spawn-row:hover { background: var(--hover); }
.graph3d { position: relative; width: 100%; height: clamp(360px, 68vh, 820px); overflow: hidden; border-radius: var(--radius-sm); cursor: grab; touch-action: none; }
.graph3d:active { cursor: grabbing; }
.graph3d .scene-nav-info, .graph3d .graph-info-msg { display: none; }
.graph3d .scene-container { position: relative; }
.float-tooltip-kap { position: absolute; z-index: 9; padding: 4px 8px; border-radius: 6px; background: var(--glass-card); color: var(--text); font-size: var(--fs-sm); white-space: nowrap; pointer-events: none; box-shadow: var(--shadow); }
</style>
</head>
<body>
{{ icons }}
<canvas class="sky" id="sky" aria-hidden="true"></canvas>
<div class="head" id="head">
<header class="top">
<div class="brand"><span class="logo" aria-hidden="true"></span><div class="brand-text"><h1>Agent Orchestrator</h1><span class="sky-note" id="sky-note"></span></div></div>
<div class="tools">
<button type="button" class="btn small ghost" id="palette-open" title="Befehle (Strg+K)" aria-label="Befehle">⌘K</button>
<button type="button" class="btn small ghost icon-btn" id="settings-open" title="Einstellungen und Ambiente" aria-label="Einstellungen">⚙</button>
<button type="button" class="btn small ghost icon-only" id="palette-open" title="Befehle (Strg+K)" aria-label="Befehle"><svg class="bi" aria-hidden="true" focusable="false"><use href="#bi-command"></use></svg></button>
<button type="button" class="btn small ghost icon-only" id="settings-open" title="Einstellungen und Ambiente" aria-label="Einstellungen"><svg class="bi" aria-hidden="true" focusable="false"><use href="#bi-gear"></use></svg></button>
</div>
</header>
<div class="navbar">
@@ -438,10 +483,16 @@ body.sky-look .sky:active { cursor: grabbing; }
{% include "page-space-steer.js" %}
{% include "page-space-scene.js" %}
{% include "page-spawn.js" %}
{% include "page-graph3d.js" %}
{% include "page-artifacts.js" %}
{% include "page-tree.js" %}
{% include "page-cleanup.js" %}
{% include "page-time.js" %}
{% include "page-pulses.js" %}
+5 -1
View File
@@ -3,6 +3,10 @@ const Nav = (() => {
const TABS = [["stream", "Stream"], ["agents", "Agents"], ["artifacts", "Artifacts"]];
const SUBS = {stream: [["inbox", "Inbox"], ["rules", "Regeln"]]};
const VIEWS = [["orbit", "Orbit"], ["chord", "Chord"], ["swimlane", "Swimlane"], ["tree", "Tree"], ["3d", "3D"]];
const ICONS = {
stream: "bi-activity", inbox: "bi-inbox", rules: "bi-card-checklist", agents: "bi-people", artifacts: "bi-git",
orbit: "bi-globe2", chord: "bi-bezier2", swimlane: "bi-bar-chart-steps", tree: "bi-diagram-3", "3d": "bi-badge-3d",
};
const OLD = {graph: ["agents", "orbit"], tree: ["artifacts", "tree"]};
const known = (view) => VIEWS.some(([id]) => id === view);
@@ -30,7 +34,7 @@ const Nav = (() => {
return {screen: "stream", view: null, redirect: null};
}
return {TABS, SUBS, VIEWS, route, hashOf, tabOf};
return {TABS, SUBS, VIEWS, ICONS, route, hashOf, tabOf};
})();
if (typeof module === "object" && module.exports) {
module.exports = Nav;
+2 -2
View File
@@ -94,7 +94,7 @@
ui.agent = key;
const text = h("textarea", {"aria-label": `Nachricht an ${name}`, placeholder: `Nachricht an ${name} …`});
const file = h("input", {type: "file", class: "field", "aria-label": "Datei anhängen"});
const button = h("button", {type: "submit", class: "btn primary"}, "Senden");
const button = h("button", {type: "submit", class: "btn primary icon-only", ...named("Senden")}, icon("bi-send"));
const classes = choice("to-one", TO_ONE, "post");
const form = h("form", {class: "drawer-form"}, text, file, classes,
h("p", {class: "muted"}, "Post kommt am Ende des Zuges an, Call sofort mit Lesebestätigung; prüfbar mit operator_message."),
@@ -118,7 +118,7 @@
const former = (people()[key] || {names: []}).names.filter((entry) => entry !== name);
drawer.replaceChildren(
h("div", {class: "drawer-head"}, face(name, true), life, h("div", {}, h("h2", {class: "panel-title"}, name), h("span", {class: "muted"}, former.length ? `Agent · früher ${former.join(", ")}` : "Agent")), h("span", {class: "spacer"}),
h("button", {type: "button", class: "btn small ghost", "aria-label": "Schließen", on: {click: closeAgent}}, "×")),
iconBtn("bi-x-lg", "Schließen", "btn small ghost", closeAgent)),
h("div", {class: "drawer-body"}, info, h("h3", {}, "Subagenten"), subs, h("h3", {}, "Verlauf"), log),
form);
drawer.hidden = false;
+1 -1
View File
@@ -27,7 +27,7 @@
}
const permission = "Notification" in window ? Notification.permission : "unsupported";
const labels = {granted: "Benachrichtigungen an", denied: "Benachrichtigungen blockiert", default: "Benachrichtigungen aktivieren", unsupported: "Benachrichtigungen fehlen im Browser"};
button.textContent = labels[permission] || labels.default;
relabel(button, "bi-bell", labels[permission] || labels.default);
button.disabled = permission !== "default";
}
+312
View File
@@ -0,0 +1,312 @@
const art = {repo: "", node: null};
const ART_KINDS = {repo: ["Repository", "bi-git"], branch: ["Branch", "bi-diagram-2"], worktree: ["Worktree", "bi-folder2-open"], agent: ["Agent", "bi-robot"], artefact: ["Artefakt", "bi-box-seam"], task: ["Aufgabe", "bi-card-checklist"]};
const ARTEFACT_ICONS = {image: "bi-box-seam", volume: "bi-hdd", log: "bi-file-text", dir: "bi-folder"};
const LANE_MARKS = {commit: ["Commit", "bi-record-circle"], review: ["Review", "bi-eye"], gate: ["Gate", "bi-shield-check"], push: ["Push", "bi-cloud-upload"]};
const tail = (path, parts) => String(path).split("/").filter(Boolean).slice(-parts).join("/");
const epochOf = (text) => {
const value = Date.parse(text);
return Number.isFinite(value) ? value / 1000 : null;
};
function artifactModel(data) {
const nodes = [];
const links = [];
const add = (node) => {
nodes.push(node);
if (node.parent) {
links.push({source: node.parent, target: node.id, kind: "contains"});
}
return node.id;
};
const leaves = (holder, wt, depth) => {
for (const name of wt.workers || []) {
add({id: `agent:${name}@${wt.path}`, kind: "agent", label: name, depth, ts: holder.ts, parent: holder.id, data: {name}, repo: holder.repo});
}
for (const found of wt.artefacts || []) {
add({id: `artefact:${holder.id}\u0000${found.task}\u0000${found.ref}`, kind: "artefact", label: found.name, depth, ts: epochOf(found.added) || holder.ts, parent: holder.id, data: found, repo: holder.repo});
}
};
for (const repo of data.repos || []) {
const base = epochOf(repo.scanned_at) || data.scanned || nowSeconds();
const rid = add({id: `repo:${repo.path}`, kind: "repo", label: repo.name, depth: 0, ts: base, parent: null, data: repo, repo: repo.path});
for (const branch of repo.branches) {
const last = branch.events && branch.events.length ? branch.events[branch.events.length - 1].ts : base;
const bid = add({id: `branch:${repo.path}\u0000${branch.name}`, kind: "branch", label: branch.name, depth: 1, ts: last, parent: rid, data: branch, repo: repo.path});
for (const wt of branch.worktrees) {
const holder = {id: `wt:${wt.path}`, kind: "worktree", label: tail(wt.path, 2), depth: 2, ts: last, parent: bid, data: {...wt, branch: branch.name}, repo: repo.path};
add(holder);
leaves(holder, wt, 3);
}
}
for (const wt of repo.detached) {
const holder = {id: `wt:${wt.path}`, kind: "worktree", label: tail(wt.path, 2), depth: 1, ts: base, parent: rid, data: {...wt, branch: ""}, repo: repo.path};
add(holder);
leaves(holder, wt, 2);
}
for (const merge of repo.merges || []) {
links.push({source: `branch:${repo.path}\u0000${merge.from}`, target: `branch:${repo.path}\u0000${merge.into}`, kind: "merge", by: merge.by});
}
for (const overlap of repo.overlaps || []) {
links.push({source: `branch:${repo.path}\u0000${overlap.a}`, target: `branch:${repo.path}\u0000${overlap.b}`, kind: "overlap", count: overlap.count});
}
}
for (const task of data.tasks || []) {
const tid = add({id: `task:${task.task}`, kind: "task", label: task.task, depth: 0, ts: nowSeconds(), parent: null, data: task, repo: ""});
for (const found of task.artefacts) {
add({id: `artefact:${tid}\u0000${found.task}\u0000${found.ref}`, kind: "artefact", label: found.name, depth: 1, ts: epochOf(found.added) || nowSeconds(), parent: tid, data: found, repo: ""});
}
}
const ids = new Set(nodes.map((node) => node.id));
return {nodes, links: links.filter((link) => ids.has(link.source) && ids.has(link.target)), byId: new Map(nodes.map((node) => [node.id, node]))};
}
function chosenRepo() {
const repos = tree.data && !tree.data.error ? tree.data.repos : [];
const weight = (repo) => repo.branches.length + repo.branches.reduce((sum, entry) => sum + entry.worktrees.length, 0);
const found = repos.find((repo) => repo.path === art.repo) || [...repos].sort((a, b) => weight(b) - weight(a))[0] || null;
art.repo = found ? found.path : "";
return found;
}
function artSelect(id) {
art.node = art.node === id ? null : id;
drawArtifacts();
}
function artMark(node, at, radius, extra, caption) {
const [kindName] = ART_KINDS[node.kind];
const classes = ["node", `a-${node.kind}`, extra, art.node === node.id ? "selected" : ""].filter(Boolean).join(" ");
return s("g", {
class: classes, transform: `translate(${fixed(at.x)} ${fixed(at.y)})`, tabindex: 0, role: "button", "aria-label": `${node.label}, ${kindName}`,
on: {
click: () => artSelect(node.id),
keydown: (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
artSelect(node.id);
}
},
},
}, s("title", {}, `${node.label} · ${kindName}`), s("circle", {r: radius}), caption ? s("text", {class: "label", y: radius + 14, "text-anchor": "middle"}, caption) : null);
}
function around(base, index, count, distance) {
const angle = (base.angle || 0) + ((index - (count - 1) / 2) * Math.PI) / Math.max(count + 2, 5);
return {x: base.x + distance * Math.cos(angle), y: base.y + distance * Math.sin(angle), angle};
}
const toneOf = (branch) => (branch.state === "merged-clean" ? "ok" : branch.state === "dirty" ? "warn" : branch.state === "protected" ? "violet" : branch.state === "stale" ? "warn" : branch.gone ? "bad" : "info");
function artOrbit(repo, model) {
const svg = chartFrame(420, `Orbit: ${repo.name} als Sonne, Branches als Planeten (Größe = Commits voraus), Worktrees als Monde, Agenten als Satelliten`);
const rings = repo.branches.length > 16 ? [200, 310] : [250];
svg.append(...rings.map((r) => s("circle", {class: "ring", r})));
const layer = s("g", {});
repo.branches.forEach((branch, index) => {
const ring = rings[index % rings.length];
const angle = -Math.PI / 2 + (2 * Math.PI * index) / Math.max(repo.branches.length, 1);
const at = {x: ring * Math.cos(angle), y: ring * Math.sin(angle), angle};
const radius = Math.min(11 + 4 * Math.log2(1 + branch.ahead), 30);
const node = model.byId.get(`branch:${repo.path}\u0000${branch.name}`);
branch.worktrees.forEach((wt, slot) => {
const moon = around(at, slot, branch.worktrees.length, radius + 18);
const holder = model.byId.get(`wt:${wt.path}`);
const leaves = model.nodes.filter((entry) => entry.parent === holder.id);
leaves.forEach((leaf, place) => layer.append(artMark(leaf, around(moon, place, leaves.length, 15), 4.5, leaf.kind === "agent" ? "satellite" : "cargo", null)));
layer.append(artMark(holder, moon, 7, wt.state === "changed" ? "dirty" : "", null));
});
layer.append(artMark(node, at, radius, `tone-${toneOf(branch)}`, repo.branches.length <= 40 ? short(branch.name).slice(0, 28) : null));
});
layer.append(artMark(model.byId.get(`repo:${repo.path}`), {x: 0, y: 0, angle: 0}, 34, "sun", repo.name.split("/").pop()));
svg.append(layer);
return svg;
}
function artChord(repo) {
const radius = 300;
const branches = repo.branches;
const spots = new Map(branches.map((branch, index) => {
const angle = -Math.PI / 2 + (2 * Math.PI * index) / Math.max(branches.length, 1);
return [branch.name, {x: radius * Math.cos(angle), y: radius * Math.sin(angle), angle}];
}));
const svg = chartFrame(440, `Chord: Überlappung der Branches von ${repo.name}; Band = gemeinsam geänderte Dateien, gestrichelt = Merge`);
const arcs = s("g", {});
for (const overlap of repo.overlaps) {
const from = spots.get(overlap.a);
const to = spots.get(overlap.b);
if (from && to) {
arcs.append(s("path", {class: "edge t-alarm", d: `M${fixed(from.x)},${fixed(from.y)} Q0,0 ${fixed(to.x)},${fixed(to.y)}`, "stroke-width": fixed(width(overlap.count))},
s("title", {}, `${overlap.a} ↔ ${overlap.b}: ${overlap.count} gemeinsame Dateien (Konfliktrisiko)\n${overlap.files.join("\n")}`)));
}
}
for (const merge of repo.merges) {
const from = spots.get(merge.from);
const to = spots.get(merge.into);
if (from && to && from !== to) {
const control = {x: (from.x + to.x) * 0.3, y: (from.y + to.y) * 0.3};
arcs.append(s("path", {class: "edge t-grant merge", d: `M${fixed(from.x)},${fixed(from.y)} Q${fixed(control.x)},${fixed(control.y)} ${fixed(to.x)},${fixed(to.y)}`, "stroke-width": 2, "marker-end": "url(#tip-grant)"},
s("title", {}, `${merge.from} → ${merge.into}: gemergt durch ${merge.by}`)));
}
}
const marks = branches.map((branch) => artMark({id: `branch:${repo.path}\u0000${branch.name}`, kind: "branch", label: branch.name}, spots.get(branch.name), 8, `tone-${toneOf(branch)}`, null));
const labels = branches.map((branch) => {
const at = spots.get(branch.name);
const cos = Math.cos(at.angle);
return s("text", {class: "label", x: fixed(at.x * 1.07), y: fixed(at.y * 1.07), dy: "0.35em", "text-anchor": cos > 0.3 ? "start" : cos < -0.3 ? "end" : "middle"}, short(branch.name).slice(0, 26));
});
svg.append(arcs, s("g", {}, marks), s("g", {}, labels));
return svg;
}
function artSwimlane(repo) {
const lanes = repo.branches;
const begin = state.since;
const end = Math.max(state.until, state.since + 1);
const [left, right, top, lane, full] = [200, 24, 40, 34, 1120];
const height = top + lanes.length * lane + 12;
const x = (ts) => left + ((Math.min(Math.max(ts, begin), end) - begin) / (end - begin)) * (full - left - right);
const svg = s("svg", {viewBox: `0 0 ${full} ${height}`, class: "chart", role: "img", "aria-label": `Swimlane: Commits, Reviews, Gates und Pushes je Branch von ${repo.name}`}, defs());
lanes.forEach((branch, index) => {
svg.append(s("rect", {class: index % 2 ? "lane odd" : "lane", x: 0, y: top + index * lane, width: full, height: lane}));
svg.append(s("text", {class: "lane-label", x: 14, y: top + index * lane + lane / 2, dy: "0.35em", on: {click: () => artSelect(`branch:${repo.path}\u0000${branch.name}`)}}, short(branch.name).slice(0, 26)));
});
for (let index = 0; index <= 6; index += 1) {
const ts = begin + ((end - begin) * index) / 6;
svg.append(s("line", {class: "grid", x1: fixed(x(ts)), x2: fixed(x(ts)), y1: top - 6, y2: height - 8}));
svg.append(s("text", {class: "axis", x: fixed(x(ts)), y: top - 14, "text-anchor": index === 0 ? "start" : index === 6 ? "end" : "middle"}, (end - begin > DAY ? absolute : clock).format(stampOf(ts))));
}
const marks = s("g", {});
lanes.forEach((branch, index) => {
const y = top + index * lane + lane / 2;
const shapes = {commit: `M-4,0 a4,4 0 1,0 8,0 a4,4 0 1,0 -8,0`, review: "M-4,-4 h8 v8 h-8 z", gate: "M0,-5 L5,4 L-5,4 z", push: "M0,-6 L5,0 L2,0 L2,5 L-2,5 L-2,0 L-5,0 z"};
for (const event of branch.events.filter((entry) => entry.ts >= begin && entry.ts <= end)) {
const tone = event.ok === false ? "bad" : event.kind === "push" ? "info" : event.kind === "commit" ? "ok" : event.ok ? "ok" : "neutral";
marks.append(s("g", {class: `lane-mark tone-${tone}`, transform: `translate(${fixed(x(event.ts))} ${y})`},
s("title", {}, `${clock.format(stampOf(event.ts))} · ${LANE_MARKS[event.kind][0]}: ${event.title}`), s("path", {d: shapes[event.kind]})));
}
});
svg.append(marks);
return svg;
}
function artLegend(view) {
const words = {
orbit: "Sonne = Repository · Planet = Branch, Größe = Commits voraus · Mond = Worktree · Satellit = Agent oder Artefakt",
chord: "Band = Dateien, die beide Branches ändern (aus den Reviews im Hub, nur dort bekannt) · gestrichelt = Merge laut gc-Klassifikation",
swimlane: "Commit = freigegebenes Review · Gate = Review-Gate und Queue-Läufe · Push = Runner-Befehl mit push · Zeitraum = 24 h ▾",
};
const marks = view === "swimlane" ? Object.values(LANE_MARKS).map(([label, name]) => h("span", {}, icon(name), label)) : [];
return h("div", {class: "legend"}, marks, h("span", {class: "muted"}, words[view]));
}
function artPanel(model) {
const node = model.byId.get(art.node);
if (!node) {
return [h("h2", {class: "panel-title"}, "Details"), h("p", {class: "muted"}, "Klicke auf ein Repository, einen Branch, einen Worktree, einen Agenten oder ein Artefakt.")];
}
const [kindName, kindIcon] = ART_KINDS[node.kind];
const data = node.data;
const body = [];
if (node.kind === "repo") {
body.push(meta([["Pfad", data.path], ["Remote", data.remote], ["Default", data.default], ["Forge", data.forge], ["Stand", data.scanned_at]]),
h("p", {class: "muted"}, `${data.branches.length} Branches · ${data.branches.reduce((sum, entry) => sum + entry.worktrees.length, 0) + data.detached.length} Worktrees`));
} else if (node.kind === "branch") {
body.push(h("div", {class: "states"}, branchStates(data)), meta([["Upstream", data.upstream], ["Kopf", data.head.slice(0, 12), data.head], ["Gemergt in", data.merged_into], ["Grund", data.reason]]),
h("h3", {}, "Trockenlauf"), h("pre", {class: "notes"}, data.plan.join("\n")), h("div", {class: "actions"}, deleteButton(data)));
} else if (node.kind === "worktree") {
body.push(h("div", {class: "states"}, worktreeStates(data)), meta([["Pfad", data.path], ["Branch", data.branch], ["HEAD", data.head.slice(0, 12), data.head]]),
data.workers.length ? h("div", {class: "who"}, data.workers.map((name) => [face(name, false), person(name)])) : h("p", {class: "muted"}, "Kein Agent arbeitet hier."),
data.review ? h("div", {class: "who"}, status(data.review.status), h("span", {}, data.review.title)) : null, h("div", {class: "actions"}, copyButton(data.path)));
} else if (node.kind === "agent") {
body.push(h("div", {class: "actions"}, btn("Agent öffnen", "btn small soft", () => openAgent(data.name))));
} else if (node.kind === "artefact") {
body.push(meta([["Ref", data.ref], ["Art", data.kind], ["Aufgabe", data.task], ["Seit", data.added]]), data.ended ? pill("Aufgabe beendet: aufräumen", "warn") : null);
} else {
body.push(meta([["Titel", data.title], ["Status", data.status]]));
}
return [
h("div", {class: "panel-head"}, h("div", {}, h("h2", {class: "panel-title"}, icon(kindIcon), " ", node.label), h("span", {class: "muted"}, kindName)),
iconBtn("bi-x-lg", "Schließen", "btn small ghost", () => artSelect(node.id))),
...body,
];
}
function artifactsScreen() {
const view = ui.views.artifacts;
const picker = h("select", {class: "field", "aria-label": "Repository", title: "Repository", on: {change: (event) => {
art.repo = event.target.value;
art.node = null;
drawArtifacts();
}}});
const count = h("span", {class: "muted"});
const reload = iconBtn("bi-arrow-clockwise", "Neu laden", "btn small soft", fetchTree, "Neu laden: den Tree-Stand von pkgmgr lesen");
const stageBox = h("div", {class: "stage"});
const aside = h("aside", {class: "card panel", "aria-live": "polite"});
const legendBox = h("div", {});
const list = h("div", {class: "repos"});
const search = view === "tree" ? h("input", {type: "search", placeholder: "Repo, Branch, Pfad oder Agent filtern …", value: tree.q, "aria-label": "Tree filtern", on: {input: (event) => {
tree.q = event.target.value;
drawArtifacts();
}}}) : null;
const removeAll = view === "tree" ? h("span", {class: "delete-all"}) : null;
tree.dom = {view, picker, count, stage: stageBox, aside, legend: legendBox, list, removeAll};
window.setTimeout(() => {
drawArtifacts();
fetchTree();
}, 0);
const controls = h("div", {class: "card controls"}, viewSwitch("artifacts"), view === "3d" ? layoutTools("artifacts") : view === "tree" ? null : picker,
h("div", {class: "toolbar", role: "toolbar", "aria-label": "Artifacts"}, search, reload, removeAll), count);
const body = view === "tree" ? list : h("div", {class: "graph-body"}, h("div", {class: "card stage-card"}, stageBox, legendBox), aside);
return h("div", {class: "graph"}, controls, body);
}
async function fetchTree() {
if (tree.busy) {
return;
}
tree.busy = true;
try {
const response = await fetch("/api/tree", {cache: "no-store"});
if (response.ok) {
tree.data = await response.json();
tree.model = artifactModel(tree.data);
} else {
tree.data = {repos: [], scanned: null, error: `/api/tree antwortet ${response.status}`};
}
} catch {
ui.offline = true;
tree.data = tree.data || {repos: [], scanned: null, error: "Hub nicht erreichbar"};
}
tree.busy = false;
drawArtifacts();
}
function drawArtifacts() {
if (!tree.dom) {
return;
}
if (tree.dom.view === "tree") {
drawTree();
return;
}
const empty = !tree.data || tree.data.error;
tree.dom.count.textContent = empty ? "" : `${tree.data.repos.length} Repositories · Stand ${clock.format(stampOf(tree.data.scanned))}`;
if (empty) {
tree.dom.stage.replaceChildren(emptyState(tree.busy || !tree.data ? "Lese Repositories …" : "Kein Tree", tree.data ? tree.data.error : "pkgmgr schreibt den Tree jede Minute, solange diese Seite offen ist."));
tree.dom.aside.replaceChildren();
return;
}
const model = tree.model;
if (art.node !== null && !model.byId.has(art.node)) {
art.node = null;
}
tree.dom.aside.replaceChildren(...artPanel(model));
if (tree.dom.view === "3d") {
stage3d("artifacts", tree.dom.stage);
return;
}
const repo = chosenRepo();
tree.dom.picker.replaceChildren(...tree.data.repos.map((entry) => h("option", {value: entry.path, selected: entry.path === art.repo}, `${entry.name} (${entry.branches.length})`)));
tree.dom.legend.replaceChildren(artLegend(tree.dom.view));
tree.dom.stage.replaceChildren(repo ? {orbit: artOrbit, chord: artChord, swimlane: artSwimlane}[tree.dom.view](repo, model) : emptyState("Kein Repository", "pkgmgr nennt kein Repository."));
}
+5 -5
View File
@@ -42,9 +42,9 @@
text.value = "";
}
};
return h("details", {class: "menu", id: "to-all"}, h("summary", {}, "Funk an alle ▾"), h("div", {class: "menu-box"},
return h("details", {class: "menu", id: "to-all"}, h("summary", named("Funk an alle"), icon("bi-broadcast-pin"), caret()), h("div", {class: "menu-box"},
text, classes, h("p", {class: "muted"}, "Newsletter kommt als Post am Ende des Zuges; Broadcast, Pan-Pan, Security und Mayday sofort."),
h("div", {class: "actions"}, h("span", {class: "spacer"}), btn("Senden", "btn soft", send))));
h("div", {class: "actions"}, h("span", {class: "spacer"}), iconBtn("bi-send", "Senden", "btn soft", send, "Senden an alle"))));
}
function openToAll() {
@@ -59,12 +59,12 @@
}
function agentsToolbar() {
const clear = btn("Mayday aufheben", "btn small danger", () => confirmAct("clear-mayday"));
const clear = iconBtn("bi-life-preserver", "Mayday aufheben", "btn small danger", () => confirmAct("clear-mayday"));
const mode = h("span", {});
const night = h("details", {class: "menu"}, h("summary", {}, "Tag/Nacht ▾"), h("div", {class: "menu-box"}, mode,
const night = h("details", {class: "menu"}, h("summary", named("Tag/Nacht"), icon("bi-moon-stars"), caret()), h("div", {class: "menu-box"}, mode,
h("p", {class: "muted"}, "Abend: Agenten beenden ihre Aufgabe. Sonnenuntergang: sie schlafen. Sonnenaufgang: alle wachen auf."),
h("div", {class: "actions"}, ACTIONS.slice(1).map(([id, label]) => btn(label, "btn small", () => confirmAct(id))))));
return {bar: h("div", {class: "toolbar", role: "toolbar", "aria-label": "Steuerung"}, toAll(), clear, btn("Priorität", "btn small", prioritySheet), night), clear, mode};
return {bar: h("div", {class: "toolbar", role: "toolbar", "aria-label": "Steuerung"}, toAll(), clear, iconBtn("bi-sort-down", "Priorität", "btn small", prioritySheet), night), clear, mode};
}
function drawToolbar() {
+1 -1
View File
@@ -25,7 +25,7 @@
window.addEventListener("hashchange", apply);
window.addEventListener("resize", measure);
window.addEventListener("resize", redrawSky);
window.addEventListener("resize", requestSpace);
window.addEventListener("resize", refresh3d);
skyPanel();
radio.element = h("audio", {preload: "none"});
radio.element.addEventListener("error", () => {
+76
View File
@@ -0,0 +1,76 @@
const CLEANUP_POLL = 2000;
const CLEANUP_PATIENCE = 900000;
const RESULT_LABELS = {done: ["gelöscht", "ok"], refused: ["verweigert", "bad"], failed: ["fehlgeschlagen", "bad"]};
function planOf(item) {
const repo = (tree.data.repos || []).find((entry) => entry.path === item.repository);
const entry = repo ? repo.branches.find((candidate) => candidate.name === item.branch) : null;
return {repo, entry};
}
function deleteDialog(items) {
if (!items.length) {
return;
}
const rows = items.map((item) => {
const {repo, entry} = planOf(item);
return h("li", {class: "dry-run"},
h("div", {class: "row"}, h("strong", {}, repo ? repo.name : item.repository), h("code", {}, item.branch)),
h("pre", {}, (entry ? entry.plan : [`${item.branch} @ ${item.tip.slice(0, 12)}`]).join("\n")));
});
const outcome = h("div", {class: "mini", "aria-live": "polite"});
const confirm = h("button", {type: "button", class: "btn danger", ...named("Löschen", "Löschen: mit deiner Passkey-Signatur an den Host übergeben")}, icon("bi-trash3"), "Löschen");
confirm.addEventListener("click", async () => {
confirm.disabled = true;
if (!await removeSigned(items, outcome)) {
confirm.disabled = false;
}
});
openSheet(items.length > 1 ? `${items.length} gemergte Branches löschen` : `${items[0].branch} löschen`, [
h("p", {class: "muted"}, "Trockenlauf von pkgmgr branch cleanup --local für genau diese Einträge: Worktree, lokaler Branch, Compose-Stack ohne -v und seine Images. Kein Remote-Branch. Der Host prüft deine Signatur und verweigert jeden Eintrag, dessen Stand sich seither geändert hat."),
h("ul", {class: "mini", "aria-label": "Trockenlauf"}, rows),
outcome,
h("div", {class: "actions"}, h("span", {class: "spacer"}), btn("Abbrechen", "btn ghost", closeSheet), confirm),
], false);
}
async function removeSigned(items, outcome) {
const signed = await proofFor(items, "Löschen");
if (signed === null) {
outcome.replaceChildren(h("p", {class: "muted"}, "Nicht signiert: nichts wurde übergeben."));
return false;
}
const result = await sendJson("/api/cleanup", {items, proof: signed.proof});
if (!result.ok || !result.data) {
toast("Löschen: nicht möglich", fail(result), false);
outcome.replaceChildren(h("p", {class: "muted"}, `${result.status} ${result.text}`.trim()));
return false;
}
outcome.replaceChildren(h("p", {class: "muted", "data-cleanup": result.data.id}, `Signiert und an den Host übergeben (Job ${result.data.id}); die Path-Unit löscht jetzt.`));
followCleanup(result.data.id, outcome, Date.now());
return true;
}
async function followCleanup(id, outcome, began) {
let data = null;
try {
const response = await fetch(`/api/cleanup/${encodeURIComponent(id)}`, {cache: "no-store"});
data = response.ok ? await response.json() : null;
} catch {
data = null;
}
if (!data || data.status !== "done") {
if (Date.now() - began < CLEANUP_PATIENCE && outcome.isConnected) {
window.setTimeout(() => followCleanup(id, outcome, began), CLEANUP_POLL);
}
return;
}
const lines = data.items.map((item) => `${item.branch}: ${(RESULT_LABELS[item.status] || [item.status])[0]}${item.reason ? ` (${item.reason})` : ""}`);
outcome.replaceChildren(h("ul", {class: "mini", "aria-label": "Ergebnis"}, data.error ? h("li", {}, data.error) : data.items.map((item) => {
const [label, tone] = RESULT_LABELS[item.status] || [item.status, "neutral"];
return h("li", {"data-result": item.status}, h("div", {class: "row"}, h("code", {}, item.branch), pill(label, tone)), item.reason ? h("span", {class: "muted"}, item.reason) : null);
})));
const good = !data.error && data.items.every((item) => item.status === "done");
toast(`Löschen: ${good ? "erledigt" : "nicht alles gelöscht"}`, data.error ? [data.error] : lines, good);
fetchTree();
}
+4 -5
View File
@@ -1,4 +1,6 @@
const NS = "http://www.w3.org/2000/svg";
const NONCE = document.currentScript ? document.currentScript.nonce : "";
const ACTIVE_BEAT = 900;
const DAY = 86400;
const CHAIR = "Maintainer";
const HUMAN = "Operator";
@@ -7,6 +9,7 @@
const PARTS = [["user", "Nutzer"], ["assistant", "Assistent"], ["tool_call", "Werkzeugaufruf"], ["tool_result", "Werkzeugergebnis"], ["system", "System"]];
const PART_TONES = {user: "info", assistant: "ok", tool_call: "violet", tool_result: "teal", system: "neutral"};
const KIND_TONES = {radio: "teal", review: "info", ask: "warn", decision: "violet", operator: "bad", register: "neutral", rename: "info", cron: "teal", rule: "ok", approval: "ok", run: "info", transcript: "neutral"};
const VIEW_ICONS = {"fuer-dich": "bi-person-check", funk: "bi-broadcast", arbeit: "bi-briefcase", transkripte: "bi-chat-left-text"};
const ITEM_LABELS = {review: "Review", ask: "Ask", decision: "Entscheidung", operator: "Operator"};
const NIGHT = {day: ["Tag", "ok"], evening: ["Abend", "warn"], sunset: ["Nacht", "violet"]};
const ACTIONS = [["clear-mayday", "Mayday aufheben", "Die Funkstille des aktiven Maydays aufheben?"], ["evening", "Abend", "Alle Agenten in den Abend schicken (kvb-agent-relax)?"], ["sunset", "Sonnenuntergang", "Alle Agenten schlafen legen (kvb-agent-sleep)?"], ["sunrise", "Sonnenaufgang", "Alle Agenten wecken (kvb-agent-wakeup)?"]];
@@ -42,15 +45,11 @@
const STAGE_LABELS = {night: "Nacht", astronomical: "Astronomische Dämmerung", nautical: "Nautische Dämmerung", civil: "Bürgerliche Dämmerung", golden: "Goldene Stunde", day: "Tag"};
const WEEKDAYS = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
const COMPASS = [["N", "Norden"], ["NO", "Nordosten"], ["O", "Osten"], ["SO", "Südosten"], ["S", "Süden"], ["SW", "Südwesten"], ["W", "Westen"], ["NW", "Nordwesten"]];
const AXES = [["time", "Zeit", "Tiefe = Zeit im gewählten Zeitraum: jeder Agent eine Weltlinie"], ["spawn", "Spawn-Tiefe", "Tiefe = Spawn-Tiefe: Subagenten hängen als Baum unter ihrer Session"]];
const SUBS_TTL = 60000;
const TURN = 10;
const NUDGE = 40;
const ZOOM = 1.2;
const GLIDE = 320;
const PLANE = 450;
const SAVE_DELAY = 700;
const TRACK_RETRY = 5000;
const CALM = [1, 9 / 8, 5 / 4, 3 / 2, 5 / 3, 2];
const TENSE = [1, 16 / 15, 6 / 5, 45 / 32, 3 / 2, 8 / 5];
const BASE = 110;
@@ -62,7 +61,7 @@
direct: [[740, 0, 0.2]],
radio: [[520, 0, 0.07]],
};
const KEYS = [["Strg+K", "Befehle, Ansichten, Agenten und KI-Befehl"], ["1 · 2 · 3", "Stream · Agents · Artifacts"], ["j / k", "Inbox: nächster / vorheriger Eintrag"], ["x", "Inbox: Eintrag auswählen"], ["a", "Inbox: Eintrag freigeben (3 s rückgängig)"], ["Enter", "Inbox: Eintrag öffnen"], ["/", "Stream durchsuchen"], ["h", "Himmel ansehen: ziehen dreht den Blick, Rad oder zwei Finger zoomen, Esc beendet"], ["Pfeile · + / − · 0", "3D-Graph und Himmel (fokussiert): drehen · zoomen · zurücksetzen"], ["Umschalt+Pfeile", "3D-Graph: verschieben (auch Umschalt+Ziehen)"], ["🎙 halten", "Spracheingabe ins Agentenfeld oder die Befehlszeile, nie automatisch gesendet"], ["?", "Diese Hilfe"], ["Esc", "Schließen"]];
const KEYS = [["Strg+K", "Befehle, Ansichten, Agenten und KI-Befehl"], ["1 · 2 · 3", "Stream · Agents · Artifacts"], ["j / k", "Inbox: nächster / vorheriger Eintrag"], ["x", "Inbox: Eintrag auswählen"], ["a", "Inbox: Eintrag freigeben (3 s rückgängig)"], ["Enter", "Inbox: Eintrag öffnen"], ["/", "Stream durchsuchen"], ["h", "Himmel ansehen: ziehen dreht den Blick, Rad oder zwei Finger zoomen, Esc beendet"], ["Pfeile · + / − · 0", "Himmel (fokussiert): drehen · zoomen · zurücksetzen"], ["Ziehen · Rad", "3D: drehen, rechts ziehen verschiebt, Rad zoomt"],["🎙 halten", "Spracheingabe ins Agentenfeld oder die Befehlszeile, nie automatisch gesendet"], ["?", "Diese Hilfe"], ["Esc", "Schließen"]];
const calm = window.matchMedia("(prefers-reduced-motion: reduce)");
const relative = new Intl.RelativeTimeFormat("de", {numeric: "auto"});
const absolute = new Intl.DateTimeFormat("de-DE", {dateStyle: "medium", timeStyle: "short"});
+15 -7
View File
@@ -17,6 +17,10 @@
}
const h = (tag, attrs, ...children) => build(document.createElement(tag), attrs, children);
const s = (tag, attrs, ...children) => build(document.createElementNS(NS, tag), attrs, children);
const icon = (name, cls) => s("svg", {class: cls ? `bi ${cls}` : "bi", "aria-hidden": "true", focusable: "false"}, s("use", {href: `#${name}`}));
const caret = () => icon("bi-caret-down-fill", "caret");
const named = (label, hint) => ({title: hint || label, "aria-label": label});
const iconBtn = (name, label, cls, run, hint) => h("button", {type: "button", class: `${cls} icon-only`, ...named(label, hint), on: {click: run}}, icon(name));
const dash = (value) => (value === "" || value === "-" || value === null || value === undefined ? "–" : value);
const targetsOf = (row) => (row.to.length ? row.to.join(", ") : "alle");
const fixed = (value) => value.toFixed(1);
@@ -91,18 +95,22 @@
return (data.runs || []).flatMap((run) => [`$ ${run.command} → ${run.code}`, run.stdout.trim(), run.stderr.trim()].filter(Boolean));
}
function relabel(button, name, label, hint) {
button.replaceChildren(icon(name));
button.setAttribute("aria-label", label);
button.title = hint || label;
}
function copyButton(text) {
const button = h("button", {type: "button", class: "btn small ghost", title: "In die Zwischenablage kopieren"}, "Kopieren");
const button = iconBtn("bi-clipboard", "Kopieren", "btn small ghost", null, "Kopieren: in die Zwischenablage");
button.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(text);
button.textContent = "Kopiert";
relabel(button, "bi-clipboard-check", "Kopiert");
} catch {
button.textContent = "Nicht möglich";
relabel(button, "bi-x-circle", "Nicht möglich");
}
window.setTimeout(() => {
button.textContent = "Kopieren";
}, 1600);
window.setTimeout(() => relabel(button, "bi-clipboard", "Kopieren"), 1600);
});
return button;
}
@@ -118,7 +126,7 @@
function openSheet(title, body, wide) {
const sheet = document.getElementById("sheet");
sheet.replaceChildren(h("div", {class: `sheet-box${wide ? " wide" : ""}`, role: "dialog", "aria-modal": "true", "aria-label": title},
h("div", {class: "sheet-head"}, h("h2", {class: "panel-title"}, title), h("span", {class: "spacer"}), h("button", {type: "button", class: "btn small ghost", "aria-label": "Schließen", on: {click: closeSheet}}, "×")),
h("div", {class: "sheet-head"}, h("h2", {class: "panel-title"}, title), h("span", {class: "spacer"}), iconBtn("bi-x-lg", "Schließen", "btn small ghost", closeSheet)),
h("div", {class: "sheet-body"}, body)));
sheet.hidden = false;
}
+8 -6
View File
@@ -103,7 +103,9 @@
return;
}
const kinds = activeKinds();
stream.dom.categories.replaceChildren(...Object.entries(state.views).map(([id, view]) => h("button", {type: "button", class: "chip preset", "aria-pressed": String(filter.view === id), on: {click: () => useView(id)}}, view.label)));
stream.dom.categories.replaceChildren(...Object.entries(state.views).map(([id, view]) => h("button", {
type: "button", class: "chip preset icon-only", ...named(view.label), "aria-pressed": String(filter.view === id), on: {click: () => useView(id)},
}, icon(VIEW_ICONS[id] || "bi-funnel"))));
const mine = userViews.map((view) => h("span", {class: "chip", "aria-pressed": String(!filter.view && filter.saved === view.name), role: "button", tabindex: 0, on: {
click: () => useSaved(view),
keydown: (event) => {
@@ -111,11 +113,11 @@
useSaved(view);
}
},
}}, view.name, h("button", {type: "button", class: "x", "aria-label": `${view.name} löschen`, on: {click: (event) => {
}}, view.name, h("button", {type: "button", class: "x", ...named(`${view.name} löschen`), on: {click: (event) => {
event.stopPropagation();
dropView(view.name);
}}}, "×")));
stream.dom.saved.replaceChildren(...mine, btn("+ Ansicht speichern", "btn small ghost", saveView));
}}}, icon("bi-x-lg"))));
stream.dom.saved.replaceChildren(...mine, iconBtn("bi-save", "Ansicht speichern", "btn small ghost", saveView));
const kindChips = KINDS.map(([id, label]) => h("button", {type: "button", class: "chip", "aria-pressed": String(kinds.has(id)), on: {click: () => {
customize();
if (filter.kinds.has(id) && filter.kinds.size > 1) {
@@ -141,11 +143,11 @@
}}}, label)) : [h("span", {class: "muted"}, "Erst mit der Art Transkripte.")];
stream.dom.parts.replaceChildren(...parts);
const focus = filter.sub || filter.session || filter.agent ? h("span", {class: "chip", "aria-pressed": "true"}, filter.sub ? `Subagent ${filter.sub.slice(0, 12)}` : filter.session ? `Session ${filter.session.slice(0, 8)}` : `Agent ${filter.agent}`,
h("button", {type: "button", class: "x", "aria-label": "Filter entfernen", on: {click: () => {
h("button", {type: "button", class: "x", ...named("Filter entfernen"), on: {click: () => {
filter.sub = "";
filter.session = "";
filter.agent = "";
refilter();
}}}, "×")) : null;
}}}, icon("bi-x-lg"))) : null;
stream.dom.focus.replaceChildren(...(focus ? [focus] : []));
}
+9 -22
View File
@@ -157,28 +157,20 @@
}
function legend() {
const axis = graph3d() ? depth() : "none";
const words = {
none: "Linienstärke = Anzahl · Nähe = Zusammenarbeit der letzten Zeit",
time: "Weltlinie = Agent, Lage = Nähe der Zusammenarbeit zu ihrer Zeit · Bogen = ein Ereignis · Ebene = Stand der Wiedergabe",
spawn: "Ebenen = Spawn-Tiefe · Bogen = Nachrichten im Zeitraum, Zahl im Stream",
};
return h("div", {class: "legend"}, LEGEND.map(([name, label]) => h("span", {}, h("i", {class: `swatch t-${name}`}), label)),
axis === "none" ? null : h("span", {}, h("i", {class: "swatch t-spawn"}), "Spawn"),
h("span", {class: "muted"}, words[axis]));
h("span", {class: "muted"}, "Linienstärke = Anzahl · Nähe = Zusammenarbeit der letzten Zeit"));
}
function side() {
const node = state.graph.nodes.find((entry) => entry.id === ui.node);
if (!node) {
const where = graph3d() ? "eine Weltlinie oder darunter auf ihren Namen" : "einen Knoten";
return [h("h2", {class: "panel-title"}, "Details"), h("p", {class: "muted"}, `Klicke auf ${where}: sein Panel zeigt Aufgaben, Claims, Subagenten und Verlauf.`)];
return [h("h2", {class: "panel-title"}, "Details"), h("p", {class: "muted"}, "Klicke auf einen Knoten: sein Panel zeigt Aufgaben, Claims, Subagenten und Verlauf.")];
}
const messages = state.radio.filter((row) => row.source === node.id || row.targets.includes(node.id)).slice(-10).reverse();
return [
h("div", {class: "panel-head"},
h("div", {}, h("h2", {class: "panel-title"}, node.label), h("span", {class: "muted"}, `${NODE_KINDS[node.kind]}${node.parent ? ` von ${labelOf(node.parent)}` : ""}`)),
h("button", {type: "button", class: "btn small ghost", "aria-label": "Schließen", on: {click: () => select(node.id)}}, "×")),
iconBtn("bi-x-lg", "Schließen", "btn small ghost", () => select(node.id))),
h("h3", {}, "Letzte Nachrichten", counter(messages.length)),
messages.length ? h("ul", {class: "mini"}, messages.map((row) => h("li", {},
h("div", {class: "row"}, klass(row.class), when(row.ts)),
@@ -188,22 +180,17 @@
}
function viewSwitch(screen) {
return h("div", {class: "segmented", role: "group", "aria-label": "Ansicht"}, Nav.VIEWS.map(([id, name]) => h("button", {type: "button", "aria-pressed": String(ui.views[screen] === id), on: {click: () => go(screen, id)}}, name)));
return h("div", {class: "segmented", role: "group", "aria-label": "Ansicht"}, Nav.VIEWS.map(([id, name]) => h("button", {
type: "button", class: "icon-only", ...named(name), "aria-pressed": String(ui.views[screen] === id), on: {click: () => go(screen, id)},
}, icon(Nav.ICONS[id]))));
}
const later = (what) => emptyState(`${what} folgt`, "Diese Ansicht ist geplant und kommt mit dem nächsten Ausbau.");
function agentsScreen() {
const view = ui.views.agents;
const stageBox = h("div", {class: "stage"});
const aside = h("aside", {class: "card panel", "aria-live": "polite"});
const toolbar = agentsToolbar();
graphDom = {stage: stageBox, aside, pulses: null, space: null, toolbar};
const body = view === "tree" ? later("Der Spawn-Baum") : h("div", {class: "graph-body"}, h("div", {class: "card stage-card"}, stageBox, legend()), aside);
return h("div", {class: "graph"}, toolbar.bar, h("div", {class: "card controls"}, viewSwitch("agents"), view === "3d" ? spaceTools() : null), body);
}
function artifactsScreen() {
const view = ui.views.artifacts;
return h("div", {class: "graph"}, h("div", {class: "card controls"}, viewSwitch("artifacts")), view === "tree" ? treeScreen() : later(`Artifacts ${Nav.VIEWS.find(([id]) => id === view)[1]}`));
graphDom = {stage: stageBox, aside, pulses: null, toolbar};
const body = view === "tree" ? spawnScreen() : h("div", {class: "graph-body"}, h("div", {class: "card stage-card"}, stageBox, view === "3d" ? null : legend()), aside);
return h("div", {class: "graph"}, toolbar.bar, h("div", {class: "card controls"}, viewSwitch("agents"), view === "3d" ? layoutTools("agents") : null), body);
}
+248
View File
@@ -0,0 +1,248 @@
const LAYOUTS = [
["none", "Kraft", "bi-magnet", "Kräfte ordnen frei: Verbundenes zieht sich an, alles stößt sich ab"],
["time", "Zeit", "bi-clock", "Tiefe = Zeit: Früheres liegt hinten, Jüngeres vorn"],
["spawn", "Spawn-Tiefe", "bi-layers", "Tiefe = Spawn-Tiefe: jede Ebene eine Generation"],
];
const KIND_COLORS = {operator: "accent", chair: "accent", session: "info", subagent: "violet", twig: "violet", repo: "warn", branch: "info", worktree: "teal", agent: "accent", artefact: "violet", task: "muted"};
const LINK_COLORS = {spawn: "violet", contains: "line-strong", merge: "grant", overlap: "alarm", direct: "direct", broadcast: "broadcast", alarm: "alarm", security: "security", grant: "grant"};
const DEPTH_GAP = 90;
const TIME_DEPTH = 640;
const g3 = {lib: null, three: null, graph: null, host: null, tab: "", sig: "", planes: [], colors: null, failed: ""};
const layoutOf = () => space.views.graph.axis;
function loadScript(src) {
return new Promise((resolve, reject) => {
const tag = document.createElement("script");
tag.src = src;
tag.nonce = NONCE;
tag.async = true;
tag.addEventListener("load", resolve);
tag.addEventListener("error", () => reject(new Error(`${src} nicht geladen`)));
document.head.append(tag);
});
}
function forceGraph() {
if (!g3.lib) {
g3.lib = loadScript("/vendor/3d-force-graph.min.js").then(() => window.ForceGraph3D);
}
return g3.lib;
}
function threeModule() {
if (!g3.three) {
g3.three = import("/vendor/three.module.js").catch(() => null);
}
return g3.three;
}
function layoutTools(tab) {
const group = h("div", {class: "segmented", role: "group", "aria-label": "Anordnung"}, LAYOUTS.map(([id, label, name, hint]) => h("button", {
type: "button", class: "icon-only", "data-layout": id, ...named(label, `${label}: ${hint}`), "aria-pressed": String(layoutOf() === id), on: {click: () => chooseLayout(tab, id, group)},
}, icon(name))));
return h("div", {class: "space-tools"}, group,
iconBtn("bi-arrow-counterclockwise", "Ansicht zurücksetzen", "btn small ghost", () => g3.graph && g3.graph.zoomToFit(calm.matches ? 0 : 600, 40)),
h("span", {class: "muted"}, "Ziehen dreht · rechts ziehen verschiebt · Rad zoomt · Klick öffnet das Panel"));
}
function chooseLayout(tab, id, group) {
for (const button of group.querySelectorAll("button")) {
button.setAttribute("aria-pressed", String(button.dataset.layout === id));
}
if (id !== layoutOf()) {
setView("graph", {...space.views.graph, axis: id}, false);
g3.sig = "";
if (ui.screen === tab) {
stage3d(tab, g3.host);
}
}
}
function agentModel() {
const depthOf = {operator: 0, chair: 1, session: 2, subagent: 3};
const nodes = state.graph.nodes.map((node) => ({
id: node.id, kind: node.kind, label: node.label, depth: depthOf[node.kind], ts: (node.timeline[0] || {}).since || state.since,
parent: node.kind === "operator" ? null : node.kind === "chair" ? HUMAN : node.kind === "session" ? chairKey() : node.parent || chairKey(),
}));
for (const node of state.graph.nodes.filter((entry) => entry.kind === "session" || entry.kind === "chair")) {
const walk = (twig, parent, depth) => {
nodes.push({id: twig.id, kind: "twig", label: twig.label, depth, ts: twig.row.started, parent, session: twig.session, sub: twig.row.id});
twig.children.forEach((child) => walk(child, twig.id, depth + 1));
};
twigs(node.id).forEach((twig) => walk(twig, node.id, depthOf[node.kind] + 1));
}
const ids = new Set(nodes.map((node) => node.id));
const links = nodes.filter((node) => node.parent && ids.has(node.parent)).map((node) => ({source: node.parent, target: node.id, kind: "spawn"}));
for (const link of aggregate().filter((entry) => entry.target !== "*" && ids.has(entry.source) && ids.has(entry.target) && entry.source !== entry.target)) {
links.push({source: link.source, target: link.target, kind: tone(link.cls), count: link.count});
}
return {nodes, links};
}
function model3d(tab) {
if (tab === "agents") {
return agentModel();
}
const built = tree.model || {nodes: [], links: []};
return {nodes: built.nodes.map((node) => ({id: node.id, kind: node.kind, label: node.label, depth: node.depth, ts: node.ts, parent: node.parent})), links: built.links.map((link) => ({...link}))};
}
function place(nodes, layout) {
const times = nodes.map((node) => node.ts).filter(Number.isFinite);
const [low, high] = [Math.min(...times), Math.max(...times)];
const span = high - low;
for (const node of nodes) {
if (layout === "spawn") {
node.fz = -node.depth * DEPTH_GAP;
} else if (layout === "time") {
node.fz = span > 0 && Number.isFinite(node.ts) ? ((node.ts - low) / span - 0.5) * TIME_DEPTH : 0;
} else {
delete node.fz;
}
}
}
function colors3d() {
const css = window.getComputedStyle(document.documentElement);
const value = (name) => css.getPropertyValue(`--${name}`).trim() || "#888";
const roles = new Set([...Object.values(KIND_COLORS), ...Object.values(LINK_COLORS), "text"]);
g3.colors = Object.fromEntries([...roles].map((role) => [role, value(role)]));
return g3.colors;
}
async function drawPlanes(layout, nodes) {
const graph = g3.graph;
const scene = graph.scene();
for (const plane of g3.planes) {
scene.remove(plane);
}
g3.planes = [];
if (layout === "none") {
return;
}
const THREE = await threeModule();
if (!THREE || g3.graph !== graph) {
return;
}
const levels = [...new Set(nodes.map((node) => node.fz))].filter(Number.isFinite).sort((a, b) => a - b);
const shown = layout === "spawn" ? levels : [levels[0], levels[levels.length - 1]].filter(Number.isFinite);
for (const z of new Set(shown)) {
const grid = new THREE.GridHelper(520, 8, g3.colors["line-strong"], g3.colors["line-strong"]);
grid.rotation.x = Math.PI / 2;
grid.position.z = z;
grid.material.transparent = true;
grid.material.opacity = 0.18;
scene.add(grid);
g3.planes.push(grid);
}
}
function pick3d(tab, node) {
if (tab === "artifacts") {
artSelect(node.id);
} else if (node.kind === "twig") {
focusSub(node.session, node.sub);
} else {
select(node.id);
}
}
function list3d(tab, data) {
const pickable = data.nodes.filter((node) => node.kind !== "operator");
return h("details", {class: "space-list"}, h("summary", {}, `${pickable.length} Knoten als Liste`),
h("div", {class: "chips", role: "group", "aria-label": "Knoten"}, pickable.slice(0, 400).map((node) => h("button", {type: "button", class: "chip", title: node.label, "data-node": node.id, on: {click: () => pick3d(tab, node)}}, node.label))));
}
function signature3d(tab, data) {
return JSON.stringify([tab, layoutOf(), data.nodes.map((node) => [node.id, node.kind, node.depth]), data.links.map((link) => [link.source, link.target, link.kind])]);
}
async function stage3d(tab, host) {
if (!host) {
return;
}
if (tab === "agents") {
loadSpawn();
}
const data = model3d(tab);
const sig = signature3d(tab, data);
if (g3.host === host && g3.sig === sig) {
return;
}
if (g3.host !== host) {
dispose3d();
g3.host = host;
g3.tab = tab;
host.replaceChildren(h("div", {class: "graph3d", role: "img", "aria-label": `3D-Graph ${tab === "agents" ? "der Agenten" : "der Artefakte"}; die Knoten stehen auch darunter als Liste`}), h("div", {class: "space-note"}, "Lade 3D …"));
}
g3.sig = sig;
const box = host.querySelector(".graph3d");
let Graph = null;
try {
Graph = await forceGraph();
} catch (error) {
g3.failed = String(error.message || error);
}
if (g3.host !== host) {
return;
}
if (!Graph) {
host.replaceChildren(emptyState("3D nicht geladen", g3.failed || "3d-force-graph fehlt."));
return;
}
if (!g3.graph) {
try {
g3.graph = Graph({controlType: "orbit"})(box);
} catch (error) {
host.replaceChildren(emptyState("3D nicht verfügbar", `Dieser Browser gibt kein WebGL: ${error.message || error}`));
g3.host = null;
return;
}
const colors = colors3d();
g3.graph.backgroundColor("rgba(0,0,0,0)").showNavInfo(false).nodeRelSize(5).nodeOpacity(0.92).linkOpacity(0.45)
.nodeLabel((node) => h("span", {}, node.label)).nodeVal((node) => ({operator: 6, chair: 4, repo: 6, branch: 3, session: 2.5}[node.kind] || 1))
.nodeColor((node) => g3.colors[KIND_COLORS[node.kind]] || colors.text).linkColor((link) => g3.colors[LINK_COLORS[link.kind]] || colors.text)
.linkWidth((link) => (link.kind === "contains" || link.kind === "spawn" ? 0.6 : Math.min(0.8 + Math.log2(1 + (link.count || 1)), 4)))
.onNodeClick((node) => pick3d(g3.tab, node)).onEngineStop(() => g3.graph && g3.graph.zoomToFit(calm.matches ? 0 : 400, 40));
g3.graph.warmupTicks(calm.matches ? 160 : 80);
if (calm.matches) {
g3.graph.cooldownTicks(0);
} else {
g3.graph.cooldownTime(4000);
}
}
const layout = layoutOf();
place(data.nodes, layout);
g3.graph.width(box.clientWidth).height(box.clientHeight).graphData(data);
drawPlanes(layout, data.nodes);
const words = `${data.nodes.length} Knoten · ${data.links.length} Kanten · ${LAYOUTS.find(([id]) => id === layout)[1]}${calm.matches ? " · ohne Bewegung (prefers-reduced-motion)" : ""}`;
host.replaceChildren(box, h("div", {class: "space-note"}, words), list3d(tab, data));
window.requestAnimationFrame(() => g3.graph && g3.host === host && g3.graph.zoomToFit(0, 40));
}
function dispose3d() {
if (g3.graph) {
g3.graph.pauseAnimation();
if (typeof g3.graph._destructor === "function") {
g3.graph._destructor();
}
}
g3.graph = null;
g3.host = null;
g3.sig = "";
g3.planes = [];
}
function refresh3d() {
if (!g3.graph || !g3.host) {
return;
}
if (!g3.host.isConnected) {
dispose3d();
return;
}
colors3d();
const box = g3.host.querySelector(".graph3d");
g3.graph.width(box.clientWidth).height(box.clientHeight).nodeColor(g3.graph.nodeColor()).linkColor(g3.graph.linkColor());
}
+10 -10
View File
@@ -22,7 +22,7 @@
function undoToast(batch, label) {
const left = h("span", {class: "muted"});
const button = h("button", {type: "button", class: "btn small"}, "Rückgängig");
const button = h("button", {type: "button", class: "btn small", ...named("Rückgängig")}, icon("bi-arrow-counterclockwise"), "Rückgängig");
const box = h("div", {class: "toast wait", role: "status"}, h("strong", {}, label), h("div", {class: "undo-row"}, left, h("span", {class: "spacer"}), button));
let timer = 0;
const count = () => {
@@ -117,11 +117,11 @@
}
const buttons = [];
if (item.kind === "review" || item.kind === "ask") {
buttons.push(btn("Freigeben", "btn small primary", () => approveOne(item)), btn("Ablehnen", "btn small danger", () => rejectOne(item)));
buttons.push(iconBtn("bi-check-lg", "Freigeben", "btn small primary", () => approveOne(item)), iconBtn("bi-x-lg", "Ablehnen", "btn small danger", () => rejectOne(item)));
} else if (item.kind === "decision" && item.recommended) {
buttons.push(btn(`Empfehlung: ${item.recommended}`, "btn small primary", () => approveOne(item)));
buttons.push(h("button", {type: "button", class: "btn small primary", ...named(`Empfehlung: ${item.recommended}`), on: {click: () => approveOne(item)}}, icon("bi-check-lg"), item.recommended));
} else if (item.kind === "operator" && item.runnable) {
buttons.push(btn("Ausführen", "btn small primary", () => approveOne(item)));
buttons.push(iconBtn("bi-play-fill", "Ausführen", "btn small primary", () => approveOne(item)));
}
return buttons;
}
@@ -153,10 +153,10 @@
actionsFor(item),
item.kind === "operator" ? copyButton(item.command) : null,
h("span", {class: "spacer"}),
btn("1 h", "btn small ghost", () => mark(item, nowSeconds() + 3600, item.pinned)),
btn("morgen", "btn small ghost", () => mark(item, tomorrow(), item.pinned)),
item.until ? btn("wecken", "btn small ghost", () => mark(item, 0, item.pinned)) : null,
btn(item.pinned ? "lösen" : "anheften", "btn small ghost", () => mark(item, item.until, !item.pinned))));
iconBtn("bi-hourglass-split", "1 h", "btn small ghost", () => mark(item, nowSeconds() + 3600, item.pinned), "1 h: eine Stunde zurückstellen"),
iconBtn("bi-sunrise", "morgen", "btn small ghost", () => mark(item, tomorrow(), item.pinned), "morgen: bis morgen 8 Uhr zurückstellen"),
item.until ? iconBtn("bi-alarm", "wecken", "btn small ghost", () => mark(item, 0, item.pinned), "wecken: wieder zeigen") : null,
iconBtn(item.pinned ? "bi-pin-angle-fill" : "bi-pin-angle", item.pinned ? "lösen" : "anheften", "btn small ghost", () => mark(item, item.until, !item.pinned))));
}
function inboxScreen() {
@@ -188,8 +188,8 @@
ui.snoozed = event.target.checked;
drawInbox();
}}});
const choose = h("button", {type: "button", class: "btn small soft", disabled: !chosen.length || ui.busy, on: {click: () => bulk(chosen, "Auswahl freigeben")}}, `Auswahl freigeben (${chosen.length})`);
const all = h("button", {type: "button", class: "btn small primary", disabled: !green.length || ui.busy, on: {click: () => bulk(green, "Alle grünen freigeben")}}, `Alle grünen freigeben (${green.length})`);
const choose = h("button", {type: "button", class: "btn small soft", ...named(`Auswahl freigeben (${chosen.length})`), disabled: !chosen.length || ui.busy, on: {click: () => bulk(chosen, "Auswahl freigeben")}}, icon("bi-check2-square"), String(chosen.length));
const all = h("button", {type: "button", class: "btn small primary", ...named(`Alle grünen freigeben (${green.length})`), disabled: !green.length || ui.busy, on: {click: () => bulk(green, "Alle grünen freigeben")}}, icon("bi-check2-all"), String(green.length));
inboxDom.replaceChildren(
h("div", {class: "inbox-head"}, h("h2", {class: "group-title"}, "Inbox", counter(waitingCount())), h("span", {class: "spacer"}),
hidden ? h("label", {class: "check"}, snoozed, `${hidden} zurückgestellt`) : null),
+1 -1
View File
@@ -1,6 +1,6 @@
async function load() {
try {
const response = await fetch(`/api/state?${windowQuery()}${wantsTrack() ? "&track=1" : ""}`, {cache: "no-store"});
const response = await fetch(`/api/state?${windowQuery()}`, {cache: "no-store"});
if (!response.ok) {
throw new Error(String(response.status));
}
+1 -1
View File
@@ -142,7 +142,7 @@
return;
}
button.setAttribute("aria-pressed", String(settings.music.on));
button.textContent = settings.music.on ? "♪ Musik an" : "♪ Musik";
relabel(button, "bi-music-note-beamed", settings.music.on ? "Musik an" : "Musik", settings.music.on ? "Musik an: generative Musik ausschalten" : "Musik: generative Musik einschalten");
}
function toggleMusic() {
+1 -1
View File
@@ -46,7 +46,7 @@
meta([["Repo", review.worktree], ["Branch", review.branch], ["Tree", (review.tree || "").slice(0, 12), review.tree], ["Score", review.score]]),
review.notes ? h("pre", {class: "notes"}, review.notes) : null,
h("div", {class: "actions"},
h("a", {class: "btn ghost", href: `/reviews/${encodeURIComponent(review.id)}/page`, target: "_blank", rel: "noopener noreferrer"}, "Vollständige Seite öffnen ↗"),
h("a", {class: "btn ghost icon-only", href: `/reviews/${encodeURIComponent(review.id)}/page`, target: "_blank", rel: "noopener noreferrer", ...named("Vollständige Seite öffnen", "Vollständige Seite öffnen (neuer Tab)")}, icon("bi-box-arrow-up-right")),
h("span", {class: "spacer"}),
item ? actionsFor(item).map((node) => {
node.addEventListener("click", closeSheet);
+7 -3
View File
@@ -2,7 +2,7 @@
function tabButton(id, label, hint) {
const badge = Object.hasOwn(state.badges, id) ? h("span", {class: "badge", hidden: true}) : null;
const button = h("button", {type: "button", id: `tab-${id}`, "aria-controls": "main", title: hint ? `${label} (${hint})` : label, on: {click: () => go(id, null)}}, label, badge);
const button = h("button", {type: "button", id: `tab-${id}`, class: "icon-only", "aria-controls": "main", ...named(label, hint ? `${label} (${hint})` : label), on: {click: () => go(id, null)}}, icon(Nav.ICONS[id]), badge);
navButtons.set(id, {button, badge});
return button;
}
@@ -42,7 +42,7 @@
function signature() {
if (ui.screen === "agents") {
return JSON.stringify([ui.views.agents, depth()]);
return JSON.stringify([ui.views.agents]);
}
return JSON.stringify(ui.screen === "artifacts" ? [ui.views.artifacts] : ui.screen === "rules" ? state.rules : []);
}
@@ -70,9 +70,13 @@
main.replaceChildren(screenOf());
drawn.screen = ui.screen;
drawn.sig = sig;
if (g3.host && !g3.host.isConnected) {
dispose3d();
}
}
stage();
drawSide();
drawSpawn();
drawToolbar();
drawInbox();
drawStatus();
@@ -89,7 +93,7 @@
const banner = document.getElementById("mayday");
const silence = state.silence;
banner.hidden = !silence;
banner.replaceChildren(...(silence ? [h("p", {class: "text"}, `MAYDAY · ${dash(silence.by)}: ${silence.text}`), btn("aufheben", "btn small", () => confirmAct("clear-mayday"))] : []));
banner.replaceChildren(...(silence ? [h("p", {class: "text"}, `MAYDAY · ${dash(silence.by)}: ${silence.text}`), iconBtn("bi-x-octagon", "aufheben", "btn small", () => confirmAct("clear-mayday"), "aufheben: Mayday beenden (radio.py clear)")] : []));
document.title = `${state.badges.inbox ? `(${state.badges.inbox}) ` : ""}Agent Orchestrator`;
window.setTimeout(measure, 0);
}
+6 -6
View File
@@ -4,7 +4,7 @@
if (space.edits === space.saved) {
space.views = {graph: {...next.space.graph}, sky: {...next.space.sky}};
space.glides = {graph: null, sky: null};
requestSpace();
refresh3d();
}
sky.mode = "";
drawSky();
@@ -56,15 +56,15 @@
}
function ambience() {
const tool = (id, label, title, run) => h("button", {type: "button", id, class: "btn small", title, on: {click: run}}, label);
const tool = (id, name, label, title, run) => h("button", {type: "button", id, class: "btn small icon-only", ...named(label, title), on: {click: run}}, icon(name));
const section = h("section", {}, h("h3", {}, "Ambiente"), h("div", {class: "toolbar"},
tool("sky-look", "🔭 Himmel", "Himmel ansehen: ziehen dreht den Blick (Taste h)", () => {
tool("sky-look", "bi-stars", "Himmel", "Himmel ansehen: ziehen dreht den Blick (Taste h)", () => {
closeSheet();
skyLook(true);
}),
tool("music-toggle", "♪ Musik", "Generative Musik an oder aus", toggleMusic),
tool("leitstand", "Leitstand", "Leitstand: Vollbild mit großer Schrift", toggleLeitstand),
tool("notify", "Benachrichtigungen", "Desktop-Benachrichtigungen des Browsers erlauben", askNotify)));
tool("music-toggle", "bi-music-note-beamed", "Musik", "Musik: generative Musik an oder aus", toggleMusic),
tool("leitstand", "bi-display", "Leitstand", "Leitstand: Vollbild mit großer Schrift", toggleLeitstand),
tool("notify", "bi-bell", "Benachrichtigungen", "Benachrichtigungen: Desktop-Benachrichtigungen des Browsers erlauben", askNotify)));
window.setTimeout(() => {
musicButton();
notifyLabel();
+10 -10
View File
@@ -22,7 +22,7 @@
root.style.setProperty("--card", `${glass ? Ambience.CARD * 100 : 100}%`);
root.style.colorScheme = mode;
root.dataset.mode = mode;
requestSpace();
refresh3d();
}
function drawMoon(paint, at, radius, moon) {
@@ -191,16 +191,16 @@
function skyPanel() {
const panel = document.getElementById("sky-panel");
const step = (text, label, run) => h("button", {type: "button", class: "btn small", title: label, "aria-label": label, on: {click: run}}, text);
const step = (name, label, run) => iconBtn(name, label, "btn small", run);
space.compass = h("span", {class: "compass", "aria-live": "polite"});
panel.append(space.compass,
step("◀", "Blick nach links drehen (Pfeil links)", () => skyHooks.turnStep(-1, 0)),
step("▶", "Blick nach rechts drehen (Pfeil rechts)", () => skyHooks.turnStep(1, 0)),
step("▲", "Blick heben (Pfeil hoch)", () => skyHooks.turnStep(0, -1)),
step("▼", "Blick senken (Pfeil runter)", () => skyHooks.turnStep(0, 1)),
step("−", "Sichtfeld weiten (Minus)", () => skyHooks.zoomStep(1 / ZOOM)),
step("+", "Hineinzoomen (Plus)", () => skyHooks.zoomStep(ZOOM)),
step("⟲ Zurücksetzen", "Blick zurücksetzen: Süden, Horizont, Sichtfeld 180° (Taste 0)", () => resetView("sky")),
step("Fertig", "Himmel verlassen (Esc)", () => skyLook(false)));
step("bi-caret-left-fill", "Blick nach links drehen (Pfeil links)", () => skyHooks.turnStep(-1, 0)),
step("bi-caret-right-fill", "Blick nach rechts drehen (Pfeil rechts)", () => skyHooks.turnStep(1, 0)),
step("bi-caret-up-fill", "Blick heben (Pfeil hoch)", () => skyHooks.turnStep(0, -1)),
step("bi-caret-down-fill", "Blick senken (Pfeil runter)", () => skyHooks.turnStep(0, 1)),
step("bi-zoom-out", "Sichtfeld weiten (Minus)", () => skyHooks.zoomStep(1 / ZOOM)),
step("bi-zoom-in", "Hineinzoomen (Plus)", () => skyHooks.zoomStep(ZOOM)),
step("bi-arrow-counterclockwise", "Blick zurücksetzen: Süden, Horizont, Sichtfeld 180° (Taste 0)", () => resetView("sky")),
step("bi-check-lg", "Himmel verlassen (Esc)", () => skyLook(false)));
steer(sky.canvas, "sky", skyHooks);
}
-197
View File
@@ -1,197 +0,0 @@
function shownCursor() {
const glide = space.cursor;
if (glide.to === null) {
return state.until;
}
const progress = calm.matches ? 1 : Math.min((performance.now() - glide.start) / PLANE, 1);
return progress >= 1 ? glide.to : glide.from + (glide.to - glide.from) * Space.ease(progress);
}
function sceneInput() {
const span = [Math.min(Math.max(state.since, state.first), state.until), state.until];
const range = ui.asOf !== null && ui.preset !== -1 ? [Math.min(state.first, span[0]), Math.max(state.now, span[1])] : span;
const lives = {};
const subagents = {};
for (const [key, entry] of space.subs) {
if (entry.data) {
lives[key] = [entry.data.since, entry.data.until];
subagents[key] = entry.data.subagents;
}
}
return {
axis: depth(), range, span, cursor: shownCursor(), nodes: state.graph.nodes, track: state.graph.track, now: Object.fromEntries(layout(state.graph.nodes)),
lives, subagents, radio: state.radio, grants: state.grants, chair: chairKey(), cap: Space.ARC_CAP,
};
}
function buildSpace() {
const input = sceneInput();
space.sceneCursor = input.cursor;
space.scene = Space.scene(input);
}
function spaceLabel() {
const hint = AXES.find(([id]) => id === depth())[2];
return `3D-Graph, ${hint}. Pfeiltasten drehen, Umschalt+Pfeile verschieben, Plus und Minus zoomen, 0 setzt zurück. Alle Weltlinien stehen darunter als Schaltflächen.`;
}
function stageSpace() {
if (!graphDom.space) {
const canvas = h("canvas", {class: "space", tabindex: 0, role: "application", "aria-roledescription": "3D-Graph", "aria-label": spaceLabel(), "aria-describedby": "space-help"});
steer(canvas, "graph", graphHooks);
graphDom.space = {canvas, note: h("div", {class: "space-note", "aria-live": "polite"}), list: h("div", {class: "space-list"}), data: null, version: -1, node: undefined, words: "", open: false};
graphDom.stage.replaceChildren(canvas, graphDom.space.note, graphDom.space.list);
}
if (space.cursor.to !== state.until) {
space.cursor = {from: space.cursor.to === null ? state.until : shownCursor(), to: state.until, start: performance.now()};
}
if (wantsTrack() && !state.graph.track.length && Date.now() - space.trackAsked > TRACK_RETRY) {
space.trackAsked = Date.now();
load();
}
buildSpace();
const held = graphDom.space;
if (held.data !== state || held.version !== space.version || held.node !== ui.node) {
held.data = state;
held.version = space.version;
held.node = ui.node;
drawSpaceList();
refreshSubs();
}
drawSpaceNote();
requestSpace();
}
function drawSpaceNote() {
const built = space.scene;
const format = (ts) => absolute.format(stampOf(ts));
const axis = built.axis === "time" ? `Zeitachse ${format(built.range[0])} – ${format(built.range[1])}` : `Stand ${format(built.span[1])}`;
const words = `${built.lines.length} Weltlinien · ${built.arcs.length} Bögen · ${axis}${built.dropped ? ` · ${built.dropped} weitere Ereignisse nur im Stream` : ""}`;
if (graphDom.space.words === words) {
return;
}
graphDom.space.words = words;
const dropped = built.dropped ? [h("span", {}, "Gezeichnet: Mayday und Pan-Pan, Spawns, Sicherheit, Freigaben, dann die neuesten."), btn("Funk im Stream", "btn small soft", () => useView("radio"))] : [];
graphDom.space.note.replaceChildren(h("span", {}, words), ...dropped);
}
function drawSpaceList() {
const built = space.scene;
const held = graphDom.space;
const chip = (line) => {
const text = line.kind === "twig" ? short(line.title) : Space.nameAt(line.timeline, built.span[1]);
const former = [...new Set(line.timeline.map((entry) => entry.name))];
const hint = line.kind === "twig" ? `${line.title} · Transkript im Stream zeigen` : `${NODE_KINDS[line.kind]}${former.length > 1 ? ` · Namen: ${former.join(" → ")}` : ""}`;
return h("button", {type: "button", class: "chip", title: hint, "aria-label": `${text}: ${hint}`, "aria-pressed": String(line.node !== null && line.node === ui.node), on: {click: () => pickLine(line)}}, line.kind === "twig" ? text : [face(text, false), text]);
};
const twigs = built.lines.filter((line) => line.kind === "twig");
const more = twigs.length ? h("details", {open: held.open}, h("summary", {}, `${twigs.length} Subagenten aus den Transkripten`), h("div", {class: "chips"}, twigs.map(chip))) : null;
if (more) {
more.addEventListener("toggle", () => {
held.open = more.open;
});
}
held.list.replaceChildren(h("div", {class: "chips", role: "group", "aria-label": "Weltlinien"}, built.lines.filter((line) => line.kind !== "twig").map(chip)), more || "");
}
async function refreshSubs() {
if (!graph3d() || space.loading) {
return;
}
const due = state.graph.nodes.filter((node) => (node.kind === "session" || node.kind === "chair") && SESSION_ID.test(node.id))
.filter((node) => !space.subs.has(node.id) || Date.now() - space.subs.get(node.id).at > SUBS_TTL);
if (!due.length) {
return;
}
space.loading = true;
await Promise.all(due.map(async (node) => {
let data = null;
try {
const response = await fetch(`/api/subagents?session=${encodeURIComponent(node.id)}`, {cache: "no-store"});
data = response.ok ? await response.json() : null;
} catch {
data = null;
}
space.subs.set(node.id, {at: Date.now(), data});
}));
space.loading = false;
space.version += 1;
stage();
}
function spaceStyle() {
if (!space.style || space.styleKey !== sky.mode) {
const css = window.getComputedStyle(document.documentElement);
const value = (name) => css.getPropertyValue(`--${name}`).trim();
const roles = {line: "line-strong", accent: "accent", muted: "muted", violet: "violet", direct: "direct", broadcast: "broadcast", alarm: "alarm", security: "security", grant: "grant", panel: "panel", text: "text"};
space.styleKey = sky.mode;
space.style = {
colors: Object.fromEntries(Object.entries(roles).map(([role, name]) => [role, value(name)])),
font: `560 ${value("fs-sm")} ${value("sans")}`, strong: `700 ${value("fs-sm")} ${value("sans")}`, level: "Tiefe",
};
}
const built = space.scene;
return {
...space.style, selected: ui.node, cursor: live() ? "Jetzt" : "Stand",
format: (ts) => (built.range[1] - built.range[0] > DAY ? absolute : clock).format(stampOf(ts)),
};
}
function requestSpace() {
if (!space.frame) {
space.frame = window.requestAnimationFrame(paintSpace);
}
}
function paintSpace() {
space.frame = 0;
if (!graph3d() || !graphDom || !graphDom.space || !space.scene) {
return;
}
const canvas = graphDom.space.canvas;
const paint = canvas.getContext("2d");
if (!paint) {
return;
}
const cursor = shownCursor();
if (cursor !== space.sceneCursor) {
buildSpace();
}
const ratio = Math.min(window.devicePixelRatio || 1, 2);
const width = canvas.clientWidth;
const height = canvas.clientHeight;
if (canvas.width !== Math.round(width * ratio) || canvas.height !== Math.round(height * ratio)) {
canvas.width = Math.round(width * ratio);
canvas.height = Math.round(height * ratio);
}
paint.setTransform(ratio, 0, 0, ratio, 0, 0);
space.cam = Space.camera(seenView("graph"), width, height);
Space.paint(paint, space.scene, space.cam, spaceStyle());
if (space.glides.graph || cursor !== space.cursor.to) {
requestSpace();
}
}
function pickLine(line) {
if (line.kind === "twig") {
focusSub(line.session, line.sub);
} else {
select(line.node);
}
}
function pickSpace(x, y) {
const hit = space.scene && space.cam ? Space.pick(space.scene, space.cam, x, y, 10) : null;
if (!hit) {
return;
}
if (hit.kind === "line") {
pickLine(hit.line);
} else if (hit.arc.kind === "spawn") {
focusSub(hit.arc.source, hit.arc.sub);
} else if (hit.arc.kind === "grant") {
select(hit.arc.target);
} else {
useSaved({name: "", kinds: ["radio"], waiting: false, q: "", agent: hit.arc.row >= 0 ? state.radio[hit.arc.row].from : nameOf(hit.arc.source)});
}
}
+3 -35
View File
@@ -1,30 +1,10 @@
const depth = () => (space.views.graph.axis === "spawn" ? "spawn" : "time");
function spaceTools() {
return h("div", {class: "space-tools"},
h("div", {class: "segmented", role: "group", "aria-label": "Tiefenachse"}, AXES.map(([id, label, hint]) => h("button", {type: "button", "aria-pressed": String(depth() === id), title: hint, "aria-label": `Tiefenachse ${label}: ${hint}`, on: {click: () => chooseAxis(id)}}, label))),
h("button", {type: "button", class: "btn small ghost", title: "Drehung, Zoom und Verschiebung zurücksetzen (Taste 0)", "aria-label": "3D-Ansicht zurücksetzen", on: {click: () => resetView("graph")}}, "⟲ Ansicht"),
h("span", {class: "muted", id: "space-help"}, "Ziehen dreht · Umschalt+Ziehen verschiebt · Rad oder zwei Finger zoomen · Pfeile, + / −, 0"));
}
const graph3d = () => ui.screen === "agents" && ui.views.agents === "3d";
const wantsTrack = () => graph3d() && depth() === "time";
function chooseAxis(id) {
if (id === depth()) {
return;
}
setView("graph", {...space.views.graph, axis: id}, false);
render(false);
}
function stage() {
if (!graphDom || ui.screen !== "agents" || ui.views.agents === "tree") {
return;
}
graphDom.pulses = null;
if (graph3d()) {
stageSpace();
if (ui.views.agents === "3d") {
stage3d("agents", graphDom.stage);
return;
}
graphDom.stage.replaceChildren({orbit, chord, swimlane}[ui.views.agents]());
@@ -59,9 +39,7 @@
space.edits += 1;
window.clearTimeout(space.timer);
space.timer = window.setTimeout(persistViews, SAVE_DELAY);
if (kind === "graph") {
requestSpace();
} else {
if (kind === "sky") {
redrawSky();
}
}
@@ -168,16 +146,6 @@
element.addEventListener("keydown", (event) => steerKey(event, kind, hooks));
}
const graphHooks = {
rotate: (dx, dy) => nudge("graph", (view) => ({...view, yaw: view.yaw - dx * 0.4, pitch: view.pitch + dy * 0.4}), false),
pan: (dx, dy) => nudge("graph", (view) => ({...view, pan_x: view.pan_x + dx / (space.cam ? space.cam.scale : 1), pan_y: view.pan_y + dy / (space.cam ? space.cam.scale : 1)}), false),
zoom: (factor) => nudge("graph", (view) => ({...view, zoom: view.zoom * factor}), false),
turnStep: (x, y) => nudge("graph", (view) => ({...view, yaw: view.yaw - x * TURN, pitch: view.pitch + y * TURN}), true),
panStep: (x, y) => nudge("graph", (view) => ({...view, pan_x: view.pan_x + x * NUDGE, pan_y: view.pan_y + y * NUDGE}), true),
zoomStep: (factor) => nudge("graph", (view) => ({...view, zoom: view.zoom * factor}), true),
pick: (x, y) => pickSpace(x, y),
};
const skyLens = () => Space.lens(seenView("sky"), window.innerWidth, window.innerHeight);
const skyHooks = {
+121
View File
@@ -0,0 +1,121 @@
const spawn = {subs: new Map(), loading: false, dom: null};
const SPAWN_KINDS = {operator: "bi-person-circle", chair: "bi-award", session: "bi-robot", subagent: "bi-cpu", twig: "bi-cpu"};
function sessionRunning(node) {
const life = (spawn.subs.get(node.id) || {}).data;
if (life && life.died) {
return false;
}
return node.claims.some((claim) => claim.beat !== null && claim.beat >= state.now - ACTIVE_BEAT);
}
function twigs(session) {
const data = (spawn.subs.get(session) || {}).data;
const rows = data ? data.subagents : [];
const roots = [];
const open = [];
for (const row of rows) {
const twig = {id: `${session}/${row.id}`, kind: "twig", label: `${row.type}: ${row.description}`, running: !row.finished, row, session, children: []};
open.length = Math.min(open.length, Math.max(row.depth - 1, 0));
(open.length ? open[open.length - 1].children : roots).push(twig);
open.push(twig);
}
return roots;
}
function spawnTree() {
const nodes = state.graph.nodes;
const chair = nodes.find((node) => node.kind === "chair");
const entry = (node) => ({
id: node.id, kind: node.kind, label: node.label, node,
running: node.kind === "operator" ? null : node.kind === "subagent" ? node.tasks.some((task) => task.status === "running") : sessionRunning(node),
children: [],
});
const root = entry(nodes.find((node) => node.kind === "operator"));
const byId = new Map([[root.id, root]]);
if (chair) {
byId.set(chair.id, entry(chair));
root.children.push(byId.get(chair.id));
}
for (const node of nodes.filter((item) => item.kind === "session")) {
byId.set(node.id, entry(node));
(byId.get(chairKey()) || root).children.push(byId.get(node.id));
}
for (const node of nodes.filter((item) => item.kind === "subagent")) {
(byId.get(node.parent) || byId.get(chairKey()) || root).children.push(entry(node));
}
for (const [id, held] of byId) {
if (held.kind === "session" || held.kind === "chair") {
held.children.push(...twigs(id));
}
}
return root;
}
function spawnFlat(root) {
const found = [];
const walk = (entry, depth, parent) => {
found.push({entry, depth, parent});
entry.children.forEach((child) => walk(child, depth + 1, entry));
};
walk(root, 0, null);
return found;
}
async function loadSpawn() {
if (spawn.loading) {
return;
}
const due = state.graph.nodes.filter((node) => (node.kind === "session" || node.kind === "chair") && SESSION_ID.test(node.id))
.filter((node) => !spawn.subs.has(node.id) || Date.now() - spawn.subs.get(node.id).at > SUBS_TTL);
if (!due.length) {
return;
}
spawn.loading = true;
await Promise.all(due.map(async (node) => {
let data = null;
try {
const response = await fetch(`/api/subagents?session=${encodeURIComponent(node.id)}`, {cache: "no-store"});
data = response.ok ? await response.json() : null;
} catch {
data = null;
}
spawn.subs.set(node.id, {at: Date.now(), data});
}));
spawn.loading = false;
drawSpawn();
stage();
}
function spawnItem(entry) {
const running = entry.running === null ? null : entry.running ? chip("bi-play-circle", "läuft", "ok") : chip("bi-check-circle", "fertig", "neutral");
const open = entry.kind === "twig" ? () => focusSub(entry.session, entry.row.id) : entry.kind === "operator" ? null : () => select(entry.id);
const name = open ? h("button", {type: "button", class: "linkish", title: entry.kind === "twig" ? "Transkript im Stream zeigen" : `${entry.label} öffnen`, on: {click: open}}, entry.label) : h("strong", {}, entry.label);
const meta = entry.kind === "twig" ? h("span", {class: "muted"}, `Tiefe ${entry.row.depth} · `, when(entry.row.started)) : h("span", {class: "muted"}, NODE_KINDS[entry.kind]);
return h("li", {"data-kind": entry.kind, "data-running": entry.running === null ? null : String(entry.running)},
h("div", {class: "spawn-row"}, h("span", {class: "state", role: "img", ...named(NODE_KINDS[entry.kind] || "Subagent")}, icon(SPAWN_KINDS[entry.kind])), name, running, meta),
entry.children.length ? h("ul", {class: "spawn-tree"}, entry.children.map(spawnItem)) : null);
}
function spawnScreen() {
const list = h("div", {class: "card spawn"});
spawn.dom = {list};
window.setTimeout(() => {
drawSpawn();
loadSpawn();
}, 0);
return list;
}
function drawSpawn() {
if (!spawn.dom || !spawn.dom.list.isConnected) {
spawn.dom = null;
return;
}
const root = spawnTree();
const all = spawnFlat(root).map(({entry}) => entry).filter((entry) => entry.running !== null);
const running = all.filter((entry) => entry.running).length;
spawn.dom.list.replaceChildren(
h("div", {class: "inbox-head"}, h("h2", {class: "group-title"}, "Spawn-Baum", counter(all.length)), h("span", {class: "muted"}, `${running} laufen · ${all.length - running} fertig · Operator → Chair → Sessions → Subagenten`)),
h("ul", {class: "spawn-tree root"}, spawnItem(root)));
}
+2 -2
View File
@@ -60,13 +60,13 @@
}
function micButton(field) {
const button = h("button", {type: "button", class: "btn small ghost icon-btn mic", "aria-pressed": "false", "aria-label": "Spracheingabe: gedrückt halten und sprechen", title: "Gedrückt halten und sprechen; der Text landet im Feld und wird nicht gesendet"}, "🎙");
const button = h("button", {type: "button", class: "btn small ghost icon-only mic", "aria-pressed": "false", ...named("Spracheingabe", "Spracheingabe: gedrückt halten und sprechen; der Text landet im Feld und wird nicht gesendet")}, icon("bi-mic"));
const reason = !window.isSecureContext || !navigator.mediaDevices
? "Das Mikrofon braucht einen sicheren Kontext: öffne den Hub über 127.0.0.1 oder localhost"
: !window.MediaRecorder ? "Dieser Browser kann nicht aufnehmen" : !state.speech.stt ? `Spracheingabe ist aus: ${state.speech.stt_off}` : "";
if (reason) {
button.disabled = true;
button.title = reason;
button.title = `Spracheingabe: ${reason}`;
return button;
}
const press = (event) => {
+2 -4
View File
@@ -30,7 +30,7 @@
const ui = {screen: "stream", views: {agents: "orbit", artifacts: "tree"}, preset: DAY, from: null, to: null, asOf: null, playing: false, busy: false, offline: false, agent: null, order: null, draft: "", node: null, cursor: 0, selected: new Set(), snoozed: false, review: null, file: 0};
const filter = {view: "fuer-dich", saved: "", kinds: new Set(state.views["fuer-dich"].kinds), waiting: true, q: "", agent: "", session: "", sub: "", parts: new Set(PARTS.map(([id]) => id))};
const stream = {key: null, events: [], entries: [], last: 0, sessions: {}, busy: false, again: false, dom: null, error: ""};
const tree = {data: null, busy: false, q: "", open: new Set(), dom: null};
const tree = {data: null, model: null, busy: false, q: "", open: new Set(), dom: null};
const palette = {items: [], index: 0, input: null, list: null, plan: null, box: null};
const known = {inbox: null, alarms: null, radio: null, batches: new Set()};
const motion = {current: new Map(), from: new Map(), target: new Map(), start: 0, duration: 0, frame: 0, asOf: undefined};
@@ -43,9 +43,7 @@
const speech = {pending: [], busy: false, warned: 0};
const mic = {recorder: null, held: false, busy: false, timer: 0};
const space = {
views: {graph: {...state.settings.space.graph}, sky: {...state.settings.space.sky}}, glides: {graph: null, sky: null}, edits: 0, saved: 0, timer: 0,
scene: null, sceneCursor: null, cam: null, frame: 0, look: false, compass: null, subs: new Map(), loading: false, version: 0,
cursor: {from: 0, to: null, start: 0}, style: null, styleKey: "", trackAsked: 0,
views: {graph: {...state.settings.space.graph}, sky: {...state.settings.space.sky}}, glides: {graph: null, sky: null}, edits: 0, saved: 0, timer: 0, look: false, compass: null,
};
let userViews = validViews(stored(STORE.views, stored("kvb-hub.views", [])));
let settings = state.settings;
+2 -2
View File
@@ -115,7 +115,7 @@
return [h("div", {class: "command"}, h("code", {}, entry.kind === "ask" ? entry.text : entry.title), copyButton(entry.kind === "ask" ? entry.text : entry.title)), h("span", {class: "muted"}, `in ${dash(where)}`), entry.kind === "operator" && entry.text !== "-" ? h("p", {class: "text"}, entry.text) : null];
}
if (entry.kind === "review") {
return [entry.text ? h("pre", {class: "notes"}, entry.text) : null, h("div", {class: "line-actions"}, btn("Diff ansehen", "btn small soft", () => reviewSheet(entry.ref.id)))];
return [entry.text ? h("pre", {class: "notes"}, entry.text) : null, h("div", {class: "line-actions"}, iconBtn("bi-eye", "Diff ansehen", "btn small soft", () => reviewSheet(entry.ref.id)))];
}
if (entry.kind === "cron") {
const ref = entry.ref;
@@ -174,7 +174,7 @@
const saved = h("div", {class: "chips", role: "group", "aria-label": "Gespeicherte Ansichten"});
const focus = h("div", {class: "chips"});
const list = h("div", {});
const more = h("details", {class: "menu"}, h("summary", {}, "Filter ▾"), h("div", {class: "menu-box end"},
const more = h("details", {class: "menu"}, h("summary", named("Filter", "Filter: Arten, Transkript-Teile, gespeicherte Ansichten, eigener Zeitraum"), icon("bi-funnel"), caret()), h("div", {class: "menu-box end"},
h("h3", {}, "Arten"), kinds, h("h3", {}, "Transkript-Teile"), parts, h("h3", {}, "Gespeicherte Ansichten"), saved, h("h3", {}, "Eigener Zeitraum"), customRange()));
stream.dom = {search, count, categories, kinds, parts, saved, focus, list};
window.setTimeout(() => {
+3 -3
View File
@@ -36,7 +36,7 @@
const presets = h("div", {class: "segmented", role: "group", "aria-label": "Zeitraum"});
const summary = h("summary", {title: "Zeitraum und Wiedergabe"});
const slider = h("input", {type: "range", min: 0, max: 1000, step: 1, value: 1000, "aria-label": "Stand der Wiedergabe"});
const play = h("button", {type: "button", class: "btn small soft"}, "▶ Wiedergabe");
const play = iconBtn("bi-play-fill", "Wiedergabe", "btn small soft", null);
const liveButton = h("button", {type: "button", class: "btn small ghost"}, "Live");
const label = h("span", {class: "window-label"});
for (const [seconds, name] of PRESETS.filter(([seconds]) => seconds !== -1)) {
@@ -102,7 +102,7 @@
const resume = ui.asOf !== null && ui.asOf < state.now - 60;
let progress = resume ? (ui.asOf - began) / span : 0;
ui.playing = true;
timeDom.play.textContent = "❚❚ Pause";
relabel(timeDom.play, "bi-pause-fill", "Pause");
const advance = async () => {
if (!ui.playing) {
return;
@@ -127,6 +127,6 @@
ui.playing = false;
window.clearTimeout(replay);
if (timeDom) {
timeDom.play.textContent = "▶ Wiedergabe";
relabel(timeDom.play, "bi-play-fill", "Wiedergabe");
}
}
+93 -44
View File
@@ -1,44 +1,84 @@
function treeScreen() {
const search = h("input", {type: "search", placeholder: "Repo, Branch, Pfad oder Agent filtern …", value: tree.q, "aria-label": "Tree filtern", on: {input: (event) => {
tree.q = event.target.value;
drawTree();
}}});
const count = h("span", {class: "muted"});
const list = h("div", {class: "repos"});
tree.dom = {list, count};
window.setTimeout(() => {
drawTree();
fetchTree();
}, 0);
return h("div", {}, h("div", {class: "filters"}, search, count, btn("Neu laden", "btn small soft", fetchTree)), list);
const chip = (name, label, tone, count) => h("span", {class: "state", "data-tone": tone, role: "img", ...named(label)}, icon(name), count === undefined ? null : String(count));
function branchStates(entry) {
return [
entry.state === "merged-clean" || entry.merged_by ? chip("bi-sign-merge-left", `gemergt${entry.merged_by ? ` durch ${entry.merged_by}` : ""}${entry.merged_into ? ` in ${entry.merged_into}` : ""}`, "ok") : null,
entry.state === "unmerged" ? chip("bi-signpost-split", "nicht gemergt", "neutral") : null,
entry.state === "protected" ? chip("bi-shield-lock", "geschützt", "violet") : null,
entry.state === "stale" ? chip("bi-hourglass-bottom", "veraltet", "warn") : null,
entry.ahead && entry.upstream ? chip("bi-cloud-upload", `${entry.ahead} Commits ungepusht`, "info", entry.ahead) : null,
entry.behind ? chip("bi-arrow-down-circle", `${entry.behind} Commits zurück`, "warn", entry.behind) : null,
entry.gone ? chip("bi-cloud-slash", "Upstream weg", "bad") : null,
!entry.upstream && !entry.gone ? chip("bi-cloud-slash", "ohne Upstream", "neutral") : null,
entry.skip ? chip("bi-slash-circle", `gc überspringt: ${entry.skip}`, "bad") : null,
];
}
async function fetchTree() {
if (tree.busy) {
return;
function worktreeStates(wt) {
const counts = [wt.staged, wt.unstaged, wt.untracked];
const changed = counts.every((value) => typeof value === "number") ? counts.reduce((sum, value) => sum + value, 0) : undefined;
return [
wt.state === "clean" ? chip("bi-check-circle", "sauber", "ok") : null,
wt.state === "changed" ? chip("bi-pencil-square", changed === undefined ? "nicht committete Änderungen" : `nicht committet: ${wt.staged} gestaged, ${wt.unstaged} geändert, ${wt.untracked} neu`, "warn", changed) : null,
wt.state === "missing" ? chip("bi-question-circle", "Worktree fehlt", "neutral") : null,
wt.locked ? chip("bi-lock-fill", `gesperrt: ${wt.locked}`, "warn") : null,
];
}
function deleteButton(entry) {
if (!entry.cleanup) {
return null;
}
tree.busy = true;
try {
const response = await fetch("/api/tree", {cache: "no-store"});
if (response.ok) {
tree.data = await response.json();
}
} catch {
ui.offline = true;
}
tree.busy = false;
drawTree();
const ready = tree.data.delete_ready === true;
const hint = ready ? `Löschen: ${entry.name} lokal entfernen, nach Trockenlauf und Signatur` : `Löschen gesperrt: ${tree.data.delete_reason || "delete_ready ist false"}`;
const button = iconBtn("bi-trash3", "Löschen", "btn small danger", () => deleteDialog([entry.cleanup]), hint);
button.disabled = !ready;
return h("span", {class: "hint", title: hint}, button);
}
function deleteAll() {
const items = (tree.data.repos || []).flatMap((repo) => repo.branches).filter((entry) => entry.cleanup).map((entry) => entry.cleanup);
const ready = tree.data.delete_ready === true && items.length > 0;
const hint = tree.data.delete_ready !== true ? `Alle gemergten löschen gesperrt: ${tree.data.delete_reason || "delete_ready ist false"}` : items.length ? `Alle gemergten löschen: ${items.length} Branches nach Trockenlauf und Signatur` : "Alle gemergten löschen: kein gemergter Branch ohne Skip-Grund";
const button = iconBtn("bi-trash3-fill", "Alle gemergten löschen", "btn small danger", () => deleteDialog(items), hint);
button.disabled = !ready;
return h("span", {class: "hint", title: hint}, button, items.length ? counter(items.length) : null);
}
function leafRow(found) {
return h("li", {class: "leaf"}, h("span", {class: "state", "data-tone": found.ended ? "warn" : "neutral", role: "img", ...named(found.kind)}, icon(ARTEFACT_ICONS[found.kind] || "bi-paperclip")),
h("code", {}, found.ref), h("span", {class: "muted"}, found.task), found.ended ? pill("Aufgabe beendet", "warn") : null);
}
function worktreeRow(wt) {
const review = wt.review;
return h("div", {class: "wt"},
h("code", {}, wt.path),
h("span", {class: "muted", title: wt.head}, wt.head.slice(0, 10)),
wt.dirty === null ? pill("fehlt", "neutral") : wt.dirty ? pill("geändert", "warn") : pill("sauber", "ok"),
wt.workers.length ? h("span", {class: "who"}, wt.workers.map((name) => [face(name, false), person(name)])) : h("span", {class: "muted"}, "niemand"),
review ? [status(review.status), review.gate ? pill(review.gate === "pass" ? "Gate grün" : "Gate rot", review.gate === "pass" ? "ok" : "bad") : null] : null,
copyButton(wt.path));
h("div", {class: "wt-head"},
h("span", {class: "states"}, worktreeStates(wt)),
h("code", {}, wt.path),
h("span", {class: "muted", title: wt.head}, wt.head.slice(0, 10)),
wt.workers.length ? h("span", {class: "who"}, wt.workers.map((name) => [face(name, false), person(name)])) : h("span", {class: "muted"}, "niemand"),
review ? [status(review.status), review.gate ? pill(review.gate === "pass" ? "Gate grün" : "Gate rot", review.gate === "pass" ? "ok" : "bad") : null] : null,
copyButton(wt.path)),
wt.artefacts && wt.artefacts.length ? h("ul", {class: "leaves", "aria-label": "Artefakte"}, wt.artefacts.map(leafRow)) : null);
}
function resultOf(repo, entry) {
const found = (tree.data.cleanups || []).flatMap((result) => result.items.map((item) => ({...item, finished: result.finished}))).filter((item) => item.repository === repo.path && item.branch === entry.name);
return found.length ? found[found.length - 1] : null;
}
function resultChip(result) {
const [name, label, tone] = result.status === "done" ? ["bi-check2-all", "gelöscht", "ok"] : result.status === "refused" ? ["bi-x-octagon", "verweigert", "bad"] : ["bi-exclamation-triangle", "fehlgeschlagen", "bad"];
return chip(name, `${label}${result.reason ? `: ${result.reason}` : ""}`, tone);
}
function branchRow(repo, entry) {
const result = resultOf(repo, entry);
return h("div", {class: "branch-row"},
h("div", {class: "branch-name"}, h("strong", {}, entry.name), h("span", {class: "states"}, branchStates(entry)), entry.upstream ? h("span", {class: "muted"}, `↔ ${entry.upstream}`) : null,
h("span", {class: "muted", title: entry.head}, entry.head.slice(0, 10)), result ? resultChip(result) : null, h("span", {class: "spacer"}), deleteButton(entry)),
entry.worktrees.map(worktreeRow));
}
function drawTree() {
@@ -47,9 +87,11 @@
}
if (!tree.data || tree.data.error) {
tree.dom.count.textContent = "";
tree.dom.list.replaceChildren(emptyState(tree.busy ? "Lese Repositories …" : "Kein Tree", tree.data ? tree.data.error : "pkgmgr schreibt den Tree jede Minute, solange diese Seite offen ist."));
tree.dom.removeAll.replaceChildren();
tree.dom.list.replaceChildren(emptyState(tree.busy || !tree.data ? "Lese Repositories …" : "Kein Tree", tree.data ? tree.data.error : "pkgmgr schreibt den Tree jede Minute, solange diese Seite offen ist."));
return;
}
tree.dom.removeAll.replaceChildren(deleteAll());
const q = tree.q.trim().toLowerCase();
const hit = (...texts) => !q || texts.some((text) => String(text).toLowerCase().includes(q));
const repos = [];
@@ -60,21 +102,22 @@
repos.push({repo, branches, detached});
}
}
tree.dom.count.textContent = `${repos.length} Repositories · Stand ${clock.format(stampOf(tree.data.scanned))}`;
if (!repos.length) {
const tasks = (tree.data.tasks || []).filter((task) => hit(task.task, task.title, ...task.artefacts.map((found) => found.ref)));
const listed = new Set(tree.data.repos.flatMap((repo) => repo.branches.map((entry) => `${repo.path}\u0000${entry.name}`)));
const gone = (tree.data.cleanups || []).flatMap((result) => result.items).filter((item) => !listed.has(`${item.repository}\u0000${item.branch}`) && hit(item.repository, item.branch));
tree.dom.count.textContent = `${repos.length} Repositories · Stand ${clock.format(stampOf(tree.data.scanned))}${tree.data.delete_ready ? "" : ` · Löschen gesperrt: ${tree.data.delete_reason}`}`;
if (!repos.length && !tasks.length && !gone.length) {
tree.dom.list.replaceChildren(emptyState("Nichts gefunden", "Kein Repository passt zum Filter."));
return;
}
tree.dom.list.replaceChildren(...repos.map(({repo, branches, detached}) => {
const cards = repos.map(({repo, branches, detached}) => {
const trees = repo.branches.flatMap((entry) => entry.worktrees).concat(repo.detached);
const dirty = trees.filter((wt) => wt.dirty).length;
const merged = repo.branches.filter((entry) => entry.cleanup).length;
const details = h("details", {class: "card repo", open: Boolean(q) || tree.open.has(repo.path)},
h("summary", {}, h("span", {}, repo.name), counter(`${repo.branches.length} Branches`), counter(`${trees.length} Worktrees`), dirty ? pill(`${dirty} geändert`, "warn") : null),
branches.map((entry) => h("div", {class: "branch-row"},
h("div", {class: "branch-name"}, h("strong", {}, entry.name), entry.upstream ? h("span", {class: "muted"}, `↔ ${entry.upstream}`) : h("span", {class: "muted"}, "ohne Upstream"),
entry.ahead ? pill(`${entry.ahead} voraus`, "info") : null, entry.behind ? pill(`${entry.behind} zurück`, "warn") : null, entry.gone ? pill("Upstream weg", "bad") : null,
h("span", {class: "muted", title: entry.head}, entry.head.slice(0, 10))),
entry.worktrees.map(worktreeRow))),
h("summary", {}, icon("bi-git"), h("span", {}, repo.name), counter(`${repo.branches.length} Branches`), counter(`${trees.length} Worktrees`),
dirty ? chip("bi-pencil-square", `${dirty} Worktrees mit Änderungen`, "warn", dirty) : null, merged ? chip("bi-sign-merge-left", `${merged} gemergt, löschbar`, "ok", merged) : null),
branches.map((entry) => branchRow(repo, entry)),
detached.length ? h("div", {class: "branch-row"}, h("div", {class: "branch-name"}, h("strong", {}, "losgelöster HEAD")), detached.map(worktreeRow)) : null);
details.addEventListener("toggle", () => {
if (details.open) {
@@ -84,5 +127,11 @@
}
});
return details;
}));
});
const loose = tasks.length ? h("details", {class: "card repo", open: true}, h("summary", {}, icon("bi-card-checklist"), h("span", {}, "Aufgaben ohne Worktree"), counter(tasks.length)),
tasks.map((task) => h("div", {class: "branch-row"}, h("div", {class: "branch-name"}, h("strong", {}, task.task), h("span", {class: "muted"}, task.title), status(task.status)),
h("ul", {class: "leaves", "aria-label": "Artefakte"}, task.artefacts.map(leafRow))))) : null;
const past = gone.length ? h("details", {class: "card repo", open: true}, h("summary", {}, icon("bi-trash3"), h("span", {}, "Zuletzt gelöscht"), counter(gone.length)),
gone.map((item) => h("div", {class: "branch-row", "data-result": item.status}, h("div", {class: "branch-name"}, h("strong", {}, item.branch), h("code", {}, item.repository), resultChip(item))))) : null;
tree.dom.list.replaceChildren(...cards, loose || "", past || "");
}