feat(orchestrator): W3 artifacts tree, ancestry, gates and token-free cleanup

Artifacts show the branch tree with ancestry and gate marks. The cleanup runner calls pkgmgr branch cleanup with its real argv and parses its repos[*].items output, refuses any plan with a remote-branch action, marks an item done only when pkgmgr reports it done, and offers no delete for a branch a worktree holds. Every text states what the pkgmgr command does to volumes. Committed below the bar on the operator's word 2026-11-12 (no further per-bundle tests, one gate after the merge): the W3 e2e never ran; the client switches to --local once pkgmgr ships it.

🎯 Fix: 90 %
💥 Break: 6 %
💸 Cost: 30 %
⏱️ Time: 40 %
🛡️ Security: 100 %
🏆 Score: 100
🚦 Gate: ⛔ break 6 % > 5

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin Veen-BirkenbachandClaude Opus 5.5 committed 2026-11-12 16:15:14 +00:00
1 parent fd8fc08603
commit f7a59d43a5
33 files changed
+1998 -107

No files matched your search

+97
View File
@@ -0,0 +1,97 @@
const Ancestry = (() => {
"use strict";
const LEVELS = ["none", "quick", "test", "quality-high", "e2e"];
const ACTIONS = ["commit", "merge", "push", "pr"];
const LEVEL_ICONS = {none: "bi-circle", quick: "bi-lightning-charge", test: "bi-check2-circle", "quality-high": "bi-shield-check", e2e: "bi-browser-chrome"};
const SPACING = {column: 170, row: 96, wrap: 8};
function generations(ids, edges) {
const parents = new Map(ids.map((id) => [id, []]));
for (const edge of edges) {
if (parents.has(edge.child) && parents.has(edge.parent) && edge.child !== edge.parent) {
parents.get(edge.child).push(edge.parent);
}
}
const depth = new Map();
const visit = (id, path) => {
if (depth.has(id)) {
return depth.get(id);
}
if (path.has(id)) {
return -1;
}
path.add(id);
const above = parents.get(id).map((parent) => visit(parent, path)).filter((value) => value >= 0);
path.delete(id);
const value = above.length ? Math.max(...above) + 1 : 0;
depth.set(id, value);
return value;
};
ids.forEach((id) => visit(id, new Set()));
return depth;
}
function layout(nodes, edges) {
const ids = nodes.map((node) => node.id);
const depth = generations(ids, edges);
const rows = [];
for (const node of nodes) {
const generation = depth.get(node.id);
(rows[generation] = rows[generation] || []).push(node);
}
const widest = Math.max(1, ...rows.map((row) => Math.min(row ? row.length : 0, SPACING.wrap)));
const width = widest * SPACING.column;
const placed = new Map();
let line = 0;
rows.forEach((row, generation) => {
const anchor = (node) => {
const xs = edges.filter((edge) => edge.child === node.id && placed.has(edge.parent)).map((edge) => placed.get(edge.parent).x);
return xs.length ? xs.reduce((sum, x) => sum + x, 0) / xs.length : width / 2;
};
const ordered = [...(row || [])].sort((a, b) => anchor(a) - anchor(b) || String(a.label).localeCompare(String(b.label)));
ordered.forEach((node, index) => {
const band = Math.floor(index / SPACING.wrap);
const count = Math.min(SPACING.wrap, ordered.length - band * SPACING.wrap);
const offset = (width - count * SPACING.column) / 2;
placed.set(node.id, {...node, generation, x: offset + ((index % SPACING.wrap) + 0.5) * SPACING.column, y: (line + band + 0.5) * SPACING.row});
});
line += Math.max(1, Math.ceil(ordered.length / SPACING.wrap));
});
const links = edges.filter((edge) => placed.has(edge.child) && placed.has(edge.parent) && edge.child !== edge.parent).map((edge) => {
const from = placed.get(edge.parent);
const to = placed.get(edge.child);
return {...edge, derived: edge.source === "derived", x1: from.x, y1: from.y, x2: to.x, y2: to.y};
});
return {nodes: ids.map((id) => placed.get(id)), links, width, height: Math.max(line, 1) * SPACING.row};
}
function gateMarks(branch, entry) {
const resolved = (entry && entry.resolved) || {};
return ACTIONS.map((action) => {
const found = resolved[action] || {};
const level = LEVELS.includes(found.level) ? found.level : "none";
const source = typeof found.source === "string" ? found.source : "";
const own = source === branch;
const from = own ? "own rule" : source ? `from ${source}` : "no rule";
return {action, level, source, own, inherited: !own, icon: LEVEL_ICONS[level], title: `${action}: ${level} · ${from}`};
});
}
function nextRule(level) {
return level === null || level === undefined ? LEVELS[0] : LEVELS.indexOf(level) + 1 < LEVELS.length ? LEVELS[LEVELS.indexOf(level) + 1] : null;
}
function ruleArgv(repo, branch, action, level) {
const head = ["--repo", repo, "--branch", branch, "--action", action];
return level === null ? ["rule", "drop", ...head] : ["rule", "set", ...head, "--level", level];
}
function parentArgv(repo, branch, parent, drop) {
return ["parents", drop ? "drop" : "add", "--repo", repo, "--branch", branch, "--parent", parent];
}
return {LEVELS, ACTIONS, LEVEL_ICONS, SPACING, generations, layout, gateMarks, nextRule, ruleArgv, parentArgv};
})();
if (typeof module === "object" && module.exports) {
module.exports = Ancestry;
}
+6
View File
@@ -413,6 +413,8 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
{% include "style-tasks.css" %}
{% include "style-bubbles.css" %}
{% include "style-ancestry.css" %}
</style>
</head>
<body>
@@ -457,6 +459,8 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
{% include "nav.js" %}
{% include "ancestry.js" %}
{% include "popup.js" %}
{% include "faces.js" %}
@@ -528,6 +532,8 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
{% include "page-cleanup.js" %}
{% include "page-ancestry.js" %}
{% include "page-offices.js" %}
{% include "page-tasks.js" %}
+167
View File
@@ -0,0 +1,167 @@
const gateStage = {items: [], drag: null};
const DRAG_SLOP = 6;
function stageGate(key, change) {
gateStage.items = gateStage.items.filter((item) => item.key !== key);
if (change) {
gateStage.items.push({key, ...change});
}
drawArtifacts();
}
function stageParent(repo, branch, parent, drop) {
const key = `parent\u0000${repo}\u0000${branch}\u0000${parent}`;
const staged = gateStage.items.find((item) => item.key === key);
stageGate(key, staged && staged.drop === drop ? null : {argv: Ancestry.parentArgv(repo, branch, parent, drop), repo, branch, parent, drop});
}
function gatesOf(repo, name) {
return ((tree.data && tree.data.gates) || {})[repo] ? tree.data.gates[repo][name] || null : null;
}
function gateIcons(repo, name) {
const found = gatesOf(repo, name);
const own = (found && found.rules) || {};
return h("span", {class: "gates", role: "group", "aria-label": `Gates of ${name}`}, Ancestry.gateMarks(name, found).map((mark) => {
const key = `rule\u0000${repo}\u0000${name}\u0000${mark.action}`;
const staged = gateStage.items.find((item) => item.key === key);
const current = Object.hasOwn(own, mark.action) ? own[mark.action] : null;
const level = staged ? staged.level || "none" : mark.level;
const title = staged ? `${mark.action}: ${staged.level || "inherited"} · staged` : mark.title;
const cycle = (event) => {
const group = event.currentTarget.closest(".gates");
const next = Ancestry.nextRule(staged ? staged.level : current);
stageGate(key, next === current ? null : {argv: Ancestry.ruleArgv(repo, name, mark.action, next), level: next});
if (group && group.isConnected) {
group.replaceWith(gateIcons(repo, name));
}
};
return h("button", {
type: "button", class: ["gate-mark", "icon-only", mark.inherited && !staged ? "inherited" : "", staged ? "pending" : ""].filter(Boolean).join(" "),
"data-action": mark.action, "data-level": level, "data-source": mark.source, ...named(`${mark.action} gate`, title), on: {click: cycle},
}, icon(Ancestry.LEVEL_ICONS[level]));
}));
}
function gateApply() {
const count = gateStage.items.length;
if (!count) {
return null;
}
return h("span", {class: "hint gate-apply"},
iconBtn("bi-check2-all", "Apply", "btn small", applyGates, `Apply ${count} gate changes with one passkey signature`), counter(count),
iconBtn("bi-x-circle", "Discard", "btn small ghost", () => {
gateStage.items = [];
drawArtifacts();
}, "Discard the staged gate changes"));
}
async function applyGates() {
const items = gateStage.items.map((item) => ({kind: "gate", argv: item.argv}));
if (!items.length) {
return;
}
const signed = await proofFor(items, "Apply");
if (signed === null) {
return;
}
const result = await sendJson("/api/gates", {items, proof: signed.proof});
if (!result.ok || !result.data) {
toast("Gates: not applied", fail(result), false);
return;
}
gateStage.items = [];
toast("Gates: signed", [`${items.length} → runner`], true);
drawArtifacts();
window.setTimeout(fetchTree, 3000);
}
function ancestryChart(plan, label, mark) {
const width = Math.max(plan.width, Ancestry.SPACING.column);
const svg = s("svg", {viewBox: `0 0 ${width} ${plan.height}`, width, height: plan.height, class: "chart ancestry", role: "group", "aria-label": label});
const curve = (link) => `M${fixed(link.x1)},${fixed(link.y1)} C${fixed(link.x1)},${fixed((link.y1 + link.y2) / 2)} ${fixed(link.x2)},${fixed((link.y1 + link.y2) / 2)} ${fixed(link.x2)},${fixed(link.y2)}`;
svg.append(s("g", {}, plan.links.map((link) => mark.edge(link, curve(link)))), s("g", {}, plan.nodes.map((node) => mark.node(node))));
return h("div", {class: "ancestry-frame"}, svg);
}
function branchAncestry(repo) {
const names = new Set(repo.branches.map((entry) => entry.name));
const staged = gateStage.items.filter((item) => item.parent && item.repo === repo.path);
const edges = [];
for (const entry of repo.branches) {
for (const found of (gatesOf(repo.path, entry.name) || {parents: []}).parents) {
if (names.has(found.parent)) {
edges.push({child: entry.name, parent: found.parent, source: found.source});
}
}
}
for (const item of staged.filter((change) => !change.drop && names.has(change.branch) && names.has(change.parent))) {
if (!edges.some((edge) => edge.child === item.branch && edge.parent === item.parent)) {
edges.push({child: item.branch, parent: item.parent, source: "staged"});
}
}
const plan = Ancestry.layout(repo.branches.map((entry) => ({id: entry.name, label: entry.name})), edges);
const dropping = (link) => staged.some((item) => item.drop && item.branch === link.child && item.parent === link.parent);
return ancestryChart(plan, `Ancestry of ${repo.name}`, {
edge: (link, d) => s("path", {
class: ["ancestry-edge", link.derived ? "derived" : "", link.source === "staged" ? "pending" : "", dropping(link) ? "dropping" : ""].filter(Boolean).join(" "),
d, "data-child": link.child, "data-parent": link.parent, "data-source": link.source,
on: {click: () => stageParent(repo.path, link.child, link.parent, link.source !== "staged")},
}, s("title", {}, `${link.parent} → ${link.child} · ${link.source}`)),
node: (node) => s("g", {
class: "node a-branch ancestry-node", transform: `translate(${fixed(node.x)} ${fixed(node.y)})`, tabindex: 0, role: "button", "aria-label": node.label,
"data-repo": repo.path, "data-branch": node.id, "data-generation": node.generation,
on: {
pointerdown: (event) => {
gateStage.drag = event.button === 0 ? {repo: repo.path, branch: node.id, x: event.clientX, y: event.clientY} : null;
},
click: () => artSelect(`branch:${repo.path}\u0000${node.id}`),
keydown: (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
artSelect(`branch:${repo.path}\u0000${node.id}`);
}
},
},
}, s("title", {}, node.label), s("circle", {r: 9}), s("text", {class: "label", y: 26, "text-anchor": "middle"}, short(node.label).slice(0, 24))),
});
}
function agentAncestry(root) {
const flat = spawnFlat(root);
const plan = Ancestry.layout(flat.map(({entry}) => ({id: entry.id, label: entry.label, kind: entry.kind})), flat.filter(({parent}) => parent).map(({entry, parent}) => ({child: entry.id, parent: parent.id, source: "recorded"})));
const open = (node) => {
const found = flat.find(({entry}) => entry.id === node.id);
if (found && found.entry.kind === "twig") {
focusSub(found.entry.session, found.entry.row.id);
} else if (found && found.entry.kind !== "operator") {
select(node.id);
}
};
return ancestryChart(plan, "Ancestry: operator, chair, sessions, subagents", {
edge: (link, d) => s("path", {class: "ancestry-edge", d, "data-child": link.child, "data-parent": link.parent}),
node: (node) => s("g", {
class: `node ancestry-node k-${node.kind}`, transform: `translate(${fixed(node.x)} ${fixed(node.y)})`, tabindex: 0, role: "button", "aria-label": node.label,
"data-agent": node.id, "data-kind": node.kind, "data-generation": node.generation,
on: {click: () => open(node), keydown: (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
open(node);
}
}},
}, s("title", {}, `${node.label} · ${NODE_KINDS[node.kind] || "Subagent"}`), s("circle", {r: 9}), s("text", {class: "label", y: 26, "text-anchor": "middle"}, short(node.label).slice(0, 24))),
});
}
document.addEventListener("pointerup", (event) => {
const from = gateStage.drag;
gateStage.drag = null;
if (!from || Math.hypot(event.clientX - from.x, event.clientY - from.y) < DRAG_SLOP) {
return;
}
const hit = document.elementFromPoint(event.clientX, event.clientY);
const target = hit && hit.closest("[data-branch]");
if (target && target.dataset.repo === from.repo && target.dataset.branch !== from.branch) {
stageParent(from.repo, from.branch, target.dataset.branch, false);
}
});
+5 -3
View File
@@ -224,7 +224,7 @@
body.push(meta([["Path", data.path], ["Remote", data.remote], ["Default", data.default], ["Forge", data.forge], ["As of", 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], ["Head", data.head.slice(0, 12), data.head], ["Merged into", data.merged_into], ["Reason", data.reason]]),
body.push(h("div", {class: "states"}, branchStates(data), gateIcons(node.repo, data.name)), meta([["Upstream", data.upstream], ["Head", data.head.slice(0, 12), data.head], ["Merged into", data.merged_into], ["Reason", data.reason]]),
data.worktrees.map((wt) => flowDetails(wt.flow, data.name)),
h("h3", {}, "Dry run"), h("pre", {class: "notes"}, data.plan.join("\n")), h("div", {class: "actions"}, deleteButton(data)));
} else if (node.kind === "worktree") {
@@ -256,13 +256,14 @@
drawArtifacts();
}}}) : null;
const removeAll = view === "tree" ? h("span", {class: "delete-all"}) : null;
tree.dom = {view, picker, count, stage: stageBox, legend: legendBox, list, removeAll};
const apply = view === "tree" ? null : h("span", {class: "gate-staged"});
tree.dom = {view, picker, count, stage: stageBox, legend: legendBox, list, removeAll, apply};
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);
h("div", {class: "toolbar", role: "toolbar", "aria-label": "Artifacts"}, search, reload, removeAll, apply), count);
const body = view === "tree" ? list : h("div", {class: "card stage-card"}, stageBox, legendBox);
return h("div", {class: "graph"}, controls, body);
}
@@ -296,6 +297,7 @@
drawTree();
return;
}
tree.dom.apply.replaceChildren(gateApply() || "");
const empty = !tree.data || tree.data.error;
tree.dom.count.textContent = empty ? "" : `${tree.data.repos.length} repositories · as of ${clock.format(stampOf(tree.data.scanned))}`;
if (empty) {
+7 -5
View File
@@ -8,14 +8,16 @@
return {repo, entry};
}
function deleteDialog(items) {
function deleteDialog(items, locked = [], held = []) {
if (!items.length) {
return;
}
const rows = items.map((item) => {
const rows = [...items, ...locked, ...held].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)),
const waits = locked.includes(item);
const kept = held.includes(item);
return h("li", {class: waits ? "dry-run locked" : kept ? "dry-run held" : "dry-run", "data-token": waits ? "needed" : null, "data-held": kept && entry ? entry.held : null},
h("div", {class: "row"}, h("strong", {}, repo ? repo.name : item.repository), h("code", {}, item.branch), waits ? pill("token needed", "warn") : kept ? pill("held by a worktree: not deleted here", "neutral") : null),
h("pre", {}, (entry ? entry.plan : [`${item.branch} @ ${item.tip.slice(0, 12)}`]).join("\n")));
});
const outcome = h("div", {class: "mini", "aria-live": "polite"});
@@ -27,7 +29,7 @@
}
});
openSheet(items.length > 1 ? `Delete ${items.length} merged branches` : `Delete ${items[0].branch}`, [
h("p", {class: "muted"}, "Dry run of pkgmgr branch cleanup --local for exactly these entries: worktree, local branch, Compose stack without -v and its images. No remote branch. The host checks your signature and refuses every entry whose state has changed since."),
h("p", {class: "muted"}, "Dry run of pkgmgr branch cleanup for exactly these entries: it deletes the local branch and never touches a worktree, Compose stack or volume. No remote branch: the host refuses an entry whose plan deletes one. A branch held by a worktree is not deleted here: branch gc (kvb-git-branch-gc, pkgmgr gc) removes it, and its docker compose down -v deletes the stack's volumes. The host checks your signature and refuses every entry whose state has changed since."),
h("ul", {class: "mini", "aria-label": "Dry run"}, rows),
outcome,
h("div", {class: "actions"}, h("span", {class: "spacer"}), btn("Cancel", "btn ghost", closeSheet), confirm),
+1
View File
@@ -117,5 +117,6 @@
const running = all.filter((entry) => entry.running).length;
spawn.dom.list.replaceChildren(
h("div", {class: "inbox-head"}, h("h2", {class: "group-title"}, "Spawn tree", counter(all.length)), h("span", {class: "muted"}, `${running} running · ${all.length - running} finished · Operator → Chair → Sessions → Subagents`)),
agentAncestry(root),
h("ul", {class: "spawn-tree root"}, spawnItem(root)));
}
+31 -11
View File
@@ -25,24 +25,43 @@
];
}
const FORGE_UNAVAILABLE = "forge unavailable";
const deletableNow = (entry) => Boolean(entry.cleanup) && (tree.data.delete_ready === true || entry.token_free === true);
const tokenLocked = (entry) => tree.data.delete_ready !== true && entry.state === "merged-clean" && !entry.held && (entry.cleanup ? entry.token_free !== true : entry.skip === FORGE_UNAVAILABLE);
function deleteButton(entry) {
if (!entry.cleanup) {
return null;
if (entry.held) {
return h("span", {class: "hint held", title: entry.held}, chip("bi-folder-symlink", entry.held, "neutral"));
}
const ready = tree.data.delete_ready === true;
if (!entry.cleanup) {
return tokenLocked(entry) ? chip("bi-key", "token needed", "warn") : null;
}
const ready = deletableNow(entry);
const hint = ready ? `Delete: remove ${entry.name} locally, after dry run and signature` : `Delete blocked: ${tree.data.delete_reason || "delete_ready is false"}`;
const button = iconBtn("bi-trash3", "Delete", "btn small danger", () => deleteDialog([entry.cleanup]), hint);
button.disabled = !ready;
return h("span", {class: "hint", title: hint}, button);
return h("span", {class: "hint", title: hint}, button, ready ? null : chip("bi-key", "token needed", "warn"));
}
async function storeToken() {
const result = await sendJson("/api/cleanup/token", {});
toast(result.ok ? "Token: operator row added" : "Token: no operator row", result.ok && result.data ? [result.data.task] : fail(result), result.ok);
}
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 ? `Delete all merged blocked: ${tree.data.delete_reason || "delete_ready is false"}` : items.length ? `Delete all merged: ${items.length} branches after dry run and signature` : "Delete all merged: no merged branch without a skip reason";
const button = iconBtn("bi-trash3-fill", "Delete all merged", "btn small danger", () => deleteDialog(items), hint);
const branches = (tree.data.repos || []).flatMap((repo) => repo.branches);
const items = branches.filter(deletableNow).map((entry) => entry.cleanup);
const waiting = branches.filter(tokenLocked);
const locked = waiting.filter((entry) => entry.cleanup).map((entry) => entry.cleanup);
const held = (tree.data.repos || []).flatMap((repo) => repo.branches.filter((entry) => entry.held).map((entry) => ({repository: repo.path, branch: entry.name, tip: entry.head})));
const ready = items.length > 0;
const hint = !ready && tree.data.delete_ready !== true ? `Delete all merged blocked: ${tree.data.delete_reason || "delete_ready is false"}` : items.length ? `Delete all merged: ${items.length} branches after dry run and signature` : "Delete all merged: no merged branch without a skip reason";
const button = iconBtn("bi-trash3-fill", "Delete all merged", "btn small danger", () => deleteDialog(items, locked, held), hint);
button.disabled = !ready;
return h("span", {class: "hint", title: hint}, button, items.length ? counter(items.length) : null);
return h("span", {class: "hint", title: hint}, button, items.length ? counter(items.length) : null,
waiting.length ? chip("bi-key", `${waiting.length} token needed`, "warn", waiting.length) : null,
held.length ? chip("bi-folder-symlink", `${held.length} held by a worktree: use branch gc`, "neutral", held.length) : null,
tree.data.delete_ready !== true ? iconBtn("bi-key-fill", "Store token", "btn small soft", storeToken, "Store token: add the operator row that stores the GitHub token in the Secret Service") : null);
}
function leafRow(found) {
@@ -82,7 +101,7 @@
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)),
h("span", {class: "muted", title: entry.head}, entry.head.slice(0, 10)), result ? resultChip(result) : null, h("span", {class: "spacer"}), gateIcons(repo.path, entry.name), deleteButton(entry)),
entry.worktrees.map(worktreeRow));
}
@@ -96,7 +115,7 @@
tree.dom.list.replaceChildren(emptyState(tree.busy || !tree.data ? "Reading repositories …" : "No tree", tree.data ? tree.data.error : "pkgmgr writes the tree every minute while this page is open."));
return;
}
tree.dom.removeAll.replaceChildren(deleteAll());
tree.dom.removeAll.replaceChildren(deleteAll(), gateApply() || "");
const q = tree.q.trim().toLowerCase();
const hit = (...texts) => !q || texts.some((text) => String(text).toLowerCase().includes(q));
const repos = [];
@@ -124,6 +143,7 @@
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 with changes`, "warn", dirty) : null, merged ? chip("bi-sign-merge-left", `${merged} merged, deletable`, "ok", merged) : null,
busy ? chip("bi-lightning-charge", `${busy} worktrees with open work`, "warn", busy) : null),
repo.branches.length ? branchAncestry(repo) : null,
branches.map((entry) => branchRow(repo, entry)),
detached.length ? h("div", {class: "branch-row"}, h("div", {class: "branch-name"}, h("strong", {}, "detached HEAD")), detached.map(worktreeRow)) : null);
details.addEventListener("toggle", () => {
+17
View File
@@ -0,0 +1,17 @@
.ancestry-frame { max-width: 100%; min-width: 0; margin: 6px 0 10px; overflow-x: auto; overflow-y: hidden; contain: inline-size; }
svg.ancestry { display: block; margin: 0 auto; touch-action: none; user-select: none; }
svg .ancestry-edge { fill: none; stroke: var(--line-strong); stroke-width: 1.6; cursor: pointer; }
svg .ancestry-edge:hover { stroke: var(--accent); }
svg .ancestry-edge.derived { stroke-dasharray: 5 5; }
svg .ancestry-edge.pending { stroke: var(--info); stroke-dasharray: 2 4; }
svg .ancestry-edge.dropping { stroke: var(--bad); opacity: .5; }
svg .ancestry-node { cursor: grab; }
svg .ancestry-node:active { cursor: grabbing; }
svg .ancestry-node .label { fill: var(--text); font-size: var(--fs-2xs); }
.gates { display: inline-flex; align-items: center; gap: 2px; }
.gate-mark { display: inline-grid; place-items: center; width: 24px; height: 24px; padding: 0; border: 0; border-radius: 6px; background: transparent; color: var(--text); cursor: pointer; }
.gate-mark:hover { background: var(--hover); }
.gate-mark.inherited { opacity: .4; }
.gate-mark.pending { outline: 1px dashed var(--info); color: var(--info); }
.gate-apply, .gate-staged { display: inline-flex; align-items: center; gap: 4px; }
.dry-run.locked pre { opacity: .6; }