Files
bench/manager/core/board.html
T
istosandClaude Opus 5 b690060be2 Finishing a phase finishes its cards, and clears up after them
Merge & clean up on a phase card now ends the phase: after the merge into
main has actually succeeded, every member the phase merged moves to done/
and its workspace is cleared the way completing an ordinary card clears
its own — worktree removed, local branch deleted, branch on the remote
deleted.

Only what the phase merged. A member is swept when its card settled into
review/ or done/ and its branch is contained in the phase branch (or
there was never a branch to bring) — the same pair the runner reads a
member as merged by. One that halted, was held or was walked back keeps
its card, its worktree and its branch: there is work in them.

Nothing uncommitted is thrown away: a member's worktree comes out without
--force, and a dirty one is reported in the ticker and kept with its
branch rather than forced.

One ending, told once. taskfiles.move_together moves the cards in a
single commit naming all of them (`board: 47, 52 → done with phase 53`,
so it publishes in team mode like any other board commit), and the moves
are marked quiet so the watcher does not also scroll five identical move
lines behind the one line the ending gets.

The other endings are untouched: "just move the card" and archiving the
phase card move no member, because neither puts anything in main.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 10:08:00 +02:00

3200 lines
157 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- The server rewrites this to "<project> · bench"; the view switcher keeps
it in step. Project first — tab truncation eats the tail. -->
<title>bench</title>
<!-- The icon is the wordmark's own b on an accent tile, as the design draws
it at app-icon size. Its outline is the same string as #mark-b below —
one letter, two places; tests/test_header_logo.py keeps them equal. -->
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill-rule='evenodd'><rect width='16' height='16' rx='4' fill='%230d6e8c'/><path fill='%23ffffff' transform='translate(4.81 3.57) scale(.011)' d='M15 0H178V210H330C450 210 565 305 565 420V530C565 645 450 740 330 740H15V648H60V92H15ZM178 314V636H305C385 636 447 585 447 530V420C447 365 385 314 305 314Z'/></svg>">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:ital,wght@0,400;0,500;0,600;1,400&display=swap">
<style>
/* ── Bench design system: salt, pine and whitewash ─────────────────────
Colour only ever means state: --accent an agent alive, --calm settled,
--alarm snagged, --idle out of mind. Mono for machine output, sans for
people. If the interface is shouting, something is actually wrong. */
:root{
--bg:#0c1a20; --canvas:#112329; --surface:#162c33; --raised:#1e373f; --sunken:#0a161b;
--border:#264349; --border-soft:#1c343a; --line:rgba(255,255,255,.05);
--text:#e9f3f3; --muted:#95afb4; --dim:#62828a;
--accent:#56c2d8; --calm:#a6c96f; --alarm:#e08a63; --idle:#5d757b;
--on-accent:#0a161b; --on-calm:#141a10;
--shadow:0 10px 24px -18px rgba(0,0,0,.9);
--pad:14px; --gap:10px; --radius:10px; --col:296px; --col-min:240px;
/* the wordmark's b-height — the one number that sizes the logo */
--logo-h:14px;
--sans:'IBM Plex Sans',system-ui,sans-serif;
--mono:'IBM Plex Mono',ui-monospace,SFMono-Regular,Menlo,monospace;
}
:root[data-theme="light"]{
--bg:#dde9ec; --canvas:#eaf3f4; --surface:#ffffff; --raised:#ffffff; --sunken:#e2edef;
--border:#c5d7db; --border-soft:#d9e6e9; --line:rgba(18,50,60,.05);
--text:#12323b; --muted:#4d6e77; --dim:#87a1a8;
--accent:#0d6e8c; --calm:#5f7f33; --alarm:#b1543a; --idle:#93a8ac;
--on-accent:#ffffff; --on-calm:#ffffff;
--shadow:0 10px 24px -20px rgba(30,50,42,.42);
}
@keyframes breathe{0%,100%{opacity:.35;transform:scale(.82)}50%{opacity:1;transform:scale(1)}}
@keyframes blink{0%,49%{opacity:1}50%,100%{opacity:0}}
@keyframes rise{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}
@keyframes fadein{from{opacity:0}to{opacity:1}}
@keyframes drain{from{transform:scaleX(1)}to{transform:scaleX(0)}}
@keyframes slidein{from{opacity:0;transform:translateX(18px)}to{opacity:1;transform:translateX(0)}}
@media (prefers-reduced-motion: reduce){
*,*::before,*::after{animation:none !important;transition:none !important}
}
*{box-sizing:border-box}
html,body{margin:0;padding:0}
body{
background:var(--bg); color:var(--text);
font:13px/1.5 var(--sans); -webkit-font-smoothing:antialiased;
height:100vh; display:flex; flex-direction:column;
}
.mono{font-family:var(--mono)}
a{color:var(--accent);text-decoration:none}
a:hover{color:var(--text);text-decoration:underline}
::selection{background:var(--accent);color:var(--on-accent)}
button{
font-family:inherit;font-size:12.5px;color:var(--muted);cursor:pointer;
background:transparent;border:1px solid var(--border);border-radius:8px;
padding:6px 10px;transition:border-color .14s ease,color .14s ease;
}
button:hover{border-color:var(--accent);color:var(--text)}
button:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
select{
font-family:inherit;font-size:12.5px;color:var(--text);background:var(--surface);
border:1px solid var(--border);border-radius:8px;padding:6px 9px;
}
.label{font-size:11px;font-weight:600;letter-spacing:.09em;text-transform:uppercase;color:var(--muted)}
.spacer{flex:1}
/* ── header ── */
header{
display:flex;align-items:center;gap:14px;padding:10px 18px;flex-wrap:wrap;
background:var(--canvas);border-bottom:1px solid var(--border);
}
/* The word is the logo — "bench" set in Zilla Slab SemiBold, tracked
-.015em. It ships as outlines, not text: this page may not fetch a font
for it, and outlines can never fall back to something else. currentColor
gives it the theme's own ink, so it is never a colour that means state,
and --logo-h scales it in one place rather than per theme. */
.brand{display:flex;align-items:baseline;gap:11px}
.brand .mark{height:var(--logo-h);width:auto;fill:currentColor;flex:none}
.brand .path{font-family:var(--mono);font-size:11.5px;color:var(--dim)}
.views{display:flex;gap:2px;padding:3px;background:var(--sunken);border:1px solid var(--border-soft);border-radius:8px}
.views button{padding:5px 12px;border:1px solid transparent;border-radius:6px;font-size:12.5px;font-weight:500;color:var(--muted)}
.views button:hover{color:var(--text);border-color:transparent}
.views button.on{background:var(--surface);border-color:var(--border);color:var(--text)}
/* the crossing: a phase halting is learned from whatever view you are on,
so the switcher carries how many are running and whether one stopped */
.views button{display:flex;align-items:center;gap:6px}
.views .vcount{font-family:var(--mono);font-size:10.5px;color:var(--dim)}
.views button.on .vcount{color:var(--accent)}
.views .vhalt{width:6px;height:6px;border-radius:99px;background:var(--alarm);flex:none}
.livechip{
display:flex;align-items:center;gap:9px;padding:5px 12px 5px 10px;cursor:pointer;
background:var(--surface);border:1px solid var(--border);border-radius:99px;font-size:12.5px;
}
.livechip .mono{font-size:11.5px;color:var(--dim)}
/* an author display beats the UA's [hidden] — say it here or the sync
chip is never hidden */
.livechip[hidden]{display:none}
/* A running phase is the same kind of fact as the agents and the sync
chips: what is happening across the board without you. One chip per
phase in flight — two phases could in principle run at once, and a
chip that showed one of them would be worse than none — so the row
gives way before the chips already here do, clipping the names it
cannot fit rather than shrinking its neighbours. */
.phasechips{display:flex;align-items:center;gap:8px;min-width:0;overflow:hidden}
.phasechips[hidden]{display:none}
.phasechips .livechip{min-width:0}
.phasechips .name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.phasechips .mono{white-space:nowrap}
.phasechips .halted{color:var(--alarm)}
.dot{width:7px;height:7px;border-radius:99px;background:var(--idle);flex:none}
.dot.live{background:var(--accent);animation:breathe 2.4s ease-in-out infinite}
/* the model chip: which brain did this, beside the name that did it.
Machine-produced, so mono; a model is not a state, so it borrows no
colour — it lives in the session-id hash's register wherever names
appear, tracking each site's hash size. cursor:help because the
unshortened string is on the title. */
.mchip{font-family:var(--mono);font-size:10.5px;font-weight:400;color:var(--dim);white-space:nowrap;cursor:help}
/* the card's row is the tight one: there the chip gives way like the
mono fact beside it rather than pushing the row wider */
.card .whorow .mchip{font-size:11px;overflow:hidden;text-overflow:ellipsis}
.f-head .s-title .mchip{font-size:12px}
.refline .mchip{font-size:11.5px}
/* ── kanban ── */
main{flex:1;display:flex;min-height:0}
.view{flex:1;min-width:0;display:none}
.view.on{display:flex}
#view-board{flex-direction:column}
#board{flex:1;display:flex;gap:var(--gap);align-items:stretch;padding:14px 18px;overflow-x:auto;min-height:0}
.kcol{flex:1 1 0;min-width:var(--col-min);max-width:var(--col);display:flex;flex-direction:column;gap:8px;min-height:0}
.kcol > h2{
margin:0;display:flex;align-items:center;gap:8px;padding:0 4px 2px;
font-size:11px;font-weight:600;letter-spacing:.09em;text-transform:uppercase;color:var(--muted);
}
.kcol > h2 .tickmark{width:6px;height:6px;border-radius:2px;flex:none}
.kcol > h2 .count{font-family:var(--mono);font-size:11px;font-weight:400;color:var(--dim)}
.kcol > h2 .note{margin-left:auto;font-weight:400;letter-spacing:0;text-transform:none;font-size:11px;color:var(--dim)}
.drop{flex:1;overflow-y:auto;display:flex;flex-direction:column;gap:var(--gap);border-radius:var(--radius)}
.drop.over{background:color-mix(in oklab, var(--accent) 9%, transparent)}
.empty{
padding:22px 14px;border:1px dashed var(--border);border-radius:var(--radius);
text-align:center;font-size:12.5px;color:var(--dim);
}
.card{
position:relative;display:flex;flex-direction:column;gap:9px;padding:var(--pad);
background:var(--surface);border:1px solid var(--border-soft);border-radius:var(--radius);
box-shadow:var(--shadow);cursor:grab;
transition:border-color .14s ease,transform .14s ease;
}
.card:hover{transform:translateY(-1px);border-color:var(--accent)}
.card.dragging{opacity:.4}
.card.selected{border-color:var(--accent)}
.card.running{border-color:var(--border)}
/* the row reserves the action buttons' 24px, so hovering swaps the pill
for actions without the card growing or anything below it shifting */
.card .toprow{display:flex;align-items:center;gap:8px;min-width:0;min-height:24px}
.card .mark{width:6px;height:6px;border-radius:2px;flex:none}
.card .mark.breathing{animation:breathe 2.4s ease-in-out infinite}
.card .ref{font-family:var(--mono);font-size:11.5px;color:var(--dim)}
.card .high{font-family:var(--mono);font-size:10.5px;letter-spacing:.06em;color:var(--alarm)}
.pill{font-size:11px;padding:2px 7px;border-radius:99px;white-space:nowrap}
.pill.drift{background:color-mix(in oklab, var(--alarm) 16%, transparent);color:var(--alarm)}
.card .title{font-size:13.5px;line-height:1.35;font-weight:500;text-wrap:pretty}
.card.done-dim .title{color:var(--muted)}
/* PR verdicts: pine when it settled, terracotta when it snagged */
.card.verdict-good{border-color:color-mix(in oklab, var(--calm) 55%, var(--border))}
.card.verdict-bad{border-color:color-mix(in oklab, var(--alarm) 55%, var(--border))}
/* 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))}
/* a phase halted: the same terracotta, on the card that now stands for
every member this view no longer draws */
.card.halted{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{
display:flex;align-items:center;gap:5px;flex-wrap:wrap;
padding-top:9px;border-top:1px solid var(--line);
}
.chip2{
position:relative;display:inline-flex;align-items:center;gap:5px;flex:none;
padding:3px 9px;background:transparent;
border:1px solid var(--border);border-radius:99px;
font-family:var(--mono);font-size:11px;color:var(--muted);
white-space:nowrap;text-decoration:none;
}
.chip2 .g2{font-size:9.5px;opacity:.75}
a.chip2:hover,button.chip2:hover{border-color:var(--accent);color:var(--accent);text-decoration:none}
.chip2.ok{color:var(--calm);border-color:color-mix(in oklab, var(--calm) 45%, transparent)}
.chip2.accent{color:var(--accent);border-color:color-mix(in oklab, var(--accent) 55%, transparent)}
.chip2.bad{color:var(--alarm);border-color:color-mix(in oklab, var(--alarm) 45%, transparent)}
.chip2.dim{color:var(--dim);border-style:dashed;cursor:help}
.chip2.dim:hover{color:var(--dim);border-color:var(--border)}
/* the archive chip is the one action in a row of destinations, and the
exception is why it sits at the far end: pushed there by margin, and
quiet — no border until you are on it — because archiving is a
tidy-up, not somewhere the card is asking you to go. Hover, armed and
busy all outrank this rule, so it never dulls a live state. */
.chip2.arch{margin-left:auto;color:var(--dim);border-color:transparent}
.card .whorow{display:flex;align-items:center;gap:7px;min-width:0}
.card .initial{
display:flex;align-items:center;justify-content:center;width:18px;height:18px;flex:none;
border-radius:99px;background:var(--sunken);border:1px solid var(--border);
font-family:var(--mono);font-size:9.5px;color:var(--muted);
}
.card .who{font-size:12px;color:var(--muted);white-space:nowrap}
.card .meta{font-family:var(--mono);font-size:11px;color:var(--dim);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.well{
display:flex;align-items:flex-start;gap:7px;padding:8px 9px;background:var(--sunken);
border-radius:7px;font-family:var(--mono);font-size:11.5px;line-height:1.45;color:var(--muted);
overflow:hidden;
}
.well .lead{color:var(--accent);flex:none}
.well .wbody{flex:1;min-width:0;overflow-wrap:anywhere}
.well.bad{border-left:2px solid var(--alarm);border-radius:0 7px 7px 0}
.well.bad .lead{color:var(--alarm)}
.caret{color:var(--accent);animation:blink 1.1s step-end infinite}
/* hover actions take over the pill's slot — never stack on top of it.
they fade in place (opacity only): the target must not travel while
the pointer approaches it. padding + negative margin widen the slot's
hitbox without moving anything; clicks landing there die at the slot. */
.hoveracts{display:none;gap:4px;animation:fadein .12s ease;padding:4px;margin:-4px}
.card:hover .hoveracts,.hoveracts:has(.armed),.hoveracts:has(.busy){display:flex}
.card.has-acts:hover .toprow .pill.status,
.card.has-acts:hover .toprow .high,
.card.has-acts:has(.hoveracts .armed) .toprow .pill.status,
.card.has-acts:has(.hoveracts .armed) .toprow .high,
.card.has-acts:has(.hoveracts .busy) .toprow .pill.status,
.card.has-acts:has(.hoveracts .busy) .toprow .high{display:none}
/* the lane head's own controls wear this too: a phase's actions are card
actions, and one look is what says so */
.lacts button,.hoveracts button{
position:relative;display:flex;align-items:center;gap:4px;
padding:4px 10px;min-height:24px;
background:var(--raised);border:1px solid var(--border);border-radius:99px;
font-size:11px;color:var(--muted);
}
.lacts button:hover,.hoveracts button:hover{border-color:var(--accent);color:var(--accent)}
.lacts button .g,.hoveracts button .g{font-family:var(--mono);font-size:10px}
/* every state's label occupies the same grid cell, so the button is
born as wide as its widest state and never reshapes under the cursor */
.actlbl{display:inline-grid;justify-items:center}
.actlbl>span{grid-area:1/1;white-space:nowrap;visibility:hidden}
.actlbl .l-rest{visibility:visible}
button.armed .actlbl .l-rest,button.busy .actlbl .l-rest{visibility:hidden}
button.armed .actlbl .l-arm{visibility:visible}
button.busy .actlbl .l-busy{visibility:visible}
/* armed = alarm, and the disarm window drains visibly along the bottom */
.lacts button.armed,.hoveracts button.armed,button.chip2.armed{color:var(--alarm);border-color:var(--alarm)}
button.armed::after{
content:"";position:absolute;left:9px;right:9px;bottom:2px;height:2px;
border-radius:99px;background:var(--alarm);transform-origin:left;
animation:drain 5s linear var(--arm-delay,0s) forwards;
}
/* busy = the request is away: breathe until the redraw or the timeout */
.lacts button.busy,.hoveracts button.busy,button.chip2.busy{color:var(--accent);border-color:var(--accent);cursor:default}
button.busy .g,button.busy .g2{animation:breathe 2.4s ease-in-out infinite}
/* ── the activity bar: log drawer + latest line + archive tray ── */
#logpanel{
flex:none;display:none;flex-direction:column;min-height:0;
background:var(--canvas);border-top:1px solid var(--border);
}
#logpanel.open{display:flex}
#loggrip{height:8px;flex:none;cursor:ns-resize;display:flex;align-items:center;justify-content:center}
#loggrip::before{content:"";width:44px;height:3px;border-radius:99px;background:var(--border)}
#loggrip:hover::before,#loggrip.dragging::before{background:var(--accent)}
#loghead{
display:flex;align-items:center;gap:10px;padding:2px 18px 8px;
border-bottom:1px solid var(--border-soft);flex-wrap:wrap;
}
#loghead .mono{font-size:11px;color:var(--dim)}
#loghead button{padding:3px 10px;border-radius:99px;font-size:11.5px}
#loghead button.on{background:var(--surface);border-color:var(--accent);color:var(--text)}
#loghead details{font-family:var(--mono);font-size:11px;color:var(--dim);position:relative}
#loghead summary{cursor:pointer;list-style:none}
#loghead summary::before{content:"▸ "}
#loghead details[open] summary::before{content:"▾ "}
#loghead ul{
position:absolute;bottom:calc(100% + 6px);right:0;z-index:25;margin:0;
padding:8px 14px 8px 26px;background:var(--raised);border:1px solid var(--border);
border-radius:8px;box-shadow:var(--shadow);max-height:40vh;overflow-y:auto;min-width:230px;
}
#loghead .xfile{cursor:pointer;padding:1px 0;white-space:nowrap}
#loghead .xfile:hover{color:var(--accent)}
#logbody{flex:none;max-height:60vh;overflow-y:auto;padding:6px 18px 12px;display:grid;gap:2px;align-content:start}
.ev{display:grid;grid-template-columns:64px 18px 1fr;align-items:baseline;gap:10px;font-size:12px;min-width:0}
.ev time{font-family:var(--mono);font-size:11px;color:var(--dim)}
.ev .glyph{font-family:var(--mono);font-size:11.5px;color:var(--muted);text-align:center}
.ev .what{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text)}
.ev .what .who-b{font-weight:600}
.ev.ok .glyph{color:var(--calm)}
.ev.bad .glyph{color:var(--alarm)}
.ev.run .glyph{color:var(--accent)}
.ev.quiet .what{color:var(--dim)}
#statusbar{
flex:none;display:flex;align-items:center;gap:14px;height:42px;padding:0 14px 0 12px;
background:var(--canvas);border-top:1px solid var(--border);
transition:background .16s ease,border-color .16s ease;
}
#statusbar.drag{border-top-color:var(--accent)}
#statusbar.hot{background:color-mix(in oklab, var(--accent) 10%, var(--canvas))}
#actbtn{
display:flex;align-items:center;gap:8px;padding:5px 10px;flex:none;
background:transparent;border:1px solid transparent;border-radius:7px;font-size:12px;color:var(--muted);
}
#actbtn:hover{border-color:var(--border);color:var(--text)}
#actbtn .chev{font-family:var(--mono);font-size:10px;color:var(--dim)}
#actbtn .mono{font-size:11px;color:var(--dim)}
#bartail{
flex:1;min-width:0;display:flex;align-items:baseline;gap:8px;
font-family:var(--mono);font-size:11.5px;color:var(--dim);overflow:hidden;
}
#bartail .t-what{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
#bartail .t-who{color:var(--muted)}
#barhint{flex:1;display:none;align-items:center;justify-content:center;gap:9px;font-size:12.5px;color:var(--accent)}
#statusbar.drag #barhint{display:flex}
#statusbar.drag #bartail{display:none}
#tray{
display:flex;align-items:center;gap:8px;padding:6px 13px;flex:none;cursor:pointer;
background:var(--surface);border:1px solid var(--border);border-radius:8px;
color:var(--muted);font-size:12.5px;
transition:background .14s ease,border-color .14s ease,transform .14s ease;
}
#statusbar.drag #tray{border:1px dashed var(--accent);color:var(--accent)}
#statusbar.hot #tray{
background:color-mix(in oklab, var(--accent) 22%, transparent);
border:1px solid var(--accent);color:var(--accent);transform:scale(1.06);
}
#tray .glyph2{font-size:13px;line-height:1}
#tray .count{padding:1px 7px;background:var(--sunken);border-radius:99px;font-family:var(--mono);font-size:11px;color:var(--dim)}
#tray b{font-weight:500}
/* ── sessions ── */
#view-flight{min-height:0}
.f-rail{
width:250px;flex:none;display:flex;flex-direction:column;gap:4px;
padding:16px 14px;background:var(--canvas);border-right:1px solid var(--border);overflow-y:auto;
}
.f-rail .label{padding:0 4px 8px}
.sess-row{
display:flex;flex-direction:column;gap:5px;padding:10px 11px;text-align:left;
background:transparent;border:1px solid transparent;border-radius:8px;cursor:pointer;
}
.sess-row:hover{border-color:var(--border-soft)}
.sess-row.sel{background:var(--surface);border-color:var(--border)}
.sess-row .top{display:flex;align-items:center;gap:7px;font-size:13px;font-weight:500;color:var(--text)}
.sess-row .sid{font-family:var(--mono);font-size:10.5px;font-weight:400;color:var(--dim)}
.sess-row .sub{font-size:11.5px;color:var(--muted);line-height:1.4;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.f-main{flex:1;min-width:0;display:flex;flex-direction:column}
.f-head{display:flex;align-items:flex-start;gap:20px;padding:15px 20px;border-bottom:1px solid var(--border-soft);flex-wrap:wrap}
.f-head .s-title{display:flex;align-items:center;gap:9px;font-size:16px;font-weight:600}
.f-head .s-title .sid{font-family:var(--mono);font-size:12px;font-weight:400;color:var(--dim)}
.f-head .s-line{font-size:12.5px;color:var(--muted);margin-top:4px}
.spark{display:flex;align-items:flex-end;gap:2px;height:34px;margin-left:auto}
.spark i{width:5px;min-height:4px;background:var(--border);border-radius:1px;display:block}
.spark i.now{background:var(--accent)}
.filters{display:flex;gap:6px;padding:12px 20px;flex-wrap:wrap}
.filters button{padding:4px 11px;border-radius:99px;font-size:12px}
.filters button.on{background:var(--surface);border-color:var(--accent);color:var(--text)}
.tl{flex:1;overflow-y:auto;padding:0 20px 20px}
.tl-row{
display:grid;grid-template-columns:66px 20px 1fr;align-items:start;gap:10px;
padding:9px 0;border-bottom:1px solid var(--line);
}
.tl-row time{font-family:var(--mono);font-size:11.5px;color:var(--dim);padding-top:1px}
.tl-row .glyph{font-family:var(--mono);font-size:11.5px;color:var(--muted);padding-top:1px;text-align:center}
.tl-row.ok .glyph{color:var(--calm)}
.tl-row.bad .glyph{color:var(--alarm)}
.tl-row.run .glyph{color:var(--accent)}
.tl-row.move .glyph{color:var(--accent)}
.tl-row .tbody{display:flex;flex-direction:column;gap:6px;min-width:0}
.tl-row .text{font-size:13px;line-height:1.45;color:var(--text);overflow-wrap:anywhere}
.tl-row .text .arrow{color:var(--accent)}
.tl-row .text .actor{color:var(--dim)}
.fold{border:none;max-width:760px}
.fold summary{cursor:pointer;list-style:none;font-family:var(--mono);font-size:11.5px;color:var(--dim)}
.fold summary::before{content:"▸ "}
.fold[open] summary::before{content:"▾ "}
.fold pre{
margin:6px 0 0;padding:8px 10px;background:var(--sunken);border-radius:6px;
font-family:var(--mono);font-size:11.5px;line-height:1.5;color:var(--muted);
white-space:pre-wrap;overflow-x:auto;
}
/* a well as a real disclosure: the summary IS the well, so its is earned
— it rotates open instead of the fold's usual ▸ prefix */
.fold.wellfold{max-width:none}
.fold.wellfold summary::before{content:none}
.fold.wellfold summary::-webkit-details-marker{display:none}
.fold.wellfold .lead{transition:transform .12s ease}
.fold.wellfold[open] .lead{transform:rotate(90deg)}
.fold.wellfold pre{max-height:300px;overflow-y:auto}
.stopbtn:hover{border-color:var(--alarm);color:var(--alarm)}
.f-empty{padding:30px 20px;font-size:12.5px;color:var(--dim)}
/* ── focus ── */
#view-focus{flex-direction:column;overflow-y:auto}
.c-top{display:flex;align-items:center;gap:10px;padding:14px 18px 0}
.c-strip{display:flex;gap:8px;flex:1;overflow-x:auto}
.c-seg{
flex:1;min-width:0;display:flex;align-items:baseline;gap:8px;padding:6px 11px;
background:var(--surface);border:1px solid var(--border-soft);border-radius:8px;
}
.c-seg .lbl{font-size:10px;font-weight:600;letter-spacing:.05em;text-transform:uppercase;color:var(--muted);white-space:nowrap}
.c-seg .n{font-family:var(--mono);font-size:14px;font-weight:500;font-variant-numeric:tabular-nums}
.c-seg.hot{border-color:color-mix(in oklab, var(--accent) 55%, var(--border))}
.c-seg.hot .n{color:var(--accent)}
.c-grid{display:grid;grid-template-columns:minmax(0,1.55fr) minmax(0,1fr);gap:14px;padding:14px 18px 6px;align-content:start}
@media (max-width:900px){.c-grid{grid-template-columns:1fr}}
.panel{
display:flex;flex-direction:column;gap:12px;padding:16px;
background:var(--surface);border:1px solid var(--border);border-radius:12px;box-shadow:var(--shadow);
}
.panel .phead{display:flex;align-items:center;gap:9px}
.panel .phead .r{margin-left:auto;font-family:var(--mono);font-size:11.5px;color:var(--dim)}
.refline{display:flex;align-items:center;gap:8px;font-family:var(--mono);font-size:11.5px;color:var(--dim);flex-wrap:wrap}
.refline .acc{color:var(--accent)}
.focus-title{margin:0;font-size:22px;line-height:1.25;font-weight:600;letter-spacing:-.015em;text-wrap:pretty}
.steps{display:flex;flex-direction:column;border:1px solid var(--border-soft);border-radius:9px;overflow:hidden}
.step{display:flex;align-items:center;gap:10px;padding:10px 13px;border-bottom:1px solid var(--line)}
.step:last-child{border-bottom:none}
.step .glyph{font-family:var(--mono);font-size:12px;width:12px;flex:none}
.step .stext{flex:1;font-size:13px}
.step.done .glyph{color:var(--calm)} .step.done .stext{color:var(--muted)}
.step.doing{background:var(--sunken)} .step.doing .glyph{color:var(--accent)} .step.doing .stext{color:var(--text);font-weight:500}
.step.todo .glyph{color:var(--dim)} .step.todo .stext{color:var(--dim)}
.c-side{display:flex;flex-direction:column;gap:14px}
.check-row{display:flex;align-items:center;gap:9px;padding:7px 0;border-bottom:1px solid var(--line)}
.check-row:last-child{border-bottom:none}
.check-row .glyph{font-family:var(--mono);font-size:12px;width:12px;flex:none}
.check-row .name{flex:1;font-family:var(--mono);font-size:12.5px;color:var(--text)}
.check-row .state{font-size:11.5px;text-align:right}
.check-row.pass .glyph,.check-row.pass .state{color:var(--calm)}
.check-row.fail .glyph,.check-row.fail .state{color:var(--alarm)}
.check-row.none .glyph,.check-row.none .state{color:var(--dim)}
.file-row{display:flex;align-items:center;gap:10px;min-width:0;padding:3px 0}
.file-row .fpath{flex:1;font-family:var(--mono);font-size:12px;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;direction:rtl;text-align:left}
.file-row .add{font-family:var(--mono);font-size:11px;color:var(--calm)}
.file-row .del{font-family:var(--mono);font-size:11px;color:var(--alarm)}
.minifeed{display:grid;gap:3px}
.also{display:flex;gap:10px;padding:0 18px 18px;flex-wrap:wrap}
.also .card{flex:1 1 240px;cursor:default}
.also .card:hover{transform:none;border-color:var(--border-soft)}
/* ── phases: a swimlane each ── */
/* The room the five columns could never spare. One lane per phase, the
same five stages left to right, and the cards are the cards — full
fidelity is the whole advantage a view buys over a rail or a band. */
#view-phases{flex-direction:column;overflow-y:auto}
#lanes{display:flex;flex-direction:column;gap:14px;padding:14px 18px;flex:1}
.lane{
display:flex;flex-direction:column;gap:11px;padding:14px;
background:var(--surface);border:1px solid var(--border);border-radius:12px;
box-shadow:var(--shadow);
}
/* colour only means state, here as everywhere: a run alive, a run stopped */
.lane.running{border-color:color-mix(in oklab, var(--accent) 45%, var(--border))}
.lane.halted{border-color:color-mix(in oklab, var(--alarm) 55%, var(--border))}
.lhead{display:flex;align-items:center;gap:9px;min-width:0;flex-wrap:wrap}
.lhead .mark{width:7px;height:7px;border-radius:2px;flex:none;background:var(--idle)}
.lhead .mark.breathing{animation:breathe 2.4s ease-in-out infinite}
.lhead .lref{font-family:var(--mono);font-size:11.5px;color:var(--dim)}
.lhead .ltitle{
padding:0;border:none;background:none;border-radius:0;
font-size:15px;font-weight:600;letter-spacing:-.01em;color:var(--text);
}
.lhead .ltitle:hover{color:var(--accent);border:none}
.lhead .lprog{font-family:var(--mono);font-size:11.5px;color:var(--muted)}
/* the phase's own controls, in the head that owns them; they wear the
card actions' look because they are card actions in every other way */
.lacts{display:flex;align-items:center;gap:6px;margin-left:auto}
.lhalt{
display:flex;align-items:center;gap:10px;padding:9px 11px;background:var(--sunken);
border-left:2px solid var(--alarm);border-radius:0 7px 7px 0;font-size:12.5px;
}
.lhalt .lglyph{color:var(--alarm);font-family:var(--mono);font-size:12px;flex:none}
.lhalt .lwhy{flex:1;min-width:0;color:var(--muted);overflow-wrap:anywhere}
.lhalt .lwhy b{color:var(--alarm);font-weight:500}
.lstages{display:flex;gap:var(--gap);align-items:flex-start;overflow-x:auto}
.lcol{flex:1 1 0;min-width:var(--col-min);display:flex;flex-direction:column;gap:8px}
.lcol > h3{
margin:0;display:flex;align-items:center;gap:8px;padding:0 4px 2px;
font-size:11px;font-weight:600;letter-spacing:.09em;text-transform:uppercase;color:var(--muted);
}
.lcol > h3 .tickmark{width:6px;height:6px;border-radius:2px;flex:none}
.lcol > h3 .count{font-family:var(--mono);font-size:11px;font-weight:400;color:var(--dim)}
.lcol > h3 .note{margin-left:auto;font-weight:400;letter-spacing:0;text-transform:none;font-size:11px;color:var(--dim)}
.lcards{display:flex;flex-direction:column;gap:var(--gap)}
.lcol .empty{padding:14px 10px;font-size:11.5px}
/* the runner's decisions, in the order it wrote them — the only thing
that can tell "not reached yet" from "started and ended badly" */
.plog{display:flex;flex-direction:column;gap:4px;padding-top:10px;border-top:1px solid var(--line)}
.plog .phead{display:flex;align-items:baseline;gap:8px;font-size:12px;font-weight:500;color:var(--muted)}
.plog .phead .mono{font-family:var(--mono);font-size:10.5px;color:var(--dim)}
.plog .plines{display:flex;flex-direction:column;gap:2px;max-height:160px;overflow-y:auto}
.plog .pline{display:flex;gap:9px;font-family:var(--mono);font-size:11.5px;line-height:1.5}
.plog .pline .stamp{color:var(--dim);flex:none}
.plog .pline .what{color:var(--muted);min-width:0;overflow-wrap:anywhere}
.plog .pline.bad .what{color:var(--alarm)}
.plog .pempty{font-size:12px;color:var(--dim)}
/* ── drawer ── */
#drawer{
position:fixed;top:0;right:0;bottom:0;width:min(460px,92vw);z-index:20;display:none;
background:var(--canvas);border-left:1px solid var(--border);
box-shadow:-24px 0 50px -30px rgba(0,0,0,.7);animation:slidein .18s ease;
}
#drawer.open{display:flex}
#drawergrip{width:10px;flex:none;cursor:ew-resize;display:flex;align-items:center;justify-content:center}
#drawergrip::before{content:"";width:3px;height:44px;border-radius:99px;background:var(--border)}
#drawergrip:hover::before,#drawergrip.dragging::before{background:var(--accent)}
#drawerbody{
flex:1;min-width:0;overflow-y:auto;display:flex;flex-direction:column;gap:14px;
padding:20px 20px 20px 6px;
}
#drawer .dhead{display:flex;align-items:center;gap:10px}
/* the dead run's excerpt, above the task itself: machine output, bounded */
#drawer .well.bad pre{margin:6px 0 4px;white-space:pre-wrap;max-height:220px;overflow-y:auto;color:var(--text)}
/* the phase's list, above the card's own text: run order, one row per
card, machine facts (position, number, stage, the runner's reading)
in mono and the title in the reader's face */
#drawer .pmembers{display:flex;flex-direction:column;gap:1px}
#drawer .phead{
display:flex;align-items:baseline;gap:8px;font-size:12px;font-weight:500;
color:var(--muted);padding-bottom:5px;
}
#drawer .phead .mono{font-family:var(--mono);font-size:10.5px;color:var(--dim)}
#drawer .prow{
display:flex;align-items:baseline;gap:8px;width:100%;text-align:left;
padding:5px 8px;border:1px solid transparent;border-radius:7px;
background:var(--sunken);font-size:12.5px;color:var(--text);
}
#drawer .prow:hover{border-color:var(--accent)}
#drawer .prow .mono{font-family:var(--mono);font-size:11px;color:var(--dim)}
#drawer .prow .pn{width:14px;flex:none;text-align:right}
#drawer .prow .pref{flex:none}
#drawer .prow .ptitle{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
#drawer .prow .pstage{flex:none;color:var(--muted)}
#drawer .prow .pstate{flex:none}
#drawer .pempty{font-size:12px;color:var(--dim);padding:4px 8px}
#drawer .dbody{font-size:13px;line-height:1.6}
#drawer .dbody h1{font-size:19px;line-height:1.3;font-weight:600;letter-spacing:-.01em;margin:0 0 4px;text-wrap:pretty}
#drawer .dbody h2{font-size:14px;margin:18px 0 6px}
#drawer .dbody h3{font-size:13px;margin:14px 0 4px}
#drawer .dbody p,#drawer .dbody li{font-size:12.5px;color:var(--muted)}
#drawer .dbody ul,#drawer .dbody ol{margin:6px 0;padding-left:18px}
#drawer .dbody li{margin:3px 0}
#drawer .dbody li>ul,#drawer .dbody li>ol{margin:3px 0}
/* task-list items: a glyph, not an input — the file is the source of truth */
#drawer .dbody li.tick{list-style:none;margin-left:-18px}
#drawer .dbody li.tick .box{
display:inline-block;width:11px;height:11px;margin-right:7px;
border:1px solid var(--border);border-radius:3px;
font-size:9px;line-height:10px;text-align:center;
}
#drawer .dbody li.tick.on .box{border-color:var(--calm);color:var(--calm)}
#drawer .dbody strong{color:var(--text)}
#drawer .dbody code{font-family:var(--mono);font-size:11.5px;background:var(--sunken);padding:1px 4px;border-radius:3px}
#drawer .dbody pre{background:var(--sunken);border-radius:8px;padding:10px 12px;overflow-x:auto}
#drawer .dbody pre code{background:none;padding:0}
#drawer .dbody hr{border:0;border-top:1px solid var(--border-soft);margin:14px 0}
#drawer .dbody blockquote{margin:8px 0;padding-left:10px;border-left:3px solid var(--border);color:var(--muted)}
#drawer .dbody .tablewrap{overflow-x:auto;margin:10px 0;border:1px solid var(--border-soft);border-radius:8px}
#drawer .dbody table{border-collapse:collapse;width:100%;font-size:12px;line-height:1.45}
#drawer .dbody th{
text-align:left;padding:7px 10px;font-size:10.5px;font-weight:600;
letter-spacing:.07em;text-transform:uppercase;color:var(--muted);
border-bottom:1px solid var(--border);background:var(--sunken);white-space:nowrap;
}
#drawer .dbody td{padding:6px 10px;border-bottom:1px solid var(--line);vertical-align:top;color:var(--muted)}
#drawer .dbody tr:last-child td{border-bottom:none}
#drawer .dbody td code{white-space:nowrap}
#drawer .dmeta{font-family:var(--mono);font-size:11px;color:var(--dim)}
/* ── completion sheet ── */
#sheetwrap{
position:fixed;inset:0;z-index:30;display:none;align-items:flex-start;
justify-content:center;padding-top:18vh;background:rgba(10,9,8,.5);
backdrop-filter:blur(2px);
}
#sheetwrap.open{display:flex}
.sheet{
width:min(460px,92vw);display:flex;flex-direction:column;gap:12px;
padding:18px;background:var(--canvas);border:1px solid var(--border);
border-radius:12px;box-shadow:0 30px 60px -30px rgba(0,0,0,.8);
animation:rise .16s ease;
}
.sheet .stitle{display:flex;align-items:center;gap:9px;font-size:15px;font-weight:600}
.sheet .stitle .mono{font-size:11.5px;font-weight:400;color:var(--dim)}
.sheet p{margin:0;font-size:12.5px;line-height:1.55;color:var(--muted)}
.sheet .sbtns{display:flex;flex-direction:column;gap:6px;margin-top:2px}
.sheet .sbtns button{text-align:left;padding:10px 12px;font-size:13px}
.sheet .sbtns button small{display:block;font-size:11.5px;color:var(--dim);margin-top:2px}
.sheet .sbtns button.shipit{background:var(--calm);border-color:var(--calm);color:var(--on-calm);font-weight:500}
.sheet .sbtns button.shipit small{color:color-mix(in oklab, var(--on-calm) 75%, transparent)}
.sheet .sbtns button.shipit:hover{border-color:var(--calm);color:var(--on-calm);filter:brightness(1.06)}
/* ── toast ──
A refusal has to say the whole thing — which member is working, and
that ‖ hold stops it — so the pill wraps and stays on the screen
rather than running off both ends of it. */
#toast{
position:fixed;left:50%;bottom:44px;transform:translateX(-50%);z-index:40;display:none;
align-items:center;gap:10px;padding:11px 16px;font-size:13px;color:var(--text);
background:var(--raised);border:1px solid var(--border);border-radius:99px;
box-shadow:0 16px 34px -22px rgba(0,0,0,.8);animation:rise .18s ease;
max-width:min(620px, calc(100vw - 48px));line-height:1.5;
}
#toast.wrapped{border-radius:16px;align-items:flex-start}
#toast.wrapped .dot{margin-top:6px}
#toast.show{display:flex}
#toast .dot{background:var(--calm)}
#toast.err .dot{background:var(--alarm)}
</style>
</head>
<body>
<header>
<!-- The wordmark, one glyph per path, on a 1000-unit em: baseline at y=740,
x-height top at 210, ascender at 0; the box runs to 746 to hold the
round letters' overshoot. Translations are the advance widths with the
design's -.015em tracking already folded in. -->
<div class="brand">
<svg class="mark" viewBox="0 0 2674 746" fill-rule="evenodd" role="img" aria-label="bench">
<path id="mark-b" d="M15 0H178V210H330C450 210 565 305 565 420V530C565 645 450 740 330 740H15V648H60V92H15ZM178 314V636H305C385 636 447 585 447 530V420C447 365 385 314 305 314Z"/>
<path transform="translate(565)" d="M205 204H319C415 204 494 283 494 379V522H148V582C148 616 172 642 206 642H330C395 642 445 615 470 566V670C440 712 390 746 319 746H205C109 746 30 667 30 571V379C30 283 109 204 205 204ZM148 428V368C148 335 172 308 206 308H318C352 308 376 335 376 368V428Z"/>
<path transform="translate(1074)" d="M15 210H330C450 210 520 300 520 400V648H565V740H357V648H402V400C402 345 375 314 320 314H178V648H223V740H15V648H60V302H15Z"/>
<path transform="translate(1639)" d="M300 204C358 204 404 212 438 236L392 326C365 313 336 308 300 308C212 308 148 378 148 475C148 572 212 642 300 642C336 642 365 637 392 624L438 714C404 738 358 746 300 746C167 746 30 640 30 475C30 310 167 204 300 204Z"/>
<path transform="translate(2094)" d="M15 0H178V210H330C450 210 520 300 520 400V648H565V740H357V648H402V400C402 345 375 314 320 314H178V648H223V740H15V648H60V92H15Z"/>
</svg>
<span class="path" id="root"></span>
</div>
<nav class="views" id="views">
<button data-view="board" class="on">Board</button>
<!-- the label is written by renderViews(): the running count, and the
mark that says a phase halted while you were somewhere else -->
<button data-view="phases">Phases</button>
<button data-view="flight">Sessions</button>
<button data-view="focus">Focus</button>
</nav>
<span class="spacer"></span>
<div class="phasechips" id="phasechips" hidden></div>
<div class="livechip" id="syncchip" hidden style="cursor:default"></div>
<div class="livechip" id="livechip" title="open Sessions"></div>
<button id="themebtn">Daylight</button>
<button id="refresh">Refresh</button>
</header>
<main>
<!-- board -->
<div class="view on" id="view-board">
<div id="board"></div>
</div>
<!-- phases: one swimlane each -->
<div class="view" id="view-phases">
<div id="lanes"></div>
</div>
<!-- sessions -->
<div class="view" id="view-flight">
<aside class="f-rail" id="frail"></aside>
<div class="f-main">
<div class="f-head" id="fsession"></div>
<div class="filters" id="ffilters"></div>
<div class="tl" id="ftl"></div>
</div>
</div>
<!-- focus -->
<div class="view" id="view-focus">
<div class="c-top">
<select id="focussel"></select>
<div class="c-strip" id="cstrip"></div>
</div>
<div class="c-grid" id="cgrid"></div>
<div class="also" id="calso"></div>
</div>
</main>
<div id="logpanel">
<div id="loggrip" title="drag to resize"></div>
<div id="loghead"></div>
<div id="logbody"></div>
</div>
<footer id="statusbar">
<button id="actbtn"></button>
<span id="bartail"></span>
<span id="barhint"><span style="font-size:14px"></span><span id="barhinttext"></span></span>
<div id="tray" title="Drop a card here to archive it"></div>
</footer>
<aside id="drawer"><div id="drawergrip" title="drag to resize"></div><div id="drawerbody"></div></aside>
<div id="sheetwrap"></div>
<div id="toast"><span class="dot"></span><span id="toastmsg"></span></div>
<script>
const $ = (sel) => document.querySelector(sel);
const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
const S = {
state: null,
events: {}, // sid -> events (streamed + lazily loaded)
loaded: {}, // sid -> true once history fetched
running: {}, // sid -> in-flight Bash event (PreToolUse)
view: 'board',
selected: null, // task open in the drawer
flightSid: null,
focusSid: null,
filter: 'all',
diffCache: {}, // agentId -> {at, files, fetching}
openFolds: new Set(), // expanded output folds, kept across renders
fileView: null, // {dir, name, content} open in the drawer
drawerKey: null, // which document the drawer last painted, for its scroll mark
logStick: true, // follow the newest event unless the user scrolled up
logOpen: localStorage.getItem('bench-log-open') === '1',
logFilter: 'all',
acts: {}, // action key -> {phase: 'armed'|'busy', until} across re-renders
dragging: null, // {file, from} while a card is mid-drag
dockHot: false, // pointer over the bar with an archivable card
dark: localStorage.getItem('bench-theme')
? localStorage.getItem('bench-theme') === 'dark'
: !window.matchMedia('(prefers-color-scheme: light)').matches,
};
/* colour only ever means state */
const STAGE_TINT = { backlog: 'var(--dim)', 'to-do': 'var(--muted)',
'in-progress': 'var(--accent)', review: 'var(--calm)', done: 'var(--idle)' };
const STAGE_NOTE = { 'to-do': 'next up', review: 'your move' };
const GLYPHS = { session: '●', end: '○', idle: '…', edit: '✎', read: '◔', search: '⌕',
command: '$', test: '▶', check: '☑', git: '⎇', plan: '≡', subagent: '⑂', web: '∿',
move: '⇢', new: '', agent: '⚑', report: '▣', sync: '⇅', phase: '⟶', other: '·' };
const FILTERS = [
['all', 'All', null],
['moves', 'Moves', new Set(['move', 'new', 'agent', 'sync', 'phase'])],
['edits', 'Edits', new Set(['edit'])],
['reads', 'Reads', new Set(['read', 'search'])],
['tests', 'Tests', new Set(['test', 'check'])],
['commands', 'Commands', new Set(['command', 'subagent', 'web', 'other'])],
['git', 'Git', new Set(['git'])],
['plan', 'Plan', new Set(['plan', 'report'])],
];
function mix(tint, pct) { return `color-mix(in oklab, ${tint} ${pct}%, transparent)`; }
function pillFor(stage, working) {
if (working) return { text: 'working', tint: 'var(--accent)', bg: mix('var(--accent)', 16) };
if (stage === 'review') return { text: 'waiting on you', tint: 'var(--calm)', bg: mix('var(--calm)', 18) };
if (stage === 'to-do') return { text: 'queued', tint: 'var(--muted)', bg: 'var(--sunken)' };
if (stage === 'in-progress') return { text: 'in progress', tint: 'var(--accent)', bg: mix('var(--accent)', 12) };
if (stage === 'done') return { text: 'done', tint: 'var(--idle)', bg: 'var(--sunken)' };
return { text: 'backlog', tint: 'var(--dim)', bg: 'var(--sunken)' };
}
/* PR verdict: the file's latest claude review + GitHub's live state.
Any red signal wins; green needs an approval and no red. */
function prVerdict(task) {
if (!task.pr) return null;
const gh = (S.state.prs || {})[task.file];
const signals = [task.prVerdict, gh && gh.verdict];
if (signals.includes('red')) return 'red';
if (signals.includes('green')) return 'green';
return 'pending';
}
function fmtClock(ts) {
return new Date(ts * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
function fmtShort(ts) {
return new Date(ts * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
function ago(ts) {
const s = Math.max(0, Date.now() / 1000 - ts);
if (s < 60) return `${Math.round(s)}s`;
if (s < 3600) return `${Math.round(s / 60)}m`;
if (s < 86400) return `${Math.round(s / 3600)}h`;
return `${Math.round(s / 86400)}d`;
}
function elapsed(fromTs, toTs) {
const s = Math.max(0, (toTs || Date.now() / 1000) - fromTs);
const m = Math.floor(s / 60), h = Math.floor(m / 60);
return h ? `${h}h ${m % 60}m` : `${m}m`;
}
function sessionElapsed(meta) {
const over = meta.status === 'ended' || !isLiveSession(meta);
return elapsed(meta.started, over ? (meta.last || meta.started) : null);
}
let toastTimer = null;
/* A refusal that names what to do instead is longer than "07 → done/", and
a message you cannot finish reading is a wall however politely it is
worded. So the pill takes the shape its text needs and stays up for as
long as that text takes to read, up to a ceiling. */
function toast(message, isError = false) {
const el = $('#toast');
$('#toastmsg').textContent = message;
el.classList.toggle('err', isError);
el.classList.toggle('wrapped', message.length > 90);
el.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => el.classList.remove('show'),
Math.min(9000, 3200 + message.length * 28));
}
function applyTheme() {
document.documentElement.setAttribute('data-theme', S.dark ? 'dark' : 'light');
$('#themebtn').textContent = S.dark ? 'Daylight' : 'Night';
localStorage.setItem('bench-theme', S.dark ? 'dark' : 'light');
}
/* ── data ─────────────────────────────────────────────────────────────── */
async function loadState() {
const res = await fetch('/api/state');
S.state = await res.json();
for (const meta of S.state.sessions) if (!(meta.id in S.events)) S.events[meta.id] = [];
$('#root').textContent = S.state.board.root.replace(/^.*?\/([^/]+\/\.task-manager).*$/, '$1');
render();
}
async function loadSession(sid) {
if (S.loaded[sid]) return;
S.loaded[sid] = true;
try {
const res = await fetch('/api/session?id=' + encodeURIComponent(sid));
const data = await res.json();
// keep any events that streamed in after the history snapshot was taken
const lastTs = data.events.length ? data.events[data.events.length - 1].ts : 0;
const streamed = (S.events[sid] || []).filter(e => e.ts > lastTs);
S.events[sid] = data.events.concat(streamed);
render();
} catch { S.loaded[sid] = false; }
}
function sessionsOf(pred) { return (S.state?.sessions || []).filter(pred); }
/* The run behind a session: the live launch record while this board still
holds it, else the identity persisted with the session itself. The two
answer different amounts — a replayed run has a name and a model, not a
process — so what depends on liveness (Hold, the worktree branch) simply
finds nothing on the second, and the chip finds what it needs on both. */
function agentFor(sid) {
const live = (S.state?.agents || []).find(a => a.session === sid);
if (live) return live;
const meta = (S.state?.sessions || []).find(m => m.id === sid);
return meta && meta.agentId
? { id: meta.agentId, name: meta.agentName, model: meta.agentModel, replayed: true }
: undefined;
}
function agentOnTask(file) {
return (S.state?.agents || []).find(a => a.task === file && a.status === 'running');
}
/* The most recent run on a card. Records outlive their processes, so the
latest launch is a max-by-start question, not a find. */
function lastRunOn(file) {
return (S.state?.agents || []).reduce(
(best, a) => (a.task === file && (!best || a.started > best.started) ? a : best), null);
}
/* A dead run is a state the card wears: alarm border, `run failed` pill and
the log's tail, until the next launch replaces it (a newer run is the
latest one) or the card moves stage (the server drops the state, and the
stage stamp keeps the card honest in the seconds before the watcher
notices). Every headless kind counts — work, act-pr, PR review, relevance. */
function failedRun(task) {
const last = lastRunOn(task.file);
const failure = last && last.status === 'failed' ? last.failure : null;
return failure && failure.stage === task.stage ? failure : null;
}
/* The line a run died on: the excerpt's last, which is where a dying
process says why ("API Error: 500 …"). Bounded, so one enormous line of
machine output cannot grow the card — the whole excerpt is a hover away. */
function whyFailed(failure) {
const lines = (failure.excerpt || '').split('\n').filter(l => l.trim());
const why = lines.length ? lines[lines.length - 1].trim() : 'no output';
return why.length > 160 ? why.slice(0, 160) + '…' : why;
}
function sessionMeta(sid) { return (S.state?.sessions || []).find(m => m.id === sid); }
function isLiveSession(m) {
const fresh = (Date.now() / 1000 - (m.last || m.started || 0)) < 900;
return m.status !== 'ended' && fresh;
}
function allTasks() { return (S.state?.board.stages || []).flatMap(s => s.tasks); }
/* Which model a run rode, shortened for the chip: the provider path an
opencode-style id carries (anthropic/model-x) and the vendor word a
claude one repeats (claude-opus-4-8) are both redundant beside a board
that already knows its vendor. Nothing else is touched — an unknown
name is shown as recorded rather than guessed at. */
function shortModel(model) {
return String(model).split('/').pop().replace(/^claude-/, '');
}
/* One chip, every place a name identifies a run. A launch that never knew
its model (it inherited the vendor default, or the session was replayed
from disk after a restart) gets nothing at all: no chip is the honest
answer, a placeholder would read as a model named "unknown". */
function modelChip(agent) {
if (!agent || !agent.model) return '';
return `<span class="mchip" title="${esc(agent.model)}">${esc(shortModel(agent.model))}</span>`;
}
function connectStream() {
const es = new EventSource('/api/stream');
let hadError = false;
es.onopen = () => {
if (hadError) { hadError = false; loadState(); }
S.sseDown = false; renderChip();
};
es.onerror = () => { hadError = true; S.sseDown = true; renderChip(); };
es.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === 'event') {
const ev = msg.event, sid = ev.session;
if (msg.session) {
const idx = (S.state.sessions || []).findIndex(m => m.id === sid);
if (idx >= 0) S.state.sessions[idx] = msg.session;
else S.state.sessions.unshift(msg.session);
}
if (ev.running) { S.running[sid] = ev; }
else {
delete S.running[sid];
(S.events[sid] ||= []).push(ev);
if (S.events[sid].length > 800) S.events[sid].splice(0, S.events[sid].length - 800);
}
// an agent's first event links it to its session — refetch so cards
// switch from "warming up" to the live line without waiting for a move
if (msg.session && msg.session.agentId) {
const rec = (S.state.agents || []).find(a => a.id === msg.session.agentId);
if (rec && !rec.session) { loadState(); return; }
}
scheduleRender();
} else if (msg.type === 'board' || msg.type === 'agents') {
loadState();
} else if (msg.type === 'toast') {
// the server needs to say something to the person, not just the
// ticker — losing a card to another board is the case that matters
toast(msg.message, !!msg.error);
} 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();
}
};
}
let renderQueued = false;
function scheduleRender() {
if (renderQueued) return;
renderQueued = true;
requestAnimationFrame(() => { renderQueued = false; render(); });
}
/* ── keeping your place across a redraw ───────────────────────────────── */
/* The page redraws by demolition: every pass throws the DOM away and
builds it again, so a scrolling element it rebuilds is a brand-new node
and a new node's scrollTop is 0. render() runs on every SSE frame and a
working agent emits an event per tool use, so without this you lose your
place several times a minute — precisely when you are trying to read.
The cure is small and paid separately: note where each scroller was
before the wipe, put it back once the new nodes are in. Both halves run
inside the same synchronous render, so nothing flashes.
Keys are stable names rather than nodes — the stage slug for a column,
the session id for a timeline — because the node itself does not
survive. A `v:` prefix marks a key belonging to a view; those are
dropped when you switch views, so coming back is a fresh look rather
than a stale position from a previous visit. The activity log spans
every view, so its key carries no prefix and outlives the switch. */
const scrollMarks = {};
function markScroll(key, el) {
if (!el) return;
scrollMarks[key] = { top: el.scrollTop, left: el.scrollLeft };
}
/* Clamp rather than guess. A column whose cards moved on may be shorter
than it was, and an offset past the new bottom should land at the new
bottom — not throw, not blank. (Content that changed *above* the
viewport still shifts what you are looking at. That is honest, and it is
not what this is for.) */
function restoreScroll(key, el) {
const at = scrollMarks[key];
// a scroller that was at the top is already where it belongs, and
// saying so here is what keeps the common frame free of the layout
// flush that reading scrollHeight costs
if (!el || !at || (!at.top && !at.left)) return;
el.scrollTop = Math.max(0, Math.min(at.top, el.scrollHeight - el.clientHeight));
el.scrollLeft = Math.max(0, Math.min(at.left, el.scrollWidth - el.clientWidth));
}
function forgetViewScroll() {
for (const key of Object.keys(scrollMarks)) {
if (key.startsWith('v:')) delete scrollMarks[key];
}
}
/* ── header ───────────────────────────────────────────────────────────── */
/* One reading of "an agent is working", shared by the header chip and the
tab title, so the two can never disagree about how many. */
function runningAgents() {
return (S.state?.agents || []).filter(a => a.status === 'running');
}
function renderChip() {
const agents = runningAgents();
const liveYou = sessionsOf(m => isLiveSession(m) && !m.agentId);
const el = $('#livechip');
if (S.sseDown) {
el.innerHTML = `<span class="dot" style="background:var(--alarm)"></span>` +
`<span style="color:var(--alarm)">reconnecting…</span>` +
`<span class="mono">what you see may be stale</span>`;
return;
}
if (agents.length) {
const longest = Math.min(...agents.map(a => a.started));
el.innerHTML = `<span class="dot live"></span>` +
`<span>${agents.length} agent${agents.length > 1 ? 's' : ''} working</span>` +
`<span class="mono">${elapsed(longest)}</span>`;
} else if (liveYou.length) {
el.innerHTML = `<span class="dot live"></span><span>you, working</span>` +
`<span class="mono">${liveYou.length} session${liveYou.length > 1 ? 's' : ''}</span>`;
} else {
el.innerHTML = `<span class="dot"></span><span style="color:var(--muted)">quiet</span>`;
}
}
/* Sync only shows itself when it has stopped converging: a stall that is
not visible is two halves of a team quietly drifting apart. Offline is
driftwood (degraded, self-healing); anything waiting on a human is
terracotta. */
function renderSync() {
const s = S.state?.sync;
const el = $('#syncchip');
if (!s || !s.enabled || s.state === 'ok') { el.hidden = true; return; }
const alarm = s.state === 'stalled';
const detail = s.detail || '';
el.hidden = false;
el.title = detail;
el.innerHTML = `<span class="dot" style="background:var(--${alarm ? 'alarm' : 'idle'})"></span>` +
`<span style="color:var(--${alarm ? 'alarm' : 'muted'})">sync ${alarm ? 'stalled' : 'behind'}</span>` +
`<span class="mono">${esc(detail.split(' — ')[0].replace(/^sync[^:]*:\s*/, ''))}</span>`;
}
/* ── a phase in flight ────────────────────────────────────────────────── */
/* The phases the header has something to say about: one running, or one
halted and not yet settled. A phase nobody has started, and one whose
card has left in-progress/, are simply not here — with none of them the
header is exactly what it was before phases existed.
A halt leads, because it is the one a person must not scroll past. */
function phasesInFlight() {
return Object.values(S.state?.phases || {})
.filter(p => p.running || p.halted)
.sort((a, b) => (b.halted ? 1 : 0) - (a.halted ? 1 : 0) ||
a.file.localeCompare(b.file));
}
/* Where a phase is up to, from the members the last pass read: how many
have landed on the phase branch, and which one is in flight — an agent
on it, or its checks still running. */
function phaseProgress(p) {
const members = p.members || [];
return {
done: members.filter(m => m.state === 'merged').length,
total: members.length,
on: members.find(m => m.state === 'running') ||
members.find(m => m.state === 'waiting') || null,
};
}
function clip(text, n) {
return text.length > n ? text.slice(0, n - 1) + '…' : text;
}
/* What the chip says after the name: the halt and where it happened, or
the progress and the card in flight. The whole of it is on the title —
a header chip is not the place a reason gets to run long. */
function phaseChipDetail(p) {
if (p.halted) {
return (p.haltedAt ? `halted at #${p.haltedAt} — ` : 'halted — ') +
clip(p.haltedWhy || p.halted, 46);
}
const at = phaseProgress(p);
const rest = at.on ? `on #${at.on.number || at.on.file}`
: (p.waitingOn || []).length ? `waiting on #${p.waitingOn[0]}` : '';
return `${at.done}/${at.total}` + (rest ? ` · ${rest}` : '');
}
function renderPhases() {
const el = $('#phasechips');
const live = phasesInFlight();
el.hidden = !live.length;
el.innerHTML = live.map(p => {
const at = phaseProgress(p);
const detail = phaseChipDetail(p);
// colour only means state: a run alive breathes in accent, a halt
// holds in alarm until the phase is resumed or held
const title = (p.halted
? `${p.file} halted${p.haltedAt ? ` at ${p.haltedAt}` : ''} — ` +
`${p.haltedWhy || p.halted}. It stays here until the phase is run again or held.`
: `${p.file} is running on ${p.branch}${at.done} of ${at.total} cards merged`) +
' · click to open the phase card';
const dot = p.halted
? '<span class="dot" style="background:var(--alarm)"></span>'
: '<span class="dot live"></span>';
return `<div class="livechip" data-phasechip="${esc(p.file)}" title="${esc(title)}">` +
dot + `<span class="name${p.halted ? ' halted' : ''}">⟶ ${esc(phaseLabel(p))}</span>` +
`<span class="mono${p.halted ? ' halted' : ''}">${esc(detail)}</span></div>`;
}).join('');
el.querySelectorAll('[data-phasechip]').forEach(chip =>
chip.addEventListener('click', () => {
const task = findTask(chip.dataset.phasechip);
if (!task) return;
setView('board');
showDetail(task);
}));
}
/* The crossing this feature stands on, seen from the other side: a phase
halts while you are on the Board, and the switcher says so without you
having to go and look. The count is the phases running; the mark is a
halt, in the alarm every other stopped thing wears. A board with no
phase in flight has a switcher that reads exactly as it always did. */
function renderViews() {
const btn = document.querySelector('#views button[data-view="phases"]');
if (!btn) return;
const all = Object.values(S.state?.phases || {});
const running = all.filter(p => p.running).length;
const halted = all.filter(p => p.halted);
btn.innerHTML = 'Phases' +
(running ? `<span class="vcount">${running}</span>` : '') +
(halted.length ? '<span class="vhalt"></span>' : '');
btn.title = halted.length
? halted.map(p => `${p.file} halted${p.haltedAt ? ` at ${p.haltedAt}` : ''} — ` +
`${p.haltedWhy || p.halted}`).join(' · ') + ' — open Phases to read it and run it again'
: running
? `${running} phase${running > 1 ? 's' : ''} running — a swimlane each`
: 'One swimlane per phase: its cards, its log, its controls';
}
function setView(view) {
// leaving a view drops where you were in it: a hidden view's scrollers
// read 0 anyway, and coming back with a remembered offset would be
// restoring a position from a visit that has since gone stale
if (view !== S.view) forgetViewScroll();
S.view = view;
document.querySelectorAll('#views button').forEach(b => b.classList.toggle('on', b.dataset.view === view));
document.querySelectorAll('.view').forEach(v => v.classList.toggle('on', v.id === 'view-' + view));
render();
}
/* The tab says which bench this is: the project first (tab truncation eats
the tail, and the tail is the same in every bench tab), then the view.
Without a project in state the server-rendered title stands.
Ahead of even the project, while and only while agents run, the count —
the one state you want most from a tab you are not looking at, and the
only place a backgrounded window can still say it. The same reasoning
that put the project first applies harder to it, so it leads: a tab
narrowed to a few characters still shows it. It is the in-flight glyph
the CI and copilot chips already use, not an emoji, and a quiet board
is the plain title, byte for byte. */
const VIEW_TITLES = { board: 'bench', phases: 'phases', flight: 'sessions', focus: 'focus' };
const WORKING_MARK = '◌';
function tabTitle(project, view, working) {
return (working ? working + WORKING_MARK + ' · ' : '') +
project + ' · ' + (VIEW_TITLES[view] || 'bench');
}
/* render() runs on every SSE frame, so the title is written only when it
actually changed rather than dozens of times a second. */
let shownTitle = null;
function renderTitle() {
if (!S.state?.project) return;
const title = tabTitle(S.state.project, S.view, runningAgents().length);
if (title === shownTitle) return;
shownTitle = title;
document.title = title;
}
function render() {
if (!S.state) return;
forgetActsOfVanishedCards();
renderTitle();
renderChip();
renderPhases();
renderViews();
renderSync();
if (S.view === 'flight') renderFlight();
else if (S.view === 'focus') renderFocus();
else renderCards();
renderBar();
renderLog();
}
/* Two views draw cards, and they draw the same cards: the Board's columns
and a phase lane's. Anything that changes what a card looks like —
selecting one, closing the drawer — redraws whichever of them you are
on, rather than assuming the Board. */
function renderCards() {
if (S.view === 'phases') renderLanes(); else renderBoard();
}
/* ── board ────────────────────────────────────────────────────────────── */
/* A phase member is not drawn on the Board view. It keeps its stage, its
file, its agent and its actions — this view simply stops listing it,
because the phase card stands for it here. The Board goes back to
meaning one thing: the work you are personally holding, in five columns
that fit.
Membership is the only thing that hides a card, so *removing* membership
is the un-hiding — no sweep, no migration, no second rule. A phase card
archived, or a number dropped from its `## Cards` list, stops deriving
the membership at all (the server weaves it fresh on every read), and a
phase that has reached done/ is over: it holds nothing and hands its
cards straight back into the columns they are genuinely in.
A membership that did not resolve hides nothing either: an authoring
mistake must not make work vanish, so a card wearing `phase drift` stays
where it can be seen. */
function heldByPhase(task) {
if (!task.phase || (task.phaseDrift || []).length) return false;
const card = findTask(task.phase.file);
return !!card && card.stage !== 'done';
}
/* What a column draws, and what its header says about what it does not.
The count is the number of cards you can see — true by construction
rather than true-with-a-footnote — and `+2 in phases` beside it is a
signpost to where the rest went, in the register the existing note uses.
It appears only where members are actually hidden, so a board with no
phases on it reads exactly as it did before they existed.
The agents note counts the agents on the cards this column draws;
counting every agent anywhere is the header's live chip's job, and that
chip still sees an agent working on a hidden member. */
function columnCards(stage) {
return stage.tasks.filter(t => !heldByPhase(t));
}
function columnNote(stage, shown) {
const agentsHere = shown.filter(t => agentOnTask(t.file)).length;
const hidden = stage.tasks.length - shown.length;
const notes = [];
if (agentsHere) notes.push(`${agentsHere} agent${agentsHere > 1 ? 's' : ''}`);
else if (STAGE_NOTE[stage.slug]) notes.push(STAGE_NOTE[stage.slug]);
if (hidden) notes.push(`+${hidden} in phases`);
return notes.join(' · ');
}
function renderBoard() {
const board = $('#board');
// where you were looking, taken before the wipe throws the nodes away:
// the columns scroll down, #board itself scrolls sideways
markScroll('v:board', board);
board.querySelectorAll('.drop[data-stage]').forEach(d =>
markScroll('v:col:' + d.dataset.stage, d));
board.innerHTML = '';
const drops = [];
for (const stage of S.state.board.stages) {
const col = document.createElement('section');
col.className = 'kcol';
const shown = columnCards(stage);
col.innerHTML =
`<h2><span class="tickmark" style="background:${STAGE_TINT[stage.slug]}"></span>` +
`<span>${stage.label}</span><span class="count">${shown.length}</span>` +
`<span class="note">${esc(columnNote(stage, shown))}</span></h2>`;
const drop = document.createElement('div');
drop.className = 'drop';
drop.dataset.stage = stage.slug;
// a column emptied by a phase says so rather than congratulating you
if (!shown.length) drop.innerHTML = '<div class="empty">' +
(stage.tasks.length ? 'Everything here is in a phase.' : 'Nothing here. Good.') +
'</div>';
for (const task of shown) drop.appendChild(cardFor(task));
drop.addEventListener('dragover', (e) => { e.preventDefault(); drop.classList.add('over'); });
drop.addEventListener('dragleave', () => drop.classList.remove('over'));
drop.addEventListener('drop', (e) => {
e.preventDefault();
drop.classList.remove('over');
const { file, from } = JSON.parse(e.dataTransfer.getData('application/json'));
if (from !== stage.slug) move(file, from, stage.slug);
});
col.appendChild(drop);
board.appendChild(col);
drops.push([stage.slug, drop]);
}
// the new nodes are in place: put the page back where it was, same frame
restoreScroll('v:board', board);
for (const [slug, drop] of drops) restoreScroll('v:col:' + slug, drop);
if (S.selected) {
S.selected = allTasks().find(t => t.file === S.selected.file) || null;
}
renderDrawer();
}
/* A phase is named by its card, so the chip says what the card says —
minus the number it usually opens with (the chip's own tooltip carries
that), and clipped to a chip's width. The whole name is one hover away. */
function phaseLabel(phase) {
const name = phase.title ? phase.title.replace(/^\s*\d+\s*[—–-]\s*/, '') : phase.file;
return name.length > 22 ? name.slice(0, 21) + '…' : name;
}
/* The phase card is now the only thing standing for its members on this
view, so it carries the summary it owes them: how far the run has got.
Progress is what has landed on the phase branch, which only a run knows
— before there is one the card says how much it holds, which is the
honest reading of a phase nobody has started. */
function phaseSummary(task) {
const snap = (S.state.phases || {})[task.file];
if (snap) {
const at = phaseProgress(snap);
return `${at.done} of ${at.total} merged`;
}
const total = (task.members || []).length;
return `${total} card${total === 1 ? '' : 's'}`;
}
/* …and what it is doing right now: the member in flight while a run is on,
the halt while one is held. Null when the phase has no story yet — a
phase waiting in to-do/ is just a card. */
function phaseFlight(task) {
const snap = (S.state.phases || {})[task.file];
if (!snap) return null;
if (snap.halted) {
return { bad: true, line: 'halted' + (snap.haltedAt ? ` at #${snap.haltedAt}` : '') +
' — ' + (snap.haltedWhy || snap.halted) };
}
if (!snap.running) return null;
const at = phaseProgress(snap);
if (at.on) return { bad: false, line: `on #${at.on.number || at.on.file}${at.on.title}` };
if ((snap.waitingOn || []).length) {
return { bad: false, line: `waiting on #${snap.waitingOn[0]}` };
}
return { bad: false, line: 'running' };
}
/* Which phases this card could join, which is also whether the action is
there at all: the phase cards waiting in to-do/, and none whatsoever for
a card already in a phase, a phase card itself (they do not nest), or a
card with no number for a list to name it by. A phase in in-progress/ is
running — its members are being worked in the order the list had when it
started — so it is not on offer. The board offers what it can do. */
function joinablePhases(task) {
if (task.phase || task.isPhase || !task.number) return [];
if (!['backlog', 'to-do'].includes(task.stage)) return [];
const stage = (S.state?.board.stages || []).find(s => s.slug === 'to-do');
return stage ? stage.tasks.filter(t => t.isPhase) : [];
}
/* Whether this card may be archived — the server's own ARCHIVE_FROM, sent
with the state. The rule (backlog, to-do and done; never a working
stage) is written down once, on the side that enforces it, so the chip
and the drag-to-the-bar gesture can never offer different cards. */
function canArchive(task) {
return (S.state.archiveFrom || []).includes(task.stage);
}
function cardFor(task) {
const el = document.createElement('article');
const agent = agentOnTask(task.file);
// a running phase has an agent alive on a card this view no longer draws,
// so the phase card wears that state on its members' behalf
const flight = task.isPhase ? phaseFlight(task) : null;
const working = !!(agent && agent.mode !== 'review') || !!(flight && !flight.bad);
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' : '')
+ (flight && flight.bad ? ' halted' : '')
+ (completing ? ' completing' : '')
+ (task.stage === 'done' ? ' done-dim' : '');
el.draggable = !completing;
let tint = working ? 'var(--accent)' : STAGE_TINT[task.stage];
let pill = agent && agent.mode === 'review'
? { text: 'reviewing', tint: 'var(--accent)', bg: mix('var(--accent)', 16) }
: pillFor(task.stage, working);
if (!agent && verdict === 'green') {
pill = { text: 'approved', tint: 'var(--calm)', bg: mix('var(--calm)', 18) };
tint = 'var(--calm)';
} else if (!agent && verdict === 'red') {
pill = { text: 'changes asked', tint: 'var(--alarm)', bg: mix('var(--alarm)', 16) };
tint = 'var(--alarm)';
}
if (failure) {
// the newest thing that happened here, and the only actionable one:
// it outranks a PR verdict from before the run died
pill = { text: 'run failed', tint: 'var(--alarm)', bg: mix('var(--alarm)', 16),
title: failure.excerpt };
tint = 'var(--alarm)';
}
if (flight && flight.bad) {
// a halt is held, not scrolled past — and on this view the phase card
// is where a person meets it, the member that stopped being hidden
pill = { text: 'halted', tint: 'var(--alarm)', bg: mix('var(--alarm)', 16),
title: flight.line };
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 || 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>` : '',
// a phase's list that does not resolve is an authoring mistake, and it
// is flagged in the same breath as status drift rather than swallowed
(task.phaseDrift || []).length
? `<span class="pill drift" title="${esc(task.phaseDrift.join(' · '))}">phase drift</span>` : '',
`<span class="pill status${completing ? ' breathing' : ''}" style="background:${pill.bg};color:${pill.tint}"` +
`${pill.title ? ` title="${esc(pill.title)}"` : ''}>${pill.text}</span>`,
];
// two actions per state, max — whatever you'd actually do without opening the card
const actions = [];
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 (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) });
} else if (task.stage === 'review' && task.pr) {
const prState2 = (S.state.prs || {})[task.file];
const reviewIn = !!task.prVerdict ||
(prState2 && (prState2.verdict !== 'pending' ||
(prState2.copilot && prState2.copilot !== 'asked')));
const reviewPR = { glyph: '◔', label: 'review PR', confirm: 'review it?', busy: 'reviewing…',
title: 'A read-only agent reviews the PR and posts its verdict to GitHub',
run: () => fireAgent(task, '/api/agent/review-pr') };
if (reviewIn) {
actions.push({ glyph: '↻', label: 'act on PR', confirm: 'act on it?', busy: 'acting…',
title: 'An agent addresses the review feedback in the worktree, commits and pushes',
run: () => fireAgent(task, '/api/agent/act-pr') }, reviewPR);
} else {
actions.push(reviewPR, { glyph: '⚑', label: 'copilot', confirm: 'ask copilot?', busy: 'asking…',
title: 'Request a GitHub Copilot review on the PR',
run: () => askCopilot(task) });
}
} else {
// someone else's card is never started by accident: the action says
// whose it is, and firing it is the deliberate takeover
const held = task.assignee && S.state.me && task.assignee !== S.state.me
? task.assignee : null;
if (task.stage === 'in-progress' && task.isPhase) {
// the phase's own launch, in the slot ▸ start work has on every other
// card: moving it here was the commitment, this is the second half
const ph = (S.state.phases || {})[task.file];
const hold = { glyph: '‖', label: 'hold', confirm: 'hold it?', busy: 'holding…',
title: 'Stop the phase — the phase branch, every card already merged '
+ 'into it and every worktree stay exactly as they are',
run: () => holdPhase(task) };
const start = held
? { glyph: '▸', label: 'take over', confirm: `take from ${held}?`,
busy: 'starting…',
title: `${held} holds this phase — running it takes the card over and `
+ 'this board becomes the one that advances it',
run: () => runPhase(task, { takeover: true }) }
: { glyph: '▸', label: 'run phase', confirm: 'run it?', busy: 'starting…',
title: ph && ph.stopped
? 'Run it again — the phase carries on from where it was held'
: 'A branch of its own, each card in the list run on it in turn, and '
+ 'one PR into main at the end',
run: () => runPhase(task) };
// A halt holds until the phase is run again or held. Holding is here,
// because a person who does not want to carry on needs a way to say
// so that is not walking the card backwards — but running it again is
// not: clearing a halt should mean having read what caused it, and
// the reason is in the lane, under the halt. So this points there.
const room = { glyph: '⟶', label: 'phases', busy: 'opening…',
title: 'Open the Phases view — this phase\'s lane, the halt that stopped it '
+ 'and ▸ run again beneath it',
run: () => { setView('phases'); return true; } };
if (ph && ph.running) actions.push(hold);
else if (ph && ph.halted) actions.push(room, hold);
else actions.push(start);
} else if (task.stage === 'in-progress') {
actions.push(held
? { glyph: '▸', label: 'take over', confirm: `take from ${held}?`, busy: 'starting…',
title: `${held} holds this card — starting work takes it over and reassigns it to you`,
run: () => fireAgent(task, '/api/agent/start', { takeover: true }) }
: { glyph: '▸', label: 'start work', confirm: 'start it?', busy: 'starting…',
title: 'A worktree, a branch, and a headless Claude on this task',
run: () => fireAgent(task, '/api/agent/start') });
} else if (task.stage === 'review') {
actions.push({ glyph: '↩', label: 'back', busy: 'moving…', title: 'Send it back for more work',
run: () => move(task.file, 'review', 'in-progress') });
} else if (task.stage === 'done') {
actions.push({ glyph: '↺', label: 'reopen', busy: 'reopening…', title: 'Put it back in the queue',
run: () => move(task.file, 'done', 'to-do') });
} else if (joinablePhases(task).length) {
// the small path, on the two stages that have room for it: the card
// you decide belongs in a phase after all. It writes one line into
// the phase card and moves nothing, so it neither costs tokens nor
// stops work — the sheet's named choice is the confirmation.
actions.push({ glyph: '⟶', label: 'phase', busy: 'choosing…',
title: "Add this card to the end of a phase's list — the phases waiting in to-do/",
run: () => { phaseSheet(task); return true; } });
}
// work in review with no PR: no board opens one behind your back, so
// the card offers it instead of the relevance check
if (task.stage === 'review' && !task.pr
&& (S.state.branches || []).includes(task.file.replace(/\.md$/, ''))) {
actions.push({ glyph: '↑', label: 'open PR', confirm: 'open it?', busy: 'opening…',
title: 'Push the branch and open its PR — the board does this when a card '
+ 'enters review, and this is how you ask for it afterwards',
run: () => openPR(task) });
} else if (actions.length < 2) {
// two per state is the whole budget, and a halted phase has already
// spent it on running it again and holding it
actions.push(stillTrue);
}
}
if (actions.length) el.classList.add('has-acts');
// the who row: who has it, plus one mono fact
let who, initial, meta;
if (agent) {
who = agent.name || (agent.mode === 'review' ? 'review agent' : 'agent');
initial = (agent.name || (agent.mode === 'review' ? 'R' : 'A')).slice(0, 1);
meta = elapsed(agent.started) + (agent.branch ? ' · ' + agent.branch : '');
} else {
initial = '·';
// a claimed card names its owner in every stage — in done/ the line is
// history: who did this. Unclaimed cards keep the old stage vocabulary.
if (task.assignee) { who = task.assignee; initial = task.assignee.slice(0, 1); }
else if (task.stage === 'backlog' || task.stage === 'to-do') who = 'nobody yet';
else if (task.stage === 'in-progress') who = 'unattended';
else if (task.stage === 'review') who = 'needs your eyes';
else who = 'merged';
meta = 'edited ' + ago(task.mtime) + ' ago';
if (task.stage === 'in-progress' && (Date.now() / 1000 - task.mtime) / 86400 >= 1) meta += ' · idle';
}
const extras = [];
if (!high && task.priority) extras.push(esc(task.priority.toLowerCase()));
if (task.type) extras.push(esc(task.type.toLowerCase()));
let liveLine = '';
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);
if (last && last.summary) {
const active = run || (smeta && smeta.status === 'active');
liveLine = `<div class="well"><span class="lead">·</span>` +
`<span class="wbody">${esc(last.summary)}${active ? '<span class="caret">▌</span>' : ''}</span></div>`;
}
} else if (agent) {
liveLine = `<div class="well"><span class="lead">·</span><span class="wbody">warming up<span class="caret">▌</span></span></div>`;
} else if (failure) {
// "API Error: 500" one hover away instead of buried in a log file: the
// line the run died on here, the whole excerpt on hover and in the sheet
liveLine = `<div class="well bad" title="${esc(failure.excerpt)}">` +
`<span class="lead">·</span><span class="wbody">rc=${esc(failure.rc)} · ` +
`${esc(whyFailed(failure))}</span></div>`;
} else if (flight) {
// the member in flight, on the card that stands for it — the same line
// the header chip carries, at the altitude a person is already reading
liveLine = `<div class="well${flight.bad ? ' bad' : ''}"><span class="lead">·</span>` +
`<span class="wbody">${esc(flight.line)}` +
`${flight.bad ? '' : '<span class="caret">▌</span>'}</span></div>`;
}
// tool chips: destinations, not statuses — they live in the card's footer
const prState = (S.state.prs || {})[task.file];
const detail = prState && prState.detail ? prState.detail : 'open the PR';
const hasBranch = (S.state.branches || []).includes(task.file.replace(/\.md$/, ''));
const chips = [];
// what the phase card now stands for: its progress, and the way through
// to the cards themselves — its own sheet lists them in run order with
// the stage each is in, which is where they are read until the Phases
// view draws them
if (task.isPhase) {
chips.push({ label: phaseSummary(task), pre: '⟶', cls: '', phase: task.file,
title: 'The cards in this phase, in run order — they are not drawn on '
+ 'the board while it holds them' });
}
// where this card sits in its phase — derived from the phase card's own
// list, and a destination like the rest of the row: it opens that card
if (task.phase) {
chips.push({ label: `${phaseLabel(task.phase)} ${task.phase.index}/${task.phase.total}`,
pre: '⟶', cls: '', phase: task.phase.file,
title: `Card ${task.phase.index} of ${task.phase.total} in phase `
+ `${task.phase.number ? '#' + task.phase.number + ' — ' : ''}${task.phase.title}` });
}
if (prState && prState.ci) {
chips.push({ label: 'CI', glyph: { pass: '✓', fail: '✕', running: '◌' }[prState.ci],
cls: { pass: 'ok', fail: 'bad', running: 'accent' }[prState.ci], title: detail });
}
if (prState && prState.conflicts) {
chips.push({ label: 'conflicts', glyph: '✕', cls: 'bad',
title: 'GitHub cannot merge this into main — ↻ act on PR can attempt a resolution merge' });
}
if (prState && prState.copilot) {
chips.push({ label: 'copilot',
glyph: { asked: '◌', approved: '✓', changes: '✕', commented: '·' }[prState.copilot],
cls: { asked: 'accent', approved: 'ok', changes: 'bad', commented: '' }[prState.copilot],
title: 'Copilot: ' + ({ asked: 'review requested', approved: 'approved',
changes: 'changes requested', commented: 'commented' }[prState.copilot]) });
}
if (task.pr) chips.push({ label: 'PR', glyph: '↗', cls: '', href: task.pr, title: detail });
let driveWell = '';
// 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',
href: d.url || '#', title: "the app, running this task's code" });
else {
chips.push({ label: 'starting', glyph: '◌', cls: 'accent',
title: 'the driver is bringing the app up — progress below' });
driveWell = `<div class="well"><span class="lead">✳</span>` +
`<span class="wbody">${esc(d.line || 'driver starting…')}<span class="caret">▌</span></span></div>`;
}
chips.push({ label: 'park', glyph: '‖', cls: '', act: 'park', title: 'Stop the drive' });
} else if (d && d.task === task.file && d.status === 'refused') {
chips.push({ label: 'drive', glyph: '✕', cls: 'bad', act: 'go',
title: 'The last drive did not come up — click to try again' });
driveWell = `<div class="well bad"><span class="lead">✳</span>` +
`<span class="wbody">${esc(d.reason || 'the driver gave up — see the drive log')}</span></div>`;
} else if (!hasBranch && !task.pr) {
chips.push({ label: 'no branch', glyph: '', cls: 'dim',
title: 'No work attached: no agent ran on this task, so there is no branch to PR, review or drive. ▸ start work runs from in-progress.' });
} else if (S.state.hasDriver && hasBranch) {
chips.push({ label: 'drive', glyph: '✳', cls: 'accent', act: 'go',
title: "Launch the app locally from this task's worktree" });
} else if (!S.state.hasDriver) {
chips.push({ label: 'no driver', glyph: '', cls: 'dim',
title: 'No driver to launch the local app — create manager/local/driver/start (an executable; see manager/core/driver.example/).' });
}
}
// project commands run against this task's worktree
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) {
chips.push({ label: cmd.name, glyph: '◌', cls: 'accent',
title: `${cmd.name} is running — the ticker narrates the ending` });
} else {
chips.push({ label: cmd.name, glyph: '$', cls: '', cmd: cmd.name,
title: cmd.help || `run local/commands/${cmd.name} against this task's worktree` });
}
}
}
// archiving, on the card instead of the length of the board away: the
// tray's own glyph, and only on the stages the tray itself accepts —
// a working card is finished or walked back, never tidied away
if (canArchive(task)) {
chips.push({ label: 'archive', pre: '⌸', cls: 'arch', arch: true,
title: 'Archive this card — out of every column, never deleted. ⌘Z brings it back.' });
}
const chipRow = chips.length
? '<div class="chiprow">' + chips.map(c => {
const g = c.glyph ? `<span class="g2">${c.glyph}</span>` : '';
const p = c.pre ? `<span class="g2">${c.pre}</span>` : '';
if (c.arch) return `<button class="chip2 ${c.cls}" data-arch="1" title="${esc(c.title)}">${p}${actLabel(c.label, 'archive it?', 'archiving…')}</button>`;
if (c.href) return `<a class="chip2 ${c.cls}" href="${esc(c.href)}" target="_blank" rel="noopener" title="${esc(c.title)}">${p}${esc(c.label)}${g}</a>`;
if (c.act) return `<button class="chip2 ${c.cls}" data-drive="${c.act}" title="${esc(c.title)}">${p}${esc(c.label)}${g}</button>`;
if (c.phase) return `<button class="chip2 ${c.cls}" data-phase="${esc(c.phase)}" title="${esc(c.title)}">${p}${esc(c.label)}${g}</button>`;
if (c.cmd) return `<button class="chip2 ${c.cls}" data-cmd="${esc(c.cmd)}" title="${esc(c.title)}">${actLabel(c.label, 'run it?', 'running…')}${g}</button>`;
return `<span class="chip2 ${c.cls}" title="${esc(c.title)}">${p}${esc(c.label)}${g}</span>`;
}).join('') + '</div>'
: '';
el.innerHTML =
`<div class="toprow">${top.join('')}</div>` +
`<div class="title">${esc(task.title)}</div>` +
`<div class="whorow"><span class="initial">${esc(initial)}</span><span class="who">${esc(who)}</span>` +
modelChip(agent) +
`<span class="meta">${esc(meta)}${extras.length ? ' · ' + extras.join(' · ') : ''}</span></div>` +
chipRow + driveWell + liveLine;
el.querySelectorAll('a.chip2').forEach(a =>
a.addEventListener('click', (e) => e.stopPropagation()));
el.querySelectorAll('[data-phase]').forEach(btn =>
btn.addEventListener('click', (e) => {
e.stopPropagation();
const phase = findTask(btn.dataset.phase);
if (phase) showDetail(phase);
}));
el.querySelectorAll('[data-drive]').forEach(btn =>
btn.addEventListener('click', (e) => {
e.stopPropagation();
if (btn.dataset.drive === 'go') startDrive(task); else parkDrive();
}));
el.querySelectorAll('[data-arch]').forEach(btn => {
btn.addEventListener('click', (e) => e.stopPropagation());
wireAction(btn, `${task.file}::archive`, {
label: 'archive', confirm: 'archive it?', busy: 'archiving…',
run: () => archiveCard(task.file, task.stage),
});
});
el.querySelectorAll('[data-cmd]').forEach(btn => {
btn.addEventListener('click', (e) => e.stopPropagation());
const name = btn.dataset.cmd;
wireAction(btn, `${task.file}::$${name}`, {
label: name, confirm: 'run it?', busy: 'running…',
run: () => runCommand(task, name),
});
});
if (actions.length) {
const slot = document.createElement('span');
slot.className = 'hoveracts';
// one guard for the whole slot: clicks in its padding or in the gap
// between buttons die here instead of opening the card's detail
slot.addEventListener('click', (e) => e.stopPropagation());
for (const act of actions) {
const btn = document.createElement('button');
btn.innerHTML = `<span class="g">${act.glyph}</span>${actLabel(act.label, act.confirm, act.busy)}`;
btn.title = act.title;
wireAction(btn, `${task.file}::${act.label}`, act);
slot.appendChild(btn);
}
el.querySelector('.toprow').appendChild(slot);
}
el.addEventListener('dragstart', (e) => {
el.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('application/json', JSON.stringify({ file: task.file, from: task.stage }));
if (canArchive(task)) {
S.dragging = { file: task.file, from: task.stage };
renderBar();
}
});
el.addEventListener('dragend', () => {
el.classList.remove('dragging');
S.dragging = null; S.dockHot = false; renderBar();
});
el.addEventListener('click', () => showDetail(task));
return el;
}
/* arm-then-fire is the contract for anything that costs tokens or stops
work. This one state machine walks every action button — hover actions
and $-command chips alike — through rest → armed → busy. The truth
lives in S.acts, keyed by task file + action, because cards are torn
down and rebuilt on every SSE render: a rebuild mid-window re-applies
the same picture (the drain bar resumes via a negative delay), and a
second click keeps its meaning across the redraw. */
const ARM_MS = 5000, FIRE_TIMEOUT_MS = 15000;
function actLabel(rest, confirm, busy) {
return `<span class="actlbl"><span class="l-rest">${esc(rest)}</span>` +
(confirm ? `<span class="l-arm">${esc(confirm)}</span>` : '') +
`<span class="l-busy">${esc(busy || rest)}</span></span>`;
}
/* An armed window belongs to the card it was opened on. A card can leave
the board while one is open — archived from another board, or moved by
hand on disk — and the window must go with it, so a card that comes
straight back (⌘Z, or a teammate's undo) is never found already armed
by a click nobody made against the card as it now stands. Keys are
`<file>::<action>`, and a task file never contains `::`. */
function forgetActsOfVanishedCards() {
const live = new Set(allTasks().map(t => t.file));
for (const key of Object.keys(S.acts)) {
if (!live.has(key.split('::')[0])) delete S.acts[key];
}
}
function wireAction(btn, key, act) {
const st = S.acts[key];
if (st && st.until > Date.now()) {
if (st.phase === 'busy') lockAction(btn);
else armAction(btn, key, st.until - Date.now());
} else if (st) delete S.acts[key];
btn.addEventListener('click', () => {
if (btn.disabled) return;
if (act.confirm && !btn.classList.contains('armed')) armAction(btn, key, ARM_MS);
else fireAction(btn, key, act);
});
}
function armAction(btn, key, remaining) {
S.acts[key] = { phase: 'armed', until: Date.now() + remaining };
// the CSS drain animation is ARM_MS long; a rebuilt button rejoins it
// partway through with a negative delay instead of starting over
btn.style.setProperty('--arm-delay', `${remaining - ARM_MS}ms`);
btn.classList.add('armed');
setTimeout(() => {
if ((S.acts[key] || {}).phase === 'armed') delete S.acts[key];
btn.classList.remove('armed');
}, remaining);
}
function lockAction(btn) {
btn.classList.remove('armed');
btn.classList.add('busy');
btn.disabled = true;
}
async function fireAction(btn, key, act) {
lockAction(btn);
S.acts[key] = { phase: 'busy', until: Date.now() + FIRE_TIMEOUT_MS };
// client-side optimism needs an honest exit: if neither the response
// nor an SSE redraw arrives, come back to rest loudly — never silently
const bail = setTimeout(() => {
if ((S.acts[key] || {}).phase !== 'busy') return;
delete S.acts[key];
toast(`${act.label} got no answer in ${FIRE_TIMEOUT_MS / 1000}s — not fired again; check the board`, true);
scheduleRender();
}, FIRE_TIMEOUT_MS);
let ok;
try { ok = await act.run() !== false; }
catch (e) { ok = false; toast(`${act.label} failed — ${e.message || 'no response'}`, true); }
clearTimeout(bail);
if ((S.acts[key] || {}).phase === 'busy') {
delete S.acts[key];
btn.disabled = false;
btn.classList.remove('busy');
// a successful run usually re-rendered the card already (loadState);
// this pass unlocks any survivor whose card did not change
scheduleRender();
}
return ok;
}
async function fireAgent(task, url, extra) {
toast(`starting on ${task.file}…`);
const res = await fetch(url, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file: task.file, stage: task.stage, ...(extra || {}) }),
});
const data = await res.json();
if (!res.ok) { toast(data.error || 'that did not start', true); return false; }
const who = data.agent.name || 'An agent';
toast(url.endsWith('act-pr')
? `${who} is acting on the review of ${task.file}'s PR`
: url.endsWith('review-pr')
? `${who} is reviewing ${task.file}'s PR`
: data.agent.mode === 'review'
? `${who} is checking ${task.file} is still true of the codebase`
: `${who} is on it — branch ${data.agent.branch}`);
await loadState();
return true;
}
async function runCommand(task, name) {
const res = await fetch('/api/command/run', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, file: task.file }),
});
const data = await res.json();
toast(res.ok ? `${name} running on ${task.file} — the ticker narrates the ending`
: (data.error || `${name} did not start`), !res.ok);
await loadState();
return res.ok;
}
async function startDrive(task) {
const res = await fetch('/api/drive/start', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file: task.file }),
});
const data = await res.json();
toast(res.ok ? 'Driver starting — progress shows on the card'
: (data.error || 'the driver did not start'), !res.ok);
loadState();
}
async function parkDrive() {
const res = await fetch('/api/drive/stop', { method: 'POST' });
const data = await res.json();
toast(res.ok ? 'Parked' : (data.error || 'could not park'), !res.ok);
loadState();
}
async function openPR(task) {
const res = await fetch('/api/pr/open', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file: task.file }),
});
const data = await res.json();
toast(res.ok ? `PR opened for ${task.file}` : (data.error || 'the PR did not open'), !res.ok);
await loadState();
return res.ok;
}
async function askCopilot(task) {
const res = await fetch('/api/pr/copilot', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file: task.file }),
});
const data = await res.json();
toast(res.ok ? `Copilot asked to review ${task.file}'s PR`
: (data.error || 'Copilot request failed'), !res.ok);
return res.ok;
}
/* Running a phase, and holding it. Both are ordinary POSTs on the one
action machine — armed, fired, and honest about failing — because a
phase is a card and its actions are card actions. What follows either
one is watched in the header chip and narrated in the ticker, so
neither says more here than what it just asked for. */
async function runPhase(task, extra) {
const res = await fetch('/api/phase/run', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file: task.file, stage: task.stage, ...(extra || {}) }),
});
const data = await res.json();
toast(res.ok ? `Phase ${task.file} running — the header chip follows it`
: (data.error || 'the phase did not start'), !res.ok);
await loadState();
return res.ok;
}
async function holdPhase(task) {
const res = await fetch('/api/phase/stop', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file: task.file, stage: task.stage }),
});
const data = await res.json();
toast(res.ok ? 'Held — the phase branch and everything merged into it stay as they are'
: (data.error || 'could not hold it'), !res.ok);
await loadState();
return res.ok;
}
async function move(file, from, to) {
// finishing a task with work attached is a decision, not just a drag
if (to === 'done') {
const task = findTask(file);
const stem = file.replace(/\.md$/, '');
if (task && (task.pr || (S.state.branches || []).includes(stem))) {
completeSheet(task, from);
return true;
}
}
return rawMove(file, from, to);
}
async function rawMove(file, from, to) {
const res = await fetch('/api/move', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file, from, to }),
});
const data = await res.json();
if (!res.ok) { toast(data.error || 'move failed', true); await loadState(); return false; }
toast(`${file}${to}/`);
if (S.selected && S.selected.file === file) S.selected = data.task;
await loadState();
return true;
}
function closeSheet() { $('#sheetwrap').classList.remove('open'); $('#sheetwrap').innerHTML = ''; }
function completeSheet(task, from) {
const d = S.state.drive;
const driving = d && d.task === task.file && ['starting', 'up'].includes(d.status);
// A phase card stands for cards the Board does not draw, so the sheet
// has to say what merging it does to them: the ones it merged go to
// done/ with it and their workspaces go too. Counted the way the lane
// counts them, from the runner's reading and the phase's own log.
const swept = task.isPhase ? mergedIn(task).size : 0;
const wrap = $('#sheetwrap');
wrap.innerHTML =
`<div class="sheet">` +
`<div class="stitle"><span>${esc(task.title)}</span>` +
`<span class="mono">${task.number ? '#' + esc(task.number) + ' · ' : ''}→ done</span></div>` +
`<p>This task has ${task.pr ? 'a PR and ' : ''}a branch with work on it. What should happen?</p>` +
`<div class="sbtns">` +
`<button id="sh-keep">Keep it where it is<small>Nothing moves, nothing changes.</small></button>` +
`<button id="sh-move">Just move the card<small>The branch${task.pr ? ', PR' : ''} and worktree stay as they are` +
`${task.isPhase ? ", and so does every card in the phase" : ''}.</small></button>` +
// team mode merges on origin: the board never makes a merge commit of
// its own, so every replica's main keeps fast-forwarding
`<button id="sh-ship" class="shipit">Merge &amp; clean up<small>${driving ? 'Park the drive, then m' : 'M'}erge ` +
((S.state.sync || {}).enabled
? `the PR on GitHub, remove the worktree and branches, move the card — local main fast-forwards on the next sync beat.`
: `the branch into main, push${task.pr ? ' (marks the PR merged)' : ''}, remove the worktree and branches, move the card.`) +
(swept ? ` The ${swept} card${swept === 1 ? '' : 's'} this phase merged go to done/ with it, and their worktrees and branches go too.` : '') +
`</small></button>` +
`</div></div>`;
wrap.classList.add('open');
wrap.addEventListener('click', (e) => { if (e.target === wrap) closeSheet(); });
$('#sh-keep').addEventListener('click', closeSheet);
$('#sh-move').addEventListener('click', () => { closeSheet(); rawMove(task.file, from, 'done'); });
$('#sh-ship').addEventListener('click', async () => {
closeSheet();
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 }),
});
const data = await res.json();
if (!res.ok) { toast(data.error || 'completion failed — the card stays', true); }
else {
const went = (data.swept || []).length;
toast(data.merged ? `${task.file} merged and cleaned up` +
(went ? ` — ${went} card${went === 1 ? '' : 's'} went to done/ with it` : '')
: `${task.file} moved to done`);
}
await loadState();
});
}
/* Joining a phase is a choice between a few named options, so it is put in
front of you the way finishing a card with work on it is — a short list
of phases, each saying what it already holds, rather than a guess. What
it writes is one line at the end of that phase's `## Cards`; the card
itself does not move, and says nothing about the phase it joined. */
function phaseSheet(task) {
const phases = joinablePhases(task);
if (!phases.length) { toast('no phase is waiting in to-do/', true); return; }
const wrap = $('#sheetwrap');
wrap.innerHTML =
`<div class="sheet">` +
`<div class="stitle"><span>${esc(task.title)}</span>` +
`<span class="mono">${task.number ? '#' + esc(task.number) + ' · ' : ''}⟶ phase</span></div>` +
`<p>Which phase runs this card? It goes at the end of that phase's list. ` +
`The card stays in ${esc(task.stage)}/ — joining a phase is not a commitment to start it.</p>` +
`<div class="sbtns">` +
phases.map((p, index) => {
const held = (p.cards || []).length;
return `<button data-pick="${index}">${esc(p.title)}` +
`<small>${held ? `holds ${held} card${held === 1 ? '' : 's'} — this one runs after them`
: 'empty so far — this one would be its first card'}</small></button>`;
}).join('') +
`<button id="sh-nophase">Not now<small>Nothing is written.</small></button>` +
`</div></div>`;
wrap.classList.add('open');
wrap.addEventListener('click', (e) => { if (e.target === wrap) closeSheet(); });
$('#sh-nophase').addEventListener('click', closeSheet);
wrap.querySelectorAll('[data-pick]').forEach(btn =>
btn.addEventListener('click', () => {
closeSheet();
addToPhase(task, phases[+btn.dataset.pick]);
}));
}
async function addToPhase(task, phase) {
const res = await fetch('/api/phase/add', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file: task.file, stage: task.stage, phase: phase.file }),
});
const data = await res.json();
toast(res.ok ? `${task.file} added to ${data.phaseName}`
: (data.error || 'that card did not join'), !res.ok);
await loadState();
return res.ok;
}
function showDetail(task) {
const changed = !S.selected || S.selected.file !== task.file;
S.selected = task;
renderCards();
if (changed) $('#drawerbody').scrollTop = 0;
}
/* The runner's word for a member, in the vocabulary the rest of the board
uses. `pending` is the phase simply not having reached it, which the
stage beside it already says — so it says nothing. */
const MEMBER_STATE = { merged: 'merged in', running: 'working',
waiting: 'checking', ready: 'ready to merge', halt: 'stopped here' };
/* A phase card answers "where is this up to" on its own face: its members
in run order, each with the stage it is in, so the question costs no
hunt across five columns. The runner's reading (merged in, working,
stopped here) is added only while a phase is in flight, because only
then is there one — and only it can tell a card that has run from one
the phase has not reached. Each row opens that card. */
function phaseMembers(t) {
if (!t.isPhase) return '';
if (!(t.members || []).length) {
return `<div class="pmembers"><div class="phead">Cards</div>` +
`<div class="pempty">This phase lists no cards yet — a <code>## Cards</code> ` +
`section, one task number per line, in the order they run.</div></div>`;
}
const snap = (S.state.phases || {})[t.file];
const read = {};
for (const m of ((snap && snap.members) || [])) read[m.file] = m;
const rows = t.members.map((m, i) => {
const seen = read[m.file];
const note = seen ? MEMBER_STATE[seen.state] || '' : '';
const tint = seen && seen.state === 'halt' ? 'var(--alarm)'
: seen && ['running', 'waiting'].includes(seen.state) ? 'var(--accent)'
: seen && seen.state === 'merged' ? 'var(--calm)' : '';
return `<button class="prow" data-member="${esc(m.file)}">` +
`<span class="pn mono">${i + 1}</span>` +
`<span class="pref mono">#${esc(m.number || '')}</span>` +
`<span class="ptitle">${esc(m.title)}</span>` +
`<span class="pstage mono">${esc(m.stage)}</span>` +
(note ? `<span class="pstate mono"${tint ? ` style="color:${tint}"` : ''}` +
`${seen.why ? ` title="${esc(seen.why)}"` : ''}>${esc(note)}</span>` : '') +
`</button>`;
}).join('');
const halt = snap && snap.halted
? `<div class="pempty" style="color:var(--alarm)">halted` +
`${snap.haltedAt ? ` at #${esc(snap.haltedAt)}` : ''} — ` +
`${esc(snap.haltedWhy || snap.halted)}</div>` : '';
return `<div class="pmembers"><div class="phead">Cards` +
`<span class="mono">${t.members.length} in run order</span></div>` +
rows + halt + `</div>`;
}
/* What the drawer is showing, as a key its scroll position can be filed
under. Per document, deliberately: a different card is different
content, so it has no mark of its own and opens at the top — which is
the behaviour showDetail() asks for outright and this must not fight. */
function drawerKey() {
if (S.fileView) return 'v:drawer:file:' + S.fileView.dir + '/' + S.fileView.name;
return S.selected ? 'v:drawer:task:' + S.selected.file : null;
}
function renderDrawer() {
const panel = $('#drawer');
const body = $('#drawerbody');
if (S.drawerKey) markScroll(S.drawerKey, body); // the document leaving
const key = S.drawerKey = drawerKey();
if (S.fileView) {
const f = S.fileView;
body.innerHTML =
`<div class="dhead"><span class="mono" style="font-size:12px;color:var(--dim)">${esc(f.dir)}/${esc(f.name)}</span>` +
`<span class="spacer"></span><button id="closeDrawer">Close</button></div>` +
`<div class="dbody">${md(f.content)}</div>`;
panel.classList.add('open');
restoreScroll(key, body);
$('#closeDrawer').addEventListener('click', () => { S.fileView = null; renderDrawer(); });
return;
}
if (!S.selected) { panel.classList.remove('open'); body.innerHTML = ''; return; }
const t = S.selected;
const agent = agentOnTask(t.file);
const failure = failedRun(t);
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) }
: pillFor(t.stage, agent && agent.mode !== 'review');
const when = new Date(t.mtime * 1000).toLocaleString();
// the whole excerpt, not just the line the card shows — the sheet is
// where you read what killed the run without opening files on disk
const failBlock = failure
? `<div class="well bad"><span class="lead">·</span><div class="wbody">` +
`<b>run failed</b> · rc=${esc(failure.rc)} · ${esc(ago(failure.ended))} ago` +
`<pre>${esc(failure.excerpt)}</pre>` +
`<span style="color:var(--dim)">${esc(failure.log || '')}</span></div></div>`
: '';
body.innerHTML =
`<div class="dhead">` +
`<span class="mono" style="font-size:12px;color:var(--dim)">${t.number ? '#' + esc(t.number) : ''}</span>` +
`<span class="pill" style="background:${pill.bg};color:${pill.tint}">${pill.text}</span>` +
`<span class="spacer"></span>` +
`<button id="closeDrawer">Close</button></div>` +
failBlock +
phaseMembers(t) +
`<div class="dbody">${md(t.body)}</div>` +
`<div class="dmeta">${esc(t.stage)}/${esc(t.file)} · ${t.words} words · edited ${esc(when)}</div>`;
panel.classList.add('open');
restoreScroll(key, body);
body.querySelectorAll('[data-member]').forEach(row =>
row.addEventListener('click', () => {
const member = findTask(row.dataset.member);
if (member) showDetail(member);
}));
$('#closeDrawer').addEventListener('click', () => { S.selected = null; renderCards(); });
}
function allRecentEvents() {
const merged = [];
for (const evs of Object.values(S.events)) merged.push(...evs.slice(-40));
merged.push(...(S.state.boardEvents || []).slice(-60));
for (const run of Object.values(S.running)) merged.push(run);
merged.sort((a, b) => (a.ts || 0) - (b.ts || 0));
return merged;
}
function evRow(ev) {
const cls = ev.running ? 'run' : ev.ok === false ? 'bad' : ev.ok === true ? 'ok' : '';
const glyph = GLYPHS[ev.kind] || '·';
const meta = ev.session ? sessionMeta(ev.session) : null;
const who = meta ? `<span class="who-b">${esc((meta.label || '').split(' · ')[0])}</span> ` : '';
return `<div class="ev ${cls}"><time>${fmtClock(ev.ts)}</time>` +
`<span class="glyph">${glyph}</span><span class="what">${who}${esc(ev.summary)}</span></div>`;
}
function renderBar() {
const bar = $('#statusbar');
const drag = !!S.dragging;
const hot = drag && S.dockHot;
bar.classList.toggle('drag', drag);
bar.classList.toggle('hot', hot);
const merged = allRecentEvents();
$('#actbtn').innerHTML =
`<span class="chev">${S.logOpen ? '▾' : '▴'}</span>` +
`<span class="dot live"></span><span>Activity</span>` +
`<span class="mono">${merged.length}</span>`;
const tail = merged[merged.length - 1];
$('#bartail').innerHTML = tail
? `<span>${fmtClock(tail.ts)}</span>` +
`<span style="color:${tail.ok === false ? 'var(--alarm)' : tail.ok === true ? 'var(--calm)' : 'var(--muted)'}">${GLYPHS[tail.kind] || '·'}</span>` +
`<span class="t-what">${tail.session && sessionMeta(tail.session) ? `<span class="t-who">${esc((sessionMeta(tail.session).label || '').split(' · ')[0])}</span> ` : ''}${esc(tail.summary)}</span>`
: `<span style="color:var(--dim)">quiet — events tick along here as they happen</span>`;
$('#barhinttext').textContent = hot
? 'Release to archive — ⌘Z brings it back'
: 'Drop anywhere along this bar to archive';
const count = S.state.archivedCount || 0;
$('#tray').innerHTML =
`<span class="glyph2">${hot ? '↓' : '⌸'}</span>` +
`<b>${hot ? 'Let go' : 'Archive'}</b>` +
(count > 0 && !hot ? `<span class="count">${count}</span>` : '');
}
function renderLog() {
$('#logpanel').classList.toggle('open', S.logOpen);
if (!S.logOpen) return;
const active = FILTERS.find(([key]) => key === S.logFilter);
const kinds = active ? active[2] : null;
const merged = allRecentEvents();
const events = merged.filter(ev => !kinds || kinds.has(ev.kind)).slice(-150);
const extras = Object.entries(S.state.board.extras).map(([name, files]) =>
`<details><summary>${name}/ ${files.length}</summary><ul>` +
files.map(f => `<li class="xfile" data-dir="${esc(name)}" data-name="${esc(f)}">${esc(f)}</li>`).join('') +
`</ul></details>`).join('');
$('#loghead').innerHTML =
`<span class="label">Everything that happened</span>` +
`<span class="mono">${merged.length} events</span>` +
`<span class="spacer"></span>` +
FILTERS.map(([key, label]) =>
`<button class="${S.logFilter === key ? 'on' : ''}" data-lf="${key}">${label}</button>`).join('') +
extras;
document.querySelectorAll('#loghead [data-lf]').forEach(b =>
b.addEventListener('click', () => { S.logFilter = b.dataset.lf; renderLog(); }));
document.querySelectorAll('#loghead .xfile').forEach(li =>
li.addEventListener('click', () => openExtra(li.dataset.dir, li.dataset.name)));
const body = $('#logbody');
markScroll('log', body);
body.innerHTML = events.length ? events.map(evRow).join('')
: '<div class="ev quiet"><time></time><span class="glyph">·</span><span class="what">Nothing here. Good.</span></div>';
restoreScroll('log', body);
// and stuck to the bottom stays stuck to the bottom: that reading was
// already right, and it outranks the mark
if (S.logStick) body.scrollTop = body.scrollHeight;
}
async function archiveCard(file, from) {
const res = await fetch('/api/archive', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file, from }),
});
const data = await res.json();
toast(res.ok ? `Archived ${file} — ⌘Z brings it back` : (data.error || 'archive failed'), !res.ok);
await loadState();
return res.ok;
}
async function unarchiveLast() {
const res = await fetch('/api/unarchive', { method: 'POST' });
const data = await res.json();
toast(res.ok ? `${data.file} brought back to ${data.to}/` : (data.error || 'nothing to bring back'), !res.ok);
await loadState();
}
/* ── phases: a swimlane each ──────────────────────────────────────────────
The Board draws the work you are personally holding; this draws what the
phases are holding. One lane per phase card, the same five stages left to
right, and the cards are the cards — the very nodes cardFor() builds for
the Board, agent line, chips and hover actions intact. That fidelity is
the whole reason this is a view and not a rail squeezed into five
columns that could never spare the width.
Nothing here ends a phase. Merging is a board move on the phase card, so
there is exactly one place where work leaves the board, and it is not
this one. */
/* Where a lane sits in the stack: a halt first, because it is the one
thing on this page waiting on a person and it must not be below the
fold; then a run in flight; then a run a person held, which is still
theirs to resume and so belongs above the phases nobody has started;
then the ones that are over. Every phase card draws a lane — a phase
whose members are all merged and which is waiting on its own PR still
owns them, and a lane that vanished exactly when it needed merging
would be a view you could not trust. */
function laneRank(task) {
const snap = (S.state.phases || {})[task.file];
if (snap && snap.halted) return 0;
if (snap && snap.running) return 1;
if (snap && snap.stopped) return 2;
return task.stage === 'done' ? 4 : 3;
}
function lanePhases() {
return allTasks().filter(t => t.isPhase).sort((a, b) =>
laneRank(a) - laneRank(b) ||
(+a.number || 0) - (+b.number || 0) ||
a.file.localeCompare(b.file));
}
/* `07`, `7` and `#007` are one card, on this side of the wire too —
taskfiles.canonical_number, in the language the page is written in. */
function canonicalNumber(number) {
const n = parseInt(number, 10);
return Number.isNaN(n) ? String(number ?? '') : String(n);
}
/* The phase log: the `- <date> <time> · <entry>` lines the runner appends
to a `## Phase log` section on the phase card, read here exactly as
phases.py writes them. It is the record of every decision a run made,
and the only thing that can tell "the phase has not reached this card"
from "it started it and it ended badly" — until this view it had
nowhere to be shown. */
const PHASE_LOG_LINE = /^-\s+(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2})\s+·\s+(.+?)\s*$/;
function phaseLog(task) {
const lines = (task.body || '').split('\n');
const start = lines.findIndex(line => /^##\s+Phase log\s*$/.test(line));
if (start < 0) return [];
const entries = [];
for (const line of lines.slice(start + 1)) {
if (/^##\s/.test(line)) break;
const match = PHASE_LOG_LINE.exec(line.trim());
if (match) entries.push({ at: match[1], text: match[2] });
}
return entries;
}
/* Which members the phase branch already holds. While a run is on, the
runner's last pass has just read it off git; once the phase leaves
in-progress/ there are no more passes — and the log, written by the same
runner as it merged them, still remembers. Two durable sources for one
fact, which is what keeps a phase in review/ from drawing a lane where
nothing ever landed. */
function mergedIn(task) {
const merged = new Set();
const snap = (S.state.phases || {})[task.file];
for (const m of ((snap && snap.members) || [])) {
if (m.state === 'merged' && m.number) merged.add(canonicalNumber(m.number));
}
for (const entry of phaseLog(task)) {
const match = /^(\d+) merged into /.exec(entry.text);
if (match) merged.add(canonicalNumber(match[1]));
}
return merged;
}
/* The columns a lane runs. The first four are the board's own stages, named
by the server that names them everywhere else. The last one is the
phase's own and it is deliberately not done/: a member merged into the
phase branch is finished as far as the phase is concerned, and is not in
main yet — main waits on the phase's own PR, at the end, for a person.
So the column is named for what it actually holds. */
function laneColumns() {
const stages = ((S.state.board || {}).stages || []).filter(s => s.slug !== 'done');
return [...stages.map(s => ({ slug: s.slug, label: s.label, tint: STAGE_TINT[s.slug] })),
{ slug: 'merged', label: 'Merged in', tint: 'var(--calm)',
note: 'on the phase branch' }];
}
/* Where one member is drawn: the stage it is actually in, unless the phase
branch already holds it — then it is in the lane's last column whatever
its card says, because within the phase that work has landed. A card in
done/ is finished by any reading. */
function laneColumnOf(task, merged) {
if (task.number && merged.has(canonicalNumber(task.number))) return 'merged';
return task.stage === 'done' ? 'merged' : task.stage;
}
/* Every card the phase lists, in run order, each with the column it is
drawn in. The card itself is the whole of what is known about it — this
only says where in the lane it goes. A listed number that resolves to no
card is not here: the phase card wears `phase drift` for that, as it
already did. */
function laneMembers(task) {
const merged = mergedIn(task);
const members = [];
(task.members || []).forEach((listed, index) => {
const card = findTask(listed.file);
if (!card) return;
members.push({ pos: index + 1, task: card, column: laneColumnOf(card, merged) });
});
return members;
}
/* What the head says it has got through, counted off the same placement
the columns draw by — so the head and the last column can never
disagree about how far the phase has got. */
function laneProgress(members) {
return { done: members.filter(m => m.column === 'merged').length,
total: members.length };
}
function laneLog(task) {
const entries = phaseLog(task);
const lines = entries.map(entry =>
`<div class="pline${/^halted\b/.test(entry.text) ? ' bad' : ''}">` +
`<span class="stamp">${esc(entry.at)}</span>` +
`<span class="what">${esc(entry.text)}</span></div>`).join('');
return `<div class="plog"><div class="phead">Phase log` +
`<span class="mono">${entries.length ? entries.length + ' in the order they were made'
: 'nothing yet'}</span></div>` +
(entries.length
? `<div class="plines" data-phase="${esc(task.file)}">${lines}</div>`
: `<div class="pempty">The runner writes a line here for every decision it makes — ` +
`a run started, a card started, a card merged, a halt.</div>`) +
`</div>`;
}
function laneFor(task) {
const el = document.createElement('section');
const snap = (S.state.phases || {})[task.file];
const members = laneMembers(task);
const at = laneProgress(members);
const halted = !!(snap && snap.halted);
const running = !!(snap && snap.running);
el.className = 'lane' + (halted ? ' halted' : running ? ' running' : '');
el.dataset.phase = task.file;
const branch = (snap && snap.branch) || 'phase/' + task.file.replace(/\.md$/, '');
// the branch is only claimed to be missing when the runner has just
// looked; without a pass to read, the name is offered without a verdict
const uncut = !!(snap && !snap.started);
const flight = phaseFlight(task);
const head =
`<div class="lhead">` +
`<span class="mark${running ? ' breathing' : ''}" style="background:${
halted ? 'var(--alarm)' : running ? 'var(--accent)' : STAGE_TINT[task.stage]}"></span>` +
(task.number ? `<span class="lref">#${esc(task.number)}</span>` : '') +
`<button class="ltitle" data-open="1" title="Open the phase card — its text, ` +
`its list and its PR">${esc(task.title)}</button>` +
`<span class="lprog">${at.total ? `${at.done} of ${at.total} merged`
: 'no cards listed'} · ${esc(task.stage)}</span>` +
`<button class="chip2" data-open="1" title="Open the phase card">card<span class="g2">↗</span></button>` +
`<span class="chip2${uncut ? ' dim' : ''}" title="${uncut
? 'The phase branch has not been cut yet — running the phase cuts it from the newest main'
: "The phase's integration branch: every member is branched from its tip and merged back into it"
}">${esc(branch)}</span>` +
`<span class="lacts"></span></div>`;
// the card the phase stopped on, named rather than left as a number
const stoppedOn = halted && snap.haltedAt
? members.find(m => m.task.number &&
canonicalNumber(m.task.number) === canonicalNumber(snap.haltedAt))
: null;
const halt = halted
? `<div class="lhalt"><span class="lglyph">✕</span><span class="lwhy">` +
`<b>halted${snap.haltedAt ? ` at #${esc(snap.haltedAt)}` : ''}</b>` +
`${stoppedOn ? ' · ' + esc(stoppedOn.task.title) : ''} — ` +
`${esc(snap.haltedWhy || snap.halted)}</span>` +
`<span class="lacts"></span></div>`
: '';
// a run in flight, in the same well the cards use for the same fact
const well = !halted && flight
? `<div class="well"><span class="lead">·</span><span class="wbody">${esc(flight.line)}` +
`<span class="caret">▌</span></span></div>`
: '';
el.innerHTML = head + halt + well +
`<div class="lstages" data-phase="${esc(task.file)}"></div>` + laneLog(task);
const stages = el.querySelector('.lstages');
for (const col of laneColumns()) {
const here = members.filter(m => m.column === col.slug);
const box = document.createElement('div');
box.className = 'lcol';
box.innerHTML = `<h3><span class="tickmark" style="background:${col.tint}"></span>` +
`<span>${esc(col.label)}</span><span class="count">${here.length}</span>` +
`<span class="note">${esc(col.note || '')}</span></h3>`;
const cards = document.createElement('div');
cards.className = 'lcards';
if (!here.length) cards.innerHTML = '<div class="empty">—</div>';
// the cards are the cards: same builder, same actions, same chips —
// including the ⟶ chip that says where each one sits in the run
for (const m of here) cards.appendChild(cardFor(m.task));
box.appendChild(cards);
stages.appendChild(box);
}
el.querySelectorAll('[data-open]').forEach(btn =>
btn.addEventListener('click', () => showDetail(task)));
// ▸ run again lives here and nowhere else: clearing a halt should mean
// having read what caused it, and the reason is the line above it
if (halted) {
laneAction(el.querySelector('.lhalt .lacts'), task,
{ glyph: '▸', label: 'run again', confirm: 'run it again?', busy: 'starting…',
title: 'Clear the halt and carry on from where it stopped — every card already '
+ 'merged into the phase branch stays merged',
run: () => runPhase(task) });
}
// ‖ hold means here what it means everywhere: the run stops, the member
// agent in flight stops with it, and nothing is unwound
if (running || halted) {
laneAction(el.querySelector('.lhead .lacts'), task,
{ glyph: '‖', label: 'hold', confirm: 'hold it?', busy: 'holding…',
title: 'Stop the phase and the agent it has in flight — the phase branch, every '
+ 'card already merged into it and every worktree stay exactly as they are',
run: () => holdPhase(task) });
}
return el;
}
/* A phase's controls are card actions in every way that matters, so they
go through the one arm-then-fire machine, keyed like every other. */
function laneAction(slot, task, act) {
if (!slot) return;
const btn = document.createElement('button');
btn.innerHTML = `<span class="g">${act.glyph}</span>${actLabel(act.label, act.confirm, act.busy)}`;
btn.title = act.title;
wireAction(btn, `${task.file}::${act.label}`, act);
slot.appendChild(btn);
}
function renderLanes() {
const view = $('#view-phases'), wrap = $('#lanes');
// where you were looking, taken before the wipe throws the nodes away:
// the page scrolls down, each lane's stages scroll sideways, each log
// scrolls in its own box
markScroll('v:phases', view);
wrap.querySelectorAll('.lstages').forEach(el =>
markScroll('v:lanestages:' + el.dataset.phase, el));
wrap.querySelectorAll('.plines').forEach(el =>
markScroll('v:phaselog:' + el.dataset.phase, el));
wrap.innerHTML = '';
const phases = lanePhases();
if (!phases.length) {
// an empty grid of five columns would be a room pretending to be full
wrap.innerHTML = '<div class="empty">No phases yet. A phase is an ordinary card ' +
'marked <b>Type: Phase</b> with a <code>## Cards</code> section listing the cards ' +
'it runs, in the order they run.</div>';
} else {
for (const task of phases) wrap.appendChild(laneFor(task));
}
restoreScroll('v:phases', view);
wrap.querySelectorAll('.lstages').forEach(el =>
restoreScroll('v:lanestages:' + el.dataset.phase, el));
wrap.querySelectorAll('.plines').forEach(el => {
// a log you have not touched opens on its newest line, which is the one
// that says where the phase is; once you have scrolled it, that is where
// it stays through every redraw
const key = 'v:phaselog:' + el.dataset.phase;
if (scrollMarks[key]) restoreScroll(key, el);
else el.scrollTop = el.scrollHeight;
});
if (S.selected) {
S.selected = allTasks().find(t => t.file === S.selected.file) || null;
}
renderDrawer();
}
/* ── sessions ─────────────────────────────────────────────────────────── */
function pickFlightSid() {
if (S.flightSid && sessionMeta(S.flightSid)) return S.flightSid;
const live = sessionsOf(isLiveSession);
const agentLive = live.find(m => m.agentId);
S.flightSid = (agentLive || live[0] || (S.state.sessions || [])[0] || {}).id || null;
return S.flightSid;
}
function renderFlight() {
const sid = pickFlightSid();
if (sid) loadSession(sid);
const rows = (S.state.sessions || []).map(m => {
const active = isLiveSession(m) && m.status === 'active';
return `<div class="sess-row${m.id === sid ? ' sel' : ''}" data-sid="${esc(m.id)}" role="button" tabindex="0">` +
`<span class="top"><span class="dot${active ? ' live' : ''}"></span>` +
`<span>${esc((m.label || m.id.slice(0, 8)).split(' · ')[0])}</span>` +
`<span class="sid">${esc(m.id.slice(0, 8))}</span>${modelChip(agentFor(m.id))}</span>` +
`<span class="sub">${m.task ? 'on ' + esc(m.task) + ' · ' : ''}${m.count || 0} events · ${m.last ? ago(m.last) + ' ago' : ''}</span>` +
(m.lastSummary ? `<span class="sub">${esc(m.lastSummary)}</span>` : '') +
`</div>`;
}).join('');
const rail = $('#frail');
markScroll('v:rail', rail);
rail.innerHTML = `<div class="label">Sessions</div>` +
(rows || '<div class="f-empty">None yet. Start an agent, or open a claude session in this repo.</div>');
restoreScroll('v:rail', rail);
document.querySelectorAll('#frail .sess-row').forEach(el =>
el.addEventListener('click', () => { S.flightSid = el.dataset.sid; render(); }));
const meta = sessionMeta(sid);
if (!meta) {
$('#fsession').innerHTML = '<span style="color:var(--dim)">no session selected</span>';
$('#ffilters').innerHTML = '';
$('#ftl').innerHTML = '';
return;
}
const events = S.events[sid] || [];
const files = new Set(events.filter(e => e.file && e.kind === 'edit').map(e => e.file));
const checks = events.filter(e => e.kind === 'test' || e.kind === 'check').length;
const agent = agentFor(sid);
const stopBtn = agent && agent.status === 'running'
? `<button id="stopagent" class="stopbtn" data-aid="${esc(agent.id)}">Hold</button>` : '';
const branch = agent && agent.branch ? ` · <span class="mono">${esc(agent.branch)}</span>` : '';
// Honesty about what the run actually rode. A known model is the chip's
// job now, beside the name; the line keeps only what the chip cannot
// say — that this launch inherited the vendor default. Interactive
// sessions say nothing.
const model = agent && !agent.model ? ` · <span class="mono">model inherited</span>` : '';
$('#fsession').innerHTML =
`<div><div class="s-title">${esc((meta.label || sid).split(' · ')[0])}` +
`<span class="sid">${esc(sid.slice(0, 8))}</span>${modelChip(agent)}</div>` +
`<div class="s-line">${meta.task ? 'on ' + esc(meta.task) + ' · ' : ''}` +
`started ${fmtShort(meta.started)} · ${meta.count || 0} events · ` +
`${files.size} files edited · ${checks} check runs${branch}${model}</div></div>` +
stopBtn + spark(events, meta);
const stop = $('#stopagent');
if (stop) stop.addEventListener('click', () => stopAgent(stop.dataset.aid));
$('#ffilters').innerHTML = FILTERS.map(([key, label]) =>
`<button class="${S.filter === key ? 'on' : ''}" data-f="${key}">${label}</button>`).join('');
document.querySelectorAll('#ffilters button').forEach(el =>
el.addEventListener('click', () => { S.filter = el.dataset.f; render(); }));
renderTimeline(sid, meta, events);
}
async function stopAgent(aid) {
const res = await fetch('/api/agent/stop', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: aid }),
});
const data = await res.json();
toast(res.ok ? 'Held — nothing is lost' : (data.error || 'could not stop it'), !res.ok);
await loadState();
return res.ok;
}
function spark(events, meta) {
if (!events.length) return '';
const start = meta.started || events[0].ts;
const end = Math.max(meta.last || 0, events[events.length - 1].ts, start + 60);
const n = 34, span = (end - start) / n;
const buckets = new Array(n).fill(0);
for (const ev of events) {
const i = Math.min(n - 1, Math.max(0, Math.floor((ev.ts - start) / span)));
buckets[i]++;
}
const max = Math.max(...buckets, 1);
const live = isLiveSession(meta);
const bars = buckets.map((b, i) =>
`<i${live && i >= n - 3 ? ' class="now"' : ''} style="height:${Math.max(4, Math.round(b / max * 30))}px"></i>`).join('');
return `<div class="spark" title="events over the session">${bars}</div>`;
}
function renderTimeline(sid, meta, events) {
const active = FILTERS.find(([key]) => key === S.filter);
const kinds = active ? active[2] : null;
const boardEvents = (S.state.boardEvents || [])
.filter(be => be.file && be.file === meta.task)
.map(be => ({ ...be, _board: true }));
let items = events.filter(ev => !kinds || kinds.has(ev.kind));
if (!kinds || kinds.has('move')) items = items.concat(boardEvents);
items.sort((a, b) => (a.ts || 0) - (b.ts || 0));
// collapse runs of consecutive reads/searches into one foldable row
const grouped = [];
for (const ev of items) {
const prev = grouped[grouped.length - 1];
if ((ev.kind === 'read' || ev.kind === 'search') && prev && prev._group) {
prev.events.push(ev); prev.ts = ev.ts; continue;
}
if (ev.kind === 'read' || ev.kind === 'search') {
grouped.push({ _group: true, ts: ev.ts, first: ev.ts, events: [ev] });
} else grouped.push(ev);
}
const run = S.running[sid];
const out = [];
if (run) out.push(tlRow({ ...run, _key: 'running' }, 'run', true));
for (let i = grouped.length - 1; i >= 0; i--) {
const item = grouped[i];
if (item._group) {
if (item.events.length === 1) out.push(tlRow(item.events[0]));
else {
const key = `g${item.first}`;
const list = item.events.map(e => e.file || e.summary.replace(/^searched /, '⌕ ')).join('\n');
out.push(`<div class="tl-row"><time>${fmtClock(item.first)}</time>` +
`<span class="glyph">${GLYPHS.read}</span><div class="tbody">` +
`<div class="text">read and searched ${item.events.length} places</div>` +
fold(key, 'show them', list) + `</div></div>`);
}
} else out.push(tlRow(item));
}
// keyed by session, so reading back through a long run survives the
// events it is still producing — and picking a different session, being
// a different key, opens at the top
const tl = $('#ftl');
markScroll('v:tl:' + sid, tl);
tl.innerHTML = out.join('') ||
'<div class="f-empty">Nothing matches this filter.</div>';
restoreScroll('v:tl:' + sid, tl);
document.querySelectorAll('#ftl details').forEach(d => {
d.addEventListener('toggle', () => {
if (d.open) S.openFolds.add(d.dataset.key); else S.openFolds.delete(d.dataset.key);
});
});
}
function fold(key, label, body) {
const open = S.openFolds.has(key) ? ' open' : '';
return `<details class="fold" data-key="${esc(key)}"${open}><summary>${esc(label)}</summary><pre>${esc(body)}</pre></details>`;
}
function tlRow(ev, forceCls, caret) {
const cls = forceCls || (ev._board ? 'move' : ev.ok === false ? 'bad' : ev.ok === true ? 'ok' : '');
const glyph = GLYPHS[ev.kind] || '·';
const key = ev._key || `e${ev.ts}`;
let text = esc(ev.summary);
if (ev.kind === 'move') {
text = `<strong>${esc(ev.file)}</strong> ${esc(ev.from)} <span class="arrow">→</span> ${esc(ev.to)}` +
` <span class="actor">· ${esc(ev.actor)}</span>`;
}
if (caret) text += '<span class="caret">▌</span>';
let extra = '';
if (ev.detail) extra = fold(key, ev.kind === 'plan' ? 'plan steps' : ev.kind === 'report' ? 'the report' : 'output', ev.detail);
else if (ev.cmd && ev.cmd.length > 90) extra = `<div class="text mono" style="font-size:11.5px;color:var(--dim)">${esc(ev.cmd)}</div>`;
return `<div class="tl-row ${cls}"><time>${fmtClock(ev.ts)}</time>` +
`<span class="glyph">${glyph}</span>` +
`<div class="tbody"><div class="text">${text}</div>${extra}</div></div>`;
}
/* ── focus ────────────────────────────────────────────────────────────── */
function pickFocusSid() {
if (S.focusSid && sessionMeta(S.focusSid)) return S.focusSid;
const live = sessionsOf(isLiveSession);
const agentLive = live.find(m => m.agentId);
S.focusSid = (agentLive || live[0] || (S.state.sessions || [])[0] || {}).id || null;
return S.focusSid;
}
function renderFocus() {
const sid = pickFocusSid();
if (sid) loadSession(sid);
const meta = sessionMeta(sid);
// the view itself scrolls, and so does the stage strip across the top
const view = $('#view-focus'), strip = $('#cstrip');
markScroll('v:focus', view);
markScroll('v:cstrip', strip);
const putBack = () => { restoreScroll('v:focus', view); restoreScroll('v:cstrip', strip); };
const options = (S.state.sessions || []).map(m =>
`<option value="${esc(m.id)}"${m.id === sid ? ' selected' : ''}>${esc(m.label || m.id.slice(0, 8))}${m.task ? ' — ' + esc(m.task) : ''}</option>`).join('');
$('#focussel').innerHTML = options || '<option>no sessions yet</option>';
const hot = meta && meta.task ? findTask(meta.task)?.stage : 'in-progress';
$('#cstrip').innerHTML = S.state.board.stages.map(s =>
`<div class="c-seg${s.slug === hot ? ' hot' : ''}"><span class="lbl">${s.label}</span><span class="n">${s.tasks.length}</span></div>`).join('');
if (!meta) {
$('#cgrid').innerHTML = `<div class="panel"><div class="phead"><span class="dot"></span>` +
`<span class="label">Right now</span></div>` +
`<p style="margin:0;color:var(--dim);font-size:12.5px">Nothing yet. Start an agent from a card, ` +
`or open a claude session in this repo — it shows up here as it works.</p></div>`;
$('#calso').innerHTML = '';
putBack();
return;
}
const events = S.events[sid] || [];
const agent = agentFor(sid);
const task = meta.task ? findTask(meta.task) : null;
const run = S.running[sid];
const live = isLiveSession(meta) && meta.status === 'active';
const last = run || events[events.length - 1];
const wellCls = last && last.ok === false ? ' bad' : '';
const caret = (run || live) ? '<span class="caret">▌</span>' : '';
let act;
if (last && last.detail) {
// only when clicking does something: the well is the summary of a
// fold, keyed into S.openFolds so it survives the SSE re-renders
const wkey = `w${last.ts}`;
act = `<details class="fold wellfold" data-key="${esc(wkey)}"${S.openFolds.has(wkey) ? ' open' : ''}>` +
`<summary><div class="well${wellCls}"><span class="lead"></span>` +
`<span class="wbody">${esc(last.summary)}${caret}</span></div></summary>` +
`<pre>${esc(last.detail)}</pre></details>`;
} else if (last) {
act = `<div class="well${wellCls}"><span class="lead">·</span><span class="wbody">${esc(last.summary)}${caret}</span></div>`;
} else {
act = '<div class="well"><span class="lead">·</span><span class="wbody">no activity yet</span></div>';
}
const plan = [...events].reverse().find(e => e.kind === 'plan' && e.detail);
let steps = '';
if (plan) {
const lines = plan.detail.split('\n').filter(Boolean);
steps = '<div class="steps">' + lines.map(l => {
const cls = l.startsWith('[x]') ? 'done' : l.startsWith('[>]') ? 'doing' : 'todo';
const glyph = cls === 'done' ? '✓' : cls === 'doing' ? '' : '○';
return `<div class="step ${cls}"><span class="glyph">${glyph}</span><span class="stext">${esc(l.replace(/^\[.\]\s*/, ''))}</span></div>`;
}).join('') + '</div>';
}
const stopBtn = agent && agent.status === 'running'
? `<button id="cstop" class="stopbtn" data-aid="${esc(agent.id)}">Hold</button>` : '';
const refBits = [];
if (task) {
refBits.push(task.number ? '#' + esc(task.number) : esc(task.file));
refBits.push(`<span class="acc">${esc((meta.label || '').split(' · ')[0])}</span>` +
modelChip(agent));
if (agent && agent.branch) refBits.push('worktree ' + esc(agent.branch));
// the chip says which model; the line is left saying only what it can't
if (agent && !agent.model) refBits.push('model inherited');
refBits.push(esc(task.stage) + '/' + esc(task.file));
} else {
refBits.push(esc(sid.slice(0, 8)), 'no task attached');
}
const nowPanel =
`<div class="panel"><div class="phead">` +
`<span class="dot${live ? ' live' : ''}"></span><span class="label">Right now</span>` +
`<span class="r" id="celapsed">${sessionElapsed(meta)} on this</span>${stopBtn}</div>` +
`<div class="refline">${refBits.join('<span>·</span>')}</div>` +
`<h1 class="focus-title">${esc(task ? task.title : 'Session')}</h1>` +
steps + act + `</div>`;
const rev = [...events].reverse();
const checkRow = (name, ev) => {
if (!ev) return `<div class="check-row none"><span class="glyph">—</span><span class="name">${esc(name)}</span><span class="state">not run</span></div>`;
const cls = ev.ok === false ? 'fail' : ev.ok === true ? 'pass' : 'none';
const glyph = ev.ok === false ? '✕' : ev.ok === true ? '✓' : '·';
const text = ev.summary.startsWith(name + ' — ')
? ev.summary.slice(name.length + 3) : ev.summary.replace(/^ran: /, '');
const state = esc(text) + ' · ' + fmtShort(ev.ts);
return `<div class="check-row ${cls}"><span class="glyph">${glyph}</span><span class="name">${esc(name)}</span><span class="state">${state}</span></div>`;
};
// One row per project-defined check — labels and command patterns come
// from the served checks definition (local/checks over core/checks),
// the same file the adapter classifies against.
const checkRows = (S.state?.checks || []).map(c => {
let re = null;
try { re = new RegExp(c.pattern); } catch { /* skip unparseable */ }
const ev = re && rev.find(e => !e.running && e.cmd && re.test(e.cmd));
return checkRow(c.label, ev);
}).join('');
const checksPanel = `<div class="panel"><div class="phead"><span class="label">Checks</span></div>` +
(checkRows || `<div class="check-row none"><span class="glyph">—</span><span class="name">no checks defined</span><span class="state"></span></div>`) +
`</div>`;
let fileRows = '', fileHead = '';
const diff = agent && S.diffCache[agent.id];
if (agent) fetchDiff(agent.id);
if (diff && diff.files && diff.files.length) {
const tp = diff.files.reduce((n, f) => n + f.plus, 0);
const tm = diff.files.reduce((n, f) => n + f.minus, 0);
fileHead = `<span class="r">+${tp} ${tm}</span>`;
fileRows = diff.files.slice(0, 12).map(f =>
`<div class="file-row"><span class="fpath">&lrm;${esc(f.file)}&lrm;</span>` +
`<span class="add">+${f.plus}</span><span class="del">${f.minus}</span></div>`).join('');
if (diff.files.length > 12) fileRows += `<div class="file-row" style="color:var(--dim);font-size:11.5px">… ${diff.files.length - 12} more</div>`;
} else {
const counts = {};
for (const ev of events) if (ev.kind === 'edit' && ev.file) counts[ev.file] = (counts[ev.file] || 0) + 1;
const entries = Object.entries(counts).sort((a, b) => b[1] - a[1]);
fileRows = entries.slice(0, 12).map(([f, n]) =>
`<div class="file-row"><span class="fpath">&lrm;${esc(f)}&lrm;</span>` +
`<span class="add" style="color:var(--dim)">${n} edit${n > 1 ? 's' : ''}</span></div>`).join('')
|| '<div class="file-row" style="color:var(--dim);font-size:12px">nothing edited yet</div>';
}
const filesPanel = `<div class="panel"><div class="phead"><span class="label">Files it has touched</span>${fileHead}</div><div>${fileRows}</div></div>`;
const recent = events.slice(-6).reverse().map(evRow).join('');
const recentPanel = `<div class="panel"><div class="phead"><span class="label">Recent</span></div>` +
`<div class="minifeed">${recent || '<span style="color:var(--dim);font-size:12px">nothing yet</span>'}</div></div>`;
$('#cgrid').innerHTML = nowPanel + `<div class="c-side">${checksPanel}${filesPanel}${recentPanel}</div>`;
document.querySelectorAll('#cgrid details.fold').forEach(d => {
d.addEventListener('toggle', () => {
if (d.open) S.openFolds.add(d.dataset.key); else S.openFolds.delete(d.dataset.key);
});
});
const cstop = $('#cstop');
if (cstop) cstop.addEventListener('click', () => stopAgent(cstop.dataset.aid));
const others = allTasks().filter(t =>
['in-progress', 'review'].includes(t.stage) && (!task || t.file !== task.file));
$('#calso').innerHTML = others.map(t => {
const pill = pillFor(t.stage, !!agentOnTask(t.file));
return `<div class="card"><div class="toprow">` +
`<span class="ref">${t.number ? '#' + esc(t.number) : ''}</span><span class="spacer"></span>` +
`<span class="pill" style="background:${pill.bg};color:${pill.tint}">${pill.text}</span></div>` +
`<div class="title">${esc(t.title)}</div></div>`;
}).join('');
putBack();
}
function findTask(file) { return allTasks().find(t => t.file === file) || null; }
async function fetchDiff(agentId) {
const cached = S.diffCache[agentId];
if (cached && (cached.fetching || Date.now() - cached.at < 8000)) return;
S.diffCache[agentId] = { ...(cached || {}), fetching: true };
try {
const res = await fetch('/api/diff?agent=' + encodeURIComponent(agentId));
const data = await res.json();
S.diffCache[agentId] = { at: Date.now(), files: data.files || [] };
if (S.view === 'focus') scheduleRender();
} catch {
S.diffCache[agentId] = { at: Date.now(), files: cached ? cached.files : [] };
}
}
/* ── extras / markdown ────────────────────────────────────────────────── */
async function openExtra(dir, name) {
const url = `/files/${encodeURIComponent(dir)}/${encodeURIComponent(name)}`;
if (name.toLowerCase().endsWith('.md')) {
try {
const res = await fetch(url);
if (!res.ok) { toast(`could not read ${name}`, true); return; }
S.fileView = { dir, name, content: await res.text() };
S.selected = null;
renderDrawer();
$('#drawerbody').scrollTop = 0;
} catch { toast(`could not read ${name}`, true); }
} else {
window.open(url, '_blank'); // html renders, everything else the browser handles
}
}
// Small markdown renderer — for these task files, which are hard-wrapped
// prose: a logical line spans several source lines. No dependencies.
function md(src) {
const blocks = esc(src).split(/\n{2,}/);
const inline = (t) => t
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/(^|[\s(])\*([^*\n]+)\*/g, '$1<em>$2</em>')
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
/* Lists: group physical lines into logical items before rendering. A new
item begins only at a marker; a line without one is continuation text
joined to the item above with a space — that is what stops a wrapped
item from sprouting a second bullet. A marker indented past the level
it sits in opens a nested list, and a shallower one closes back to the
level that fits, so children indent under their parent instead of
flattening beside it. */
const MARKER = /^([ \t]*)(?:[-*]|\d+\.)[ \t]+(.*)$/;
const ORDERED = /^[ \t]*\d+\./;
const TICK = /^\[([ xX])\]\s*(.*)$/; // - [ ] / - [x], read-only here
const listTree = (lines) => {
const root = { ordered: ORDERED.test(lines[0]), items: [] };
const stack = [{ indent: null, list: root }];
let last = null;
for (const line of lines) {
const m = line.match(MARKER);
if (!m) { // continuation of the item above
if (last && line.trim()) last.text += ' ' + line.trim();
continue;
}
const indent = m[1].replace(/\t/g, ' ').length;
const top = () => stack[stack.length - 1];
if (top().indent === null) top().indent = indent;
while (stack.length > 1 && indent < top().indent) stack.pop();
if (last && indent > top().indent) {
last.sub = { ordered: ORDERED.test(line), items: [] };
stack.push({ indent, list: last.sub });
}
last = { text: m[2], sub: null };
top().list.items.push(last);
}
return root;
};
const listHtml = (list) => {
const tag = list.ordered ? 'ol' : 'ul';
return `<${tag}>` + list.items.map((it) => {
const t = it.text.match(TICK);
const body = inline(t ? t[2] : it.text) + (it.sub ? listHtml(it.sub) : '');
if (!t) return `<li>${body}</li>`;
// A glyph, never an input: the task file is the source of truth and
// the drawer is not an editor. Done reads as settled, open is neutral.
const done = t[1] !== ' ';
return `<li class="tick${done ? ' on' : ''}">` +
`<span class="box" role="img" aria-label="${done ? 'done' : 'not done'}">` +
`${done ? '✓' : ''}</span>${body}</li>`;
}).join('') + `</${tag}>`;
};
let inFence = false, fenced = [];
const out = [];
for (const block of blocks) {
if (inFence || block.startsWith('```')) {
fenced.push(block);
const ticks = (block.match(/```/g) || []).length;
inFence = inFence ? ticks % 2 === 0 : ticks % 2 === 1;
if (!inFence) {
const body = fenced.join('\n\n').replace(/^```[^\n]*\n?/, '').replace(/```\s*$/, '');
out.push(`<pre><code>${body}</code></pre>`);
fenced = [];
}
continue;
}
const text = block.replace(/\s+$/, ''); // the split leaves the last block a trailing newline
const lines = text.split('\n');
if (lines.length >= 2 && /^\s*\|.*\|\s*$/.test(lines[0]) && /^\s*\|[\s:|-]+\|\s*$/.test(lines[1])) {
const cells = (l) => l.trim().replace(/^\||\|$/g, '').split('|').map(c => inline(c.trim()));
const head = cells(lines[0]);
const rows = lines.slice(2).filter(l => /\|/.test(l)).map(cells);
out.push('<div class="tablewrap"><table><thead><tr>' +
head.map(h => `<th>${h}</th>`).join('') + '</tr></thead><tbody>' +
rows.map(r => '<tr>' + r.map(c => `<td>${c}</td>`).join('') + '</tr>').join('') +
'</tbody></table></div>');
} else if (/^#{1,6}\s/.test(text)) {
out.push(lines.map(l => {
const m = l.match(/^(#{1,6})\s+(.*)$/);
return m ? `<h${m[1].length}>${inline(m[2])}</h${m[1].length}>` : `<p>${inline(l)}</p>`;
}).join(''));
} else if (MARKER.test(lines[0])) {
out.push(listHtml(listTree(lines)));
} else if (/^&gt;/.test(text)) {
// reflow, don't preserve the author's wrap column
out.push('<blockquote>' + inline(text.replace(/^&gt;\s?/gm, '')).replace(/\n/g, ' ') + '</blockquote>');
} else if (/^-{3,}$/.test(text.trim())) {
out.push('<hr>');
} else {
out.push(`<p>${inline(text).replace(/\n/g, ' ')}</p>`);
}
}
if (fenced.length) out.push(`<pre><code>${fenced.join('\n\n')}</code></pre>`);
return out.join('');
}
/* ── boot ─────────────────────────────────────────────────────────────── */
document.querySelectorAll('#views button').forEach(b =>
b.addEventListener('click', () => setView(b.dataset.view)));
$('#livechip').addEventListener('click', () => setView('flight'));
$('#themebtn').addEventListener('click', () => { S.dark = !S.dark; applyTheme(); });
$('#focussel').addEventListener('change', (e) => { S.focusSid = e.target.value; render(); });
$('#refresh').addEventListener('click', loadState);
window.addEventListener('focus', loadState);
document.addEventListener('keydown', (e) => {
if (e.key !== 'Escape') return;
if ($('#sheetwrap').classList.contains('open')) { closeSheet(); }
else if (S.fileView) { S.fileView = null; renderDrawer(); }
else if (S.selected) { S.selected = null; renderCards(); }
});
/* the drawer: drag its left edge to widen */
{
const grip = $('#drawergrip'), panel = $('#drawer');
const saved = parseInt(localStorage.getItem('bench-drawer-w'), 10);
if (saved) panel.style.width = Math.min(saved, Math.round(innerWidth * 0.92)) + 'px';
let dragging = false, startX = 0, startW = 0;
grip.addEventListener('mousedown', (e) => {
dragging = true; startX = e.clientX; startW = panel.offsetWidth;
grip.classList.add('dragging'); e.preventDefault();
});
document.addEventListener('mousemove', (e) => {
if (!dragging) return;
const w = Math.min(Math.max(startW + (startX - e.clientX), 380), Math.round(innerWidth * 0.92));
panel.style.width = w + 'px';
});
document.addEventListener('mouseup', () => {
if (!dragging) return;
dragging = false; grip.classList.remove('dragging');
localStorage.setItem('bench-drawer-w', panel.offsetWidth);
});
}
/* the activity log: drag its grip to resize, scroll to read back */
{
const grip = $('#loggrip'), body = $('#logbody');
const clamp = (px) => Math.min(Math.max(px, 80), Math.round(innerHeight * 0.6));
let h = clamp(parseInt(localStorage.getItem('bench-log-h'), 10) ||
Math.round(innerHeight * 0.30));
body.style.height = h + 'px';
let dragging = false, startY = 0, startH = 0;
grip.addEventListener('mousedown', (e) => {
dragging = true; startY = e.clientY; startH = body.offsetHeight;
grip.classList.add('dragging'); e.preventDefault();
});
document.addEventListener('mousemove', (e) => {
if (!dragging) return;
h = clamp(startH + (startY - e.clientY));
body.style.height = h + 'px';
});
document.addEventListener('mouseup', () => {
if (!dragging) return;
dragging = false; grip.classList.remove('dragging');
localStorage.setItem('bench-log-h', h);
});
body.addEventListener('scroll', () => {
S.logStick = body.scrollTop + body.clientHeight >= body.scrollHeight - 8;
});
}
/* the activity bar: toggle, archive dock, ⌘Z */
$('#actbtn').addEventListener('click', () => {
S.logOpen = !S.logOpen;
localStorage.setItem('bench-log-open', S.logOpen ? '1' : '0');
renderBar(); renderLog();
});
$('#tray').addEventListener('click', () => {
const n = S.state && S.state.archivedCount || 0;
toast(n ? `${n} archived — ⌘Z undoes the last one from this board` : 'Nothing archived yet. Drag a card here.');
});
{
const bar = $('#statusbar');
bar.addEventListener('dragover', (e) => {
if (!S.dragging) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (!S.dockHot) { S.dockHot = true; renderBar(); }
});
bar.addEventListener('dragleave', (e) => {
if (e.relatedTarget && bar.contains(e.relatedTarget)) return;
if (S.dockHot) { S.dockHot = false; renderBar(); }
});
bar.addEventListener('drop', (e) => {
e.preventDefault();
const d = S.dragging;
S.dragging = null; S.dockHot = false; renderBar();
if (d) archiveCard(d.file, d.from);
});
}
document.addEventListener('keydown', (e) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'z' &&
!/input|textarea|select/i.test(e.target.tagName)) {
e.preventDefault();
unarchiveLast();
}
});
let tickCount = 0;
setInterval(() => {
renderChip();
const el = $('#celapsed');
if (el && S.focusSid) {
const m = sessionMeta(S.focusSid);
if (m) el.textContent = `${sessionElapsed(m)} on this`;
}
// safety net: refetch every 30s so a missed broadcast can never
// leave the page lying for long
if (++tickCount % 6 === 0) loadState();
}, 5000);
applyTheme();
loadState().then(connectStream);
</script>
</body>
</html>