@ucsandman/legcli 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +560 -0
- package/LICENSE +58 -0
- package/NOTICE +4 -0
- package/README.md +558 -0
- package/bin/fake-agent.mjs +143 -0
- package/bin/leg.mjs +449 -0
- package/docs/DECISIONS.md +119 -0
- package/docs/DEMO.md +214 -0
- package/docs/DEVIATIONS.md +182 -0
- package/docs/ERRORS.md +326 -0
- package/docs/README.md +40 -0
- package/docs/REUSE.md +289 -0
- package/docs/ROADMAP-v2.md +55 -0
- package/docs/VOCABULARY.md +243 -0
- package/docs/adapters.md +300 -0
- package/docs/board-guide.md +579 -0
- package/docs/cli-contracts.md +401 -0
- package/docs/concepts.md +426 -0
- package/docs/configuration.md +220 -0
- package/docs/faq.md +206 -0
- package/docs/getting-started.md +251 -0
- package/docs/real-run.md +80 -0
- package/docs/review-2026-09-14.md +72 -0
- package/docs/screenshots/board-400px.png +0 -0
- package/docs/screenshots/board-details-open.png +0 -0
- package/docs/screenshots/board-done.png +0 -0
- package/docs/screenshots/board-drawer.png +0 -0
- package/docs/screenshots/board-empty.png +0 -0
- package/docs/screenshots/board-handoff.png +0 -0
- package/docs/screenshots/board-running.png +0 -0
- package/docs/screenshots/demo-1-claude-running.png +0 -0
- package/docs/screenshots/demo-2-limit-hit.png +0 -0
- package/docs/screenshots/demo-3-handoff-bundle.png +0 -0
- package/docs/screenshots/demo-4-codex-running.png +0 -0
- package/docs/screenshots/demo-5-done.png +0 -0
- package/docs/screenshots/floor-final-1280.png +0 -0
- package/docs/screenshots/floor-final-400.png +0 -0
- package/docs/screenshots/floor-landing.png +0 -0
- package/docs/screenshots/floor.png +0 -0
- package/docs/screenshots/share-guest-1280.png +0 -0
- package/docs/screenshots/share-owner-1280.png +0 -0
- package/docs/screenshots/terminals-1280.png +0 -0
- package/docs/screenshots/thumb-done.jpg +0 -0
- package/docs/screenshots/thumb-handoff-details.jpg +0 -0
- package/docs/screenshots/thumb-limit-hit.jpg +0 -0
- package/docs/screenshots/youtube-thumbnail.jpg +0 -0
- package/fixtures/limits/agy/agy-resource-exhausted.json +11 -0
- package/fixtures/limits/claude/claude-budget-limit.json +11 -0
- package/fixtures/limits/claude/claude-max-turns.json +11 -0
- package/fixtures/limits/claude/claude-model-limit.json +11 -0
- package/fixtures/limits/claude/claude-session-limit.json +11 -0
- package/fixtures/limits/claude/claude-weekly-limit.json +11 -0
- package/fixtures/limits/codex/codex-quota-exceeded.json +11 -0
- package/fixtures/limits/codex/codex-rate-limit-exceeded.json +11 -0
- package/fixtures/limits/codex/codex-skills-notice.json +11 -0
- package/fixtures/limits/codex/codex-usage-limit-reached.json +11 -0
- package/fixtures/limits/codex/codex-usage-limit.json +11 -0
- package/fixtures/limits/generic/generic-429.json +11 -0
- package/fixtures/limits/generic/generic-overloaded.json +11 -0
- package/fixtures/limits/generic/generic-quota.json +11 -0
- package/fixtures/limits/generic/generic-rate-limit.json +11 -0
- package/fixtures/limits/generic/generic-resource-exhausted.json +11 -0
- package/fixtures/limits/generic/generic-usage-limit.json +11 -0
- package/fixtures/limits/grok/grok-not-logged-in.json +11 -0
- package/fixtures/limits/negative/auth-source-set.json +11 -0
- package/fixtures/limits/negative/compile-error.json +11 -0
- package/fixtures/limits/negative/empty-stdout-exit-0.json +11 -0
- package/fixtures/live/agy/attempt-1-scratch-workspace.out.log +1 -0
- package/fixtures/live/agy/cmd.txt +1 -0
- package/fixtures/live/agy/err.log +0 -0
- package/fixtures/live/agy/out.log +1 -0
- package/fixtures/live/agy/parsed.json +5 -0
- package/fixtures/live/agy/run.json +18 -0
- package/fixtures/live/agy/supervisor.log +2 -0
- package/fixtures/live/claude/cmd.txt +1 -0
- package/fixtures/live/claude/err.log +0 -0
- package/fixtures/live/claude/limit-rate_limit.json +21 -0
- package/fixtures/live/claude/out.log +1 -0
- package/fixtures/live/claude/parsed.json +11 -0
- package/fixtures/live/claude/run.json +18 -0
- package/fixtures/live/claude/supervisor.log +2 -0
- package/fixtures/live/codex/cmd.txt +1 -0
- package/fixtures/live/codex/err.log +1 -0
- package/fixtures/live/codex/last.md +2 -0
- package/fixtures/live/codex/limit-usage_limit_exceeded.json +25 -0
- package/fixtures/live/codex/out.log +8 -0
- package/fixtures/live/codex/parsed.json +15 -0
- package/fixtures/live/codex/run.json +18 -0
- package/fixtures/live/codex/supervisor.log +2 -0
- package/fixtures/live/grok/cmd.txt +1 -0
- package/fixtures/live/grok/err.log +32 -0
- package/fixtures/live/grok/out.log +7 -0
- package/fixtures/live/grok/parsed.json +5 -0
- package/fixtures/live/grok/run.json +18 -0
- package/fixtures/live/grok/supervisor.log +2 -0
- package/fixtures/verified.json +34 -0
- package/package.json +68 -0
- package/scripts/board-shots.mjs +69 -0
- package/scripts/build-docs-site.mjs +308 -0
- package/scripts/check-claims.mjs +150 -0
- package/scripts/clean-clone-check.sh +42 -0
- package/scripts/license-sign.mjs +23 -0
- package/scripts/limits-table.mjs +38 -0
- package/scripts/live-limits.mjs +36 -0
- package/scripts/privacy-check.mjs +63 -0
- package/scripts/probe.mjs +92 -0
- package/scripts/seed-fake-cards.mjs +34 -0
- package/scripts/seed-floor-board.mjs +69 -0
- package/scripts/seed-wes-board.mjs +91 -0
- package/scripts/stripe-setup.mjs +99 -0
- package/scripts/vercel-env.mjs +40 -0
- package/src/accounts.mjs +119 -0
- package/src/adapters/agy.mjs +56 -0
- package/src/adapters/claude.mjs +54 -0
- package/src/adapters/codex.mjs +73 -0
- package/src/adapters/common.mjs +32 -0
- package/src/adapters/fake.mjs +48 -0
- package/src/adapters/grok.mjs +51 -0
- package/src/adapters/index.mjs +27 -0
- package/src/adapters/resolve.mjs +40 -0
- package/src/attach.mjs +592 -0
- package/src/auth.mjs +85 -0
- package/src/board/board.css +543 -0
- package/src/board/board.js +1307 -0
- package/src/board/favicon.svg +1 -0
- package/src/board/floor.html +127 -0
- package/src/board/floor.js +670 -0
- package/src/board/fonts/OFL-atkinson-hyperlegible-next.txt +93 -0
- package/src/board/fonts/OFL-azeret-mono.txt +94 -0
- package/src/board/fonts/atkinson-board.woff2 +0 -0
- package/src/board/fonts/azeret-board.woff2 +0 -0
- package/src/board/index.html +251 -0
- package/src/board/sessions.js +1451 -0
- package/src/bundle.mjs +103 -0
- package/src/cards.mjs +114 -0
- package/src/chain.mjs +264 -0
- package/src/commands.mjs +79 -0
- package/src/contract.mjs +73 -0
- package/src/env.mjs +19 -0
- package/src/fsx.mjs +72 -0
- package/src/git-snapshot.mjs +152 -0
- package/src/handoff.mjs +171 -0
- package/src/hook.mjs +49 -0
- package/src/land.mjs +106 -0
- package/src/launcher.mjs +272 -0
- package/src/leases.mjs +86 -0
- package/src/ledger.mjs +369 -0
- package/src/license.mjs +168 -0
- package/src/limits.mjs +97 -0
- package/src/live-capture.mjs +69 -0
- package/src/mergequeue.mjs +225 -0
- package/src/orchestrator.mjs +365 -0
- package/src/pipeline.mjs +100 -0
- package/src/preferences.mjs +47 -0
- package/src/presets.mjs +23 -0
- package/src/ratelimit.mjs +49 -0
- package/src/redact.mjs +47 -0
- package/src/resume.mjs +354 -0
- package/src/runner.mjs +438 -0
- package/src/scheduler.mjs +118 -0
- package/src/server.mjs +845 -0
- package/src/session-detail.mjs +129 -0
- package/src/sessions.mjs +202 -0
- package/src/share.mjs +163 -0
- package/src/stations/agent.mjs +42 -0
- package/src/stations/build.mjs +9 -0
- package/src/stations/human.mjs +5 -0
- package/src/stations/land.mjs +6 -0
- package/src/stations/plan.mjs +8 -0
- package/src/stations/pr.mjs +31 -0
- package/src/stations/review.mjs +8 -0
- package/src/stations/test.mjs +27 -0
- package/src/store.mjs +90 -0
- package/src/sync/dashclaw.mjs +70 -0
- package/src/sync/index.mjs +54 -0
- package/src/sync/workboard.mjs +54 -0
- package/src/taps/agy.mjs +64 -0
- package/src/taps/claude-usage.mjs +67 -0
- package/src/taps/claude.mjs +170 -0
- package/src/taps/codex.mjs +286 -0
- package/src/trust.mjs +323 -0
- package/src/usage.mjs +179 -0
- package/src/wait.mjs +30 -0
- package/src/worktree.mjs +207 -0
|
@@ -0,0 +1,1451 @@
|
|
|
1
|
+
// Terminals lane: the window rail (5h/7d per login), the sessions started with
|
|
2
|
+
// `baton claude|codex|agy`, overlap flags (two live sessions editing the same
|
|
3
|
+
// file), and what has landed on trunk. Data: /api/sessions, pushed as the SSE
|
|
4
|
+
// `sessions` event (board.js re-dispatches it as `baton:sessions`).
|
|
5
|
+
//
|
|
6
|
+
// The design is .design/BOARD-DESIGN.md sections 6.1 to 6.6; the ids, class
|
|
7
|
+
// names and frozen source shapes are .design/BUILD-CONTRACT.md section 6.1.
|
|
8
|
+
// Two tests read this file as SOURCE TEXT, not as behaviour:
|
|
9
|
+
// test/board-drawer.test.mjs takeScroll/putScroll, renderDrawer's
|
|
10
|
+
// capture -> wipe -> restore order, the four data-scroll-key literals, and
|
|
11
|
+
// the two "newest first" strings.
|
|
12
|
+
// test/resume.test.mjs resumeLine(v) and the line that appends it.
|
|
13
|
+
// Read both before renaming anything here. board-a11y does not scan this file,
|
|
14
|
+
// so every control it builds carrying a visible name or an 'aria-label' is a
|
|
15
|
+
// hand rule with no test behind it.
|
|
16
|
+
(function () {
|
|
17
|
+
'use strict'
|
|
18
|
+
// 6.3: the nine shipped status words, unchanged, each with the tone of its
|
|
19
|
+
// 7px mark. The word carries the meaning; the mark is aria-hidden.
|
|
20
|
+
const STATUS = {
|
|
21
|
+
starting: ['starting', 'run'], running: ['running', 'run'], warning: ['near limit', 'warn'], limit: ['limit hit', 'danger'],
|
|
22
|
+
handing_off: ['handing off', 'warn'], waiting: ['waiting for reset', 'warn'], handed_off: ['handed off', 'idle'], ended: ['ended', 'idle'], lost: ['lost', 'danger'],
|
|
23
|
+
}
|
|
24
|
+
const WIN_WORDS = { '5h': '5 hour', '7d': '7 day' }
|
|
25
|
+
const IDS = ['claude', 'codex', 'agy', 'fake']
|
|
26
|
+
|
|
27
|
+
let view = null
|
|
28
|
+
const NO_BRANCH_BLOCKER = 'this terminal works in the checkout itself: there is no branch of its own to land'
|
|
29
|
+
let hoisted = new Set()
|
|
30
|
+
let trunkOpen = false
|
|
31
|
+
let finishedOpen = false
|
|
32
|
+
let pendingConfirm = null
|
|
33
|
+
const sessionEditors = new Map()
|
|
34
|
+
const alsoOpen = new Set()
|
|
35
|
+
const actionNotes = new Map()
|
|
36
|
+
const lastTone = new Map()
|
|
37
|
+
const defaultEditor = { order: null, dirty: false, saving: false, status: '', statusClass: '' }
|
|
38
|
+
|
|
39
|
+
function getToken() { return localStorage.getItem('legToken') || localStorage.getItem('batonToken') || '' }
|
|
40
|
+
async function api(path, opts = {}) {
|
|
41
|
+
const headers = { 'Content-Type': 'application/json' }
|
|
42
|
+
const token = getToken()
|
|
43
|
+
if (token) headers.Authorization = `Bearer ${token}`
|
|
44
|
+
const r = await fetch(path, { method: opts.method || 'GET', headers, body: opts.body ? JSON.stringify(opts.body) : undefined })
|
|
45
|
+
const data = await r.json().catch(() => ({}))
|
|
46
|
+
if (!r.ok) throw new Error(data.error || `${r.status} ${r.statusText}`)
|
|
47
|
+
return data
|
|
48
|
+
}
|
|
49
|
+
function el(tag, attrs, children) {
|
|
50
|
+
const n = document.createElement(tag)
|
|
51
|
+
for (const [k, v] of Object.entries(attrs || {})) { if (v === null || v === undefined) continue; if (k === 'class') n.className = v; else n.setAttribute(k, v) }
|
|
52
|
+
for (const c of children || []) { if (c === null || c === undefined) continue; n.appendChild(typeof c === 'string' ? document.createTextNode(c) : c) }
|
|
53
|
+
return n
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// 6.9: this page has ONE system message, and it lives in board.js, published
|
|
57
|
+
// as window.batonMessage. A result that belongs to a terminal is written into
|
|
58
|
+
// that terminal's sentence slot instead; only what belongs to no object at all
|
|
59
|
+
// comes through here. This file no longer writes into #toast itself.
|
|
60
|
+
function sysMessage(text, tone) { const fn = window.legMessage || window.batonMessage; if (typeof fn === 'function') fn(text, tone) }
|
|
61
|
+
|
|
62
|
+
// ---- times. The head prints `Times are local.` once, so no row repeats it ----
|
|
63
|
+
// THIS FILE OWNS THE TIME GRAMMAR FOR THE WHOLE BOARD. ago(), clockAt(),
|
|
64
|
+
// until() and elapsedClock() below are copied character for character into
|
|
65
|
+
// src/board/floor.js and src/board/board.js, which cannot import from here.
|
|
66
|
+
// One fact must never print in two formats across the two pages, so a change
|
|
67
|
+
// to any of these four is a change to all three files in the same commit.
|
|
68
|
+
function ago(ms) {
|
|
69
|
+
const s = Math.max(0, Math.floor(ms / 1000))
|
|
70
|
+
if (s < 60) return `${s}s`
|
|
71
|
+
const m = Math.floor(s / 60)
|
|
72
|
+
if (m < 60) return `${m}m`
|
|
73
|
+
const h = Math.floor(m / 60)
|
|
74
|
+
if (h < 48) return `${h}h${m % 60 ? ` ${m % 60}m` : ''}`
|
|
75
|
+
const d = Math.floor(h / 24)
|
|
76
|
+
return `${d}d${h % 24 ? ` ${h % 24}h` : ''}`
|
|
77
|
+
}
|
|
78
|
+
// aria-valuetext is read out loud, so the gap is spelled out there and stays
|
|
79
|
+
// abbreviated in the visible cells. Both floor, so the spoken sentence and the
|
|
80
|
+
// cell beside it never differ by a minute on the same rail.
|
|
81
|
+
function spoken(ms) {
|
|
82
|
+
const m = Math.max(0, Math.floor(ms / 60000))
|
|
83
|
+
if (m < 60) return `${m} minute${m === 1 ? '' : 's'}`
|
|
84
|
+
const h = Math.floor(m / 60)
|
|
85
|
+
const rest = m % 60
|
|
86
|
+
if (h < 48) return `${h} hour${h === 1 ? '' : 's'}${rest ? ` ${rest} minute${rest === 1 ? '' : 's'}` : ''}`
|
|
87
|
+
return `${Math.floor(h / 24)} days`
|
|
88
|
+
}
|
|
89
|
+
function clockAt(ms) {
|
|
90
|
+
if (!Number.isFinite(ms)) return 'unknown'
|
|
91
|
+
const d = new Date(ms)
|
|
92
|
+
const out = Math.abs(ms - Date.now())
|
|
93
|
+
if (out < 20 * 3600000) return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
|
|
94
|
+
if (out < 6 * 86400000) return d.toLocaleString([], { weekday: 'short', hour: 'numeric', minute: '2-digit' })
|
|
95
|
+
return d.toLocaleDateString([], { month: 'short', day: 'numeric' })
|
|
96
|
+
}
|
|
97
|
+
function until(epochS) { return Number.isFinite(epochS) ? clockAt(epochS * 1000) : 'unknown' }
|
|
98
|
+
function whenAgo(ts) {
|
|
99
|
+
const t = Date.parse(ts)
|
|
100
|
+
return Number.isFinite(t) ? `${ago(Date.now() - t)} ago` : ''
|
|
101
|
+
}
|
|
102
|
+
// G12: fixed form, zero padded, so a column of elapsed clocks aligns on its
|
|
103
|
+
// colon whether the terminal has run four minutes or four hours.
|
|
104
|
+
function elapsedClock(ms) {
|
|
105
|
+
const s = Math.max(0, Math.floor(ms / 1000))
|
|
106
|
+
const pad = (n) => String(n).padStart(2, '0')
|
|
107
|
+
const mm = pad(Math.floor(s / 60) % 60)
|
|
108
|
+
const ss = pad(s % 60)
|
|
109
|
+
const h = Math.floor(s / 3600)
|
|
110
|
+
return h ? `${pad(h)}:${mm}:${ss}` : `${mm}:${ss}`
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// A pasted screenshot arrives in the prompt as a machine tag carrying an
|
|
114
|
+
// absolute temp path. On the board that is two lines of noise in front of the
|
|
115
|
+
// sentence the reader came to read, and it puts the operator's own home
|
|
116
|
+
// directory on screen. The tag is replaced by what it actually was.
|
|
117
|
+
function promptText(task) {
|
|
118
|
+
if (!task) return 'no prompt yet'
|
|
119
|
+
const cleaned = String(task)
|
|
120
|
+
.replace(/<image\b[^>]*>[\s\S]*?<\/image>/gi, ' ')
|
|
121
|
+
.replace(/<image\b[^>]*>/gi, ' ')
|
|
122
|
+
.replace(/\[Image #\d+\]/g, ' ')
|
|
123
|
+
.replace(/\s+/g, ' ')
|
|
124
|
+
.trim()
|
|
125
|
+
if (!cleaned) return 'an image, with no text'
|
|
126
|
+
return /^<image/i.test(String(task).trim()) || /\[Image #\d+\]/.test(String(task)) ? `(image) ${cleaned}` : cleaned
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// A file is identified by its name. The directory above it is the same for
|
|
130
|
+
// every file in the list, and when the work happens in a temp directory the
|
|
131
|
+
// full path is 90 characters of noise per file with the operator's user name
|
|
132
|
+
// in the middle of it. The whole path stays on the title and in the detail.
|
|
133
|
+
function fileLabel(path) {
|
|
134
|
+
const parts = String(path).split(/[\\/]/).filter(Boolean)
|
|
135
|
+
return parts.length ? parts[parts.length - 1] + (/[\\/]$/.test(String(path)) ? '/' : '') : String(path)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function accountLabel(a) { return a.label || (a.account === 'default' ? a.agent : `${a.agent}/${a.account}`) }
|
|
139
|
+
function optionLabel(a) { return a ? (a.account && a.account !== 'default' ? `${a.agent}/${a.account}` : a.agent) : 'none' }
|
|
140
|
+
function idOf(agent) { return IDS.includes(agent) ? agent : 'fake' }
|
|
141
|
+
const tail = (id) => String(id).split('-').slice(-2).join('-')
|
|
142
|
+
const shared = () => Boolean(view && view.share && view.share.on)
|
|
143
|
+
const isMine = (s) => Boolean(view && view.you && s.owner && view.you.name === s.owner)
|
|
144
|
+
|
|
145
|
+
// ---- 6.1 the window rail ----------------------------------------------
|
|
146
|
+
// One state value per account drives the .acct modifier, every rail cell, the
|
|
147
|
+
// tier word and the spoken sentence, so those four cannot disagree.
|
|
148
|
+
function acctState(a) {
|
|
149
|
+
if (a.shared === false) return 'notshared'
|
|
150
|
+
if (a.loading) return 'loading'
|
|
151
|
+
if (Number.isFinite(a.limited_until) && a.limited_until * 1000 > Date.now()) return 'walled'
|
|
152
|
+
if (!a.five_hour && !a.seven_day) return 'noreading'
|
|
153
|
+
if (a.stale && a.agent !== 'agy') return 'stale'
|
|
154
|
+
return 'ok'
|
|
155
|
+
}
|
|
156
|
+
// What one window's numeral cell can say. A window with no reading prints the
|
|
157
|
+
// refusal, never a zero: a zero is a reading.
|
|
158
|
+
function windowState(a, w) {
|
|
159
|
+
const s = acctState(a)
|
|
160
|
+
if (s === 'notshared' || s === 'loading') return s
|
|
161
|
+
return w && Number.isFinite(w.pct) ? 'reading' : 'noreading'
|
|
162
|
+
}
|
|
163
|
+
// 6.1.6: a meter announces its name and its value text, so the whole answer
|
|
164
|
+
// goes in one sentence instead of four separate visible cells.
|
|
165
|
+
function railValueText(a, w, kind) {
|
|
166
|
+
const words = WIN_WORDS[kind] || kind
|
|
167
|
+
const state = windowState(a, w)
|
|
168
|
+
const parts = []
|
|
169
|
+
if (state === 'notshared') parts.push(`Usage for the ${words} window is not shared with guests.`)
|
|
170
|
+
else if (state === 'loading') parts.push(`No reading for the ${words} window has come back from /api/sessions yet.`)
|
|
171
|
+
else if (state === 'noreading') {
|
|
172
|
+
parts.push(a.agent === 'agy'
|
|
173
|
+
? `agy publishes no usage percentage for the ${words} window. Leg sees the wall when agy hits it.`
|
|
174
|
+
: `No reading for the ${words} window yet.`)
|
|
175
|
+
} else {
|
|
176
|
+
parts.push(`${Math.round(w.pct)} percent of the ${words} window used.`)
|
|
177
|
+
if (Number.isFinite(w.resets_at)) parts.push(`Resets at ${until(w.resets_at)}, in ${spoken(w.resets_at * 1000 - Date.now())}.`)
|
|
178
|
+
}
|
|
179
|
+
if (acctState(a) === 'walled') parts.push(`${accountLabel(a)} is at its wall until ${until(a.limited_until)}, in ${spoken(a.limited_until * 1000 - Date.now())}.`)
|
|
180
|
+
const observed = Date.parse(a.observed_at || a.updated_at || '')
|
|
181
|
+
if (a.stale && a.agent !== 'agy' && Number.isFinite(observed)) parts.push(`Read at ${clockAt(observed)}, ${spoken(Date.now() - observed)} ago, stale.`)
|
|
182
|
+
return parts.join(' ')
|
|
183
|
+
}
|
|
184
|
+
function worstWindow(a) {
|
|
185
|
+
const w = [a.five_hour, a.seven_day].filter((x) => x && Number.isFinite(x.pct)).sort((x, y) => y.pct - x.pct)
|
|
186
|
+
return w[0] || a.five_hour || a.seven_day || null
|
|
187
|
+
}
|
|
188
|
+
// Drives the condensed head under 620px: walled first, then the highest
|
|
189
|
+
// percentage across both windows, ties broken by the soonest reset.
|
|
190
|
+
function closestToWall(accounts) {
|
|
191
|
+
const walled = (a) => (acctState(a) === 'walled' ? 1 : 0)
|
|
192
|
+
const worst = (a) => { const w = worstWindow(a); return w && Number.isFinite(w.pct) ? w.pct : -1 }
|
|
193
|
+
const soonest = (a) => {
|
|
194
|
+
const r = [a.five_hour, a.seven_day].filter((w) => w && Number.isFinite(w.resets_at)).map((w) => w.resets_at)
|
|
195
|
+
return r.length ? Math.min(...r) : Infinity
|
|
196
|
+
}
|
|
197
|
+
return [...accounts].sort((x, y) => (walled(y) - walled(x)) || (worst(y) - worst(x)) || (soonest(x) - soonest(y)))[0] || null
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// The instrument. One track, one fill, one numeral, and a 2px notch cut
|
|
201
|
+
// through the bar where 85 percent sits. `minor` is the second window on a
|
|
202
|
+
// login: the same instrument at half the height and a smaller numeral, so
|
|
203
|
+
// the reader's eye lands on the window that is closest to a wall first.
|
|
204
|
+
function gauge(account, win, kind, { minor = false } = {}) {
|
|
205
|
+
const state = windowState(account, win)
|
|
206
|
+
const pct = state === 'reading' ? Math.max(0, Math.min(100, Math.round(win.pct))) : null
|
|
207
|
+
const words = WIN_WORDS[kind] || kind
|
|
208
|
+
const valueText = railValueText(account, win, kind)
|
|
209
|
+
const id = idOf(account.agent)
|
|
210
|
+
// severity paints INSIDE the track and nowhere else: the fill runs in the
|
|
211
|
+
// login's own identity colour up to 85 percent and in the over colour past
|
|
212
|
+
// it, so the bar shows the reserve it has eaten without colouring a word
|
|
213
|
+
const fill = pct === null ? null : el('span', {
|
|
214
|
+
class: 'gauge-fill',
|
|
215
|
+
style: pct <= 85
|
|
216
|
+
? `width:${pct}%;background:var(--id-${id})`
|
|
217
|
+
: `width:${pct}%;background:linear-gradient(to right,var(--id-${id}) 0 ${((85 / pct) * 100).toFixed(2)}%,var(--danger) ${((85 / pct) * 100).toFixed(2)}% 100%)`,
|
|
218
|
+
})
|
|
219
|
+
// 6.1.6: a meter with no value is not a meter. aria-valuenow is required by
|
|
220
|
+
// role=meter, and an empty or absent one is announced as zero percent, which
|
|
221
|
+
// is the fabricated reading the visible cell refuses to print. With no value
|
|
222
|
+
// the track drops the role and carries the same sentence as its name.
|
|
223
|
+
const semantics = pct === null
|
|
224
|
+
? { role: 'img', 'aria-label': `${accountLabel(account)}, ${words} window. ${valueText}` }
|
|
225
|
+
: {
|
|
226
|
+
role: 'meter',
|
|
227
|
+
'aria-valuemin': '0',
|
|
228
|
+
'aria-valuemax': '100',
|
|
229
|
+
'aria-valuenow': String(pct),
|
|
230
|
+
'aria-label': `${accountLabel(account)}, ${words} window`,
|
|
231
|
+
'aria-valuetext': valueText,
|
|
232
|
+
}
|
|
233
|
+
if (pct === null && state !== 'reading') {
|
|
234
|
+
// no number exists, so no instrument is drawn. A track with nothing in it
|
|
235
|
+
// is a reading of zero to anyone glancing at it.
|
|
236
|
+
return el('div', { class: 'gauge gauge--none' }, [
|
|
237
|
+
el('span', { class: 'gauge-label' }, [words]),
|
|
238
|
+
el('span', { class: 'gauge-read--none', ...semantics }, [state === 'notshared' ? 'not shared' : state === 'loading' ? 'reading' : 'no reading']),
|
|
239
|
+
])
|
|
240
|
+
}
|
|
241
|
+
const readout = [el('span', { class: 'gauge-read' }, [`${pct}%`])]
|
|
242
|
+
const observed = Date.parse(account.observed_at || account.updated_at || '')
|
|
243
|
+
if (!minor && account.stale && account.agent !== 'agy' && Number.isFinite(observed)) {
|
|
244
|
+
readout.push(el('span', { class: 'gauge-note' }, [`measured ${ago(Date.now() - observed)} ago`]))
|
|
245
|
+
} else if (!minor && win && Number.isFinite(win.resets_at)) {
|
|
246
|
+
readout.push(el('span', { class: 'gauge-note' }, [`resets ${until(win.resets_at)}`]))
|
|
247
|
+
}
|
|
248
|
+
return el('div', { class: `gauge${minor ? ' gauge--minor' : ''}` }, [
|
|
249
|
+
el('span', { class: 'gauge-label' }, [words]),
|
|
250
|
+
el('div', { class: 'gauge-track', ...semantics }, [fill, el('span', { class: 'gauge-post', style: 'left:85%' })]),
|
|
251
|
+
el('div', { class: 'gauge-readout' }, readout),
|
|
252
|
+
])
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// A login is a raised object, and how much surface it gets is the design
|
|
256
|
+
// saying how much it matters. The login carrying the terminals gets the wide
|
|
257
|
+
// lit panel with both of its windows drawn; a login with one fact to report
|
|
258
|
+
// gets a half panel; a login that publishes no figure draws no instrument at
|
|
259
|
+
// all, because an empty track reads as a measurement of zero.
|
|
260
|
+
function loginPanel(a, { lit = false } = {}) {
|
|
261
|
+
const state = acctState(a)
|
|
262
|
+
const id = idOf(a.agent)
|
|
263
|
+
const panel = el('article', { class: `panel${lit ? ' panel--lit' : ''}`, 'aria-label': `${accountLabel(a)} usage` })
|
|
264
|
+
const live = a.live ? `${a.live} terminal${a.live === 1 ? '' : 's'} working` : 'no terminals'
|
|
265
|
+
panel.appendChild(el('div', { class: 'panel-head' }, [
|
|
266
|
+
el('span', { class: 'who' }, [el('span', { class: `dot id-${id}` }), el('span', { class: `acct-name id-${id}` }, [accountLabel(a)])]),
|
|
267
|
+
el('span', { class: 'who-note' }, [live]),
|
|
268
|
+
]))
|
|
269
|
+
|
|
270
|
+
if (state === 'notshared' || state === 'loading') {
|
|
271
|
+
panel.appendChild(gauge(a, null, '5h'))
|
|
272
|
+
panel.appendChild(el('p', { class: 'reading-sub reading-sub--lead' }, [state === 'notshared' ? 'Usage for this login is not shared with guests.' : 'Waiting for the first reading.']))
|
|
273
|
+
return panel
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// A login at its wall is reporting the loudest fact it has, and it is a
|
|
277
|
+
// known one: say that instead of "no reading", whether or not a percentage
|
|
278
|
+
// ever came back.
|
|
279
|
+
if (state === 'walled' && !a.five_hour && !a.seven_day) {
|
|
280
|
+
panel.appendChild(el('p', { class: 'reading' }, ['At the wall']))
|
|
281
|
+
panel.appendChild(el('p', { class: 'reading-sub' }, [Number.isFinite(a.limited_until)
|
|
282
|
+
? `Back ${until(a.limited_until)}. Nothing runs on ${accountLabel(a)} until then.`
|
|
283
|
+
: `Nothing runs on ${accountLabel(a)} until it resets.`]))
|
|
284
|
+
return panel
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// agy publishes no usage figure, ever, so there is nothing to draw and the
|
|
288
|
+
// panel says so in a sentence instead of drawing an empty instrument
|
|
289
|
+
if (!a.five_hour && !a.seven_day) {
|
|
290
|
+
panel.appendChild(gauge(a, null, '5h'))
|
|
291
|
+
panel.appendChild(el('p', { class: 'reading-sub reading-sub--lead' }, [a.agent === 'agy'
|
|
292
|
+
? 'agy publishes no usage figure, ever. Leg shows its terminals and their elapsed time instead.'
|
|
293
|
+
: `No reading has come back from ${accountLabel(a)} yet.`]))
|
|
294
|
+
return panel
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// the window closest to a wall is drawn first and full size; the other is
|
|
298
|
+
// the same instrument at half height, so which one to read is not a question
|
|
299
|
+
const [first, second] = [a.seven_day, a.five_hour].every((w) => w && Number.isFinite(w.pct))
|
|
300
|
+
? (a.seven_day.pct >= a.five_hour.pct ? [[a.seven_day, '7d'], [a.five_hour, '5h']] : [[a.five_hour, '5h'], [a.seven_day, '7d']])
|
|
301
|
+
: [[a.five_hour && Number.isFinite(a.five_hour.pct) ? a.five_hour : a.seven_day, a.five_hour && Number.isFinite(a.five_hour.pct) ? '5h' : '7d'], null]
|
|
302
|
+
panel.appendChild(el('div', { class: 'gauge-block' }, [gauge(a, first[0], first[1])]))
|
|
303
|
+
if (second) panel.appendChild(el('div', { class: 'gauge-block' }, [gauge(a, second[0], second[1], { minor: true })]))
|
|
304
|
+
|
|
305
|
+
if (state === 'walled') {
|
|
306
|
+
panel.appendChild(el('p', { class: 'reading' }, ['At the wall']))
|
|
307
|
+
panel.appendChild(el('p', { class: 'reading-sub' }, [`Back ${until(a.limited_until)}. Nothing runs on ${accountLabel(a)} until then.`]))
|
|
308
|
+
}
|
|
309
|
+
return panel
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// The headline is the one fact that decides what happens next, said as a
|
|
313
|
+
// sentence. It is never the number that the panel under it already prints:
|
|
314
|
+
// the same figure in the two largest slots on the page is one fact taking up
|
|
315
|
+
// two, which is what the rejected head did.
|
|
316
|
+
function verdictLines(list, sessions) {
|
|
317
|
+
const liveSessions = (sessions || []).filter((s) => s.active)
|
|
318
|
+
const withReading = list.filter((a) => worstWindow(a) && Number.isFinite(worstWindow(a).pct))
|
|
319
|
+
if (!list.length) return { line: 'Reading the logins.', sub: '' }
|
|
320
|
+
|
|
321
|
+
const walled = list.filter((a) => acctState(a) === 'walled')
|
|
322
|
+
const busiest = [...list].sort((x, y) => (y.live || 0) - (x.live || 0))[0]
|
|
323
|
+
const onOneLogin = liveSessions.length > 0 && new Set(liveSessions.map((s) => s.agent)).size === 1
|
|
324
|
+
const subject = onOneLogin ? list.find((a) => a.agent === liveSessions[0].agent) || busiest : closestToWall(list)
|
|
325
|
+
const w = subject ? worstWindow(subject) : null
|
|
326
|
+
const known = w && Number.isFinite(w.pct)
|
|
327
|
+
const left = known ? Math.max(0, 100 - Math.round(w.pct)) : null
|
|
328
|
+
|
|
329
|
+
let line
|
|
330
|
+
if (!liveSessions.length) {
|
|
331
|
+
line = walled.length
|
|
332
|
+
? `Nothing is running, and ${walled.map(accountLabel).join(' and ')} ${walled.length === 1 ? 'is' : 'are'} at the wall.`
|
|
333
|
+
: 'Nothing is running.'
|
|
334
|
+
} else if (onOneLogin && known) {
|
|
335
|
+
// C's framing: one login, one point of failure. The number is what is
|
|
336
|
+
// LEFT, because that is the quantity the reader is deciding against.
|
|
337
|
+
// One terminal is not "all the terminal": the plural sentence is about
|
|
338
|
+
// everything riding on one login, and with a single terminal there is no
|
|
339
|
+
// "all" to make, so it says the plainer thing.
|
|
340
|
+
line = liveSessions.length === 1
|
|
341
|
+
? `Your terminal is on ${accountLabel(subject)}, and ${accountLabel(subject)} has ${left}% left.`
|
|
342
|
+
: `All ${liveSessions.length} terminals are on ${accountLabel(subject)}, and ${accountLabel(subject)} has ${left}% left.`
|
|
343
|
+
} else if (known) {
|
|
344
|
+
line = `${accountLabel(subject)} has ${left}% left, and ${liveSessions.length} terminal${liveSessions.length === 1 ? ' is' : 's are'} working.`
|
|
345
|
+
} else {
|
|
346
|
+
line = `${liveSessions.length} terminal${liveSessions.length === 1 ? ' is' : 's are'} working.`
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// A's staleness sentence. A reading taken two hours ago with terminals
|
|
350
|
+
// running since is a floor, not a measurement, and saying which direction
|
|
351
|
+
// it is wrong in is the whole point of printing the age.
|
|
352
|
+
const parts = []
|
|
353
|
+
const observed = subject ? Date.parse(subject.observed_at || subject.updated_at || '') : NaN
|
|
354
|
+
if (subject && subject.stale && subject.agent !== 'agy' && Number.isFinite(observed) && known) {
|
|
355
|
+
parts.push(liveSessions.length
|
|
356
|
+
? `Measured ${ago(Date.now() - observed)} ago. ${liveSessions.length} terminal${liveSessions.length === 1 ? ' has' : 's have'} been running since, so the real figure is higher than ${Math.round(w.pct)} percent, never lower.`
|
|
357
|
+
: `Measured ${ago(Date.now() - observed)} ago.`)
|
|
358
|
+
}
|
|
359
|
+
for (const a of walled) {
|
|
360
|
+
if (a === subject) continue
|
|
361
|
+
parts.push(Number.isFinite(a.limited_until)
|
|
362
|
+
? `${accountLabel(a)} is at the wall until ${until(a.limited_until)}.`
|
|
363
|
+
: `${accountLabel(a)} is at the wall.`)
|
|
364
|
+
}
|
|
365
|
+
if (!withReading.length && liveSessions.length) parts.push('No login has reported a usage figure yet.')
|
|
366
|
+
return { line, sub: parts.slice(0, 2).join(' ') }
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// 6.1.5, all eight states. The walled state is an ADDITIONAL state of the
|
|
370
|
+
// rail, never a replacement for it: both percentages, both reset times and
|
|
371
|
+
// both rails stay on screen while the account is at its wall, and R4 grows.
|
|
372
|
+
function renderAccounts(accounts) {
|
|
373
|
+
const box = document.getElementById('accounts')
|
|
374
|
+
if (!box) return
|
|
375
|
+
box.textContent = ''
|
|
376
|
+
const list = accounts || []
|
|
377
|
+
|
|
378
|
+
const { line, sub } = verdictLines(list, view ? view.sessions : [])
|
|
379
|
+
const h1 = document.getElementById('verdict-line')
|
|
380
|
+
const p = document.getElementById('verdict-sub')
|
|
381
|
+
if (h1) h1.textContent = line
|
|
382
|
+
if (p) p.textContent = sub
|
|
383
|
+
|
|
384
|
+
if (!list.length) return
|
|
385
|
+
// Size encodes importance. The login the terminals are on gets the wide lit
|
|
386
|
+
// panel; the rest share the row beneath it. When every login is idle the
|
|
387
|
+
// one closest to a wall leads, because that is the one that decides whether
|
|
388
|
+
// the next terminal can start.
|
|
389
|
+
const liveSessions = (view ? view.sessions || [] : []).filter((s) => s.active)
|
|
390
|
+
const lead = (liveSessions.length && list.find((a) => a.agent === liveSessions[0].agent)) || closestToWall(list) || list[0]
|
|
391
|
+
const rest = list.filter((a) => a !== lead)
|
|
392
|
+
box.appendChild(loginPanel(lead, { lit: true }))
|
|
393
|
+
if (rest.length === 1) box.appendChild(loginPanel(rest[0]))
|
|
394
|
+
else if (rest.length) box.appendChild(el('div', { class: 'logins-pair' }, rest.map((a) => loginPanel(a))))
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Before the first /api/sessions comes back the rail draws its face and its
|
|
398
|
+
// graduations and says `reading`. Never a zero.
|
|
399
|
+
function renderLoadingHead() {
|
|
400
|
+
renderAccounts([{ label: 'accounts', agent: null, account: 'default', loading: true, five_hour: null, seven_day: null }])
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// ---- 6.4 rankedNotes: one ranking, four surfaces ------------------------
|
|
404
|
+
// The notice set is modelled as ranked DATA, not written per fixture. The
|
|
405
|
+
// panel's one sentence, the `also:` disclosure, the expanded detail and the
|
|
406
|
+
// Terminals region-head count all read this list, so a live overlap warning
|
|
407
|
+
// cannot end up behind a past success on the same row.
|
|
408
|
+
function landLine(s) {
|
|
409
|
+
const L = s.land
|
|
410
|
+
if (!L) return null
|
|
411
|
+
const who = L.by && L.by !== 'local' ? `, by ${L.by}` : ''
|
|
412
|
+
if (L.state === 'landing') return { rank: 7, cat: 'landing', tone: 'warn', text: `landing ${L.branch} onto ${L.base}: commit, rebase, test, fast-forward` }
|
|
413
|
+
if (L.state === 'landed') {
|
|
414
|
+
const at = Date.parse(L.at || '')
|
|
415
|
+
if (Number.isFinite(at) && Date.now() - at > 10 * 60 * 1000) return null
|
|
416
|
+
const n = L.files ? L.files.length : null
|
|
417
|
+
const counts = n === null ? '' : `, ${n} file${n === 1 ? '' : 's'}, +${L.insertions || 0}/-${L.deletions || 0}${L.tested ? '' : ', untested'}`
|
|
418
|
+
// every other field in this template is guarded; an unrecorded sha used to
|
|
419
|
+
// render `undefin`, which reads as a real short hash
|
|
420
|
+
const sha = L.sha ? String(L.sha).slice(0, 7) : 'an unrecorded commit'
|
|
421
|
+
return { rank: 9, cat: 'landed', tone: 'ok', text: `landed on ${L.base}, ${sha}${counts}${who}` }
|
|
422
|
+
}
|
|
423
|
+
if (L.state === 'noop') return { rank: 9, cat: 'nothing to land', tone: 'muted', text: `nothing to land: ${L.branch} has no changes beyond ${L.base}` }
|
|
424
|
+
if (L.state === 'interrupted') return { rank: 2, cat: 'landing cut off', tone: 'danger', text: 'the landing was cut off (the board restarted); press Land again' }
|
|
425
|
+
// G9: the bounce states the consequence, not just the event. "bounced:
|
|
426
|
+
// CONFLICT" leaves the reader guessing what happened to their work.
|
|
427
|
+
const at = Date.parse(L.at || '')
|
|
428
|
+
const when = Number.isFinite(at) ? ` at ${clockAt(at)}` : ''
|
|
429
|
+
const first = String(L.detail || L.reason || '').split('\n')[0].slice(0, 160)
|
|
430
|
+
return { rank: 2, cat: 'bounced', tone: 'danger', text: `Land was attempted${when} onto ${L.base} and bounced${who}: ${first}. The branch still holds every commit; nothing was lost.` }
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function rankedNotes(s) {
|
|
434
|
+
const out = []
|
|
435
|
+
// 6.13: someone else's terminal says one thing and nothing else
|
|
436
|
+
if (s.hidden) {
|
|
437
|
+
out.push({ rank: 10, cat: 'read-only', tone: 'muted', text: `read-only: ${s.owner || 'another human'} owns this terminal` })
|
|
438
|
+
return out
|
|
439
|
+
}
|
|
440
|
+
// the last thing the reader pressed, while it is still the freshest fact
|
|
441
|
+
const done = actionNotes.get(s.session_id)
|
|
442
|
+
if (done && Date.now() - done.at < 60000) out.push({ rank: done.tone === 'danger' ? 2 : 9, cat: 'last action', tone: done.tone, text: done.text })
|
|
443
|
+
if (s.all_out && s.all_out.length) out.push({ rank: 1, cat: 'all out', tone: 'danger', text: `every option is out, first back: ${optionLabel(s.all_out[0])} ${until(s.all_out[0].resets_at)}` })
|
|
444
|
+
if (s.limit) out.push({ rank: 1, cat: 'limit', tone: 'danger', text: `limit: ${s.limit.reason}${Number.isFinite(s.limit.resets_at) ? `, back ${until(s.limit.resets_at)}` : ''}` })
|
|
445
|
+
const land = landLine(s)
|
|
446
|
+
if (land) out.push(land)
|
|
447
|
+
// rank 3, and section 10 item 2: the best sentence in the product. It names
|
|
448
|
+
// the agent, the session, the file, the mechanism and the consequence in one
|
|
449
|
+
// line. Kept character for character; only its position changed.
|
|
450
|
+
for (const o of s.overlap || []) {
|
|
451
|
+
// the same elision as the files line below it: one form per page
|
|
452
|
+
const files = `${o.files.slice(0, 3).join(', ')}${o.files.length > 3 ? `, and ${o.files.length - 3} more` : ''}`
|
|
453
|
+
out.push({ rank: 3, cat: 'overlap', tone: 'danger', text: o.separate
|
|
454
|
+
? `${o.agent} (${tail(o.session_id)}) is changing ${files} in another checkout; whoever lands second rebases`
|
|
455
|
+
: `${o.agent} (${tail(o.session_id)}) is editing ${files} too` })
|
|
456
|
+
}
|
|
457
|
+
for (const r of s.requests || []) out.push({ rank: 4, cat: 'handoff request', tone: 'warn', text: `${r.by} asked to take this terminal at ${clockAt(Date.parse(r.at))}` })
|
|
458
|
+
if (s.status === 'waiting' && s.waiting) out.push({ rank: 5, cat: 'waiting', tone: 'warn', text: `waiting for ${optionLabel(s.waiting)} at ${until(s.waiting.resets_at)}` })
|
|
459
|
+
if (s.status === 'handing_off' && s.handoff && s.handoff.to) out.push({ rank: 6, cat: 'handing off', tone: 'warn', text: `handing off to ${optionLabel(s.handoff.to)}, ${s.handoff.reason}${s.handoff.at ? `, ${ago(Date.now() - Date.parse(s.handoff.at))}` : ''}` })
|
|
460
|
+
// rank 8 does not restate the percentage: the head prints it in 30px type a
|
|
461
|
+
// few inches above. It names what the head does not carry, the fallback.
|
|
462
|
+
if (s.warning) out.push({ rank: 8, cat: 'near limit', tone: 'warn', text: `near the ${s.warning.window} wall, next: ${s.chain && s.chain[0] ? optionLabel(s.chain[0]) : 'no eligible fallback'}` })
|
|
463
|
+
// rank 10 is a genuine fallback, so a state nobody wrote a fixture for still
|
|
464
|
+
// gets a correct sentence rather than an empty slot.
|
|
465
|
+
out.push({ rank: 10, cat: 'activity', tone: 'muted', text: `turn ${s.turns || 0}${s.last_activity ? `, last activity ${clockAt(Date.parse(s.last_activity))}` : ''}` })
|
|
466
|
+
out.sort((a, b) => a.rank - b.rank)
|
|
467
|
+
return out
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// needs you: limit hit, bounced, overlap, a pending handoff request, all out.
|
|
471
|
+
// The same predicate raises the panel, sorts the list and counts the region
|
|
472
|
+
// head, so the three cannot disagree about what is wrong.
|
|
473
|
+
function needsYou(s, notes) { return !s.hidden && notes.length > 0 && notes[0].rank <= 4 }
|
|
474
|
+
|
|
475
|
+
// 6.3: a 7px square plus a word. The square is aria-hidden and the word is the
|
|
476
|
+
// meaning. The annunciation fires only on a transition INTO danger while the
|
|
477
|
+
// page is open, never on first paint.
|
|
478
|
+
// `override` is how a needs-you panel says so in words. The panel's only other
|
|
479
|
+
// carrier of that state is one elevation step (--e2 to --e3, 1.15:1), so
|
|
480
|
+
// without the word colour is carrying it alone and two panels that need
|
|
481
|
+
// opposite things from the reader both said `running`.
|
|
482
|
+
function statusMark(status, key, override) {
|
|
483
|
+
const [word, tone] = STATUS[status] || [status, 'idle']
|
|
484
|
+
const was = lastTone.get(key)
|
|
485
|
+
const fresh = was !== undefined && was !== 'danger' && tone === 'danger'
|
|
486
|
+
lastTone.set(key, tone)
|
|
487
|
+
return el('span', { class: 'status-word' }, [
|
|
488
|
+
el('span', { class: `mark tone-${tone}${fresh ? ' state-active' : ''}`, 'aria-hidden': 'true' }),
|
|
489
|
+
override || word,
|
|
490
|
+
])
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// 6.5: no confirm() and no modal. The button row swaps for one sentence and
|
|
494
|
+
// two buttons, focus moves to Cancel, and Escape cancels.
|
|
495
|
+
function confirmRow(question, verb, onYes) {
|
|
496
|
+
const yes = el('button', { type: 'button', class: 'btn btn-danger' }, [verb])
|
|
497
|
+
const no = el('button', { type: 'button', class: 'btn btn-secondary' }, ['Cancel'])
|
|
498
|
+
yes.addEventListener('click', () => { pendingConfirm = null; onYes(yes) })
|
|
499
|
+
no.addEventListener('click', () => { pendingConfirm = null; renderSessions(view) })
|
|
500
|
+
setTimeout(() => no.focus(), 0)
|
|
501
|
+
return el('div', { class: 'confirm-row' }, [el('span', { class: 'sentence' }, [question]), yes, no])
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// G11: the full machine strings, never shortened. A path the reader cannot
|
|
505
|
+
// copy is a path they cannot check.
|
|
506
|
+
function whereBlock(s) {
|
|
507
|
+
const rows = []
|
|
508
|
+
const add = (k, v) => { if (v) rows.push(el('div', { class: 'kv-key' }, [k]), el('div', { class: 'kv-val' }, [String(v)])) }
|
|
509
|
+
add('path', s.worktree ? s.worktree.path : s.cwd)
|
|
510
|
+
add('branch', s.worktree ? s.worktree.branch : s.branch)
|
|
511
|
+
add('cut from', s.worktree ? (s.worktree.base || 'a detached HEAD') : null)
|
|
512
|
+
add('transcript', s.transcript_path)
|
|
513
|
+
// one sha length per page: the trunk list and the land sentence print seven
|
|
514
|
+
// characters, so the HEAD row does too, with the full forty in its title the
|
|
515
|
+
// way a path carries its own full string
|
|
516
|
+
if (s.head) rows.push(el('div', { class: 'kv-key' }, ['head']), el('div', { class: 'kv-val', title: String(s.head) }, [String(s.head).slice(0, 7)]))
|
|
517
|
+
return el('div', { class: 'kv' }, rows)
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function moveOrder(order, index, delta) {
|
|
521
|
+
const target = index + delta
|
|
522
|
+
if (target < 0 || target >= order.length) return [...order]
|
|
523
|
+
const next = [...order]
|
|
524
|
+
;[next[index], next[target]] = [next[target], next[index]]
|
|
525
|
+
return next
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function orderRows(order, onMove, scope) {
|
|
529
|
+
const box = el('div', {})
|
|
530
|
+
order.forEach((agent, index) => {
|
|
531
|
+
const attrs = (direction) => ({
|
|
532
|
+
'data-order-scope': scope,
|
|
533
|
+
'data-order-agent': agent,
|
|
534
|
+
'data-order-direction': direction,
|
|
535
|
+
'data-focus-key': `order:${scope}:${agent}:${direction}`,
|
|
536
|
+
})
|
|
537
|
+
const up = el('button', { type: 'button', class: 'btn btn-secondary', 'aria-label': `Move ${agent} earlier`, disabled: index === 0 ? '' : null, ...attrs('up') }, ['Up'])
|
|
538
|
+
const down = el('button', { type: 'button', class: 'btn btn-secondary', 'aria-label': `Move ${agent} later`, disabled: index === order.length - 1 ? '' : null, ...attrs('down') }, ['Down'])
|
|
539
|
+
up.addEventListener('click', () => onMove(index, -1))
|
|
540
|
+
down.addEventListener('click', () => onMove(index, 1))
|
|
541
|
+
box.appendChild(el('div', { class: 'order-row' }, [el('span', { class: `order-row-name chip-id-${idOf(agent)}` }, [`${index + 1}. ${agent}`]), up, down]))
|
|
542
|
+
})
|
|
543
|
+
return box
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// Every region on this page is wiped and rebuilt on a timer, so a control the
|
|
547
|
+
// reader had tabbed to is a different element three seconds later and focus
|
|
548
|
+
// lands back on <body>. Controls that survive a rebuild by identity carry a
|
|
549
|
+
// stable data-focus-key and this pair carries the focus across, with the caret
|
|
550
|
+
// where it applies. Written first for the handoff-order buttons; the detail
|
|
551
|
+
// region, the panels and the default-order editor all use the same pair now.
|
|
552
|
+
function takeFocus(box) {
|
|
553
|
+
const node = document.activeElement
|
|
554
|
+
if (!box || !node || !box.contains(node)) return null
|
|
555
|
+
const key = typeof node.getAttribute === 'function' ? node.getAttribute('data-focus-key') : null
|
|
556
|
+
if (!key) return null
|
|
557
|
+
const at = { key }
|
|
558
|
+
if (typeof node.selectionStart === 'number') { at.start = node.selectionStart; at.end = node.selectionEnd }
|
|
559
|
+
return at
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function putFocus(box, at) {
|
|
563
|
+
if (!box || !at) return
|
|
564
|
+
// a key can carry a file path, so both CSS string escapes have to survive
|
|
565
|
+
const key = at.key.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
|
566
|
+
let target = null
|
|
567
|
+
try { target = box.querySelector(`[data-focus-key="${key}"]`) } catch { return }
|
|
568
|
+
// an agent moved to the end of the list loses its own Down button: focus the
|
|
569
|
+
// control beside it rather than dropping the reader back to the top
|
|
570
|
+
if (target && target.disabled) target = target.parentElement?.querySelector('[data-focus-key]:not([disabled])') || null
|
|
571
|
+
if (!target) return
|
|
572
|
+
// preventScroll is load-bearing, not a nicety. This runs on a 3-second
|
|
573
|
+
// timer, and a bare focus() scrolls its element into view every time, so a
|
|
574
|
+
// reader who clicked any button was dragged back to it on every poll and
|
|
575
|
+
// could not scroll the page at all. Restoring focus after a rebuild must
|
|
576
|
+
// never move the viewport; only a focus move the reader asked for may.
|
|
577
|
+
target.focus({ preventScroll: true })
|
|
578
|
+
if (typeof at.start === 'number' && typeof target.setSelectionRange === 'function') {
|
|
579
|
+
try { target.setSelectionRange(at.start, at.end) } catch { /* no longer a text field */ }
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// A rebuild clears any selection that spans it, so the timed re-sort stands
|
|
584
|
+
// down while the reader is selecting a path or a sentence out of a panel. A
|
|
585
|
+
// real data push still redraws: the words on screen win over the drag.
|
|
586
|
+
function selectionInsideGrid() {
|
|
587
|
+
const sel = typeof document.getSelection === 'function' ? document.getSelection() : null
|
|
588
|
+
if (!sel || sel.isCollapsed || !String(sel).trim()) return false
|
|
589
|
+
const grid = document.getElementById('session-grid')
|
|
590
|
+
return Boolean(grid && sel.anchorNode && grid.contains(sel.anchorNode))
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// Absolute priority, not a rotation: the saved list decides, minus the agent
|
|
594
|
+
// already running here, so an agent placed last stays last.
|
|
595
|
+
function agentsAfter(current, order) {
|
|
596
|
+
return order.filter((agent) => agent !== current)
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function renderDefaultOrder(v) {
|
|
600
|
+
const field = document.getElementById('default-order')
|
|
601
|
+
if (!field) return
|
|
602
|
+
field.hidden = !v.preferences
|
|
603
|
+
if (!v.preferences) return
|
|
604
|
+
if (!defaultEditor.order || (!defaultEditor.dirty && !defaultEditor.saving)) defaultEditor.order = [...v.preferences.handoff_order]
|
|
605
|
+
const list = document.getElementById('default-order-list')
|
|
606
|
+
const focus = takeFocus(document)
|
|
607
|
+
list.textContent = ''
|
|
608
|
+
list.appendChild(orderRows(defaultEditor.order, (index, delta) => {
|
|
609
|
+
defaultEditor.order = moveOrder(defaultEditor.order, index, delta)
|
|
610
|
+
defaultEditor.dirty = true
|
|
611
|
+
defaultEditor.status = ''
|
|
612
|
+
renderDefaultOrder(view)
|
|
613
|
+
}, 'default'))
|
|
614
|
+
const save = document.getElementById('default-order-save')
|
|
615
|
+
save.disabled = defaultEditor.saving || !defaultEditor.dirty
|
|
616
|
+
save.textContent = defaultEditor.saving ? 'Saving…' : 'Save default'
|
|
617
|
+
const status = document.getElementById('default-order-status')
|
|
618
|
+
status.textContent = defaultEditor.status
|
|
619
|
+
status.className = `field-status ${defaultEditor.statusClass}`
|
|
620
|
+
putFocus(document, focus)
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
async function saveDefaultOrder() {
|
|
624
|
+
defaultEditor.saving = true
|
|
625
|
+
defaultEditor.status = ''
|
|
626
|
+
renderDefaultOrder(view)
|
|
627
|
+
try {
|
|
628
|
+
const data = await api('/api/settings', { method: 'PATCH', body: { handoff_order: defaultEditor.order } })
|
|
629
|
+
if (view) view.preferences = data.preferences
|
|
630
|
+
defaultEditor.order = [...data.preferences.handoff_order]
|
|
631
|
+
defaultEditor.dirty = false
|
|
632
|
+
defaultEditor.status = 'Saved for new terminals.'
|
|
633
|
+
defaultEditor.statusClass = 'ok'
|
|
634
|
+
} catch (err) {
|
|
635
|
+
defaultEditor.status = err.message
|
|
636
|
+
defaultEditor.statusClass = 'bad'
|
|
637
|
+
} finally {
|
|
638
|
+
defaultEditor.saving = false
|
|
639
|
+
renderDefaultOrder(view)
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// A result that belongs to a terminal is written into that terminal's sentence
|
|
644
|
+
// slot, where the reader is already looking. Only a result that belongs to no
|
|
645
|
+
// object on this page goes to the one system message.
|
|
646
|
+
async function act(id, action, btn) {
|
|
647
|
+
btn.disabled = true
|
|
648
|
+
actionNotes.delete(id)
|
|
649
|
+
try {
|
|
650
|
+
if (action.startsWith('requests/')) {
|
|
651
|
+
await api(`/api/sessions/${encodeURIComponent(id)}/${action}`, { method: 'POST' })
|
|
652
|
+
actionNotes.set(id, { at: Date.now(), tone: 'ok', text: action.endsWith('approve') ? 'approved; this terminal hands off in a few seconds' : 'the request was dismissed' })
|
|
653
|
+
} else if (action === 'request-handoff') {
|
|
654
|
+
await api(`/api/sessions/${encodeURIComponent(id)}/request-handoff`, { method: 'POST' })
|
|
655
|
+
sysMessage('asked; the owner of that terminal decides', 'ok')
|
|
656
|
+
} else if (action === 'remove') {
|
|
657
|
+
const r = await api(`/api/sessions/${encodeURIComponent(id)}`, { method: 'DELETE' })
|
|
658
|
+
sysMessage(r.worktree ? (r.worktree.removed ? (r.worktree.branchDeleted === false ? 'removed the terminal and its worktree; the branch is kept' : 'removed the terminal, its worktree and its branch') : `removed the terminal; the worktree is kept: ${r.worktree.reason}`) : 'removed the terminal', 'ok')
|
|
659
|
+
} else if (action === 'remove-record') {
|
|
660
|
+
await api(`/api/sessions/${encodeURIComponent(id)}?force=1&keep_worktree=1`, { method: 'DELETE' })
|
|
661
|
+
sysMessage('removed the Leg record; the worktree and the branch are kept', 'ok')
|
|
662
|
+
} else {
|
|
663
|
+
await api(`/api/sessions/${encodeURIComponent(id)}/${action}`, { method: 'POST' })
|
|
664
|
+
if (action === 'handoff') actionNotes.set(id, { at: Date.now(), tone: 'warn', text: 'hand-off requested; this terminal switches agents in a few seconds' })
|
|
665
|
+
else if (action === 'end') actionNotes.set(id, { at: Date.now(), tone: 'warn', text: 'end requested; the agent stops after its current turn' })
|
|
666
|
+
}
|
|
667
|
+
refresh()
|
|
668
|
+
} catch (err) {
|
|
669
|
+
actionNotes.set(id, { at: Date.now(), tone: 'danger', text: err.message })
|
|
670
|
+
btn.disabled = false
|
|
671
|
+
if (view) renderSessions(view)
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function renderHandoffOrder(s) {
|
|
676
|
+
const wrap = el('div', {})
|
|
677
|
+
// One chip for the whole sequence, not one per agent: `.chip + .chip::before`
|
|
678
|
+
// puts a middot between every pair of siblings, so separate chips rendered
|
|
679
|
+
// `now: codex · then · claude`, a five-item list with two items called
|
|
680
|
+
// "then". The agent names keep their identity colour inside it.
|
|
681
|
+
const sequence = el('div', { class: 'chain-rail', 'aria-label': 'Terminal handoff sequence' })
|
|
682
|
+
const chain = el('span', { class: 'chip' }, ['now: ', el('span', { class: `chip-id-${idOf(s.agent)}` }, [optionLabel(s)])])
|
|
683
|
+
// the fonts carry no arrow glyph, so the word does the arrow's job
|
|
684
|
+
for (const next of s.chain || []) chain.append(document.createTextNode(', then '), el('span', { class: `chip-id-${idOf(next.agent)}` }, [optionLabel(next)]))
|
|
685
|
+
sequence.appendChild(chain)
|
|
686
|
+
wrap.appendChild(sequence)
|
|
687
|
+
const preferred = optionLabel(s.preferred_next)
|
|
688
|
+
const eligible = optionLabel(s.eligible_next)
|
|
689
|
+
if (!s.handoff_availability_known) wrap.appendChild(el('p', { class: 'sentence tone-muted' }, [`preferred: ${preferred}, and current eligibility is unavailable for this older terminal`]))
|
|
690
|
+
else if (!s.eligible_next) wrap.appendChild(el('p', { class: 'sentence tone-warn' }, [`preferred: ${preferred}. No fallback is eligible now; Leg waits if every account is at its limit.`]))
|
|
691
|
+
else if (eligible !== preferred) wrap.appendChild(el('p', { class: 'sentence tone-muted' }, [`preferred: ${preferred}, first eligible now: ${eligible}`]))
|
|
692
|
+
else wrap.appendChild(el('p', { class: 'sentence tone-muted' }, [`first eligible now: ${eligible}`]))
|
|
693
|
+
wrap.appendChild(el('p', { class: 'blocker' }, ['Used after a usage limit or Hand off now. A normal exit ends this terminal.']))
|
|
694
|
+
|
|
695
|
+
const editableNow = ['starting', 'running', 'warning', 'limit', 'waiting'].includes(s.status)
|
|
696
|
+
if (!s.hidden && editableNow) {
|
|
697
|
+
let state = sessionEditors.get(s.session_id)
|
|
698
|
+
const sourceOrder = s.can_edit_handoff_order ? s.handoff_order : (view?.preferences?.handoff_order ?? s.handoff_order)
|
|
699
|
+
if (!state) {
|
|
700
|
+
state = { open: false, order: [...sourceOrder], dirty: false, saving: false, status: '', statusClass: '' }
|
|
701
|
+
sessionEditors.set(s.session_id, state)
|
|
702
|
+
} else if (!state.dirty && !state.saving) state.order = [...sourceOrder]
|
|
703
|
+
const change = el('button', { type: 'button', class: 'btn btn-secondary', 'aria-expanded': state.open ? 'true' : 'false', 'data-focus-key': `order-toggle:${s.session_id}` }, [state.open ? 'Close order editor' : 'Change order'])
|
|
704
|
+
change.addEventListener('click', () => { state.open = !state.open; renderDrawer() })
|
|
705
|
+
wrap.appendChild(change)
|
|
706
|
+
if (state.open) {
|
|
707
|
+
const editor = el('div', { class: 'detail-section' })
|
|
708
|
+
editor.appendChild(el('p', { class: 'blocker' }, [s.can_edit_handoff_order
|
|
709
|
+
? 'Move agents to set the priority for this terminal. All three stay available; the agent running now is skipped, and the rest keep this order.'
|
|
710
|
+
: 'This terminal started before order changes were available. Save this order for the next terminal, then restart when ready.']))
|
|
711
|
+
editor.appendChild(orderRows(state.order, (index, delta) => {
|
|
712
|
+
state.order = moveOrder(state.order, index, delta)
|
|
713
|
+
state.dirty = true
|
|
714
|
+
state.status = ''
|
|
715
|
+
renderDrawer()
|
|
716
|
+
}, s.session_id))
|
|
717
|
+
editor.appendChild(el('p', { class: 'blocker' }, [`Draft priority after ${s.agent}: ${agentsAfter(s.agent, state.order).join(', then ')}`]))
|
|
718
|
+
const status = el('span', { class: `field-status ${state.statusClass}`, 'aria-live': 'polite' }, [state.status])
|
|
719
|
+
const save = el('button', { type: 'button', class: 'btn btn-secondary', disabled: state.saving || !state.dirty ? '' : null, 'data-focus-key': `order-save:${s.session_id}` }, [state.saving ? 'Saving…' : s.can_edit_handoff_order ? 'Save for this terminal' : 'Save as default for next launch'])
|
|
720
|
+
save.addEventListener('click', async () => {
|
|
721
|
+
state.saving = true; state.status = ''; renderDrawer()
|
|
722
|
+
try {
|
|
723
|
+
if (s.can_edit_handoff_order) {
|
|
724
|
+
await api(`/api/sessions/${encodeURIComponent(s.session_id)}/handoff-order`, { method: 'POST', body: { handoff_order: state.order } })
|
|
725
|
+
state.status = 'Saved for this terminal.'
|
|
726
|
+
} else {
|
|
727
|
+
const data = await api('/api/settings', { method: 'PATCH', body: { handoff_order: state.order } })
|
|
728
|
+
if (view) view.preferences = data.preferences
|
|
729
|
+
defaultEditor.order = [...data.preferences.handoff_order]
|
|
730
|
+
defaultEditor.dirty = false
|
|
731
|
+
state.status = 'Saved as the default. Restart this terminal when ready.'
|
|
732
|
+
}
|
|
733
|
+
state.dirty = false
|
|
734
|
+
state.statusClass = 'ok'
|
|
735
|
+
await refresh()
|
|
736
|
+
} catch (err) {
|
|
737
|
+
state.status = err.message
|
|
738
|
+
state.statusClass = 'bad'
|
|
739
|
+
} finally {
|
|
740
|
+
state.saving = false
|
|
741
|
+
renderDrawer()
|
|
742
|
+
}
|
|
743
|
+
})
|
|
744
|
+
editor.append(status, save)
|
|
745
|
+
wrap.appendChild(editor)
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
return wrap
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// One terminal, as a row inside the terminals panel rather than a card of its
|
|
752
|
+
// own. Cards made every terminal the same size whatever it was doing; a row
|
|
753
|
+
// lets the panel carry the group and leaves elevation free to mean one thing.
|
|
754
|
+
// Reading order across the row is what it is doing, what it is working on,
|
|
755
|
+
// how long it has been at it, and what you can do about it.
|
|
756
|
+
function renderSession(s) {
|
|
757
|
+
const notes = rankedNotes(s)
|
|
758
|
+
const urgent = needsYou(s, notes)
|
|
759
|
+
// the article is named so the accessibility tree does not hand the reader
|
|
760
|
+
// three identical triples of Land / Hand off now / Details / End
|
|
761
|
+
const term = el('article', { class: `term${urgent ? ' is-urgent' : ''}`, 'data-session-id': s.session_id, 'aria-label': `${s.agent} ${tail(s.session_id)}` })
|
|
762
|
+
const row = el('div', { class: 'term-row' })
|
|
763
|
+
const body = el('div', { class: 'term-body' })
|
|
764
|
+
|
|
765
|
+
// The register is the caption for the prompt underneath, not a column of
|
|
766
|
+
// its own: state first, then where the work is, then anything unusual about
|
|
767
|
+
// this terminal. One line, meta colour, the smallest type on the board.
|
|
768
|
+
const branch = s.worktree ? s.worktree.branch : s.branch
|
|
769
|
+
const register = el('div', { class: 'term-register' }, [
|
|
770
|
+
statusMark(s.status, s.session_id, urgent ? 'waiting on you' : null),
|
|
771
|
+
el('span', { class: 'term-where', title: s.cwd || null }, [`${s.repo_name || s.cwd || 'unknown repo'}${branch ? ` on ${branch}` : ''}`]),
|
|
772
|
+
])
|
|
773
|
+
if (s.account !== 'default') register.appendChild(el('span', { class: 'chip' }, [s.account]))
|
|
774
|
+
if (shared() && s.owner) register.appendChild(el('span', { class: 'chip' }, [isMine(s) ? `${s.owner}, you` : s.owner]))
|
|
775
|
+
if (s.lineage && s.lineage.from) register.appendChild(el('span', { class: 'chip' }, [`from ${s.lineage.from}`]))
|
|
776
|
+
if (s.worktree) register.appendChild(el('span', { class: 'chip' }, [`own worktree, from ${s.worktree.base || 'a detached HEAD'}`]))
|
|
777
|
+
body.appendChild(register)
|
|
778
|
+
|
|
779
|
+
if (s.hidden) body.appendChild(el('p', { class: 'term-prompt term-prompt--empty' }, ['prompt hidden']))
|
|
780
|
+
else {
|
|
781
|
+
// a real <button>, so Enter, Space, the focus ring and touch all come free
|
|
782
|
+
const prompt = el('button', { type: 'button', class: `panel-prompt${s.task ? '' : ' term-prompt--empty'}`, title: s.task || 'no prompt yet', 'aria-expanded': drawer.id === s.session_id ? 'true' : 'false', 'data-focus-key': `prompt:${s.session_id}` }, [promptText(s.task)])
|
|
783
|
+
prompt.addEventListener('click', () => (drawer.id === s.session_id ? closeSessionDrawer() : openSessionDrawer(s.session_id, 'prompt')))
|
|
784
|
+
body.appendChild(prompt)
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// Exactly one sentence, the highest-ranked note. A terminal that is merely
|
|
788
|
+
// running has nothing to say that its own row does not already show, and
|
|
789
|
+
// four rows each saying "activity" is four lines of noise that make the one
|
|
790
|
+
// row with something real to say harder to find. The sentence is kept for a
|
|
791
|
+
// row that needs the reader, and for anything not simply running.
|
|
792
|
+
const own = notes.filter((n) => !hoisted.has(n.text))
|
|
793
|
+
const quiet = !urgent && own[0] && own[0].tone === 'muted'
|
|
794
|
+
if (own[0] && !quiet) body.appendChild(el('p', { class: `sentence tone-${own[0].tone}` }, [own[0].text]))
|
|
795
|
+
const rest = quiet ? [] : own.slice(1)
|
|
796
|
+
if (rest.length) {
|
|
797
|
+
// G3: the demoted notes are NAMED, never counted. "2 more" tells the
|
|
798
|
+
// reader nothing about whether the thing behind it matters.
|
|
799
|
+
const open = alsoOpen.has(s.session_id)
|
|
800
|
+
// `btn` is what zeroes the native button chrome; `btn-text` alone shipped
|
|
801
|
+
// the browser's grey bevelled box, label at 2.19:1 and invisible on hover
|
|
802
|
+
const also = el('button', { type: 'button', class: 'btn btn-text also', 'aria-expanded': open ? 'true' : 'false', 'data-focus-key': `also:${s.session_id}` }, [`also: ${rest.map((n) => n.cat).join(', ')}`])
|
|
803
|
+
also.addEventListener('click', () => { if (open) alsoOpen.delete(s.session_id); else alsoOpen.add(s.session_id); renderSessions(view) })
|
|
804
|
+
body.appendChild(also)
|
|
805
|
+
if (open) for (const n of rest) body.appendChild(el('p', { class: `sentence tone-${n.tone}` }, [n.text]))
|
|
806
|
+
}
|
|
807
|
+
const touched = s.files || []
|
|
808
|
+
if (!s.hidden && touched.length) {
|
|
809
|
+
// comma-separated text, not chips: six file names are a sentence, and a
|
|
810
|
+
// file that is also in an overlap is named in that sentence anyway
|
|
811
|
+
const overlapFiles = new Set((s.overlap || []).flatMap((o) => o.files))
|
|
812
|
+
const line = el('p', { class: 'files' })
|
|
813
|
+
touched.slice(0, 6).forEach((f, i) => {
|
|
814
|
+
if (i) line.appendChild(document.createTextNode(', '))
|
|
815
|
+
line.appendChild(el('span', { class: `file${overlapFiles.has(f) ? ' is-overlap' : ''}`, title: f }, [fileLabel(f)]))
|
|
816
|
+
})
|
|
817
|
+
if (touched.length > 6) line.appendChild(document.createTextNode(`, and ${touched.length - 6} more`))
|
|
818
|
+
body.appendChild(line)
|
|
819
|
+
}
|
|
820
|
+
row.appendChild(body)
|
|
821
|
+
|
|
822
|
+
// elapsed and the short id, right-aligned and small: the two facts you scan
|
|
823
|
+
// down the column rather than read
|
|
824
|
+
row.appendChild(el('div', { class: 'term-clock' }, [
|
|
825
|
+
el('span', { class: 'term-when elapsed', 'data-elapsed-from': String(Date.parse(s.started_at) || 0), 'data-elapsed-format': 'compact', title: `started ${new Date(s.started_at).toLocaleString()}` }, [ago(s.elapsed_ms)]),
|
|
826
|
+
// the tail is `codex-99ab`, printed immediately after the word `codex`:
|
|
827
|
+
// the prefix is the agent name twice, and it is the half that squeezed
|
|
828
|
+
// the state word out of the identity column on a one-line row
|
|
829
|
+
el('span', { class: 'term-id', title: s.session_id }, [tail(s.session_id).replace(new RegExp(`^${s.agent}-`), '')]),
|
|
830
|
+
]))
|
|
831
|
+
|
|
832
|
+
if (pendingConfirm && pendingConfirm.id === s.session_id) {
|
|
833
|
+
term.appendChild(row)
|
|
834
|
+
term.appendChild(confirmRow(pendingConfirm.question, pendingConfirm.verb, (btn) => act(s.session_id, pendingConfirm.action, btn)))
|
|
835
|
+
return term
|
|
836
|
+
}
|
|
837
|
+
const actions = el('div', { class: 'term-actions' })
|
|
838
|
+
const ask = (question, verb, action) => () => { pendingConfirm = { id: s.session_id, question, verb, action }; renderSessions(view) }
|
|
839
|
+
if (s.hidden) {
|
|
840
|
+
if (s.active) {
|
|
841
|
+
const q = el('button', { type: 'button', class: 'btn btn-secondary', title: `ask ${s.owner || 'the owner'} to hand this terminal off; they approve it on their own board` }, ['Request handoff'])
|
|
842
|
+
q.addEventListener('click', () => act(s.session_id, 'request-handoff', q))
|
|
843
|
+
actions.appendChild(q)
|
|
844
|
+
}
|
|
845
|
+
row.appendChild(actions)
|
|
846
|
+
term.appendChild(row)
|
|
847
|
+
return term
|
|
848
|
+
}
|
|
849
|
+
// G10: the order is Land, Hand off now, Details, End, and it never reflows
|
|
850
|
+
// by availability. A button that does not apply is omitted, never moved.
|
|
851
|
+
const landing = Boolean(s.land && s.land.state === 'landing')
|
|
852
|
+
const blocker = s.worktree ? s.land_blocker : NO_BRANCH_BLOCKER
|
|
853
|
+
// G10 is disabled-with-its-reason, so the reason is attached to the control
|
|
854
|
+
// as well as printed: the title used to be on the inverse condition, giving
|
|
855
|
+
// the tooltip to the button that explains itself and none to the one that
|
|
856
|
+
// needs it, and nothing connected the sentence below to the button above.
|
|
857
|
+
const blockerId = blocker ? `land-blocker-${s.session_id}` : null
|
|
858
|
+
// Land is the primary action only when it can actually run. A disabled
|
|
859
|
+
// button painted in the one accent colour spends the loudest thing in the
|
|
860
|
+
// design on something the reader cannot do.
|
|
861
|
+
const land = el('button', {
|
|
862
|
+
type: 'button',
|
|
863
|
+
class: `btn ${blocker ? 'btn-secondary' : 'btn-primary'}${landing ? ' is-loading' : ''}`,
|
|
864
|
+
disabled: blocker || landing ? '' : null,
|
|
865
|
+
'data-focus-key': `land:${s.session_id}`,
|
|
866
|
+
'aria-describedby': blockerId,
|
|
867
|
+
title: blocker || `commit this terminal's work on ${s.worktree.branch}, rebase it onto ${s.worktree.base}, run the tests, fast-forward ${s.worktree.base}; a bounce says why`,
|
|
868
|
+
}, [landing ? 'Landing…' : 'Land'])
|
|
869
|
+
land.addEventListener('click', () => act(s.session_id, 'land', land))
|
|
870
|
+
actions.appendChild(land)
|
|
871
|
+
if (s.active) {
|
|
872
|
+
const h = el('button', { type: 'button', class: `btn ${blocker ? 'btn-primary' : 'btn-secondary'}`, title: 'save the bundle, stop this agent, start the next option in the same terminal', 'data-focus-key': `handoff:${s.session_id}` }, ['Hand off now'])
|
|
873
|
+
h.addEventListener('click', () => act(s.session_id, 'handoff', h))
|
|
874
|
+
actions.appendChild(h)
|
|
875
|
+
}
|
|
876
|
+
const details = el('button', { type: 'button', class: 'btn btn-secondary', 'aria-expanded': drawer.id === s.session_id ? 'true' : 'false', 'data-focus-key': `details:${s.session_id}` }, ['Details'])
|
|
877
|
+
details.addEventListener('click', () => (drawer.id === s.session_id ? closeSessionDrawer() : openSessionDrawer(s.session_id, 'details')))
|
|
878
|
+
actions.appendChild(details)
|
|
879
|
+
if (s.active) {
|
|
880
|
+
const e = el('button', { type: 'button', class: 'btn btn-danger', 'data-focus-key': `end:${s.session_id}` }, ['End'])
|
|
881
|
+
e.addEventListener('click', ask('End this terminal? The agent stops and the bundle is kept.', 'End', 'end'))
|
|
882
|
+
actions.appendChild(e)
|
|
883
|
+
} else {
|
|
884
|
+
const r = el('button', { type: 'button', class: 'btn btn-danger', 'data-focus-key': `remove:${s.session_id}` }, ['Remove'])
|
|
885
|
+
r.addEventListener('click', ask(s.worktree
|
|
886
|
+
? `Remove this terminal? Its worktree at ${s.worktree.path} and its branch ${s.worktree.branch} go with it.`
|
|
887
|
+
: 'Remove this terminal? Leg\'s record of it is deleted.', 'Remove', 'remove'))
|
|
888
|
+
actions.appendChild(r)
|
|
889
|
+
if (s.worktree) {
|
|
890
|
+
const keep = el('button', { type: 'button', class: 'btn btn-danger', 'data-focus-key': `remove-record:${s.session_id}` }, ['Remove record'])
|
|
891
|
+
keep.addEventListener('click', ask(`Remove only the Leg record for ${s.session_id}? The worktree at ${s.worktree.path} and the branch ${s.worktree.branch} stay, with every commit.`, 'Remove record', 'remove-record'))
|
|
892
|
+
actions.appendChild(keep)
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
for (const r of s.requests || []) {
|
|
896
|
+
const ok = el('button', { type: 'button', class: 'btn btn-primary', title: `hand this terminal off for ${r.by}` }, [`Approve ${r.by}`])
|
|
897
|
+
ok.addEventListener('click', () => act(s.session_id, `requests/${encodeURIComponent(r.by)}/approve`, ok))
|
|
898
|
+
const no = el('button', { type: 'button', class: 'btn btn-danger' }, [`Dismiss ${r.by}`])
|
|
899
|
+
no.addEventListener('click', () => act(s.session_id, `requests/${encodeURIComponent(r.by)}/dismiss`, no))
|
|
900
|
+
actions.append(ok, no)
|
|
901
|
+
}
|
|
902
|
+
row.appendChild(actions)
|
|
903
|
+
term.appendChild(row)
|
|
904
|
+
// the reason a disabled control is disabled is printed, never left in a title
|
|
905
|
+
if (blocker) term.appendChild(el('p', { class: 'blocker', id: blockerId }, [blocker]))
|
|
906
|
+
return term
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
// ---- 6.6 the detail region: an in-flow expansion, never an overlay ------
|
|
910
|
+
// The messages, the diffs and the timeline are one extra fetch per open
|
|
911
|
+
// terminal, so nothing here is requested until the region is open, and the
|
|
912
|
+
// poll stops when it is paused or the tab is in the background.
|
|
913
|
+
const drawer = { id: null, paused: false, timer: null, detail: null, error: '', expanded: new Set(), diffs: new Map(), turnCap: 8, openedBy: 'prompt' }
|
|
914
|
+
|
|
915
|
+
function drawerSession() { return view && view.sessions ? view.sessions.find((s) => s.session_id === drawer.id) : null }
|
|
916
|
+
// The region is MOVED under the panel it expands, so the reference is cached:
|
|
917
|
+
// once it has been moved into the sessions list, getElementById would stop
|
|
918
|
+
// finding it the moment that list is rebuilt.
|
|
919
|
+
let regionEl = null
|
|
920
|
+
function detailRegion() {
|
|
921
|
+
if (!regionEl) regionEl = document.getElementById('session-drawer')
|
|
922
|
+
return regionEl
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
function openSessionDrawer(id, from) {
|
|
926
|
+
drawer.id = id
|
|
927
|
+
drawer.openedBy = from === 'details' ? 'details' : 'prompt'
|
|
928
|
+
drawer.detail = null
|
|
929
|
+
drawer.error = ''
|
|
930
|
+
drawer.paused = false
|
|
931
|
+
drawer.turnCap = 8
|
|
932
|
+
drawer.expanded.clear()
|
|
933
|
+
drawer.diffs.clear()
|
|
934
|
+
const region = detailRegion()
|
|
935
|
+
if (region) { region.hidden = false; region.setAttribute('aria-hidden', 'false') }
|
|
936
|
+
// in flow, directly under the panel it belongs to, so the page keeps exactly
|
|
937
|
+
// one scroll container and the instrument head stays visible. renderSessions
|
|
938
|
+
// is what puts it there, and it runs before the content is built so that
|
|
939
|
+
// moving the region cannot blur what the next line focuses.
|
|
940
|
+
if (view) renderSessions(view)
|
|
941
|
+
renderDrawer()
|
|
942
|
+
loadDrawer()
|
|
943
|
+
if (drawer.timer) clearInterval(drawer.timer)
|
|
944
|
+
drawer.timer = setInterval(() => { if (!drawer.paused && !document.hidden) loadDrawer() }, 3000)
|
|
945
|
+
document.getElementById('session-drawer-close')?.focus()
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
function closeSessionDrawer() {
|
|
949
|
+
if (drawer.timer) clearInterval(drawer.timer)
|
|
950
|
+
drawer.timer = null
|
|
951
|
+
const id = drawer.id
|
|
952
|
+
const from = drawer.openedBy
|
|
953
|
+
drawer.id = null
|
|
954
|
+
const region = detailRegion()
|
|
955
|
+
if (region) { region.hidden = true; region.setAttribute('aria-hidden', 'true') }
|
|
956
|
+
if (view) renderSessions(view)
|
|
957
|
+
// back to the control that opened it, not to a different control on the same
|
|
958
|
+
// panel: closing from Details used to land the reader on the prompt button
|
|
959
|
+
if (id) putFocus(document, { key: `${from === 'details' ? 'details' : 'prompt'}:${id}` })
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
async function loadDrawer() {
|
|
963
|
+
if (!drawer.id) return
|
|
964
|
+
const id = drawer.id
|
|
965
|
+
try {
|
|
966
|
+
const data = await api(`/api/sessions/${encodeURIComponent(id)}/detail`)
|
|
967
|
+
if (drawer.id !== id) return // the region moved on while this was in flight
|
|
968
|
+
drawer.detail = data
|
|
969
|
+
drawer.error = ''
|
|
970
|
+
} catch (err) {
|
|
971
|
+
if (drawer.id !== id) return
|
|
972
|
+
drawer.error = err.message
|
|
973
|
+
}
|
|
974
|
+
renderDrawer()
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
function paintDiff(pre, d) {
|
|
978
|
+
pre.textContent = ''
|
|
979
|
+
const body = String(d.diff || '')
|
|
980
|
+
// an empty diff has three quite different meanings; say which one this is
|
|
981
|
+
if (!body.trim()) {
|
|
982
|
+
pre.appendChild(el('span', { class: 'hunk' }, [
|
|
983
|
+
d.state === 'committed' ? 'no changes against HEAD: this file is already committed'
|
|
984
|
+
: d.state === 'gone' ? 'this path is not in the working tree any more'
|
|
985
|
+
: 'nothing to show for this path',
|
|
986
|
+
]))
|
|
987
|
+
return
|
|
988
|
+
}
|
|
989
|
+
for (const line of body.split('\n')) {
|
|
990
|
+
const cls = line.startsWith('+') && !line.startsWith('+++') ? 'add'
|
|
991
|
+
: line.startsWith('-') && !line.startsWith('---') ? 'del'
|
|
992
|
+
: line.startsWith('@@') ? 'hunk' : ''
|
|
993
|
+
pre.appendChild(el('span', { class: cls || null }, [line + '\n']))
|
|
994
|
+
}
|
|
995
|
+
if (d.truncated) pre.appendChild(el('span', { class: 'hunk' }, [`cut off at ${d.diff.split('\n').length} lines`]))
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
// A failure is never cached: it used to be written into the success map, where
|
|
999
|
+
// it was indistinguishable from a real diff to paintDiff and survived every
|
|
1000
|
+
// collapse and re-expand, so one transient 400 pinned that file to an error
|
|
1001
|
+
// until the whole region was closed. The message is painted and nothing is
|
|
1002
|
+
// stored, so the next expand asks again.
|
|
1003
|
+
async function showDiff(path, pre) {
|
|
1004
|
+
if (drawer.diffs.has(path)) { paintDiff(pre, drawer.diffs.get(path)); return }
|
|
1005
|
+
pre.textContent = 'reading the diff'
|
|
1006
|
+
try {
|
|
1007
|
+
const d = await api(`/api/sessions/${encodeURIComponent(drawer.id)}/diff?file=${encodeURIComponent(path)}`)
|
|
1008
|
+
drawer.diffs.set(path, d)
|
|
1009
|
+
paintDiff(pre, d)
|
|
1010
|
+
} catch (err) {
|
|
1011
|
+
pre.textContent = ''
|
|
1012
|
+
pre.appendChild(el('span', { class: 'hunk' }, [`the diff could not be read: ${err.message}. Collapse this file and open it again to retry.`]))
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
function messageRow(m, key) {
|
|
1017
|
+
return el('div', { class: 'turn drawer-msg' }, [
|
|
1018
|
+
el('span', { class: 'turn-role drawer-msg-role' }, [m.role === 'user' ? 'human' : 'agent']),
|
|
1019
|
+
m.ts ? el('span', { class: 'turn-when drawer-msg-when' }, [whenAgo(m.ts)]) : null,
|
|
1020
|
+
// every box that can scroll carries a key, so where the reader had
|
|
1021
|
+
// scrolled to survives the rebuild three seconds later
|
|
1022
|
+
el('p', { 'data-scroll-key': `msg:${key}` }, [m.text]),
|
|
1023
|
+
])
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
function fileRow(f) {
|
|
1027
|
+
const wrap = el('div', {})
|
|
1028
|
+
const pre = el('pre', { class: 'drawer-diff', hidden: '', 'data-scroll-key': `diff:${f.path}` })
|
|
1029
|
+
const row = el('button', { type: 'button', class: 'btn btn-text file-row', 'aria-expanded': 'false', 'data-focus-key': `file:${f.path}` }, [
|
|
1030
|
+
el('span', { class: 'mono', title: f.path }, [f.path]),
|
|
1031
|
+
f.dirty ? el('span', { class: 'chip chip-state-warn' }, ['uncommitted']) : null,
|
|
1032
|
+
Number.isFinite(f.adds) ? el('span', { class: 'chip chip-state-ok' }, [`+${f.adds}`]) : null,
|
|
1033
|
+
// red is not spent on a diff: the minus glyph carries the deletion
|
|
1034
|
+
Number.isFinite(f.dels) ? el('span', { class: 'chip' }, [`-${f.dels}`]) : null,
|
|
1035
|
+
// no counts against HEAD: the agent created it, or it is already committed
|
|
1036
|
+
Number.isFinite(f.adds) ? null : el('span', { class: 'chip' }, [f.state === 'new' ? 'new' : f.state === 'committed' ? 'committed' : String(f.state || '')]),
|
|
1037
|
+
])
|
|
1038
|
+
const toggle = () => {
|
|
1039
|
+
const opening = pre.hidden
|
|
1040
|
+
pre.hidden = !opening
|
|
1041
|
+
row.setAttribute('aria-expanded', opening ? 'true' : 'false')
|
|
1042
|
+
if (opening) { drawer.expanded.add(f.path); showDiff(f.path, pre) } else drawer.expanded.delete(f.path)
|
|
1043
|
+
}
|
|
1044
|
+
row.addEventListener('click', toggle)
|
|
1045
|
+
if (drawer.expanded.has(f.path)) { pre.hidden = false; row.setAttribute('aria-expanded', 'true'); showDiff(f.path, pre) }
|
|
1046
|
+
wrap.append(row, pre)
|
|
1047
|
+
return wrap
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
// The region is rebuilt from scratch every poll so every relative timestamp
|
|
1051
|
+
// stays honest. That used to throw away where the reader had scrolled inside
|
|
1052
|
+
// the task box, a message or the timeline: they snapped back to the top every
|
|
1053
|
+
// three seconds, which made a long message impossible to read. Each scrollable
|
|
1054
|
+
// box carries a stable data-scroll-key, and its offset is carried across.
|
|
1055
|
+
function takeScroll(box) {
|
|
1056
|
+
const at = new Map()
|
|
1057
|
+
for (const node of box.querySelectorAll('[data-scroll-key]')) if (node.scrollTop) at.set(node.getAttribute('data-scroll-key'), node.scrollTop)
|
|
1058
|
+
return at
|
|
1059
|
+
}
|
|
1060
|
+
function putScroll(box, at) {
|
|
1061
|
+
if (!at.size) return
|
|
1062
|
+
for (const node of box.querySelectorAll('[data-scroll-key]')) {
|
|
1063
|
+
const was = at.get(node.getAttribute('data-scroll-key'))
|
|
1064
|
+
if (was) node.scrollTop = was
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
// Whether .baton/RESUME.md still describes the repository a reader would find.
|
|
1069
|
+
// The server recomputes this from git on every poll, so the line is a verdict
|
|
1070
|
+
// about right now, not a timestamp the file remembered about itself.
|
|
1071
|
+
function resumeLine(v) {
|
|
1072
|
+
const cls = v.state === 'fresh' ? 'sentence tone-ok' : v.state === 'missing' ? 'sentence tone-muted' : 'sentence tone-warn'
|
|
1073
|
+
const text = v.state === 'fresh'
|
|
1074
|
+
? `RESUME.md is current${v.written_at ? `, written ${whenAgo(v.written_at)}` : ''}`
|
|
1075
|
+
: v.state === 'missing'
|
|
1076
|
+
? 'no RESUME.md in this checkout yet; one is written at the first hand-off'
|
|
1077
|
+
: v.state === 'unstamped'
|
|
1078
|
+
? 'RESUME.md carries no Leg stamp, so its freshness cannot be checked'
|
|
1079
|
+
: `RESUME.md is stale: ${v.reasons.join('; ')}`
|
|
1080
|
+
return el('p', { class: cls, title: 'freshness is recomputed from git on every poll; leg resume --check' }, [text])
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
function section(title, note, body) {
|
|
1084
|
+
const s = el('section', { class: 'detail-section' }, [
|
|
1085
|
+
el('h3', { class: 'detail-heading' }, [title, note ? el('span', { class: 'detail-sub' }, [note]) : null]),
|
|
1086
|
+
])
|
|
1087
|
+
s.appendChild(body)
|
|
1088
|
+
return s
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
// G14: a cap names its volume. `showing 8 of 34 turns` beats `last 8 turns`,
|
|
1092
|
+
// which never said how much the reader could not see.
|
|
1093
|
+
function capLine(shown, total, noun, onMore) {
|
|
1094
|
+
const line = el('div', { class: 'cap-line' }, [`showing ${shown} of ${total} ${noun}`])
|
|
1095
|
+
if (onMore) {
|
|
1096
|
+
// the same label as the card drawer's overflow control (board.js): one
|
|
1097
|
+
// wording, so `show 40` and `show 40 more` are not read as two controls
|
|
1098
|
+
const more = el('button', { type: 'button', class: 'btn btn-text', 'data-focus-key': `more:${noun}` }, ['show 40 more'])
|
|
1099
|
+
more.addEventListener('click', onMore)
|
|
1100
|
+
line.appendChild(more)
|
|
1101
|
+
}
|
|
1102
|
+
return line
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
function renderDrawer() {
|
|
1106
|
+
const box = document.getElementById('session-drawer-content')
|
|
1107
|
+
if (!box) return
|
|
1108
|
+
const inner = takeScroll(box)
|
|
1109
|
+
const focus = takeFocus(box)
|
|
1110
|
+
const s = drawerSession()
|
|
1111
|
+
const d = drawer.detail
|
|
1112
|
+
box.textContent = ''
|
|
1113
|
+
if (!s) {
|
|
1114
|
+
box.appendChild(el('p', { class: 'sentence tone-muted' }, ['This terminal is no longer on the board.']))
|
|
1115
|
+
return
|
|
1116
|
+
}
|
|
1117
|
+
const [label] = STATUS[s.status] || [s.status]
|
|
1118
|
+
const controls = el('div', { class: 'cap-line' }, [
|
|
1119
|
+
el('span', { class: `acct-name chip-id-${idOf(s.agent)}` }, [s.agent]),
|
|
1120
|
+
el('span', { class: 'chip' }, [tail(s.session_id)]),
|
|
1121
|
+
])
|
|
1122
|
+
const pause = el('button', { type: 'button', class: 'btn btn-secondary', id: 'session-drawer-pause', 'data-focus-key': 'drawer-pause' }, [drawer.paused ? 'Resume updates' : 'Pause updates'])
|
|
1123
|
+
pause.addEventListener('click', () => { drawer.paused = !drawer.paused; if (!drawer.paused) loadDrawer(); else renderDrawer() })
|
|
1124
|
+
const close = el('button', { type: 'button', class: 'btn btn-secondary', id: 'session-drawer-close', 'data-focus-key': 'drawer-close' }, ['Close'])
|
|
1125
|
+
close.addEventListener('click', closeSessionDrawer)
|
|
1126
|
+
controls.append(pause, close)
|
|
1127
|
+
box.appendChild(controls)
|
|
1128
|
+
if (drawer.error) box.appendChild(el('p', { class: 'sentence tone-danger' }, [drawer.error]))
|
|
1129
|
+
|
|
1130
|
+
const last = d && d.messages ? [...d.messages].reverse().find((m) => m.role === 'assistant') : null
|
|
1131
|
+
const now = el('div', { class: 'detail-section' }, [
|
|
1132
|
+
el('div', { class: 'well' }, [
|
|
1133
|
+
el('div', {}, [`${label}, ${s.turns || 0} turn${s.turns === 1 ? '' : 's'}${s.last_activity ? `, last activity ${clockAt(Date.parse(s.last_activity))}` : ''}`]),
|
|
1134
|
+
last ? el('p', { class: 'sentence' }, [last.text]) : el('p', { class: 'sentence tone-muted' }, [d ? 'nothing said yet' : 'reading the transcript']),
|
|
1135
|
+
]),
|
|
1136
|
+
// G11: path, branch and transcript in full, never shortened
|
|
1137
|
+
whereBlock(s),
|
|
1138
|
+
s.worktree ? el('p', { class: 'blocker' }, [`Another terminal was live in this checkout, so this one works in its own worktree and lands by rebasing ${s.worktree.branch} onto ${s.worktree.base || 'its base'}.`]) : null,
|
|
1139
|
+
s.usage_error ? el('p', { class: 'blocker' }, [`usage unknown (${s.usage_error}); the limit still hands off`]) : null,
|
|
1140
|
+
])
|
|
1141
|
+
box.appendChild(section('Now', drawer.paused ? 'paused' : `every 3 seconds${d ? `, read ${whenAgo(d.ts)}` : ''}`, now))
|
|
1142
|
+
|
|
1143
|
+
box.appendChild(section('Task', 'the prompt this terminal started from',
|
|
1144
|
+
el('p', { class: 'drawer-task', 'data-scroll-key': 'task' }, [s.task || 'no prompt yet'])))
|
|
1145
|
+
|
|
1146
|
+
// newest first, here and in the timeline: the region is meant to be left
|
|
1147
|
+
// open beside the work, where the thing worth seeing is the last thing that
|
|
1148
|
+
// happened, not the oldest one thirty lines up
|
|
1149
|
+
const msgs = el('div', {})
|
|
1150
|
+
const all = d && d.messages ? d.messages : []
|
|
1151
|
+
const turns = all.slice(-drawer.turnCap)
|
|
1152
|
+
// the key is the message's own position in the shown transcript, so a new
|
|
1153
|
+
// turn arriving does not move every older box's scroll offset onto its
|
|
1154
|
+
// neighbour
|
|
1155
|
+
if (turns.length) turns.map((m, i) => messageRow(m, m.ts || i)).reverse().forEach((row) => msgs.appendChild(row))
|
|
1156
|
+
else msgs.appendChild(el('p', { class: 'sentence tone-muted' }, [d ? 'no transcript for this agent' : 'reading the transcript']))
|
|
1157
|
+
if (all.length > turns.length) msgs.appendChild(capLine(turns.length, all.length, 'turns', () => { drawer.turnCap += 40; renderDrawer() }))
|
|
1158
|
+
box.appendChild(section('Conversation', `showing ${turns.length} of ${all.length} turns, newest first`, msgs))
|
|
1159
|
+
|
|
1160
|
+
const files = el('div', {})
|
|
1161
|
+
const list = (d && d.files) || []
|
|
1162
|
+
if (list.length) for (const f of list) files.appendChild(fileRow(f))
|
|
1163
|
+
else files.appendChild(el('p', { class: 'sentence tone-muted' }, [d ? 'no files changed yet' : 'reading the file list']))
|
|
1164
|
+
box.appendChild(section('Files', list.length ? `${list.length} changed, open one for its diff` : '', files))
|
|
1165
|
+
|
|
1166
|
+
const timeline = el('div', { class: 'drawer-timeline', 'data-scroll-key': 'timeline' })
|
|
1167
|
+
const events = (d && d.events) || []
|
|
1168
|
+
for (const e of events.slice(-40).reverse()) {
|
|
1169
|
+
// clockAt, not a slice of the ISO string: that printed UTC, in 24-hour
|
|
1170
|
+
// with seconds, under a page that says `Times are local.` and beside a
|
|
1171
|
+
// header on the same panel printing the same instant as 11:04 PM. The
|
|
1172
|
+
// summary is a block, as board.js:754 builds the same row, or the kind
|
|
1173
|
+
// word and the sentence render glued: `lostrunner pid 999002 is gone`.
|
|
1174
|
+
timeline.appendChild(el('div', { class: 'turn' }, [
|
|
1175
|
+
el('span', { class: 'mono turn-when' }, [clockAt(Date.parse(e.ts))]),
|
|
1176
|
+
el('span', { class: 'turn-role' }, [e.type]),
|
|
1177
|
+
el('p', {}, [e.summary || '']),
|
|
1178
|
+
]))
|
|
1179
|
+
}
|
|
1180
|
+
if (!events.length) timeline.appendChild(el('p', { class: 'sentence tone-muted' }, [d ? 'nothing recorded yet' : 'reading the timeline']))
|
|
1181
|
+
const shownEvents = Math.min(events.length, 40)
|
|
1182
|
+
const timelineBody = el('div', { class: 'detail-section' }, [timeline])
|
|
1183
|
+
if (events.length > shownEvents) timelineBody.appendChild(capLine(shownEvents, events.length, 'events', null))
|
|
1184
|
+
box.appendChild(section('Timeline', 'this terminal, newest first', timelineBody))
|
|
1185
|
+
|
|
1186
|
+
const next = el('div', { class: 'detail-section' }, [renderHandoffOrder(s)])
|
|
1187
|
+
if (s.bundle) next.appendChild(el('p', { class: 'blocker' }, [`bundle ${s.bundle.id}${s.bundle.at ? `, saved ${whenAgo(s.bundle.at)}` : ''}`]))
|
|
1188
|
+
if (d && d.resume) next.appendChild(resumeLine(d.resume))
|
|
1189
|
+
box.appendChild(section('What happens next', '', next))
|
|
1190
|
+
putScroll(box, inner)
|
|
1191
|
+
putFocus(box, focus)
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
// ---- the Terminals region ----------------------------------------------
|
|
1195
|
+
// A verdict with its volume (G14), counted from the same ranking the panels
|
|
1196
|
+
// print, so the head and the rows cannot disagree.
|
|
1197
|
+
function terminalsMeta(list, notesOf) {
|
|
1198
|
+
const meta = document.querySelector('.region-terminals .region-meta')
|
|
1199
|
+
if (!meta) return
|
|
1200
|
+
const waiting = list.filter((s) => needsYou(s, notesOf.get(s.session_id) || [])).length
|
|
1201
|
+
const running = list.filter((s) => s.active).length
|
|
1202
|
+
const landed = list.map((s) => (s.land && s.land.state === 'landed' ? Date.parse(s.land.at || '') : NaN)).filter(Number.isFinite).sort((a, b) => b - a)[0]
|
|
1203
|
+
// the verdict alone. The two help sentences that used to follow it here are
|
|
1204
|
+
// static, never change, and are printed in the expansion where the handoff
|
|
1205
|
+
// order actually is; carrying them in the region head made the one number
|
|
1206
|
+
// that moves the tail of a 108-character paragraph. Running comes first so
|
|
1207
|
+
// `2 waiting on you, 3 running` is not read as five terminals.
|
|
1208
|
+
const verdict = !list.length ? 'nothing is running'
|
|
1209
|
+
: waiting ? `${running} running, ${waiting} waiting on you`
|
|
1210
|
+
: `${running} running, nothing is waiting on you`
|
|
1211
|
+
meta.textContent = `${verdict}${landed ? `, last landed ${clockAt(landed)}` : ''}`
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
// A fact that is true of every terminal on the board is a property of the
|
|
1215
|
+
// board, not of any row. Printed per row it was the same sentence three
|
|
1216
|
+
// times, which is three lines of noise around the one row that had something
|
|
1217
|
+
// of its own to say. The per-row copy stays in the DOM, visually hidden, so
|
|
1218
|
+
// the Land button's aria-describedby still resolves to its own reason.
|
|
1219
|
+
function sharedBlocker(list) {
|
|
1220
|
+
const shown = list.filter((s) => !s.hidden)
|
|
1221
|
+
if (shown.length < 2) return null
|
|
1222
|
+
const first = shown[0].worktree ? shown[0].land_blocker : NO_BRANCH_BLOCKER
|
|
1223
|
+
if (!first) return null
|
|
1224
|
+
return shown.every((s) => (s.worktree ? s.land_blocker : NO_BRANCH_BLOCKER) === first) ? first : null
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
// Any sentence two or more terminals would print identically is a fact about
|
|
1228
|
+
// the board, not about a terminal. `near the 7d wall, next: codex` on three
|
|
1229
|
+
// rows is one fact and two lines of noise, and it is set in the alarm weight,
|
|
1230
|
+
// so the noise is the loudest thing on the page.
|
|
1231
|
+
function sharedNotes(list, notesOf) {
|
|
1232
|
+
const seen = new Map()
|
|
1233
|
+
for (const s of list) {
|
|
1234
|
+
for (const n of notesOf.get(s.session_id) || []) {
|
|
1235
|
+
const at = seen.get(n.text) || { n, count: 0 }
|
|
1236
|
+
at.count += 1
|
|
1237
|
+
seen.set(n.text, at)
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
// only a note that carries a state worth acting on is worth saying at board
|
|
1241
|
+
// level. `turn 12, last activity 1:04 AM` is per-terminal detail: shared by
|
|
1242
|
+
// coincidence, not a fact about the board, and hoisting it put the quietest
|
|
1243
|
+
// sentence on the page in the loudest position.
|
|
1244
|
+
return [...seen.values()].filter((x) => x.count >= 2 && x.n.tone !== 'muted').map((x) => x.n)
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
function hoistShared(list, notesOf) {
|
|
1248
|
+
const slot = document.getElementById('terminals-hoisted')
|
|
1249
|
+
if (!slot) return
|
|
1250
|
+
const shared = sharedBlocker(list)
|
|
1251
|
+
const notes = notesOf ? sharedNotes(list, notesOf) : []
|
|
1252
|
+
hoisted = new Set(notes.map((n) => n.text))
|
|
1253
|
+
slot.textContent = ''
|
|
1254
|
+
for (const n of notes) slot.appendChild(el('span', { class: `hoisted-note tone-${n.tone}` }, [n.text]))
|
|
1255
|
+
if (shared) slot.appendChild(el('span', { class: 'hoisted-note tone-muted' }, [`every terminal here ${shared.replace(/^this terminal /, '')}`]))
|
|
1256
|
+
slot.hidden = !slot.childNodes.length
|
|
1257
|
+
for (const p of document.querySelectorAll('#session-grid .blocker')) p.classList.toggle('is-hoisted', Boolean(shared))
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
// Finished terminals are history. After a day of work they are most of the
|
|
1261
|
+
// list, and drawn as full rows they bury the one or two that are live, so
|
|
1262
|
+
// they leave the panel entirely and become a ledger cell with a drawer.
|
|
1263
|
+
function renderFinished(finished, region) {
|
|
1264
|
+
const head = document.getElementById('finished-head')
|
|
1265
|
+
const meta = document.querySelector('.region-finished .region-meta')
|
|
1266
|
+
const slot = document.getElementById('finished-actions')
|
|
1267
|
+
const box = document.getElementById('finished-list')
|
|
1268
|
+
const panel = document.getElementById('finished-drawer')
|
|
1269
|
+
if (!head || !meta || !slot || !box || !panel) return
|
|
1270
|
+
slot.textContent = ''
|
|
1271
|
+
box.textContent = ''
|
|
1272
|
+
if (!finished.length) {
|
|
1273
|
+
head.textContent = 'No finished terminals'
|
|
1274
|
+
meta.textContent = 'Every terminal Leg knows about is still live.'
|
|
1275
|
+
panel.hidden = true
|
|
1276
|
+
return
|
|
1277
|
+
}
|
|
1278
|
+
const counts = {}
|
|
1279
|
+
for (const s of finished) counts[s.status] = (counts[s.status] || 0) + 1
|
|
1280
|
+
head.textContent = `${finished.length} finished`
|
|
1281
|
+
meta.textContent = Object.entries(counts).map(([k, n]) => `${n} ${(STATUS[k] && STATUS[k][0]) || k}`).join(', ')
|
|
1282
|
+
// naming eight dead session ids is a wall of text nobody reads. The repos
|
|
1283
|
+
// they were working in is the fact worth carrying.
|
|
1284
|
+
const repos = [...new Set(finished.map((s) => s.repo_name).filter(Boolean))]
|
|
1285
|
+
if (repos.length) meta.textContent += `, in ${repos.slice(0, 3).join(', ')}${repos.length > 3 ? ` and ${repos.length - 3} more` : ''}`
|
|
1286
|
+
const btn = el('button', { type: 'button', class: 'btn btn-secondary', 'aria-expanded': finishedOpen ? 'true' : 'false', 'aria-controls': 'finished-drawer', 'data-focus-key': 'finished-toggle' },
|
|
1287
|
+
[finishedOpen ? `Hide the ${finished.length}` : `View all ${finished.length}`])
|
|
1288
|
+
btn.addEventListener('click', () => { finishedOpen = !finishedOpen; renderSessions(view) })
|
|
1289
|
+
slot.appendChild(btn)
|
|
1290
|
+
panel.hidden = !finishedOpen
|
|
1291
|
+
const headline = document.getElementById('finished-drawer-head')
|
|
1292
|
+
if (headline) headline.textContent = `${finished.length} finished terminal${finished.length === 1 ? '' : 's'}`
|
|
1293
|
+
if (!finishedOpen) return
|
|
1294
|
+
for (const s of finished) {
|
|
1295
|
+
const panelEl = renderSession(s)
|
|
1296
|
+
box.appendChild(panelEl)
|
|
1297
|
+
if (drawer.id === s.session_id && region) panelEl.after(region)
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
function renderSessions(v) {
|
|
1302
|
+
const focus = takeFocus(document)
|
|
1303
|
+
const grid = document.getElementById('session-grid')
|
|
1304
|
+
if (!grid) return
|
|
1305
|
+
// park the expanded region back on the body before the list is wiped, so it
|
|
1306
|
+
// is never orphaned by the rebuild and never loses its own content.
|
|
1307
|
+
// Detaching a subtree resets scrollTop on every scrollable box inside it,
|
|
1308
|
+
// so a reader half way down a 200-line diff was returned to the top by a
|
|
1309
|
+
// rebuild of the list around them. The offsets are read before the move and
|
|
1310
|
+
// written back after it, the same contract renderDrawer keeps.
|
|
1311
|
+
const region = detailRegion()
|
|
1312
|
+
const parked = region ? takeScroll(region) : null
|
|
1313
|
+
if (region && region.parentNode === grid) document.body.appendChild(region)
|
|
1314
|
+
grid.textContent = ''
|
|
1315
|
+
const list = [...v.sessions]
|
|
1316
|
+
const notesOf = new Map(list.map((s) => [s.session_id, rankedNotes(s)]))
|
|
1317
|
+
const urgent = (s) => needsYou(s, notesOf.get(s.session_id) || [])
|
|
1318
|
+
// needs-you first, then started_at ascending. The re-sort every 15 seconds
|
|
1319
|
+
// only moves the needs-you partition, so a panel never slides under the
|
|
1320
|
+
// cursor for a reason the reader cannot see.
|
|
1321
|
+
list.sort((a, b) => (urgent(a) === urgent(b) ? Date.parse(a.started_at) - Date.parse(b.started_at) : urgent(a) ? -1 : 1))
|
|
1322
|
+
const empty = document.querySelector('.region-terminals .empty-line')
|
|
1323
|
+
if (empty) empty.hidden = list.some((s) => s.active || needsYou(s, notesOf.get(s.session_id) || []))
|
|
1324
|
+
|
|
1325
|
+
// A terminal that has ended or been lost is history, and history does not
|
|
1326
|
+
// belong in the panel that shows what is live. It moves to the ledger.
|
|
1327
|
+
const done = (s) => !s.active && !urgent(s) && drawer.id !== s.session_id
|
|
1328
|
+
const live = list.filter((s) => !done(s))
|
|
1329
|
+
const finished = list.filter(done)
|
|
1330
|
+
terminalsMeta(live, notesOf)
|
|
1331
|
+
// computed over the rows that are actually drawn: a sentence shared only by
|
|
1332
|
+
// terminals collapsed into the ledger is not on screen to be deduped
|
|
1333
|
+
hoistShared(live, notesOf)
|
|
1334
|
+
for (const s of live) {
|
|
1335
|
+
const panel = renderSession(s)
|
|
1336
|
+
grid.appendChild(panel)
|
|
1337
|
+
if (drawer.id === s.session_id && region) panel.after(region)
|
|
1338
|
+
}
|
|
1339
|
+
// the per-row copies exist only now, so the pass that hides the ones the
|
|
1340
|
+
// region already says runs after the rows are in the document
|
|
1341
|
+
hoistShared(live, notesOf)
|
|
1342
|
+
const section = document.querySelector('.region-terminals')
|
|
1343
|
+
if (section) section.hidden = false
|
|
1344
|
+
grid.hidden = live.length === 0
|
|
1345
|
+
renderFinished(finished, region)
|
|
1346
|
+
// every control in a panel is a new element after this rebuild, and the
|
|
1347
|
+
// expanded region was moved out and back, which blurs whatever was focused
|
|
1348
|
+
// inside it and zeroes every box it could scroll: put the reader back on
|
|
1349
|
+
// the control they were on, at the offset they had scrolled to
|
|
1350
|
+
if (region && parked) putScroll(region, parked)
|
|
1351
|
+
putFocus(document, focus)
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
// What landed is history too. The full list was eighteen rows of git log at
|
|
1355
|
+
// the same visual weight as the live terminals, so the loudest thing on the
|
|
1356
|
+
// page was a commit from eleven days ago. It is a count and a disclosure now,
|
|
1357
|
+
// and `git log` is one keystroke away in the terminal already open.
|
|
1358
|
+
function renderTrunk(v) {
|
|
1359
|
+
const box = document.getElementById('trunk')
|
|
1360
|
+
const head = document.getElementById('trunk-head')
|
|
1361
|
+
const meta = document.querySelector('.region-trunk .region-meta')
|
|
1362
|
+
const slot = document.getElementById('trunk-actions')
|
|
1363
|
+
const panel = document.getElementById('trunk-drawer')
|
|
1364
|
+
if (!box || !head || !meta || !slot || !panel) return
|
|
1365
|
+
box.textContent = ''
|
|
1366
|
+
slot.textContent = ''
|
|
1367
|
+
const all = (v.trunk || []).filter((t) => t.branch)
|
|
1368
|
+
const branches = all.map((t) => `${t.repo_name}@${t.branch}`)
|
|
1369
|
+
const total = all.reduce((n, t) => n + t.commits.length, 0)
|
|
1370
|
+
if (!total) {
|
|
1371
|
+
head.textContent = 'Nothing landed yet'
|
|
1372
|
+
meta.textContent = 'Land commits a terminal\'s work, rebases it onto main, runs the tests and fast-forwards.'
|
|
1373
|
+
panel.hidden = true
|
|
1374
|
+
return
|
|
1375
|
+
}
|
|
1376
|
+
const newest = all.flatMap((t) => t.commits).map((c) => c.when).filter(Boolean)[0]
|
|
1377
|
+
head.textContent = `${total} landed`
|
|
1378
|
+
meta.textContent = `${newest ? `newest ${newest}, ` : ''}on ${branches.slice(0, 3).join(', ')}${branches.length > 3 ? ` and ${branches.length - 3} more` : ''}`
|
|
1379
|
+
const btn = el('button', { type: 'button', class: 'btn btn-secondary', 'aria-expanded': trunkOpen ? 'true' : 'false', 'aria-controls': 'trunk-drawer', 'data-focus-key': 'trunk-toggle' },
|
|
1380
|
+
[trunkOpen ? `Hide the ${total}` : `View ${total} commit${total === 1 ? '' : 's'}`])
|
|
1381
|
+
btn.addEventListener('click', () => { trunkOpen = !trunkOpen; renderTrunk(view) })
|
|
1382
|
+
slot.appendChild(btn)
|
|
1383
|
+
panel.hidden = !trunkOpen
|
|
1384
|
+
const headline = document.getElementById('trunk-drawer-head')
|
|
1385
|
+
if (headline) headline.textContent = `${total} commit${total === 1 ? '' : 's'} landed on ${branches.slice(0, 2).join(', ')}`
|
|
1386
|
+
if (!trunkOpen) return
|
|
1387
|
+
for (const t of all) {
|
|
1388
|
+
box.appendChild(el('div', { class: 'group-head' }, [`${t.repo_name} on ${t.branch}`, el('span', {}, [`${t.commits.length} commit${t.commits.length === 1 ? '' : 's'}`])]))
|
|
1389
|
+
for (const c of t.commits) {
|
|
1390
|
+
const lb = c.landed_by
|
|
1391
|
+
const by = lb && lb.by && lb.by !== 'local' ? ` for ${lb.by}` : ''
|
|
1392
|
+
box.appendChild(el('div', { class: 'trunk-row' }, [
|
|
1393
|
+
el('span', { class: 'line-main', title: c.sha }, [c.subject]),
|
|
1394
|
+
el('span', { class: 'line-when' }, [lb
|
|
1395
|
+
? el('span', { class: 'landed-by', title: `Land pressed on the card of ${lb.agent} session ${lb.session_id}${by}${Number.isFinite(Date.parse(lb.at)) ? `, ${new Date(lb.at).toLocaleString()}` : ''}` }, [`${c.when}, by ${lb.agent}${by}`])
|
|
1396
|
+
: `${c.when}, ${c.author}`]),
|
|
1397
|
+
]))
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
// The elapsed clock ticks every second and the connection word sits in the
|
|
1403
|
+
// topbar: between them, liveness is proved by real data and nothing on this
|
|
1404
|
+
// board has to pulse to look alive.
|
|
1405
|
+
function tickElapsed() {
|
|
1406
|
+
for (const node of document.querySelectorAll('[data-elapsed-from]')) {
|
|
1407
|
+
const from = Number(node.getAttribute('data-elapsed-from'))
|
|
1408
|
+
if (!from) continue
|
|
1409
|
+
node.textContent = node.getAttribute('data-elapsed-format') === 'compact'
|
|
1410
|
+
? ago(Date.now() - from)
|
|
1411
|
+
: elapsedClock(Date.now() - from)
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
function render(v) {
|
|
1416
|
+
view = v
|
|
1417
|
+
const who = document.getElementById('whoami')
|
|
1418
|
+
if (who) {
|
|
1419
|
+
who.hidden = !(v.share && v.share.on)
|
|
1420
|
+
who.textContent = v.share && v.share.on && v.you ? `you are ${v.you.name}${v.you.role === 'owner' ? '' : ', a guest'}, ${v.share.people} on this board` : ''
|
|
1421
|
+
}
|
|
1422
|
+
renderAccounts(v.accounts || [])
|
|
1423
|
+
renderDefaultOrder(v)
|
|
1424
|
+
renderSessions(v)
|
|
1425
|
+
renderTrunk(v)
|
|
1426
|
+
// the panel behind the expansion just changed: status, turns and what is
|
|
1427
|
+
// next live in the session view, so redraw the region from it
|
|
1428
|
+
if (drawer.id) { if (drawerSession()) renderDrawer(); else closeSessionDrawer() }
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
async function refresh() {
|
|
1432
|
+
try { render(await api('/api/sessions')) } catch (err) { sysMessage(err.message, 'danger') }
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
window.addEventListener('leg:sessions', (e) => render(e.detail));
|
|
1436
|
+
window.addEventListener('baton:sessions', (e) => render(e.detail));
|
|
1437
|
+
document.addEventListener('keydown', (e) => {
|
|
1438
|
+
if (e.key !== 'Escape') return
|
|
1439
|
+
if (pendingConfirm) { pendingConfirm = null; if (view) renderSessions(view); return }
|
|
1440
|
+
if (drawer.id) closeSessionDrawer()
|
|
1441
|
+
})
|
|
1442
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
1443
|
+
document.getElementById('default-order-save')?.addEventListener('click', saveDefaultOrder)
|
|
1444
|
+
renderLoadingHead()
|
|
1445
|
+
refresh()
|
|
1446
|
+
setInterval(tickElapsed, 1000)
|
|
1447
|
+
// the timed re-sort exists to move the needs-you partition, which can wait a
|
|
1448
|
+
// few seconds: it stands down mid-selection rather than clearing the drag
|
|
1449
|
+
setInterval(() => { if (view && !selectionInsideGrid()) renderSessions(view) }, 15000)
|
|
1450
|
+
})
|
|
1451
|
+
})()
|