…and the card wears it, and holds still

The card takes the accent border and a breathing `completing` pill in
the slot the status pill already owns — the working vocabulary, because
that is what is happening — and carries the latest narrated step on its
activity line, so "parking the drive" and "cleaned up: worktree and
local branch removed" land on the card and not only in the ticker.

While it is claimed the card offers nothing: no hover actions, no drive
or command chips (both run against the worktree being removed), and not
draggable, so the sheet cannot be reopened behind the run. Suppressed,
not ignored on click — an action that looks available and does nothing
is the same lie in a different place.
This commit is contained in:
istos
2026-07-31 16:57:16 +02:00
parent 7df6e19d0d
commit 5736126142
2 changed files with 60 additions and 9 deletions
+14
View File
@@ -470,6 +470,20 @@ step narrates in the ticker; a merge conflict aborts cleanly and the card
stays put. Cards without work move silently, and hand-moves on disk are
never intercepted — the board only asks when you act through it.
That work takes as long as it takes, so **the card wears it** rather than
sitting there looking idle while its branch is disassembled: from the
first step to the last it takes the accent border and a breathing
`completing` pill — the same vocabulary as an agent working, because that
is what is happening — and carries the latest narrated step on its
activity line ("parking the drive", "merged task/29-… into main",
"cleaned up: worktree and local branch removed"). While it does, it has
no hover actions, no drive or command chips and cannot be dragged, and a
second `complete` for the same card is refused rather than started. The
claim lives in this board's memory and is released on every exit —
merged, conflicted or crashed — so a failure gives the card straight back
and a board restarted mid-completion leaves nothing stuck. Other replicas
see the card unchanged until the move itself arrives.
With `BOARD_SYNC` on the merge is made **on origin** instead: the board
runs `gh pr merge` on the card's PR, cleans up and moves the card, and
local `main` fast-forwards to the result on the next beat. Replicas
+46 -9
View File
@@ -161,6 +161,15 @@
/* the last run on this card died: the same terracotta, worn until the
next launch replaces it or the card moves stage */
.card.run-failed{border-color:color-mix(in oklab, var(--alarm) 55%, var(--border))}
/* merge & clean up is running on this card: the working vocabulary,
because that is what is happening — the board is disassembling its
branch. No new colour, no new animation, and nothing to grab: the card
is not draggable and carries no actions until it lands in done/. */
.card.completing{
border-color:color-mix(in oklab, var(--accent) 55%, var(--border));cursor:default;
}
.card.completing:hover{transform:none;border-color:color-mix(in oklab, var(--accent) 55%, var(--border))}
.card .pill.status.breathing{animation:breathe 2.4s ease-in-out infinite}
/* tool chips: destinations, not statuses — they live in the card's footer,
never squeezed into the author row */
.chiprow{
@@ -817,6 +826,11 @@ function connectStream() {
} else if (msg.type === 'board_event') {
S.state?.boardEvents.push(msg.event);
scheduleRender();
} else if (msg.type === 'completing') {
// the cards this board is merging and cleaning up, and which step
// each is on — whole map, so it can never go stale in pieces
if (S.state) S.state.completing = msg.completing;
scheduleRender();
}
};
}
@@ -968,13 +982,17 @@ function cardFor(task) {
const working = agent && agent.mode !== 'review';
const verdict = task.stage === 'review' ? prVerdict(task) : null;
const failure = failedRun(task);
// the server holds this, not the tab that clicked: while merge & clean up
// runs, the card is mid-disassembly and hands back none of its actions
const completing = (S.state.completing || {})[task.file];
el.className = 'card'
+ (S.selected && S.selected.file === task.file ? ' selected' : '')
+ (working ? ' running' : '')
+ (verdict === 'green' ? ' verdict-good' : verdict === 'red' ? ' verdict-bad' : '')
+ (failure ? ' run-failed' : '')
+ (completing ? ' completing' : '')
+ (task.stage === 'done' ? ' done-dim' : '');
el.draggable = true;
el.draggable = !completing;
let tint = working ? 'var(--accent)' : STAGE_TINT[task.stage];
let pill = agent && agent.mode === 'review'
@@ -994,14 +1012,21 @@ function cardFor(task) {
title: failure.excerpt };
tint = 'var(--alarm)';
}
if (completing) {
// live work outranks every settled reading: the branch behind an
// 'approved' pill is being merged away as you look at it
pill = { text: 'completing', tint: 'var(--accent)', bg: mix('var(--accent)', 16),
title: 'merge & clean up is running — the card comes back when it lands in done/' };
tint = 'var(--accent)';
}
const high = (task.priority || '').toLowerCase() === 'high';
const top = [
`<span class="mark${working ? ' breathing' : ''}" style="background:${tint}"></span>`,
`<span class="mark${working || completing ? ' breathing' : ''}" style="background:${tint}"></span>`,
`<span class="ref">${task.number ? '#' + esc(task.number) : esc(task.file.slice(0, 10))}</span>`,
'<span class="spacer"></span>',
high ? '<span class="high">HIGH</span>' : '',
task.statusMismatch ? `<span class="pill drift" title="File says ${esc(task.declaredStatus)}">drift</span>` : '',
`<span class="pill status" style="background:${pill.bg};color:${pill.tint}"` +
`<span class="pill status${completing ? ' breathing' : ''}" style="background:${pill.bg};color:${pill.tint}"` +
`${pill.title ? ` title="${esc(pill.title)}"` : ''}>${pill.text}</span>`,
];
@@ -1010,7 +1035,10 @@ function cardFor(task) {
const stillTrue = { glyph: '◔', label: 'still true?', confirm: 'check it?', busy: 'checking…',
title: 'A read-only agent checks this task is still true of the codebase',
run: () => fireAgent(task, '/api/agent/review') };
if (agent) {
if (completing) {
// none, deliberately: an action that looks available and does nothing
// is the same lie as a card that looks idle while its branch is deleted
} else if (agent) {
actions.push({ glyph: '‖', label: 'hold', confirm: 'hold it?', busy: 'holding…',
title: 'Stop this agent — nothing is lost',
run: () => stopAgent(agent.id) });
@@ -1088,7 +1116,12 @@ function cardFor(task) {
if (task.type) extras.push(esc(task.type.toLowerCase()));
let liveLine = '';
if (agent && agent.session) {
if (completing) {
// the steps are narrated as board events against this file; the card
// wears the latest one instead of leaving the ticker to tell the story
liveLine = `<div class="well"><span class="lead">·</span>` +
`<span class="wbody">${esc(completing.step || 'completing…')}<span class="caret">▌</span></span></div>`;
} else if (agent && agent.session) {
const run = S.running[agent.session];
const smeta = sessionMeta(agent.session);
const last = run || (smeta && smeta.lastSummary ? { summary: smeta.lastSummary, ok: null } : null);
@@ -1129,7 +1162,9 @@ function cardFor(task) {
}
if (task.pr) chips.push({ label: 'PR', glyph: '↗', cls: '', href: task.pr, title: detail });
let driveWell = '';
if (task.stage === 'review') {
// the drive and the project's commands run against a worktree this card
// is in the middle of removing: no chip offers either while it does
if (task.stage === 'review' && !completing) {
const d = S.state.drive;
if (d && d.task === task.file && (d.status === 'up' || d.status === 'starting')) {
if (d.status === 'up') chips.push({ label: 'open', glyph: '✳', cls: 'accent',
@@ -1158,7 +1193,7 @@ function cardFor(task) {
}
}
// project commands run against this task's worktree
if (hasBranch && ['in-progress', 'review'].includes(task.stage)) {
if (hasBranch && !completing && ['in-progress', 'review'].includes(task.stage)) {
for (const cmd of (S.state.commands || [])) {
const running = (S.state.commandRuns || []).some(r => r.task === task.file && r.name === cmd.name);
if (running) {
@@ -1435,7 +1470,7 @@ function completeSheet(task, from) {
$('#sh-move').addEventListener('click', () => { closeSheet(); rawMove(task.file, from, 'done'); });
$('#sh-ship').addEventListener('click', async () => {
closeSheet();
toast(`Completing ${task.file} — the ticker narrates each step`);
toast(`Completing ${task.file} — the card shows each step`);
const res = await fetch('/api/task/complete', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file: task.file, from }),
@@ -1471,7 +1506,9 @@ function renderDrawer() {
const t = S.selected;
const agent = agentOnTask(t.file);
const failure = failedRun(t);
const pill = failure
const pill = (S.state.completing || {})[t.file]
? { text: 'completing', tint: 'var(--accent)', bg: mix('var(--accent)', 16) }
: failure
? { text: 'run failed', tint: 'var(--alarm)', bg: mix('var(--alarm)', 16) }
: agent && agent.mode === 'review'
? { text: 'reviewing', tint: 'var(--accent)', bg: mix('var(--accent)', 16) }