Files
bench/manager/core/board.html
T
istos 419daef5e6 A redraw keeps where you were looking
renderBoard() starts with `board.innerHTML = ''` and rebuilds every
column, so each column's scrolling .drop was a brand-new node on every
pass — 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 a column being read
snapped back to the top several times a minute: worst exactly when there
is most to read. The same wipe threw away #board's horizontal position,
and the same demolition costs the session timeline, the sessions rail,
the Focus view, the drawer, and the activity log's place whenever it was
not stuck to the bottom.

Rather than a reconciling render — the real cure, and a far larger change
to the way the page works — the position is paid for separately: note
where each scroller was before the wipe, put it back once the new nodes
are in, both halves 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 is what does not
survive. Restoring clamps, so a column whose cards moved on lands at its
new bottom instead of past it, and one now shorter than its own viewport
lands at the top instead of at a negative offset.

A `v:` prefix marks a key belonging to a view, and switching views drops
those: coming back is a fresh look, not a stale offset from a previous
visit. The activity log spans every view, so its key carries no prefix
and outlives the switch. The two behaviours that were already right are
left alone and now have tests holding them there — the log still follows
the newest line when it was stuck to the bottom (that reading runs after
the restore, so it wins), and the drawer still opens at the top when you
select a different card, which falls out of keying the drawer per
document rather than being fought for.

The helpers run for real under node, as tests/test_drawer_markdown.py
already does with md(); the source-level invariants beside them hold the
mark/restore pairs in order around each wipe, and one of them fails if a
new scrolling element is added to the CSS without a renderer keeping its
place.

    python3 -m unittest discover -s tests   → 854 tests, ok
2026-08-01 17:15:30 +02:00

2625 lines
128 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)}
.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))}
/* 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)}
.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}
.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);
}
.hoveracts button:hover{border-color:var(--accent);color:var(--accent)}
.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 */
.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 */
.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)}
/* ── 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 ── */
#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;
}
#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>
<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>
<!-- 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;
function toast(message, isError = false) {
const el = $('#toast');
$('#toastmsg').textContent = message;
el.classList.toggle('err', isError);
el.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => el.classList.remove('show'), 3200);
}
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);
}));
}
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', 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;
renderTitle();
renderChip();
renderPhases();
renderSync();
if (S.view === 'board') renderBoard();
else if (S.view === 'flight') renderFlight();
else renderFocus();
renderBar();
renderLog();
}
/* ── board ────────────────────────────────────────────────────────────── */
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 agentsHere = stage.tasks.filter(t => agentOnTask(t.file)).length;
const note = agentsHere ? `${agentsHere} agent${agentsHere > 1 ? 's' : ''}` : (STAGE_NOTE[stage.slug] || '');
col.innerHTML =
`<h2><span class="tickmark" style="background:${STAGE_TINT[stage.slug]}"></span>` +
`<span>${stage.label}</span><span class="count">${stage.tasks.length}</span>` +
`<span class="note">${esc(note)}</span></h2>`;
const drop = document.createElement('div');
drop.className = 'drop';
drop.dataset.stage = stage.slug;
if (!stage.tasks.length) drop.innerHTML = '<div class="empty">Nothing here. Good.</div>';
for (const task of stage.tasks) 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;
}
/* 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) : [];
}
function cardFor(task) {
const el = document.createElement('article');
const agent = agentOnTask(task.file);
const working = agent && agent.mode !== 'review';
const verdict = task.stage === 'review' ? prVerdict(task) : null;
const failure = failedRun(task);
// the server holds this, not the tab that clicked: while merge & clean up
// runs, the card is mid-disassembly and hands back none of its actions
const completing = (S.state.completing || {})[task.file];
el.className = 'card'
+ (S.selected && S.selected.file === task.file ? ' selected' : '')
+ (working ? ' running' : '')
+ (verdict === 'green' ? ' verdict-good' : verdict === 'red' ? ' verdict-bad' : '')
+ (failure ? ' run-failed' : '')
+ (completing ? ' completing' : '')
+ (task.stage === 'done' ? ' done-dim' : '');
el.draggable = !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 (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.halted
? 'Run it again — the halt is cleared and the phase carries on from '
+ 'where it stopped'
: '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: both are here,
// because a person who has read it and does not want to carry on
// needs a way to say so that is not walking the card backwards
if (ph && ph.running) actions.push(hold);
else if (ph && ph.halted) actions.push(start, 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>`;
}
// 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 = [];
// 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` });
}
}
}
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.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-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 (['backlog', 'to-do', 'done'].includes(task.stage)) {
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>`;
}
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);
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.</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.`) +
`</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 toast(data.merged ? `${task.file} merged and cleaned up` : `${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;
renderBoard();
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; renderBoard(); });
}
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();
}
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();
}
/* ── 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; renderBoard(); }
});
/* 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>