`**Type:** Phase` plus a `## Cards` section is the whole model. The phase card is the only authority on membership: it lists its members in run order, and a member's phase and position are derived from that list rather than stored on the member — one direction, one place to edit. - taskfiles.py reads the section (numbers only; `31`, `#31` and `031` are one card, indented lines are a member's own continuation) and parses `Depends on` at last, into the numbers it names. Nothing acts on it: the list says what runs next, the line says whether it may. - collect() weaves the board: members resolved in document order, each member given its phase and 3/5 position. What will not resolve is flagged rather than skipped — a number no card has, a card two phases both list (both flagged), a card listed twice, a line naming no number, a phase listed by a phase. - board.html gives a member card a `⟶ <phase> 3/5` chip in the footer row beside CI and PR ↗, opening the phase card, and wears phase drift as a pill next to status drift. Nothing runs yet — no branch, no launches, no advancing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2241 lines
110 KiB
HTML
2241 lines
110 KiB
HTML
<!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}
|
||
.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)}
|
||
#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="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) =>
|
||
({ '&': '&', '<': '<', '>': '>', '"': '"' }[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
|
||
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: '⇅', other: '·' };
|
||
const FILTERS = [
|
||
['all', 'All', null],
|
||
['moves', 'Moves', new Set(['move', 'new', 'agent', 'sync'])],
|
||
['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(); });
|
||
}
|
||
|
||
/* ── 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>`;
|
||
}
|
||
|
||
function setView(view) {
|
||
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();
|
||
renderSync();
|
||
if (S.view === 'board') renderBoard();
|
||
else if (S.view === 'flight') renderFlight();
|
||
else renderFocus();
|
||
renderBar();
|
||
renderLog();
|
||
}
|
||
|
||
/* ── board ────────────────────────────────────────────────────────────── */
|
||
|
||
function renderBoard() {
|
||
const board = $('#board');
|
||
board.innerHTML = '';
|
||
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);
|
||
}
|
||
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;
|
||
}
|
||
|
||
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 {
|
||
if (task.stage === 'in-progress') {
|
||
// 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;
|
||
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') });
|
||
}
|
||
// 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 {
|
||
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;
|
||
}
|
||
|
||
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 & 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();
|
||
});
|
||
}
|
||
|
||
function showDetail(task) {
|
||
const changed = !S.selected || S.selected.file !== task.file;
|
||
S.selected = task;
|
||
renderBoard();
|
||
if (changed) $('#drawerbody').scrollTop = 0;
|
||
}
|
||
|
||
function renderDrawer() {
|
||
const panel = $('#drawer');
|
||
const body = $('#drawerbody');
|
||
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');
|
||
$('#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 +
|
||
`<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');
|
||
$('#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');
|
||
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>';
|
||
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('');
|
||
$('#frail').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>');
|
||
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));
|
||
}
|
||
$('#ftl').innerHTML = out.join('') ||
|
||
'<div class="f-empty">Nothing matches this filter.</div>';
|
||
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);
|
||
|
||
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 = '';
|
||
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">‎${esc(f.file)}‎</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">‎${esc(f)}‎</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('');
|
||
}
|
||
|
||
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 (/^>/.test(text)) {
|
||
// reflow, don't preserve the author's wrap column
|
||
out.push('<blockquote>' + inline(text.replace(/^>\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>
|