@ucsandman/legcli 0.11.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +213 -0
- package/README.md +95 -65
- package/bin/leg.mjs +123 -14
- package/docs/DECISIONS.md +18 -0
- package/docs/DEMO.md +20 -14
- package/docs/DEVIATIONS.md +1 -0
- package/docs/ERRORS.md +68 -0
- package/docs/ROADMAP-v2.md +50 -5
- package/docs/VOCABULARY.md +27 -0
- package/docs/board-guide.md +529 -96
- package/docs/cli-contracts.md +241 -5
- package/docs/concepts.md +167 -19
- package/docs/configuration.md +65 -1
- package/docs/faq.md +21 -5
- package/docs/getting-started.md +15 -11
- package/docs/redesign-2026-09-17.md +477 -0
- package/docs/screenshots/background-1280.png +0 -0
- package/docs/screenshots/board-400px.png +0 -0
- package/docs/screenshots/board-details-open.png +0 -0
- package/docs/screenshots/board-drawer.png +0 -0
- package/docs/screenshots/board-handoff.png +0 -0
- package/docs/screenshots/board-running.png +0 -0
- package/docs/screenshots/capacity-drawer-1280.png +0 -0
- package/docs/screenshots/floor.png +0 -0
- package/docs/screenshots/new-card-dialog.png +0 -0
- package/docs/screenshots/settings-ladder-1280.png +0 -0
- package/docs/screenshots/terminals-1280.png +0 -0
- package/fixtures/limits/claude/claude-fable-limit.json +11 -0
- package/fixtures/limits/claude/claude-model-limit.json +1 -1
- package/fixtures/limits/claude/claude-session-limit.json +1 -1
- package/fixtures/limits/claude/claude-weekly-limit.json +1 -1
- package/fixtures/live/claude/resume-model-probe.json +20 -0
- package/fixtures/live/claude/usage-oauth.json +87 -0
- package/fixtures/verified.json +1 -1
- package/package.json +3 -2
- package/scripts/board-jump-probe.mjs +335 -0
- package/scripts/seed-fake-cards.mjs +59 -6
- package/scripts/seed-wes-board.mjs +81 -12
- package/src/accounts.mjs +6 -1
- package/src/attach.mjs +378 -93
- package/src/audit.mjs +1 -1
- package/src/board/board.css +203 -11
- package/src/board/board.js +664 -200
- package/src/board/entry.js +343 -0
- package/src/board/floor.html +51 -39
- package/src/board/floor.js +585 -73
- package/src/board/index.html +122 -45
- package/src/board/sessions.js +1569 -141
- package/src/board/strip.js +163 -0
- package/src/buckets.mjs +101 -0
- package/src/cards.mjs +9 -1
- package/src/chain.mjs +13 -0
- package/src/hook.mjs +7 -1
- package/src/ledger.mjs +10 -2
- package/src/models.mjs +265 -0
- package/src/orchestrator.mjs +13 -4
- package/src/preferences.mjs +278 -5
- package/src/scheduler.mjs +24 -1
- package/src/server.mjs +625 -78
- package/src/sessions.mjs +17 -1
- package/src/taps/claude-usage.mjs +107 -3
- package/src/taps/claude.mjs +144 -5
- package/src/taps/codex.mjs +23 -3
- package/src/usage-poll.mjs +260 -0
- package/src/usage.mjs +439 -12
package/src/board/sessions.js
CHANGED
|
@@ -23,6 +23,14 @@
|
|
|
23
23
|
}
|
|
24
24
|
const WIN_WORDS = { '5h': '5 hour', '7d': '7 day' }
|
|
25
25
|
const IDS = ['claude', 'codex', 'agy', 'grok', 'fake']
|
|
26
|
+
// Mirrors MODEL_ALIASES and ALL_HANDOFF_AGENTS in src/buckets.mjs and
|
|
27
|
+
// src/preferences.mjs. This file is served to a browser and cannot import
|
|
28
|
+
// from either, so the lists are copied here and nowhere else on the board; a
|
|
29
|
+
// new alias is a change to both files in the same commit. The server is still
|
|
30
|
+
// the authority: it answers a rung it has never heard of with a 400 sentence,
|
|
31
|
+
// which the editor prints verbatim.
|
|
32
|
+
const MODEL_ALIASES = { claude: ['fable', 'opus', 'sonnet', 'haiku'], codex: [], agy: [], grok: [] }
|
|
33
|
+
const LADDER_AGENTS = ['claude', 'codex', 'agy', 'grok']
|
|
26
34
|
|
|
27
35
|
let view = null
|
|
28
36
|
const NO_BRANCH_BLOCKER = 'this terminal works in the checkout itself: there is no branch of its own to land'
|
|
@@ -30,13 +38,28 @@
|
|
|
30
38
|
let trunkOpen = false
|
|
31
39
|
let finishedOpen = false
|
|
32
40
|
let pendingConfirm = null
|
|
41
|
+
// The order the terminals were last drawn in, and when that order first
|
|
42
|
+
// stopped matching the sort. A reader with a terminal expanded is reading a
|
|
43
|
+
// region whose position on the page is decided by the rows above it: a row
|
|
44
|
+
// crossing the needs-you partition moved the whole expansion 205px down the
|
|
45
|
+
// screen mid-sentence, with scrollY unchanged, so no scroll-hold probe could
|
|
46
|
+
// see it. See listOrder/holdsOrder below and scripts/board-jump-probe.mjs.
|
|
47
|
+
let heldOrder = []
|
|
48
|
+
let orderDivergedAt = 0
|
|
33
49
|
const sessionEditors = new Map()
|
|
34
50
|
const alsoOpen = new Set()
|
|
35
51
|
const actionNotes = new Map()
|
|
36
52
|
const lastTone = new Map()
|
|
37
|
-
const defaultEditor = {
|
|
38
|
-
|
|
39
|
-
|
|
53
|
+
const defaultEditor = { ladder: null, climb_back: 'next-handoff', may_spend: false, reserve: {}, dirty: false, saving: false, status: '', statusClass: '' }
|
|
54
|
+
|
|
55
|
+
// localStorage THROWS rather than answering null in a browser with site data
|
|
56
|
+
// blocked, in a partitioned webview and on a board opened from a file. This
|
|
57
|
+
// read runs inside api(), so an unguarded one takes every fetch on the page
|
|
58
|
+
// down before it is issued and the board looks exactly like a dead server.
|
|
59
|
+
// An empty token is already a valid state: the header is only added if (token).
|
|
60
|
+
function getToken() {
|
|
61
|
+
try { return localStorage.getItem('legToken') || localStorage.getItem('batonToken') || '' } catch { return '' }
|
|
62
|
+
}
|
|
40
63
|
async function api(path, opts = {}) {
|
|
41
64
|
const headers = { 'Content-Type': 'application/json' }
|
|
42
65
|
const token = getToken()
|
|
@@ -139,8 +162,15 @@
|
|
|
139
162
|
function optionLabel(a) { return a ? (a.account && a.account !== 'default' ? `${a.agent}/${a.account}` : a.agent) : 'none' }
|
|
140
163
|
function idOf(agent) { return IDS.includes(agent) ? agent : 'fake' }
|
|
141
164
|
const tail = (id) => String(id).split('-').slice(-2).join('-')
|
|
165
|
+
// `leg#7f3a`: the repo this terminal is in and the short id the row prints,
|
|
166
|
+
// which is how a human refers to it out loud and in the verdict.
|
|
167
|
+
const shortId = (s) => tail(s.session_id).replace(new RegExp(`^${s.agent || ''}-`), '')
|
|
168
|
+
const rowName = (s) => `${s.repo_name || s.agent || 'terminal'}#${shortId(s)}`
|
|
142
169
|
const shared = () => Boolean(view && view.share && view.share.on)
|
|
143
170
|
const isMine = (s) => Boolean(view && view.you && s.owner && view.you.name === s.owner)
|
|
171
|
+
// the pipeline board belongs to the owner and the operators of the machine;
|
|
172
|
+
// a guest's End row offers no card (the route answers 403)
|
|
173
|
+
const canCards = () => !(view && view.you && view.you.role && view.you.role !== 'owner' && view.you.role !== 'operator')
|
|
144
174
|
|
|
145
175
|
// ---- 6.1 the window rail ----------------------------------------------
|
|
146
176
|
// One state value per account drives the .acct modifier, every rail cell, the
|
|
@@ -252,6 +282,251 @@
|
|
|
252
282
|
])
|
|
253
283
|
}
|
|
254
284
|
|
|
285
|
+
// ---- the binding bucket -------------------------------------------------
|
|
286
|
+
// The bucket that will actually stop the work: the one the endpoint marked
|
|
287
|
+
// active, else the highest percentage it reported, else the legacy hottest of
|
|
288
|
+
// the two windows, which is all an older record or a guest payload carries.
|
|
289
|
+
// Mirrors binding() in src/usage.mjs; the board cannot import from it.
|
|
290
|
+
//
|
|
291
|
+
// src/board/strip.js OWNS this grammar and the capacity strip that prints it,
|
|
292
|
+
// because /floor prints the same tokens from the same payload: two pages
|
|
293
|
+
// computing a binding bucket their own way is the defect the strip was built
|
|
294
|
+
// to end. The names here are this file's callers and its test seam; the
|
|
295
|
+
// answers come from that one file, which is loaded before this one and is
|
|
296
|
+
// handed this file's primitives (use(), at the bottom).
|
|
297
|
+
const strip = () => window.legStrip
|
|
298
|
+
// strip.js is a plain script like this one, so it is handed this file's
|
|
299
|
+
// primitives instead of growing a second copy of the time grammar or the
|
|
300
|
+
// login labels. Every name below is a function declaration above, so the
|
|
301
|
+
// reference is live however late strip.js calls it.
|
|
302
|
+
if (typeof window !== 'undefined' && window.legStrip) {
|
|
303
|
+
window.legStrip.use({ el, accountLabel, idOf, acctState, worstWindow, until, clockAt, spoken })
|
|
304
|
+
}
|
|
305
|
+
const bindingOf = (a) => strip().bindingOf(a)
|
|
306
|
+
// the same bucket inside a sentence: "63% of its week"
|
|
307
|
+
const windowPhrase = (b) => strip().windowPhrase(b)
|
|
308
|
+
// the account's own window, ignoring any model bucket: what a same-login
|
|
309
|
+
// model rung still has to spend, and what an account wall would take away
|
|
310
|
+
function accountBucket(a) {
|
|
311
|
+
const flat = (Array.isArray(a && a.buckets) ? a.buckets : []).filter((b) => b && !b.model && Number.isFinite(b.percent))
|
|
312
|
+
const b = [...flat].sort((x, y) => y.percent - x.percent)[0]
|
|
313
|
+
if (b) return { kind: b.kind, model: null, percent: b.percent, resets_at: Number.isFinite(b.resets_at) ? b.resets_at : null, scope: 'account' }
|
|
314
|
+
const legacy = bindingOf(a)
|
|
315
|
+
return legacy && !legacy.model ? legacy : null
|
|
316
|
+
}
|
|
317
|
+
const Model = (m) => (m ? String(m).charAt(0).toUpperCase() + String(m).slice(1) : '')
|
|
318
|
+
// every model this login has published anything about, and which of them are
|
|
319
|
+
// out. Owned by strip.js with the rest of the bucket grammar; named here for
|
|
320
|
+
// the model rail and the verdict that read them.
|
|
321
|
+
const knownModels = (a) => strip().knownModels(a)
|
|
322
|
+
const wallFor = (a, model) => strip().wallFor(a, model)
|
|
323
|
+
const walledModels = (a) => strip().walledModels(a)
|
|
324
|
+
function openModels(a) { return knownModels(a).filter((m) => !wallFor(a, m)) }
|
|
325
|
+
function modelBucket(a, model) {
|
|
326
|
+
return (Array.isArray(a && a.buckets) ? a.buckets : []).find((b) => b && b.model === model && Number.isFinite(b.percent)) || null
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ---- the forecast (spec A.5 row 4, E rule 6) -----------------------------
|
|
330
|
+
// `forecast` rides the binding bucket, computed by `burn()` in src/usage.mjs:
|
|
331
|
+
// a rate measured inside ONE window, never across a reset, and only past a
|
|
332
|
+
// gate of three samples spanning ten minutes. Under the gate there is no time
|
|
333
|
+
// at all, and every time that is printed carries the volume it came from: a
|
|
334
|
+
// figure with no sample count beside it is a guess wearing an instrument's
|
|
335
|
+
// clothes, and this one is printed in the largest type on the page.
|
|
336
|
+
function forecastOf(c) {
|
|
337
|
+
const f = c && c.forecast
|
|
338
|
+
return f && Number.isFinite(f.seconds_left) && Number.isFinite(f.samples) && Number.isFinite(f.span_s) ? f : null
|
|
339
|
+
}
|
|
340
|
+
// `2h 40m` under a day, `3d 5h` beyond, and spelled-out minutes under ten:
|
|
341
|
+
// `0h 8m` reads as an instrument, and eight minutes is a sentence.
|
|
342
|
+
function burnPhrase(secondsLeft) {
|
|
343
|
+
const m = Math.max(0, Math.round(secondsLeft / 60))
|
|
344
|
+
if (m < 10) return `${m} minute${m === 1 ? '' : 's'}`
|
|
345
|
+
if (m < 60) return `${m}m`
|
|
346
|
+
const h = Math.floor(m / 60)
|
|
347
|
+
if (h < 24) return `${h}h${m % 60 ? ` ${m % 60}m` : ''}`
|
|
348
|
+
const d = Math.floor(h / 24)
|
|
349
|
+
return `${d}d${h % 24 ? ` ${h % 24}h` : ''}`
|
|
350
|
+
}
|
|
351
|
+
function burnVolume(f, lead = 'from') { return `${lead} ${f.samples} sample${f.samples === 1 ? '' : 's'} over ${ago(f.span_s * 1000)}` }
|
|
352
|
+
function andList(xs) { return xs.length < 2 ? xs.join('') : `${xs.slice(0, -1).join(', ')} and ${xs[xs.length - 1]}` }
|
|
353
|
+
|
|
354
|
+
// ---- what one terminal is waiting for ------------------------------------
|
|
355
|
+
// `waiting` carries TWO shapes under one key and they mean opposite things.
|
|
356
|
+
// `{ type: 'reset', agent, account, resets_at, since }` is the all-out
|
|
357
|
+
// countdown the runner writes: nobody is being waited on, the child is
|
|
358
|
+
// already dead. The Notification shapes below are a HUMAN being waited on.
|
|
359
|
+
// Both carry `type` and `since`, so the shape is told apart by the type and
|
|
360
|
+
// never by the presence of a field (src/sessions.mjs createSession).
|
|
361
|
+
const NOTIFY_TYPES = ['permission_prompt', 'idle_prompt', 'agent_needs_input', 'quota_auto_resume']
|
|
362
|
+
// src/taps/claude.mjs QUOTA_STAND_DOWN, character for character: two waiters
|
|
363
|
+
// on one terminal is the failure to avoid, so the row says who is waiting.
|
|
364
|
+
const QUOTA_STAND_DOWN = 'Claude Code is waiting at the limit itself; Leg is not handing this one off.'
|
|
365
|
+
// Nothing clears `waiting` on the way out: the ended transition in
|
|
366
|
+
// src/attach.mjs and the lost one in src/sessions.mjs both leave the last
|
|
367
|
+
// Notification shape on the record. A dead terminal is not waiting on
|
|
368
|
+
// anybody, so the guard is the one quietPhrase below already keeps: without
|
|
369
|
+
// it a terminal that exited at a permission prompt said "waiting on you"
|
|
370
|
+
// forever, held the tab badge and never fell into the Finished ledger.
|
|
371
|
+
function notifyWait(s) {
|
|
372
|
+
const w = s && s.waiting
|
|
373
|
+
return w && s.active && NOTIFY_TYPES.includes(w.type) ? w : null
|
|
374
|
+
}
|
|
375
|
+
function resetWait(s) {
|
|
376
|
+
const w = s && s.waiting
|
|
377
|
+
return w && !NOTIFY_TYPES.includes(w.type) ? w : null
|
|
378
|
+
}
|
|
379
|
+
// A.6 rank 3. The question is printed verbatim, capped at the 160 characters
|
|
380
|
+
// the hook itself stores: a paraphrase of what an agent asked for is the one
|
|
381
|
+
// thing a human cannot check against the terminal in front of them.
|
|
382
|
+
function waitingNote(w) {
|
|
383
|
+
if (!w) return null
|
|
384
|
+
const since = Date.parse(w.since || '')
|
|
385
|
+
const msg = String(w.message || '').slice(0, 160)
|
|
386
|
+
const asked = Number.isFinite(since) ? `, asked ${ago(Date.now() - since)} ago` : ''
|
|
387
|
+
if (w.type === 'quota_auto_resume') return { rank: 3, cat: 'standing down', tone: 'warn', text: msg || QUOTA_STAND_DOWN }
|
|
388
|
+
if (w.type === 'permission_prompt') return { rank: 3, cat: 'waiting on you', tone: 'warn', text: `waiting on you: permission to run ${msg || 'a tool'}${asked}` }
|
|
389
|
+
if (w.type === 'idle_prompt') return { rank: 3, cat: 'waiting on you', tone: 'warn', text: `waiting on you: idle${Number.isFinite(since) ? ` since ${clockAt(since)}` : ''}` }
|
|
390
|
+
return { rank: 3, cat: 'waiting on you', tone: 'warn', text: `waiting on you: ${msg || 'it asked for something'}` }
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// ---- A.4 rows 7 to 9 and 12: the register's data tokens ------------------
|
|
394
|
+
// Pure data in reading order, so the words can be asserted without a DOM and
|
|
395
|
+
// the row builder below stays a list of appends.
|
|
396
|
+
const QUIET_MS = 2 * 60 * 1000
|
|
397
|
+
// `claude/fable`. The agent alone when the model is unknown: a printed model
|
|
398
|
+
// nobody chose is a wrong number in disguise, so there is never a default.
|
|
399
|
+
function modelToken(s) { return s && s.agent ? (s.model ? `${s.agent}/${s.model}` : s.agent) : null }
|
|
400
|
+
// an observation, not a demand, and printed in the muted tone for every
|
|
401
|
+
// agent. A row already waiting on a human says that instead.
|
|
402
|
+
function quietPhrase(s) {
|
|
403
|
+
if (!s || !s.active || notifyWait(s)) return null
|
|
404
|
+
const t = Date.parse(s.last_activity || '')
|
|
405
|
+
if (!Number.isFinite(t)) return null
|
|
406
|
+
const idle = Date.now() - t
|
|
407
|
+
return idle >= QUIET_MS ? `quiet ${ago(idle)}` : null
|
|
408
|
+
}
|
|
409
|
+
// A.4 row 12: the bucket that will actually stop THIS terminal, which depends
|
|
410
|
+
// on the model it is running. `capacity` is binding(usage, session.model),
|
|
411
|
+
// computed per request in src/server.mjs and never persisted.
|
|
412
|
+
function capacityPhrase(s) {
|
|
413
|
+
const c = s && s.capacity
|
|
414
|
+
if (!c || !Number.isFinite(c.percent)) return null
|
|
415
|
+
const label = s.account && s.account !== 'default' ? `${s.agent}/${s.account}` : s.agent
|
|
416
|
+
// With a rate the row says the same fact in the unit the reader is actually
|
|
417
|
+
// deciding in, and carries the volume it was drawn from. Under the gate it
|
|
418
|
+
// is the percentage again (E rule 6): no time is printed from two readings.
|
|
419
|
+
const f = forecastOf(c)
|
|
420
|
+
if (f) return `about ${burnPhrase(f.seconds_left)} of ${c.scope === 'model' && c.model ? c.model : label} left, ${burnVolume(f)}`
|
|
421
|
+
const pct = Math.round(c.percent)
|
|
422
|
+
if (c.scope === 'model' && c.model) return `${pct}% of the ${c.model} week`
|
|
423
|
+
const win = c.kind === 'session' || c.kind === 'five_hour' ? '5-hour window' : 'week'
|
|
424
|
+
return `${pct}% of the ${label} ${win}`
|
|
425
|
+
}
|
|
426
|
+
function registerTokens(s) {
|
|
427
|
+
const out = []
|
|
428
|
+
const dirty = Array.isArray(s && s.files_dirty) ? s.files_dirty.length : 0
|
|
429
|
+
if (dirty) out.push({ kind: 'dirty', text: `dirty ${dirty}` })
|
|
430
|
+
// `ahead` is a git count the runner polls; an older record does not carry
|
|
431
|
+
// it, and a count nobody measured is never printed as zero
|
|
432
|
+
if (Number.isFinite(s && s.ahead) && s.ahead > 0) out.push({ kind: 'ahead', text: `ahead ${s.ahead}` })
|
|
433
|
+
const model = modelToken(s)
|
|
434
|
+
if (model) out.push({ kind: 'model', text: model })
|
|
435
|
+
const quiet = quietPhrase(s)
|
|
436
|
+
if (quiet) out.push({ kind: 'quiet', text: quiet })
|
|
437
|
+
return out
|
|
438
|
+
}
|
|
439
|
+
// A.6 rank 8.5: said only when the bucket that binds this terminal is at or
|
|
440
|
+
// past the warning line. Model-scoped, a same-login rung is the answer;
|
|
441
|
+
// account-scoped, it buys nothing and the sentence says which login is next.
|
|
442
|
+
function capacityNote(s, account) {
|
|
443
|
+
const c = s && s.capacity
|
|
444
|
+
if (!c || !Number.isFinite(c.percent) || c.percent < WARN_PCT) return null
|
|
445
|
+
const pct = Math.round(c.percent)
|
|
446
|
+
const label = s.account && s.account !== 'default' ? `${s.agent}/${s.account}` : s.agent
|
|
447
|
+
if (c.scope === 'model' && c.model) {
|
|
448
|
+
const alt = account ? openModels(account).filter((m) => m !== c.model)[0] : null
|
|
449
|
+
return { rank: 8.5, cat: 'near the model wall', tone: 'warn', text: `${c.model} at ${pct}% of its week${alt ? `; Hand off > ${s.agent}/${alt} keeps this terminal` : ''}` }
|
|
450
|
+
}
|
|
451
|
+
const next = (s.chain || []).find((x) => x && x.agent !== s.agent)
|
|
452
|
+
return { rank: 8.5, cat: 'near the login wall', tone: 'warn', text: `${label} at ${pct}%, shared by every model${next ? `; next off ${label}: ${optionLabel(next)}` : ''}` }
|
|
453
|
+
}
|
|
454
|
+
function accountOf(s) {
|
|
455
|
+
const list = (view && view.accounts) || []
|
|
456
|
+
return list.find((a) => a.agent === s.agent && (a.account || 'default') === (s.account || 'default')) || null
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// ---- the capacity strip -------------------------------------------------
|
|
460
|
+
// One 44px band under the verdict, one token per login, and nothing else:
|
|
461
|
+
// usage is a property of the work now, not a region of its own. The panels
|
|
462
|
+
// are not rewritten, they move behind the disclosure at the end of the strip.
|
|
463
|
+
// The token prints the BINDING bucket, because the board printing 47% for a
|
|
464
|
+
// login whose active bucket is at 63% is the defect this strip exists for.
|
|
465
|
+
//
|
|
466
|
+
// src/board/strip.js DRAWS IT, for this page and for /floor, from the same
|
|
467
|
+
// /api/sessions accounts payload. These three names are what this file's
|
|
468
|
+
// callers and test/board-verdict.test.mjs reach for; there is one token
|
|
469
|
+
// builder behind them.
|
|
470
|
+
const capFigure = (a, b) => strip().capFigure(a, b)
|
|
471
|
+
const capToken = (a) => strip().capToken(a)
|
|
472
|
+
const capacityStrip = (list) => strip().capacityStrip(list)
|
|
473
|
+
|
|
474
|
+
// The model rail, on the panel head inside the drawer: one chip per model
|
|
475
|
+
// this login has published a bucket or a wall for. A walled model says when
|
|
476
|
+
// it is back, in words, because a wall is attributed from wording and a
|
|
477
|
+
// percentage is measured, and one must never be printed as the other.
|
|
478
|
+
function modelRail(a) {
|
|
479
|
+
const models = knownModels(a)
|
|
480
|
+
if (!models.length) return null
|
|
481
|
+
const rail = el('span', { class: 'model-rail', 'aria-label': `${accountLabel(a)} models` })
|
|
482
|
+
// B.6: an open chip is the one control that turns a figure into a decision,
|
|
483
|
+
// so it becomes a button that puts that rung at the top of ONE terminal's
|
|
484
|
+
// ladder: the one open in the detail region, else the first live row on
|
|
485
|
+
// this login. With no live terminal on the login there is nothing to
|
|
486
|
+
// re-point and the chip stays what it was, a reading.
|
|
487
|
+
const target = railTerminal(a)
|
|
488
|
+
for (const m of models) {
|
|
489
|
+
const wall = wallFor(a, m)
|
|
490
|
+
const b = modelBucket(a, m)
|
|
491
|
+
const text = wall ? `${m} out until ${until(wall.limited_until)}` : b ? `${m} ${Math.round(b.percent)}%` : m
|
|
492
|
+
const pickable = !wall && target && target.model !== m
|
|
493
|
+
if (!pickable) {
|
|
494
|
+
rail.appendChild(el('span', { class: `model-chip${wall ? ' is-out' : ''}${target && target.model === m ? ' is-current' : ''}`, title: wall && wall.evidence ? wall.evidence : null }, [text]))
|
|
495
|
+
continue
|
|
496
|
+
}
|
|
497
|
+
const chip = el('button', {
|
|
498
|
+
type: 'button', class: 'btn btn-text model-chip model-chip--pick',
|
|
499
|
+
'data-focus-key': `rail:${a.agent}:${a.account || 'default'}:${m}`,
|
|
500
|
+
title: `Put ${a.agent} / ${m} at the top of ${rowName(target)}'s ladder`,
|
|
501
|
+
}, [text])
|
|
502
|
+
chip.addEventListener('click', () => pickRung(target, { agent: a.agent, account: a.account || 'default', model: m }, chip))
|
|
503
|
+
rail.appendChild(chip)
|
|
504
|
+
}
|
|
505
|
+
return rail
|
|
506
|
+
}
|
|
507
|
+
// the terminal a rail chip re-points: the open one if it is on this login,
|
|
508
|
+
// else the first live row on it
|
|
509
|
+
function railTerminal(a) {
|
|
510
|
+
const live = ((view && view.sessions) || []).filter((s) => s.active && !s.hidden && s.agent === a.agent && (s.account || 'default') === (a.account || 'default') && s.can_edit_handoff_order)
|
|
511
|
+
return live.find((s) => s.session_id === drawer.id) || live[0] || null
|
|
512
|
+
}
|
|
513
|
+
// Moving a rung to the top of one terminal's ladder is the same POST the
|
|
514
|
+
// ladder editor makes, so the two cannot drift: one route, one shape.
|
|
515
|
+
async function pickRung(s, rung, btn) {
|
|
516
|
+
btn.disabled = true
|
|
517
|
+
actionNotes.delete(s.session_id)
|
|
518
|
+
const rest = (s.handoff_ladder || []).filter((r) => rungKey(r) !== rungKey(rung))
|
|
519
|
+
const kept = (s.handoff_ladder || []).find((r) => rungKey(r) === rungKey(rung))
|
|
520
|
+
const ladder = [kept || { ...rung, when: 'always', cost: rung.agent === 'agy' ? 'free' : rung.agent === 'grok' ? 'metered' : 'plan' }, ...rest]
|
|
521
|
+
try {
|
|
522
|
+
await api(`/api/sessions/${encodeURIComponent(s.session_id)}/handoff-order`, { method: 'POST', body: { handoff_ladder: ladder } })
|
|
523
|
+
actionNotes.set(s.session_id, { at: Date.now(), tone: 'ok', text: `${rungLabel(rung)} is the next rung for this terminal` })
|
|
524
|
+
} catch (err) {
|
|
525
|
+
actionNotes.set(s.session_id, { at: Date.now(), tone: 'danger', text: err.message })
|
|
526
|
+
}
|
|
527
|
+
refresh()
|
|
528
|
+
}
|
|
529
|
+
|
|
255
530
|
// A login is a raised object, and how much surface it gets is the design
|
|
256
531
|
// saying how much it matters. The login carrying the terminals gets the wide
|
|
257
532
|
// lit panel with both of its windows drawn; a login with one fact to report
|
|
@@ -265,6 +540,8 @@
|
|
|
265
540
|
panel.appendChild(el('div', { class: 'panel-head' }, [
|
|
266
541
|
el('span', { class: 'who' }, [el('span', { class: `dot id-${id}` }), el('span', { class: `acct-name id-${id}` }, [accountLabel(a)])]),
|
|
267
542
|
el('span', { class: 'who-note' }, [live]),
|
|
543
|
+
// one chip per model this login has published a bucket or a wall for
|
|
544
|
+
modelRail(a),
|
|
268
545
|
]))
|
|
269
546
|
|
|
270
547
|
if (state === 'notshared' || state === 'loading') {
|
|
@@ -310,62 +587,288 @@
|
|
|
310
587
|
}
|
|
311
588
|
|
|
312
589
|
// The headline is the one fact that decides what happens next, said as a
|
|
313
|
-
// sentence. It is never the number that the
|
|
590
|
+
// sentence. It is never the number that the strip under it already prints:
|
|
314
591
|
// the same figure in the two largest slots on the page is one fact taking up
|
|
315
592
|
// two, which is what the rejected head did.
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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.`
|
|
593
|
+
//
|
|
594
|
+
// VERDICT_CH is a MEASUREMENT, not a taste: at 1280 the verdict column is
|
|
595
|
+
// 26ch (891px) and 300 random sentences per length, drawn from this table's
|
|
596
|
+
// own vocabulary, still fit two 56.16px lines at 60 characters. 56 is that
|
|
597
|
+
// ceiling with four characters of slack for a longer login name, and
|
|
598
|
+
// test/board-verdict.test.mjs holds every branch under it.
|
|
599
|
+
const VERDICT_CH = 56
|
|
600
|
+
// the sub is two lines of 17px inside `max-width: 54ch`, which is about 120
|
|
601
|
+
// characters; clauses are added while they fit and dropped whole after that.
|
|
602
|
+
const SUB_CH = 120
|
|
603
|
+
// mirrors WARN_PCT in src/usage.mjs, which the board cannot import from. A
|
|
604
|
+
// bucket at or past it is close enough to the wall that the row says so.
|
|
605
|
+
const WARN_PCT = 85
|
|
606
|
+
// Each headline is written as a preferred form and shorter fallbacks, so a
|
|
607
|
+
// login called `claude/very-long-account` costs a clause, never a third line.
|
|
608
|
+
function headline(...forms) {
|
|
609
|
+
const real = forms.filter(Boolean)
|
|
610
|
+
for (const f of real) if (f.length <= VERDICT_CH) return f
|
|
611
|
+
const last = String(real[real.length - 1] || '')
|
|
612
|
+
const cut = last.slice(0, VERDICT_CH - 1)
|
|
613
|
+
const space = cut.lastIndexOf(' ')
|
|
614
|
+
return `${(space > 20 ? cut.slice(0, space) : cut).replace(/[,.;:]$/, '')}.`
|
|
615
|
+
}
|
|
616
|
+
function subLine(...parts) {
|
|
617
|
+
const out = []
|
|
618
|
+
for (const p of parts.filter(Boolean)) {
|
|
619
|
+
const next = out.concat(p).join(' ')
|
|
620
|
+
if (next.length <= SUB_CH) out.push(p)
|
|
347
621
|
}
|
|
622
|
+
return out.join(' ')
|
|
623
|
+
}
|
|
348
624
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
625
|
+
// A.5, top to bottom. A bucket whose state is unknown is never named, and
|
|
626
|
+
// "Measured Ns ago" appears only when the reading is actually stale.
|
|
627
|
+
// C.5: the verdict never mentions cards unless one is waiting on a human.
|
|
628
|
+
// `cards_waiting` on the payload is {count, first:{id,title,station,since}};
|
|
629
|
+
// an older server sends a bare number and a guest is sent nothing at all, and
|
|
630
|
+
// neither of those can name a card, so neither prints a sentence about one.
|
|
631
|
+
function waitingCard(cards) {
|
|
632
|
+
const first = cards && typeof cards === 'object' ? cards.first : null
|
|
633
|
+
return first && first.id ? first : null
|
|
634
|
+
}
|
|
635
|
+
// `card 3e1c`: the tail of the card id, which is how a card is named on its
|
|
636
|
+
// own row and out loud.
|
|
637
|
+
function cardName(id) { return `card ${String(id).split('-').filter(Boolean).slice(-1)[0] || id}` }
|
|
638
|
+
function verdictLines(list, sessions, cards) {
|
|
639
|
+
const accounts = (list || []).filter((a) => a && a.agent && !a.loading)
|
|
640
|
+
if (!accounts.length) return { line: 'Reading the logins.', sub: '' }
|
|
641
|
+
const live = (sessions || []).filter((s) => s.active)
|
|
642
|
+
const acctOf = (s) => accounts.find((a) => a.agent === s.agent && (a.account || 'default') === (s.account || 'default'))
|
|
643
|
+
const liveOn = (a) => live.filter((s) => acctOf(s) === a).length
|
|
644
|
+
const walled = accounts.filter((a) => acctState(a) === 'walled')
|
|
645
|
+
const carrying = accounts.filter((a) => liveOn(a) > 0)
|
|
646
|
+
const subject = (carrying.length ? closestToWall(carrying) : closestToWall(accounts)) || accounts[0]
|
|
647
|
+
const b = bindingOf(subject)
|
|
648
|
+
const other = (a) => accounts.filter((x) => x !== a)
|
|
649
|
+
const openElsewhere = other(subject).filter((a) => acctState(a) !== 'walled')
|
|
650
|
+
const leftOf = (bb) => Math.max(0, 100 - Math.round(bb.percent))
|
|
651
|
+
const figure = (a) => { const bb = bindingOf(a); return bb ? (bb.model ? `${accountLabel(a)} is at ${Math.round(bb.percent)}% of the ${Model(bb.model)} week.` : `${accountLabel(a)} is at ${Math.round(bb.percent)}% of ${windowPhrase(bb)}.`) : null }
|
|
652
|
+
const wallClause = (a) => (Number.isFinite(a.limited_until) ? `${accountLabel(a)} is at its limit until ${until(a.limited_until)}.` : `${accountLabel(a)} is at its limit.`)
|
|
653
|
+
const otherWalls = () => walled.filter((a) => a !== subject).map(wallClause).join(' ') || null
|
|
654
|
+
// a reading taken two hours ago with terminals running since is a floor,
|
|
655
|
+
// not a measurement, and the direction it is wrong in is the whole point
|
|
656
|
+
const staleClause = (a, bb) => {
|
|
657
|
+
const observed = Date.parse((a && (a.observed_at || a.updated_at)) || '')
|
|
658
|
+
if (!a || !a.stale || a.agent === 'agy' || !Number.isFinite(observed) || !bb) return null
|
|
659
|
+
const n = liveOn(a)
|
|
660
|
+
if (!n) return `Measured ${ago(Date.now() - observed)} ago.`
|
|
661
|
+
return `Measured ${ago(Date.now() - observed)} ago. ${n} terminal${n === 1 ? ' runs' : 's run'} on it, so the real figure is higher, never lower.`
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// 1. a human is blocked. Attention is the scarcer thing, so it outranks
|
|
665
|
+
// usage. `waiting` carries {type, message, since} from the Notification
|
|
666
|
+
// hook; the all-out countdown on the same key carries {type: 'reset',
|
|
667
|
+
// agent, account, resets_at, since} and is not a human being waited on, so
|
|
668
|
+
// the TYPE is checked and never the presence of `since`. `quota_auto_resume`
|
|
669
|
+
// is left out here too: nobody asked the human anything, Claude Code is
|
|
670
|
+
// holding its own turn, and the row says so in its own sentence.
|
|
671
|
+
const blocked = live
|
|
672
|
+
.filter((s) => notifyWait(s) && s.waiting.type !== 'quota_auto_resume' && Number.isFinite(Date.parse(s.waiting.since)))
|
|
673
|
+
.sort((x, y) => Date.parse(x.waiting.since) - Date.parse(y.waiting.since))[0]
|
|
674
|
+
if (blocked) {
|
|
675
|
+
const who = rowName(blocked)
|
|
676
|
+
const waited = spoken(Date.now() - Date.parse(blocked.waiting.since))
|
|
677
|
+
const others = live.length - 1
|
|
678
|
+
return {
|
|
679
|
+
line: headline(`${who} has waited on you for ${waited}.`, `${who} has waited on you for ${ago(Date.now() - Date.parse(blocked.waiting.since))}.`, `${who} is waiting on you.`),
|
|
680
|
+
sub: subLine(
|
|
681
|
+
// the question in the words of the thing that asked it, and each
|
|
682
|
+
// type asks a different question: a permission prompt names a tool,
|
|
683
|
+
// an idle prompt names a clock, and the third carries its own text
|
|
684
|
+
blocked.waiting.type === 'permission_prompt' && blocked.waiting.message ? `It asked to run ${String(blocked.waiting.message).slice(0, 80)}.`
|
|
685
|
+
: blocked.waiting.type === 'idle_prompt' ? `It has had no input since ${clockAt(Date.parse(blocked.waiting.since))}.`
|
|
686
|
+
: blocked.waiting.message ? `It asked: ${String(blocked.waiting.message).slice(0, 80)}` : null,
|
|
687
|
+
others > 0 ? `The other ${others === 1 ? 'terminal is' : `${others} terminals are`} still running.` : null,
|
|
688
|
+
),
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// 1b. the same rule one level down: no terminal is blocked, but a card is,
|
|
693
|
+
// and a card waiting at a review station is a human being waited on too. It
|
|
694
|
+
// outranks every usage branch for the reason branch 1 does, and it is the
|
|
695
|
+
// ONLY thing that puts a card in the verdict (C.5).
|
|
696
|
+
const card = waitingCard(cards)
|
|
697
|
+
const cardSince = card ? Date.parse(card.since || '') : NaN
|
|
698
|
+
if (card) {
|
|
699
|
+
const who = cardName(card.id)
|
|
700
|
+
return {
|
|
701
|
+
line: Number.isFinite(cardSince)
|
|
702
|
+
? headline(`${who} has waited on you for ${spoken(Date.now() - cardSince)}.`, `${who} has waited on you for ${ago(Date.now() - cardSince)}.`, `${who} is waiting on you.`)
|
|
703
|
+
: headline(`${who} is waiting on you.`),
|
|
704
|
+
sub: subLine(card.station ? `It is at the ${card.station} station.` : null, 'Approve or Reassign on its row.'),
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// 2. every login at its limit: nothing anywhere can be started, whatever
|
|
709
|
+
// is running. Said before the per-login branches because it is the whole
|
|
710
|
+
// board's state, not this login's.
|
|
711
|
+
if (walled.length === accounts.length && walled.length > 1) {
|
|
712
|
+
const first = [...walled].sort((x, y) => (x.limited_until || Infinity) - (y.limited_until || Infinity))[0]
|
|
713
|
+
return {
|
|
714
|
+
line: headline(`Every login is at its limit; ${accountLabel(first)} is back first.`, `Every login is at its limit.`),
|
|
715
|
+
sub: subLine(Number.isFinite(first.limited_until) ? `${accountLabel(first)} returns ${until(first.limited_until)}.` : null),
|
|
716
|
+
}
|
|
358
717
|
}
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
718
|
+
|
|
719
|
+
if (live.length) {
|
|
720
|
+
// 3. an account-scoped bucket binds, and this login has model rungs that
|
|
721
|
+
// therefore buy nothing. Only said where models are KNOWN: on a login
|
|
722
|
+
// with no model buckets there is no switch to warn anyone off.
|
|
723
|
+
if (b && b.scope === 'account' && knownModels(subject).length) {
|
|
724
|
+
const alt = openModels(subject)[0]
|
|
725
|
+
// the rung a hand-off off this login would ACTUALLY take, from the
|
|
726
|
+
// chooser's own answer for a terminal running here, and only the
|
|
727
|
+
// nearest open login when no live row has one. A login that is open is
|
|
728
|
+
// not the same fact as a rung that is eligible: a reserve, a cost gate
|
|
729
|
+
// or a `below N%` rung can rule one out, and the sentence that names it
|
|
730
|
+
// is the sentence the reader acts on.
|
|
731
|
+
const eligible = live.filter((s) => acctOf(s) === subject).map((s) => s.eligible_next).find(Boolean)
|
|
732
|
+
const next = eligible || openElsewhere[0]
|
|
733
|
+
return {
|
|
734
|
+
line: headline(`${accountLabel(subject)} has ${leftOf(b)}% left, shared by every model.`, `${accountLabel(subject)} has ${leftOf(b)}% left, for every model.`),
|
|
735
|
+
sub: subLine(
|
|
736
|
+
alt ? `Switching to ${alt} buys nothing.` : null,
|
|
737
|
+
next ? `Next off ${accountLabel(subject)}: ${eligible ? rungLabel(next) : accountLabel(next)}.` : 'Nothing else is open.',
|
|
738
|
+
staleClause(subject, b),
|
|
739
|
+
),
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// 4. a model bucket is walled while the account window is open: the one
|
|
744
|
+
// case where a same-login switch is the answer.
|
|
745
|
+
const out = acctState(subject) === 'walled' ? [] : walledModels(subject)
|
|
746
|
+
if (out.length) {
|
|
747
|
+
const wall = wallFor(subject, out[0])
|
|
748
|
+
const open = openModels(subject)[0]
|
|
749
|
+
const acct = accountBucket(subject)
|
|
750
|
+
return {
|
|
751
|
+
line: headline(
|
|
752
|
+
`${Model(out[0])} is out until ${until(wall.limited_until)}; ${open || 'no other model'} is open.`,
|
|
753
|
+
`${Model(out[0])} is out until ${until(wall.limited_until)}.`,
|
|
754
|
+
),
|
|
755
|
+
sub: subLine(
|
|
756
|
+
acct ? `${accountLabel(subject)} still has ${leftOf(acct)}% of ${windowPhrase(acct)}.` : null,
|
|
757
|
+
open ? `Hand off > ${subject.agent}/${open} keeps this terminal.` : null,
|
|
758
|
+
),
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// 5. a model bucket came back and a terminal is still on the rung Leg
|
|
763
|
+
// dropped it to. A wall Leg recorded whose clock has run out is the only
|
|
764
|
+
// evidence that a model returned, and a live row on this login running a
|
|
765
|
+
// DIFFERENT model is the only evidence anyone is still downshifted; with
|
|
766
|
+
// neither the sentence is never guessed at.
|
|
767
|
+
//
|
|
768
|
+
// Spec A.5 lists this row under "several logins carry work", where it can
|
|
769
|
+
// never fire: the two branches below return for any login that has a
|
|
770
|
+
// figure at all. It is a state CHANGE, and it outranks the two branches
|
|
771
|
+
// that restate a standing percentage (DEVIATIONS.md, step 3).
|
|
772
|
+
const back = knownModels(subject)
|
|
773
|
+
.filter((m) => { const w = subject.walls && subject.walls[m]; return w && Number.isFinite(w.limited_until) && w.limited_until * 1000 <= Date.now() && !wallFor(subject, m) })
|
|
774
|
+
.find((m) => live.some((s) => acctOf(s) === subject && s.model && s.model !== m))
|
|
775
|
+
if (back) {
|
|
776
|
+
const down = live.find((s) => acctOf(s) === subject && s.model && s.model !== back)
|
|
777
|
+
return {
|
|
778
|
+
line: headline(`${Model(back)} is back; ${rowName(down)} is still on ${down.model}.`, `${Model(back)} is back.`),
|
|
779
|
+
sub: subLine(`Leg climbs back at the next hand-off.`, `Back to ${back} on the row does it now.`),
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// 5.5. the burn rate (A.5 row 4): a time beats a percentage, because the
|
|
784
|
+
// decision is about the afternoon and not about the number. The rate
|
|
785
|
+
// rides each session's own `capacity` (binding(usage, model) in
|
|
786
|
+
// src/server.mjs carries burn()); the accounts payload is buckets, not
|
|
787
|
+
// rates, so a live row bound to the SAME bucket as the strip's figure is
|
|
788
|
+
// where the board reads it. A guest payload has no capacity at all, so a
|
|
789
|
+
// guest board never prints a time, which is the rule the strip follows
|
|
790
|
+
// already. Placed under the model-came-back branch and above the two
|
|
791
|
+
// standing-percentage branches: a state CHANGE still outranks a figure.
|
|
792
|
+
const rate = b
|
|
793
|
+
? live.map((s) => ((acctOf(s) === subject && s.capacity && s.capacity.kind === b.kind && (s.capacity.model || null) === (b.model || null)) ? forecastOf(s.capacity) : null)).find(Boolean) || null
|
|
794
|
+
: null
|
|
795
|
+
if (rate) {
|
|
796
|
+
const who = b.model ? Model(b.model) : accountLabel(subject)
|
|
797
|
+
// which other models this login has published a bucket or a wall for:
|
|
798
|
+
// said because a Fable figure is NOT the login's figure, and the reader
|
|
799
|
+
// who takes it for one plans the wrong afternoon. A model nobody has
|
|
800
|
+
// seen is never named, so a login with one bucket says nothing here.
|
|
801
|
+
const others = b.model ? knownModels(subject).filter((m) => m !== b.model).map(Model) : []
|
|
802
|
+
return {
|
|
803
|
+
line: headline(`About ${burnPhrase(rate.seconds_left)} of ${who} left.`, `About ${burnPhrase(rate.seconds_left)} left.`),
|
|
804
|
+
sub: subLine(
|
|
805
|
+
`${burnVolume(rate, 'From')}.`,
|
|
806
|
+
b.model
|
|
807
|
+
? (others.length ? `${andList(others)} ${others.length === 1 ? 'has its own bucket' : 'have their own buckets'}.` : null)
|
|
808
|
+
: 'Shared by every model.',
|
|
809
|
+
staleClause(subject, b),
|
|
810
|
+
),
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// 6. one login carries every live terminal: one login, one point of
|
|
815
|
+
// failure, and that is what the sentence says.
|
|
816
|
+
if (carrying.length === 1 && b) {
|
|
817
|
+
const who = b.model ? Model(b.model) : accountLabel(subject)
|
|
818
|
+
return {
|
|
819
|
+
line: headline(
|
|
820
|
+
`${who} is at ${Math.round(b.percent)}% of ${windowPhrase(b)}, the only login open.`,
|
|
821
|
+
`${who} is at ${Math.round(b.percent)}% of ${windowPhrase(b)}.`,
|
|
822
|
+
),
|
|
823
|
+
sub: subLine(staleClause(subject, b), otherWalls()),
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// 7. several logins carry work: name the one closest to a wall, and the
|
|
828
|
+
// volume, then put the next login's figure in the sub.
|
|
829
|
+
if (b) {
|
|
830
|
+
const second = other(subject).map(figure).filter(Boolean)[0]
|
|
831
|
+
return {
|
|
832
|
+
line: headline(
|
|
833
|
+
`${accountLabel(subject)} has ${leftOf(b)}% left, and ${live.length} terminal${live.length === 1 ? ' is' : 's are'} working.`,
|
|
834
|
+
`${accountLabel(subject)} has ${leftOf(b)}% left.`,
|
|
835
|
+
),
|
|
836
|
+
sub: subLine(second, staleClause(subject, b), otherWalls()),
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// live, and not one login has published a figure. Never a guess.
|
|
841
|
+
return {
|
|
842
|
+
line: headline(`${live.length} terminal${live.length === 1 ? ' is' : 's are'} working, and no login has a figure.`, `${live.length} terminal${live.length === 1 ? ' is' : 's are'} working.`),
|
|
843
|
+
sub: subLine(otherWalls(), 'No login has reported a usage figure yet.'),
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
// 8 to 11. nothing is running.
|
|
848
|
+
const bestOpen = openElsewhere.concat(acctState(subject) === 'walled' ? [] : [subject]).map((a) => ({ a, b: bindingOf(a) })).filter((x) => x.b).sort((x, y) => y.b.percent - x.b.percent)[0]
|
|
849
|
+
const openLine = bestOpen ? `${bestOpen.b.model ? Model(bestOpen.b.model) : accountLabel(bestOpen.a)} is at ${Math.round(bestOpen.b.percent)}% of ${windowPhrase(bestOpen.b)}.` : null
|
|
850
|
+
if (walled.length) {
|
|
851
|
+
const first = [...walled].sort((x, y) => (x.limited_until || Infinity) - (y.limited_until || Infinity))[0]
|
|
852
|
+
return {
|
|
853
|
+
line: headline(
|
|
854
|
+
`Nothing is running. ${accountLabel(first)} is back ${until(first.limited_until)}.`,
|
|
855
|
+
`Nothing is running. ${accountLabel(first)} is at its limit.`,
|
|
856
|
+
),
|
|
857
|
+
sub: subLine(openLine),
|
|
858
|
+
}
|
|
364
859
|
}
|
|
365
|
-
if (
|
|
366
|
-
return { line
|
|
860
|
+
if (bestOpen) return { line: headline(`Nothing is running. ${openLine}`, 'Nothing is running.'), sub: '' }
|
|
861
|
+
return { line: 'Nothing is running, and no login has a figure.', sub: '' }
|
|
367
862
|
}
|
|
368
863
|
|
|
864
|
+
// The login panels are behind one disclosure now, and whether it is open is
|
|
865
|
+
// the reader's decision, kept across reloads and shared with /floor, which
|
|
866
|
+
// puts the same panels behind the same button. strip.js owns the state and
|
|
867
|
+
// the key; these two names are what this file's init and its click binding
|
|
868
|
+
// reach for.
|
|
869
|
+
const renderCapacityToggle = () => strip().renderCapacityToggle()
|
|
870
|
+
const toggleCapacity = () => strip().toggleCapacity()
|
|
871
|
+
|
|
369
872
|
// 6.1.5, all eight states. The walled state is an ADDITIONAL state of the
|
|
370
873
|
// rail, never a replacement for it: both percentages, both reset times and
|
|
371
874
|
// both rails stay on screen while the account is at its wall, and R4 grows.
|
|
@@ -375,11 +878,14 @@
|
|
|
375
878
|
box.textContent = ''
|
|
376
879
|
const list = accounts || []
|
|
377
880
|
|
|
378
|
-
const { line, sub } = verdictLines(list, view ? view.sessions : [])
|
|
881
|
+
const { line, sub } = verdictLines(list, view ? view.sessions : [], view ? view.cards_waiting : null)
|
|
379
882
|
const h1 = document.getElementById('verdict-line')
|
|
380
883
|
const p = document.getElementById('verdict-sub')
|
|
381
884
|
if (h1) h1.textContent = line
|
|
382
885
|
if (p) p.textContent = sub
|
|
886
|
+
// the strip is the only usage on screen until the reader opens the drawer
|
|
887
|
+
capacityStrip(list)
|
|
888
|
+
renderCapacityToggle()
|
|
383
889
|
|
|
384
890
|
if (!list.length) return
|
|
385
891
|
// Size encodes importance. The login the terminals are on gets the wide lit
|
|
@@ -454,12 +960,24 @@
|
|
|
454
960
|
? `${o.agent} (${tail(o.session_id)}) is changing ${files} in another checkout; whoever lands second rebases`
|
|
455
961
|
: `${o.agent} (${tail(o.session_id)}) is editing ${files} too` })
|
|
456
962
|
}
|
|
963
|
+
// A.6: a terminal parked at a permission prompt is waiting on a human, and
|
|
964
|
+
// at rank 3 it raises the row, sorts it and counts it in the region head
|
|
965
|
+
// through `needsYou` without a second predicate.
|
|
966
|
+
const waits = waitingNote(notifyWait(s))
|
|
967
|
+
if (waits) out.push(waits)
|
|
457
968
|
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
|
-
|
|
969
|
+
// the OTHER shape on `waiting`: the all-out countdown, where the child is
|
|
970
|
+
// already dead and nobody is waiting on a human
|
|
971
|
+
if (s.status === 'waiting' && resetWait(s)) out.push({ rank: 5, cat: 'waiting', tone: 'warn', text: `waiting for ${optionLabel(s.waiting)} at ${until(s.waiting.resets_at)}` })
|
|
459
972
|
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
973
|
// rank 8 does not restate the percentage: the head prints it in 30px type a
|
|
461
974
|
// few inches above. It names what the head does not carry, the fallback.
|
|
462
975
|
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'}` })
|
|
976
|
+
// rank 8.5: the bucket that binds THIS terminal is near its wall. It sorts
|
|
977
|
+
// under the account's own warning and above the activity fallback, because
|
|
978
|
+
// it is the more specific of the two: per model, not per login.
|
|
979
|
+
const cap = capacityNote(s, accountOf(s))
|
|
980
|
+
if (cap) out.push(cap)
|
|
463
981
|
// rank 10 is a genuine fallback, so a state nobody wrote a fixture for still
|
|
464
982
|
// gets a correct sentence rather than an empty slot.
|
|
465
983
|
out.push({ rank: 10, cat: 'activity', tone: 'muted', text: `turn ${s.turns || 0}${s.last_activity ? `, last activity ${clockAt(Date.parse(s.last_activity))}` : ''}` })
|
|
@@ -517,32 +1035,195 @@
|
|
|
517
1035
|
return el('div', { class: 'kv' }, rows)
|
|
518
1036
|
}
|
|
519
1037
|
|
|
520
|
-
|
|
1038
|
+
// ---- B.3 the fallback ladder, as an editable list -----------------------
|
|
1039
|
+
// A rung is a destination, not an agent: {agent, account, model, when, cost}.
|
|
1040
|
+
// Everything below is pure, so the round trip from a saved ladder to the
|
|
1041
|
+
// controls and back is testable without a DOM.
|
|
1042
|
+
function moveRung(ladder, index, delta) {
|
|
521
1043
|
const target = index + delta
|
|
522
|
-
if (target < 0 || target >=
|
|
523
|
-
const next = [...
|
|
1044
|
+
if (target < 0 || target >= ladder.length) return [...ladder]
|
|
1045
|
+
const next = [...ladder]
|
|
524
1046
|
;[next[index], next[target]] = [next[target], next[index]]
|
|
525
1047
|
return next
|
|
526
1048
|
}
|
|
1049
|
+
// `claude / opus`, `codex`, `claude/work / sonnet`: the login first, then the
|
|
1050
|
+
// model, and a model is never invented for an agent that published none.
|
|
1051
|
+
function rungLabel(r) {
|
|
1052
|
+
if (!r || !r.agent) return 'none'
|
|
1053
|
+
const login = r.account && r.account !== 'default' ? `${r.agent}/${r.account}` : r.agent
|
|
1054
|
+
return r.model ? `${login} / ${r.model}` : login
|
|
1055
|
+
}
|
|
1056
|
+
const rungKey = (r) => `${r.agent}--${r.account || 'default'}--${r.model || ''}`
|
|
1057
|
+
// What a rung spends, in words. `plan` is the subscription already paid for,
|
|
1058
|
+
// which is why it is the only cost that reads as nothing extra.
|
|
1059
|
+
const COST_WORDS = { free: 'free', plan: 'on the plan', credits: 'spends usage credits', metered: 'spends metered credits' }
|
|
1060
|
+
function costWord(cost) { return COST_WORDS[cost] || COST_WORDS.plan }
|
|
1061
|
+
|
|
1062
|
+
// B.6, one option in the Hand off picker. Three fields, always in this order:
|
|
1063
|
+
// the rung, what taking it does to the conversation, and what it costs you
|
|
1064
|
+
// right now. A rung that cannot be taken carries the SERVER'S reason
|
|
1065
|
+
// verbatim, never a board paraphrase: that sentence is the one the chooser
|
|
1066
|
+
// itself would print in the ledger, and two wordings for one refusal is how a
|
|
1067
|
+
// greyed row starts lying. The separator is a middot because a browser
|
|
1068
|
+
// collapses runs of spaces inside an <option>.
|
|
1069
|
+
function handoffOptionText(t) {
|
|
1070
|
+
const mode = t.keeps_conversation ? 'same terminal, keeps the conversation' : 'new agent, from the bundle'
|
|
1071
|
+
let state
|
|
1072
|
+
if (!t.available) state = `${t.reason || 'not available right now'}${Number.isFinite(t.resets_at) ? ` until ${until(t.resets_at)}` : ''}`
|
|
1073
|
+
else if (t.reason) state = t.reason
|
|
1074
|
+
else state = ['credits', 'metered'].includes(t.cost) ? `ready, ${costWord(t.cost)}` : 'ready'
|
|
1075
|
+
return [rungLabel(t), mode, state].join(' · ')
|
|
1076
|
+
}
|
|
1077
|
+
// A destination is a rung, so it is remembered as one. The index is still the
|
|
1078
|
+
// option's value (an account name is not ours to parse), but an index is a
|
|
1079
|
+
// position in a list the poll rewrites, and the pick has to outlive that.
|
|
1080
|
+
function pickKey(t) { return t && t.agent ? `${t.agent}/${t.account || 'default'}/${t.model || ''}` : null }
|
|
1081
|
+
function pickIndex(targets, key) {
|
|
1082
|
+
if (!key) return ''
|
|
1083
|
+
const at = (targets || []).findIndex((t) => pickKey(t) === key)
|
|
1084
|
+
return at < 0 ? '' : String(at)
|
|
1085
|
+
}
|
|
1086
|
+
// `when` is one string on the record and two controls on screen.
|
|
1087
|
+
function whenKind(when) { return String(when || 'always').startsWith('below:') ? 'below' : (when === 'walled-only' ? 'walled-only' : 'always') }
|
|
1088
|
+
// The server's own range is 0 to 100 (WHEN_RE in src/preferences.mjs), and
|
|
1089
|
+
// what is stored is what is shown: a zero is a reading, so a rung saved as
|
|
1090
|
+
// `below:0` renders as 0 and carries its consequence (whenFlag) rather than
|
|
1091
|
+
// being redrawn as a 50 nobody chose. Only a `below:` with no number at all
|
|
1092
|
+
// falls back.
|
|
1093
|
+
function whenPct(when) {
|
|
1094
|
+
const n = Number(String(when || '').slice('below:'.length))
|
|
1095
|
+
return Number.isFinite(n) && n >= 0 && n <= 100 && String(when || '').slice('below:'.length).trim() !== '' ? n : 50
|
|
1096
|
+
}
|
|
1097
|
+
function whenString(kind, pct) {
|
|
1098
|
+
if (kind === 'walled-only') return 'walled-only'
|
|
1099
|
+
if (kind !== 'below') return 'always'
|
|
1100
|
+
const n = Math.max(0, Math.min(100, Math.round(Number(pct) || 0)))
|
|
1101
|
+
return `below:${n}`
|
|
1102
|
+
}
|
|
1103
|
+
// An empty box is not a percentage. Clearing the number to type a new one and
|
|
1104
|
+
// tabbing away used to store `below:1`, a rung that is never taken; the rung
|
|
1105
|
+
// goes back to `always`, which is the rule with no number in it.
|
|
1106
|
+
function whenFromBox(value) {
|
|
1107
|
+
const raw = String(value === null || value === undefined ? '' : value).trim()
|
|
1108
|
+
return raw === '' ? 'always' : whenString('below', raw)
|
|
1109
|
+
}
|
|
1110
|
+
// A stored number the editor would never produce is shown with what it does,
|
|
1111
|
+
// never rewritten: both ends of the server's range are legal and neither is
|
|
1112
|
+
// a percentage anybody means.
|
|
1113
|
+
function whenFlag(when) {
|
|
1114
|
+
if (whenKind(when) !== 'below') return null
|
|
1115
|
+
const n = whenPct(when)
|
|
1116
|
+
if (n === 0) return 'below 0% is never true: this rung is never taken'
|
|
1117
|
+
if (n === 100) return 'below 100% is always true: this rung is taken like always'
|
|
1118
|
+
return null
|
|
1119
|
+
}
|
|
1120
|
+
const WHEN_WORDS = [['always', 'always'], ['below', 'below N%'], ['walled-only', 'walled only']]
|
|
527
1121
|
|
|
528
|
-
|
|
1122
|
+
// The editor. Numbered rows, one select and one optional number per rung, and
|
|
1123
|
+
// three buttons that survive a rebuild by data-focus-key the way the order
|
|
1124
|
+
// editor's did. No drag: a list this short is faster with two buttons, and a
|
|
1125
|
+
// drag has no keyboard.
|
|
1126
|
+
function ladderRows(ladder, onChange, scope) {
|
|
529
1127
|
const box = el('div', {})
|
|
530
|
-
|
|
531
|
-
const
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
})
|
|
537
|
-
|
|
538
|
-
const
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
1128
|
+
ladder.forEach((r, index) => {
|
|
1129
|
+
const key = rungKey(r)
|
|
1130
|
+
const label = rungLabel(r)
|
|
1131
|
+
const row = el('div', { class: 'ladder-row' })
|
|
1132
|
+
row.appendChild(el('span', { class: 'ladder-num' }, [`${index + 1}.`]))
|
|
1133
|
+
row.appendChild(el('span', { class: `dot id-${idOf(r.agent)}`, 'aria-hidden': 'true' }))
|
|
1134
|
+
row.appendChild(el('span', { class: `ladder-name chip-id-${idOf(r.agent)}` }, [label]))
|
|
1135
|
+
|
|
1136
|
+
const kind = whenKind(r.when)
|
|
1137
|
+
const whenId = `ladder-when-${scope}-${key}`
|
|
1138
|
+
const when = el('select', { id: whenId, class: 'ladder-when', 'aria-label': `When to take ${label}`, 'data-focus-key': `ladder:${scope}:${key}:when` })
|
|
1139
|
+
for (const [value, text] of WHEN_WORDS) {
|
|
1140
|
+
const opt = el('option', { value }, [text])
|
|
1141
|
+
if (value === kind) opt.setAttribute('selected', 'selected')
|
|
1142
|
+
when.appendChild(opt)
|
|
1143
|
+
}
|
|
1144
|
+
when.value = kind
|
|
1145
|
+
when.addEventListener('change', () => onChange(ladder.map((x, i) => (i === index ? { ...x, when: whenString(when.value, whenPct(r.when)) } : x))))
|
|
1146
|
+
// one cell for the rule and its number, so the selects line up in a
|
|
1147
|
+
// column whether or not a rung carries a percentage
|
|
1148
|
+
const rule = el('span', { class: 'ladder-rule' }, [when])
|
|
1149
|
+
row.appendChild(rule)
|
|
1150
|
+
if (kind === 'below') {
|
|
1151
|
+
const pct = el('input', {
|
|
1152
|
+
type: 'number', min: '0', max: '100', class: 'ladder-pct', value: String(whenPct(r.when)),
|
|
1153
|
+
'aria-label': `Take ${label} only under this percent`, 'data-focus-key': `ladder:${scope}:${key}:pct`,
|
|
1154
|
+
})
|
|
1155
|
+
pct.addEventListener('change', () => onChange(ladder.map((x, i) => (i === index ? { ...x, when: whenFromBox(pct.value) } : x))))
|
|
1156
|
+
rule.appendChild(pct)
|
|
1157
|
+
rule.appendChild(el('span', { class: 'ladder-cost' }, ['%']))
|
|
1158
|
+
const flag = whenFlag(r.when)
|
|
1159
|
+
if (flag) rule.appendChild(el('span', { class: 'ladder-flag tone-warn' }, [flag]))
|
|
1160
|
+
}
|
|
1161
|
+
row.appendChild(el('span', { class: 'ladder-cost ladder-costcol' }, [costWord(r.cost)]))
|
|
1162
|
+
|
|
1163
|
+
const up = el('button', { type: 'button', class: 'btn btn-secondary', 'aria-label': `Move ${label} earlier`, disabled: index === 0 ? '' : null, 'data-focus-key': `ladder:${scope}:${key}:up` }, ['Up'])
|
|
1164
|
+
const down = el('button', { type: 'button', class: 'btn btn-secondary', 'aria-label': `Move ${label} later`, disabled: index === ladder.length - 1 ? '' : null, 'data-focus-key': `ladder:${scope}:${key}:down` }, ['Down'])
|
|
1165
|
+
const drop = el('button', { type: 'button', class: 'btn btn-secondary', 'aria-label': `Remove ${label} from the ladder`, disabled: ladder.length < 2 ? '' : null, 'data-focus-key': `ladder:${scope}:${key}:remove` }, ['Remove'])
|
|
1166
|
+
up.addEventListener('click', () => onChange(moveRung(ladder, index, -1)))
|
|
1167
|
+
down.addEventListener('click', () => onChange(moveRung(ladder, index, 1)))
|
|
1168
|
+
drop.addEventListener('click', () => onChange(ladder.filter((_, i) => i !== index)))
|
|
1169
|
+
row.append(up, down, drop)
|
|
1170
|
+
box.appendChild(row)
|
|
542
1171
|
})
|
|
543
1172
|
return box
|
|
544
1173
|
}
|
|
545
1174
|
|
|
1175
|
+
// `[+ Add a rung]`: an agent and one of its models, or `default` for the
|
|
1176
|
+
// model the CLI picks itself. An agent Leg knows no model names for offers
|
|
1177
|
+
// `default` alone rather than a guess.
|
|
1178
|
+
// A dead control is worse than none: pressing Add on a rung the ladder
|
|
1179
|
+
// already carries used to do nothing at all, with no row, no sentence and no
|
|
1180
|
+
// hover reason, so the reader pressed it again. It names the rung it found
|
|
1181
|
+
// and where it already is.
|
|
1182
|
+
function duplicateRung(ladder, rung) {
|
|
1183
|
+
const at = (ladder || []).findIndex((r) => rungKey(r) === rungKey(rung))
|
|
1184
|
+
return at < 0 ? null : `${rungLabel(rung)} is already rung ${at + 1}.`
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
// The models this MACHINE publishes for an agent, from /api/models
|
|
1188
|
+
// (src/models.mjs), which board.js fetches once and parks on `state.models`.
|
|
1189
|
+
// MODEL_ALIASES is the fallback and stays the truth for claude, whose four
|
|
1190
|
+
// words are aliases Claude Code resolves rather than service-side ids; codex,
|
|
1191
|
+
// agy and grok have no entry there at all, so the rung editor could offer
|
|
1192
|
+
// them `default` and nothing else and a reader could not put gpt-5.6-luna on
|
|
1193
|
+
// a rung from this page.
|
|
1194
|
+
function catalogModels(agent) {
|
|
1195
|
+
const board = typeof window !== 'undefined' && window.legBoard ? window.legBoard : null
|
|
1196
|
+
const live = board && board.models ? board.models[agent] : null
|
|
1197
|
+
if (Array.isArray(live) && live.length) return live
|
|
1198
|
+
return (MODEL_ALIASES[agent] || []).map((id) => ({ id, label: id }))
|
|
1199
|
+
}
|
|
1200
|
+
function addRungRow(ladder, onChange, scope, onRefuse) {
|
|
1201
|
+
const row = el('div', { class: 'ladder-add' })
|
|
1202
|
+
const agentId = `ladder-add-agent-${scope}`
|
|
1203
|
+
const modelId = `ladder-add-model-${scope}`
|
|
1204
|
+
const agent = el('select', { id: agentId, class: 'ladder-when', 'aria-label': 'Agent for the new rung', 'data-focus-key': `ladder:${scope}:add:agent` })
|
|
1205
|
+
for (const a of LADDER_AGENTS) agent.appendChild(el('option', { value: a }, [a]))
|
|
1206
|
+
agent.value = LADDER_AGENTS[0]
|
|
1207
|
+
const model = el('select', { id: modelId, class: 'ladder-when', 'aria-label': 'Model for the new rung', 'data-focus-key': `ladder:${scope}:add:model` })
|
|
1208
|
+
const fillModels = () => {
|
|
1209
|
+
model.textContent = ''
|
|
1210
|
+
model.appendChild(el('option', { value: '' }, ['default']))
|
|
1211
|
+
for (const m of catalogModels(agent.value)) model.appendChild(el('option', { value: m.id }, [m.label || m.id]))
|
|
1212
|
+
model.value = ''
|
|
1213
|
+
}
|
|
1214
|
+
fillModels()
|
|
1215
|
+
agent.addEventListener('change', fillModels)
|
|
1216
|
+
const add = el('button', { type: 'button', class: 'btn btn-secondary', 'data-focus-key': `ladder:${scope}:add:go` }, ['+ Add a rung'])
|
|
1217
|
+
add.addEventListener('click', () => {
|
|
1218
|
+
const rung = { agent: agent.value, account: 'default', model: model.value || null, when: 'always', cost: agent.value === 'agy' ? 'free' : agent.value === 'grok' ? 'metered' : 'plan' }
|
|
1219
|
+
const already = duplicateRung(ladder, rung)
|
|
1220
|
+
if (already) { if (onRefuse) onRefuse(already); return }
|
|
1221
|
+
onChange([...ladder, rung])
|
|
1222
|
+
})
|
|
1223
|
+
row.append(el('label', { for: agentId }, ['Add']), agent, el('label', { for: modelId }, ['model']), model, add)
|
|
1224
|
+
return row
|
|
1225
|
+
}
|
|
1226
|
+
|
|
546
1227
|
// Every region on this page is wiped and rebuilt on a timer, so a control the
|
|
547
1228
|
// reader had tabbed to is a different element three seconds later and focus
|
|
548
1229
|
// lands back on <body>. Controls that survive a rebuild by identity carry a
|
|
@@ -583,17 +1264,152 @@
|
|
|
583
1264
|
// A rebuild clears any selection that spans it, so the timed re-sort stands
|
|
584
1265
|
// down while the reader is selecting a path or a sentence out of a panel. A
|
|
585
1266
|
// real data push still redraws: the words on screen win over the drag.
|
|
586
|
-
function
|
|
1267
|
+
function selectionInside(box) {
|
|
587
1268
|
const sel = typeof document.getSelection === 'function' ? document.getSelection() : null
|
|
588
1269
|
if (!sel || sel.isCollapsed || !String(sel).trim()) return false
|
|
1270
|
+
return Boolean(box && sel.anchorNode && typeof box.contains === 'function' && box.contains(sel.anchorNode))
|
|
1271
|
+
}
|
|
1272
|
+
function selectionInsideGrid() { return selectionInside(document.getElementById('session-grid')) }
|
|
1273
|
+
|
|
1274
|
+
// How long the expanded region may refuse to redraw itself. The grid's order
|
|
1275
|
+
// hold and this one answer different questions -- one is about rows moving
|
|
1276
|
+
// under the reader, the other about the region going stale -- so they have
|
|
1277
|
+
// their own bounds: a held selection may freeze the ORDER for as long as the
|
|
1278
|
+
// drag lasts, but it may not hide finished turns for more than twenty
|
|
1279
|
+
// seconds. See the paragraph in renderDrawer.
|
|
1280
|
+
const DRAWER_HOLD_MS = 20000
|
|
1281
|
+
let drawerHeldAt = 0
|
|
1282
|
+
function drawerStandsDown({ timed, selecting, confirming, heldForMs }) {
|
|
1283
|
+
// a redraw the reader asked for (show 40 more, Resume updates, a rung
|
|
1284
|
+
// moved) is never a surprise; only the poll's own redraw stands down
|
|
1285
|
+
if (!timed) return false
|
|
1286
|
+
if (confirming) return true
|
|
1287
|
+
if (!selecting) return false
|
|
1288
|
+
return heldForMs < DRAWER_HOLD_MS
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
// ---- the list holds still while the reader is inside it ------------------
|
|
1292
|
+
// Reported 2026-09-18: "the page is hard to interact with while a terminal is
|
|
1293
|
+
// running and the details are expanded, it keeps jumping around and knocking
|
|
1294
|
+
// me out of what I'm doing." Every push re-sorts (needs-you first), so a row
|
|
1295
|
+
// crossing that partition moves every row after it, and the expansion hanging
|
|
1296
|
+
// under one of them goes with it. The sort is right; running it under the
|
|
1297
|
+
// reader's cursor is not. While they are demonstrably inside the list the
|
|
1298
|
+
// previous order is held, and the new one is applied on the first render
|
|
1299
|
+
// after they come out.
|
|
1300
|
+
//
|
|
1301
|
+
// Two of the four reasons are unbounded and two are not. An expansion and a
|
|
1302
|
+
// live selection are the reader in the middle of something, and both end with
|
|
1303
|
+
// one click of theirs. A pointer resting on a row and a button still holding
|
|
1304
|
+
// focus after a click are states that last until the machine is touched
|
|
1305
|
+
// again, and an unbounded hold on either would freeze the needs-you sort for
|
|
1306
|
+
// the rest of the day, which is a worse bug than the one this fixes.
|
|
1307
|
+
//
|
|
1308
|
+
// 2026-09-18, second pass: an expansion is not one of the bounded two. A
|
|
1309
|
+
// reader leaves one open and walks away -- that IS the resting state of a
|
|
1310
|
+
// dashboard -- and while it was open the needs-you sort never ran again. A
|
|
1311
|
+
// terminal that hit a permission prompt went urgent, the tab badge counted
|
|
1312
|
+
// it, and the row it was on stayed at the bottom of the list for as long as
|
|
1313
|
+
// the expansion lived. Waiting hours to be shown a terminal that is waiting
|
|
1314
|
+
// on you is a worse bug than a row moving. So `expanded` takes the same
|
|
1315
|
+
// ORDER_HOLD_MS release hovering and focus have: the reader gets 30 seconds
|
|
1316
|
+
// of stillness from each push, and then the sort is allowed to run.
|
|
1317
|
+
//
|
|
1318
|
+
// The expanded row itself is not pinned to its slot, and deliberately so:
|
|
1319
|
+
// the row the reader is waiting on is usually BELOW the expansion (that is
|
|
1320
|
+
// the reported case), so a pin would leave it exactly where it was buried.
|
|
1321
|
+
// The expansion moving down the LIST does not move it on the SCREEN --
|
|
1322
|
+
// holdAnchor puts its viewport offset back after the rebuild, which is the
|
|
1323
|
+
// thing the reader actually perceives, and the probe asserts both halves.
|
|
1324
|
+
//
|
|
1325
|
+
// A live selection keeps its unbounded hold. A drag really does die on a
|
|
1326
|
+
// rebuild, and it ends the moment the reader lets go.
|
|
1327
|
+
const ORDER_HOLD_MS = 30000
|
|
1328
|
+
function holdsOrder({ expanded, selecting, hovering, focusInside, divergedForMs }) {
|
|
1329
|
+
if (selecting) return true
|
|
1330
|
+
if (!expanded && !hovering && !focusInside) return false
|
|
1331
|
+
return !(divergedForMs >= ORDER_HOLD_MS)
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
// The ids to draw, in order: the sort, or the order they had when the reader
|
|
1335
|
+
// went in. A terminal that started while the order was held joins at the end,
|
|
1336
|
+
// where it cannot move anything on screen; one that left simply drops out.
|
|
1337
|
+
function listOrder(natural, held, hold) {
|
|
1338
|
+
if (!hold || !held || !held.length) return natural
|
|
1339
|
+
const at = new Map(held.map((id, i) => [id, i]))
|
|
1340
|
+
const known = natural.filter((id) => at.has(id)).sort((a, b) => at.get(a) - at.get(b))
|
|
1341
|
+
return known.concat(natural.filter((id) => !at.has(id)))
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
function readerIsInTheList() {
|
|
589
1345
|
const grid = document.getElementById('session-grid')
|
|
590
|
-
|
|
1346
|
+
const region = detailRegion()
|
|
1347
|
+
// a region that is hidden is one the reader has just closed: the focus
|
|
1348
|
+
// still sitting on its Close button is not a reason to hold the order
|
|
1349
|
+
const open = region && !region.hidden ? region : null
|
|
1350
|
+
const node = document.activeElement
|
|
1351
|
+
const has = (box) => Boolean(box && node && node !== document.body && typeof box.contains === 'function' && box.contains(node))
|
|
1352
|
+
let hovering = false
|
|
1353
|
+
// :hover is the pointer's own position, which no event listener has to be
|
|
1354
|
+
// kept in sync with. A DOM that cannot answer it holds nothing.
|
|
1355
|
+
try { hovering = Boolean(document.querySelector('#session-grid .term:hover, #session-drawer:hover')) } catch { hovering = false }
|
|
1356
|
+
return holdsOrder({
|
|
1357
|
+
expanded: Boolean(drawer.id),
|
|
1358
|
+
selecting: selectionInside(grid) || selectionInside(open),
|
|
1359
|
+
hovering,
|
|
1360
|
+
focusInside: has(grid) || has(open),
|
|
1361
|
+
divergedForMs: orderDivergedAt ? Date.now() - orderDivergedAt : 0,
|
|
1362
|
+
})
|
|
591
1363
|
}
|
|
592
1364
|
|
|
593
1365
|
// Absolute priority, not a rotation: the saved list decides, minus the agent
|
|
594
1366
|
// already running here, so an agent placed last stays last.
|
|
595
|
-
|
|
596
|
-
|
|
1367
|
+
// The rungs below the one a terminal is on: what it would try next, in order.
|
|
1368
|
+
function rungsAfter(current, ladder) {
|
|
1369
|
+
return ladder.filter((r) => !(r.agent === current.agent && (r.account || 'default') === (current.account || 'default') && (r.model || null) === (current.model || null)))
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
// B.6 `Back to fable`. Two conditions, both of which must be KNOWN true from
|
|
1373
|
+
// data this page already has: the row is running a model below the top rung
|
|
1374
|
+
// of its own login's ladder, and that top rung is open. "Open" means no
|
|
1375
|
+
// active wall on the model, no wall on the login, and either no bucket for it
|
|
1376
|
+
// at all or a bucket under 100. A login this board has no record for is not
|
|
1377
|
+
// "open", it is unknown, and an unknown destination gets no button: the whole
|
|
1378
|
+
// point of the control is that it will work when pressed.
|
|
1379
|
+
function topRungFor(s) {
|
|
1380
|
+
const ladder = Array.isArray(s && s.handoff_ladder) ? s.handoff_ladder : []
|
|
1381
|
+
return ladder.find((r) => r.agent === s.agent && (r.account || 'default') === (s.account || 'default')) || null
|
|
1382
|
+
}
|
|
1383
|
+
function climbTarget(s, accounts) {
|
|
1384
|
+
if (!s || !s.active || !s.model || s.hidden) return null
|
|
1385
|
+
const top = topRungFor(s)
|
|
1386
|
+
if (!top || !top.model || top.model === s.model) return null
|
|
1387
|
+
const names = MODEL_ALIASES[s.agent] || []
|
|
1388
|
+
const best = names.indexOf(top.model)
|
|
1389
|
+
const here = names.indexOf(s.model)
|
|
1390
|
+
if (best < 0 || here < 0 || here <= best) return null
|
|
1391
|
+
const acct = (accounts || []).find((x) => x.agent === s.agent && (x.account || 'default') === (s.account || 'default'))
|
|
1392
|
+
if (!acct || acctState(acct) === 'walled' || wallFor(acct, top.model)) return null
|
|
1393
|
+
const b = modelBucket(acct, top.model)
|
|
1394
|
+
if (b && b.percent >= 100) return null
|
|
1395
|
+
return top
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
// B.5: the one sentence beside the checkbox, and the one that replaces it on
|
|
1399
|
+
// a login whose credits are off, where there is nothing to spend and so no
|
|
1400
|
+
// decision to offer. A dead control is worse than none.
|
|
1401
|
+
const MAY_SPEND_SENTENCE = 'A rung that spends usage credits or metered balance may be taken by an automatic hand-off.'
|
|
1402
|
+
const NO_CREDITS_SENTENCE = 'Usage credits are off, so there is nothing to spend through the wall.'
|
|
1403
|
+
// B.7, printed under the choice because it is not one: killing a working
|
|
1404
|
+
// agent to save budget loses the turn.
|
|
1405
|
+
const CLIMB_RULE = 'Leg never interrupts a running turn to climb.'
|
|
1406
|
+
const CLIMB_WORDS = {
|
|
1407
|
+
'next-handoff': 'Leg climbs back to the top rung at the next hand-off.',
|
|
1408
|
+
never: 'Stay on the lower rung until you press Back to fable.',
|
|
1409
|
+
}
|
|
1410
|
+
function creditsOff(v) {
|
|
1411
|
+
const a = ((v && v.accounts) || []).find((x) => x.agent === 'claude' && (x.account || 'default') === 'default')
|
|
1412
|
+
return Boolean(a && a.extra_usage && a.extra_usage.enabled === false)
|
|
597
1413
|
}
|
|
598
1414
|
|
|
599
1415
|
function renderDefaultOrder(v) {
|
|
@@ -601,19 +1417,93 @@
|
|
|
601
1417
|
if (!field) return
|
|
602
1418
|
field.hidden = !v.preferences
|
|
603
1419
|
if (!v.preferences) return
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
defaultEditor.
|
|
1420
|
+
const fresh = !defaultEditor.ladder || (!defaultEditor.dirty && !defaultEditor.saving)
|
|
1421
|
+
if (fresh) {
|
|
1422
|
+
defaultEditor.ladder = (v.preferences.handoff_ladder || []).map((r) => ({ ...r }))
|
|
1423
|
+
defaultEditor.climb_back = v.preferences.climb_back || 'next-handoff'
|
|
1424
|
+
defaultEditor.may_spend = Boolean(v.preferences.may_spend)
|
|
1425
|
+
defaultEditor.reserve = { ...(v.preferences.reserve || {}) }
|
|
1426
|
+
}
|
|
1427
|
+
const touch = (next) => {
|
|
1428
|
+
if (next) defaultEditor.ladder = next
|
|
610
1429
|
defaultEditor.dirty = true
|
|
611
1430
|
defaultEditor.status = ''
|
|
612
1431
|
renderDefaultOrder(view)
|
|
613
|
-
}
|
|
1432
|
+
}
|
|
1433
|
+
const focus = takeFocus(document)
|
|
1434
|
+
|
|
1435
|
+
const list = document.getElementById('default-order-list')
|
|
1436
|
+
list.textContent = ''
|
|
1437
|
+
list.appendChild(ladderRows(defaultEditor.ladder, touch, 'default'))
|
|
1438
|
+
list.appendChild(addRungRow(defaultEditor.ladder, touch, 'default', (msg) => {
|
|
1439
|
+
defaultEditor.status = msg
|
|
1440
|
+
defaultEditor.statusClass = 'bad'
|
|
1441
|
+
renderDefaultOrder(view)
|
|
1442
|
+
}))
|
|
1443
|
+
|
|
1444
|
+
// may_spend
|
|
1445
|
+
const spend = document.getElementById('ladder-spend')
|
|
1446
|
+
if (spend) {
|
|
1447
|
+
spend.textContent = ''
|
|
1448
|
+
const box = el('input', { type: 'checkbox', id: 'ladder-may-spend', 'aria-label': 'Let an automatic hand-off spend', 'data-focus-key': 'ladder:default:may-spend' })
|
|
1449
|
+
if (defaultEditor.may_spend) box.setAttribute('checked', 'checked')
|
|
1450
|
+
box.checked = defaultEditor.may_spend
|
|
1451
|
+
box.addEventListener('change', () => { defaultEditor.may_spend = Boolean(box.checked); touch(null) })
|
|
1452
|
+
spend.appendChild(el('label', { class: 'notify-toggle', for: 'ladder-may-spend' }, [box, ' Let an automatic hand-off spend']))
|
|
1453
|
+
spend.appendChild(el('p', { class: 'field-help' }, [MAY_SPEND_SENTENCE]))
|
|
1454
|
+
// B.5: with credits off and `can_toggle` false there is nothing to turn
|
|
1455
|
+
// on from here, so this states the fact and offers no button. A control
|
|
1456
|
+
// that cannot do anything is worse than none.
|
|
1457
|
+
if (creditsOff(v)) spend.appendChild(el('p', { class: 'field-help' }, [NO_CREDITS_SENTENCE]))
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
// climb back
|
|
1461
|
+
const climb = document.getElementById('ladder-climb')
|
|
1462
|
+
if (climb) {
|
|
1463
|
+
climb.textContent = ''
|
|
1464
|
+
climb.appendChild(el('legend', {}, ['Climbing back']))
|
|
1465
|
+
for (const [value, sentence] of Object.entries(CLIMB_WORDS)) {
|
|
1466
|
+
const id = `ladder-climb-${value}`
|
|
1467
|
+
const radio = el('input', { type: 'radio', name: 'ladder-climb-back', id, value, 'aria-label': sentence, 'data-focus-key': `ladder:default:climb:${value}` })
|
|
1468
|
+
if (defaultEditor.climb_back === value) radio.setAttribute('checked', 'checked')
|
|
1469
|
+
radio.checked = defaultEditor.climb_back === value
|
|
1470
|
+
radio.addEventListener('change', () => { defaultEditor.climb_back = value; touch(null) })
|
|
1471
|
+
climb.appendChild(el('label', { class: 'notify-toggle', for: id }, [radio, ` ${sentence}`]))
|
|
1472
|
+
}
|
|
1473
|
+
climb.appendChild(el('p', { class: 'field-help' }, [CLIMB_RULE]))
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
// reserve, one number per login
|
|
1477
|
+
const reserve = document.getElementById('ladder-reserve')
|
|
1478
|
+
if (reserve) {
|
|
1479
|
+
reserve.textContent = ''
|
|
1480
|
+
const logins = []
|
|
1481
|
+
for (const r of defaultEditor.ladder) if (!logins.includes(r.agent)) logins.push(r.agent)
|
|
1482
|
+
for (const agent of logins) {
|
|
1483
|
+
const id = `ladder-reserve-${agent}`
|
|
1484
|
+
const n = el('input', {
|
|
1485
|
+
type: 'number', min: '0', max: '100', class: 'ladder-pct', id,
|
|
1486
|
+
value: Number.isFinite(Number(defaultEditor.reserve[agent])) ? String(defaultEditor.reserve[agent]) : '0',
|
|
1487
|
+
'aria-label': `Keep this percent of ${agent} for your own terminals`,
|
|
1488
|
+
'data-focus-key': `ladder:default:reserve:${agent}`,
|
|
1489
|
+
})
|
|
1490
|
+
n.addEventListener('change', () => {
|
|
1491
|
+
const pct = Math.max(0, Math.min(100, Math.round(Number(n.value) || 0)))
|
|
1492
|
+
if (pct > 0) defaultEditor.reserve[agent] = pct
|
|
1493
|
+
else delete defaultEditor.reserve[agent]
|
|
1494
|
+
touch(null)
|
|
1495
|
+
})
|
|
1496
|
+
reserve.appendChild(el('div', { class: 'ladder-row' }, [
|
|
1497
|
+
el('label', { for: id }, [`Keep`]), n,
|
|
1498
|
+
el('span', { class: 'ladder-cost' }, [`% of ${agent} for your own terminals`]),
|
|
1499
|
+
]))
|
|
1500
|
+
}
|
|
1501
|
+
reserve.appendChild(el('p', { class: 'field-help' }, ['0 keeps nothing back. An automatic hand-off skips a rung past the floor; a hand-off you press yourself still takes it, and the picker says so.']))
|
|
1502
|
+
}
|
|
1503
|
+
|
|
614
1504
|
const save = document.getElementById('default-order-save')
|
|
615
1505
|
save.disabled = defaultEditor.saving || !defaultEditor.dirty
|
|
616
|
-
save.textContent = defaultEditor.saving ? 'Saving…' : 'Save
|
|
1506
|
+
save.textContent = defaultEditor.saving ? 'Saving…' : 'Save ladder'
|
|
617
1507
|
const status = document.getElementById('default-order-status')
|
|
618
1508
|
status.textContent = defaultEditor.status
|
|
619
1509
|
status.className = `field-status ${defaultEditor.statusClass}`
|
|
@@ -625,13 +1515,22 @@
|
|
|
625
1515
|
defaultEditor.status = ''
|
|
626
1516
|
renderDefaultOrder(view)
|
|
627
1517
|
try {
|
|
628
|
-
const data = await api('/api/settings', {
|
|
1518
|
+
const data = await api('/api/settings', {
|
|
1519
|
+
method: 'PATCH',
|
|
1520
|
+
body: {
|
|
1521
|
+
handoff_ladder: defaultEditor.ladder,
|
|
1522
|
+
climb_back: defaultEditor.climb_back,
|
|
1523
|
+
may_spend: defaultEditor.may_spend,
|
|
1524
|
+
reserve: defaultEditor.reserve,
|
|
1525
|
+
},
|
|
1526
|
+
})
|
|
629
1527
|
if (view) view.preferences = data.preferences
|
|
630
|
-
defaultEditor.
|
|
1528
|
+
defaultEditor.ladder = (data.preferences.handoff_ladder || []).map((r) => ({ ...r }))
|
|
631
1529
|
defaultEditor.dirty = false
|
|
632
|
-
defaultEditor.status =
|
|
1530
|
+
defaultEditor.status = `Saved for new terminals. Order off the ladder: ${(data.preferences.handoff_order || []).join(', ')}.`
|
|
633
1531
|
defaultEditor.statusClass = 'ok'
|
|
634
1532
|
} catch (err) {
|
|
1533
|
+
// the server's own sentence, verbatim: it is the one that names the rung
|
|
635
1534
|
defaultEditor.status = err.message
|
|
636
1535
|
defaultEditor.statusClass = 'bad'
|
|
637
1536
|
} finally {
|
|
@@ -661,8 +1560,9 @@
|
|
|
661
1560
|
sysMessage('removed the Leg record; the worktree and the branch are kept', 'ok')
|
|
662
1561
|
} else {
|
|
663
1562
|
await api(`/api/sessions/${encodeURIComponent(id)}/${action}`, { method: 'POST', body })
|
|
664
|
-
if (action === 'handoff') actionNotes.set(id, { at: Date.now(), tone: 'warn', text: body && body.agent ? `hand-off to ${
|
|
1563
|
+
if (action === 'handoff') actionNotes.set(id, { at: Date.now(), tone: 'warn', text: body && body.agent ? `hand-off to ${rungLabel(body)} requested; this terminal switches agents in a few seconds` : 'hand-off requested; this terminal switches agents in a few seconds' })
|
|
665
1564
|
else if (action === 'end') actionNotes.set(id, { at: Date.now(), tone: 'warn', text: 'end requested; the agent stops after its current turn' })
|
|
1565
|
+
else if (action === 'end-as-card') actionNotes.set(id, { at: Date.now(), tone: 'ok', text: 'ended; a background card continues the task: in this checkout when the terminal had one of its own, else in a checkout of its own with the uncommitted work carried over. It is under Background, and Take over on it brings the work back to a terminal.' })
|
|
666
1566
|
else if (action === 'land/fix') actionNotes.set(id, { at: Date.now(), tone: 'ok', text: 'applied fix' })
|
|
667
1567
|
}
|
|
668
1568
|
refresh()
|
|
@@ -680,13 +1580,15 @@
|
|
|
680
1580
|
// `now: codex · then · claude`, a five-item list with two items called
|
|
681
1581
|
// "then". The agent names keep their identity colour inside it.
|
|
682
1582
|
const sequence = el('div', { class: 'chain-rail', 'aria-label': 'Terminal handoff sequence' })
|
|
683
|
-
const chain = el('span', { class: 'chip' }, ['now: ', el('span', { class: `chip-id-${idOf(s.agent)}` }, [
|
|
1583
|
+
const chain = el('span', { class: 'chip' }, ['now: ', el('span', { class: `chip-id-${idOf(s.agent)}` }, [rungLabel(s)])])
|
|
684
1584
|
// the fonts carry no arrow glyph, so the word does the arrow's job
|
|
685
|
-
for (const next of s.chain || []) chain.append(document.createTextNode(', then '), el('span', { class: `chip-id-${idOf(next.agent)}` }, [
|
|
1585
|
+
for (const next of s.chain || []) chain.append(document.createTextNode(', then '), el('span', { class: `chip-id-${idOf(next.agent)}` }, [rungLabel(next)]))
|
|
686
1586
|
sequence.appendChild(chain)
|
|
687
1587
|
wrap.appendChild(sequence)
|
|
688
|
-
|
|
689
|
-
|
|
1588
|
+
// the rung, not just the login: `claude / opus` and `claude / sonnet` are
|
|
1589
|
+
// two destinations and the sequence above already names both
|
|
1590
|
+
const preferred = s.preferred_next ? rungLabel(s.preferred_next) : 'none'
|
|
1591
|
+
const eligible = s.eligible_next ? rungLabel(s.eligible_next) : 'none'
|
|
690
1592
|
if (!s.handoff_availability_known) wrap.appendChild(el('p', { class: 'sentence tone-muted' }, [`preferred: ${preferred}, and current eligibility is unavailable for this older terminal`]))
|
|
691
1593
|
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.`]))
|
|
692
1594
|
else if (eligible !== preferred) wrap.appendChild(el('p', { class: 'sentence tone-muted' }, [`preferred: ${preferred}, first eligible now: ${eligible}`]))
|
|
@@ -702,17 +1604,39 @@
|
|
|
702
1604
|
const pick = el('div', { class: 'form-row' })
|
|
703
1605
|
const selectId = `handoff-to-${s.session_id}`
|
|
704
1606
|
pick.appendChild(el('label', { for: selectId }, ['Hand off now to']))
|
|
705
|
-
const select = el('select', { id: selectId })
|
|
1607
|
+
const select = el('select', { id: selectId, 'aria-label': 'Hand off now to' })
|
|
706
1608
|
select.appendChild(el('option', { value: '' }, ['the next option in the order']))
|
|
707
1609
|
targets.forEach((t, i) => {
|
|
708
|
-
const note = t.available ? '' : ` — ${t.reason}${Number.isFinite(t.resets_at) ? `, back ${until(t.resets_at)}` : ''}`
|
|
709
1610
|
// the index is the value: an account name is not ours to parse
|
|
710
|
-
select.appendChild(el('option', { value: String(i), disabled: t.available ? null : 'disabled' }, [
|
|
1611
|
+
select.appendChild(el('option', { value: String(i), disabled: t.available ? null : 'disabled' }, [handoffOptionText(t)]))
|
|
1612
|
+
})
|
|
1613
|
+
// The drawer rebuilds itself every 3 seconds, and a destination chosen
|
|
1614
|
+
// four seconds ago was silently back to "the next option in the order"
|
|
1615
|
+
// while the confirm row still said the rung's name. The pick is held on
|
|
1616
|
+
// `drawer`, beside turnCap and expanded, and it is keyed by the RUNG, not
|
|
1617
|
+
// by the index: the poll can reorder the rows under it.
|
|
1618
|
+
select.value = pickIndex(targets, drawer.pick)
|
|
1619
|
+
if (drawer.pick && select.value === '') drawer.pick = null
|
|
1620
|
+
select.addEventListener('change', () => {
|
|
1621
|
+
const at = select.value === '' ? null : targets[Number(select.value)]
|
|
1622
|
+
drawer.pick = at ? pickKey(at) : null
|
|
711
1623
|
})
|
|
712
1624
|
const go = el('button', { type: 'button', class: 'btn btn-secondary' }, ['Hand off'])
|
|
713
1625
|
go.addEventListener('click', () => {
|
|
714
1626
|
const t = select.value === '' ? null : targets[Number(select.value)]
|
|
715
|
-
|
|
1627
|
+
// the confirm row names the destination and says whether the
|
|
1628
|
+
// conversation survives, because those are the two things that differ
|
|
1629
|
+
// between one rung and the next and neither is guessable from the row
|
|
1630
|
+
pendingConfirm = {
|
|
1631
|
+
id: s.session_id,
|
|
1632
|
+
question: t
|
|
1633
|
+
? `Hands off to ${rungLabel(t)}. ${t.keeps_conversation ? 'Same terminal, and the conversation is kept.' : 'A new agent starts in this terminal, primed from the bundle.'}`
|
|
1634
|
+
: 'Hands off to the first open rung of this terminal\'s ladder. The current turn stops.',
|
|
1635
|
+
verb: 'Hand off',
|
|
1636
|
+
action: 'handoff',
|
|
1637
|
+
body: t ? { agent: t.agent, account: t.account, ...(t.model ? { model: t.model } : {}) } : null,
|
|
1638
|
+
}
|
|
1639
|
+
renderSessions(view)
|
|
716
1640
|
})
|
|
717
1641
|
pick.appendChild(el('div', { class: 'chain-rail' }, [select, go]))
|
|
718
1642
|
if (!targets.some((t) => t.available)) {
|
|
@@ -724,38 +1648,43 @@
|
|
|
724
1648
|
const editableNow = ['starting', 'running', 'warning', 'limit', 'waiting'].includes(s.status)
|
|
725
1649
|
if (!s.hidden && editableNow) {
|
|
726
1650
|
let state = sessionEditors.get(s.session_id)
|
|
727
|
-
|
|
1651
|
+
// this terminal's own ladder, else the machine default it would take on
|
|
1652
|
+
// its next launch. A terminal that cannot be edited in place still shows
|
|
1653
|
+
// the list it is about to inherit, so the save below means something.
|
|
1654
|
+
const source = (s.can_edit_handoff_order ? s.handoff_ladder : (view?.preferences?.handoff_ladder ?? s.handoff_ladder)) || []
|
|
1655
|
+
const copy = () => source.map((r) => ({ ...r }))
|
|
728
1656
|
if (!state) {
|
|
729
|
-
state = { open: false,
|
|
1657
|
+
state = { open: false, ladder: copy(), dirty: false, saving: false, status: '', statusClass: '' }
|
|
730
1658
|
sessionEditors.set(s.session_id, state)
|
|
731
|
-
} else if (!state.dirty && !state.saving) state.
|
|
732
|
-
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
|
|
1659
|
+
} else if (!state.dirty && !state.saving) state.ladder = copy()
|
|
1660
|
+
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 ladder editor' : 'Change the ladder'])
|
|
733
1661
|
change.addEventListener('click', () => { state.open = !state.open; renderDrawer() })
|
|
734
1662
|
wrap.appendChild(change)
|
|
735
1663
|
if (state.open) {
|
|
736
1664
|
const editor = el('div', { class: 'detail-section' })
|
|
737
1665
|
editor.appendChild(el('p', { class: 'blocker' }, [s.can_edit_handoff_order
|
|
738
|
-
? '
|
|
739
|
-
: 'This terminal started before
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
state.status =
|
|
1666
|
+
? 'Each rung is an agent, a login and a model. Leg walks down from the top and takes the first rung that is open; the rung this terminal is already on is skipped.'
|
|
1667
|
+
: 'This terminal started before ladder changes were available. Save this ladder as the default, then restart when ready.']))
|
|
1668
|
+
const touch = (next) => { state.ladder = next; state.dirty = true; state.status = ''; renderDrawer() }
|
|
1669
|
+
editor.appendChild(ladderRows(state.ladder, touch, s.session_id))
|
|
1670
|
+
editor.appendChild(addRungRow(state.ladder, touch, s.session_id, (msg) => {
|
|
1671
|
+
state.status = msg
|
|
1672
|
+
state.statusClass = 'bad'
|
|
744
1673
|
renderDrawer()
|
|
745
|
-
}
|
|
746
|
-
editor.appendChild(el('p', { class: 'blocker' }, [`Draft
|
|
1674
|
+
}))
|
|
1675
|
+
editor.appendChild(el('p', { class: 'blocker' }, [`Draft ladder after ${rungLabel(s)}: ${rungsAfter(s, state.ladder).map(rungLabel).join(', then ') || 'nothing left'}`]))
|
|
747
1676
|
const status = el('span', { class: `field-status ${state.statusClass}`, 'aria-live': 'polite' }, [state.status])
|
|
748
1677
|
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'])
|
|
749
1678
|
save.addEventListener('click', async () => {
|
|
750
1679
|
state.saving = true; state.status = ''; renderDrawer()
|
|
751
1680
|
try {
|
|
752
1681
|
if (s.can_edit_handoff_order) {
|
|
753
|
-
await api(`/api/sessions/${encodeURIComponent(s.session_id)}/handoff-order`, { method: 'POST', body: {
|
|
1682
|
+
await api(`/api/sessions/${encodeURIComponent(s.session_id)}/handoff-order`, { method: 'POST', body: { handoff_ladder: state.ladder } })
|
|
754
1683
|
state.status = 'Saved for this terminal.'
|
|
755
1684
|
} else {
|
|
756
|
-
const data = await api('/api/settings', { method: 'PATCH', body: {
|
|
1685
|
+
const data = await api('/api/settings', { method: 'PATCH', body: { handoff_ladder: state.ladder } })
|
|
757
1686
|
if (view) view.preferences = data.preferences
|
|
758
|
-
defaultEditor.
|
|
1687
|
+
defaultEditor.ladder = (data.preferences.handoff_ladder || []).map((r) => ({ ...r }))
|
|
759
1688
|
defaultEditor.dirty = false
|
|
760
1689
|
state.status = 'Saved as the default. Restart this terminal when ready.'
|
|
761
1690
|
}
|
|
@@ -763,6 +1692,7 @@
|
|
|
763
1692
|
state.statusClass = 'ok'
|
|
764
1693
|
await refresh()
|
|
765
1694
|
} catch (err) {
|
|
1695
|
+
// the 409 or the 400 in the server's own words
|
|
766
1696
|
state.status = err.message
|
|
767
1697
|
state.statusClass = 'bad'
|
|
768
1698
|
} finally {
|
|
@@ -799,6 +1729,13 @@
|
|
|
799
1729
|
statusMark(s.status, s.session_id, urgent ? 'waiting on you' : null),
|
|
800
1730
|
el('span', { class: 'term-where', title: s.cwd || null }, [`${s.repo_name || s.cwd || 'unknown repo'}${branch ? ` on ${branch}` : ''}`]),
|
|
801
1731
|
])
|
|
1732
|
+
// A.4 rows 7 to 9, in reading order and on the SAME line: what changed,
|
|
1733
|
+
// which model actually answered, and whether it has gone quiet. The model
|
|
1734
|
+
// token is the agent and the model it resolved to, never a default.
|
|
1735
|
+
for (const t of registerTokens(s)) {
|
|
1736
|
+
if (t.kind === 'model') register.appendChild(el('span', { class: `term-model chip-id-${idOf(s.agent)}` }, [t.text]))
|
|
1737
|
+
else register.appendChild(el('span', { class: `term-${t.kind}` }, [t.text]))
|
|
1738
|
+
}
|
|
802
1739
|
if (s.account !== 'default') register.appendChild(el('span', { class: 'chip' }, [s.account]))
|
|
803
1740
|
if (shared() && s.owner) register.appendChild(el('span', { class: 'chip' }, [isMine(s) ? `${s.owner}, you` : s.owner]))
|
|
804
1741
|
if (s.lineage && s.lineage.from) register.appendChild(el('span', { class: 'chip' }, [`from ${s.lineage.from}`]))
|
|
@@ -839,6 +1776,7 @@
|
|
|
839
1776
|
if (open) for (const n of rest) body.appendChild(el('p', { class: `sentence tone-${n.tone}` }, [n.text]))
|
|
840
1777
|
}
|
|
841
1778
|
const touched = s.files || []
|
|
1779
|
+
let files = null
|
|
842
1780
|
if (!s.hidden && touched.length) {
|
|
843
1781
|
// comma-separated text, not chips: six file names are a sentence, and a
|
|
844
1782
|
// file that is also in an overlap is named in that sentence anyway
|
|
@@ -849,7 +1787,37 @@
|
|
|
849
1787
|
line.appendChild(el('span', { class: `file${overlapFiles.has(f) ? ' is-overlap' : ''}`, title: f }, [fileLabel(f)]))
|
|
850
1788
|
})
|
|
851
1789
|
if (touched.length > 6) line.appendChild(document.createTextNode(`, and ${touched.length - 6} more`))
|
|
852
|
-
|
|
1790
|
+
files = line
|
|
1791
|
+
}
|
|
1792
|
+
// A.4 row 12, at the right of the files line: the bucket that will stop
|
|
1793
|
+
// THIS terminal, which is per model and so is a fact about the row. The
|
|
1794
|
+
// region head carries the share clause that stops anyone adding three
|
|
1795
|
+
// rows' figures together (A.7).
|
|
1796
|
+
const phrase = s.hidden ? null : capacityPhrase(s)
|
|
1797
|
+
// B.6: the climb is a link on the capacity line, not a fifth button in the
|
|
1798
|
+
// 2x2 grid. It belongs beside the figure that explains why the row was
|
|
1799
|
+
// dropped a rung in the first place, and the grid is the shipped shape.
|
|
1800
|
+
const top = climbTarget(s, view && view.accounts)
|
|
1801
|
+
let climb = null
|
|
1802
|
+
if (top) {
|
|
1803
|
+
const back = (s.handoff_targets || []).find((t) => t.agent === top.agent && t.account === top.account && t.model === top.model)
|
|
1804
|
+
climb = el('button', { type: 'button', class: 'btn btn-text term-climb', 'data-focus-key': `climb:${s.session_id}` }, [`Back to ${top.model}`])
|
|
1805
|
+
climb.addEventListener('click', () => {
|
|
1806
|
+
pendingConfirm = {
|
|
1807
|
+
id: s.session_id,
|
|
1808
|
+
question: `Hands off now. The current turn stops and ${top.model} continues from ${back && back.keeps_conversation ? 'the conversation' : 'the bundle'}.`,
|
|
1809
|
+
verb: `Back to ${top.model}`,
|
|
1810
|
+
action: 'handoff',
|
|
1811
|
+
body: { agent: top.agent, account: top.account, model: top.model },
|
|
1812
|
+
}
|
|
1813
|
+
renderSessions(view)
|
|
1814
|
+
})
|
|
1815
|
+
}
|
|
1816
|
+
if (files || phrase || climb) {
|
|
1817
|
+
body.appendChild(el('div', { class: 'term-meta' }, [
|
|
1818
|
+
files,
|
|
1819
|
+
phrase || climb ? el('span', { class: 'term-capacity' }, [phrase, climb]) : null,
|
|
1820
|
+
]))
|
|
853
1821
|
}
|
|
854
1822
|
row.appendChild(body)
|
|
855
1823
|
|
|
@@ -871,11 +1839,20 @@
|
|
|
871
1839
|
// TypeError going only to the console.
|
|
872
1840
|
const pending = pendingConfirm
|
|
873
1841
|
term.appendChild(row)
|
|
874
|
-
|
|
1842
|
+
const confirm = confirmRow(pending.question, pending.verb, (btn) => act(s.session_id, pending.action, btn, pending.body ?? null))
|
|
1843
|
+
// a second verb on the same row, never a fifth grid button: End grows
|
|
1844
|
+
// 'End, and keep going as a card' (spec C.4), the moment being 'I have
|
|
1845
|
+
// to leave, keep going'. It sits before Cancel and takes the snapshot too.
|
|
1846
|
+
if (pending.alt) {
|
|
1847
|
+
const more = el('button', { type: 'button', class: 'btn btn-secondary', title: pending.alt.title || null }, [pending.alt.verb])
|
|
1848
|
+
more.addEventListener('click', () => { pendingConfirm = null; act(s.session_id, pending.alt.action, more, null) })
|
|
1849
|
+
confirm.insertBefore(more, confirm.lastChild)
|
|
1850
|
+
}
|
|
1851
|
+
term.appendChild(confirm)
|
|
875
1852
|
return term
|
|
876
1853
|
}
|
|
877
1854
|
const actions = el('div', { class: 'term-actions' })
|
|
878
|
-
const ask = (question, verb, action) => () => { pendingConfirm = { id: s.session_id, question, verb, action }; renderSessions(view) }
|
|
1855
|
+
const ask = (question, verb, action, alt = null) => () => { pendingConfirm = { id: s.session_id, question, verb, action, alt }; renderSessions(view) }
|
|
879
1856
|
if (s.hidden) {
|
|
880
1857
|
if (s.active) {
|
|
881
1858
|
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'])
|
|
@@ -948,7 +1925,10 @@
|
|
|
948
1925
|
}
|
|
949
1926
|
if (s.active) {
|
|
950
1927
|
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'])
|
|
951
|
-
|
|
1928
|
+
// the same confirm row End and the picker already use. This stops the
|
|
1929
|
+
// current turn of a working agent, and the `h` key presses this button:
|
|
1930
|
+
// an action that costs a turn asks first, whichever hand pressed it.
|
|
1931
|
+
h.addEventListener('click', ask('Hands off to the first open rung of this terminal\'s ladder. The current turn stops.', 'Hand off', 'handoff'))
|
|
952
1932
|
actions.appendChild(h)
|
|
953
1933
|
}
|
|
954
1934
|
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'])
|
|
@@ -956,7 +1936,11 @@
|
|
|
956
1936
|
actions.appendChild(details)
|
|
957
1937
|
if (s.active) {
|
|
958
1938
|
const e = el('button', { type: 'button', class: 'btn btn-danger', 'data-focus-key': `end:${s.session_id}` }, ['End'])
|
|
959
|
-
|
|
1939
|
+
// the second verb only where a card can continue: a terminal outside a
|
|
1940
|
+
// git repository has no branch for a card to work on (the route says so
|
|
1941
|
+
// with a 409, and a control that can only fail is not offered)
|
|
1942
|
+
e.addEventListener('click', ask('End this terminal? The agent stops and the bundle is kept.', 'End', 'end',
|
|
1943
|
+
s.repo && canCards() ? { verb: 'End, and keep going as a card', action: 'end-as-card', title: 'write the bundle, hand this checkout to a background card that continues the task, and end this terminal' } : null))
|
|
960
1944
|
actions.appendChild(e)
|
|
961
1945
|
} else {
|
|
962
1946
|
const r = el('button', { type: 'button', class: 'btn btn-danger', 'data-focus-key': `remove:${s.session_id}` }, ['Remove'])
|
|
@@ -1013,9 +1997,15 @@
|
|
|
1013
1997
|
// The messages, the diffs and the timeline are one extra fetch per open
|
|
1014
1998
|
// terminal, so nothing here is requested until the region is open, and the
|
|
1015
1999
|
// poll stops when it is paused or the tab is in the background.
|
|
1016
|
-
const drawer = { id: null, paused: false, timer: null, detail: null, error: '', expanded: new Set(), diffs: new Map(), turnCap: 8, openedBy: 'prompt' }
|
|
2000
|
+
const drawer = { id: null, paused: false, timer: null, detail: null, error: '', expanded: new Set(), diffs: new Map(), turnCap: 8, openedBy: 'prompt', pick: null }
|
|
1017
2001
|
|
|
1018
2002
|
function drawerSession() { return view && view.sessions ? view.sessions.find((s) => s.session_id === drawer.id) : null }
|
|
2003
|
+
// the one control on this page whose value is a decision in flight
|
|
2004
|
+
function pickHasFocus() {
|
|
2005
|
+
const node = document.activeElement
|
|
2006
|
+
const id = node && typeof node.id === 'string' ? node.id : ''
|
|
2007
|
+
return id.startsWith('handoff-to-')
|
|
2008
|
+
}
|
|
1019
2009
|
// The region is MOVED under the panel it expands, so the reference is cached:
|
|
1020
2010
|
// once it has been moved into the sessions list, getElementById would stop
|
|
1021
2011
|
// finding it the moment that list is rebuilt.
|
|
@@ -1032,6 +2022,8 @@
|
|
|
1032
2022
|
drawer.error = ''
|
|
1033
2023
|
drawer.paused = false
|
|
1034
2024
|
drawer.turnCap = 8
|
|
2025
|
+
// a destination chosen on one terminal is not a destination on the next
|
|
2026
|
+
drawer.pick = null
|
|
1035
2027
|
drawer.expanded.clear()
|
|
1036
2028
|
drawer.diffs.clear()
|
|
1037
2029
|
const region = detailRegion()
|
|
@@ -1044,7 +2036,11 @@
|
|
|
1044
2036
|
renderDrawer()
|
|
1045
2037
|
loadDrawer()
|
|
1046
2038
|
if (drawer.timer) clearInterval(drawer.timer)
|
|
1047
|
-
|
|
2039
|
+
// and it stands down while the reader is inside the destination select:
|
|
2040
|
+
// rebuilding a <select> under an open option list closes it, so a list that
|
|
2041
|
+
// takes longer than three seconds to read could not be read at all. The
|
|
2042
|
+
// pick itself survives the rebuild by rung (pickIndex above).
|
|
2043
|
+
drawer.timer = setInterval(() => { if (!drawer.paused && !document.hidden && !pickHasFocus()) loadDrawer({ timed: true }) }, 3000)
|
|
1048
2044
|
document.getElementById('session-drawer-close')?.focus()
|
|
1049
2045
|
}
|
|
1050
2046
|
|
|
@@ -1062,7 +2058,7 @@
|
|
|
1062
2058
|
if (id) putFocus(document, { key: `${from === 'details' ? 'details' : 'prompt'}:${id}` })
|
|
1063
2059
|
}
|
|
1064
2060
|
|
|
1065
|
-
async function loadDrawer() {
|
|
2061
|
+
async function loadDrawer({ timed = false } = {}) {
|
|
1066
2062
|
if (!drawer.id) return
|
|
1067
2063
|
const id = drawer.id
|
|
1068
2064
|
try {
|
|
@@ -1074,7 +2070,7 @@
|
|
|
1074
2070
|
if (drawer.id !== id) return
|
|
1075
2071
|
drawer.error = err.message
|
|
1076
2072
|
}
|
|
1077
|
-
renderDrawer()
|
|
2073
|
+
renderDrawer({ timed })
|
|
1078
2074
|
}
|
|
1079
2075
|
|
|
1080
2076
|
function paintDiff(pre, d) {
|
|
@@ -1171,6 +2167,41 @@
|
|
|
1171
2167
|
}
|
|
1172
2168
|
}
|
|
1173
2169
|
|
|
2170
|
+
// The page's own scroll anchor. A box the reader is reading keeps its offset
|
|
2171
|
+
// in the VIEWPORT, not its offset in the document: everything above it is
|
|
2172
|
+
// rebuilt on a timer and may change height or order, and the browser's native
|
|
2173
|
+
// scroll anchoring does not survive a subtree being wiped and refilled. These
|
|
2174
|
+
// two are the page-level twin of takeScroll/putScroll, and they only ever run
|
|
2175
|
+
// for a region that is open and on screen.
|
|
2176
|
+
function anchorTop(box) {
|
|
2177
|
+
if (!box || box.hidden || typeof box.getBoundingClientRect !== 'function') return null
|
|
2178
|
+
if (typeof window.scrollBy !== 'function') return null
|
|
2179
|
+
return box.getBoundingClientRect().top
|
|
2180
|
+
}
|
|
2181
|
+
function holdAnchor(box, was) {
|
|
2182
|
+
if (was === null || was === undefined) return
|
|
2183
|
+
const now = anchorTop(box)
|
|
2184
|
+
if (now === null) return
|
|
2185
|
+
const moved = now - was
|
|
2186
|
+
// sub-pixel reflow is not a jump; a row that crossed the needs-you
|
|
2187
|
+
// partition above the reader is 200px of one
|
|
2188
|
+
if (Math.abs(moved) < 1) return
|
|
2189
|
+
window.scrollBy(0, moved)
|
|
2190
|
+
}
|
|
2191
|
+
// ...and only for a region that already HAD a position the reader gave it.
|
|
2192
|
+
// #session-drawer lives at the end of <body> until renderSessions moves it
|
|
2193
|
+
// under the panel it expands, and it is parked back there every time it
|
|
2194
|
+
// closes. On the render that opens an expansion, anchorTop therefore reads a
|
|
2195
|
+
// viewport top several hundred pixels below where the region is about to
|
|
2196
|
+
// land, and holdAnchor "restores" it by yanking the whole window up: a reader
|
|
2197
|
+
// scrolled to 400 was thrown to 0 and then dragged back to 803 by the focus
|
|
2198
|
+
// on Close, 403px of movement on the most common click on the board. A region
|
|
2199
|
+
// that is not in the list yet has nothing to put back.
|
|
2200
|
+
function anchorFor(region, grid) {
|
|
2201
|
+
if (!region || !grid || region.parentNode !== grid) return null
|
|
2202
|
+
return anchorTop(region)
|
|
2203
|
+
}
|
|
2204
|
+
|
|
1174
2205
|
// Whether .baton/RESUME.md still describes the repository a reader would find.
|
|
1175
2206
|
// The server recomputes this from git on every poll, so the line is a verdict
|
|
1176
2207
|
// about right now, not a timestamp the file remembered about itself.
|
|
@@ -1251,6 +2282,53 @@
|
|
|
1251
2282
|
return box
|
|
1252
2283
|
}
|
|
1253
2284
|
|
|
2285
|
+
// ---- the timeline ---------------------------------------------------------
|
|
2286
|
+
// A usage poll that says the same sentence every ten seconds is one fact, not
|
|
2287
|
+
// forty lines, and forty lines of it push everything that actually happened
|
|
2288
|
+
// off the top of a region the reader is trying to read. A run of events of
|
|
2289
|
+
// the same type whose summary repeats the previous one word for word, each
|
|
2290
|
+
// within a minute of the one before it, becomes one line carrying ×N. Only a
|
|
2291
|
+
// RUN collapses: two identical lines with something else between them are two
|
|
2292
|
+
// things that happened and are printed as two.
|
|
2293
|
+
const REPEAT_MS = 60000
|
|
2294
|
+
function collapseEvents(events) {
|
|
2295
|
+
const out = []
|
|
2296
|
+
for (const e of events || []) {
|
|
2297
|
+
const last = out[out.length - 1]
|
|
2298
|
+
const gap = last ? Date.parse(e.ts) - Date.parse(last.last_ts) : NaN
|
|
2299
|
+
if (last && last.type === e.type && (last.summary || '') === (e.summary || '') && gap >= 0 && gap < REPEAT_MS) {
|
|
2300
|
+
last.count += 1
|
|
2301
|
+
last.last_ts = e.ts
|
|
2302
|
+
continue
|
|
2303
|
+
}
|
|
2304
|
+
out.push({ ...e, count: 1, last_ts: e.ts })
|
|
2305
|
+
}
|
|
2306
|
+
return out
|
|
2307
|
+
}
|
|
2308
|
+
|
|
2309
|
+
// The timeline is newest first, so a new event arrives ABOVE everything the
|
|
2310
|
+
// reader is looking at and carrying its scrollTop across the rebuild moves
|
|
2311
|
+
// them down a line every time one lands. The line at the top edge of the box
|
|
2312
|
+
// is the anchor instead: it stays where it was and the new one appears above.
|
|
2313
|
+
function takeTimelineAnchor(box) {
|
|
2314
|
+
const node = box && typeof box.querySelector === 'function' ? box.querySelector('[data-scroll-key="timeline"]') : null
|
|
2315
|
+
if (!node || !node.scrollTop) return null
|
|
2316
|
+
for (const item of node.querySelectorAll('[data-event-key]')) {
|
|
2317
|
+
if (item.offsetTop >= node.scrollTop) return { key: item.getAttribute('data-event-key'), from: item.offsetTop - node.scrollTop }
|
|
2318
|
+
}
|
|
2319
|
+
return null
|
|
2320
|
+
}
|
|
2321
|
+
function putTimelineAnchor(box, at) {
|
|
2322
|
+
if (!at) return
|
|
2323
|
+
const node = box && typeof box.querySelector === 'function' ? box.querySelector('[data-scroll-key="timeline"]') : null
|
|
2324
|
+
if (!node) return
|
|
2325
|
+
const key = at.key.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
|
2326
|
+
let item = null
|
|
2327
|
+
try { item = node.querySelector(`[data-event-key="${key}"]`) } catch { return }
|
|
2328
|
+
if (!item) return
|
|
2329
|
+
node.scrollTop = Math.max(0, item.offsetTop - at.from)
|
|
2330
|
+
}
|
|
2331
|
+
|
|
1254
2332
|
function section(title, note, body) {
|
|
1255
2333
|
const s = el('section', { class: 'detail-section' }, [
|
|
1256
2334
|
el('h3', { class: 'detail-heading' }, [title, note ? el('span', { class: 'detail-sub' }, [note]) : null]),
|
|
@@ -1273,10 +2351,42 @@
|
|
|
1273
2351
|
return line
|
|
1274
2352
|
}
|
|
1275
2353
|
|
|
1276
|
-
function renderDrawer() {
|
|
2354
|
+
function renderDrawer({ timed = false } = {}) {
|
|
1277
2355
|
const box = document.getElementById('session-drawer-content')
|
|
1278
2356
|
if (!box) return
|
|
2357
|
+
// A rebuild destroys any selection that spans it, and this one runs every
|
|
2358
|
+
// three seconds: a reader dragging across a path, a session id or a
|
|
2359
|
+
// timeline sentence lost it before they reached the end of the word, every
|
|
2360
|
+
// time. The region stands down until they let go, the same contract the
|
|
2361
|
+
// grid already keeps for its own timed re-sort, and one click anywhere
|
|
2362
|
+
// resumes it. A confirm row is a question the reader is answering right
|
|
2363
|
+
// now: rebuilding the picker under it drops the click that answers it.
|
|
2364
|
+
// `timed` is the poll's redraw and nobody else's: a redraw the reader asked
|
|
2365
|
+
// for (show 40 more, Resume updates, a rung moved) is never a surprise and
|
|
2366
|
+
// is the default, so a new caller cannot silently inherit the stand-down.
|
|
2367
|
+
//
|
|
2368
|
+
// 2026-09-18, second pass: "until they let go" was not a bound. A
|
|
2369
|
+
// double-click leaves an uncollapsed selection behind, which is exactly the
|
|
2370
|
+
// gesture the paragraph above is written for, and a reader who picks a path
|
|
2371
|
+
// out of the region and keeps reading never lets go of anything. Twelve
|
|
2372
|
+
// turns finished and not one of them was drawn in sixty seconds, with
|
|
2373
|
+
// nothing on screen saying the region was stale. The stand-down is capped:
|
|
2374
|
+
// twenty seconds is longer than any drag and shorter than a reader's
|
|
2375
|
+
// patience for "what is it doing now". That redraw does cost the selection,
|
|
2376
|
+
// which is the price of being told the truth about the terminal.
|
|
2377
|
+
//
|
|
2378
|
+
// The confirm row half is narrowed to THIS terminal's question. A confirm
|
|
2379
|
+
// row on an unrelated row used to freeze this region too.
|
|
2380
|
+
const selecting = selectionInside(box)
|
|
2381
|
+
drawerHeldAt = selecting ? (drawerHeldAt || Date.now()) : 0
|
|
2382
|
+
if (drawerStandsDown({
|
|
2383
|
+
timed,
|
|
2384
|
+
selecting,
|
|
2385
|
+
confirming: Boolean(pendingConfirm && pendingConfirm.id === drawer.id),
|
|
2386
|
+
heldForMs: drawerHeldAt ? Date.now() - drawerHeldAt : 0,
|
|
2387
|
+
})) return
|
|
1279
2388
|
const inner = takeScroll(box)
|
|
2389
|
+
const onLine = takeTimelineAnchor(box)
|
|
1280
2390
|
const focus = takeFocus(box)
|
|
1281
2391
|
const s = drawerSession()
|
|
1282
2392
|
const d = drawer.detail
|
|
@@ -1338,20 +2448,27 @@
|
|
|
1338
2448
|
|
|
1339
2449
|
const timeline = el('div', { class: 'drawer-timeline', 'data-scroll-key': 'timeline' })
|
|
1340
2450
|
const events = (d && d.events) || []
|
|
1341
|
-
|
|
2451
|
+
const lines = collapseEvents(events)
|
|
2452
|
+
const shownLines = lines.slice(-40).reverse()
|
|
2453
|
+
for (const e of shownLines) {
|
|
1342
2454
|
// clockAt, not a slice of the ISO string: that printed UTC, in 24-hour
|
|
1343
2455
|
// with seconds, under a page that says `Times are local.` and beside a
|
|
1344
2456
|
// header on the same panel printing the same instant as 11:04 PM. The
|
|
1345
2457
|
// summary is a block, as board.js:754 builds the same row, or the kind
|
|
1346
2458
|
// word and the sentence render glued: `lostrunner pid 999002 is gone`.
|
|
1347
|
-
|
|
1348
|
-
|
|
2459
|
+
// data-event-key is the anchor the timeline's own scroll is held by when
|
|
2460
|
+
// a new line arrives above the one the reader is reading.
|
|
2461
|
+
timeline.appendChild(el('div', { class: 'turn timeline-item', 'data-event-key': `${e.ts}|${e.type}` }, [
|
|
2462
|
+
el('span', { class: 'mono turn-when' }, [clockAt(Date.parse(e.last_ts || e.ts))]),
|
|
1349
2463
|
el('span', { class: 'turn-role' }, [e.type]),
|
|
1350
|
-
el('p', { class: 'timeline-summary' }, [e.summary || '']),
|
|
2464
|
+
el('p', { class: 'timeline-summary' }, [e.summary || '', e.count > 1 ? el('span', { class: 'timeline-count', title: `this line was recorded ${e.count} times in a row` }, [`×${e.count}`]) : null]),
|
|
1351
2465
|
]))
|
|
1352
2466
|
}
|
|
1353
2467
|
if (!events.length) timeline.appendChild(el('p', { class: 'sentence tone-muted' }, [d ? 'nothing recorded yet' : 'reading the timeline']))
|
|
1354
|
-
|
|
2468
|
+
// G14: the cap names the volume, and the volume is EVENTS, not lines: a
|
|
2469
|
+
// collapsed line stands for every repeat it swallowed, so the two numbers
|
|
2470
|
+
// still add up against `events.length`.
|
|
2471
|
+
const shownEvents = shownLines.reduce((n, e) => n + (e.count || 1), 0)
|
|
1355
2472
|
const timelineBody = el('div', { class: 'detail-section' }, [timeline])
|
|
1356
2473
|
if (events.length > shownEvents) timelineBody.appendChild(capLine(shownEvents, events.length, 'events', null))
|
|
1357
2474
|
box.appendChild(section('Timeline', 'this terminal, newest first', timelineBody))
|
|
@@ -1364,6 +2481,9 @@
|
|
|
1364
2481
|
const harness = harnessSection(s, d)
|
|
1365
2482
|
if (harness) box.appendChild(section('Harness', 'the working environment this leg was given', harness))
|
|
1366
2483
|
putScroll(box, inner)
|
|
2484
|
+
// after putScroll, never instead of it: putScroll puts the box back where
|
|
2485
|
+
// it was and this corrects for the lines that arrived above the reader
|
|
2486
|
+
putTimelineAnchor(box, onLine)
|
|
1367
2487
|
putFocus(box, focus)
|
|
1368
2488
|
}
|
|
1369
2489
|
|
|
@@ -1384,7 +2504,22 @@
|
|
|
1384
2504
|
const verdict = !list.length ? 'nothing is running'
|
|
1385
2505
|
: waiting ? `${running} running, ${waiting} waiting on you`
|
|
1386
2506
|
: `${running} running, nothing is waiting on you`
|
|
1387
|
-
meta.textContent = `${verdict}${landed ? `, last landed ${clockAt(landed)}` : ''}`
|
|
2507
|
+
meta.textContent = `${verdict}${shareClause(list)}${landed ? `, last landed ${clockAt(landed)}` : ''}`
|
|
2508
|
+
}
|
|
2509
|
+
|
|
2510
|
+
// A.7: a fact true of every row is a property of the region and is said once,
|
|
2511
|
+
// here. Per-row usage is per MODEL, which is real; the reader who adds three
|
|
2512
|
+
// rows' figures together is stopped by this clause and by nothing else.
|
|
2513
|
+
function shareClause(list) {
|
|
2514
|
+
const groups = new Map()
|
|
2515
|
+
for (const s of list.filter((x) => x.active)) {
|
|
2516
|
+
const key = `${s.agent}/${s.account || 'default'}`
|
|
2517
|
+
groups.set(key, (groups.get(key) || 0) + 1)
|
|
2518
|
+
}
|
|
2519
|
+
const [key, n] = [...groups.entries()].sort((x, y) => y[1] - x[1])[0] || []
|
|
2520
|
+
if (!n || n < 2) return ''
|
|
2521
|
+
const label = key.endsWith('/default') ? key.slice(0, -'/default'.length) : key
|
|
2522
|
+
return `, ${n} share the ${label} login`
|
|
1388
2523
|
}
|
|
1389
2524
|
|
|
1390
2525
|
// A fact that is true of every terminal on the board is a property of the
|
|
@@ -1433,6 +2568,203 @@
|
|
|
1433
2568
|
for (const p of document.querySelectorAll('#session-grid .blocker')) p.classList.toggle('is-hoisted', Boolean(shared))
|
|
1434
2569
|
}
|
|
1435
2570
|
|
|
2571
|
+
// ---- A.4 row 18 and E: where a waiting terminal says so off-screen ------
|
|
2572
|
+
// The tab badge is always on and needs no permission: the title and the
|
|
2573
|
+
// favicon are the only surface a browser gives a tab nobody is looking at.
|
|
2574
|
+
// favicon.svg itself is never touched; the dot is a variant drawn inline.
|
|
2575
|
+
const FAVICON = '/favicon.svg'
|
|
2576
|
+
const FAVICON_DOT = `data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">\u{1F9BF}</text><circle cx="78" cy="22" r="20" fill="#E64343"/></svg>')}`
|
|
2577
|
+
function titleBadge(n) {
|
|
2578
|
+
const title = n > 0 ? `(${n}) Leg` : 'Leg'
|
|
2579
|
+
if (document.title !== title) document.title = title
|
|
2580
|
+
const link = document.querySelector('link[rel~="icon"]')
|
|
2581
|
+
if (!link) return
|
|
2582
|
+
const href = n > 0 ? FAVICON_DOT : FAVICON
|
|
2583
|
+
if (link.getAttribute('href') !== href) link.setAttribute('href', href)
|
|
2584
|
+
}
|
|
2585
|
+
|
|
2586
|
+
// A browser toast fires on the TRANSITION into needing a human, never on
|
|
2587
|
+
// every render and never on first paint: the same rule the status mark's
|
|
2588
|
+
// annunciation keeps, for the same reason. A reload is not a new event.
|
|
2589
|
+
let announced = null
|
|
2590
|
+
// An OS toast is for a transition the reader was not there for. Firing one
|
|
2591
|
+
// over the window they are watching announces a state they just caused and
|
|
2592
|
+
// have already read on the row (pressing Land and getting a bounce is the
|
|
2593
|
+
// common one). The tab badge still updates either way: it costs nothing and
|
|
2594
|
+
// asks for nothing. Both halves are needed, because a visible tab in an
|
|
2595
|
+
// unfocused window is a reader who is somewhere else.
|
|
2596
|
+
function readerIsWatching() {
|
|
2597
|
+
return document.visibilityState === 'visible' && typeof document.hasFocus === 'function' && document.hasFocus()
|
|
2598
|
+
}
|
|
2599
|
+
function announceWaiting(rows, prefs) {
|
|
2600
|
+
const ids = new Set(rows.map((s) => s.session_id))
|
|
2601
|
+
if (announced && !readerIsWatching() && prefs && prefs.notify_board && typeof Notification !== 'undefined' && Notification.permission === 'granted') {
|
|
2602
|
+
for (const s of rows) {
|
|
2603
|
+
if (announced.has(s.session_id)) continue
|
|
2604
|
+
const note = rankedNotes(s)[0]
|
|
2605
|
+
try { new Notification(`${rowName(s)} is waiting on you`, { body: note ? note.text : '', tag: s.session_id }) } catch { /* a browser that refuses the constructor is not a reason to stop rendering */ }
|
|
2606
|
+
}
|
|
2607
|
+
}
|
|
2608
|
+
announced = ids
|
|
2609
|
+
}
|
|
2610
|
+
|
|
2611
|
+
const NOT_SECURE = 'This page is not a secure context. Open the board at http://localhost:<port> to turn toasts on.'
|
|
2612
|
+
function secureSentence() {
|
|
2613
|
+
const port = (window.location && window.location.port) || ''
|
|
2614
|
+
return NOT_SECURE.replace('<port>', port || '4747')
|
|
2615
|
+
}
|
|
2616
|
+
// E, the notifications table. Two toggles and no third: the tab badge above
|
|
2617
|
+
// is always on, so there is nothing to decide about it. The board toggle
|
|
2618
|
+
// reads `window.isSecureContext` at RUNTIME, because whether 127.0.0.1
|
|
2619
|
+
// counts is the browser's answer and not one this file may assume.
|
|
2620
|
+
function renderNotifySettings(v) {
|
|
2621
|
+
const box = document.getElementById('notify-settings')
|
|
2622
|
+
if (!box) return
|
|
2623
|
+
const prefs = v && v.preferences
|
|
2624
|
+
box.hidden = !prefs
|
|
2625
|
+
if (!prefs) return
|
|
2626
|
+
const term = document.getElementById('notify-terminal')
|
|
2627
|
+
const brd = document.getElementById('notify-board')
|
|
2628
|
+
const help = document.getElementById('notify-board-help')
|
|
2629
|
+
if (term) term.checked = prefs.notify_terminal !== false
|
|
2630
|
+
const secure = Boolean(window.isSecureContext)
|
|
2631
|
+
if (brd) {
|
|
2632
|
+
brd.disabled = !secure
|
|
2633
|
+
brd.checked = secure && prefs.notify_board === true
|
|
2634
|
+
}
|
|
2635
|
+
if (help) {
|
|
2636
|
+
const state = typeof Notification === 'undefined' ? 'this browser has no notifications' : `permission: ${Notification.permission}`
|
|
2637
|
+
help.textContent = secure ? `The browser asks the first time you turn this on (${state}).` : secureSentence()
|
|
2638
|
+
}
|
|
2639
|
+
}
|
|
2640
|
+
async function saveNotify(patch) {
|
|
2641
|
+
const status = document.getElementById('notify-status')
|
|
2642
|
+
if (status) status.textContent = 'Saving…'
|
|
2643
|
+
try {
|
|
2644
|
+
const data = await api('/api/settings', { method: 'PATCH', body: patch })
|
|
2645
|
+
if (view && data && data.preferences) view.preferences = data.preferences
|
|
2646
|
+
if (status) status.textContent = 'Saved.'
|
|
2647
|
+
renderNotifySettings(view)
|
|
2648
|
+
} catch (err) {
|
|
2649
|
+
if (status) status.textContent = err.message
|
|
2650
|
+
renderNotifySettings(view)
|
|
2651
|
+
}
|
|
2652
|
+
}
|
|
2653
|
+
|
|
2654
|
+
// ---- D14: the keyboard map ----------------------------------------------
|
|
2655
|
+
// Every binding CLICKS a button that is already on the row, so no key is a
|
|
2656
|
+
// second way to do anything and nothing here can drift from the buttons.
|
|
2657
|
+
// `data-focus-key` is the same handle the focus-restore pass uses.
|
|
2658
|
+
const KEY_BUTTONS = [
|
|
2659
|
+
{ key: 'h', focus: 'handoff', button: 'Hand off now' },
|
|
2660
|
+
{ key: 'l', focus: 'land', button: 'Land' },
|
|
2661
|
+
{ key: 'd', focus: 'details', button: 'Details' },
|
|
2662
|
+
{ key: 'e', focus: 'end', button: 'End' },
|
|
2663
|
+
]
|
|
2664
|
+
const KEY_MOVES = [
|
|
2665
|
+
{ key: 'j', what: 'move the ring to the next terminal' },
|
|
2666
|
+
{ key: 'k', what: 'move the ring to the previous terminal' },
|
|
2667
|
+
{ key: '1 to 9', what: 'move the ring to that terminal' },
|
|
2668
|
+
{ key: '?', what: 'open and close this map' },
|
|
2669
|
+
{ key: 'Escape', what: 'close this map, cancel a confirm row, or close an expansion' },
|
|
2670
|
+
]
|
|
2671
|
+
// The ring is a TERMINAL, not a position. The grid re-sorts on every SSE push
|
|
2672
|
+
// and on the 15 second timer (needs-you rows first), so an index left the
|
|
2673
|
+
// ring painted on whichever row slid into that slot while the reader was
|
|
2674
|
+
// looking at their terminal, and the next key ended a session they never
|
|
2675
|
+
// chose. `data-session-id` is on every .term, and DOM focus is already
|
|
2676
|
+
// carried by identity through data-focus-key, so this makes the two agree.
|
|
2677
|
+
let ringId = null
|
|
2678
|
+
let keymapOpen = false
|
|
2679
|
+
function termRows() { return [...document.querySelectorAll('#session-grid .term')] }
|
|
2680
|
+
function ringIndex(list) {
|
|
2681
|
+
if (!ringId) return -1
|
|
2682
|
+
return (list || termRows()).findIndex((r) => r.getAttribute('data-session-id') === ringId)
|
|
2683
|
+
}
|
|
2684
|
+
function paintRing(list) {
|
|
2685
|
+
const rows = list || termRows()
|
|
2686
|
+
const at = ringIndex(rows)
|
|
2687
|
+
// a terminal that ended and moved to the ledger takes its ring with it,
|
|
2688
|
+
// rather than leaving it to be inherited by the row that took its place
|
|
2689
|
+
if (ringId && at < 0) ringId = null
|
|
2690
|
+
rows.forEach((r, i) => r.classList.toggle('is-focused', i === at))
|
|
2691
|
+
}
|
|
2692
|
+
// the ring moves focus to the row's first button, so a screen reader
|
|
2693
|
+
// announces the row it landed on rather than leaving the reader nowhere
|
|
2694
|
+
function moveRing(to) {
|
|
2695
|
+
const rows = termRows()
|
|
2696
|
+
if (!rows.length) return
|
|
2697
|
+
const ringAt = Math.max(0, Math.min(rows.length - 1, to))
|
|
2698
|
+
ringId = rows[ringAt].getAttribute('data-session-id')
|
|
2699
|
+
paintRing(rows)
|
|
2700
|
+
// the prompt button first: it is the row's first control and its label is
|
|
2701
|
+
// the prompt, so a screen reader announces WHICH terminal the ring landed
|
|
2702
|
+
// on rather than a bare `Land`. A row with no prompt (someone else's) falls
|
|
2703
|
+
// through to whatever control it does have.
|
|
2704
|
+
const btn = rows[ringAt].querySelector('.panel-prompt, .term-actions .btn, button')
|
|
2705
|
+
if (btn && btn.focus) btn.focus()
|
|
2706
|
+
}
|
|
2707
|
+
function pressOnRing(focusKey) {
|
|
2708
|
+
const rows = termRows()
|
|
2709
|
+
if (!rows.length) return
|
|
2710
|
+
const at = ringIndex(rows)
|
|
2711
|
+
// With no ring there is nothing painted, so a key that acted would act on
|
|
2712
|
+
// whichever row the needs-you sort put first, with no way for the reader to
|
|
2713
|
+
// see which one that was before the POST went out. The first press only
|
|
2714
|
+
// moves and paints the ring; the second acts.
|
|
2715
|
+
if (at < 0) { moveRing(0); return }
|
|
2716
|
+
const btn = rows[at].querySelector(`[data-focus-key^="${focusKey}:"]`)
|
|
2717
|
+
if (btn && !btn.disabled) btn.click()
|
|
2718
|
+
}
|
|
2719
|
+
function renderKeymap() {
|
|
2720
|
+
const box = document.getElementById('keymap')
|
|
2721
|
+
const list = document.getElementById('keymap-list')
|
|
2722
|
+
if (!box || !list) return
|
|
2723
|
+
box.hidden = !keymapOpen
|
|
2724
|
+
if (!keymapOpen) return
|
|
2725
|
+
list.textContent = ''
|
|
2726
|
+
for (const k of KEY_MOVES) {
|
|
2727
|
+
list.appendChild(el('dt', { class: 'keymap-key' }, [k.key]))
|
|
2728
|
+
list.appendChild(el('dd', { class: 'keymap-what' }, [k.what]))
|
|
2729
|
+
}
|
|
2730
|
+
for (const k of KEY_BUTTONS) {
|
|
2731
|
+
list.appendChild(el('dt', { class: 'keymap-key' }, [k.key]))
|
|
2732
|
+
list.appendChild(el('dd', { class: 'keymap-what' }, [`press ${k.button} on the terminal the ring is on`]))
|
|
2733
|
+
}
|
|
2734
|
+
}
|
|
2735
|
+
function toggleKeymap(open) {
|
|
2736
|
+
keymapOpen = open === undefined ? !keymapOpen : open
|
|
2737
|
+
renderKeymap()
|
|
2738
|
+
if (keymapOpen) { const c = document.getElementById('keymap-close'); if (c && c.focus) c.focus() }
|
|
2739
|
+
}
|
|
2740
|
+
// a key pressed into a field is text, never a command
|
|
2741
|
+
function isTyping(e) {
|
|
2742
|
+
const t = e.target
|
|
2743
|
+
const tag = t && t.tagName ? String(t.tagName).toLowerCase() : ''
|
|
2744
|
+
return tag === 'input' || tag === 'select' || tag === 'textarea' || Boolean(t && t.isContentEditable)
|
|
2745
|
+
}
|
|
2746
|
+
// A <dialog> opened with showModal() still sends its keydowns to document, so
|
|
2747
|
+
// `h` typed at the New card dialog reached a row the reader cannot see, and a
|
|
2748
|
+
// confirm row is a question that has not been answered yet. Both own the page
|
|
2749
|
+
// while they are up. Escape has its own branch above this handler, so every
|
|
2750
|
+
// one of them can still be dismissed.
|
|
2751
|
+
function boardBusy() {
|
|
2752
|
+
if (pendingConfirm) return true
|
|
2753
|
+
try { return Boolean(document.querySelector('dialog[open]')) } catch { return false }
|
|
2754
|
+
}
|
|
2755
|
+
function boardKey(e) {
|
|
2756
|
+
if (e.ctrlKey || e.metaKey || e.altKey || isTyping(e)) return
|
|
2757
|
+
if (boardBusy()) return
|
|
2758
|
+
if (e.key === '?') { toggleKeymap(); e.preventDefault(); return }
|
|
2759
|
+
// the map is a panel explaining these keys; reading it must not fire them
|
|
2760
|
+
if (keymapOpen) return
|
|
2761
|
+
if (e.key === 'j') { moveRing(ringIndex() + 1); e.preventDefault(); return }
|
|
2762
|
+
if (e.key === 'k') { moveRing(ringIndex() - 1); e.preventDefault(); return }
|
|
2763
|
+
if (/^[1-9]$/.test(e.key)) { moveRing(Number(e.key) - 1); e.preventDefault(); return }
|
|
2764
|
+
const hit = KEY_BUTTONS.find((k) => k.key === e.key)
|
|
2765
|
+
if (hit) { pressOnRing(hit.focus); e.preventDefault() }
|
|
2766
|
+
}
|
|
2767
|
+
|
|
1436
2768
|
// Finished terminals are history. After a day of work they are most of the
|
|
1437
2769
|
// list, and drawn as full rows they bury the one or two that are live, so
|
|
1438
2770
|
// they leave the panel entirely and become a ledger cell with a drawer.
|
|
@@ -1478,23 +2810,36 @@
|
|
|
1478
2810
|
const focus = takeFocus(document)
|
|
1479
2811
|
const grid = document.getElementById('session-grid')
|
|
1480
2812
|
if (!grid) return
|
|
1481
|
-
//
|
|
1482
|
-
//
|
|
1483
|
-
//
|
|
1484
|
-
//
|
|
1485
|
-
//
|
|
1486
|
-
//
|
|
2813
|
+
// The expanded region is a child of this grid, so a rebuild of the list can
|
|
2814
|
+
// orphan it, reset every scrollable box inside it and clear any selection
|
|
2815
|
+
// spanning it. It is left in place where it can be (see `keep` below), and
|
|
2816
|
+
// when it does have to move -- to the ledger, or into the list for the
|
|
2817
|
+
// first time -- its scroll offsets are read here and written back after,
|
|
2818
|
+
// the same contract renderDrawer keeps.
|
|
1487
2819
|
const region = detailRegion()
|
|
1488
2820
|
const parked = region ? takeScroll(region) : null
|
|
1489
|
-
|
|
1490
|
-
|
|
2821
|
+
// where the expansion sat in the viewport before any of this. Rows above it
|
|
2822
|
+
// are rebuilt from scratch and can change height as well as order, and the
|
|
2823
|
+
// browser has no anchor of its own across a wipe, so the offset is measured
|
|
2824
|
+
// here and restored at the end. A full in-place diff of every row is the
|
|
2825
|
+
// real answer to "rows that did not change keep their DOM nodes"; this is
|
|
2826
|
+
// the narrower one, and it holds the one thing the reader is looking at
|
|
2827
|
+
// still whatever the rows above it do.
|
|
2828
|
+
const wasAt = anchorFor(region, grid)
|
|
1491
2829
|
const list = [...v.sessions]
|
|
1492
2830
|
const notesOf = new Map(list.map((s) => [s.session_id, rankedNotes(s)]))
|
|
1493
2831
|
const urgent = (s) => needsYou(s, notesOf.get(s.session_id) || [])
|
|
1494
|
-
// needs-you first, then started_at ascending.
|
|
1495
|
-
// only moves the needs-you partition, so a panel never slides under the
|
|
1496
|
-
// cursor for a reason the reader cannot see.
|
|
2832
|
+
// needs-you first, then started_at ascending.
|
|
1497
2833
|
list.sort((a, b) => (urgent(a) === urgent(b) ? Date.parse(a.started_at) - Date.parse(b.started_at) : urgent(a) ? -1 : 1))
|
|
2834
|
+
// and then held still if the reader is inside the list: the sort is what
|
|
2835
|
+
// the order WILL be, heldOrder is what it is on screen until they come out
|
|
2836
|
+
const natural = list.map((s) => s.session_id)
|
|
2837
|
+
const order = listOrder(natural, heldOrder, readerIsInTheList())
|
|
2838
|
+
const rank = new Map(order.map((id, i) => [id, i]))
|
|
2839
|
+
list.sort((a, b) => rank.get(a.session_id) - rank.get(b.session_id))
|
|
2840
|
+
heldOrder = order
|
|
2841
|
+
const same = order.length === natural.length && order.every((id, i) => id === natural[i])
|
|
2842
|
+
orderDivergedAt = same ? 0 : (orderDivergedAt || Date.now())
|
|
1498
2843
|
const empty = document.querySelector('.region-terminals .empty-line')
|
|
1499
2844
|
if (empty) empty.hidden = list.some((s) => s.active || needsYou(s, notesOf.get(s.session_id) || []))
|
|
1500
2845
|
|
|
@@ -1503,14 +2848,36 @@
|
|
|
1503
2848
|
const done = (s) => !s.active && !urgent(s) && drawer.id !== s.session_id
|
|
1504
2849
|
const live = list.filter((s) => !done(s))
|
|
1505
2850
|
const finished = list.filter(done)
|
|
2851
|
+
// A.4 row 18 and E: the tab badge and the browser toast read the same
|
|
2852
|
+
// predicate the rows sort on and the region head counts, so the three
|
|
2853
|
+
// cannot disagree about who is waiting.
|
|
2854
|
+
const waiting = list.filter(urgent)
|
|
2855
|
+
titleBadge(waiting.length)
|
|
2856
|
+
announceWaiting(waiting, (view && view.preferences) || {})
|
|
1506
2857
|
terminalsMeta(live, notesOf)
|
|
1507
2858
|
// computed over the rows that are actually drawn: a sentence shared only by
|
|
1508
2859
|
// terminals collapsed into the ledger is not on screen to be deduped
|
|
1509
2860
|
hoistShared(live, notesOf)
|
|
2861
|
+
// The expansion stays exactly where it is whenever it can. Taking a subtree
|
|
2862
|
+
// out of the document and putting it back clears any text selection inside
|
|
2863
|
+
// it, and this runs on every push: a reader dragging across a path in the
|
|
2864
|
+
// expansion lost the drag every time a row anywhere on the board changed.
|
|
2865
|
+
// The rows around it are removed and rebuilt; it is not touched, so the
|
|
2866
|
+
// selection, and the scroll offsets inside it, are never disturbed.
|
|
2867
|
+
const keep = region && region.parentNode === grid && live.some((s) => s.session_id === drawer.id) ? region : null
|
|
2868
|
+
if (region && !keep && region.parentNode === grid) document.body.appendChild(region)
|
|
2869
|
+
for (const node of [...grid.childNodes]) if (node !== keep) grid.removeChild(node)
|
|
2870
|
+
let past = false
|
|
1510
2871
|
for (const s of live) {
|
|
1511
2872
|
const panel = renderSession(s)
|
|
1512
|
-
|
|
1513
|
-
|
|
2873
|
+
// rows up to and including the expanded one go in above it, the rest
|
|
2874
|
+
// after it, which keeps the region under the panel it belongs to
|
|
2875
|
+
if (keep && !past) grid.insertBefore(panel, keep)
|
|
2876
|
+
else grid.appendChild(panel)
|
|
2877
|
+
if (drawer.id === s.session_id) {
|
|
2878
|
+
past = true
|
|
2879
|
+
if (!keep && region) panel.after(region)
|
|
2880
|
+
}
|
|
1514
2881
|
}
|
|
1515
2882
|
// the per-row copies exist only now, so the pass that hides the ones the
|
|
1516
2883
|
// region already says runs after the rows are in the document
|
|
@@ -1525,6 +2892,13 @@
|
|
|
1525
2892
|
// the control they were on, at the offset they had scrolled to
|
|
1526
2893
|
if (region && parked) putScroll(region, parked)
|
|
1527
2894
|
putFocus(document, focus)
|
|
2895
|
+
// the rows above the expansion are new elements of their own height and in
|
|
2896
|
+
// their own order now: put the expansion back where the reader left it
|
|
2897
|
+
holdAnchor(region, wasAt)
|
|
2898
|
+
// the rows are new elements: the ring is a class, so it is repainted onto
|
|
2899
|
+
// the terminal the reader left it on rather than stealing focus again. This
|
|
2900
|
+
// list was just re-sorted, so painting by position would move it.
|
|
2901
|
+
paintRing()
|
|
1528
2902
|
}
|
|
1529
2903
|
|
|
1530
2904
|
// What landed is history too. The full list was eighteen rows of git log at
|
|
@@ -1597,6 +2971,7 @@
|
|
|
1597
2971
|
}
|
|
1598
2972
|
renderAccounts(v.accounts || [])
|
|
1599
2973
|
renderDefaultOrder(v)
|
|
2974
|
+
renderNotifySettings(v)
|
|
1600
2975
|
// A rebuild replaces every button in the grid. A confirm row is a question
|
|
1601
2976
|
// the reader is answering right now, and a push landing between their
|
|
1602
2977
|
// mousedown and their mouseup dropped the click: the browser fires `click`
|
|
@@ -1607,7 +2982,7 @@
|
|
|
1607
2982
|
renderTrunk(v)
|
|
1608
2983
|
// the panel behind the expansion just changed: status, turns and what is
|
|
1609
2984
|
// next live in the session view, so redraw the region from it
|
|
1610
|
-
if (drawer.id) { if (drawerSession()) renderDrawer(); else closeSessionDrawer() }
|
|
2985
|
+
if (drawer.id) { if (drawerSession()) renderDrawer({ timed: true }); else closeSessionDrawer() }
|
|
1611
2986
|
}
|
|
1612
2987
|
|
|
1613
2988
|
async function refresh() {
|
|
@@ -1618,14 +2993,67 @@
|
|
|
1618
2993
|
// `baton:sessions` alias for every push; this file had been registered on
|
|
1619
2994
|
// `leg:sessions` twice and on the alias once, so one push rebuilt the entire
|
|
1620
2995
|
// terminals grid three times over.
|
|
2996
|
+
// The verdict is a pure function of the payload, and its character budget is
|
|
2997
|
+
// a measurement, so test/board-verdict.test.mjs drives the branches directly
|
|
2998
|
+
// through this seam. In a browser there is no `module`, and nothing here
|
|
2999
|
+
// depends on it. board-updates.test.mjs uses the same pattern in board.js.
|
|
3000
|
+
if (typeof module !== 'undefined') {
|
|
3001
|
+
module.exports = {
|
|
3002
|
+
verdictLines, VERDICT_CH, SUB_CH, WARN_PCT, bindingOf, capFigure, capToken, shareClause, headline,
|
|
3003
|
+
rankedNotes, needsYou, registerTokens, capacityPhrase, capacityNote, waitingNote, notifyWait, resetWait,
|
|
3004
|
+
KEY_BUTTONS, KEY_MOVES,
|
|
3005
|
+
// D14: the ring is state, not a pure function, so test/board-keyboard
|
|
3006
|
+
// drives it through the same keydown listener a reader presses and reads
|
|
3007
|
+
// the paint back through these two. getToken is here because a board in a
|
|
3008
|
+
// browser with storage blocked has to render, and that cannot be asserted
|
|
3009
|
+
// from the source text.
|
|
3010
|
+
paintRing, ringSession: () => ringId, getToken, announceWaiting, readerIsWatching,
|
|
3011
|
+
// 2026-09-18 "it keeps jumping around": the order the list holds while the
|
|
3012
|
+
// reader is inside it, and the run of identical status lines that used to
|
|
3013
|
+
// fill the timeline. Both are pure, so test/board-jump.test.mjs drives
|
|
3014
|
+
// them directly; the render that calls them is proved by
|
|
3015
|
+
// scripts/board-jump-probe.mjs in a real browser.
|
|
3016
|
+
listOrder, holdsOrder, collapseEvents, readerIsInTheList, ORDER_HOLD_MS,
|
|
3017
|
+
// ...and the two bounds the second pass put on that hold: which regions
|
|
3018
|
+
// have a scroll anchor worth restoring, and how long the region may
|
|
3019
|
+
// refuse to redraw itself under a selection the reader has forgotten.
|
|
3020
|
+
anchorFor, drawerStandsDown, DRAWER_HOLD_MS,
|
|
3021
|
+
// B.6: the pick that survives the poll, the rule box and the refusal the
|
|
3022
|
+
// Add button prints are all pure and are asserted without a DOM
|
|
3023
|
+
pickKey, pickIndex, whenFromBox, whenFlag, duplicateRung,
|
|
3024
|
+
// B.6: the picker's option text, the ladder's round trip and the climb
|
|
3025
|
+
// predicate are pure, so they are asserted without a DOM
|
|
3026
|
+
handoffOptionText, rungLabel, costWord, whenKind, whenPct, whenString, moveRung, rungsAfter,
|
|
3027
|
+
climbTarget, topRungFor, MODEL_ALIASES, LADDER_AGENTS,
|
|
3028
|
+
MAY_SPEND_SENTENCE, NO_CREDITS_SENTENCE, CLIMB_RULE, CLIMB_WORDS,
|
|
3029
|
+
}
|
|
3030
|
+
}
|
|
3031
|
+
|
|
1621
3032
|
window.addEventListener('leg:sessions', (e) => render(e.detail))
|
|
1622
3033
|
document.addEventListener('keydown', (e) => {
|
|
1623
|
-
if (e.key
|
|
1624
|
-
|
|
1625
|
-
|
|
3034
|
+
if (e.key === 'Escape') {
|
|
3035
|
+
if (keymapOpen) { toggleKeymap(false); return }
|
|
3036
|
+
if (pendingConfirm) { pendingConfirm = null; if (view) renderSessions(view); return }
|
|
3037
|
+
if (drawer.id) closeSessionDrawer()
|
|
3038
|
+
return
|
|
3039
|
+
}
|
|
3040
|
+
boardKey(e)
|
|
1626
3041
|
})
|
|
1627
3042
|
document.addEventListener('DOMContentLoaded', () => {
|
|
1628
3043
|
document.getElementById('default-order-save')?.addEventListener('click', saveDefaultOrder)
|
|
3044
|
+
document.getElementById('capacity-toggle')?.addEventListener('click', toggleCapacity)
|
|
3045
|
+
document.getElementById('keymap-close')?.addEventListener('click', () => toggleKeymap(false))
|
|
3046
|
+
document.getElementById('notify-terminal')?.addEventListener('change', (e) => saveNotify({ notify_terminal: Boolean(e.target.checked) }))
|
|
3047
|
+
// the permission is asked for on the toggle, never on load: a page that
|
|
3048
|
+
// asks for a permission nobody wanted is a page people close
|
|
3049
|
+
document.getElementById('notify-board')?.addEventListener('change', async (e) => {
|
|
3050
|
+
const on = Boolean(e.target.checked)
|
|
3051
|
+
if (on && typeof Notification !== 'undefined' && Notification.permission === 'default') {
|
|
3052
|
+
try { await Notification.requestPermission() } catch { /* a refusal is an answer; the toggle still saves */ }
|
|
3053
|
+
}
|
|
3054
|
+
saveNotify({ notify_board: on })
|
|
3055
|
+
})
|
|
3056
|
+
renderCapacityToggle()
|
|
1629
3057
|
renderLoadingHead()
|
|
1630
3058
|
refresh()
|
|
1631
3059
|
setInterval(tickElapsed, 1000)
|