1984 lines
96 KiB
HTML
1984 lines
96 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}
|
||
|
||
/* ── 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))}
|
||
/* 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}
|
||
#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 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); }
|
||
function agentFor(sid) { return (S.state?.agents || []).find(a => a.session === sid); }
|
||
function agentOnTask(file) {
|
||
return (S.state?.agents || []).find(a => a.task === file && a.status === 'running');
|
||
}
|
||
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); }
|
||
|
||
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();
|
||
}
|
||
};
|
||
}
|
||
|
||
let renderQueued = false;
|
||
function scheduleRender() {
|
||
if (renderQueued) return;
|
||
renderQueued = true;
|
||
requestAnimationFrame(() => { renderQueued = false; render(); });
|
||
}
|
||
|
||
/* ── header ───────────────────────────────────────────────────────────── */
|
||
|
||
function renderChip() {
|
||
const agents = (S.state?.agents || []).filter(a => a.status === 'running');
|
||
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. */
|
||
const VIEW_TITLES = { board: 'bench', flight: 'sessions', focus: 'focus' };
|
||
|
||
function renderTitle() {
|
||
if (!S.state?.project) return;
|
||
document.title = S.state.project + ' · ' + (VIEW_TITLES[S.view] || 'bench');
|
||
}
|
||
|
||
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();
|
||
}
|
||
|
||
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;
|
||
el.className = 'card'
|
||
+ (S.selected && S.selected.file === task.file ? ' selected' : '')
|
||
+ (working ? ' running' : '')
|
||
+ (verdict === 'green' ? ' verdict-good' : verdict === 'red' ? ' verdict-bad' : '')
|
||
+ (task.stage === 'done' ? ' done-dim' : '');
|
||
el.draggable = true;
|
||
|
||
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)';
|
||
}
|
||
const high = (task.priority || '').toLowerCase() === 'high';
|
||
const top = [
|
||
`<span class="mark${working ? ' breathing' : ''}" style="background:${tint}"></span>`,
|
||
`<span class="ref">${task.number ? '#' + esc(task.number) : esc(task.file.slice(0, 10))}</span>`,
|
||
'<span class="spacer"></span>',
|
||
high ? '<span class="high">HIGH</span>' : '',
|
||
task.statusMismatch ? `<span class="pill drift" title="File says ${esc(task.declaredStatus)}">drift</span>` : '',
|
||
`<span class="pill status" style="background:${pill.bg};color:${pill.tint}">${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 (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 (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>`;
|
||
}
|
||
|
||
// 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 = [];
|
||
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 = '';
|
||
if (task.stage === 'review') {
|
||
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 && ['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>` : '';
|
||
if (c.href) return `<a class="chip2 ${c.cls}" href="${esc(c.href)}" target="_blank" rel="noopener" title="${esc(c.title)}">${esc(c.label)}${g}</a>`;
|
||
if (c.act) return `<button class="chip2 ${c.cls}" data-drive="${c.act}" title="${esc(c.title)}">${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)}">${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>` +
|
||
`<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-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 ticker narrates 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 pill = 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();
|
||
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>` +
|
||
`<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></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: the configured model, or
|
||
// the vendor default it inherited. Interactive sessions say nothing.
|
||
const model = agent ? ` · <span class="mono">${agent.model ? esc(agent.model) : '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></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>`);
|
||
if (agent && agent.branch) refBits.push('worktree ' + esc(agent.branch));
|
||
if (agent) refBits.push(agent.model ? esc(agent.model) : '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 — enough for these task files, 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>');
|
||
|
||
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 lines = block.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(block)) {
|
||
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 (/^\s*[-*]\s/.test(block)) {
|
||
out.push('<ul>' + lines.map(l => `<li>${inline(l.replace(/^\s*[-*]\s+/, ''))}</li>`).join('') + '</ul>');
|
||
} else if (/^\s*\d+\.\s/.test(block)) {
|
||
out.push('<ol>' + lines.map(l => `<li>${inline(l.replace(/^\s*\d+\.\s+/, ''))}</li>`).join('') + '</ol>');
|
||
} else if (/^>/.test(block)) {
|
||
out.push('<blockquote>' + inline(block.replace(/^>\s?/gm, '')).replace(/\n/g, '<br>') + '</blockquote>');
|
||
} else if (/^-{3,}$/.test(block.trim())) {
|
||
out.push('<hr>');
|
||
} else {
|
||
out.push(`<p>${inline(block).replace(/\n/g, '<br>')}</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>
|