- GET /api/logbook/progress-map: one compact document, cached until an input changes (main's HEAD, the Logbook symbol table and statistics, match/emulator/library results, facts, names, source files, globals, deferral notes); ?have=<version> answers {"same": true}
- definitions follow the Logbook's progress.py (pc102.csv category game, status decompiled or later), so the headline equals game_bytes_decompiled_percent; engine, library and unsorted code are shown but not counted
- history points now record the commit and the game-code bytes
- the page lays out once per load, filter or size change, hit-tests hover on the computed rectangles, builds the 600-entry units list only when opened, and polls once a minute only while the Logbook is visible
1629 lines
167 KiB
HTML
1629 lines
167 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<meta name="color-scheme" content="light dark">
|
||
<title>Harbor</title>
|
||
<script>try{var r=document.documentElement,t=JSON.parse(localStorage.getItem('harbor.theme')),p=JSON.parse(localStorage.getItem('harbor.palette'));if(t==='light'||t==='dark')r.dataset.theme=t;if(typeof p==='string'&&/^[a-z0-9-]{1,30}$/.test(p)&&p!=='harbor')r.dataset.palette=p}catch(e){}</script>
|
||
<link rel="preload" href="static/InterVariable.woff2" as="font" type="font/woff2" crossorigin>
|
||
<link rel="stylesheet" href="static/harbor.css">
|
||
<link rel="stylesheet" href="static/themes.css">
|
||
<style>
|
||
body { overflow: hidden; }
|
||
.shell { display: grid; grid-template-columns: 252px minmax(0, 1fr); height: 100dvh; }
|
||
|
||
/* sidebar */
|
||
.side {
|
||
display: flex; flex-direction: column; gap: 22px; padding: 18px 12px 14px; overflow-y: auto; z-index: 20;
|
||
border-right: 1px solid var(--line);
|
||
}
|
||
.brand { display: flex; align-items: center; gap: 11px; padding: 2px 8px; }
|
||
.logo {
|
||
width: 38px; height: 38px; border-radius: 12px; flex-shrink: 0; display: grid; place-items: center; color: var(--on-action);
|
||
background: linear-gradient(145deg, color-mix(in srgb, var(--action) 70%, #fff), var(--action) 55%, color-mix(in srgb, var(--action) 78%, #000));
|
||
box-shadow: 0 8px 18px -8px color-mix(in srgb, var(--action) 75%, transparent), inset 0 1px 0 rgba(255, 255, 255, .3);
|
||
}
|
||
.logo svg.i { width: 20px; height: 20px; stroke-width: 2; }
|
||
.brand-name { font: 700 14.5px/1.25 var(--font-display); letter-spacing: -.2px; overflow-wrap: anywhere; }
|
||
.brand-sub { font-size: 11.5px; color: var(--muted); }
|
||
.side select { width: 100%; }
|
||
.nav { display: flex; flex-direction: column; gap: 2px; }
|
||
.nav-label { font-size: 10.5px; letter-spacing: 1px; text-transform: uppercase; font-weight: 700; color: var(--faint); padding: 0 10px 6px; }
|
||
.nav-btn {
|
||
display: flex; align-items: center; gap: 11px; width: 100%; height: 36px; padding: 0 10px; border: 0; border-radius: 9px;
|
||
background: transparent; color: var(--text-2); font-size: 13px; font-weight: 600; text-align: left; transition: background .15s, color .15s;
|
||
}
|
||
.nav-btn:hover { background: var(--surface-3); color: var(--text); text-decoration: none; }
|
||
.nav-btn svg.i { color: var(--muted); }
|
||
.nav-btn[aria-current="page"] { background: var(--accent-soft); color: var(--accent-ink); }
|
||
.nav-btn[aria-current="page"] svg.i { color: var(--accent); }
|
||
.nav-btn .count-badge, .nav-btn .end { margin-left: auto; }
|
||
.side-foot { margin-top: auto; display: flex; flex-direction: column; gap: 12px; padding: 14px 6px 0; border-top: 1px solid var(--line); }
|
||
.side-foot .row { justify-content: space-between; flex-wrap: nowrap; }
|
||
.theme span { display: grid; place-items: center; padding: 5px 9px; }
|
||
|
||
/* main column */
|
||
.main { display: flex; flex-direction: column; min-width: 0; min-height: 0; }
|
||
.topbar {
|
||
display: flex; align-items: center; gap: 14px; padding: 18px 32px 16px; flex-shrink: 0;
|
||
border-bottom: 1px solid var(--line); background: var(--header);
|
||
}
|
||
.topbar h1 { margin: 0; font: 700 21px/1.2 var(--font-display); letter-spacing: -.5px; }
|
||
.topbar .sub { font-size: 12.5px; color: var(--muted); margin-top: 2px; }
|
||
#menuBtn, .scrim { display: none; }
|
||
.content { flex: 1; min-height: 0; overflow-y: auto; padding: 24px 32px 56px; }
|
||
.view { display: none; max-width: 1380px; margin: 0 auto; gap: 20px; }
|
||
.view.on { display: grid; }
|
||
.lb { --lb-game: var(--blue); --lb-engine: var(--violet); --lb-platform: var(--amber); --lb-crt: var(--muted); --lb-unsorted: var(--line-2); --lb-done: var(--green); }
|
||
/* progress map (decomp.dev style) */
|
||
.lb { --pm-exact: var(--green); --pm-verified: color-mix(in srgb, var(--green) 62%, var(--surface)); --pm-readable: color-mix(in srgb, color-mix(in srgb, var(--green) 52%, #d8c22e) 68%, var(--surface));
|
||
--pm-raw: var(--line-2); --pm-deferred: color-mix(in srgb, var(--line-2) 62%, var(--amber)); --pm-unsorted: color-mix(in srgb, var(--line-2) 50%, var(--surface-3));
|
||
--pm-engine: color-mix(in srgb, var(--blue) 30%, var(--line-2)); --pm-library: color-mix(in srgb, var(--blue) 22%, var(--line-2)); }
|
||
.pm-title { margin: 0 0 14px; font: 700 22px/1.25 var(--font-display); letter-spacing: -.4px; } .pm-title b { color: var(--green); font-variant-numeric: tabular-nums; }
|
||
.pm-bars { display: grid; gap: 12px; }
|
||
.pm-bar-l { display: flex; align-items: baseline; gap: 6px; font-size: 13px; margin-bottom: 5px; flex-wrap: wrap; } .pm-bar-l .grow { flex: 1; } .pm-bar-l .mono { font: 600 12.5px var(--mono); font-variant-numeric: tabular-nums; }
|
||
.pm-bar { display: flex; height: 14px; border-radius: 7px; overflow: hidden; background: var(--surface-3); }
|
||
.pm-seg { display: block; height: 100%; min-width: 2px; cursor: default; }
|
||
.pm-seg.pm-exact { background: var(--pm-exact); } .pm-seg.pm-verified { background: var(--pm-verified); }
|
||
.pm-seg.pm-readable { background: repeating-linear-gradient(135deg, var(--pm-readable) 0 4px, color-mix(in srgb, var(--pm-readable) 45%, var(--surface-3)) 4px 8px); }
|
||
.pm-seg.pm-named { background: var(--blue); }
|
||
.pm-commit { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 10px; margin-top: 12px; font-size: 13px; color: var(--muted); } .pm-commit b { color: var(--text); font-weight: 600; } .pm-commit .grow { flex: 1; }
|
||
.pm-tools { display: flex; flex-wrap: wrap; gap: 8px 10px; align-items: center; margin-top: 16px; } .pm-tools select { max-width: min(100%, 340px); } .pm-tools input { flex: 1 1 220px; min-width: 0; }
|
||
.pm-crumb { display: flex; flex-wrap: wrap; gap: 6px 10px; align-items: center; margin-top: 12px; font-size: 13px; } .pm-crumb:empty { display: none; } .pm-crumb .pm-bar-row { flex: 1 1 100%; }
|
||
.pm-wrap { position: relative; margin-top: 12px; } canvas#pmMap { width: 100%; height: 560px; display: block; border-radius: 9px; cursor: pointer; }
|
||
.pm-tip { position: fixed; z-index: 50; pointer-events: none; max-width: 340px; padding: 8px 10px; border-radius: 8px; background: var(--surface); color: var(--text); border: 1px solid var(--line-2); box-shadow: var(--shadow-3); font-size: 12.5px; line-height: 1.45; }
|
||
.pm-tip .mono { font-family: var(--mono); font-size: 11.5px; color: var(--muted); } .pm-tip b { overflow-wrap: anywhere; }
|
||
@media (max-width: 700px) { canvas#pmMap { height: 380px; } .pm-title { font-size: 19px; } }
|
||
.lb-head { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: 10px 20px; }
|
||
.lb-head .chip { display: inline-block; font: 600 11.5px/1 var(--font, inherit); letter-spacing: .06em; text-transform: uppercase; color: var(--accent-ink); background: var(--accent-soft); border-radius: 999px; padding: 6px 10px; }
|
||
.lb-head p { margin: 8px 0 0; color: var(--muted); max-width: 70ch; }
|
||
.lb-stamp { font: 12px/1.5 var(--mono); color: var(--muted); text-align: right; } .lb-stamp b { color: var(--text); font-weight: 600; }
|
||
.lb-figs { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 12px; }
|
||
.lb-fig { background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-2, 12px); padding: 14px 16px; }
|
||
.lb-fig .v { font: 700 26px/1.1 var(--mono); font-variant-numeric: tabular-nums; } .lb-fig .l { color: var(--muted); font-size: 13px; margin-top: 4px; } .lb-fig .n { color: var(--faint); font: 11.5px/1.35 var(--mono); margin-top: 6px; }
|
||
canvas#lbMap { width: 100%; height: 150px; display: block; }
|
||
.lb-axis { display: flex; justify-content: space-between; font: 11.5px var(--mono); color: var(--faint); margin-top: 6px; }
|
||
.lb-legend { display: flex; flex-wrap: wrap; gap: 6px 16px; margin-top: 12px; font-size: 12.5px; color: var(--muted); }
|
||
.lb-sw { display: inline-block; width: 10px; height: 10px; border-radius: 3px; margin-right: 6px; vertical-align: -1px; }
|
||
.lb-stack { display: flex; height: 24px; border-radius: 7px; overflow: hidden; background: var(--surface-3); gap: 2px; margin-top: 10px; }
|
||
.lb-stack i { display: block; height: 100%; min-width: 2px; }
|
||
.lb-hatch { background: repeating-linear-gradient(135deg, var(--surface-3) 0 5px, var(--line-2) 5px 10px) !important; }
|
||
.lb-cat { display: grid; grid-template-columns: 14px minmax(0, 1fr) auto; gap: 2px 10px; padding: 10px 0; border-top: 1px solid var(--line); align-items: start; }
|
||
.lb-cat .sw { width: 12px; height: 12px; border-radius: 3px; margin-top: 4px; } .lb-cat .plan { color: var(--muted); font-size: 12.5px; }
|
||
.lb-cat .nums { text-align: right; font: 12.5px/1.45 var(--mono); font-variant-numeric: tabular-nums; white-space: nowrap; color: var(--muted); } .lb-cat .nums b { color: var(--text); }
|
||
.lb-cat .sub { grid-column: 2 / 4; margin-top: 8px; }
|
||
.lb-big { font: 700 26px/1.1 var(--mono); font-variant-numeric: tabular-nums; }
|
||
svg#lbTrend { width: 100%; height: 110px; display: block; }
|
||
.lb-ms h3 { font-size: 14px; margin: 0 0 6px; display: flex; gap: 10px; align-items: baseline; flex-wrap: wrap; } .lb-ms h3 .code { font-family: var(--mono); color: var(--accent-ink); }
|
||
.lb-ms { padding: 10px 0; border-top: 1px solid var(--line); } .lb-ms:first-child { border-top: 0; }
|
||
.lb-ms ul { list-style: none; margin: 6px 0 0; padding: 0; display: grid; gap: 4px; } .lb-ms li { display: grid; grid-template-columns: 16px 1fr; gap: 6px; font-size: 13px; color: var(--muted); }
|
||
.lb-ms li::before { content: "○"; } .lb-ms li.done { color: var(--text); } .lb-ms li.done::before { content: "●"; color: var(--green); }
|
||
.lb-phase { font-weight: 700; color: var(--accent-ink); margin-top: 10px; font-size: 13px; letter-spacing: .04em; text-transform: uppercase; }
|
||
.lb-log { list-style: none; margin: 0; padding: 0; } .lb-log li { display: grid; grid-template-columns: 104px minmax(0, 1fr); gap: 12px; padding: 12px 0; border-top: 1px solid var(--line); }
|
||
.lb-log li:first-child { border-top: 0; } .lb-log time { font: 11.5px/1.5 var(--mono); color: var(--faint); }
|
||
.lb-log .kind { font: 700 10px/1 inherit; letter-spacing: .08em; text-transform: uppercase; color: var(--accent-ink); margin-right: 8px; }
|
||
.lb-log .t { font-weight: 600; } .lb-log .d { color: var(--muted); font-size: 13px; white-space: pre-wrap; overflow-wrap: anywhere; margin-top: 4px; }
|
||
@media (max-width: 560px) { .lb-log li { grid-template-columns: 1fr; gap: 2px; } .lb-stamp { text-align: left; } }
|
||
.tierbar { display: flex; height: 22px; border-radius: 7px; overflow: hidden; background: var(--surface-3); gap: 2px; }
|
||
.tierbar span { display: block; height: 100%; min-width: 3px; }
|
||
.tier-exact { background: var(--green); } .tier-verified { background: var(--blue); } .tier-readable { background: var(--violet); }
|
||
.tier-deferred { background: var(--amber); } .tier-raw { background: var(--line-2); }
|
||
.tier-library, .tier-engine { background: repeating-linear-gradient(45deg, var(--surface-3) 0 4px, var(--line-2) 4px 8px); }
|
||
.tier-key { display: inline-block; width: 10px; height: 10px; border-radius: 3px; margin-right: 8px; vertical-align: -1px; }
|
||
td.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||
.fn-head { display: flex; flex-wrap: wrap; gap: 8px 12px; align-items: center; margin-top: 16px; }
|
||
.fn-head h3 { margin: 0; font-size: 16px; }
|
||
.fn-facts { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 7px 16px; font-size: 13px; margin: 14px 0 0; }
|
||
.fn-facts dt { color: var(--muted); } .fn-facts dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
|
||
.disasm { white-space: pre; font: 11.5px/1.5 var(--mono); max-height: 440px; overflow: auto; padding: 10px 12px; border-radius: 9px; background: var(--surface-3); margin: 8px 0 0; }
|
||
.band { padding: 12px 18px; border-top: 1px solid var(--line); } .band:first-child { border-top: 0; }
|
||
.band .cell-who { gap: 8px; } .band ul { margin: 8px 0 0; }
|
||
.cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 460px), 1fr)); gap: 20px; align-items: start; }
|
||
.sub-h { font-size: 11px; letter-spacing: .8px; text-transform: uppercase; color: var(--faint); font-weight: 700; margin: 20px 0 8px; }
|
||
.flush th:first-child, .flush td:first-child { padding-left: 18px; }
|
||
.flush th:last-child, .flush td:last-child { padding-right: 18px; }
|
||
.flush > .note { padding: 10px 18px 14px; }
|
||
|
||
/* work control */
|
||
.hero {
|
||
--tone: var(--green); --tone-soft: var(--green-soft);
|
||
position: relative; overflow: hidden; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 18px; align-items: center;
|
||
padding: 20px 22px; border-radius: var(--r-4); border: 1px solid var(--line); background: var(--surface); box-shadow: var(--shadow-2);
|
||
animation: rise .35s var(--ease) both;
|
||
}
|
||
.hero::before { content: ""; position: absolute; inset: 0; pointer-events: none; background: radial-gradient(640px 220px at 0% 0%, var(--tone-soft), transparent 70%); }
|
||
.hero > * { position: relative; }
|
||
.hero.partial { --tone: var(--amber); --tone-soft: var(--amber-soft); }
|
||
.hero.stopped { --tone: var(--red); --tone-soft: var(--red-soft); }
|
||
.hero-icon { width: 54px; height: 54px; border-radius: 17px; display: grid; place-items: center; background: var(--tone-soft); color: var(--tone); }
|
||
.hero-icon svg.i { width: 24px; height: 24px; stroke-width: 2; }
|
||
.eyebrow { font-size: 11px; font-weight: 750; letter-spacing: 1px; text-transform: uppercase; color: var(--tone); }
|
||
.hero h2 { margin: 2px 0 3px; font: 700 21px/1.25 var(--font-display); letter-spacing: -.45px; }
|
||
.hero-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; justify-content: flex-end; }
|
||
.hero-actions input { width: 250px; height: 34px; }
|
||
|
||
/* stat tiles */
|
||
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(178px, 1fr)); gap: 14px; }
|
||
.stat {
|
||
--tone: var(--accent); --tone-soft: var(--accent-soft);
|
||
display: flex; align-items: center; gap: 13px; text-align: left; padding: 14px 16px; border-radius: var(--r-3);
|
||
border: 1px solid var(--line); background: var(--surface); box-shadow: var(--shadow-1); color: var(--text);
|
||
transition: transform .18s var(--ease), box-shadow .18s, border-color .18s;
|
||
}
|
||
.stat:hover { transform: translateY(-2px); box-shadow: var(--shadow-2); border-color: var(--line-2); }
|
||
.stat-icon { width: 42px; height: 42px; border-radius: 13px; display: grid; place-items: center; flex-shrink: 0; background: var(--tone-soft); color: var(--tone); }
|
||
.stat-icon svg.i { width: 19px; height: 19px; }
|
||
.stat-v { display: block; font: 700 23px/1.1 var(--font-display); letter-spacing: -.6px; font-variant-numeric: tabular-nums; }
|
||
.stat-l { display: block; font-size: 12px; font-weight: 650; color: var(--text-2); margin-top: 2px; }
|
||
.stat-s { display: block; font-size: 11.5px; color: var(--muted); }
|
||
.stat.green, .hero .green { --tone: var(--green); --tone-soft: var(--green-soft); }
|
||
.stat.red { --tone: var(--red); --tone-soft: var(--red-soft); }
|
||
.stat.amber { --tone: var(--amber); --tone-soft: var(--amber-soft); }
|
||
.stat.violet { --tone: var(--violet); --tone-soft: var(--violet-soft); }
|
||
.stat.blue { --tone: var(--blue); --tone-soft: var(--blue-soft); }
|
||
.stat.quiet { --tone: var(--muted); --tone-soft: var(--surface-3); }
|
||
|
||
/* questions and attention */
|
||
.card.ask { border-color: color-mix(in srgb, var(--accent) 40%, var(--line)); box-shadow: 0 0 0 4px var(--accent-soft), var(--shadow-2); }
|
||
.card.warn > header h2 svg.i { color: var(--amber); }
|
||
.card.ask > header h2 svg.i { color: var(--accent); }
|
||
.qitem { display: flex; gap: 12px; padding: 14px 0; border-top: 1px solid var(--line); }
|
||
.qitem:first-child { border-top: 0; padding-top: 0; }
|
||
.qitem:last-child { padding-bottom: 0; }
|
||
.qitem .who { font-size: 12px; color: var(--muted); }
|
||
.qitem .qt { font-size: 14px; font-weight: 550; margin: 2px 0 10px; overflow-wrap: anywhere; }
|
||
.attn { list-style: none; margin: 0; padding: 0; }
|
||
.attn li { display: flex; gap: 10px; align-items: baseline; padding: 9px 0; border-top: 1px solid var(--line); font-size: 13px; overflow-wrap: anywhere; }
|
||
.attn li:first-child { border-top: 0; padding-top: 0; }
|
||
.attn li:last-child { padding-bottom: 0; }
|
||
.attn .tag { flex-shrink: 0; }
|
||
|
||
/* crew */
|
||
.crew { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 290px), 1fr)); gap: 14px; }
|
||
.agent {
|
||
display: flex; flex-direction: column; gap: 11px; padding: 16px; min-width: 0; border-radius: var(--r-3);
|
||
border: 1px solid var(--line); background: var(--surface-2); transition: border-color .2s, box-shadow .2s;
|
||
}
|
||
.agent:hover { border-color: var(--line-2); box-shadow: var(--shadow-1); }
|
||
.agent.working { border-color: color-mix(in srgb, var(--green) 45%, var(--line)); background: linear-gradient(180deg, color-mix(in srgb, var(--green-soft) 70%, var(--surface-2)), var(--surface-2) 60%); }
|
||
.agent-top { display: flex; gap: 12px; align-items: center; }
|
||
.agent .name { font: 650 14.5px/1.25 var(--font-display); }
|
||
.agent .role { font-size: 12px; color: var(--muted); }
|
||
.agent-now { display: flex; flex-direction: column; gap: 3px; }
|
||
.doing { font-size: 12.5px; color: var(--text-2); overflow-wrap: anywhere; }
|
||
.doing .when { color: var(--faint); }
|
||
.gitline {
|
||
display: flex; flex-wrap: wrap; align-items: center; gap: 3px 9px; padding: 7px 10px; border-radius: 9px;
|
||
font: 11.5px/1.4 var(--mono); color: var(--muted); background: var(--surface-3); overflow-wrap: anywhere;
|
||
}
|
||
.gitline b { color: var(--text); font-weight: 600; }
|
||
.gitwarn { color: var(--amber); }
|
||
.links { display: flex; flex-wrap: wrap; gap: 5px; }
|
||
.handoff summary { cursor: pointer; font-size: 12px; color: var(--muted); }
|
||
.handoff summary:hover { color: var(--text); }
|
||
.handoff pre { white-space: pre-wrap; font: 11.5px/1.5 var(--mono); margin: 6px 0 0; max-height: 220px; overflow: auto; padding: 10px; border-radius: 9px; background: var(--surface-3); }
|
||
.agent-actions { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; margin-top: auto; padding-top: 2px; }
|
||
.agent-actions .tag { margin-left: auto; }
|
||
|
||
/* launch */
|
||
.stepper { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 14px; }
|
||
.stepper li { display: flex; gap: 12px; align-items: flex-start; padding: 15px 16px; border-radius: var(--r-3); background: var(--surface); border: 1px solid var(--line); box-shadow: var(--shadow-1); }
|
||
.stepper .n { width: 30px; height: 30px; border-radius: 10px; display: grid; place-items: center; font-weight: 750; font-size: 13px; background: var(--accent-soft); color: var(--accent-ink); flex-shrink: 0; }
|
||
.stepper b { display: block; font-size: 13px; }
|
||
.stepper .d { font-size: 12px; color: var(--muted); }
|
||
.launch td { vertical-align: middle; }
|
||
.launch .who { display: flex; align-items: center; gap: 10px; }
|
||
.step { display: inline-flex; align-items: center; gap: 7px; font-size: 12.5px; white-space: nowrap; color: var(--muted); }
|
||
.step i { width: 20px; height: 20px; border-radius: 50%; display: inline-grid; place-items: center; font-style: normal; font-size: 11px; font-weight: 800; background: var(--surface-3); color: var(--faint); }
|
||
.step.done { color: var(--text); }
|
||
.step.done i { background: var(--green); color: var(--on-green); box-shadow: 0 0 0 3px var(--green-soft); }
|
||
.step.wait { color: var(--amber); }
|
||
.step.wait i { background: var(--amber-soft); color: var(--amber); }
|
||
.acts { display: flex; gap: 6px; flex-wrap: wrap; justify-content: flex-end; align-items: center; }
|
||
.acts select { height: 28px; padding: 0 24px 0 9px; font-size: 12px; }
|
||
|
||
/* tasks */
|
||
.taskform { display: grid; grid-template-columns: minmax(0, 2.4fr) repeat(4, minmax(0, 1fr)) auto; gap: 10px; }
|
||
.taskform input, .taskform select { height: 36px; }
|
||
.taskform .btn { height: 36px; }
|
||
.bulk { margin-top: 16px; padding-top: 14px; border-top: 1px dashed var(--line-2); }
|
||
.bulk summary { cursor: pointer; font-size: 12.5px; font-weight: 600; color: var(--accent); display: inline-flex; align-items: center; gap: 7px; }
|
||
.bulk summary::-webkit-details-marker { display: none; }
|
||
.bulk summary::marker { content: ""; }
|
||
.bulk textarea { min-height: 130px; font: 12.5px/1.55 var(--mono); margin-top: 10px; }
|
||
.prio { display: inline-flex; align-items: center; gap: 7px; font-size: 12.5px; text-transform: capitalize; white-space: nowrap; }
|
||
.prio::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: var(--faint); }
|
||
.prio-urgent { color: var(--red); font-weight: 700; } .prio-urgent::before { background: var(--red); box-shadow: 0 0 0 3px var(--red-soft); }
|
||
.prio-high { color: var(--amber); font-weight: 650; } .prio-high::before { background: var(--amber); }
|
||
.prio-normal::before { background: var(--blue); }
|
||
.prio-low { color: var(--muted); } .prio-low::before { background: var(--line-2); }
|
||
.cell-who { display: flex; align-items: center; gap: 8px; white-space: nowrap; }
|
||
|
||
/* activity */
|
||
.feed { list-style: none; margin: 0; padding: 0; max-height: 460px; overflow: auto; font-size: 12.5px; }
|
||
.feed li { display: grid; grid-template-columns: 52px 120px 110px minmax(0, 1fr); gap: 10px; align-items: center; padding: 7px 2px; border-top: 1px solid var(--line); }
|
||
.feed li:first-child { border-top: 0; }
|
||
.feed li.empty { display: grid; grid-template-columns: 1fr; }
|
||
.feed .t { color: var(--faint); font-variant-numeric: tabular-nums; font-size: 11.5px; }
|
||
.feed .who { display: flex; align-items: center; gap: 8px; font-weight: 600; min-width: 0; }
|
||
.feed .tg { font: 11.5px var(--mono); color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.pulse-row, .pulse-axis { display: grid; grid-template-columns: 120px minmax(0, 1fr) 96px; gap: 14px; align-items: center; }
|
||
.pulse-row { margin: 7px 0; font-size: 12.5px; }
|
||
.pulse-row .who { display: flex; align-items: center; gap: 8px; font-weight: 600; }
|
||
.pulse-row .bar { height: 24px; border-radius: 7px; overflow: hidden; background: var(--surface-3); }
|
||
.pulse-row svg { width: 100%; height: 100%; display: block; }
|
||
.pulse-axis { font-size: 11px; color: var(--faint); margin-top: 8px; }
|
||
.pulse-axis div:nth-child(2) { display: flex; justify-content: space-between; }
|
||
.metric { display: grid; grid-template-columns: minmax(0, 1fr) 130px 64px; gap: 12px; align-items: center; padding: 12px 0; border-top: 1px solid var(--line); }
|
||
.metric:first-child { border-top: 0; padding-top: 0; }
|
||
.metric .v { font: 700 25px/1.1 var(--font-display); letter-spacing: -.6px; font-variant-numeric: tabular-nums; }
|
||
.metric .d { font-size: 12px; font-weight: 700; color: var(--green); text-align: right; }
|
||
.metric .d.neg { color: var(--red); }
|
||
.spark { width: 130px; height: 34px; display: block; }
|
||
|
||
/* knowledge */
|
||
.searchbar { display: flex; gap: 10px; }
|
||
.searchbar .field { position: relative; flex: 1; min-width: 0; }
|
||
.searchbar .field svg.i { position: absolute; left: 13px; top: 50%; transform: translateY(-50%); color: var(--faint); }
|
||
.searchbar input { width: 100%; height: 44px; padding-left: 40px; font-size: 14px; border-radius: var(--r-2); }
|
||
.searchbar .btn { height: 44px; padding: 0 18px; }
|
||
#results:not(:empty) { margin-top: 16px; }
|
||
.msgs { display: flex; flex-direction: column; gap: 10px; }
|
||
#pins { max-height: 560px; overflow: auto; }
|
||
#threadMsgs { max-height: 54vh; overflow: auto; padding-right: 4px; }
|
||
.msg { display: flex; gap: 11px; padding: 12px 14px; border: 1px solid var(--line); border-radius: var(--r-2); background: var(--surface-2); }
|
||
.msg.urgent { border-color: color-mix(in srgb, var(--red) 50%, var(--line)); background: color-mix(in srgb, var(--red-soft) 55%, var(--surface-2)); }
|
||
.msg-main { flex: 1; min-width: 0; }
|
||
.msg .h { font-size: 12px; color: var(--muted); display: flex; gap: 6px; flex-wrap: wrap; align-items: center; margin-bottom: 4px; }
|
||
.msg .h strong { color: var(--text); }
|
||
.msg .b { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 13px; }
|
||
.threads { list-style: none; margin: 0; padding: 0; max-height: 560px; overflow: auto; }
|
||
.threads li { display: flex; gap: 11px; align-items: center; padding: 10px; border-radius: 10px; cursor: pointer; transition: background .15s; }
|
||
.threads li:hover { background: var(--surface-2); }
|
||
.threads li > svg.i { color: var(--faint); }
|
||
.threads .t { flex: 1; min-width: 0; overflow-wrap: anywhere; font-size: 13px; }
|
||
.threads .s { font-size: 12px; color: var(--muted); white-space: nowrap; }
|
||
.ctx { white-space: pre-wrap; font: 12px/1.55 var(--mono); background: var(--surface-2); border: 1px solid var(--line); border-radius: var(--r-2); padding: 12px 14px; }
|
||
.summary { background: var(--accent-soft); border-radius: var(--r-2); padding: 12px 14px; font-size: 13px; white-space: pre-wrap; }
|
||
|
||
/* dialogs */
|
||
#promptText { min-height: 50vh; font: 12px/1.5 var(--mono); }
|
||
.radio-row { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
|
||
.field-label { display: block; font-size: 12px; font-weight: 650; color: var(--text-2); margin: 14px 0 6px; }
|
||
dialog .body input[type=text], dialog .body select { width: 100%; }
|
||
|
||
@media (max-width: 1100px) {
|
||
.taskform { grid-template-columns: 1fr 1fr; }
|
||
.taskform #tTitle { grid-column: 1 / -1; }
|
||
}
|
||
@media (max-width: 900px) {
|
||
.shell { grid-template-columns: 1fr; }
|
||
.side { position: fixed; inset: 0 auto 0 0; width: min(284px, 86vw); transform: translateX(-104%); transition: transform .26s var(--ease); background: var(--side-solid); box-shadow: var(--shadow-3); }
|
||
.shell.open .side { transform: none; }
|
||
.scrim { display: block; position: fixed; inset: 0; z-index: 15; background: rgba(8, 12, 20, .45); opacity: 0; pointer-events: none; transition: opacity .2s; }
|
||
.shell.open .scrim { opacity: 1; pointer-events: auto; }
|
||
#menuBtn { display: inline-flex; }
|
||
.topbar { padding: 12px 16px; gap: 10px; }
|
||
.topbar .sub { display: none; }
|
||
.topbar h1 { font-size: 18px; }
|
||
.content { padding: 16px 16px 40px; }
|
||
.hero { grid-template-columns: auto minmax(0, 1fr); padding: 18px; }
|
||
.hero-actions { grid-column: 1 / -1; justify-content: stretch; }
|
||
.hero-actions input { flex: 1 1 100%; width: auto; }
|
||
.hero-actions .btn { flex: 1; }
|
||
.feed li { grid-template-columns: 46px minmax(0, 1fr); }
|
||
.feed .tool, .feed .tg { grid-column: 2; }
|
||
.pulse-row, .pulse-axis { grid-template-columns: 92px minmax(0, 1fr); }
|
||
.pulse-row > :last-child, .pulse-axis > :last-child { display: none; }
|
||
}
|
||
@media (max-width: 560px) {
|
||
.stats { grid-template-columns: 1fr 1fr; gap: 10px; }
|
||
.stat { padding: 12px; gap: 10px; }
|
||
.stat-icon { width: 34px; height: 34px; border-radius: 11px; }
|
||
.stat-s { display: none; }
|
||
.searchbar { flex-direction: column; }
|
||
.metric { grid-template-columns: minmax(0, 1fr) 90px 52px; }
|
||
.spark { width: 90px; }
|
||
}
|
||
|
||
/* top bar: the same in every section */
|
||
.view-icon { width: 40px; height: 40px; border-radius: 13px; display: grid; place-items: center; flex-shrink: 0; background: var(--accent-soft); color: var(--accent); }
|
||
.view-icon svg.i { width: 20px; height: 20px; }
|
||
.view-icon.is-face { background: transparent; }
|
||
.view-icon .avatar { width: 40px; height: 40px; border-radius: 13px; }
|
||
.topbar .title { min-width: 0; }
|
||
.topbar h1, .topbar .sub { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.top-actions { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
|
||
.top-actions .avail { margin-left: 4px; }
|
||
|
||
/* chat: the log and composer on the left, the people on the right */
|
||
.content.flat { padding: 0; overflow: hidden; }
|
||
#view-chat { max-width: none; height: 100%; gap: 0; grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr); }
|
||
.chat-main { display: flex; flex-direction: column; min-width: 0; min-height: 0; }
|
||
|
||
/* the chat's people, in the sidebar under the board's sections */
|
||
.person {
|
||
display: flex; align-items: center; gap: 10px; width: 100%; padding: 6px 10px; border: 0; border-radius: 9px;
|
||
background: transparent; color: var(--text-2); text-align: left; transition: background .15s;
|
||
}
|
||
.person:hover { background: var(--surface-3); }
|
||
.person[aria-current="page"] { background: var(--accent-soft); }
|
||
.person[aria-current="page"] .p-name { color: var(--accent-ink); }
|
||
.p-copy { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||
.p-name, .p-sub, .p-doing { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.p-name { font-size: 13px; font-weight: 600; color: var(--text); }
|
||
.p-sub, .p-doing { font-size: 11.5px; color: var(--muted); }
|
||
.p-doing { color: var(--text-2); opacity: .8; }
|
||
.p-work { color: var(--green); font-weight: 650; }
|
||
.p-work::before { content: ""; display: inline-block; width: 6px; height: 6px; margin-right: 5px; vertical-align: 1px; border-radius: 50%; background: currentColor; animation: pulse 1.4s infinite; }
|
||
.person .count-badge { margin-left: auto; }
|
||
.everyone { --avatar: var(--action); color: var(--on-action); }
|
||
.stopbar, .threadbar { display: none; align-items: center; gap: 10px; padding: 9px 28px; font-size: 12.5px; flex-shrink: 0; }
|
||
.stopbar { color: var(--red-ink); font-weight: 650; background: var(--red-soft); border-bottom: 1px solid color-mix(in srgb, var(--red) 22%, transparent); }
|
||
.stopbar button { margin-left: auto; border: 0; background: none; padding: 0; color: inherit; font: inherit; text-decoration: underline; white-space: nowrap; }
|
||
.threadbar { color: var(--accent-ink); background: var(--accent-soft); border-bottom: 1px solid var(--line); }
|
||
.threadbar .grow { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.stopbar.on, .threadbar.on { display: flex; }
|
||
|
||
/* the log */
|
||
.log-wrap { flex: 1; min-height: 0; position: relative; display: flex; flex-direction: column; }
|
||
.log { position: relative; flex: 1; min-height: 0; overflow: auto; padding: 8px 32px 28px; overscroll-behavior: contain; }
|
||
.log > * { max-width: 1040px; margin-left: auto; margin-right: auto; }
|
||
/* rows out of view are not laid out or painted (the browser keeps their last size) */
|
||
.log > .mrow { content-visibility: auto; contain-intrinsic-size: auto 90px; }
|
||
.date { display: flex; align-items: center; gap: 14px; margin-top: 26px; margin-bottom: 8px; font-size: 11px; font-weight: 650; color: var(--muted); }
|
||
.date::before, .date::after { content: ""; flex: 1; height: 1px; background: var(--line); }
|
||
.date span { padding: 3px 11px; border-radius: 999px; background: var(--surface); border: 1px solid var(--line); box-shadow: var(--shadow-1); }
|
||
.mrow { display: flex; align-items: flex-end; gap: 10px; margin-top: 16px; }
|
||
.mrow.own { flex-direction: row-reverse; }
|
||
.mrow.cont { margin-top: 3px; }
|
||
.mrow.cont .avatar { visibility: hidden; }
|
||
.mrow .avatar { width: 32px; height: 32px; border-radius: 11px; font-size: 10.5px; }
|
||
.stack { max-width: min(720px, 78%); min-width: 0; display: flex; flex-direction: column; align-items: flex-start; }
|
||
.own .stack { align-items: flex-end; }
|
||
.meta { font-size: 11.5px; color: var(--muted); padding: 0 4px 5px; display: flex; flex-wrap: wrap; align-items: center; gap: 4px; }
|
||
.meta strong { color: var(--text); font-weight: 650; }
|
||
.meta .arrow { color: var(--faint); }
|
||
.mrow.cont .meta { display: none; }
|
||
.bubble {
|
||
background: var(--surface); border: 1px solid var(--line); border-radius: 18px 18px 18px 6px; padding: 10px 15px;
|
||
white-space: pre-wrap; overflow-wrap: anywhere; font-size: 13.5px; line-height: 1.6; box-shadow: var(--shadow-1);
|
||
}
|
||
.mrow.cont .bubble { border-top-left-radius: 6px; }
|
||
.own .bubble { background: linear-gradient(160deg, var(--action), var(--action-2)); border-color: transparent; color: var(--on-action); border-radius: 18px 18px 6px 18px; box-shadow: 0 8px 18px -12px var(--action); }
|
||
.own.cont .bubble { border-top-right-radius: 6px; }
|
||
.bubble.urgent { border-color: var(--red); box-shadow: inset 0 0 0 1px var(--red), 0 6px 18px -10px var(--red); }
|
||
.own .bubble.urgent { box-shadow: 0 0 0 2px var(--red); }
|
||
.bubble code { font: 12px/1.6 var(--mono); background: var(--surface-3); padding: 1px 5px; border-radius: 5px; }
|
||
.own .bubble code { background: color-mix(in srgb, var(--on-action) 18%, transparent); }
|
||
.bubble a { color: inherit; text-decoration: underline; }
|
||
.files { margin-top: 8px; display: flex; flex-wrap: wrap; gap: 6px; white-space: normal; }
|
||
.files a { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; border: 1px solid var(--line); border-radius: 8px; padding: 3px 9px; background: var(--surface-2); color: var(--accent); text-decoration: none; }
|
||
.own .files a { background: color-mix(in srgb, var(--on-action) 15%, transparent); border-color: color-mix(in srgb, var(--on-action) 25%, transparent); color: var(--on-action); }
|
||
.foot { color: var(--faint); font-size: 11px; padding: 5px 4px 0; display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||
.own .foot { justify-content: flex-end; }
|
||
.ackbtn { font-size: 11.5px; font-weight: 650; border: 1px solid var(--accent); color: var(--accent-ink); background: var(--accent-soft); border-radius: 7px; padding: 2px 10px; }
|
||
.ackbtn:hover { background: var(--action); color: var(--on-action); }
|
||
.linkbtn { font-size: 11px; border: 0; background: transparent; color: var(--muted); padding: 0; cursor: pointer; opacity: 0; transition: opacity .15s; }
|
||
.linkbtn:hover { color: var(--accent); }
|
||
.mrow:hover .linkbtn, .linkbtn:focus-visible { opacity: 1; }
|
||
@media (hover: none) { .linkbtn { opacity: 1; } }
|
||
.chip { display: inline-flex; align-items: center; height: 19px; font-size: 10.5px; font-weight: 650; padding: 0 8px; border-radius: 999px; background: var(--surface-3); color: var(--muted); border: 0; margin-left: 2px; }
|
||
button.chip { cursor: pointer; }
|
||
button.chip:hover { color: var(--accent-ink); background: var(--accent-soft); }
|
||
.chip.urgent, .chip.kind-stop { background: var(--red-soft); color: var(--red-ink); }
|
||
.chip.high, .chip.pin { background: var(--amber-soft); color: var(--amber-ink); }
|
||
.chip.kind-resume, .chip.kind-answer { background: var(--green-soft); color: var(--green-ink); }
|
||
.chip.kind-finding, .chip.kind-retraction, .chip.fixed, .chip.kind-decision { background: var(--violet-soft); color: var(--violet-ink); }
|
||
.chip.kind-question, .chip.kind-handoff { background: var(--blue-soft); color: var(--blue-ink); }
|
||
.qbox { margin-top: 10px; display: flex; flex-wrap: wrap; gap: 6px; align-items: center; white-space: normal; }
|
||
.qbtn { font-size: 12.5px; font-weight: 650; border: 1px solid var(--accent); color: var(--accent-ink); background: var(--surface); border-radius: 9px; padding: 5px 13px; transition: background .15s, color .15s; }
|
||
.qbtn:hover { background: var(--action); color: var(--on-action); }
|
||
.own .qbtn { color: var(--on-action); border-color: color-mix(in srgb, var(--on-action) 50%, transparent); background: color-mix(in srgb, var(--on-action) 12%, transparent); }
|
||
.answered { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 650; color: var(--green-ink); }
|
||
.answered.waiting { color: var(--muted); }
|
||
.own .answered { color: var(--on-action); }
|
||
.earlier { display: flex; align-items: center; gap: 6px; width: fit-content; margin: 10px auto 4px; font-size: 12px; font-weight: 600; color: var(--accent); background: var(--surface); border: 1px solid var(--line); border-radius: 999px; padding: 6px 14px; box-shadow: var(--shadow-1); }
|
||
.earlier:hover { border-color: var(--accent); }
|
||
.log > .empty { margin-top: 12vh; }
|
||
.log > .empty svg.i { width: 44px; height: 44px; padding: 10px; border-radius: 16px; background: var(--accent-soft); color: var(--accent); stroke-width: 1.6; }
|
||
.new-messages {
|
||
position: absolute; bottom: 16px; left: 50%; transform: translateX(-50%); z-index: 2; display: inline-flex; align-items: center; gap: 6px;
|
||
border: 0; background: var(--action); color: var(--on-action); font-size: 12.5px; font-weight: 650; border-radius: 999px; padding: 8px 16px; box-shadow: var(--shadow-3);
|
||
animation: rise .25s var(--ease);
|
||
}
|
||
|
||
/* composer */
|
||
.composer-area { flex-shrink: 0; padding: 12px 28px 12px; border-top: 1px solid var(--line); background: color-mix(in srgb, var(--surface) 60%, transparent); }
|
||
.composer { max-width: 1040px; margin: 0 auto; background: var(--surface); border: 1px solid var(--line-2); border-radius: 18px; box-shadow: var(--shadow-2); transition: border-color .15s, box-shadow .15s; }
|
||
.composer:focus-within { border-color: var(--accent); box-shadow: var(--ring), var(--shadow-2); }
|
||
.recipient { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 10px 12px 0 14px; font-size: 12px; color: var(--muted); }
|
||
.recipient select { height: 28px; padding: 0 26px 0 11px; border-radius: 999px; border-color: transparent; background-color: var(--accent-soft); color: var(--accent-ink); font-weight: 650; font-size: 12px; max-width: 200px; }
|
||
.toggle-chip span svg.i { width: 13px; height: 13px; }
|
||
.quick { display: flex; gap: 6px; margin-left: auto; flex-wrap: wrap; }
|
||
.quick button { display: inline-flex; align-items: center; gap: 5px; height: 26px; padding: 0 10px; font-size: 11.5px; font-weight: 600; border: 1px solid var(--line); background: transparent; color: var(--muted); border-radius: 999px; transition: all .15s; }
|
||
.quick button:hover { color: var(--accent-ink); border-color: transparent; background: var(--accent-soft); }
|
||
.quick svg.i { width: 13px; height: 13px; }
|
||
.input-row { display: flex; align-items: flex-end; gap: 8px; padding: 6px 10px 10px 16px; }
|
||
#text, #text:focus { border: 0; background: transparent; box-shadow: none; outline: none; resize: none; padding: 7px 0; flex: 1; width: auto; min-width: 0; min-height: 36px; max-height: 180px; font-size: 14px; line-height: 1.5; }
|
||
.mic, .send { width: 38px; height: 38px; border-radius: 12px; display: grid; place-items: center; flex-shrink: 0; transition: background .15s, transform .08s, color .15s; }
|
||
.mic { border: 1px solid var(--line-2); background: var(--surface); color: var(--muted); }
|
||
.mic:hover { color: var(--text); background: var(--surface-2); }
|
||
.mic.on { background: var(--red); border-color: var(--red); color: var(--on-red); animation: pulse 1.4s infinite; }
|
||
.send { border: 0; background: var(--action); color: var(--on-action); box-shadow: 0 8px 16px -10px var(--action); }
|
||
.send:hover { background: var(--action-2); }
|
||
.send:active { transform: scale(.95); }
|
||
.send:disabled { opacity: .45; cursor: default; }
|
||
.send svg.i { width: 18px; height: 18px; stroke-width: 2.2; }
|
||
.composer-footer { max-width: 1040px; margin: 0 auto; display: flex; justify-content: space-between; gap: 12px; font-size: 11px; color: var(--faint); padding: 8px 6px 0; }
|
||
#feedback.error { color: var(--red); }
|
||
#feedback.cmdhelp { flex: 1; white-space: pre-wrap; font: 11.5px/1.55 var(--mono); color: var(--text-2); background: var(--surface-2); border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; }
|
||
|
||
/* the catch-up dialog */
|
||
#catchup { width: min(720px, calc(100vw - 32px)); }
|
||
#catchBody ul { list-style: none; margin: 0; padding: 0; }
|
||
#catchBody li { display: flex; gap: 10px; align-items: center; padding: 8px 0; border-top: 1px solid var(--line); font-size: 13px; }
|
||
#catchBody li:first-child { border-top: 0; }
|
||
#catchBody li .avatar { width: 26px; height: 26px; border-radius: 8px; font-size: 9px; }
|
||
|
||
@media (max-width: 1180px) { .top-actions .lbl { display: none; } .top-actions .btn { width: 34px; padding: 0; } }
|
||
@media (max-width: 900px) {
|
||
.log { padding: 4px 16px 20px; }
|
||
.composer-area { padding: 10px 12px; }
|
||
.stopbar, .threadbar { padding: 8px 16px; }
|
||
.stack { max-width: 85%; }
|
||
}
|
||
@media (max-width: 560px) {
|
||
.top-actions .btn, .view-icon { display: none; }
|
||
.mrow .avatar { width: 26px; height: 26px; border-radius: 9px; font-size: 9px; }
|
||
.stack { max-width: 86%; }
|
||
.quick { margin-left: 0; width: 100%; }
|
||
.composer-footer .footer-right { display: none; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="shell" id="shell">
|
||
<aside class="side" aria-label="Board navigation">
|
||
<div>
|
||
<div class="brand"><div class="logo" aria-hidden="true"><svg class="i"><use href="static/icons.svg#anchor"/></svg></div>
|
||
<div class="grow"><div class="brand-name" id="boardTitle">Harbor</div><div class="brand-sub">Harbor</div></div></div>
|
||
<select id="projectSelect" aria-label="Project" style="margin-top:14px" hidden></select>
|
||
</div>
|
||
<nav class="nav" aria-label="Sections">
|
||
<div class="nav-label">Board</div>
|
||
<button class="nav-btn" data-go="overview" title="Overview (1)"><svg class="i"><use href="static/icons.svg#home"/></svg><span>Overview</span><span class="count-badge" id="navOverview" hidden></span></button>
|
||
<button class="nav-btn" data-go="logbook" title="Logbook (9)"><svg class="i"><use href="static/icons.svg#anchor"/></svg><span>Logbook</span></button>
|
||
<button class="nav-btn" data-go="launch" title="Launch (2)"><svg class="i"><use href="static/icons.svg#rocket"/></svg><span>Launch</span><span class="count-badge" id="navLaunch" hidden></span></button>
|
||
<button class="nav-btn" data-go="tasks" title="Tasks (3)"><svg class="i"><use href="static/icons.svg#tasks"/></svg><span>Tasks</span><span class="count-badge" id="navTasks" hidden></span></button>
|
||
<button class="nav-btn" data-go="review" title="Review (4)"><svg class="i"><use href="static/icons.svg#review"/></svg><span>Review</span><span class="count-badge" id="navReview" hidden></span></button>
|
||
<button class="nav-btn" data-go="decomp" title="Decomp (8)"><svg class="i"><use href="static/icons.svg#terminal"/></svg><span>Decomp</span></button>
|
||
<button class="nav-btn" data-go="activity" title="Activity (5)"><svg class="i"><use href="static/icons.svg#activity"/></svg><span>Activity</span><span class="count-badge" id="navActivity" hidden></span></button>
|
||
<button class="nav-btn" data-go="knowledge" title="Knowledge (6)"><svg class="i"><use href="static/icons.svg#book"/></svg><span>Knowledge</span><span class="count-badge" id="navKnowledge" hidden></span></button>
|
||
<button class="nav-btn" data-go="health" title="Health (7)"><svg class="i"><use href="static/icons.svg#heart"/></svg><span>Health</span></button>
|
||
</nav>
|
||
<nav class="nav" aria-label="Chat">
|
||
<div class="nav-label">Chat</div>
|
||
<button class="person" data-filter="All" title="The whole team's conversation (C)"><div class="avatar sm everyone" aria-hidden="true">#</div><span class="p-copy"><span class="p-name">Everyone</span><span class="p-sub">The whole team</span></span><span class="count-badge" id="chatUnread" hidden></span></button>
|
||
<div id="participants"></div>
|
||
</nav>
|
||
<div class="side-foot">
|
||
<div class="row"><button type="button" class="theme-btn" data-theme-open title="Colour theme and mode"><svg class="i"><use href="static/icons.svg#palette"/></svg><span data-theme-name>Harbor</span></button>
|
||
<div class="segmented theme" role="radiogroup" aria-label="Theme">
|
||
<label title="Match the system"><input type="radio" name="theme" value="auto" aria-label="System theme"><span><svg class="i sm"><use href="static/icons.svg#monitor"/></svg></span></label>
|
||
<label title="Light"><input type="radio" name="theme" value="light" aria-label="Light theme"><span><svg class="i sm"><use href="static/icons.svg#sun"/></svg></span></label>
|
||
<label title="Dark"><input type="radio" name="theme" value="dark" aria-label="Dark theme"><span><svg class="i sm"><use href="static/icons.svg#moon"/></svg></span></label>
|
||
</div></div>
|
||
<div class="live" id="conn"><span class="dot"></span><span id="connText">Connecting</span></div>
|
||
</div>
|
||
</aside>
|
||
<div class="scrim" id="scrim"></div>
|
||
|
||
<div class="main">
|
||
<header class="topbar">
|
||
<button class="btn icon ghost" id="menuBtn" aria-label="Open the menu"><svg class="i lg"><use href="static/icons.svg#menu"/></svg></button>
|
||
<div class="view-icon" id="viewIcon" aria-hidden="true"></div>
|
||
<div class="grow title"><h1 id="viewTitle">Harbor</h1><div class="sub" id="viewSub"></div></div>
|
||
<div class="top-actions">
|
||
<button class="btn ghost" id="sound" title="Sound for messages to you: off, beep, or read aloud">Sound off</button>
|
||
<button class="btn ghost" id="notify" aria-pressed="false" title="Desktop notifications for messages to you">Notify</button>
|
||
<button class="avail" id="availBtn" title="Tell the team whether you are around">Here</button>
|
||
</div>
|
||
</header>
|
||
|
||
<main class="content" id="content">
|
||
<!-- chat -->
|
||
<section class="view" id="view-chat" aria-label="Chat">
|
||
<div class="chat-main">
|
||
<div class="stopbar" id="stopbar"></div>
|
||
<div class="threadbar" id="threadbar"><svg class="i"><use href="static/icons.svg#thread"/></svg><strong class="grow" id="threadName"></strong>
|
||
<button class="btn sm" id="settleChatThread" title="Mark this thread settled">Close thread</button><button class="btn sm primary" id="leaveThread">Back to all</button></div>
|
||
<div class="log-wrap">
|
||
<div class="log" id="log" role="log" aria-label="Messages" aria-live="polite"><div class="empty"><strong>Your team’s conversation</strong>Connecting…</div></div>
|
||
<button class="new-messages" id="newMessages" hidden>New messages <svg class="i sm"><use href="static/icons.svg#chevron"/></svg></button>
|
||
</div>
|
||
<div class="composer-area"><form class="composer" id="composer">
|
||
<div class="recipient"><label for="to">To</label><select id="to" aria-label="Recipient"></select>
|
||
<label class="toggle-chip"><input type="checkbox" id="askAck"><span><svg class="i"><use href="static/icons.svg#check"/></svg>Ask to acknowledge</span></label>
|
||
<label class="toggle-chip danger"><input type="checkbox" id="urgent"><span><svg class="i"><use href="static/icons.svg#zap"/></svg>Urgent</span></label>
|
||
<div class="quick" aria-label="Quick replies">
|
||
<button type="button" data-quick="night" title="Tell the team you are away until 8 AM"><svg class="i"><use href="static/icons.svg#moon-zz"/></svg>Good night</button>
|
||
<button type="button" data-quick="morning" title="Back, and catch up on what happened"><svg class="i"><use href="static/icons.svg#sunrise"/></svg>Good morning</button>
|
||
<button type="button" data-quick="status" title="The last 12 hours at a glance"><svg class="i"><use href="static/icons.svg#list"/></svg>Status?</button></div></div>
|
||
<div class="input-row"><textarea id="text" rows="1" maxlength="20000" placeholder="Write to the team… @Name addresses someone, /help lists commands" aria-label="Message"></textarea>
|
||
<button class="mic" id="mic" type="button" title="Dictate a message" aria-label="Dictate a message"><svg class="i"><use href="static/icons.svg#mic"/></svg></button>
|
||
<button class="send" id="send" type="submit" aria-label="Send" title="Send"><svg class="i"><use href="static/icons.svg#send"/></svg></button></div></form>
|
||
<div class="composer-footer"><span id="feedback">Enter to send · Shift + Enter for a new line</span><span class="footer-right">Central time</span></div></div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- overview -->
|
||
<section class="view" id="view-overview" aria-label="Overview">
|
||
<div class="hero" id="banner">
|
||
<div class="hero-icon" id="heroIcon"><svg class="i"><use href="static/icons.svg#play"/></svg></div>
|
||
<div class="grow"><div class="eyebrow">Work control</div><h2 id="bannerTitle">…</h2><div class="note" id="bannerNote"></div><div class="note faint" id="flagNote"></div></div>
|
||
<div class="hero-actions">
|
||
<input type="text" id="reason" placeholder="Reason (optional, shown to the team)" aria-label="Reason">
|
||
<button class="btn stop" id="stopAll"><svg class="i sm"><use href="static/icons.svg#stop"/></svg>Stop everyone</button>
|
||
<button class="btn go" id="resumeAll"><svg class="i sm"><use href="static/icons.svg#play"/></svg>Resume everyone</button>
|
||
</div>
|
||
</div>
|
||
<div class="stats" id="stats"></div>
|
||
<section class="card ask" aria-labelledby="h-questions" id="questionsCard" hidden>
|
||
<header><h2 id="h-questions"><svg class="i"><use href="static/icons.svg#help"/></svg>Questions for you</h2><span class="count" id="questionCount"></span></header>
|
||
<div class="body" id="questions"></div>
|
||
</section>
|
||
<section class="card warn" aria-labelledby="h-attention" id="attentionCard" hidden>
|
||
<header><h2 id="h-attention"><svg class="i"><use href="static/icons.svg#alert"/></svg>Needs attention</h2><span class="count" id="attentionCount"></span></header>
|
||
<div class="body"><ul class="attn" id="attention"></ul></div>
|
||
</section>
|
||
<section class="card" aria-labelledby="h-crew">
|
||
<header><h2 id="h-crew"><svg class="i"><use href="static/icons.svg#users"/></svg>Crew</h2><span class="count" id="crewCount"></span></header>
|
||
<div class="body"><div class="crew" id="agents"></div></div>
|
||
</section>
|
||
<section class="card" aria-labelledby="h-digest">
|
||
<header><h2 id="h-digest"><svg class="i"><use href="static/icons.svg#clock"/></svg>What happened</h2><span class="grow"></span>
|
||
<select id="since" aria-label="Time window"><option value="2h">Last 2 hours</option><option value="8h" selected>Last 8 hours</option><option value="16h">Last 16 hours</option><option value="24h">Last day</option><option value="3d">Last 3 days</option></select></header>
|
||
<div class="body scroll" id="digest"></div>
|
||
</section>
|
||
</section>
|
||
|
||
<!-- launch -->
|
||
<section class="view" id="view-launch" aria-label="Launch">
|
||
<ol class="stepper">
|
||
<li><span class="n">1</span><div><b>Open the app</b><span class="d">Send the agent any message; it reads its brief and shows up as connected.</span></div></li>
|
||
<li><span class="n">2</span><div><b>Roll call</b><span class="d">Ask it to answer "Here". Works while it is stopped.</span></div></li>
|
||
<li><span class="n">3</span><div><b>Trial run</b><span class="d">Let one agent work for 15 to 60 minutes; the stop returns by itself.</span></div></li>
|
||
<li><span class="n">4</span><div><b>Resume</b><span class="d">Watch the live feed, then let it work for real.</span></div></li>
|
||
</ol>
|
||
<section class="card" aria-labelledby="h-launch">
|
||
<header><h2 id="h-launch"><svg class="i"><use href="static/icons.svg#rocket"/></svg>Agents</h2><span class="grow"></span>
|
||
<button class="btn sm" id="rollcallAll" title="Ask every agent to answer 'Here' - works while they are stopped"><svg class="i sm"><use href="static/icons.svg#users"/></svg>Roll call everyone</button></header>
|
||
<div class="body flush scroll" id="launch"></div>
|
||
</section>
|
||
</section>
|
||
|
||
<!-- tasks -->
|
||
<section class="view" id="view-tasks" aria-label="Tasks">
|
||
<section class="card" aria-labelledby="h-newtask">
|
||
<header><h2 id="h-newtask"><svg class="i"><use href="static/icons.svg#plus"/></svg>New task</h2><span class="grow"></span><span class="note" id="taskNote"></span></header>
|
||
<div class="body">
|
||
<form class="taskform" id="taskForm"><input type="text" id="tTitle" placeholder="New task, e.g. Make 0x445310 readable" aria-label="Task title" required>
|
||
<input type="text" id="tScope" placeholder="Scope (fn:0x445310)" aria-label="Scope">
|
||
<select id="tAssignee" aria-label="Assign to"><option value="">Anyone (next)</option></select>
|
||
<select id="tPriority" aria-label="Priority"><option value="normal">Normal</option><option value="high">High</option><option value="urgent">Urgent</option><option value="low">Low</option></select>
|
||
<input type="text" id="tAfter" placeholder="After tasks (3, 4)" aria-label="Depends on">
|
||
<button class="btn primary"><svg class="i sm"><use href="static/icons.svg#plus"/></svg>Add task</button></form>
|
||
<details class="bulk"><summary><svg class="i sm"><use href="static/icons.svg#list"/></svg>Bulk add: one task per line ("0x445310 RouteCellCost" or a plain title); addresses go to their band owner</summary>
|
||
<form id="bulkForm"><textarea id="bulkText" placeholder="0x445310 RouteCellCost 0x4E2680 CreateShipWakeModel Write the README" aria-label="Tasks, one per line"></textarea>
|
||
<div class="row" style="margin-top:10px"><select id="bulkTemplate" aria-label="Template"><option value="">No template</option></select>
|
||
<select id="bulkPriority" aria-label="Priority"><option value="normal">Normal</option><option value="high">High</option><option value="urgent">Urgent</option><option value="low">Low</option></select>
|
||
<span class="grow note" id="bulkNote"></span><button class="btn primary">Add all</button></div></form></details>
|
||
</div>
|
||
</section>
|
||
<section class="card" aria-labelledby="h-tasks">
|
||
<header><h2 id="h-tasks"><svg class="i"><use href="static/icons.svg#tasks"/></svg>Queue</h2><span class="count" id="taskCount"></span><span class="grow"></span>
|
||
<label class="switch"><input type="checkbox" id="tasksAll"> Include finished</label></header>
|
||
<div class="body flush scroll" id="tasks"></div>
|
||
</section>
|
||
</section>
|
||
|
||
<!-- review -->
|
||
<section class="view" id="view-review" aria-label="Review">
|
||
<section class="card" aria-labelledby="h-queue">
|
||
<header><h2 id="h-queue"><svg class="i"><use href="static/icons.svg#review"/></svg>Review queue</h2><span class="count" id="queueCount"></span></header>
|
||
<div class="body flush scroll" id="queue"></div>
|
||
</section>
|
||
<div class="cols">
|
||
<section class="card" aria-labelledby="h-acks">
|
||
<header><h2 id="h-acks"><svg class="i"><use href="static/icons.svg#check"/></svg>Waiting for acknowledgement</h2><span class="count" id="ackCount"></span></header>
|
||
<div class="body flush scroll" id="acks"></div>
|
||
</section>
|
||
<section class="card" aria-labelledby="h-claims">
|
||
<header><h2 id="h-claims"><svg class="i"><use href="static/icons.svg#flag"/></svg>Claims</h2><span class="count" id="claimCount"></span></header>
|
||
<div class="body flush scroll" id="claims"></div>
|
||
</section>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- activity -->
|
||
<section class="view" id="view-activity" aria-label="Activity">
|
||
<section class="card" aria-labelledby="h-pulse">
|
||
<header><h2 id="h-pulse"><svg class="i"><use href="static/icons.svg#activity"/></svg>Team pulse</h2><span class="note">tool calls and messages, 5-minute steps</span><span class="grow"></span>
|
||
<select id="pulseHours" aria-label="Window"><option value="6">6 hours</option><option value="12" selected>12 hours</option><option value="24">24 hours</option><option value="48">48 hours</option></select></header>
|
||
<div class="body" id="pulse"></div>
|
||
</section>
|
||
<div class="cols">
|
||
<section class="card" aria-labelledby="h-feed">
|
||
<header><h2 id="h-feed"><svg class="i"><use href="static/icons.svg#zap"/></svg>Live feed</h2><span class="note">the latest tool calls, from the hooks</span></header>
|
||
<div class="body"><ul class="feed" id="feed"></ul></div>
|
||
</section>
|
||
<section class="card" aria-labelledby="h-score">
|
||
<header><h2 id="h-score"><svg class="i"><use href="static/icons.svg#trophy"/></svg>Scoreboard</h2><span class="count" id="winCount"></span></header>
|
||
<div class="body"><div id="metrics"></div><h3 class="sub-h">Landed in the last 24 hours</h3><ul class="attn" id="wins"></ul></div>
|
||
</section>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- knowledge -->
|
||
<section class="view" id="view-knowledge" aria-label="Knowledge">
|
||
<section class="card" aria-labelledby="h-search">
|
||
<header><h2 id="h-search"><svg class="i"><use href="static/icons.svg#search"/></svg>Search every message</h2></header>
|
||
<div class="body">
|
||
<form class="searchbar" id="searchForm"><div class="field"><svg class="i"><use href="static/icons.svg#search"/></svg>
|
||
<input type="search" id="q" placeholder="Words, fn:0x445310, or filters: from:Kimi kind:decision since:2d pinned:yes" aria-label="Search"></div>
|
||
<button class="btn primary">Search</button></form>
|
||
<div class="note" style="margin-top:8px">A scope like <code>fn:0x445310</code> shows everything known about it: claims, tasks, reviews, notes and experts.</div>
|
||
<div class="msgs" id="results"></div>
|
||
</div>
|
||
</section>
|
||
<div class="cols">
|
||
<section class="card" aria-labelledby="h-pins">
|
||
<header><h2 id="h-pins"><svg class="i"><use href="static/icons.svg#pin"/></svg>Pinned and decisions</h2></header>
|
||
<div class="body"><div class="msgs" id="pins"></div></div>
|
||
</section>
|
||
<section class="card" aria-labelledby="h-threads">
|
||
<header><h2 id="h-threads"><svg class="i"><use href="static/icons.svg#thread"/></svg>Threads</h2><span class="grow"></span><label class="switch"><input type="checkbox" id="archive"> Chat archive</label></header>
|
||
<div class="body"><ul class="threads" id="threads"></ul></div>
|
||
</section>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- logbook -->
|
||
<section class="view lb" id="view-logbook" aria-label="Logbook">
|
||
<section class="card"><div class="body lb-head">
|
||
<div><span class="chip" id="lbMilestone">Milestone</span>
|
||
<p>Decompiling Sid Meier’s Pirates! (2004) into readable C, then rebuilding it in Rust, charted function by function against the PC 1.0.2 executable.</p></div>
|
||
<div class="lb-stamp" id="lbStamp">Loading…</div></div></section>
|
||
<section class="card" aria-labelledby="h-pm" id="pmCard">
|
||
<div class="body">
|
||
<h2 class="pm-title" id="h-pm"><span id="pmProject">The project</span> is <b id="pmPct">–</b> decompiled</h2>
|
||
<div class="pm-bars" id="pmBars"></div>
|
||
<div class="pm-commit"><span id="pmCommit"></span><span class="grow"></span>
|
||
<button class="btn sm" id="pmPrev" type="button">Previous</button><button class="btn sm" id="pmNext" type="button">Next</button><button class="btn sm" id="pmLatest" type="button">Latest</button></div>
|
||
<div class="note" id="pmHistNote" style="margin-top:6px"></div>
|
||
<div class="pm-tools"><select id="pmUnits" aria-label="Jump to a unit"></select>
|
||
<input type="search" id="pmFilter" placeholder="Filter: name, <70%, >10kb" aria-label="Filter units"><span class="note" id="pmCount"></span></div>
|
||
<div class="pm-crumb" id="pmCrumb"></div>
|
||
<div class="pm-wrap"><canvas id="pmMap" aria-label="Treemap of units, area by code bytes, colour by share decompiled"></canvas></div>
|
||
<div class="lb-legend"><span><i class="lb-sw" style="background:var(--pm-exact)"></i>exact match</span><span><i class="lb-sw" style="background:var(--pm-verified)"></i>verified in the emulator</span><span><i class="lb-sw" style="background:var(--pm-readable)"></i>readable</span><span><i class="lb-sw" style="background:var(--pm-raw)"></i>raw</span><span><i class="lb-sw" style="background:var(--pm-deferred)"></i>deferred</span><span><i class="lb-sw" style="background:var(--pm-unsorted)"></i>unsorted (not counted)</span><span><i class="lb-sw" style="background:var(--pm-engine)"></i>engine and libraries: named, not decompiled (not counted)</span></div>
|
||
<div class="note" style="margin-top:8px">A unit is an original source file (from the Xbox build’s asserts) or, without one, a 64 KB address block. Area is code bytes; a unit’s green is the share of its game-code bytes decompiled. Click a unit to see its functions.</div>
|
||
<div class="pm-tip" id="pmTip" hidden></div>
|
||
</div>
|
||
</section>
|
||
<div class="lb-figs" id="lbFigs"></div>
|
||
<section class="card" aria-labelledby="h-lbmap">
|
||
<header><h2 id="h-lbmap"><svg class="i"><use href="static/icons.svg#layout"/></svg>Chart of the executable</h2><span class="note">bytes per category along the code addresses</span></header>
|
||
<div class="body"><canvas id="lbMap" aria-label="Address space of the executable, bytes per category"></canvas>
|
||
<div class="lb-axis"><span id="lbLow">0x401000</span><span>code address →</span><span id="lbHigh"></span></div>
|
||
<div class="lb-legend" id="lbLegend"></div></div>
|
||
</section>
|
||
<div class="cols">
|
||
<section class="card" aria-labelledby="h-lbwhere">
|
||
<header><h2 id="h-lbwhere"><svg class="i"><use href="static/icons.svg#trophy"/></svg>Where the code stands</h2></header>
|
||
<div class="body" id="lbWhere"></div>
|
||
</section>
|
||
<section class="card" aria-labelledby="h-lbtrend">
|
||
<header><h2 id="h-lbtrend"><svg class="i"><use href="static/icons.svg#activity"/></svg>Trend</h2></header>
|
||
<div class="body"><svg id="lbTrend" viewBox="0 0 300 110" preserveAspectRatio="none" aria-label="Progress over time"></svg>
|
||
<div class="lb-legend"><span><i class="lb-sw" style="background:var(--lb-game)"></i>classified</span><span><i class="lb-sw" style="background:var(--lb-platform)"></i>game code decompiled</span><span><i class="lb-sw" style="background:var(--lb-done)"></i>done (functions handled)</span></div>
|
||
<div class="note" id="lbTrendNote" style="margin-top:8px"></div></div>
|
||
</section>
|
||
</div>
|
||
<div class="cols">
|
||
<section class="card" aria-labelledby="h-lbms">
|
||
<header><h2 id="h-lbms"><svg class="i"><use href="static/icons.svg#flag"/></svg>Milestones</h2></header>
|
||
<div class="body" id="lbMilestones"></div>
|
||
</section>
|
||
<section class="card" aria-labelledby="h-lblog">
|
||
<header><h2 id="h-lblog"><svg class="i"><use href="static/icons.svg#book"/></svg>Ship’s log</h2><span class="count" id="lbLogCount"></span></header>
|
||
<div class="body"><ul class="lb-log" id="lbLog"></ul></div>
|
||
</section>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- decomp -->
|
||
<section class="view" id="view-decomp" aria-label="Decomp">
|
||
<section class="card" aria-labelledby="h-progress">
|
||
<header><h2 id="h-progress"><svg class="i"><use href="static/icons.svg#trophy"/></svg>Progress by function</h2><span class="count" id="progressSum"></span><span class="grow"></span>
|
||
<button class="btn sm" id="refreshDecomp"><svg class="i sm"><use href="static/icons.svg#refresh"/></svg>Refresh</button></header>
|
||
<div class="body" id="progress"></div>
|
||
</section>
|
||
<section class="card" aria-labelledby="h-inspect">
|
||
<header><h2 id="h-inspect"><svg class="i"><use href="static/icons.svg#search"/></svg>Inspect a function</h2><span class="note">original name, source file, convention, match and near-duplicates</span></header>
|
||
<div class="body">
|
||
<form class="searchbar" id="fnForm"><div class="field"><svg class="i"><use href="static/icons.svg#search"/></svg>
|
||
<input type="search" id="fnTarget" placeholder="An address (0x4ADC70) or a name (CanCarry)" aria-label="Function"></div>
|
||
<button class="btn primary">Inspect</button></form>
|
||
<div id="fnInfo"></div>
|
||
</div>
|
||
</section>
|
||
<div class="cols">
|
||
<section class="card" aria-labelledby="h-next">
|
||
<header><h2 id="h-next"><svg class="i"><use href="static/icons.svg#list"/></svg>Next up in each band</h2><span class="note">what next_functions hands out, easiest first</span></header>
|
||
<div class="body flush scroll" id="nextUp"></div>
|
||
</section>
|
||
<section class="card" aria-labelledby="h-deferred">
|
||
<header><h2 id="h-deferred"><svg class="i"><use href="static/icons.svg#clock"/></svg>Deferred</h2><span class="count" id="deferCount"></span></header>
|
||
<div class="body flush scroll" id="deferred"></div>
|
||
</section>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- health -->
|
||
<section class="view" id="view-health" aria-label="Health">
|
||
<section class="card" aria-labelledby="h-notify">
|
||
<header><h2 id="h-notify"><svg class="i"><use href="static/icons.svg#bell"/></svg>Notifications</h2></header>
|
||
<div class="body"><label class="switch" title="Windows notifications for questions, urgent messages and messages to you - also with this page closed"><input type="checkbox" id="toasts"> Windows notifications for questions, urgent messages and messages to you, also while this page is closed</label></div>
|
||
</section>
|
||
<section class="card" aria-labelledby="h-retools">
|
||
<header><h2 id="h-retools"><svg class="i"><use href="static/icons.svg#terminal"/></svg>Reverse-engineering tools</h2><span class="count" id="reSum"></span></header>
|
||
<div class="body flush scroll" id="retools"></div>
|
||
</section>
|
||
<section class="card" aria-labelledby="h-health">
|
||
<header><h2 id="h-health"><svg class="i"><use href="static/icons.svg#heart"/></svg>Health and connections</h2><span class="count" id="healthSum"></span><span class="grow"></span>
|
||
<button class="btn sm primary" id="runDoctor"><svg class="i sm"><use href="static/icons.svg#refresh"/></svg>Run checks</button></header>
|
||
<div class="body flush scroll" id="health"></div>
|
||
</section>
|
||
</section>
|
||
</main>
|
||
</div>
|
||
</div>
|
||
|
||
<dialog id="availDialog" aria-labelledby="availTitle">
|
||
<form id="availForm">
|
||
<header><h2 id="availTitle">Your availability</h2><button type="button" class="btn icon sm ghost" id="availClose" aria-label="Close"><svg class="i"><use href="static/icons.svg#x"/></svg></button></header>
|
||
<div class="body">
|
||
<div class="segmented" role="radiogroup" aria-label="Status">
|
||
<label><input type="radio" name="st" value="here" checked><span>Here</span></label>
|
||
<label><input type="radio" name="st" value="away"><span>Away</span></label>
|
||
<label><input type="radio" name="st" value="dnd"><span>Do not disturb</span></label>
|
||
</div>
|
||
<label class="field-label" for="availUntil">Until</label>
|
||
<select id="availUntil"><option value="">No end time</option><option value="1h">1 hour</option><option value="4h">4 hours</option><option value="tomorrow">Tomorrow 8 AM</option></select>
|
||
<label class="field-label" for="availNote">Note for the team</label>
|
||
<input type="text" id="availNote" placeholder="e.g. decide with Claude, don't wait for me">
|
||
</div>
|
||
<footer><span class="grow note">Agents see this at the top of every brief.</span><button class="btn primary">Save</button></footer>
|
||
</form>
|
||
</dialog>
|
||
<dialog id="promptDialog" aria-labelledby="promptTitle">
|
||
<header><h2 id="promptTitle">Restart prompt</h2><button class="btn sm" id="promptCopy"><svg class="i sm"><use href="static/icons.svg#copy"/></svg>Copy</button><button class="btn icon sm ghost" id="promptClose" aria-label="Close"><svg class="i"><use href="static/icons.svg#x"/></svg></button></header>
|
||
<div class="body"><textarea id="promptText" readonly aria-label="Prompt"></textarea></div>
|
||
<footer><span class="grow note" id="promptNote">Paste this into a fresh session for the agent.</span></footer>
|
||
</dialog>
|
||
<dialog id="threadDialog" aria-labelledby="threadTitle">
|
||
<header><h2 id="threadTitle">Thread</h2>
|
||
<button class="btn sm" id="threadToChat" title="Open this thread in the chat"><svg class="i sm"><use href="static/icons.svg#chat"/></svg>Open in chat</button>
|
||
<button class="btn sm" id="exportThread" title="Download the thread as Markdown"><svg class="i sm"><use href="static/icons.svg#download"/></svg>Export</button>
|
||
<button class="btn sm" id="settleThread" title="Mark the thread settled">Close thread</button>
|
||
<button class="btn icon sm ghost" id="closeThread" aria-label="Close"><svg class="i"><use href="static/icons.svg#x"/></svg></button></header>
|
||
<div class="body"><div class="msgs" id="threadMsgs"></div></div>
|
||
<form id="replyForm"><footer style="flex-direction:column;align-items:stretch">
|
||
<textarea id="replyText" placeholder="Reply in this thread" aria-label="Reply" style="min-height:64px"></textarea>
|
||
<div class="row"><span class="note grow" id="replyNote"></span>
|
||
<label class="check"><input type="checkbox" id="replyAck"> ask for acknowledgement</label>
|
||
<select id="replyImportance" aria-label="Importance"><option>normal</option><option>high</option><option>urgent</option></select>
|
||
<button class="btn primary"><svg class="i sm"><use href="static/icons.svg#send"/></svg>Send</button></div>
|
||
</footer></form>
|
||
</dialog>
|
||
|
||
<dialog id="catchup" aria-labelledby="catchTitle">
|
||
<header><h2 id="catchTitle">While you were away</h2><button type="button" class="btn icon sm ghost" id="catchClose" aria-label="Close"><svg class="i"><use href="static/icons.svg#x"/></svg></button></header>
|
||
<div class="body" id="catchBody"></div>
|
||
<footer><button type="button" class="btn primary" id="askStatus" title="Ask every running agent for a status reply"><svg class="i sm"><use href="static/icons.svg#chat"/></svg>Ask the agents for a status</button></footer>
|
||
</dialog>
|
||
|
||
<script src="static/themes.js"></script>
|
||
<script>
|
||
let TOKEN=(document.querySelector('meta[name=harbor-token]')||{}).content||'';
|
||
// the server makes a new token each start: a page left open across a restart fetches it again instead of failing
|
||
async function freshToken(){const r=await fetch('./',{cache:'no-store'});const m=(await r.text()).match(/name="harbor-token" content="([^"]+)"/);if(m)TOKEN=m[1];return !!m}
|
||
const el=id=>document.getElementById(id);
|
||
const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||
const store={get(k,d){try{const v=localStorage.getItem('harbor.'+k);return v===null?d:JSON.parse(v)}catch(e){return d}},set(k,v){try{localStorage.setItem('harbor.'+k,JSON.stringify(v))}catch(e){}}};
|
||
const ic=(n,c)=>'<svg class="i'+(c?' '+c:'')+'" aria-hidden="true"><use href="static/icons.svg#'+n+'"></use></svg>';
|
||
const empty=(icon,title,text)=>'<div class="empty">'+ic(icon)+'<strong>'+esc(title)+'</strong><span>'+(text||'')+'</span></div>';
|
||
const INIT={Claude:'CL',Codex:'CX',DeepSeek:'DS',OpenCode:'OC',Kimi:'KM',User:'You'};
|
||
const ago=m=>m==null?'never':m<1?'just now':m<120?m+' min ago':m<2880?Math.round(m/60)+' h ago':Math.round(m/1440)+' days ago';
|
||
const central=s=>{const d=new Date(s);return isNaN(d)?s:d.toLocaleString('en-US',{timeZone:'America/Chicago',month:'short',day:'numeric',hour:'numeric',minute:'2-digit',timeZoneName:'short'})};
|
||
const STATE={submitted:'blue',in_review:'violet',changes_requested:'amber',approved:'green',held:'',landed:'green',withdrawn:'',rejected:'red'};
|
||
let data=null,openThread=null,current=null,doctorRan=false,unseen=0;
|
||
const HUMAN=()=>data&&data.project?data.project.human:'User';
|
||
function avatar(n,size,state){const a=(data&&data.agents.find(x=>x.name===n))||{};
|
||
return '<div class="avatar'+(size?' '+size:'')+' '+esc(String(n).toLowerCase())+'"'+(a.color?' style="--avatar:'+esc(a.color)+'"':'')+(state?' data-state="'+esc(state)+'"':'')+' aria-hidden="true">'+esc(a.initials||INIT[n]||String(n).slice(0,2))+'</div>'}
|
||
function put(id,html){const e=typeof id==='string'?el(id):id;if(e._html===html)return false;
|
||
const open=[...e.querySelectorAll('details[open][data-k]')].map(d=>d.dataset.k);e._html=html;e.innerHTML=html;
|
||
open.forEach(k=>{const d=e.querySelector('details[data-k="'+CSS.escape(k)+'"]');if(d)d.open=true});return true}
|
||
async function post(path,body,again){const r=await fetch(path,{method:'POST',headers:{'Content-Type':'application/json','X-Harbor-Token':TOKEN},body:JSON.stringify(body)});const j=await r.json().catch(()=>({}));if(r.status===403&&/stale page/.test(j.error||'')&&!again&&await freshToken())return post(path,body,true);if(!r.ok||j.ok===false)throw new Error(j.error||('HTTP '+r.status));return j}
|
||
async function get(path){const r=await fetch(path,{cache:'no-store'});const j=await r.json();if(!r.ok)throw new Error(j.error||('HTTP '+r.status));return j}
|
||
/* Run fn every ms while the page is visible and every hiddenMs while it is hidden (null: not at all). The next run is
|
||
timed from the end of the last one, so slow requests never pile up; showing the page again runs everything at once. */
|
||
const LOOPS=[];
|
||
function every(fn,ms,hiddenMs){const L={busy:false,t:0};
|
||
L.run=async()=>{clearTimeout(L.t);if(L.busy)return;
|
||
if(!document.hidden||hiddenMs){L.busy=true;try{await fn()}catch(e){}L.busy=false}
|
||
L.t=setTimeout(L.run,document.hidden?(hiddenMs||5000):ms)};
|
||
L.t=setTimeout(L.run,document.hidden&&hiddenMs?hiddenMs:ms);LOOPS.push(L);return L}
|
||
document.addEventListener('visibilitychange',()=>{if(!document.hidden)LOOPS.forEach(L=>{if(!L.busy)L.run()})});
|
||
|
||
const VIEWS={overview:['Overview','Who is working, what needs you, and what happened','home'],launch:['Launch','Bring agents online one careful step at a time','rocket'],
|
||
tasks:['Tasks','The shared queue that idle agents take work from','tasks'],review:['Review','Submitted work, acknowledgements and claims','review'],
|
||
activity:['Activity','Live tool calls, the team pulse and the scoreboard','activity'],knowledge:['Knowledge','Search every message, pinned decisions and threads','book'],
|
||
health:['Health','Harbor in every agent app, the database, backups and the server','heart'],
|
||
decomp:['Decomp','Progress by function, the function inspector and what each agent takes next','terminal'],
|
||
logbook:['Logbook','Where the decomp stands, how it got here and what comes next','anchor'],chat:['Chat','The team’s conversation','chat']};
|
||
function show(view){if(!VIEWS[view])view='overview';current=view;
|
||
document.querySelectorAll('.view').forEach(v=>v.classList.toggle('on',v.id==='view-'+view));
|
||
document.querySelectorAll('.nav-btn[data-go]').forEach(b=>{if(b.dataset.go===view)b.setAttribute('aria-current','page');else b.removeAttribute('aria-current')});
|
||
el('content').classList.toggle('flat',view==='chat');
|
||
if(view!=='chat'){el('viewTitle').textContent=VIEWS[view][0];el('viewSub').textContent=VIEWS[view][1];const vi=el('viewIcon');vi.className='view-icon';put(vi,ic(VIEWS[view][2]))}
|
||
paintTitle();
|
||
if(view!=='chat')store.set('board.view',view);if(location.hash.slice(1)!==view)location.hash=view;
|
||
el('shell').classList.remove('open');el('content').scrollTop=0;
|
||
Chat.view();
|
||
if(data&&view==='overview')renderDigest();
|
||
if(data&&view==='activity'){renderPulse();renderFeed()}
|
||
if(data&&view==='launch')renderLaunch();
|
||
if(data&&view==='decomp')renderDecomp();
|
||
if(data&&view==='logbook')renderLogbook();
|
||
if(view==='health')renderTools();
|
||
if(view==='health'&&!doctorRan)runDoctor()}
|
||
function paintTitle(){document.title=(unseen&&document.hidden?'('+unseen+') ':'')+(data?data.project.title+' · ':'')+(current?VIEWS[current][0]:'Harbor')}
|
||
|
||
function agentState(a){return a.stopped.length?'stopped':a.trial_until?'trial':a.working?'working':a.idle_minutes!=null&&a.idle_minutes<15?'online':'idle'}
|
||
function renderControl(){
|
||
const all=data.flags.find(f=>f.key==='stop:all');const ais=data.agents.filter(a=>a.kind!=='human');
|
||
const nStopped=ais.filter(a=>a.stopped.length).length;const stopped=ais.length>0&&nStopped===ais.length;
|
||
el('banner').className='hero'+(stopped?' stopped':nStopped?' partial':'');
|
||
put('heroIcon',ic(stopped?'stop':nStopped?'alert':'play'));
|
||
el('bannerTitle').textContent=stopped?'Everyone is stopped':nStopped?nStopped+' of '+ais.length+' stopped':'Everyone may work';
|
||
el('bannerNote').textContent=all?((stopped?'Stopped':'Resumed')+' '+central(all.set_at)+' by '+(all.set_by===HUMAN()?'you':all.set_by)+(all.reason?' · '+all.reason:'')):'Workers check this at the start of every session (harbor brief).';
|
||
el('stopAll').disabled=stopped;el('resumeAll').disabled=!nStopped;
|
||
el('flagNote').textContent='Only you can resume. An agent can stop itself.';
|
||
const working=ais.filter(a=>a.working).length;
|
||
el('crewCount').textContent=ais.length+' agents'+(working?' · '+working+' working':'');
|
||
put('agents',ais.map(a=>{
|
||
const own=a.stopped.length>0,state=agentState(a);
|
||
const why=own&&a.stopped[0]&&a.stopped[0].reason?' title="'+esc(a.stopped[0].reason)+'"':'';
|
||
const st=a.trial_until&&!own?'<span class="tag amber"><span class="dot"></span>trial until '+esc(central(a.trial_until))+'</span>':own?'<span class="tag red"'+why+'><span class="dot"></span>stopped</span>':'<span class="tag green"><span class="dot"></span>may work</span>';
|
||
const unread=a.unread?'<span class="tag'+(a.urgent?' red':' blue')+'">'+a.unread+' unread'+(a.urgent?', '+a.urgent+' urgent':'')+'</span>':'';
|
||
return '<article class="agent '+state+'"><div class="agent-top">'+avatar(a.name,'lg',state)+'<div class="grow"><div class="name">'+esc(a.name)+'</div><div class="role">'+esc(a.role)+' · '+(a.idle_minutes==null?'not seen yet':'active '+esc(ago(a.idle_minutes)))+'</div></div>'+st+'</div>'+
|
||
((a.working||a.doing)?'<div class="agent-now">'+(a.working?'<div class="working">Working'+(a.last_tool?' · '+esc(a.last_tool):'')+'</div>':'')+(a.doing?'<div class="doing">'+esc(a.doing)+' <span class="when">· '+esc(ago(a.doing_minutes))+'</span></div>':'')+'</div>':'')+
|
||
gitLine(a.name)+linkBadges(a)+
|
||
(a.handoff?'<details class="handoff" data-k="'+esc(a.name)+'"><summary>Last handoff · '+esc(ago(a.handoff.minutes))+'</summary><pre>'+esc(a.handoff.text)+'</pre></details>':'')+
|
||
'<div class="agent-actions">'+(own?'<button class="btn sm go" data-resume="'+esc(a.name)+'">'+ic('play','sm')+'Resume</button>':'<button class="btn sm" data-stop="'+esc(a.name)+'">'+ic('stop','sm')+'Stop</button>')+
|
||
'<button class="btn sm" data-ping="'+esc(a.name)+'" title="Send an urgent are-you-there that asks for an acknowledgement">'+ic('bell','sm')+'Ping</button><button class="btn sm" data-prompt="'+esc(a.name)+'" title="A prompt to paste into a fresh session">'+ic('terminal','sm')+'Prompt</button>'+unread+'</div></article>'}).join(''));
|
||
}
|
||
function gitLine(name){const g=(data.git||{})[name];if(!g)return '';if(g.error)return '<div class="gitline gitwarn" title="'+esc(g.error)+'">'+ic('git','sm')+'git unavailable</div>';
|
||
const lc=g.last_commit||{};const parts=['<b>'+esc(g.branch)+'</b>',esc(lc.sha||'')+' '+esc(ago(lc.at?Math.round((Date.now()-new Date(lc.at))/60000):null))];
|
||
if(g.dirty)parts.push('<span class="gitwarn">'+g.dirty+' uncommitted</span>');if(g.behind_main)parts.push(g.behind_main+' behind main');
|
||
const u=(g.unsubmitted||[]).length;if(u)parts.push('<span class="gitwarn">'+u+(u>=50?'+':'')+' not submitted</span>');
|
||
return '<div class="gitline" title="'+esc(lc.subject||'')+'">'+ic('git','sm')+parts.map(p=>'<span>'+p+'</span>').join('')+'</div>'}
|
||
function linkBadges(a){
|
||
const c=a.connections||[];if(!c.length)return '<div class="links"><span class="tag" title="Harbor has not seen this agent through its app yet">not connected yet</span></div>';
|
||
return '<div class="links">'+c.map(x=>'<span class="tag '+(x.minutes<15?'green':'')+'" title="'+x.count+' calls">'+esc(x.channel)+(x.host?' · '+esc(x.host):'')+' · '+esc(ago(x.minutes))+'</span>').join('')+'</div>'}
|
||
function renderStats(){
|
||
const ais=data.agents.filter(a=>a.kind!=='human');const working=ais.filter(a=>a.working).length,stopped=ais.filter(a=>a.stopped.length).length;
|
||
const progress=data.tasks.filter(t=>t.state==='in_progress').length,acks=new Set(data.pending_acks.map(r=>r.id)).size,wins=(data.wins||[]).length;
|
||
const tiles=[['activity','Working now',working,'of '+ais.length+' agents','activity',working?'green':'quiet'],
|
||
['stop','Stopped',stopped,stopped?'resume them on Launch':'everyone may work','launch',stopped?'red':'quiet'],
|
||
['tasks','Open tasks',data.tasks.length,progress+' in progress','tasks','blue'],
|
||
['review','In review',data.queue.length,acks?acks+' waiting for an ack':'review queue','review','violet'],
|
||
['trophy','Landed today',wins,'last 24 hours','activity',wins?'amber':'quiet']];
|
||
put('stats',tiles.map(([i,label,v,sub,go,tone])=>'<button class="stat '+tone+'" data-go="'+go+'"><span class="stat-icon">'+ic(i)+'</span><span><span class="stat-v">'+v+'</span><span class="stat-l">'+esc(label)+'</span><span class="stat-s">'+esc(sub)+'</span></span></button>').join(''));
|
||
const badge=(id,n,cls)=>{const b=el(id);b.textContent=n||'';b.className='count-badge'+(cls?' '+cls:'');b.hidden=!n};
|
||
const q=(data.questions||[]).length;
|
||
badge('navOverview',q+(data.attention||[]).length,q?'hot':'');badge('navLaunch',stopped,'');badge('navTasks',data.tasks.length,'');
|
||
badge('navReview',data.queue.length,'accent');badge('navActivity',working,'green');badge('navKnowledge',(data.pins||[]).length,'');
|
||
const me=data.agents.find(a=>a.name===HUMAN());badge('chatUnread',me&&me.unread||0,me&&me.urgent?'hot':'accent');
|
||
}
|
||
function step(done,label,title,wait){return '<span class="step'+(done?' done':wait?' wait':'')+'" title="'+esc(title||'')+'"><i>'+(done?'✓':wait?'…':'·')+'</i>'+esc(label)+'</span>'}
|
||
async function renderLaunch(){
|
||
let L;try{L=await get('api/launch')}catch(e){return}
|
||
const rows=L.agents.map(a=>{const rc=a.rollcall;const stateTxt=a.state==='stopped'?'stopped':a.state==='trial'?'trial until '+central(a.trial_until):'running';
|
||
return '<tr><td><div class="who">'+avatar(a.agent,'sm',a.state==='stopped'?'stopped':a.state==='trial'?'trial':a.working?'working':a.connected?'online':'')+'<div><strong>'+esc(a.agent)+'</strong><div class="note">'+esc(a.host||'')+'</div></div></div></td>'+
|
||
'<td>'+step(a.connected,'app connected',a.connected_via.join(', ')||'not seen through MCP or hooks yet')+'</td>'+
|
||
'<td>'+step(!!a.hooks,'stop gate',a.hooks?'hooks reported in '+central(a.hooks):'the app has not run Harbor hooks yet: a stop is not enforced for this agent and the live feed cannot see its work',a.connected&&!a.hooks)+'</td>'+
|
||
'<td>'+step(!!a.brief_at,'read brief',a.brief_at?central(a.brief_at):'not yet')+'</td>'+
|
||
'<td>'+step(rc&&rc.answer,'roll call',rc?(rc.answer?'answered in '+rc.seconds+' s':rc.read_at?'read, not answered yet':'not read yet'):'not asked',rc&&!rc.answer)+'</td>'+
|
||
'<td>'+step(a.state!=='stopped',stateTxt,a.stop_reason||'',a.state==='trial')+(a.working?' <span class="working">working</span>':'')+'</td>'+
|
||
'<td><div class="acts"><button class="btn sm" data-prompt="'+esc(a.agent)+'">'+ic('terminal','sm')+'Prompt</button><button class="btn sm" data-rollcall="'+esc(a.agent)+'">Roll call</button>'+
|
||
(a.state==='stopped'?'<select data-trialsel="'+esc(a.agent)+'" aria-label="Trial length"><option value="15m">15 min</option><option value="30m" selected>30 min</option><option value="1h">1 hour</option></select><button class="btn sm" data-trial="'+esc(a.agent)+'">Trial</button><button class="btn sm go" data-resume="'+esc(a.agent)+'">'+ic('play','sm')+'Resume</button>'
|
||
:'<button class="btn sm" data-stop="'+esc(a.agent)+'">'+ic('stop','sm')+'Stop</button>')+'</div></td></tr>'}).join('');
|
||
const keep={};document.querySelectorAll('[data-trialsel]').forEach(s=>keep[s.dataset.trialsel]=s.value);
|
||
const html='<table class="launch"><thead><tr><th>Agent</th><th>Connected</th><th>Hooks</th><th>Brief</th><th>Roll call</th><th>State</th><th></th></tr></thead><tbody>'+rows+'</tbody></table>'+(L.rollcall?'<div class="note">Last roll call '+esc(central(L.rollcall.at))+': '+L.rollcall.agents.filter(x=>x.answer).length+' of '+L.rollcall.agents.length+' answered.</div>':'');
|
||
if(html===renderLaunch.last||document.activeElement&&document.activeElement.matches('[data-trialsel]'))return;
|
||
renderLaunch.last=html;el('launch').innerHTML=html;
|
||
document.querySelectorAll('[data-trialsel]').forEach(s=>{if(keep[s.dataset.trialsel])s.value=keep[s.dataset.trialsel]});
|
||
}
|
||
async function renderFeed(){let f;try{f=await get('api/feed?limit=60')}catch(e){return}
|
||
put('feed',f.length?f.map(r=>'<li><span class="t">'+esc(r.seconds<60?r.seconds+' s':Math.round(r.seconds/60)+' min')+'</span><span class="who">'+avatar(r.agent,'xs')+esc(r.agent)+'</span><span class="tool">'+(r.tool?'<span class="tag">'+esc(r.tool)+'</span>':'')+'</span><span class="tg" title="'+esc(r.target||'')+'">'+esc(r.target||'')+'</span></li>').join('')
|
||
:'<li class="empty">'+ic('zap')+'<strong>No tool calls yet</strong><span>They appear here as soon as an agent works with Harbor\'s hooks installed.</span></li>')}
|
||
const PRIO={0:'urgent',1:'high',2:'normal',3:'low'};
|
||
let tasksCache=[];
|
||
async function renderTasks(){
|
||
const rows=el('tasksAll').checked?await get('api/tasks?all=1'):data.tasks;tasksCache=rows;
|
||
const open=data.tasks.length;el('taskCount').textContent=open?open+' open':'';
|
||
put('tasks',rows.length?'<table><thead><tr><th>#</th><th>Task</th><th>State</th><th>Who</th><th>Priority</th><th></th></tr></thead><tbody>'+rows.map(t=>'<tr><td class="mono">'+t.id+'</td><td>'+esc(t.title)+(t.scope?'<div class="note mono">'+esc(t.scope)+'</div>':'')+(t.waiting_on.length?'<div class="note">after '+t.waiting_on.map(d=>'#'+d).join(', ')+'</div>':'')+(t.state==='blocked'?'<div class="note err">blocked: '+esc(t.block_reason||'')+'</div>':'')+(t.item_id?'<div class="note">review item #'+t.item_id+'</div>':'')+'</td><td><span class="tag '+({open:'blue',in_progress:'violet',blocked:'red',review:'amber',done:'green'}[t.state]||'')+'">'+esc(t.state.replace('_',' '))+'</span></td><td>'+(t.assignee?'<span class="cell-who">'+avatar(t.assignee,'xs')+esc(t.assignee)+'</span>':'<span class="muted">anyone</span>')+'</td><td><span class="prio prio-'+esc(t.priority_name)+'">'+esc(t.priority_name)+'</span></td><td style="text-align:right">'+(['done','cancelled'].includes(t.state)?'<button class="btn sm" data-task="'+t.id+'" data-to="open">Reopen</button>':'<button class="btn sm ghost" data-task="'+t.id+'" data-to="cancelled">Cancel</button>')+'</td></tr>').join('')+'</tbody></table>'
|
||
:empty('tasks','No tasks','Add one above; idle agents take the best open task with <code>next</code>.'));
|
||
const opts=data.agents.filter(a=>a.kind!=='human').map(a=>a.name);const sel=el('tAssignee');if(sel.options.length!==opts.length+1){sel.replaceChildren(new Option('Anyone (next)',''),...opts.map(n=>new Option(n,n)))}
|
||
}
|
||
function renderPins(){
|
||
const p=data.pins||[];put('pins',p.length?p.map(msgHtml).join(''):empty('pin','Nothing pinned','Agents pin rules and decisions with <code>pin</code>; you can pin from the chat.'));
|
||
}
|
||
const PULSE_STEP=5*60000;
|
||
async function renderPulse(){
|
||
const hours=Number(el('pulseHours').value);let d;try{d=await get('api/pulse?hours='+hours)}catch(e){return}
|
||
const end=Date.now(),start=end-hours*3600000,cells=Math.ceil(hours*12);
|
||
const agents=data.agents.filter(a=>a.kind!=='human');
|
||
const rows=agents.map(a=>{const series=d.agents[a.name]||[];const counts=new Array(cells).fill(0);
|
||
for(const b of series){const i=Math.floor((new Date(b.bucket)-start)/PULSE_STEP);if(i>=0&&i<cells)counts[i]+=b.tools+b.messages}
|
||
const peak=Math.max(1,...counts);const w=100/cells;let total=counts.reduce((x,y)=>x+y,0);
|
||
const rects=counts.map((c,i)=>c?'<rect x="'+(i*w).toFixed(3)+'" y="0" width="'+(w+0.05).toFixed(3)+'" height="18" fill="'+esc(a.color||'#4688e6')+'" fill-opacity="'+(0.25+0.75*c/peak).toFixed(2)+'"><title>'+esc(central(new Date(start+i*PULSE_STEP).toISOString()))+': '+c+'</title></rect>':'').join('');
|
||
const idle=series.length?Math.round((end-new Date(series[series.length-1].bucket))/60000):null;
|
||
return '<div class="pulse-row"><div class="who">'+avatar(a.name,'xs')+esc(a.name)+'</div><div class="bar"><svg viewBox="0 0 100 18" preserveAspectRatio="none" role="img" aria-label="'+esc(a.name)+' activity">'+rects+'</svg></div><div class="note">'+(total?(idle!=null&&idle<10?'<span class="working">now</span>':'idle '+esc(ago(idle))):'no activity')+'</div></div>'}).join('');
|
||
const t0=new Date(start),tm=new Date(start+hours*1800000);
|
||
put('pulse',rows+'<div class="pulse-axis"><div></div><div><span>'+esc(central(t0.toISOString()))+'</span><span>'+esc(central(tm.toISOString()))+'</span><span>now</span></div><div></div></div>');
|
||
}
|
||
function sparkline(series){if(series.length<2)return '<span></span>';const v=series.map(s=>s.value),lo=Math.min(...v),hi=Math.max(...v),span=hi-lo||1;
|
||
const pts=v.map((x,i)=>(i*100/(v.length-1)).toFixed(1)+','+(28-((x-lo)/span)*26).toFixed(1)).join(' ');
|
||
return '<svg class="spark" viewBox="0 0 100 30" preserveAspectRatio="none" aria-hidden="true"><polygon points="0,30 '+pts+' 100,30" style="fill:var(--accent);fill-opacity:.12"/><polyline points="'+pts+'" style="fill:none;stroke:var(--accent)" stroke-width="1.8" stroke-linejoin="round" vector-effect="non-scaling-stroke"/></svg>'}
|
||
function renderScore(){
|
||
const m=data.metrics||{};const names=Object.keys(m);
|
||
put('metrics',names.length?names.map(n=>{const s=m[n].series,l=m[n].latest,first=s[0].value,diff=l.value-first;
|
||
return '<div class="metric"><div><div class="v">'+esc(Number(l.value).toLocaleString())+'</div><div class="note">'+esc(n)+' · '+esc(l.by_agent)+' · '+esc(ago(Math.round((Date.now()-new Date(l.at))/60000)))+'</div></div>'+sparkline(s)+'<div class="d'+(diff<0?' neg':'')+'">'+(diff?(diff>0?'+':'')+esc(Number(diff.toFixed(2)).toLocaleString()):'')+'</div></div>'}).join('')
|
||
:empty('activity','No metrics yet','Agents report numbers with <code>metric readable 779</code>.'));
|
||
const w=data.wins||[];el('winCount').textContent=w.length?w.length+' landed today':'';
|
||
put('wins',w.length?w.map(x=>'<li><span class="tag green">'+ic('check','sm')+'landed</span><span>'+esc(x.title)+' <span class="note">by '+esc(x.author)+' · '+esc(central(x.at))+'</span></span></li>').join(''):'<li class="note">Nothing landed in the last 24 hours.</li>');
|
||
}
|
||
function renderQuestions(){
|
||
const q=data.questions||[];el('questionsCard').hidden=!q.length;el('questionCount').textContent=q.length?q.length+' waiting':'';
|
||
put('questions',q.map(x=>'<div class="qitem">'+avatar(x.asker,'sm')+'<div class="grow"><div class="who"><strong>'+esc(x.asker)+'</strong> asks</div><div class="qt">'+esc(x.question)+'</div><div class="row">'+x.options.map(o=>'<button class="btn sm" data-answer="'+x.id+'" data-value="'+esc(o)+'">'+esc(o)+'</button>').join('')+'<button class="btn sm ghost" data-answer="'+x.id+'" data-value="">'+(x.options.length?'Other…':'Answer…')+'</button></div></div></div>').join(''));
|
||
}
|
||
function renderAvail(){
|
||
const s=data.presence||{status:'here'};const b=el('availBtn');b.className='avail '+(s.status==='here'?'':s.status);
|
||
b.textContent=s.status==='here'?'Here':(s.status==='away'?'Away':'Do not disturb')+(s.until?' until '+central(s.until):'');
|
||
}
|
||
function renderAttention(){
|
||
const a=data.attention||[];el('attentionCard').hidden=!a.length;el('attentionCount').textContent=a.length?a.length+' item'+(a.length>1?'s':''):'';
|
||
const label={unsubmitted:'Not submitted',old_base:'Old base',busy_no_progress:'Maybe stuck',collision:'Same file',questions:'Questions',ack_overdue:'Ack overdue',item_stale:'Review waiting',agent_quiet:'Quiet',due_overdue:'Answer overdue',task_overdue:'Task overdue',task_blocked:'Blocked',human_away:'While you are away',no_hooks:'No stop gate',resting:'Resting',unread_questions:'Unanswered'};
|
||
put('attention',a.map(x=>'<li><span class="tag amber">'+esc(label[x.kind]||x.kind)+'</span><span>'+esc(x.text)+'</span></li>').join(''));
|
||
}
|
||
async function renderDigest(){
|
||
try{const d=await get('api/digest?since='+encodeURIComponent(el('since').value));
|
||
const active=d.agents.filter(a=>a.messages||a.submitted||a.reviewed||a.landed||a.claims);
|
||
let h=active.length?'<table><thead><tr><th>Who</th><th>Messages</th><th>Submitted</th><th>Reviewed</th><th>Landed</th><th>Claims</th><th>Doing now</th></tr></thead><tbody>'+active.map(a=>'<tr><td><span class="cell-who">'+avatar(a.name,'xs')+esc(a.name)+'</span></td><td>'+a.messages+' in '+a.threads+' thread'+(a.threads===1?'':'s')+'</td><td>'+a.submitted+'</td><td>'+a.reviewed+'</td><td>'+a.landed+'</td><td>'+a.claims+'</td><td>'+esc(a.doing||'')+'</td></tr>').join('')+'</tbody></table>':empty('clock','A quiet stretch','Nobody did anything in this window.');
|
||
const ev=d.item_events.map(e=>'<li><span class="tag violet">#'+e.item_id+'</span><span>'+esc(e.title)+': '+esc(e.from_state||'new')+' → '+esc(e.to_state)+' by '+esc(e.agent)+'</span></li>').concat(d.flags.map(f=>'<li><span class="tag '+(f.value==='on'?'red':'green')+'">'+esc(f.key)+' '+esc(f.value)+'</span><span>by '+esc(f.set_by)+(f.reason?' · '+esc(f.reason):'')+'</span></li>'));
|
||
if(ev.length)h+='<h3 class="sub-h">Review and control changes</h3><ul class="attn">'+ev.join('')+'</ul>';
|
||
if(d.to_human.length)h+='<h3 class="sub-h">Messages to you</h3><div class="msgs">'+d.to_human.map(msgHtml).join('')+'</div>';
|
||
put('digest',h)}catch(e){put('digest','<div class="err">'+esc(e.message)+'</div>')}
|
||
}
|
||
function renderProjects(){
|
||
el('boardTitle').textContent=data.project.title;paintTitle();
|
||
const s=el('projectSelect');if(data.projects.length<2){s.hidden=true;return}s.hidden=false;
|
||
if(s.options.length!==data.projects.length)s.replaceChildren(...data.projects.map(n=>new Option(n,n)));s.value=data.project.name;
|
||
}
|
||
function renderQueue(){
|
||
const q=data.queue;el('queueCount').textContent=q.length?q.length+' open':'';
|
||
put('queue',q.length?'<table><thead><tr><th>#</th><th>Item</th><th>Author</th><th>State</th><th>Waiting</th></tr></thead><tbody>'+q.map(i=>'<tr class="click" data-thread="'+esc(i.thread_id||'')+'"><td class="mono">'+i.id+'</td><td>'+esc(i.title)+(i.scope?'<div class="note mono">'+esc(i.scope)+'</div>':'')+(i.commit_hash?'<div class="note mono">'+esc(String(i.commit_hash).slice(0,12))+(i.branch?' on '+esc(i.branch):'')+'</div>':'')+'</td><td><span class="cell-who">'+avatar(i.author,'xs')+esc(i.author)+'</span></td><td><span class="tag '+(STATE[i.state]||'')+'">'+esc(i.state.replace('_',' '))+'</span>'+(i.assignee?'<div class="note">'+esc(i.assignee)+'</div>':'')+'</td><td>'+esc(ago(i.idle_minutes))+'<div class="note">opened '+esc(ago(i.age_minutes))+'</div></td></tr>').join('')+'</tbody></table>'
|
||
:empty('review','Nothing waiting for review','Submitted work shows up here with its commit and reviewer.'));
|
||
}
|
||
function renderAcks(){
|
||
const groups=[],byId={};
|
||
data.pending_acks.forEach(r=>{let g=byId[r.id];if(!g){g=byId[r.id]={id:r.id,sender:r.sender,body:r.body,age:r.age_minutes,agents:[]};groups.push(g)}g.agents.push(r.agent)});
|
||
el('ackCount').textContent=groups.length?groups.length+' message'+(groups.length>1?'s':''):'';
|
||
put('acks',groups.length?'<table><thead><tr><th>#</th><th>From</th><th>Waiting on</th><th>Message</th><th>Sent</th></tr></thead><tbody>'+groups.map(g=>'<tr><td class="mono">'+g.id+'</td><td style="white-space:nowrap">'+esc(g.sender===HUMAN()?'you':g.sender)+'</td><td>'+esc(g.agents.map(n=>n===HUMAN()?'you':n).join(', '))+(g.agents.includes(HUMAN())?'<div style="margin-top:6px"><button class="btn sm primary" data-ack="'+g.id+'">Acknowledge</button></div>':'')+'</td><td>'+esc(g.body.length>=160?g.body+'…':g.body)+'</td><td>'+esc(ago(g.age))+'</td></tr>').join('')+'</tbody></table>'
|
||
:empty('check','All acknowledged','Every message that asked for an acknowledgement has one.'));
|
||
}
|
||
function renderClaims(){
|
||
const c=data.claims;el('claimCount').textContent=c.length?c.length+' active':'';
|
||
put('claims',c.length?'<table><thead><tr><th>Agent</th><th>Kind</th><th>Scope</th><th>Until</th></tr></thead><tbody>'+c.map(x=>'<tr><td><span class="cell-who">'+avatar(x.agent,'xs')+esc(x.agent)+'</span></td><td><span class="tag">'+esc(x.kind)+'</span></td><td class="mono">'+esc(x.scope)+(x.note?'<div class="note">'+esc(x.note)+'</div>':'')+'</td><td>'+esc(x.expires_at?central(x.expires_at):'standing')+'</td></tr>').join('')+'</tbody></table>'
|
||
:empty('flag','No claims','Nobody holds a claim right now.'));
|
||
}
|
||
async function renderThreads(){
|
||
const list=el('archive').checked?await get('api/threads?archive=1&limit=120'):data.threads;
|
||
put('threads',list.length?list.map(t=>'<li data-thread="'+t.id+'">'+ic('thread')+'<div class="t"><strong>'+esc(t.subject||t.topic)+'</strong>'+(t.subject&&t.subject!==t.topic?' <span class="note mono">'+esc(t.topic)+'</span>':'')+'</div>'+(t.state==='closed'?'<span class="tag">closed</span>':'')+(t.unread?'<span class="count-badge accent" title="new">'+t.unread+'</span>':'')+'<span class="s">'+t.messages+' msg</span></li>').join(''):'<li>'+empty('thread','No threads yet','')+'</li>');
|
||
}
|
||
const msgHtml=m=>'<article class="msg'+(m.importance==='urgent'?' urgent':'')+'">'+avatar(m.from,'sm')+'<div class="msg-main"><div class="h"><strong>'+esc(m.from===HUMAN()?'You':m.from)+'</strong>→ '+esc(m.to===HUMAN()?'you':m.to)+' · <span title="'+esc(m.time)+'">'+esc(central(m.time.replace(' ','T')))+'</span> · <span class="mono">#'+m.id+'</span>'+(m.topic?' <button class="tag blue" data-thread="'+esc(m.topic)+'" title="Open the thread">'+esc(m.topic.startsWith('legacy:')?'archive '+m.topic.slice(7):m.topic)+'</button>':'')+(m.kind!=='note'?' <span class="tag violet">'+esc(m.kind)+'</span>':'')+(m.importance!=='normal'?' <span class="tag '+(m.importance==='urgent'?'red':'amber')+'">'+esc(m.importance)+'</span>':'')+(m.ack?' <span class="tag">ack</span>':'')+'</div><div class="b">'+esc(m.text)+'</div>'+((Array.isArray(m.attachments)?m.attachments:[]).length?'<div class="row" style="margin-top:6px">'+m.attachments.map(f=>'<a class="tag blue" href="files/'+encodeURIComponent(f)+'">'+ic('file','sm')+esc(f.slice(13))+'</a>').join('')+'</div>':'')+'</div></article>';
|
||
async function showThread(id){
|
||
const t=await get('api/thread?id='+encodeURIComponent(id));openThread=t.thread;openThread.last=t.messages.length?t.messages[t.messages.length-1].id:null;
|
||
el('threadTitle').textContent=(t.thread.subject||t.thread.topic)+' · '+t.messages.length+' messages';
|
||
el('threadMsgs').innerHTML=(t.summary?'<div class="summary"><b>Summary by '+esc(t.summary.author)+'</b> · '+esc(central(t.summary.at))+'\n'+esc(t.summary.body)+'</div>':'')+(t.messages.map(msgHtml).join('')||empty('thread','Empty',''));
|
||
el('replyNote').textContent='';el('threadToChat').hidden=t.thread.topic.startsWith('legacy:');el('replyForm').hidden=t.thread.topic.startsWith('legacy:');el('settleThread').hidden=t.thread.topic.startsWith('legacy:');el('settleThread').textContent=t.thread.state==='closed'?'Reopen thread':'Close thread';
|
||
if(!el('threadDialog').open)el('threadDialog').showModal();
|
||
el('threadMsgs').scrollTop=el('threadMsgs').scrollHeight;
|
||
}
|
||
async function runDoctor(){doctorRan=true;el('runDoctor').disabled=true;el('healthSum').textContent='';
|
||
put('health',empty('refresh','Checking…','Looking at every agent app, the database, backups and the server.'));
|
||
try{const c=await get('api/doctor');const tone={ok:'green',warn:'amber',fail:'red',info:''};const n=s=>c.filter(x=>x.status===s).length;
|
||
el('healthSum').textContent=n('ok')+' ok · '+n('warn')+' warnings · '+n('fail')+' failing';
|
||
put('health','<table><thead><tr><th>Status</th><th>Area</th><th>Check</th></tr></thead><tbody>'+c.map(x=>'<tr><td><span class="tag '+(tone[x.status]||'')+'"><span class="dot"></span>'+esc(x.status)+'</span></td><td class="mono">'+esc(x.area)+'</td><td>'+esc(x.check)+'<div class="note">'+esc(x.detail)+'</div>'+(x.fix&&x.status!=='ok'?'<div class="note">→ '+esc(x.fix)+'</div>':'')+'</td></tr>').join('')+'</tbody></table>')}
|
||
catch(err){put('health','<div class="empty err">'+esc(err.message)+'</div>')}
|
||
finally{el('runDoctor').disabled=false}}
|
||
async function refresh(){
|
||
try{data=await get('api/overview');el('toasts').checked=!!data.toasts;
|
||
{const s=el('bulkTemplate');const names=data.templates||[];if(s.options.length!==names.length+1)s.replaceChildren(new Option('No template',''),...names.map(n=>new Option('Template: '+n,n)))}
|
||
renderControl();renderStats();renderAttention();renderProjects();renderAvail();renderPins();renderScore();renderQuestions();if(!el('tasksAll').checked)renderTasks();renderQueue();renderAcks();renderClaims();if(!el('archive').checked)renderThreads();
|
||
el('conn').className='live ok';el('connText').textContent='Live · '+central(data.now)}
|
||
catch(e){el('conn').className='live bad';el('connText').textContent='Reconnecting'}
|
||
}
|
||
async function flag(target,value){
|
||
if(value==='off'&&!confirm('Resume '+(target==='all'?'everyone':target)+'? Workers that check Harbor will start working again.'))return;
|
||
try{await post('api/flag',{target,value,reason:el('reason').value.trim()||null});el('reason').value='';await refresh();if(current==='launch')renderLaunch()}catch(e){alert('Not changed: '+e.message)}
|
||
}
|
||
el('stopAll').onclick=()=>flag('all','on');el('resumeAll').onclick=()=>flag('all','off');
|
||
document.addEventListener('click',async e=>{
|
||
const go=e.target.closest('[data-go]');if(go)return show(go.dataset.go);
|
||
if(e.target.closest('#view-chat,[data-filter]'))return;
|
||
const s=e.target.closest('[data-stop]'),r=e.target.closest('[data-resume]'),a=e.target.closest('[data-ack]'),t=e.target.closest('[data-thread]');
|
||
const pg=e.target.closest('[data-ping]'),pr=e.target.closest('[data-prompt]'),tk=e.target.closest('[data-task]'),an=e.target.closest('[data-answer]');
|
||
const rcb=e.target.closest('[data-rollcall]'),trb=e.target.closest('[data-trial]');
|
||
if(rcb){rcb.disabled=true;try{await post('api/rollcall',{agents:[rcb.dataset.rollcall]});rcb.textContent='Asked';renderLaunch()}catch(err){alert(err.message);rcb.disabled=false}return}
|
||
if(trb){const who=trb.dataset.trial;const len=(document.querySelector('[data-trialsel="'+who+'"]')||{}).value||'30m';
|
||
if(!confirm('Let '+who+' work for '+len.replace('m',' minutes').replace('1h','1 hour')+'? The stop comes back by itself afterwards.'))return;
|
||
try{await post('api/flag',{target:who,value:'off',until:len,reason:'trial run'});await refresh();renderLaunch()}catch(err){alert(err.message)}return}
|
||
if(an){let value=an.dataset.value;if(!value){value=prompt('Your answer');if(!value)return;value=(an.parentElement.querySelectorAll('[data-answer]').length>1?'other: ':'')+value}
|
||
an.disabled=true;try{await post('api/answer',{question:Number(an.dataset.answer),answer:value});await refresh()}catch(err){an.disabled=false;alert(err.message)}return}
|
||
if(pg){pg.disabled=true;try{await post('api/ping',{agent:pg.dataset.ping});pg.textContent='Pinged'}catch(err){alert(err.message);pg.disabled=false}return}
|
||
if(pr){try{const j=await get('api/prompt?agent='+encodeURIComponent(pr.dataset.prompt));el('promptTitle').textContent='Restart prompt for '+pr.dataset.prompt;el('promptText').value=j.prompt;el('promptNote').textContent='Paste this into a fresh session for the agent.';el('promptDialog').showModal()}catch(err){alert(err.message)}return}
|
||
if(tk){const to=tk.dataset.to;if(to==='cancelled'&&!confirm('Cancel task #'+tk.dataset.task+'?'))return;try{await post('api/task',{action:'update',id:Number(tk.dataset.task),state:to});await refresh();renderTasks()}catch(err){alert(err.message)}return}
|
||
if(s)return flag(s.dataset.stop,'on');if(r)return flag(r.dataset.resume,'off');
|
||
if(a){try{await post('api/ack',{ids:[Number(a.dataset.ack)]});refresh()}catch(err){alert(err.message)}return}
|
||
if(t&&t.dataset.thread)showThread(t.dataset.thread).catch(err=>alert(err.message));
|
||
});
|
||
el('closeThread').onclick=()=>el('threadDialog').close();
|
||
el('threadToChat').onclick=()=>{if(!openThread)return;const t=openThread.topic;el('threadDialog').close();Chat.openThread(t)};
|
||
el('exportThread').onclick=()=>{if(openThread)location.href='api/export?thread='+encodeURIComponent(openThread.topic)};
|
||
el('settleThread').onclick=async()=>{if(!openThread)return;try{await post('api/thread-state',{thread:openThread.topic,state:openThread.state==='closed'?'open':'closed'});await showThread(openThread.id);refresh()}catch(err){alert(err.message)}};
|
||
el('since').onchange=renderDigest;
|
||
el('tasksAll').onchange=renderTasks;
|
||
el('toasts').onchange=async e=>{try{await post('api/toasts',{on:e.target.checked})}catch(err){alert(err.message);e.target.checked=!e.target.checked}};
|
||
el('bulkForm').onsubmit=async e=>{e.preventDefault();const text=el('bulkText').value.trim();if(!text)return;
|
||
try{const r=await post('api/bulk',{text,template:el('bulkTemplate').value||null,priority:el('bulkPriority').value});
|
||
el('bulkNote').textContent='Added '+r.created+' task(s)'+(r.skipped.length?'; skipped: '+r.skipped.join('; '):'')+'.';el('bulkText').value='';await refresh();renderTasks()}
|
||
catch(err){el('bulkNote').innerHTML='<span class="err">'+esc(err.message)+'</span>'}};
|
||
el('taskForm').onsubmit=async e=>{e.preventDefault();const title=el('tTitle').value.trim();if(!title)return;
|
||
const after=el('tAfter').value.split(/[\s,#]+/).filter(Boolean).map(Number).filter(n=>n>0);
|
||
try{const r=await post('api/task',{action:'add',title,scope:el('tScope').value.trim()||null,assignee:el('tAssignee').value||null,priority:el('tPriority').value,after});
|
||
el('tTitle').value='';el('tScope').value='';el('tAfter').value='';el('taskNote').textContent='Added task #'+r.task.id+'.';await refresh();renderTasks()}
|
||
catch(err){el('taskNote').innerHTML='<span class="err">'+esc(err.message)+'</span>'}};
|
||
el('rollcallAll').onclick=async()=>{try{await post('api/rollcall',{});renderLaunch()}catch(err){alert(err.message)}};
|
||
el('runDoctor').onclick=runDoctor;
|
||
el('availBtn').onclick=()=>{const s=data&&data.presence||{status:'here'};for(const r of document.querySelectorAll('[name=st]'))r.checked=r.value===s.status;el('availNote').value=s.note||'';el('availDialog').showModal()};
|
||
el('availClose').onclick=()=>el('availDialog').close();
|
||
el('availForm').onsubmit=async e=>{e.preventDefault();const status=document.querySelector('[name=st]:checked').value;let until=el('availUntil').value||null;
|
||
if(until==='tomorrow'){const d=new Date();d.setDate(d.getDate()+1);d.setHours(8,0,0,0);until=d.toISOString().replace(/\.\d+Z$/,'Z')}
|
||
try{await post('api/presence',{status,until:status==='here'?null:until,note:el('availNote').value.trim()||null});el('availDialog').close();refresh()}catch(err){alert(err.message)}};
|
||
el('promptClose').onclick=()=>el('promptDialog').close();
|
||
el('promptCopy').onclick=async()=>{const t=el('promptText');try{await navigator.clipboard.writeText(t.value);el('promptNote').textContent='Copied.'}catch(e){t.select();document.execCommand('copy');el('promptNote').textContent='Copied.'}};
|
||
el('projectSelect').onchange=e=>{location.href='/p/'+encodeURIComponent(e.target.value)+'/board'+location.hash};
|
||
el('archive').onchange=()=>renderThreads();
|
||
el('replyForm').onsubmit=async e=>{e.preventDefault();const text=el('replyText').value.trim();if(!text||!openThread)return;
|
||
try{const j=await post('send',{text,thread:openThread.topic,reply_to:openThread.last,ack:el('replyAck').checked,importance:el('replyImportance').value});el('replyText').value='';el('replyNote').textContent=j.warnings&&j.warnings.length?'Sent · '+j.warnings.join('; '):'Sent';await showThread(openThread.id)}catch(err){el('replyNote').innerHTML='<span class="err">'+esc(err.message)+'</span>'}};
|
||
el('searchForm').onsubmit=async e=>{e.preventDefault();const q=el('q').value.trim();if(!q)return;
|
||
try{let ctx='';if(/^(fn:|range:|file:|name:|task:)\S+$|^0x[0-9a-f]+$/i.test(q)){const c=await get('api/context?scope='+encodeURIComponent(q));ctx='<div class="ctx">'+esc(c.text)+'</div>'}
|
||
const hits=ctx?[]:await get('api/search?q='+encodeURIComponent(q));el('results').innerHTML=ctx+(hits.length?hits.map(msgHtml).join(''):(ctx?'':empty('search','No message matches','Try fewer words, or a filter like <code>from:Kimi</code>.')))}
|
||
catch(err){el('results').innerHTML='<div class="err">'+esc(err.message)+'</div>'}};
|
||
el('menuBtn').onclick=()=>el('shell').classList.add('open');
|
||
el('scrim').onclick=()=>el('shell').classList.remove('open');
|
||
window.addEventListener('hashchange',()=>{const v=location.hash.slice(1);if(v&&v!==current)show(v)});
|
||
const TIERS=[['exact','Exact match','byte-identical with the game’s own compiler'],['verified','Verified','behaves the same as the game in the emulator'],
|
||
['readable','Readable','readable C that passed review'],['deferred','Deferred','set aside with a reason after failed attempts'],['raw','Raw','not decompiled yet'],
|
||
['library','Library','C runtime, D3DX9 and other linked libraries: never decompiled'],['engine','Engine','Gamebryo engine code: names only']];
|
||
const fmt=n=>Number(n||0).toLocaleString('en-US');
|
||
function renderDecomp(){renderProgress();renderNextUp();renderDeferred()}
|
||
async function renderProgress(){let p;try{p=await get('api/decomp/progress')}catch(e){put('progress','<div class="empty err">'+esc(e.message)+'</div>');return}
|
||
const t=p.tiers,own=['exact','verified','readable','deferred','raw'].reduce((a,k)=>a+t[k].functions,0),done=t.exact.functions+t.verified.functions+t.readable.functions;
|
||
el('progressSum').textContent=fmt(done)+' of '+fmt(own)+' game functions decompiled';
|
||
put('progress',tierBar(t)+'<div class="lb-legend">'+TIERS.map(([k,l])=>'<span><i class="tier-key tier-'+k+'"></i>'+esc(l)+' '+fmt(t[k].functions)+'</span>').join('')+'</div><div class="note" style="margin-top:10px">Bytes, categories, the trend and the ship’s log: <a href="#logbook" data-go="logbook">open the Logbook</a>.</div>');}
|
||
function tierTable(t){return '<table style="margin-top:14px"><thead><tr><th>Tier</th><th style="text-align:right">Functions</th><th style="text-align:right">Bytes</th><th>Meaning</th></tr></thead><tbody>'
|
||
+TIERS.map(([k,l,d])=>'<tr><td><span class="tier-key tier-'+k+'"></span>'+esc(l)+'</td><td class="num">'+fmt(t[k].functions)+'</td><td class="num">'+fmt(t[k].bytes)+'</td><td class="note">'+esc(d)+'</td></tr>').join('')+'</tbody></table>'}
|
||
function tierBar(t){return '<div class="tierbar" role="img" aria-label="'+esc(TIERS.map(([k,l])=>l+' '+t[k].functions).join(', '))+'">'+TIERS.map(([k,l])=>t[k].functions?'<span class="tier-'+k+'" style="flex:'+t[k].functions+'" title="'+esc(l)+': '+fmt(t[k].functions)+' functions"></span>':'').join('')+'</div>'}
|
||
async function renderNextUp(){if(!el('nextUp')._html)put('nextUp',empty('clock','Ranking the bands…','This takes a few seconds the first time.'));let q;
|
||
try{q=await get('api/decomp/queues')}catch(e){put('nextUp','<div class="empty err">'+esc(e.message)+'</div>');return}
|
||
put('nextUp',q.length?q.map(a=>'<div class="band"><div class="cell-who">'+avatar(a.agent,'xs')+'<b>'+esc(a.agent)+'</b><span class="note mono">'+esc(a.bands.map(b=>b.join('–')).join(', '))+'</span></div>'
|
||
+(a.next.length?'<ul class="attn">'+a.next.map(f=>'<li><a href="#" class="mono" data-fn="'+esc(f.address)+'">'+esc(f.address)+'</a> '+esc(f.name||(f.file||'').replace(/\.c$/,''))+'<div class="note">'+esc(f.why)+'</div></li>').join('')+'</ul>'
|
||
:'<div class="note">'+esc(a.note||'Nothing left in this band.')+'</div>')+'</div>').join(''):empty('list','No bands yet','The coordinator gives each agent an address band.'))}
|
||
async function renderDeferred(){let d;try{d=await get('api/decomp/deferred')}catch(e){return}el('deferCount').textContent=d.length||'';
|
||
put('deferred',d.length?'<table><thead><tr><th>Function</th><th>Reason</th><th>Evidence</th></tr></thead><tbody>'+d.map(x=>'<tr><td><a href="#" class="mono" data-fn="'+esc(x.address)+'">'+esc(x.address)+'</a><div class="note">'+esc(x.name||'')+'</div></td><td><span class="tag amber">'+esc(x.reason)+'</span><div class="note">'+esc(x.author)+'</div></td><td class="note">'+esc(x.evidence)+'</td></tr>').join('')+'</tbody></table>'
|
||
:empty('check','Nothing deferred','After two failed attempts an agent sets a function aside here, with the reason and what it found.'))}
|
||
async function inspectFn(){const target=el('fnTarget').value.trim();if(!target)return;put('fnInfo',empty('search','Looking it up…',''));let f;
|
||
try{f=await get('api/decomp/function?target='+encodeURIComponent(target))}catch(e){put('fnInfo','<div class="empty err">'+esc(e.message)+'</div>');return}
|
||
const fn=f.function||{},mac=f.mac_name,src=f.source_file,m=f.match,b=f.behaviour,c=f.convention_from_bytes;
|
||
const row=(k,v)=>v?'<dt>'+esc(k)+'</dt><dd>'+v+'</dd>':'';
|
||
const note=f.names_row?String(f.names_row).split(',').slice(3).join(',').replace(/^"|"$/g,'').replace(/""/g,'"'):'';
|
||
const exact=m&&['exact','exact-relaxed','match'].includes(m.status),near=m&&m.status==='exact-but-targets-differ';
|
||
put('fnInfo','<div class="fn-head"><h3 class="mono">'+esc(fn.start||f.address)+'</h3><b>'+esc(fn.name||'unnamed')+'</b>'
|
||
+'<span class="tag '+(f.status==='readable'?'violet':'')+'">'+esc(f.status)+'</span>'+(f.library?'<span class="tag">library</span>':'')
|
||
+(m?'<span class="tag '+(exact?'green':near?'amber':'')+'">'+(exact?'exact match':near?'same bytes, different targets':esc(m.status)+(m.similarity!=null?' · '+Math.round(m.similarity*100)+'% similar':''))+'</span>':'')
|
||
+(b?'<span class="tag '+(b.verdict==='same'?'green':b.verdict==='differs'?'red':'')+'">emulator: '+esc(b.verdict)+'</span>':'')
|
||
+'<span class="note">'+esc(fn.size??'?')+' bytes · '+esc(f.category||'')+'</span></div>'
|
||
+'<dl class="fn-facts">'+row('Original name',mac?'<code>'+esc(mac.demangled)+'</code> <span class="note">'+esc(mac.confidence)+' confidence, from the Mac build</span>':'')
|
||
+row('Source file',src?'<code>'+esc(src.source_file)+'</code>'+(src.lines?' line '+esc(src.lines):'')+' <span class="note">'+esc(src.confidence)+' confidence, from the Xbox build</span>':'')
|
||
+row('Convention',c?esc(c.summary)+(c.ret_pops&&c.ret_pops.some(x=>x)?' · cleans up '+esc(c.ret_pops.join('/'))+' bytes':''):'')
|
||
+row('Decompiled file',f.src_file?'<span class="mono">'+esc(f.src_file)+'</span>':'')
|
||
+row('Deferred',f.deferred?'<span class="tag amber">'+esc(f.deferred.reason)+'</span> '+esc(f.deferred.evidence):'')
|
||
+row('Emulator',b&&b.verdict!=='same'?esc(b.reason||''):b&&b.weak?'same, but no trial ran to the end in both':'')
|
||
+row('Prototype',f.prototype?'<code>'+esc(f.prototype)+'</code>':'')+row('Team note',note?esc(note.slice(0,700)):'')+'</dl>'
|
||
+(f.similar&&f.similar.length?'<h3 class="sub-h">Near-duplicates</h3><ul class="attn">'+f.similar.map(s=>'<li><a href="#" class="mono" data-fn="'+esc(s.address)+'">'+esc(s.address)+'</a> '+esc(s.name||'')+' <span class="note">'+Math.round(s.score*100)+'% alike · '+esc(s.status)+'</span></li>').join('')+'</ul>':'')
|
||
+'<details data-k="disasm"><summary>Disassembly</summary><pre class="disasm">'+esc(f.disassembly||'')+'</pre></details>')}
|
||
async function renderTools(){let t;try{t=await get('api/decomp/tools')}catch(e){put('retools','<div class="empty err">'+esc(e.message)+'</div>');return}
|
||
const bad=t.filter(x=>!x.ok).length;el('reSum').textContent=bad?bad+' to check':'all in place';
|
||
put('retools','<table><thead><tr><th>Status</th><th>Tool</th></tr></thead><tbody>'+t.map(x=>'<tr><td><span class="tag '+(x.ok?'green':'amber')+'"><span class="dot"></span>'+(x.ok?'ok':'check')+'</span></td><td>'+esc(x.tool)+'<div class="note">'+esc(x.detail)+'</div></td></tr>').join('')+'</tbody></table>')}
|
||
const LB_CATS=[['game','Game code','Firaxis game logic: decompiled to readable C, then ported to Rust'],['engine','Engine','Gamebryo: named and documented, replaced in the port'],
|
||
['platform','Platform','Miles, Bink, D3D9 and input wrappers: replaced in the port'],['crt','C runtime','MSVC 7.1 runtime: replaced by Rust’s standard library'],['unsorted','Unsorted','not classified yet; each lands in one of the four above']];
|
||
let LB=null;
|
||
const pct=n=>Number(n||0).toFixed(Number(n)>=10?1:2)+'%';
|
||
const kb=b=>b>=1048576?(b/1048576).toFixed(2)+' MB':Math.round((b||0)/1024)+' KB';
|
||
const cssVar=n=>getComputedStyle(el('view-logbook')).getPropertyValue(n).trim();
|
||
async function renderLogbook(){try{LB=await get('api/logbook')}catch(e){put('lbStamp','<span class="err">'+esc(e.message)+'</span>');return}
|
||
const s=LB.stats||{},cat=k=>(s.by_category||{})[k]||{functions:0,bytes:0};
|
||
const cur=LB.milestones.find(m=>/progress/i.test(m.state||''));el('lbMilestone').textContent=cur?'Milestone '+cur.code+' · '+cur.title:'Milestones';
|
||
const au=LB.auto||{};put('lbStamp',(s.updated_at?'Statistics updated <b>'+esc(central(s.updated_at))+'</b><br>main at <b>'+esc(s.commit||'–')+'</b>':'No statistics yet')+(au.last_refresh?'<br>Keeps itself current: last checked '+esc(central(au.last_refresh))+(au.last_error?' · <span class="err">'+esc(String(au.last_error).slice(0,120))+'</span>':''):''));
|
||
const total=s.functions||0,game=cat('game').functions,done=(s.by_status&&s.by_status.decompiled||{}).functions||0,uns=cat('unsorted').functions;
|
||
const handled=total-uns-Math.max(0,game-done);
|
||
put('lbFigs',[[pct(total?100*handled/total:0)+' done',fmt(handled)+' of '+fmt(total)+' functions handled','game code decompiled, everything else identified'],
|
||
[fmt(done)+' of '+fmt(game),'game functions decompiled to readable C',fmt(game-done)+' to go'],
|
||
[fmt(uns),'functions not sorted yet','each one is either game code (to decompile) or engine/library code (to name)']]
|
||
.map(([v,l,n])=>'<div class="lb-fig"><div class="v">'+esc(v)+'</div><div class="l">'+esc(l)+'</div><div class="n">'+esc(n)+'</div></div>').join(''));
|
||
put('lbLegend',LB_CATS.map(([k,n])=>'<span><i class="lb-sw'+(k==='unsorted'?' lb-hatch':'')+'" style="background:var(--lb-'+k+')"></i>'+esc(n)+'</span>').join('')+'<span><i class="lb-sw" style="background:var(--lb-done)"></i>decompiled share under each column</span>');
|
||
drawLbMap();pmLoad().then(()=>{if(PM.doc)pmLayout()});renderLbWhere(s);renderLbTrend(LB.history);renderLbMilestones(LB.milestones);
|
||
el('lbLogCount').textContent=LB.log_total?LB.log_total+' entries':'';
|
||
put('lbLog',LB.log.length?LB.log.map(e=>'<li><time>'+esc(central(e.at))+'</time><div><div class="t"><span class="kind">'+esc(e.kind)+'</span>'+esc(e.title)+'</div><div class="d">'+esc(e.detail)+'</div></div></li>').join(''):'<li class="note">No entries yet.</li>')}
|
||
async function renderLbWhere(s){const total=s.bytes||0,cat=k=>(s.by_category||{})[k]||{functions:0,bytes:0},uns=cat('unsorted'),sorted=total?100*(total-uns.bytes)/total:0;
|
||
let tiers='';try{const p=await get('api/decomp/progress');tiers='<h3 class="sub-h" style="margin-top:18px">Game functions by how far they have come</h3>'+tierBar(p.tiers)+tierTable(p.tiers)}catch(e){}
|
||
put('lbWhere','<div><span class="lb-big">'+pct(sorted)+'</span> <span class="note">of the executable’s code is sorted into a category · '+fmt(uns.functions)+' functions ('+kb(uns.bytes)+') still unsorted</span></div>'
|
||
+'<div class="lb-stack" role="img" aria-label="Code bytes by category">'+LB_CATS.map(([k,n])=>{const sh=total?100*cat(k).bytes/total:0;return '<i class="'+(k==='unsorted'?'lb-hatch':'')+'" style="flex:0 0 '+sh.toFixed(3)+'%;background:var(--lb-'+k+')" title="'+esc(n)+': '+sh.toFixed(1)+'% of bytes"></i>'}).join('')+'</div>'
|
||
+'<div class="lb-axis"><span>0</span><span>sorted '+pct(sorted)+' · unsorted '+pct(100-sorted)+'</span><span>'+kb(total)+'</span></div>'
|
||
+LB_CATS.map(([k,n,plan])=>{const c=cat(k),sh=total?100*c.bytes/total:0;return '<div class="lb-cat"><span class="sw'+(k==='unsorted'?' lb-hatch':'')+'" style="background:var(--lb-'+k+')"></span><div><b>'+esc(n)+'</b><div class="plan">'+esc(plan)+'</div></div><div class="nums"><b>'+sh.toFixed(1)+'%</b> of bytes<br>'+fmt(c.functions)+' fn · '+kb(c.bytes)+'</div>'
|
||
+(k==='game'?'<div class="sub"><div class="tierbar" style="height:8px"><span class="tier-readable" style="flex:'+(s.game_bytes_decompiled_percent||0)+'"></span><span style="flex:'+Math.max(0,100-(s.game_bytes_decompiled_percent||0))+';background:transparent"></span></div><div class="note">'+pct(s.game_bytes_decompiled_percent)+' of game bytes decompiled</div></div>':'')+'</div>'}).join('')
|
||
+'<div class="note" style="margin-top:10px">Bar and shares are by bytes of code; counts are functions. Only game code is decompiled and ported; the rest is named and replaced.</div>'+tiers)}
|
||
function drawLbMap(){const m=LB&&LB.map,c=el('lbMap');if(!c||!m||!c.clientWidth)return;const r=window.devicePixelRatio||1,w=c.clientWidth,h=c.clientHeight;
|
||
c.width=Math.round(w*r);c.height=Math.round(h*r);const g=c.getContext('2d');g.setTransform(r,0,0,r,0,0);g.clearRect(0,0,w,h);
|
||
const cells=m.cells.map(x=>x.split(',').map(Number)),max=Math.max(1,...cells.map(x=>x.slice(0,5).reduce((a,b)=>a+b,0)));
|
||
const colors=LB_CATS.map(([k])=>cssVar('--lb-'+k)),done=cssVar('--lb-done'),rule=cssVar('--line'),cw=w/cells.length,base=h-12;
|
||
g.fillStyle=rule;g.fillRect(0,base,w,1);
|
||
cells.forEach((x,i)=>{let y=base;for(let k=0;k<5;k++){const hh=x[k]/max*(base-4);if(hh<=0)continue;g.fillStyle=colors[k];g.fillRect(i*cw+.5,y-hh,Math.max(1,cw-1),hh);y-=hh}
|
||
const tot=x.slice(0,5).reduce((a,b)=>a+b,0);if(tot>0&&x[5]>0){g.fillStyle=done;g.fillRect(i*cw+.5,base+4,Math.max(1,(cw-1)*Math.min(1,x[5]/tot)),6)}});
|
||
el('lbLow').textContent=m.low;el('lbHigh').textContent=m.high}
|
||
function renderLbTrend(points){const svg=el('lbTrend');if(!points.length){svg.innerHTML='';el('lbTrendNote').textContent='The trend starts with the second report.';return}
|
||
const n=points.length,x=i=>n===1?150:i*300/(n-1),y=v=>104-Math.min(100,v||0)/100*96,path=k=>points.map((p,i)=>(i?'L':'M')+x(i).toFixed(1)+','+y(p[k]).toFixed(1)).join(' ');
|
||
svg.innerHTML=[0,25,50,75,100].map(v=>'<line x1="0" x2="300" y1="'+y(v)+'" y2="'+y(v)+'" stroke="var(--line)" stroke-width="0.5"/>').join('')
|
||
+[['classified','--lb-game'],['decompiled','--lb-platform'],['done','--lb-done']].map(([k,c])=>{const pts=points.map((p,i)=>[i,p[k]]).filter(q=>q[1]!=null);return pts.length?'<path d="'+pts.map(([i,v],j)=>(j?'L':'M')+x(i).toFixed(1)+','+y(v).toFixed(1)).join(' ')+'" fill="none" stroke="var('+c+')" stroke-width="2.5" vector-effect="non-scaling-stroke"/>':''}).join('');
|
||
el('lbTrendNote').textContent=n+' reports since '+new Date(points[0].at).toLocaleDateString()+'. Scale 0–100%.'}
|
||
function renderLbMilestones(list){let last='';put('lbMilestones',list.length?list.map(m=>{const head=m.phase&&m.phase!==last?'<div class="lb-phase">'+esc(m.phase)+'</div>':'';last=m.phase||last;
|
||
const items=m.items||[],done=items.filter(i=>i.done).length,li=items.map(i=>'<li class="'+(i.done?'done':'')+'">'+esc(i.text)+'</li>').join(''),open=/progress/i.test(m.state||'');
|
||
return head+'<div class="lb-ms"><h3><span class="code">'+esc(m.code)+'</span>'+esc(m.title)+'<span class="note">'+done+'/'+items.length+(m.state?' · '+esc(m.state):'')+'</span></h3>'+(open?'<ul>'+li+'</ul>':'<details data-k="ms-'+esc(m.id)+'"><summary class="note">Show '+items.length+' steps</summary><ul>'+li+'</ul></details>')+'</div>'}).join(''):empty('flag','No milestones yet',''))}
|
||
window.addEventListener('resize',()=>{if(current==='logbook')drawLbMap()});
|
||
/* progress map (decomp.dev style): one compact document per change, laid out once per load, filter or resize, drawn on a canvas */
|
||
const PM={doc:null,version:null,unit:null,hist:null,rects:[],items:[],hover:-1,base:null,colors:null,busy:false,topFilter:''};
|
||
const PM_LABEL={exact:'exact match',verified:'verified in the emulator',readable:'readable',deferred:'deferred',raw:'raw',unsorted:'unsorted (not counted)',engine:'engine: named, not decompiled',library:'library: named, not decompiled'};
|
||
const PM_KIND={game:'game code',unsorted:'unsorted',engine:'engine',library:'library'};
|
||
const kB=b=>b>=1e6?(b/1e6).toFixed(2)+' MB':(b/1e3).toFixed(2)+' kB';
|
||
const hx=a=>'0x'+Number(a).toString(16).toUpperCase();
|
||
const pc=(b,t)=>(t?100*b/t:0).toFixed(2);
|
||
function rel(iso){const t=Date.parse(iso||'');if(!t)return 'at an unknown time';const s=(Date.now()-t)/1000;if(s<90)return 'just now';const m=s/60;if(m<90)return Math.round(m)+' minutes ago';const h=m/60;if(h<36)return Math.round(h)+' hours ago';return Math.round(h/24)+' days ago'}
|
||
async function pmLoad(){if(PM.busy)return;PM.busy=true;try{
|
||
const d=await get('api/logbook/progress-map'+(PM.version?'?have='+encodeURIComponent(PM.version):''));if(d.same)return;
|
||
const U=d.unit_fields;d.units=d.units.map((u,i)=>{const o={i,fns:[]};U.forEach((k,j)=>o[k]=u[j]);o.pct=o.counted?100*(o.exact+o.verified+o.readable)/o.counted:null;o.lname=o.name.toLowerCase();return o});
|
||
d.functions.forEach(f=>{const o={unit:f[0],address:f[1],size:f[2],status:d.statuses[f[3]],name:f[4]};o.lname=o.name.toLowerCase();d.units[o.unit].fns.push(o)});delete d.functions;
|
||
PM.doc=d;PM.version=d.version;if(PM.unit!=null&&!d.units[PM.unit])PM.unit=null;
|
||
PM.latest=(()=>{const h=d.history;for(let i=h.length-1;i>=0;i--)if(h[i][0]===d.updated_at)return i;return h.length})();
|
||
pmUnitsSelect();pmHeader();pmLayout()}
|
||
catch(e){put('pmBars','<div class="err">'+esc(e.message)+'</div>')}finally{PM.busy=false}}
|
||
function pmBar(label,size,segs,total,extra){const sum=segs.reduce((a,s)=>a+s[1],0);
|
||
return '<div class="pm-bar-row"><div class="pm-bar-l"><b>'+esc(label)+'</b><span class="note">('+esc(size)+')</span>'+(extra?'<span class="note">'+esc(extra)+'</span>':'')+'<span class="grow"></span><span class="mono">'+pc(sum,total)+'%</span></div>'
|
||
+'<div class="pm-bar" role="img" aria-label="'+esc(label+': '+segs.filter(s=>s[1]>0).map(s=>s[2]).join('; '))+'">'+segs.map(([c,b,t])=>b>0?'<span class="pm-seg '+c+'" style="width:'+(total?100*b/total:0).toFixed(3)+'%" data-tip="'+esc(t)+'"></span>':'').join('')+'</div></div>'}
|
||
function pmCodeSegs(x,total){return [['pm-exact',x.exact,pc(x.exact,total)+'% perfect match ('+kB(x.exact)+')'],
|
||
['pm-verified',x.verified,pc(x.verified,total)+'% decompiled, verified in the emulator, not exact ('+kB(x.verified)+')'],
|
||
['pm-readable',x.readable,pc(x.readable,total)+'% decompiled, readable only ('+kB(x.readable)+')']]}
|
||
function pmHeader(){const d=PM.doc;if(!d)return;const h=d.history,pt=PM.hist!=null?h[PM.hist]:null,c=d.code;
|
||
el('pmProject').textContent=d.project;
|
||
if(!pt){el('pmPct').textContent=d.percent.toFixed(2)+'%';
|
||
put('pmBars',pmBar('Code',kB(c.bytes),pmCodeSegs(c,c.bytes),c.bytes,'game code · '+fmt(c.decompiled_functions)+' of '+fmt(c.functions)+' functions')
|
||
+(d.data?pmBar('Data',fmt(d.data.addresses)+' addresses',[['pm-named',d.data.named,pc(d.data.named,d.data.addresses)+'% of the data addresses the code touches have a name in globals.csv ('+fmt(d.data.named)+' of '+fmt(d.data.addresses)+')']],d.data.addresses,'globals named'):''));
|
||
const at=d.commit.at||d.updated_at,moved=d.main.sha&&d.commit.sha&&d.main.sha.slice(0,7)!==String(d.commit.sha).slice(0,7);
|
||
put('pmCommit','Updated <b title="'+esc(at||'')+'">'+esc(rel(at))+'</b> in commit <b class="mono">'+esc(d.commit.sha||'–')+'</b>'+(moved?' <span class="note">· main has moved on to '+esc(d.main.sha)+'; the statistics follow within minutes</span>':''));
|
||
el('pmHistNote').textContent=d.percent!==d.logbook_percent&&d.logbook_percent!=null?'The Logbook statistics say '+d.logbook_percent+'%; they are being rebuilt.':''}
|
||
else{const p=pt[1],gb=pt[3];el('pmPct').textContent=Number(p).toFixed(2)+'%';
|
||
put('pmBars',pmBar('Code',gb?kB(gb):'size not recorded',[['pm-readable',p,Number(p).toFixed(2)+'% decompiled'+(gb?' ('+kB(gb*p/100)+')':'')+'; the split into exact, verified and readable was not recorded']],100,'game code'));
|
||
put('pmCommit','Recorded <b title="'+esc(pt[0])+'">'+esc(rel(pt[0]))+'</b>'+(pt[2]?' in commit <b class="mono">'+esc(pt[2])+'</b>':' <span class="note">· commit not recorded</span>')+' <span class="note">· report '+(PM.hist+1)+' of '+h.length+'</span>');
|
||
el('pmHistNote').textContent='An earlier report: the headline and the Code bar are from then. The map and the Data bar show Latest, since per-function history is not stored.'}
|
||
el('pmPrev').disabled=!h.length||PM.hist===0||(PM.hist==null&&PM.latest<1);el('pmNext').disabled=pt==null;el('pmLatest').disabled=pt==null}
|
||
function pmStep(k){const d=PM.doc;if(!d)return;const L=PM.latest;let i=PM.hist==null?L:PM.hist;
|
||
i=k==='latest'?L:k==='prev'?Math.max(0,i-1):i+1;PM.hist=i>=L?null:i;pmHeader()}
|
||
function pmFilter(){const conds=[],words=[],cmp=(op,a,b)=>op==='<'?a<b:op==='<='?a<=b:op==='>'?a>b:a>=b;
|
||
for(const t of (el('pmFilter').value||'').trim().toLowerCase().split(/\s+/).filter(Boolean)){let m;
|
||
if((m=t.match(/^([<>]=?)(\d+(?:\.\d+)?)%$/)))conds.push(['pct',m[1],+m[2]]);
|
||
else if((m=t.match(/^([<>]=?)(\d+(?:\.\d+)?)(b|k|kb|m|mb)?$/)))conds.push(['size',m[1],+m[2]*({b:1,k:1e3,kb:1e3,m:1e6,mb:1e6}[m[3]||'b'])]);
|
||
else words.push(t)}
|
||
return (name,kind,pct,size,more)=>conds.every(([k,op,v])=>k==='pct'?pct!=null&&cmp(op,pct,v):cmp(op,size,v))&&words.every(w=>name.includes(w)||kind===w||(more?more(w):false))}
|
||
function pmLayout(){const d=PM.doc,c=el('pmMap');if(!d||!c||!c.clientWidth)return;const test=pmFilter();let items,of;
|
||
if(PM.unit==null){items=d.units.filter(u=>u.bytes>0&&test(u.lname,u.kind,u.pct,u.bytes,w=>u.fns.some(f=>f.lname.includes(w))));of=d.units.length+' units'}
|
||
else{const u=d.units[PM.unit];items=u.fns.filter(f=>f.size>0&&test(f.lname,f.status,['exact','verified','readable'].includes(f.status)?100:['engine','library'].includes(f.status)?null:0,f.size));of=u.fns.length+' functions'}
|
||
const sz=x=>x.fns?x.bytes:x.size;items.sort((a,b)=>sz(b)-sz(a));
|
||
PM.items=items;PM.w=c.clientWidth;PM.h=c.clientHeight;PM.rects=squarify(items.map(sz),0,0,PM.w,PM.h);PM.hover=-1;
|
||
el('pmCount').textContent=items.length+' of '+of+' · '+kB(items.reduce((a,x)=>a+sz(x),0));pmCrumb();pmPaint()}
|
||
function squarify(vals,x,y,w,h){const out=new Array(vals.length),total=vals.reduce((a,b)=>a+b,0);if(!total||w<=0||h<=0)return out;
|
||
const scale=w*h/total,worst=(s,mn,mx,side)=>Math.max(side*side*mx/(s*s),s*s/(side*side*mn));let i=0;
|
||
while(i<vals.length){const side=Math.min(w,h);let a=vals[i]*scale,sum=a,mn=a,mx=a,best=worst(sum,mn,mx,side),j=i+1;
|
||
while(j<vals.length){const b=vals[j]*scale,s2=sum+b,m2=Math.min(mn,b),M2=Math.max(mx,b),w2=worst(s2,m2,M2,side);if(w2>best)break;sum=s2;mn=m2;mx=M2;best=w2;j++}
|
||
if(w>=h){const cw=sum/h;let yy=y;for(let k=i;k<j;k++){const hh=vals[k]*scale/cw;out[k]=[x,yy,cw,hh];yy+=hh}x+=cw;w-=cw}
|
||
else{const rh=sum/w;let xx=x;for(let k=i;k<j;k++){const ww=vals[k]*scale/rh;out[k]=[xx,y,ww,rh];xx+=ww}y+=rh;h-=rh}
|
||
i=j}
|
||
return out}
|
||
function pmColors(){if(PM.colors)return PM.colors;const host=el('view-logbook'),probe=document.createElement('i'),cv=document.createElement('canvas');host.appendChild(probe);cv.width=cv.height=1;
|
||
const g=cv.getContext('2d',{willReadFrequently:true}),rgb=v=>{probe.style.color='var('+v+')';const s=getComputedStyle(probe).color;g.clearRect(0,0,1,1);g.fillStyle='#808080';g.fillStyle=s;g.fillRect(0,0,1,1);const p=g.getImageData(0,0,1,1).data;return [p[0],p[1],p[2]]};
|
||
const C={};['exact','verified','readable','deferred','raw','unsorted','engine','library'].forEach(k=>C[k]=rgb('--pm-'+k));C.bg=rgb('--surface');C.text=rgb('--text');probe.remove();
|
||
return PM.colors=C}
|
||
const pmCss=a=>'rgb('+a.map(v=>Math.round(v)).join(',')+')';
|
||
function pmFill(it,C){if(!it.fns)return C[it.status];if(it.kind!=='game'||it.pct==null)return C[it.kind==='unsorted'?'unsorted':it.kind==='engine'?'engine':'library'];
|
||
const t=it.pct<=0?0:.18+.82*it.pct/100;return C.raw.map((v,k)=>v+(C.exact[k]-v)*t)}
|
||
function pmPaint(){const c=el('pmMap');if(!c||!c.clientWidth)return;const r=window.devicePixelRatio||1,W=c.clientWidth,H=c.clientHeight,C=pmColors();
|
||
c.width=Math.round(W*r);c.height=Math.round(H*r);const off=PM.base||(PM.base=document.createElement('canvas'));off.width=c.width;off.height=c.height;
|
||
const g=off.getContext('2d');g.setTransform(r,0,0,r,0,0);g.fillStyle=pmCss(C.bg);g.fillRect(0,0,W,H);g.textBaseline='top';
|
||
const font=getComputedStyle(c).fontFamily;
|
||
PM.items.forEach((it,i)=>{const q=PM.rects[i];if(!q||q[2]<.5||q[3]<.5)return;const [x,y,w,h]=q,f=pmFill(it,C);g.fillStyle=pmCss(f);g.fillRect(x+.5,y+.5,Math.max(.5,w-1),Math.max(.5,h-1));
|
||
if(w<44||h<17)return;const light=(.299*f[0]+.587*f[1]+.114*f[2])>150;g.fillStyle=light?'rgba(0,0,0,.82)':'rgba(255,255,255,.92)';
|
||
g.save();g.beginPath();g.rect(x+1,y+1,w-2,h-2);g.clip();g.font='600 11.5px '+font;g.fillText(it.fns?it.name.split('/').pop():it.name,x+5,y+4);
|
||
if(h>32){g.font='11px '+font;g.globalAlpha=.8;g.fillText(it.fns?(it.pct!=null&&it.kind==='game'?it.pct.toFixed(1)+'%':PM_KIND[it.kind])+' · '+kB(it.bytes):PM_LABEL[it.status].split(':')[0]+' · '+it.size+' B',x+5,y+18)}g.restore()});
|
||
pmBlit()}
|
||
function pmBlit(){const c=el('pmMap');if(!PM.base||!c.width)return;const g=c.getContext('2d'),r=window.devicePixelRatio||1;g.setTransform(1,0,0,1,0,0);g.drawImage(PM.base,0,0);
|
||
const q=PM.rects[PM.hover];if(q){g.setTransform(r,0,0,r,0,0);g.strokeStyle=pmCss(pmColors().text);g.lineWidth=2;g.strokeRect(q[0]+1,q[1]+1,Math.max(1,q[2]-2),Math.max(1,q[3]-2))}}
|
||
function pmTipHtml(it){if(it.fns){const g=it.kind==='game'&&it.pct!=null;
|
||
return '<b>'+esc(it.name)+'</b><div class="mono">'+hx(it.lo)+'–'+hx(it.hi)+' · '+kB(it.bytes)+' · '+fmt(it.functions)+' functions</div>'
|
||
+(g?'<div><b>'+it.pct.toFixed(2)+'%</b> of '+kB(it.counted)+' game code decompiled</div><div class="mono">exact '+kB(it.exact)+' · verified '+kB(it.verified)+' · readable '+kB(it.readable)+'</div>'
|
||
:'<div>'+esc(PM_KIND[it.kind])+(it.kind==='unsorted'?': not counted until sorted':': named, not decompiled; not counted')+'</div>')+'<div class="mono">click to see its functions</div>'}
|
||
return '<b>'+esc(it.name)+'</b><div class="mono">'+hx(it.address)+'–'+hx(it.address+it.size)+' · '+fmt(it.size)+' bytes</div><div>'+esc(PM_LABEL[it.status])+'</div><div class="mono">click to inspect in the Decomp view</div>'}
|
||
function pmTip(html,x,y){const t=el('pmTip');if(html==null){t.hidden=true;t._for=null;return}if(t._for!==html){t.innerHTML=html;t._for=html}t.hidden=false;
|
||
const w=t.offsetWidth,h=t.offsetHeight;t.style.left=Math.max(8,Math.min(x+14,innerWidth-w-8))+'px';t.style.top=(y+16+h>innerHeight?y-h-10:y+16)+'px'}
|
||
function pmCrumb(){const d=PM.doc;if(PM.unit==null){put('pmCrumb','');return}const u=d.units[PM.unit];
|
||
put('pmCrumb','<button class="btn sm" type="button" id="pmBack">← All units</button><b>'+esc(u.name)+'</b><span class="tag">'+esc(PM_KIND[u.kind])+'</span><span class="note mono">'+hx(u.lo)+'–'+hx(u.hi)+' · '+fmt(u.functions)+' functions · '+kB(u.bytes)+'</span>'
|
||
+(u.counted?pmBar('Game code in this unit',kB(u.counted),pmCodeSegs(u,u.counted),u.counted):''))}
|
||
function pmDrill(i){if(PM.unit==null)PM.topFilter=el('pmFilter').value;PM.unit=i;el('pmFilter').value='';if(el('pmUnits')._filled)el('pmUnits').value=String(i);else pmUnitsSelect();pmTip(null);pmLayout()}
|
||
function pmBack(){PM.unit=null;el('pmFilter').value=PM.topFilter||'';if(el('pmUnits')._filled)el('pmUnits').value='';else pmUnitsSelect();pmTip(null);pmLayout()}
|
||
function pmUnitsSelect(){const d=PM.doc,s=el('pmUnits'),u=PM.unit!=null?d.units[PM.unit]:null;s._filled=false; /* the full list is built only when someone opens it */
|
||
put(s,'<option value="">All units ('+d.units.length+')</option>'+(u?'<option value="'+u.i+'">'+esc(u.name)+'</option>':''));s.value=u?String(u.i):''}
|
||
function pmFillUnits(){const d=PM.doc,s=el('pmUnits');if(!d||s._filled)return;s._filled=true;const groups={game:[],unsorted:[],engine:[],library:[]};d.units.forEach(u=>(groups[u.kind]||groups.library).push(u));
|
||
put(s,'<option value="">All units ('+d.units.length+')</option>'+[['game','Game code'],['unsorted','Unsorted'],['engine','Engine (Gamebryo)'],['library','Libraries']].map(([k,l])=>groups[k].length?'<optgroup label="'+esc(l)+'">'
|
||
+groups[k].sort((a,b)=>a.name.localeCompare(b.name)).map(u=>'<option value="'+u.i+'">'+esc(u.name)+(k==='game'&&u.pct!=null?' · '+Math.floor(u.pct)+'%':'')+'</option>').join('')+'</optgroup>':'').join(''));
|
||
s.value=PM.unit==null?'':String(PM.unit)}
|
||
(()=>{const c=el('pmMap');let pending=null;document.body.appendChild(el('pmTip')); /* fixed position must be relative to the viewport, not a card */
|
||
const hit=(x,y)=>{for(let i=0;i<PM.rects.length;i++){const q=PM.rects[i];if(q&&x>=q[0]&&x<q[0]+q[2]&&y>=q[1]&&y<q[1]+q[3])return i}return -1};
|
||
c.addEventListener('mousemove',e=>{const b=c.getBoundingClientRect(),i=hit(e.clientX-b.left,e.clientY-b.top);
|
||
if(i!==PM.hover){PM.hover=i;if(!pending)pending=requestAnimationFrame(()=>{pending=null;pmBlit()})}pmTip(i<0?null:pmTipHtml(PM.items[i]),e.clientX,e.clientY)});
|
||
c.addEventListener('mouseleave',()=>{PM.hover=-1;pmBlit();pmTip(null)});
|
||
c.addEventListener('click',e=>{const b=c.getBoundingClientRect(),i=hit(e.clientX-b.left,e.clientY-b.top);if(i<0)return;const it=PM.items[i];
|
||
if(it.fns)return pmDrill(it.i);pmTip(null);show('decomp');el('fnTarget').value=hx(it.address);inspectFn();el('h-inspect').scrollIntoView({block:'start'})});
|
||
const card=el('pmCard');card.addEventListener('mouseover',e=>{const s=e.target.closest('[data-tip]');if(s)pmTip(esc(s.dataset.tip),e.clientX,e.clientY)});
|
||
card.addEventListener('mousemove',e=>{const s=e.target.closest('[data-tip]');if(s)pmTip(esc(s.dataset.tip),e.clientX,e.clientY)});
|
||
card.addEventListener('mouseout',e=>{if(e.target.closest('[data-tip]'))pmTip(null)});
|
||
card.addEventListener('click',e=>{if(e.target.closest('#pmBack'))pmBack()});
|
||
['pointerdown','focus','keydown'].forEach(t=>el('pmUnits').addEventListener(t,pmFillUnits));
|
||
el('pmUnits').addEventListener('change',e=>{const v=e.target.value;if(v==='')pmBack();else pmDrill(Number(v))});
|
||
let ft=0;el('pmFilter').addEventListener('input',()=>{clearTimeout(ft);ft=setTimeout(pmLayout,180)});
|
||
el('pmPrev').onclick=()=>pmStep('prev');el('pmNext').onclick=()=>pmStep('next');el('pmLatest').onclick=()=>pmStep('latest');
|
||
let rt=0;new ResizeObserver(()=>{clearTimeout(rt);rt=setTimeout(()=>{if(current==='logbook'&&c.clientWidth&&(c.clientWidth!==PM.w||c.clientHeight!==PM.h))pmLayout()},120)}).observe(c); /* only when its size really changes */
|
||
const recolor=()=>{PM.colors=null;if(current==='logbook'&&PM.doc)pmPaint()};
|
||
new MutationObserver(recolor).observe(document.documentElement,{attributes:true,attributeFilter:['data-theme','data-palette']});
|
||
matchMedia('(prefers-color-scheme: dark)').addEventListener('change',recolor);
|
||
every(()=>{if(current==='logbook')return pmLoad()},60000,null)})();
|
||
el('fnForm').addEventListener('submit',e=>{e.preventDefault();inspectFn()});
|
||
el('refreshDecomp').addEventListener('click',renderDecomp);
|
||
document.addEventListener('click',e=>{const a=e.target.closest('[data-fn]');if(!a)return;e.preventDefault();if(current!=='decomp')show('decomp');el('fnTarget').value=a.dataset.fn;inspectFn();el('h-inspect').scrollIntoView({behavior:'smooth',block:'start'})});
|
||
document.addEventListener('keydown',e=>{if(e.ctrlKey||e.metaKey||e.altKey||e.target.closest('input,textarea,select,[contenteditable]')||document.querySelector('dialog[open]'))return;
|
||
const keys=Object.keys(VIEWS).filter(k=>k!=='chat');const i=Number(e.key)-1;if(i>=0&&i<keys.length){e.preventDefault();show(keys[i])}
|
||
else if(e.key==='c'){e.preventDefault();show('chat');el('text').focus()}
|
||
else if(e.key==='/'){e.preventDefault();show('knowledge');el('q').focus()}
|
||
else if(e.key==='Escape')el('shell').classList.remove('open')});
|
||
/* The chat section. Its people sit in the sidebar under the board's sections; picking one, or Everyone, opens the
|
||
chat filtered to them. Messages keep arriving in every section, so sound, notifications and the unread badge work
|
||
wherever you are. */
|
||
const Chat=(()=>{
|
||
const CENTRAL='America/Chicago';
|
||
const log=el('log');
|
||
const fmt=s=>esc(s).replace(/`([^`]+)`/g,'<code>$1</code>');
|
||
/* one formatter each, made once: building an Intl formatter per message cost milliseconds per render */
|
||
const DAY_FMT=new Intl.DateTimeFormat('en-US',{timeZone:CENTRAL,year:'numeric',month:'numeric',day:'numeric'});
|
||
const TIME_FMT=new Intl.DateTimeFormat('en-US',{timeZone:CENTRAL,hour:'numeric',minute:'2-digit',timeZoneName:'short'});
|
||
const centralDay=d=>DAY_FMT.format(d);
|
||
const centralTime=d=>TIME_FMT.format(d);
|
||
const since=s=>{if(!s)return 'never';const m=Math.round((Date.now()-new Date(s))/60000);return m<1?'just now':m<120?m+' min ago':m<2880?Math.round(m/60)+' h ago':Math.round(m/1440)+' days ago'};
|
||
let people=[],byName={},human='User',messages=[],lastId=0,filter='All',thread=null,sending=false,receipts={},overviewData=null,loaded=false;
|
||
async function getJSON(path){const r=await fetch(path,{cache:'no-store'});const j=await r.json().catch(()=>({}));if(!r.ok)throw new Error(j.error||('HTTP '+r.status));return j}
|
||
const face=(p,state,size)=>'<div class="avatar'+(size?' '+size:'')+'" style="--avatar:'+esc(p.color||'#7b8797')+'"'+(state?' data-state="'+esc(state)+'"':'')+' aria-hidden="true">'+esc(p.initials||String(p.name).slice(0,2))+'</div>';
|
||
const display=n=>n===human?'You':n;
|
||
const active=()=>current==='chat';
|
||
|
||
function mountPeople(){
|
||
byName=Object.fromEntries(people.map(p=>[p.name,p]));
|
||
const live=n=>(overviewData?overviewData.agents.find(a=>a.name===n):null)||{};
|
||
put('participants',people.map(p=>{
|
||
const a=live(p.name),stopped=!!(a.stopped&&a.stopped.length),trial=a.trial_until&&!stopped;
|
||
const fresh=p.last_seen&&(Date.now()-new Date(p.last_seen))<15*60000;
|
||
const state=p.kind==='human'?'':stopped?'stopped':a.working?'working':trial?'trial':fresh?'online':'idle';
|
||
const status=p.kind==='human'?esc(p.role||'Project owner'):a.working?'<span class="p-work">Working'+(a.last_tool?' · '+esc(a.last_tool):'')+'</span>':stopped?'Stopped':trial?'Trial until '+esc(centralTime(new Date(a.trial_until))):(p.last_seen?'Active '+esc(since(p.last_seen)):'Not seen yet');
|
||
const on=active()&&!thread&&filter===p.name;
|
||
const tip=[p.role,p.doing,a.working&&a.last_target].filter(Boolean).join(' · ');
|
||
return '<button class="person" data-filter="'+esc(p.name)+'"'+(on?' aria-current="page"':'')+' title="'+esc(tip)+'">'+face(p,state,'sm')+'<span class="p-copy"><span class="p-name">'+esc(display(p.name))+'</span><span class="p-sub">'+status+'</span>'+(p.doing&&p.kind!=='human'?'<span class="p-doing">'+esc(p.doing)+'</span>':'')+'</span></button>'}).join(''));
|
||
const all=document.querySelector('.person[data-filter="All"]');if(active()&&!thread&&filter==='All')all.setAttribute('aria-current','page');else all.removeAttribute('aria-current');
|
||
const sel=el('to').value,agents=people.filter(p=>p.kind!=='human');
|
||
const opts=(thread?[['@thread','Thread participants']]:[]).concat([['All','Everyone']],agents.map(p=>[p.name,p.name]));
|
||
const key=opts.map(o=>o[0]).join('|');
|
||
if(el('to')._key!==key){el('to')._key=key;el('to').replaceChildren(...opts.map(([v,t])=>new Option(t,v)));if([...el('to').options].some(o=>o.value===sel))el('to').value=sel}
|
||
}
|
||
function parseDate(v){const d=new Date(String(v).replace(' ','T'));return isNaN(d)?null:d}
|
||
function dateLabel(d){if(!d)return '';const now=new Date();if(centralDay(d)===centralDay(now))return 'Today';const y=new Date(now-86400000);if(centralDay(d)===centralDay(y))return 'Yesterday';return d.toLocaleDateString('en-US',{timeZone:CENTRAL,weekday:'short',month:'short',day:'numeric'})}
|
||
const nearBottom=()=>log.scrollHeight-log.scrollTop-log.clientHeight<90;
|
||
function scrollBottom(){log.scrollTop=log.scrollHeight;el('newMessages').hidden=true}
|
||
function chips(m){let s='';
|
||
if(m.topic&&m.topic!=='general'&&!m.topic.startsWith('legacy:'))s+='<button class="chip" data-thread="'+esc(m.topic)+'" title="'+esc(m.subject||m.topic)+' · open thread">'+esc(m.topic)+'</button>';
|
||
if(m.kind&&m.kind!=='note')s+='<span class="chip kind-'+esc(m.kind)+'">'+esc(m.kind)+'</span>';
|
||
if(m.importance&&m.importance!=='normal')s+='<span class="chip '+esc(m.importance)+'">'+esc(m.importance)+'</span>';
|
||
if(m.ack)s+='<span class="chip" title="Asks for an acknowledgement">ack</span>';
|
||
if(m.due_at)s+='<span class="chip high" title="Answer needed by">due '+esc(centralTime(new Date(m.due_at)))+'</span>';
|
||
if(m.corrects)s+='<span class="chip fixed">corrects #'+esc(m.corrects)+'</span>';
|
||
if(Array.isArray(m.corrected_by))s+='<span class="chip fixed" title="A later message corrects this one">corrected by '+m.corrected_by.map(i=>'#'+esc(i)).join(', ')+'</span>';
|
||
if(m.pinned)s+='<span class="chip pin">pinned</span>';return s}
|
||
function questionBox(m){const q=m.question;if(!q)return '';
|
||
if(q.answer)return '<div class="qbox"><span class="answered">'+ic('check','sm')+'Answered'+(q.answered_by?' by '+esc(display(q.answered_by)):'')+': '+esc(q.answer)+'</span></div>';
|
||
if(q.to!==human)return '<div class="qbox"><span class="answered waiting">'+ic('clock','sm')+'Waiting for '+esc(q.to)+'</span></div>';
|
||
const opts=(Array.isArray(q.options)?q.options:[]);
|
||
return '<div class="qbox">'+opts.map(o=>'<button class="qbtn" data-answer="'+esc(q.id)+'" data-value="'+esc(o)+'">'+esc(o)+'</button>').join('')+'<button class="qbtn" data-answer="'+esc(q.id)+'" data-value="">'+(opts.length?'Other…':'Answer…')+'</button></div>'}
|
||
function receiptLine(m){const r=receipts[m.id];if(!r)return '';
|
||
const parts=[];if(r.acked.length)parts.push('Acknowledged by '+r.acked.map(display).join(', '));if(r.read.length)parts.push('Seen by '+r.read.map(display).join(', '));if(r.waiting.length)parts.push('Not yet seen: '+r.waiting.map(display).join(', '));
|
||
return parts.length?'<span>'+esc(parts.join(' · '))+'</span>':''}
|
||
function ackButton(m){if(!(m.ack||m.due_at)||m.from===human)return '';const r=receipts[m.id];if(!(m.recipients||[]).some(x=>x===human||x==='All'))return '';
|
||
if(r&&r.acked.includes(human))return '<span>You acknowledged</span>';return '<button class="ackbtn" data-ack="'+m.id+'">Acknowledge</button>'}
|
||
function visibleMessages(){return messages.filter(m=>{
|
||
if(thread)return m.topic===thread;
|
||
return filter==='All'||m.from===filter||(m.recipients||[m.to]).some(r=>r===filter||r==='All')})}
|
||
/* The log keeps a bounded window of the newest messages in the DOM (WINDOW rows; "Load earlier" adds WINDOW more).
|
||
Rendering all of them (800+ long messages, 10,000 nodes, a 440,000 px column) and rebuilding the whole log on every
|
||
new message or receipt froze the page for about two seconds each time. Now new messages are appended, a changed
|
||
receipt or mark repaints only its row, and the whole window is rebuilt only when the view changes. */
|
||
const WINDOW=150;
|
||
let windowSize=WINDOW,shown={key:null,ids:[],sigs:new Map()};
|
||
const dayKey=m=>{const d=parseDate(m.at||m.time);return d?centralDay(d):String(m.time)};
|
||
const prevOf=(vis,i)=>i>0&&dayKey(vis[i-1])===dayKey(vis[i])?vis[i-1]:null; /* a date line starts a new group */
|
||
function rowSig(m){return JSON.stringify([m.corrected_by||null,!!m.pinned,m.question?[m.question.answer,m.question.answered_by]:null,receipts[m.id]||null])}
|
||
function dateLine(m){const d=parseDate(m.at||m.time);const dv=document.createElement('div');dv.className='date';dv.innerHTML='<span>'+esc(dateLabel(d)||m.time)+'</span>';return dv}
|
||
function buildRow(m,prev,next){
|
||
const d=parseDate(m.at||m.time);
|
||
const pd=prev?parseDate(prev.at||prev.time):null;
|
||
const cont=prev&&prev.from===m.from&&prev.to===m.to&&prev.topic===m.topic&&d&&pd&&d-pd<300000&&!m.ack;
|
||
const nd=next?parseDate(next.at||next.time):null;
|
||
const last=!next||next.from!==m.from||next.to!==m.to||next.topic!==m.topic||!nd||!d||nd-d>=300000||next.ack;
|
||
const p=byName[m.from]||{name:m.from,initials:String(m.from).slice(0,2)};const own=m.from===human;
|
||
const row=document.createElement('div');row.className='mrow'+(own?' own':'')+(cont?' cont':'');row.dataset.id=m.id;
|
||
const to=(m.recipients||[m.to]).map(r=>r===human?'you':r==='All'?'everyone':r).join(', ');
|
||
if(!Array.isArray(m.attachments))m.attachments=[];
|
||
const files=m.attachments.length?'<div class="files">'+m.attachments.map(f=>'<a href="files/'+encodeURIComponent(f)+'">'+ic('file','sm')+esc(f.slice(13))+'</a>').join('')+'</div>':'';
|
||
const pinBtn='<button class="linkbtn" data-pin="'+m.id+'" data-unpin="'+(m.pinned?1:'')+'">'+(m.pinned?'Unpin':'Pin')+'</button>';
|
||
const foot=last||m.ack||m.pinned?'<div class="foot"><span title="#'+m.id+' · '+esc(m.time)+'">'+esc(d?centralTime(d):m.time)+'</span>'+(own?receiptLine(m):'')+ackButton(m)+pinBtn+'</div>':'';
|
||
row.innerHTML=face(p)+'<div class="stack"><div class="meta"><strong>'+esc(display(m.from))+'</strong><span class="arrow">→</span>'+esc(to)+chips(m)+'</div><div class="bubble'+(m.importance==='urgent'?' urgent':'')+'">'+fmt(m.text)+files+questionBox(m)+'</div>'+foot+'</div>';
|
||
return row}
|
||
/* the first row in view and its offset, to keep the reader's place across a rebuild */
|
||
function topRow(){const st=log.scrollTop;for(const r of log.querySelectorAll('.mrow'))if(r.offsetTop+r.offsetHeight>st)return {id:r.dataset.id,off:r.offsetTop-st};return null}
|
||
function renderLog(force=false,preserve=false){
|
||
const all=visibleMessages(),key=JSON.stringify([filter,thread]);
|
||
if(!force&&!preserve&&shown.key===key&&shown.ids.length){
|
||
const start=all.findIndex(m=>m.id===shown.ids[0]),vis=start<0?null:all.slice(start),n=shown.ids.length;
|
||
const sameHead=vis&&vis.length>=n&&shown.ids.every((id,i)=>vis[i].id===id);
|
||
/* append in place, unless the window has grown far past its size and the reader is at the bottom (then trim) */
|
||
if(sameHead&&!(vis.length>windowSize+WINDOW&&nearBottom()))return patchLog(vis)}
|
||
const atBottom=!preserve&&(force||nearBottom()||shown.key===null);const anchor=atBottom?null:topRow();
|
||
const vis=all.slice(-windowSize),frag=document.createDocumentFragment();let day='';
|
||
if(!vis.length){const e=document.createElement('div');e.className='empty';e.innerHTML=ic(thread?'thread':'chat')+'<strong>Nothing here yet</strong><span>'+(thread?'This thread has no recent messages; search in Knowledge for older ones.':'Messages will appear here.')+'</span>';frag.appendChild(e)}
|
||
vis.forEach((m,i)=>{const k=dayKey(m);if(k!==day){frag.appendChild(dateLine(m));day=k}frag.appendChild(buildRow(m,prevOf(vis,i),vis[i+1]))});
|
||
if(all.length>vis.length||(!thread&&messages.length&&messages[0].id>1)){const b=document.createElement('button');b.className='earlier';b.id='earlier';b.innerHTML=ic('clock','sm')+'Load earlier messages';frag.prepend(b)}
|
||
log.replaceChildren(frag);
|
||
shown={key,ids:vis.map(m=>m.id),sigs:new Map(vis.map(m=>[m.id,rowSig(m)]))};
|
||
if(atBottom)scrollBottom();
|
||
else if(anchor){const r=log.querySelector('.mrow[data-id="'+anchor.id+'"]');if(r)log.scrollTop=r.offsetTop-anchor.off}
|
||
}
|
||
function patchLog(vis){
|
||
const n=shown.ids.length,added=vis.length-n,redo=new Set();
|
||
for(let i=0;i<n;i++)if(shown.sigs.get(vis[i].id)!==rowSig(vis[i]))redo.add(i);
|
||
if(!added&&!redo.size)return;
|
||
const atBottom=nearBottom();
|
||
if(added)redo.add(n-1); /* the old last row may lose its footer or join the new group */
|
||
for(const i of redo){const m=vis[i],node=log.querySelector('.mrow[data-id="'+m.id+'"]');
|
||
if(node)node.replaceWith(buildRow(m,prevOf(vis,i),vis[i+1]));shown.sigs.set(m.id,rowSig(m))}
|
||
if(added){const frag=document.createDocumentFragment();
|
||
for(let i=n;i<vis.length;i++){const m=vis[i];if(dayKey(m)!==dayKey(vis[i-1]))frag.appendChild(dateLine(m));
|
||
frag.appendChild(buildRow(m,prevOf(vis,i),vis[i+1]));shown.ids.push(m.id);shown.sigs.set(m.id,rowSig(m))}
|
||
log.appendChild(frag);
|
||
if(atBottom)scrollBottom();else el('newMessages').hidden=false}
|
||
}
|
||
function markRead(){const me=overviewData&&overviewData.agents.find(a=>a.name===human);if(me&&me.unread)post('api/read',{}).then(()=>refresh()).catch(()=>{})}
|
||
function view(){mountPeople();if(!active())return;updateHeader();if(loaded)renderLog(true);if(!document.hidden&&document.hasFocus())markRead()}
|
||
function setFilter(v){filter=v;thread=null;windowSize=WINDOW;el('shell').classList.remove('open');if(active())view();else show('chat')}
|
||
function setThread(t){thread=t;windowSize=WINDOW;if(active())view();else show('chat');el('to').value='@thread'}
|
||
function updateHeader(){
|
||
el('threadbar').classList.toggle('on',!!thread);
|
||
if(thread){const m=messages.find(x=>x.topic===thread);el('threadName').textContent=thread+(m&&m.subject&&m.subject!==thread?' · '+m.subject:'')}
|
||
if(!active())return;
|
||
const icon=el('viewIcon'),person=!thread&&filter!=='All'&&byName[filter];
|
||
icon.className='view-icon'+(person?' is-face':'');put(icon,person?face(byName[filter]):ic(thread?'thread':'chat'));
|
||
if(thread){el('viewTitle').textContent='Thread';el('viewSub').textContent=thread}
|
||
else{const busy=overviewData?overviewData.agents.filter(a=>a.working).map(a=>a.name):[];
|
||
el('viewTitle').textContent=filter==='All'?'Everyone':'With '+display(filter);
|
||
el('viewSub').textContent=busy.length?(busy.join(', ')+(busy.length>1?' are':' is')+' working…'):(filter==='All'?(people.filter(p=>p.kind!=='human').length+' agents · you · one shared conversation'):'Includes messages to everyone')}
|
||
}
|
||
let audioCtx=null;
|
||
function beep(){try{audioCtx=audioCtx||new (window.AudioContext||window.webkitAudioContext)();const o=audioCtx.createOscillator(),g=audioCtx.createGain();o.type='sine';o.frequency.value=880;g.gain.setValueAtTime(0.0001,audioCtx.currentTime);g.gain.exponentialRampToValueAtTime(0.15,audioCtx.currentTime+0.02);g.gain.exponentialRampToValueAtTime(0.0001,audioCtx.currentTime+0.35);o.connect(g).connect(audioCtx.destination);o.start();o.stop(audioCtx.currentTime+0.4)}catch(e){}}
|
||
function speak(m){try{if(!('speechSynthesis' in window))return beep();const u=new SpeechSynthesisUtterance(display(m.from)+(m.importance==='urgent'?' says, urgently: ':' says: ')+String(m.text).slice(0,260));u.rate=1.05;speechSynthesis.speak(u)}catch(e){}}
|
||
const SOUND_MODES=['off','beep','speak'],SOUND_LABEL={off:'Sound off',beep:'Sound: beep',speak:'Read aloud'},SOUND_ICON={off:'volume-off',beep:'volume',speak:'speak'};
|
||
function paintSound(){const s=store.get('sound','off');const b=el('sound');b.innerHTML=ic(SOUND_ICON[s]||'volume-off')+'<span class="lbl">'+esc(SOUND_LABEL[s]||'Sound off')+'</span>';b.setAttribute('aria-label',SOUND_LABEL[s]||'Sound off');b.setAttribute('aria-pressed',String(s!=='off'))}
|
||
function soundFor(fresh){const s=store.get('sound','off');if(s==='off')return;const mine=fresh.filter(m=>m.from!==human&&((m.recipients||[]).includes(human)||m.importance==='urgent'||(m.question&&m.question.to===human)));
|
||
if(!mine.length)return;if(s==='beep')beep();else mine.slice(-2).forEach(speak)}
|
||
function notifyFor(fresh){soundFor(fresh);
|
||
const mine=fresh.filter(m=>m.from!==human&&((m.recipients||[]).includes(human)||m.importance==='urgent'));
|
||
if(document.hidden){unseen+=fresh.filter(m=>m.from!==human).length;paintTitle()}
|
||
if(!mine.length||!store.get('notify',false)||!('Notification' in window)||Notification.permission!=='granted'||!document.hidden)return;
|
||
for(const m of mine.slice(-3)){try{new Notification(display(m.from)+(m.importance==='urgent'?' (urgent)':''),{body:m.text.slice(0,180),tag:'harbor-'+m.id})}catch(e){}}
|
||
}
|
||
async function pollMessages(){
|
||
try{const fresh=await getJSON(lastId?'messages?after='+lastId:'messages');
|
||
if(!lastId||fresh.length){if(lastId)notifyFor(fresh);messages=lastId?messages.concat(fresh).slice(-2000):fresh;lastId=messages.reduce((n,m)=>Math.max(n,m.id||0),lastId);loaded=true;if(active())renderLog()}}
|
||
catch(e){}
|
||
}
|
||
async function pollPeople(){
|
||
try{overviewData=data||await getJSON('api/overview');people=await getJSON('participants');human=overviewData.project.human;
|
||
const ais=overviewData.agents.filter(a=>a.kind!=='human');const stop=ais.filter(a=>a.stopped&&a.stopped.length).map(a=>a.name);
|
||
el('stopbar').classList.toggle('on',stop.length>0);
|
||
put('stopbar',stop.length?ic('stop')+'<span>Stopped: '+esc(stop.length===ais.length?'everyone':stop.join(', '))+'</span><button type="button" data-go="launch">Resume them from Launch</button>':'');
|
||
mountPeople();if(!thread)updateHeader();
|
||
const ids=messages.filter(m=>m.from===human||m.ack||m.due_at).slice(-40).map(m=>m.id);
|
||
if(ids.length){receipts=await getJSON('api/receipts?ids='+ids.join(','));if(active())renderLog()}
|
||
if(active()&&!document.hidden&&document.hasFocus())markRead();
|
||
}catch(e){}
|
||
}
|
||
const box=el('text');if(matchMedia('(max-width: 680px)').matches)box.placeholder='Message the team… /help for commands';
|
||
function resizeBox(){box.style.height='auto';box.style.height=Math.min(box.scrollHeight,180)+'px'}box.addEventListener('input',resizeBox);
|
||
const HELP=`/stop <Agent|all> [reason] stop someone (or everyone)
|
||
/resume <Agent|all> resume
|
||
/away [8h|2h|tomorrow] [note] tell the team you are away
|
||
/dnd [time] [note] /here do not disturb / back
|
||
/ping <Agent> urgent "are you there?"
|
||
/task <title> [@Agent] [!high|!urgent|!low] [fn:0x...] add a task
|
||
/pin <message id> pin a message
|
||
/status who is working, stopped, waiting
|
||
/find <words> search every message (Knowledge)`;
|
||
function untilFrom(word){if(!word)return null;if(word==='tomorrow'){const d=new Date();d.setDate(d.getDate()+1);d.setHours(8,0,0,0);return d.toISOString().replace(/\.\d+Z$/,'Z')}return /^\d+(\.\d+)?[mhd]$/.test(word)?word:null}
|
||
const BARE=new Set(['help','?','here','back','status']);
|
||
async function slash(text){const [cmd,...rest]=text.slice(1).split(/\s+/);const arg=rest.join(' ');
|
||
if(BARE.has((cmd||'').toLowerCase())&&rest.length)return false; /* "/here is the log" is a message */
|
||
const say=(msg,err)=>{el('feedback').className=err?'error':'';el('feedback').textContent=msg};
|
||
const agentName=n=>{const p=people.find(x=>x.name.toLowerCase()===String(n||'').toLowerCase()&&x.kind!=='human');return p?p.name:(String(n).toLowerCase()==='all'?'all':null)};
|
||
switch((cmd||'').toLowerCase()){
|
||
case 'help':case '?':el('feedback').className='cmdhelp';el('feedback').textContent=HELP;return true;
|
||
case 'stop':{const who=agentName(rest[0]);if(!who)return say('Who? /stop <Agent|all> [reason]',1),true;await post('api/flag',{target:who,value:'on',reason:rest.slice(1).join(' ')||null});refresh();say('Stopped '+who+'.');return true}
|
||
case 'resume':{const who=agentName(rest[0]);if(!who)return say('Who? /resume <Agent|all>',1),true;if(!confirm('Resume '+who+'?'))return true;await post('api/flag',{target:who,value:'off',reason:rest.slice(1).join(' ')||null});refresh();say('Resumed '+who+'.');return true}
|
||
case 'away':case 'dnd':{const until=untilFrom(rest[0]);const note=(until?rest.slice(1):rest).join(' ')||null;await post('api/presence',{status:cmd.toLowerCase()==='dnd'?'dnd':'away',until,note});await refresh();await pollPeople();say('The team now sees you as '+(cmd.toLowerCase()==='dnd'?'do not disturb':'away')+(until?' until '+(rest[0]==='tomorrow'?'tomorrow 8 AM':rest[0]):'')+'.');return true}
|
||
case 'here':case 'back':await post('api/presence',{status:'here'});await refresh();await pollPeople();say('Welcome back.');return true;
|
||
case 'ping':{const who=agentName(rest[0]);if(!who||who==='all')return say('Who? /ping <Agent>',1),true;await post('api/ping',{agent:who});say('Pinged '+who+'.');return true}
|
||
case 'task':{let assignee=null,priority='normal',scope=null;const words=[];for(const w of rest){if(w.startsWith('@')&&agentName(w.slice(1)))assignee=agentName(w.slice(1));else if(/^!(urgent|high|normal|low)$/i.test(w))priority=w.slice(1).toLowerCase();else if(/^(fn:|range:|file:|name:)/i.test(w)||/^0x[0-9a-f]+$/i.test(w))scope=w;else words.push(w)}
|
||
if(!words.length)return say('/task <title> [@Agent] [!high] [fn:0x...]',1),true;const r=await post('api/task',{action:'add',title:words.join(' '),assignee,priority,scope});refresh();say('Added task #'+r.task.id+(assignee?' for '+assignee:'')+'.');return true}
|
||
case 'pin':{const id=Number(rest[0]);if(!id)return say('/pin <message id>',1),true;await post('api/pin',{id});await refreshMarks();say('Pinned #'+id+'.');return true}
|
||
case 'status':{const o=await getJSON('api/overview');const stopped=o.agents.filter(a=>a.stopped&&a.stopped.length).map(a=>a.name);const working=o.agents.filter(a=>a.working).map(a=>a.name);
|
||
say((working.length?'Working: '+working.join(', ')+'. ':'Nobody is working right now. ')+(stopped.length?'Stopped: '+stopped.join(', ')+'. ':'')+o.tasks.length+' open tasks, '+o.queue.length+' in review, '+o.questions.length+' questions for you.');return true}
|
||
case 'find':case 'search':show('knowledge');el('q').value=arg;if(arg)el('searchForm').requestSubmit();return true;
|
||
default:return false}}
|
||
el('composer').addEventListener('submit',async e=>{e.preventDefault();const text=box.value.trim();if(!text||sending)return;
|
||
if(text.startsWith('/')&&!text.startsWith('//')){try{if(await slash(text)){box.value='';resizeBox();return}}catch(err){el('feedback').className='error';el('feedback').textContent=err.message;return}}
|
||
if(recognizer)recognizer.stop();
|
||
sending=true;el('send').disabled=true;
|
||
try{const body={text,ack:el('askAck').checked,importance:el('urgent').checked?'urgent':'normal'};const to=el('to').value;
|
||
if(to==='@thread'&&thread){const last=messages.filter(m=>m.topic===thread).pop();body.thread=thread;if(last)body.reply_to=last.id;else body.to='All'}else{body.to=to;if(thread)body.thread=thread}
|
||
const reply=await post('send',body);box.value='';resizeBox();el('askAck').checked=false;el('urgent').checked=false;
|
||
el('feedback').className='';el('feedback').textContent=reply.warnings&&reply.warnings.length?'Sent · note: '+reply.warnings.join('; '):'Sent to '+reply.targets.map(display).join(', ');
|
||
await pollMessages();scrollBottom()}
|
||
catch(err){el('feedback').className='error';el('feedback').textContent='Not sent ('+err.message+'). Your text is kept; try again.'}
|
||
finally{sending=false;el('send').disabled=false;box.focus()}});
|
||
box.addEventListener('keydown',e=>{if(e.key==='Enter'&&!e.shiftKey&&!e.isComposing){e.preventDefault();el('composer').requestSubmit()}});
|
||
document.addEventListener('click',async e=>{
|
||
const f=e.target.closest('[data-filter]');if(f)return setFilter(f.dataset.filter);
|
||
if(!e.target.closest('#view-chat'))return;
|
||
const t=e.target.closest('[data-thread]');if(t)return setThread(t.dataset.thread);
|
||
if(e.target.closest('#earlier'))return loadEarlier();
|
||
const qk=e.target.closest('[data-quick]');if(qk){try{await quick(qk.dataset.quick)}catch(err){alert(err.message)}return}
|
||
const ans=e.target.closest('[data-answer]');if(ans){let value=ans.dataset.value;if(!value){value=prompt('Your answer');if(!value)return;value=(ans.parentElement.querySelectorAll('[data-value]').length>1?'other: ':'')+value}
|
||
ans.disabled=true;try{await post('api/answer',{question:Number(ans.dataset.answer),answer:value});await refreshMarks();refresh();el('feedback').className='';el('feedback').textContent='Answer sent.'}catch(err){ans.disabled=false;alert(err.message)}return}
|
||
const pb=e.target.closest('[data-pin]');if(pb){try{await post('api/pin',{id:Number(pb.dataset.pin),unpin:!!pb.dataset.unpin});await refreshMarks()}catch(err){alert(err.message)}return}
|
||
const a=e.target.closest('[data-ack]');if(a){a.disabled=true;try{await post('api/ack',{ids:[Number(a.dataset.ack)]});await pollPeople();refresh()}catch(err){a.disabled=false;alert(err.message)}}});
|
||
/* show WINDOW more rows: from memory first, from the server once everything loaded is on screen */
|
||
async function loadEarlier(){const first=messages[0];if(!first)return;
|
||
if(visibleMessages().length<=shown.ids.length){
|
||
try{const older=await getJSON('messages?before='+first.id);if(!older.length){el('earlier')&&el('earlier').remove();return}
|
||
messages=older.concat(messages)}catch(e){return}}
|
||
windowSize=shown.ids.length+WINDOW;renderLog(false,true)}
|
||
async function refreshMarks(){if(!messages.length||!active())return;try{
|
||
const tail=await getJSON('messages?before='+(messages[messages.length-1].id+1));const byId=new Map(tail.map(m=>[m.id,m]));
|
||
let changed=false;for(const m of messages){const n=byId.get(m.id);if(n&&(JSON.stringify(n.corrected_by)!==JSON.stringify(m.corrected_by)||!!n.pinned!==!!m.pinned||JSON.stringify(n.question)!==JSON.stringify(m.question))){m.corrected_by=n.corrected_by;m.pinned=n.pinned;m.question=n.question;changed=true}}
|
||
if(changed&&active())renderLog()}catch(e){}}
|
||
async function catchUp(sinceWhen,title){
|
||
let d,o;try{[d,o]=await Promise.all([getJSON('api/digest?since='+encodeURIComponent(sinceWhen)),getJSON('api/overview')])}catch(e){return}
|
||
const li=a=>a.length?'<ul>'+a.join('')+'</ul>':'<div class="note">Nothing.</div>';
|
||
const team=d.agents.filter(a=>a.name!==human);
|
||
const work=team.map(a=>{const ov=o.agents.find(x=>x.name===a.name)||{};const bits=[];if(a.messages)bits.push(a.messages+' messages');if(a.submitted)bits.push(a.submitted+' submitted');if(a.reviewed)bits.push(a.reviewed+' reviewed');if(a.landed)bits.push(a.landed+' landed');
|
||
return '<li>'+face(byName[a.name]||{name:a.name},ov.stopped&&ov.stopped.length?'stopped':ov.working?'working':'')+'<span><b>'+esc(a.name)+'</b>: '+(bits.join(', ')||'quiet')+(ov.stopped&&ov.stopped.length?' · stopped':'')+(a.doing?' · now: '+esc(a.doing):'')+(ov.handoff?' · handoff '+esc(since(ov.handoff.at)):'')+'</span></li>'});
|
||
const wins=(o.wins||[]).map(w=>'<li><span class="tag green">landed</span><span>'+esc(w.title)+' <span class="note">by '+esc(w.author)+'</span></span></li>');
|
||
const qs=(o.questions||[]).map(q=>'<li><span class="tag blue">'+esc(q.asker)+'</span><span>'+esc(q.question)+'</span></li>');
|
||
const att=(o.attention||[]).map(a=>'<li><span class="tag amber">attention</span><span>'+esc(a.text)+'</span></li>');
|
||
const tasks=(o.tasks||[]).filter(t=>t.state!=='open').map(t=>'<li><span class="tag violet">#'+t.id+' '+esc(t.state.replace('_',' '))+'</span><span>'+esc(t.assignee||'')+' · '+esc(t.title)+'</span></li>');
|
||
el('catchTitle').textContent=title;
|
||
el('catchBody').innerHTML='<div class="note">Since '+esc(centralTime(new Date(d.since)))+'</div><h3 class="sub-h">The team</h3>'+li(work)+'<h3 class="sub-h">Landed</h3>'+li(wins)+'<h3 class="sub-h">Questions for you</h3>'+li(qs)+'<h3 class="sub-h">Needs attention</h3>'+li(att)+'<h3 class="sub-h">Work in progress</h3>'+li(tasks);
|
||
el('catchup').showModal()}
|
||
el('catchClose').onclick=()=>el('catchup').close();
|
||
el('askStatus').onclick=async()=>{const running=(overviewData?overviewData.agents:[]).filter(a=>a.kind!=='human'&&!(a.stopped&&a.stopped.length)).map(a=>a.name);
|
||
if(!running.length)return alert('Every agent is stopped; nobody would answer.');
|
||
try{await post('send',{to:running.join(','),text:'Status please: reply in this thread with what you finished, what is in flight, and what comes next. Keep it short.',thread:'status',ack:true});el('catchup').close();el('feedback').textContent='Asked '+running.join(', ')+' for a status.'}catch(err){alert(err.message)}};
|
||
async function quick(kind){
|
||
if(kind==='night'){const lead=(overviewData&&overviewData.project.coordinators||[])[0]||'the coordinator';
|
||
const text='Heading out for the night. I am away until 8 AM: keep working on your tasks, decide with '+lead+' instead of waiting for me, and write a handoff before you stop.';
|
||
if(!confirm('Send to everyone and mark you away until 8 AM?\n\n'+text))return;
|
||
const d=new Date();if(d.getHours()>=8)d.setDate(d.getDate()+1);d.setHours(8,0,0,0);
|
||
await post('api/presence',{status:'away',until:d.toISOString().replace(/\.\d+Z$/,'Z'),note:'decide with '+lead+', do not wait for me'});
|
||
await post('send',{to:'All',text});await pollMessages();await refresh();await pollPeople();el('feedback').textContent='Good night. The team sees you away until 8 AM.';return}
|
||
if(kind==='morning'){const p=overviewData&&overviewData.presence;const from=p&&p.set_at&&(p.status!=='here'||p.expired)?p.set_at:'12h';
|
||
await post('api/presence',{status:'here'});await refresh();await pollPeople();return catchUp(from,'Good morning - while you were away')}
|
||
if(kind==='status')return catchUp('12h','Status: the last 12 hours')}
|
||
let recognizer=null;
|
||
el('mic').onclick=()=>{const SR=window.SpeechRecognition||window.webkitSpeechRecognition;if(!SR)return alert('This browser has no speech recognition; Chrome or Edge do.');
|
||
if(recognizer){recognizer.stop();return}
|
||
recognizer=new SR();recognizer.lang='en-US';recognizer.interimResults=true;recognizer.continuous=true;const start=box.value;
|
||
recognizer.onresult=e=>{let t='';for(const r of e.results)t+=r[0].transcript;box.value=(start?start+' ':'')+t.trim();resizeBox()};
|
||
recognizer.onend=()=>{recognizer=null;el('mic').classList.remove('on');box.focus()};recognizer.onerror=()=>{};
|
||
recognizer.start();el('mic').classList.add('on')};
|
||
el('leaveThread').onclick=()=>setFilter('All');
|
||
el('settleChatThread').onclick=async()=>{if(!thread)return;try{await post('api/thread-state',{thread,state:'closed'});el('feedback').textContent='Thread closed; a new message reopens it.'}catch(err){alert(err.message)}};
|
||
el('newMessages').addEventListener('click',scrollBottom);log.addEventListener('scroll',()=>{if(nearBottom())el('newMessages').hidden=true});
|
||
el('sound').onclick=()=>{const s=store.get('sound','off');const next=SOUND_MODES[(SOUND_MODES.indexOf(s)+1)%SOUND_MODES.length];store.set('sound',next);paintSound();if(next==='beep')beep();if(next==='speak')speak({from:'Harbor',text:'Messages to you will be read aloud.',importance:'normal'})};
|
||
function paintNotify(){const on=store.get('notify',false)&&('Notification' in window)&&Notification.permission==='granted';const b=el('notify');b.setAttribute('aria-pressed',String(on));b.innerHTML=ic(on?'bell':'bell-off')+'<span class="lbl">'+(on?'Notify on':'Notify')+'</span>';b.setAttribute('aria-label',on?'Desktop notifications on':'Desktop notifications off')}
|
||
el('notify').onclick=async()=>{if(!('Notification' in window))return alert('This browser has no desktop notifications.');
|
||
if(store.get('notify',false)&&Notification.permission==='granted'){store.set('notify',false)}else{const p=await Notification.requestPermission();store.set('notify',p==='granted')}paintNotify()};
|
||
window.addEventListener('focus',()=>{if(active())markRead()});
|
||
paintNotify();paintSound();
|
||
(async()=>{await pollPeople();await pollMessages();if(active())view();
|
||
every(pollMessages,2000,10000);every(pollPeople,6000,60000);every(refreshMarks,20000,null)})(); /* hidden: still poll for sound and notifications, slower */
|
||
return {view,openThread:setThread};
|
||
})();
|
||
el('pulseHours').onchange=renderPulse;
|
||
document.addEventListener('visibilitychange',()=>{if(!document.hidden){unseen=0;paintTitle()}});
|
||
const startQ=new URLSearchParams(location.search).get('q');
|
||
const onBoard=/\/board\/?$/.test(location.pathname);
|
||
show(startQ?'knowledge':(location.hash.slice(1)||(onBoard?store.get('board.view','overview'):'chat')));
|
||
refresh().then(()=>{renderDigest();if(current==='activity'){renderPulse();renderFeed()}if(current==='launch')renderLaunch();if(current==='decomp')renderDecomp();if(current==='logbook')renderLogbook();if(current==='health')renderTools();if(startQ){el('q').value=startQ;el('searchForm').requestSubmit()}});
|
||
every(refresh,3000,30000);
|
||
every(()=>{if(current==='overview')return renderDigest()},30000,null);
|
||
every(()=>{if(current==='activity')return renderPulse()},60000,null);
|
||
every(()=>{if(current==='launch')return renderLaunch()},5000,null);
|
||
every(()=>{if(current==='activity')return renderFeed()},3000,null);
|
||
</script>
|
||
</body>
|
||
</html>
|