@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.
Files changed (184) hide show
  1. package/CHANGELOG.md +560 -0
  2. package/LICENSE +58 -0
  3. package/NOTICE +4 -0
  4. package/README.md +558 -0
  5. package/bin/fake-agent.mjs +143 -0
  6. package/bin/leg.mjs +449 -0
  7. package/docs/DECISIONS.md +119 -0
  8. package/docs/DEMO.md +214 -0
  9. package/docs/DEVIATIONS.md +182 -0
  10. package/docs/ERRORS.md +326 -0
  11. package/docs/README.md +40 -0
  12. package/docs/REUSE.md +289 -0
  13. package/docs/ROADMAP-v2.md +55 -0
  14. package/docs/VOCABULARY.md +243 -0
  15. package/docs/adapters.md +300 -0
  16. package/docs/board-guide.md +579 -0
  17. package/docs/cli-contracts.md +401 -0
  18. package/docs/concepts.md +426 -0
  19. package/docs/configuration.md +220 -0
  20. package/docs/faq.md +206 -0
  21. package/docs/getting-started.md +251 -0
  22. package/docs/real-run.md +80 -0
  23. package/docs/review-2026-09-14.md +72 -0
  24. package/docs/screenshots/board-400px.png +0 -0
  25. package/docs/screenshots/board-details-open.png +0 -0
  26. package/docs/screenshots/board-done.png +0 -0
  27. package/docs/screenshots/board-drawer.png +0 -0
  28. package/docs/screenshots/board-empty.png +0 -0
  29. package/docs/screenshots/board-handoff.png +0 -0
  30. package/docs/screenshots/board-running.png +0 -0
  31. package/docs/screenshots/demo-1-claude-running.png +0 -0
  32. package/docs/screenshots/demo-2-limit-hit.png +0 -0
  33. package/docs/screenshots/demo-3-handoff-bundle.png +0 -0
  34. package/docs/screenshots/demo-4-codex-running.png +0 -0
  35. package/docs/screenshots/demo-5-done.png +0 -0
  36. package/docs/screenshots/floor-final-1280.png +0 -0
  37. package/docs/screenshots/floor-final-400.png +0 -0
  38. package/docs/screenshots/floor-landing.png +0 -0
  39. package/docs/screenshots/floor.png +0 -0
  40. package/docs/screenshots/share-guest-1280.png +0 -0
  41. package/docs/screenshots/share-owner-1280.png +0 -0
  42. package/docs/screenshots/terminals-1280.png +0 -0
  43. package/docs/screenshots/thumb-done.jpg +0 -0
  44. package/docs/screenshots/thumb-handoff-details.jpg +0 -0
  45. package/docs/screenshots/thumb-limit-hit.jpg +0 -0
  46. package/docs/screenshots/youtube-thumbnail.jpg +0 -0
  47. package/fixtures/limits/agy/agy-resource-exhausted.json +11 -0
  48. package/fixtures/limits/claude/claude-budget-limit.json +11 -0
  49. package/fixtures/limits/claude/claude-max-turns.json +11 -0
  50. package/fixtures/limits/claude/claude-model-limit.json +11 -0
  51. package/fixtures/limits/claude/claude-session-limit.json +11 -0
  52. package/fixtures/limits/claude/claude-weekly-limit.json +11 -0
  53. package/fixtures/limits/codex/codex-quota-exceeded.json +11 -0
  54. package/fixtures/limits/codex/codex-rate-limit-exceeded.json +11 -0
  55. package/fixtures/limits/codex/codex-skills-notice.json +11 -0
  56. package/fixtures/limits/codex/codex-usage-limit-reached.json +11 -0
  57. package/fixtures/limits/codex/codex-usage-limit.json +11 -0
  58. package/fixtures/limits/generic/generic-429.json +11 -0
  59. package/fixtures/limits/generic/generic-overloaded.json +11 -0
  60. package/fixtures/limits/generic/generic-quota.json +11 -0
  61. package/fixtures/limits/generic/generic-rate-limit.json +11 -0
  62. package/fixtures/limits/generic/generic-resource-exhausted.json +11 -0
  63. package/fixtures/limits/generic/generic-usage-limit.json +11 -0
  64. package/fixtures/limits/grok/grok-not-logged-in.json +11 -0
  65. package/fixtures/limits/negative/auth-source-set.json +11 -0
  66. package/fixtures/limits/negative/compile-error.json +11 -0
  67. package/fixtures/limits/negative/empty-stdout-exit-0.json +11 -0
  68. package/fixtures/live/agy/attempt-1-scratch-workspace.out.log +1 -0
  69. package/fixtures/live/agy/cmd.txt +1 -0
  70. package/fixtures/live/agy/err.log +0 -0
  71. package/fixtures/live/agy/out.log +1 -0
  72. package/fixtures/live/agy/parsed.json +5 -0
  73. package/fixtures/live/agy/run.json +18 -0
  74. package/fixtures/live/agy/supervisor.log +2 -0
  75. package/fixtures/live/claude/cmd.txt +1 -0
  76. package/fixtures/live/claude/err.log +0 -0
  77. package/fixtures/live/claude/limit-rate_limit.json +21 -0
  78. package/fixtures/live/claude/out.log +1 -0
  79. package/fixtures/live/claude/parsed.json +11 -0
  80. package/fixtures/live/claude/run.json +18 -0
  81. package/fixtures/live/claude/supervisor.log +2 -0
  82. package/fixtures/live/codex/cmd.txt +1 -0
  83. package/fixtures/live/codex/err.log +1 -0
  84. package/fixtures/live/codex/last.md +2 -0
  85. package/fixtures/live/codex/limit-usage_limit_exceeded.json +25 -0
  86. package/fixtures/live/codex/out.log +8 -0
  87. package/fixtures/live/codex/parsed.json +15 -0
  88. package/fixtures/live/codex/run.json +18 -0
  89. package/fixtures/live/codex/supervisor.log +2 -0
  90. package/fixtures/live/grok/cmd.txt +1 -0
  91. package/fixtures/live/grok/err.log +32 -0
  92. package/fixtures/live/grok/out.log +7 -0
  93. package/fixtures/live/grok/parsed.json +5 -0
  94. package/fixtures/live/grok/run.json +18 -0
  95. package/fixtures/live/grok/supervisor.log +2 -0
  96. package/fixtures/verified.json +34 -0
  97. package/package.json +68 -0
  98. package/scripts/board-shots.mjs +69 -0
  99. package/scripts/build-docs-site.mjs +308 -0
  100. package/scripts/check-claims.mjs +150 -0
  101. package/scripts/clean-clone-check.sh +42 -0
  102. package/scripts/license-sign.mjs +23 -0
  103. package/scripts/limits-table.mjs +38 -0
  104. package/scripts/live-limits.mjs +36 -0
  105. package/scripts/privacy-check.mjs +63 -0
  106. package/scripts/probe.mjs +92 -0
  107. package/scripts/seed-fake-cards.mjs +34 -0
  108. package/scripts/seed-floor-board.mjs +69 -0
  109. package/scripts/seed-wes-board.mjs +91 -0
  110. package/scripts/stripe-setup.mjs +99 -0
  111. package/scripts/vercel-env.mjs +40 -0
  112. package/src/accounts.mjs +119 -0
  113. package/src/adapters/agy.mjs +56 -0
  114. package/src/adapters/claude.mjs +54 -0
  115. package/src/adapters/codex.mjs +73 -0
  116. package/src/adapters/common.mjs +32 -0
  117. package/src/adapters/fake.mjs +48 -0
  118. package/src/adapters/grok.mjs +51 -0
  119. package/src/adapters/index.mjs +27 -0
  120. package/src/adapters/resolve.mjs +40 -0
  121. package/src/attach.mjs +592 -0
  122. package/src/auth.mjs +85 -0
  123. package/src/board/board.css +543 -0
  124. package/src/board/board.js +1307 -0
  125. package/src/board/favicon.svg +1 -0
  126. package/src/board/floor.html +127 -0
  127. package/src/board/floor.js +670 -0
  128. package/src/board/fonts/OFL-atkinson-hyperlegible-next.txt +93 -0
  129. package/src/board/fonts/OFL-azeret-mono.txt +94 -0
  130. package/src/board/fonts/atkinson-board.woff2 +0 -0
  131. package/src/board/fonts/azeret-board.woff2 +0 -0
  132. package/src/board/index.html +251 -0
  133. package/src/board/sessions.js +1451 -0
  134. package/src/bundle.mjs +103 -0
  135. package/src/cards.mjs +114 -0
  136. package/src/chain.mjs +264 -0
  137. package/src/commands.mjs +79 -0
  138. package/src/contract.mjs +73 -0
  139. package/src/env.mjs +19 -0
  140. package/src/fsx.mjs +72 -0
  141. package/src/git-snapshot.mjs +152 -0
  142. package/src/handoff.mjs +171 -0
  143. package/src/hook.mjs +49 -0
  144. package/src/land.mjs +106 -0
  145. package/src/launcher.mjs +272 -0
  146. package/src/leases.mjs +86 -0
  147. package/src/ledger.mjs +369 -0
  148. package/src/license.mjs +168 -0
  149. package/src/limits.mjs +97 -0
  150. package/src/live-capture.mjs +69 -0
  151. package/src/mergequeue.mjs +225 -0
  152. package/src/orchestrator.mjs +365 -0
  153. package/src/pipeline.mjs +100 -0
  154. package/src/preferences.mjs +47 -0
  155. package/src/presets.mjs +23 -0
  156. package/src/ratelimit.mjs +49 -0
  157. package/src/redact.mjs +47 -0
  158. package/src/resume.mjs +354 -0
  159. package/src/runner.mjs +438 -0
  160. package/src/scheduler.mjs +118 -0
  161. package/src/server.mjs +845 -0
  162. package/src/session-detail.mjs +129 -0
  163. package/src/sessions.mjs +202 -0
  164. package/src/share.mjs +163 -0
  165. package/src/stations/agent.mjs +42 -0
  166. package/src/stations/build.mjs +9 -0
  167. package/src/stations/human.mjs +5 -0
  168. package/src/stations/land.mjs +6 -0
  169. package/src/stations/plan.mjs +8 -0
  170. package/src/stations/pr.mjs +31 -0
  171. package/src/stations/review.mjs +8 -0
  172. package/src/stations/test.mjs +27 -0
  173. package/src/store.mjs +90 -0
  174. package/src/sync/dashclaw.mjs +70 -0
  175. package/src/sync/index.mjs +54 -0
  176. package/src/sync/workboard.mjs +54 -0
  177. package/src/taps/agy.mjs +64 -0
  178. package/src/taps/claude-usage.mjs +67 -0
  179. package/src/taps/claude.mjs +170 -0
  180. package/src/taps/codex.mjs +286 -0
  181. package/src/trust.mjs +323 -0
  182. package/src/usage.mjs +179 -0
  183. package/src/wait.mjs +30 -0
  184. 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
+ })()