@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/board.js
CHANGED
|
@@ -33,6 +33,11 @@
|
|
|
33
33
|
needs_approval: 0, waiting_human: 0, failed: 1, killed: 1, running: 2,
|
|
34
34
|
handing_off: 2, paused: 3, queued: 4, backlog: 5, done: 6,
|
|
35
35
|
}
|
|
36
|
+
// C.1: liveness decides the surface. A live card is a row in the Background
|
|
37
|
+
// panel; a finished one collapses into the one ledger line.
|
|
38
|
+
const LIVE_STATUSES = ['backlog', 'queued', 'running', 'handing_off', 'needs_approval', 'waiting_human', 'paused']
|
|
39
|
+
const FINISHED_STATUSES = ['done', 'failed', 'killed']
|
|
40
|
+
const isLive = (card) => LIVE_STATUSES.includes(card.status)
|
|
36
41
|
// 6.5 G10: the button order is fixed and never reflows by availability
|
|
37
42
|
const ACTION_ORDER = ['approve', 'enqueue', 'resume', 'pause', 'handoff_now', 'rerun', 'reassign', 'kill']
|
|
38
43
|
const ACTION_LABELS = {
|
|
@@ -58,6 +63,12 @@
|
|
|
58
63
|
}
|
|
59
64
|
const AGENT_IDS = ['claude', 'codex', 'agy', 'grok']
|
|
60
65
|
const DEFAULT_BIND = '127.0.0.1:4747'
|
|
66
|
+
// The version these page files shipped with. The server answers /api/health
|
|
67
|
+
// with the version of the PROCESS, and the two drift apart the moment a
|
|
68
|
+
// release lands on disk under a board that was started before it: the page
|
|
69
|
+
// then draws controls the process has no routes for (an empty agent select,
|
|
70
|
+
// no buckets). test/files-version.test.mjs pins this to package.json.
|
|
71
|
+
const FILES_VERSION = '0.13.0'
|
|
61
72
|
const TIMELINE_CAP = 12
|
|
62
73
|
// mirrors LOOPBACK in src/auth.mjs; state.bind is "<host>:<port>" and an IPv6
|
|
63
74
|
// host arrives bracketed
|
|
@@ -101,6 +112,17 @@
|
|
|
101
112
|
healthKnown: false,
|
|
102
113
|
bindKnown: false,
|
|
103
114
|
lastHello: null,
|
|
115
|
+
// C.2: the one-line entry infers its repo from the most recently focused
|
|
116
|
+
// terminal, published by sessions.js on the leg:sessions window event.
|
|
117
|
+
sessions: [],
|
|
118
|
+
// one entry per repo with a live terminal: { repo, repo_name, branch }
|
|
119
|
+
repoTrunks: [],
|
|
120
|
+
preferences: null,
|
|
121
|
+
// { claude: [{id, label, default}], codex: [...], agy: [...], grok: [...] }
|
|
122
|
+
// from /api/models. null until it answers; an agent missing from it offers
|
|
123
|
+
// its provider default and nothing else, which is what a bare `leg <agent>`
|
|
124
|
+
// already does.
|
|
125
|
+
models: null,
|
|
104
126
|
}
|
|
105
127
|
// a card push while an agent writes its log only moves these two
|
|
106
128
|
const VOLATILE_CARD_FIELDS = ['last_event', 'elapsed_ms']
|
|
@@ -216,20 +238,19 @@
|
|
|
216
238
|
}
|
|
217
239
|
|
|
218
240
|
// an idle card has no run to measure, and a run whose start did not parse is
|
|
219
|
-
// not a zero-length run: both print the placeholder rather than a number
|
|
220
|
-
// A running card counts up from its current run
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
//
|
|
241
|
+
// not a zero-length run: both print the placeholder rather than a number.
|
|
242
|
+
// A running card counts up from its current run, in the column the terminal
|
|
243
|
+
// rows above hold their run clock in. A live card BETWEEN runs has no run
|
|
244
|
+
// clock at all: created_at to updated_at is the card's whole age, which for
|
|
245
|
+
// a card that sat in the backlog for a day reads `24:05:00` in a column that
|
|
246
|
+
// means "this run", so it is labelled for what it is instead: how long it
|
|
247
|
+
// has been sitting in the state it is in.
|
|
224
248
|
function runElapsed(card) {
|
|
225
249
|
const from = card.active_run ? Date.parse(card.active_run.started_at) : NaN
|
|
226
250
|
if (Number.isFinite(from)) return elapsedClock(Date.now() - from)
|
|
227
251
|
if (!card.runs_count) return '--:--'
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
return Number.isFinite(started) && Number.isFinite(ended) && ended >= started
|
|
231
|
-
? elapsedClock(ended - started)
|
|
232
|
-
: '--:--'
|
|
252
|
+
const since = Date.parse(card.updated_at)
|
|
253
|
+
return Number.isFinite(since) && since <= Date.now() ? `idle ${agoShort(Date.now() - since)}` : '--:--'
|
|
233
254
|
}
|
|
234
255
|
|
|
235
256
|
function clockAt(ms) {
|
|
@@ -301,6 +322,21 @@
|
|
|
301
322
|
window.dispatchEvent(new CustomEvent('baton:sessions', { detail }))
|
|
302
323
|
}
|
|
303
324
|
|
|
325
|
+
// the entry line's repo inference reads the latest sessions payload without
|
|
326
|
+
// owning the terminals region: sessions.js still renders it, this file only
|
|
327
|
+
// keeps a copy for "the most recently focused terminal's repo". Guarded the
|
|
328
|
+
// same way window.legMessage is above: the pure-logic test harness passes a
|
|
329
|
+
// window stub with no addEventListener at all.
|
|
330
|
+
if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
|
|
331
|
+
window.addEventListener('leg:sessions', (e) => {
|
|
332
|
+
state.sessions = (e.detail && e.detail.sessions) || []
|
|
333
|
+
// each repo's own default branch, read once per push by the server: the
|
|
334
|
+
// entry line's `on <branch>` and the trunk it posts come from here
|
|
335
|
+
state.repoTrunks = (e.detail && e.detail.trunk) || []
|
|
336
|
+
renderEntryLine()
|
|
337
|
+
})
|
|
338
|
+
}
|
|
339
|
+
|
|
304
340
|
function connectSse() {
|
|
305
341
|
if (state.es) { try { state.es.close() } catch { /* ignore */ } }
|
|
306
342
|
const request = ++state.sseRequest
|
|
@@ -395,6 +431,7 @@
|
|
|
395
431
|
state.isOwner = !(data.you && data.you.role && data.you.role !== 'owner')
|
|
396
432
|
state.healthKnown = true
|
|
397
433
|
renderBoardFacts(data)
|
|
434
|
+
versionSkew(data.version)
|
|
398
435
|
renderTokenMeta()
|
|
399
436
|
// a guest is told who they are by health, which is open to them
|
|
400
437
|
if (data.you && data.you.role && data.you.role !== 'owner') { guestMode(); return false }
|
|
@@ -416,60 +453,15 @@
|
|
|
416
453
|
}
|
|
417
454
|
}
|
|
418
455
|
|
|
419
|
-
// ---- 6.12 step
|
|
420
|
-
//
|
|
421
|
-
//
|
|
422
|
-
//
|
|
456
|
+
// ---- 6.12 step 6: liveness split, cards reborn ----
|
|
457
|
+
// C.1: a live card is a row in the Background panel, right under Terminals.
|
|
458
|
+
// A finished one falls into one ledger cell. `cardsOpen` now gates the
|
|
459
|
+
// finished-cards drawer, not a background-tasks drawer: there is no drawer
|
|
460
|
+
// for live cards, they are always on the page.
|
|
423
461
|
let cardsOpen = false
|
|
424
462
|
function toggleEmptyState() {
|
|
425
463
|
const empty = document.getElementById('empty-state')
|
|
426
|
-
|
|
427
|
-
const panel = document.getElementById('cards-drawer')
|
|
428
|
-
const hasCards = state.cards.size > 0
|
|
429
|
-
empty.hidden = hasCards
|
|
430
|
-
list.hidden = !hasCards
|
|
431
|
-
if (panel) panel.hidden = !(hasCards && cardsOpen)
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
function countCards(...statuses) {
|
|
435
|
-
let n = 0
|
|
436
|
-
for (const c of state.cards.values()) if (statuses.includes(c.status)) n += 1
|
|
437
|
-
return n
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
// G14: a verdict carries its volume
|
|
441
|
-
function cardsMeta() {
|
|
442
|
-
const total = state.cards.size
|
|
443
|
-
if (!total) return ''
|
|
444
|
-
const running = countCards('running', 'handing_off')
|
|
445
|
-
const queued = countCards('queued')
|
|
446
|
-
const backlog = countCards('backlog')
|
|
447
|
-
const waiting = countCards('waiting_human', 'needs_approval')
|
|
448
|
-
const finished = countCards('done', 'failed', 'killed')
|
|
449
|
-
const parts = []
|
|
450
|
-
if (running) parts.push(`${running} running`)
|
|
451
|
-
if (queued) parts.push(`${queued} queued`)
|
|
452
|
-
if (backlog) parts.push(`${backlog} in backlog`)
|
|
453
|
-
if (waiting) parts.push(`${waiting} waiting on you`)
|
|
454
|
-
if (finished) parts.push(`${finished} finished`)
|
|
455
|
-
return parts.length ? parts.join(', ') : `${total} cards, nothing is running`
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
function renderCardsMeta() {
|
|
459
|
-
const meta = document.querySelector('#board .region-meta')
|
|
460
|
-
const head = document.getElementById('cards-head')
|
|
461
|
-
const slot = document.querySelector('#board .ledger-actions')
|
|
462
|
-
const total = state.cards.size
|
|
463
|
-
if (meta) meta.textContent = total ? cardsMeta() : 'Nothing is queued. Leg starts the next login only when a terminal hands off.'
|
|
464
|
-
if (head) head.textContent = total ? `${total} background task${total === 1 ? '' : 's'}` : 'No background tasks'
|
|
465
|
-
if (!slot) return
|
|
466
|
-
const existing = document.getElementById('cards-toggle')
|
|
467
|
-
if (!total) { if (existing) existing.remove(); return }
|
|
468
|
-
const label = cardsOpen ? `Hide the ${total}` : `View ${total} card${total === 1 ? '' : 's'}`
|
|
469
|
-
if (existing) { existing.textContent = label; existing.setAttribute('aria-expanded', cardsOpen ? 'true' : 'false'); return }
|
|
470
|
-
const btn = el('button', { type: 'button', class: 'btn btn-secondary', id: 'cards-toggle', 'aria-expanded': cardsOpen ? 'true' : 'false', 'aria-controls': 'cards-drawer' }, [label])
|
|
471
|
-
btn.addEventListener('click', () => { cardsOpen = !cardsOpen; toggleEmptyState(); renderCardsMeta() })
|
|
472
|
-
slot.appendChild(btn)
|
|
464
|
+
if (empty) empty.hidden = state.cards.size > 0
|
|
473
465
|
}
|
|
474
466
|
|
|
475
467
|
function rowRank(card) {
|
|
@@ -487,11 +479,102 @@
|
|
|
487
479
|
return [...state.cards.values()].sort((a, b) => rowRank(a) - rowRank(b) || startedAt(a) - startedAt(b))
|
|
488
480
|
}
|
|
489
481
|
|
|
482
|
+
function liveCards() { return orderedCards().filter((c) => isLive(c)) }
|
|
483
|
+
|
|
484
|
+
// C.5: finished cards, newest first, for the ledger's [View] drawer.
|
|
485
|
+
function finishedCards() {
|
|
486
|
+
return [...state.cards.values()]
|
|
487
|
+
.filter((c) => FINISHED_STATUSES.includes(c.status))
|
|
488
|
+
.sort((a, b) => (Date.parse(b.updated_at) || 0) - (Date.parse(a.updated_at) || 0))
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// C.5: "1 running, 1 waiting on you", the same predicate as needsYou on the
|
|
492
|
+
// terminals region. A zero clause is omitted rather than printed as 0.
|
|
493
|
+
function backgroundMeta(live) {
|
|
494
|
+
const running = live.filter((c) => ['running', 'handing_off'].includes(c.status)).length
|
|
495
|
+
const waiting = live.filter((c) => ['needs_approval', 'waiting_human'].includes(c.status)).length
|
|
496
|
+
const parts = []
|
|
497
|
+
if (running) parts.push(`${running} running`)
|
|
498
|
+
if (waiting) parts.push(`${waiting} waiting on you`)
|
|
499
|
+
return parts.join(', ')
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function renderBackgroundRegion(live) {
|
|
503
|
+
const section = document.getElementById('background')
|
|
504
|
+
if (section) section.hidden = live.length === 0
|
|
505
|
+
const meta = document.querySelector('#background .region-meta')
|
|
506
|
+
if (meta) meta.textContent = backgroundMeta(live)
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// C.1, C.5: "3 finished cards, 2 done, 1 failed, last 11:02 PM", one line,
|
|
510
|
+
// never a count of the live cards (they are their own region now).
|
|
511
|
+
function renderFinishedLedger() {
|
|
512
|
+
const finished = finishedCards()
|
|
513
|
+
const head = document.getElementById('cards-head')
|
|
514
|
+
const meta = document.querySelector('#board .region-meta')
|
|
515
|
+
if (head) {
|
|
516
|
+
if (!finished.length) head.textContent = 'No finished cards'
|
|
517
|
+
else {
|
|
518
|
+
const done = finished.filter((c) => c.status === 'done').length
|
|
519
|
+
const failed = finished.filter((c) => c.status === 'failed').length
|
|
520
|
+
const killed = finished.filter((c) => c.status === 'killed').length
|
|
521
|
+
const parts = []
|
|
522
|
+
if (done) parts.push(`${done} done`)
|
|
523
|
+
if (failed) parts.push(`${failed} failed`)
|
|
524
|
+
if (killed) parts.push(`${killed} killed`)
|
|
525
|
+
head.textContent = `${plural(finished.length, 'finished card')}, ${parts.join(', ')}, last ${clock(finished[0].updated_at)}`
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
if (meta) meta.textContent = ''
|
|
529
|
+
const slot = document.querySelector('#board .ledger-actions')
|
|
530
|
+
if (slot) {
|
|
531
|
+
let view = document.getElementById('cards-view-toggle')
|
|
532
|
+
if (!finished.length) { if (view) view.remove() } else {
|
|
533
|
+
if (!view) {
|
|
534
|
+
view = el('button', { type: 'button', class: 'btn btn-secondary', id: 'cards-view-toggle', 'aria-controls': 'cards-drawer' }, ['View'])
|
|
535
|
+
view.addEventListener('click', () => { cardsOpen = !cardsOpen; renderFinishedLedger() })
|
|
536
|
+
slot.appendChild(view)
|
|
537
|
+
}
|
|
538
|
+
view.textContent = cardsOpen ? 'Hide' : 'View'
|
|
539
|
+
view.setAttribute('aria-expanded', cardsOpen ? 'true' : 'false')
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
const panel = document.getElementById('cards-drawer')
|
|
543
|
+
if (panel) panel.hidden = !(cardsOpen && finished.length)
|
|
544
|
+
renderFinishedList(finished)
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// C.5: each drawer row is one line, never the full interactive row a live
|
|
548
|
+
// card gets: "done after 3 runs, landed 7f3a2c1" or "failed at station
|
|
549
|
+
// build after 2 runs: <last event summary>".
|
|
550
|
+
function finishedLine(card) {
|
|
551
|
+
const runs = plural(card.runs_count || 0, 'run')
|
|
552
|
+
const last = card.last_event ? card.last_event.summary : 'no events yet'
|
|
553
|
+
if (card.status === 'done') {
|
|
554
|
+
const landed = card.land && card.land.state === 'landed' && card.land.sha ? `landed ${String(card.land.sha).slice(0, 7)}` : last
|
|
555
|
+
return `done after ${runs}, ${landed}`
|
|
556
|
+
}
|
|
557
|
+
if (card.status === 'failed') return `failed at station ${card.station} after ${runs}: ${last}`
|
|
558
|
+
return `killed after ${runs}`
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function renderFinishedList(finished) {
|
|
562
|
+
const list = document.getElementById('columns')
|
|
563
|
+
if (!list) return
|
|
564
|
+
list.textContent = ''
|
|
565
|
+
for (const card of finished) {
|
|
566
|
+
list.appendChild(el('div', { class: 'finished-line' }, [
|
|
567
|
+
el('span', { class: 'row-meta mono' }, [card.title || card.card_id]),
|
|
568
|
+
el('span', { class: 'finished-names' }, [finishedLine(card)]),
|
|
569
|
+
]))
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
490
573
|
// Re-appending every row costs one DOM move each and can move a node out from
|
|
491
574
|
// under the cursor, so it happens only when the order string actually changed.
|
|
492
575
|
function orderRows() {
|
|
493
|
-
const list = document.getElementById('
|
|
494
|
-
const order =
|
|
576
|
+
const list = document.getElementById('background-grid')
|
|
577
|
+
const order = liveCards().map((c) => c.card_id)
|
|
495
578
|
const key = order.join(',')
|
|
496
579
|
if (key === state.rowOrder) return
|
|
497
580
|
state.rowOrder = key
|
|
@@ -502,19 +585,29 @@
|
|
|
502
585
|
if (state.drawerId) placeDetail(state.drawerId)
|
|
503
586
|
}
|
|
504
587
|
|
|
588
|
+
// called after anything that can change which cards are live, finished, or
|
|
589
|
+
// how many of each: cheap text/count work, safe to run in full every time.
|
|
590
|
+
function refreshCardMeta() {
|
|
591
|
+
const live = liveCards()
|
|
592
|
+
renderBackgroundRegion(live)
|
|
593
|
+
renderFinishedLedger()
|
|
594
|
+
placeEntryLine(live.length > 0)
|
|
595
|
+
renderEntryLine()
|
|
596
|
+
toggleEmptyState()
|
|
597
|
+
}
|
|
598
|
+
|
|
505
599
|
function renderBoard() {
|
|
506
|
-
const
|
|
600
|
+
const grid = document.getElementById('background-grid')
|
|
507
601
|
// the detail region is a child of this list while a row is expanded: park it
|
|
508
602
|
// back on the body so the wipe below does not take it out of the document
|
|
509
603
|
document.body.appendChild(document.getElementById('drawer'))
|
|
510
|
-
|
|
604
|
+
if (grid) grid.textContent = ''
|
|
511
605
|
state.cardNodes = new Map()
|
|
512
|
-
const
|
|
513
|
-
for (const card of
|
|
514
|
-
state.rowOrder =
|
|
606
|
+
const live = liveCards()
|
|
607
|
+
for (const card of live) renderRow(card)
|
|
608
|
+
state.rowOrder = live.map((c) => c.card_id).join(',')
|
|
515
609
|
if (state.drawerId) placeDetail(state.drawerId)
|
|
516
|
-
|
|
517
|
-
toggleEmptyState()
|
|
610
|
+
refreshCardMeta()
|
|
518
611
|
}
|
|
519
612
|
|
|
520
613
|
// the board is pushed a card for every write under its directory, log bytes
|
|
@@ -541,14 +634,22 @@
|
|
|
541
634
|
return cache.expanded ? 200 : 8
|
|
542
635
|
}
|
|
543
636
|
|
|
637
|
+
// a card that fell off liveness (running -> done, say) loses its row; the
|
|
638
|
+
// finished ledger picks it up on the next refreshCardMeta() instead
|
|
639
|
+
function removeLiveRow(id) {
|
|
640
|
+
const root = state.cardNodes.get(id)
|
|
641
|
+
if (root) { root.remove(); state.cardNodes.delete(id) }
|
|
642
|
+
state.rowOrder = ''
|
|
643
|
+
if (state.drawerId === id) closeDrawer()
|
|
644
|
+
}
|
|
645
|
+
|
|
544
646
|
function upsertCard(card) {
|
|
545
647
|
state.boardRevision += 1
|
|
546
648
|
const prev = state.cards.get(card.card_id)
|
|
547
649
|
const tail = staleLogTail(state.logState.get(card.card_id), card, Date.now())
|
|
548
|
-
|
|
549
|
-
orderRows()
|
|
550
|
-
|
|
551
|
-
toggleEmptyState()
|
|
650
|
+
state.cards.set(card.card_id, card)
|
|
651
|
+
if (isLive(card)) { renderRow(card); orderRows() } else { removeLiveRow(card.card_id) }
|
|
652
|
+
refreshCardMeta()
|
|
552
653
|
if (tail) ensureLogLoaded(card.card_id, tail)
|
|
553
654
|
if (state.drawerId === card.card_id && drawerRefreshNeeded(prev, card)) scheduleDrawerRefresh()
|
|
554
655
|
}
|
|
@@ -557,12 +658,8 @@
|
|
|
557
658
|
state.boardRevision += 1
|
|
558
659
|
state.cards.delete(id)
|
|
559
660
|
state.logState.delete(id)
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
state.rowOrder = ''
|
|
563
|
-
renderCardsMeta()
|
|
564
|
-
toggleEmptyState()
|
|
565
|
-
if (state.drawerId === id) closeDrawer()
|
|
661
|
+
removeLiveRow(id)
|
|
662
|
+
refreshCardMeta()
|
|
566
663
|
}
|
|
567
664
|
|
|
568
665
|
function onLedgerEvent(ev) {
|
|
@@ -618,16 +715,14 @@
|
|
|
618
715
|
return { tone: 'warn', text: `waiting on you at station ${card.station} since ${clock(card.updated_at)}` }
|
|
619
716
|
}
|
|
620
717
|
if (card.status === 'done') return { tone: 'ok', text: `done after ${plural(card.runs_count || 0, 'run')}, ${last ? last.summary : 'no events yet'}` }
|
|
718
|
+
// C.3: a card runs -p --output-format json, mute until the leg exits. Once
|
|
719
|
+
// it has said anything real the generic branch below still carries it.
|
|
720
|
+
if (card.status === 'running' && last && (last.type === 'leg_started' || last.summary === 'leg started')) {
|
|
721
|
+
return { tone: 'muted', text: `no message until this leg ends, started ${clock(last.ts)}` }
|
|
722
|
+
}
|
|
621
723
|
return { tone: 'muted', text: `${formatLastEvent(last)}${last ? `, ${clock(last.ts)}` : ''}` }
|
|
622
724
|
}
|
|
623
725
|
|
|
624
|
-
function shortWorktree(card) {
|
|
625
|
-
if (!card.worktree) return ''
|
|
626
|
-
const parts = String(card.worktree).split(/[\\/]/).filter(Boolean)
|
|
627
|
-
let i = parts.lastIndexOf('.leg-worktrees'); if (i === -1) i = parts.lastIndexOf('.baton-worktrees');
|
|
628
|
-
return i > 0 ? parts.slice(i - 1).join('/') : parts.slice(-2).join('/')
|
|
629
|
-
}
|
|
630
|
-
|
|
631
726
|
// 6.11: flat inline tokens, middot-separated by CSS, each agent name printed
|
|
632
727
|
// beside its own colour so identity never rides on hue alone.
|
|
633
728
|
function buildChainRail(card) {
|
|
@@ -690,11 +785,17 @@
|
|
|
690
785
|
row.cancelBtn.focus()
|
|
691
786
|
}
|
|
692
787
|
|
|
693
|
-
|
|
694
|
-
|
|
788
|
+
// C.3: at most four buttons in the row's 2x2 grid, fixed order; anything
|
|
789
|
+
// left over moves into the expansion instead of a fifth slot.
|
|
790
|
+
function splitActions(card) {
|
|
695
791
|
const available = card.actions || []
|
|
696
|
-
|
|
697
|
-
|
|
792
|
+
const ordered = ACTION_ORDER.filter((a) => available.includes(a) && ACTION_LABELS[a])
|
|
793
|
+
return { shown: ordered.slice(0, 4), overflow: ordered.slice(4) }
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
function buildActions(card, actions) {
|
|
797
|
+
const wrap = el('div', { class: 'row-actions' })
|
|
798
|
+
for (const action of actions) {
|
|
698
799
|
const label = ACTION_LABELS[action]
|
|
699
800
|
const cls = `btn ${ACTION_CLASS[action] || 'btn-secondary'}`
|
|
700
801
|
const run = action === 'reassign' ? () => openReassign(card, wrap) : () => runAction(card, action)
|
|
@@ -706,6 +807,82 @@
|
|
|
706
807
|
return wrap
|
|
707
808
|
}
|
|
708
809
|
|
|
810
|
+
// C.3: a short relative age ("6m ago"), for the work stat line only. The
|
|
811
|
+
// pinned time grammar in sessions.js (elapsedClock/clockAt) is unrelated:
|
|
812
|
+
// this is a duration since a timestamp, not an elapsed-run clock.
|
|
813
|
+
function agoShort(ms) {
|
|
814
|
+
const s = Math.max(0, Math.floor(ms / 1000))
|
|
815
|
+
if (s < 60) return `${s}s`
|
|
816
|
+
const m = Math.floor(s / 60)
|
|
817
|
+
if (m < 60) return `${m}m`
|
|
818
|
+
const h = Math.floor(m / 60)
|
|
819
|
+
if (h < 48) return `${h}h`
|
|
820
|
+
return `${Math.floor(h / 24)}d`
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function agoSince(ts) {
|
|
824
|
+
const at = ts ? Date.parse(ts) : NaN
|
|
825
|
+
return Number.isFinite(at) ? agoShort(Date.now() - at) : 'an unknown time'
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// C.3: the branch a live card works on is leg/<card_id> once it has a
|
|
829
|
+
// worktree (src/worktree.mjs branchName), else it has not started and the
|
|
830
|
+
// register falls back to the trunk it will branch from.
|
|
831
|
+
// A terminal's short id is the random tail of its session id. A card id has
|
|
832
|
+
// no hash on the end, only a slug, and `card-20260917-2300-seeded-live-card-3`
|
|
833
|
+
// ends in `3`: a bare digit identifies nothing on a board with four cards. So
|
|
834
|
+
// the short id is the last segment, grown leftwards until it is a token a
|
|
835
|
+
// reader can match back to the row (4 characters or more).
|
|
836
|
+
function cardShortId(id) {
|
|
837
|
+
const parts = String(id || '').split('-').filter(Boolean)
|
|
838
|
+
if (!parts.length) return ''
|
|
839
|
+
let out = parts[parts.length - 1]
|
|
840
|
+
for (let i = parts.length - 2; i >= 0 && out.length < 4; i--) out = `${parts[i]}-${out}`
|
|
841
|
+
return out
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// The branch this card's checkout is really on, for a reader who is going to
|
|
845
|
+
// paste it into `git checkout`. A card that cut its own worktree is on
|
|
846
|
+
// `leg/<card-id>`; one that adopted a terminal's is on that TERMINAL's
|
|
847
|
+
// branch, which no reader can derive from the card id, so the server records
|
|
848
|
+
// it with the worktree (`worktree_branch`). Shortened for the row only when
|
|
849
|
+
// the full name does not fit, and the full name is on the element's title.
|
|
850
|
+
function cardBranch(card) {
|
|
851
|
+
if (card.worktree_branch) return card.worktree_branch
|
|
852
|
+
if (card.worktree) return `leg/${card.card_id}`
|
|
853
|
+
return card.trunk || 'main'
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
function cardAgent(card) { return (card.agent_model && card.agent_model.agent) || card.active_adapter || null }
|
|
857
|
+
|
|
858
|
+
function cardAgentModelText(card) {
|
|
859
|
+
const agent = cardAgent(card)
|
|
860
|
+
if (!agent) return null
|
|
861
|
+
const model = card.agent_model && card.agent_model.model
|
|
862
|
+
return model ? `${agent}/${model}` : agent
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
// C.3: "4 files, +212 -18, tests green 6m ago", built only from the parts
|
|
866
|
+
// the server actually measured. Nothing measured prints nothing.
|
|
867
|
+
function workStatLine(card) {
|
|
868
|
+
const parts = []
|
|
869
|
+
const w = card.work
|
|
870
|
+
if (w) {
|
|
871
|
+
if (Number.isFinite(w.files)) parts.push(plural(w.files, 'file'))
|
|
872
|
+
const ins = Number.isFinite(w.insertions) ? `+${w.insertions}` : ''
|
|
873
|
+
const del = Number.isFinite(w.deletions) ? `-${w.deletions}` : ''
|
|
874
|
+
const diff = [ins, del].filter(Boolean).join(' ')
|
|
875
|
+
if (diff) parts.push(diff)
|
|
876
|
+
}
|
|
877
|
+
if (card.tests) parts.push(`tests ${card.tests.state} ${agoSince(card.tests.at)} ago`)
|
|
878
|
+
if (card.land) {
|
|
879
|
+
if (card.land.state === 'bounced') parts.push(`land bounced: ${card.land.reason || 'unknown reason'}`)
|
|
880
|
+
else if (card.land.state === 'landed' && card.land.sha) parts.push(`landed ${String(card.land.sha).slice(0, 7)}`)
|
|
881
|
+
else if (card.land.state === 'failed') parts.push(`land failed${card.land.reason ? `: ${card.land.reason}` : ''}`)
|
|
882
|
+
}
|
|
883
|
+
return parts.join(', ')
|
|
884
|
+
}
|
|
885
|
+
|
|
709
886
|
// the reassign picker replaces the buttons in place, like the confirm row
|
|
710
887
|
async function openReassign(card, wrap) {
|
|
711
888
|
if (!state.adapters) {
|
|
@@ -734,33 +911,37 @@
|
|
|
734
911
|
wrap.appendChild(el('div', { class: 'reassign-picker' }, [adapterSelect, modeSelect, apply, cancel]))
|
|
735
912
|
}
|
|
736
913
|
|
|
737
|
-
//
|
|
738
|
-
//
|
|
739
|
-
//
|
|
914
|
+
// C.3: R1 register (state, station, repo/branch, agent/model), R2 title +
|
|
915
|
+
// sentence, R3 the work stat line (only what is measured), R4 elapsed, the
|
|
916
|
+
// short id, and at most four action buttons. A card row reads down the same
|
|
917
|
+
// four columns as a terminal (.row already carries that shape, see
|
|
918
|
+
// board.css's own comment on it: "the same shape as .term-row").
|
|
740
919
|
function buildRow(card) {
|
|
741
920
|
const said = cardSentence(card)
|
|
742
|
-
const
|
|
743
|
-
const
|
|
921
|
+
const agentText = cardAgentModelText(card)
|
|
922
|
+
const agentBase = cardAgent(card)
|
|
744
923
|
const title = el('button', {
|
|
745
924
|
type: 'button', class: 'row-title', 'aria-expanded': state.drawerId === card.card_id ? 'true' : 'false',
|
|
746
925
|
onclick: () => expandRow(card.card_id),
|
|
747
926
|
}, [card.title || truncate(card.task, 60) || card.card_id])
|
|
748
927
|
const sentence = el('p', { class: `sentence tone-${said.tone}` }, [said.text])
|
|
749
928
|
const elapsed = el('span', { class: 'elapsed' }, [runElapsed(card)])
|
|
929
|
+
const statLine = workStatLine(card)
|
|
930
|
+
const { shown } = splitActions(card)
|
|
750
931
|
const cells = [
|
|
751
932
|
el('div', { class: 'r1' }, [
|
|
752
|
-
el('span', { class: agent ? `chip chip-id-${agentClass(agent)}` : 'chip' }, [agent || 'no agent']),
|
|
753
|
-
card.station && card.station !== '-' ? el('span', { class: 'chip' }, [card.station]) : null,
|
|
754
933
|
statusWord(card),
|
|
934
|
+
card.station && card.station !== '-' ? el('span', { class: 'chip' }, [card.station]) : null,
|
|
935
|
+
el('span', { class: 'row-meta mono', title: `${card.repo_name || 'no repo'} on ${cardBranch(card)}` }, [`${card.repo_name || 'no repo'} on ${truncate(cardBranch(card), 32)}`]),
|
|
936
|
+
agentText ? el('span', { class: `chip chip-id-${agentClass(agentBase)}` }, [agentText]) : null,
|
|
755
937
|
]),
|
|
756
938
|
el('div', { class: 'r2' }, [title, sentence]),
|
|
757
|
-
el('div', { class: 'r3' }, [
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
939
|
+
el('div', { class: 'r3' }, statLine ? [el('p', { class: 'row-meta' }, [statLine])] : []),
|
|
940
|
+
el('div', { class: 'r4' }, [
|
|
941
|
+
elapsed,
|
|
942
|
+
el('span', { class: 'row-meta mono', title: card.card_id }, [cardShortId(card.card_id)]),
|
|
943
|
+
buildActions(card, shown),
|
|
762
944
|
]),
|
|
763
|
-
el('div', { class: 'r4' }, [elapsed, buildActions(card)]),
|
|
764
945
|
]
|
|
765
946
|
return { cells, slots: { title, sentence, elapsed } }
|
|
766
947
|
}
|
|
@@ -771,7 +952,7 @@
|
|
|
771
952
|
if (!root) {
|
|
772
953
|
root = el('article', { class: 'row', 'data-card-id': card.card_id })
|
|
773
954
|
state.cardNodes.set(card.card_id, root)
|
|
774
|
-
document.getElementById('
|
|
955
|
+
document.getElementById('background-grid').appendChild(root)
|
|
775
956
|
state.rowOrder = ''
|
|
776
957
|
} else {
|
|
777
958
|
while (root.firstChild) root.removeChild(root.firstChild)
|
|
@@ -854,6 +1035,27 @@
|
|
|
854
1035
|
return [el('div', { class: 'kv' }, rows), copy]
|
|
855
1036
|
}
|
|
856
1037
|
|
|
1038
|
+
// C.4: card -> terminal. Take over pauses the card and hands back one
|
|
1039
|
+
// copyable command; a terminal cannot be opened from a browser tab, so the
|
|
1040
|
+
// board says that plainly rather than pretending it can.
|
|
1041
|
+
function buildTakeOver(card) {
|
|
1042
|
+
const box = el('div', { class: 'kv' })
|
|
1043
|
+
const btn = el('button', { type: 'button', class: 'btn btn-secondary', onclick: async () => {
|
|
1044
|
+
try {
|
|
1045
|
+
const data = await api(`/api/cards/${encodeURIComponent(card.card_id)}/take-over`, { method: 'POST', body: {} })
|
|
1046
|
+
box.textContent = ''
|
|
1047
|
+
box.appendChild(el('p', { class: 'sentence tone-muted' }, ['A terminal cannot be opened from a browser tab, so this is the one command Leg hands you.']))
|
|
1048
|
+
const row = el('div', { class: 'reassign-picker' }, [
|
|
1049
|
+
el('span', { class: 'row-meta mono' }, [data.command]),
|
|
1050
|
+
el('button', { type: 'button', class: 'btn btn-secondary', onclick: () => copyToClipboard(data.command) }, ['Copy']),
|
|
1051
|
+
])
|
|
1052
|
+
box.appendChild(row)
|
|
1053
|
+
} catch (err) { toast(err.message) }
|
|
1054
|
+
} }, ['Take over'])
|
|
1055
|
+
box.appendChild(btn)
|
|
1056
|
+
return detailSection('Take over', 'pauses the card and starts an interactive terminal from its bundle', box)
|
|
1057
|
+
}
|
|
1058
|
+
|
|
857
1059
|
function eventRow(e) {
|
|
858
1060
|
return el('div', { class: 'turn' }, [
|
|
859
1061
|
el('span', { class: 'turn-when' }, [clock(e.ts)]),
|
|
@@ -901,6 +1103,17 @@
|
|
|
901
1103
|
for (const st of stations) pipeline.push(...kvRow(st.name, `${st.kind}${st.name === card.station ? ', this station' : ''}`))
|
|
902
1104
|
content.appendChild(detailSection('Pipeline', at ? `station ${at} of ${stations.length}` : `${plural(stations.length, 'station')}, none started`, el('div', { class: 'kv' }, pipeline)))
|
|
903
1105
|
|
|
1106
|
+
// C.3: the chain rail, the leases and the pipeline above all moved out of
|
|
1107
|
+
// the row and into this expansion; the row itself carries only the model
|
|
1108
|
+
// token and the station name now.
|
|
1109
|
+
content.appendChild(detailSection('Chain', null, buildChainRail(card)))
|
|
1110
|
+
content.appendChild(detailSection('Leases', null, buildLeases(card)))
|
|
1111
|
+
|
|
1112
|
+
const { overflow } = splitActions(card)
|
|
1113
|
+
if (overflow.length) content.appendChild(detailSection('More actions', 'past the four on the row', buildActions(card, overflow)))
|
|
1114
|
+
|
|
1115
|
+
content.appendChild(buildTakeOver(card))
|
|
1116
|
+
|
|
904
1117
|
const runs = detail.runs || []
|
|
905
1118
|
const runRows = []
|
|
906
1119
|
for (const r of runs) runRows.push(...kvRow(`run ${r.run}`, `${r.adapter}, ${r.outcome ?? r.status}, signal ${r.signal ?? 'none'}, exit ${r.exit_code ?? 'none'}`))
|
|
@@ -1012,22 +1225,71 @@
|
|
|
1012
1225
|
if (root && root.slots) root.slots.title.focus()
|
|
1013
1226
|
}
|
|
1014
1227
|
|
|
1228
|
+
// ---- 6.15 step 6: the one-line background entry (C.2) ----
|
|
1229
|
+
// src/board/entry.js OWNS THE ROW. It is on this page and on /floor, and one
|
|
1230
|
+
// sentence that starts work has to post one body from both, so the row lives
|
|
1231
|
+
// in a file both pages load and this file keeps only the names its own code
|
|
1232
|
+
// and its test seams call. `state` is handed over live: the row reads
|
|
1233
|
+
// sessions, cards, preferences, adapters and models on every render, never a
|
|
1234
|
+
// copy taken at mount.
|
|
1235
|
+
const entryUi = window.legEntry.create({
|
|
1236
|
+
el,
|
|
1237
|
+
api,
|
|
1238
|
+
toast,
|
|
1239
|
+
host: state,
|
|
1240
|
+
boxId: 'card-entry',
|
|
1241
|
+
isGuest,
|
|
1242
|
+
onCreated: (card) => upsertCard(card),
|
|
1243
|
+
// this page has the dialog markup, so More settings opens it in place
|
|
1244
|
+
onMoreSettings: () => openNewCardDialog(),
|
|
1245
|
+
// C.2: the row sits under the Background panel, and under Terminals when
|
|
1246
|
+
// there are no live cards to sit under
|
|
1247
|
+
moveUnder: { whenLive: 'background', whenEmpty: '.region-terminals' },
|
|
1248
|
+
})
|
|
1249
|
+
const entryState = entryUi.entryState
|
|
1250
|
+
const knownRepos = () => entryUi.knownRepos()
|
|
1251
|
+
const entryRepo = () => entryUi.entryRepo()
|
|
1252
|
+
const realAdapters = () => entryUi.realAdapters()
|
|
1253
|
+
const ladderAgents = () => entryUi.ladderAgents()
|
|
1254
|
+
const asRung = (agent, model) => entryUi.asRung(agent, model)
|
|
1255
|
+
const ladderLabel = (r) => entryUi.ladderLabel(r)
|
|
1256
|
+
const modelSelect = (agent, model, label, onPick) => entryUi.modelSelect(agent, model, label, onPick)
|
|
1257
|
+
const entryChain = () => entryUi.entryChain()
|
|
1258
|
+
const entryTrunk = (repo) => entryUi.entryTrunk(repo)
|
|
1259
|
+
const renderEntryLine = () => entryUi.renderEntryLine()
|
|
1260
|
+
const placeEntryLine = (hasLive) => entryUi.placeEntryLine(hasLive)
|
|
1261
|
+
|
|
1262
|
+
// sessions.js draws the ladder editor in Settings and needs the same model
|
|
1263
|
+
// catalog, but the two files share no module scope (both are plain scripts
|
|
1264
|
+
// served to the browser), so the one fetch is published here and read there.
|
|
1265
|
+
// Narrow on purpose: the catalog and nothing else of this file's state.
|
|
1266
|
+
function publishModels() {
|
|
1267
|
+
if (typeof window === 'undefined') return
|
|
1268
|
+
window.legBoard = { models: state.models }
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1015
1271
|
// ---- new card dialog ----
|
|
1272
|
+
// Two questions, side by side: WHAT the work is (task, repo, branch) and WHO
|
|
1273
|
+
// runs it. "Who runs it" is the same ladder the one-line entry row walks,
|
|
1274
|
+
// one row per rung and a model select on each, prefilled from preferences so
|
|
1275
|
+
// the dialog opens showing exactly what pressing Start on that row would
|
|
1276
|
+
// have done. Every other field the dialog ever had is under Advanced, and
|
|
1277
|
+
// every one of them still posts.
|
|
1016
1278
|
function newCardDialogEls() {
|
|
1017
1279
|
return {
|
|
1018
1280
|
dialog: document.getElementById('new-card-dialog'),
|
|
1019
1281
|
form: document.getElementById('new-card-form'),
|
|
1020
1282
|
error: document.getElementById('new-card-error'),
|
|
1021
1283
|
repo: document.getElementById('nc-repo'),
|
|
1284
|
+
repoKnown: document.getElementById('nc-repo-known'),
|
|
1022
1285
|
task: document.getElementById('nc-task'),
|
|
1023
|
-
firstAgent: document.getElementById('nc-first-agent'),
|
|
1024
|
-
firstControls: document.getElementById('nc-first-controls'),
|
|
1025
1286
|
testAdapter: document.getElementById('nc-test-adapter'),
|
|
1026
1287
|
fallbackSummary: document.getElementById('nc-fallback-summary'),
|
|
1027
1288
|
pipeline: document.getElementById('nc-pipeline'),
|
|
1028
1289
|
customPipeline: document.getElementById('nc-custom-pipeline'),
|
|
1029
1290
|
chainRows: document.getElementById('nc-chain-rows'),
|
|
1030
1291
|
addRowBtn: document.getElementById('nc-add-row'),
|
|
1292
|
+
saveLadder: document.getElementById('nc-save-ladder'),
|
|
1031
1293
|
leases: document.getElementById('nc-leases'),
|
|
1032
1294
|
trunk: document.getElementById('nc-trunk'),
|
|
1033
1295
|
landMode: document.getElementById('nc-land-mode'),
|
|
@@ -1040,139 +1302,266 @@
|
|
|
1040
1302
|
|
|
1041
1303
|
function adapterLabel(adapter) { return adapter.fake ? `${adapter.name} (test/demo)` : adapter.name }
|
|
1042
1304
|
|
|
1043
|
-
//
|
|
1044
|
-
//
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
return used.filter(Boolean)
|
|
1049
|
-
}
|
|
1050
|
-
|
|
1051
|
-
function addChainRow(ui, { adapter: preferred = null, first = false } = {}) {
|
|
1052
|
-
const adapterSelect = el('select', { 'aria-label': 'Chain adapter' })
|
|
1053
|
-
const adapters = [...(state.adapters || [])].sort((a, b) => Number(a.fake) - Number(b.fake))
|
|
1054
|
-
for (const a of adapters) adapterSelect.appendChild(el('option', { value: a.name }, [adapterLabel(a)]))
|
|
1055
|
-
// Add fallback agent used to default to the first option in the list, which
|
|
1056
|
-
// is normally the agent already chosen as First agent: the summary line then
|
|
1057
|
-
// read "Leg tries claude, then agy, then claude", a fallback that cannot
|
|
1058
|
-
// fire. rebuildDefaultFallbacks already applies this filter.
|
|
1059
|
-
if (!preferred && !first) {
|
|
1060
|
-
const used = chosenAdapters(ui)
|
|
1061
|
-
preferred = adapters.filter((a) => !a.fake).map((a) => a.name).find((name) => !used.includes(name)) || null
|
|
1062
|
-
}
|
|
1063
|
-
if (preferred && adapters.some((a) => a.name === preferred)) adapterSelect.value = preferred
|
|
1064
|
-
const modeSelect = el('select', { 'aria-label': 'Chain mode' })
|
|
1065
|
-
const approveCheckbox = el('input', { type: 'checkbox', 'aria-label': 'Approve before this leg' })
|
|
1066
|
-
const approveLabel = el('label', {}, [approveCheckbox, ' approval before start'])
|
|
1067
|
-
const turnsInput = el('input', { type: 'number', min: '0', 'aria-label': 'Max turns', placeholder: 'max turns' })
|
|
1068
|
-
const fakeInput = el('input', { type: 'text', 'aria-label': 'Scripted test behavior', placeholder: 'test behavior' })
|
|
1069
|
-
const removeBtn = first ? null : el('button', { type: 'button', class: 'btn btn-danger', 'aria-label': 'Remove fallback agent' }, ['Remove'])
|
|
1070
|
-
const title = el('span', { class: 'fallback-row-title' }, [first ? `First: ${preferred}` : `Fallback ${ui.chainRows.children.length + 1}`])
|
|
1071
|
-
const row = el('div', { class: `chain-row${first ? '' : ' fallback-row'}` }, [title, adapterSelect, modeSelect, approveLabel, turnsInput, fakeInput, removeBtn])
|
|
1072
|
-
if (first) adapterSelect.hidden = true
|
|
1073
|
-
if (removeBtn) removeBtn.addEventListener('click', () => { row.remove(); refreshFallbackSummary(ui) })
|
|
1305
|
+
// The rows as DATA. The DOM used to be the record: a row's values were read
|
|
1306
|
+
// back off its own inputs, which works until rows can move, because moving a
|
|
1307
|
+
// row means rebuilding it and a rebuilt input is empty. Reorder, remove and
|
|
1308
|
+
// renumber are all list operations here, and the DOM is redrawn from the list.
|
|
1309
|
+
let ncRows = []
|
|
1074
1310
|
|
|
1075
|
-
|
|
1076
|
-
modeSelect.textContent = ''
|
|
1077
|
-
const adapter = (state.adapters || []).find((a) => a.name === adapterSelect.value)
|
|
1078
|
-
const allowed = adapter ? adapter.modes.allowed : []
|
|
1079
|
-
for (const m of allowed) modeSelect.appendChild(el('option', { value: m }, [MODE_LABELS[m] ? `${MODE_LABELS[m]} (${m})` : m]))
|
|
1080
|
-
if (adapter && adapter.modes.default) modeSelect.value = adapter.modes.default
|
|
1081
|
-
fakeInput.hidden = !(adapter && adapter.fake)
|
|
1082
|
-
}
|
|
1083
|
-
adapterSelect.addEventListener('change', populateModes)
|
|
1084
|
-
// the summary sentence names the fallback agents by row, so it has to
|
|
1085
|
-
// follow a row whose agent the reader changed after it was added
|
|
1086
|
-
if (!first) adapterSelect.addEventListener('change', () => refreshFallbackSummary(ui))
|
|
1087
|
-
populateModes()
|
|
1311
|
+
function ncAdapter(name) { return (state.adapters || []).find((a) => a.name === name) || null }
|
|
1088
1312
|
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1313
|
+
function ncRow(agent, model) {
|
|
1314
|
+
const adapter = ncAdapter(agent)
|
|
1315
|
+
return {
|
|
1316
|
+
agent: adapter ? adapter.name : agent,
|
|
1317
|
+
model: model || '',
|
|
1318
|
+
mode: adapter && adapter.modes ? (adapter.modes.default || '') : '',
|
|
1319
|
+
approve: false, turns: '', fake: '',
|
|
1320
|
+
}
|
|
1093
1321
|
}
|
|
1094
1322
|
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1323
|
+
// the first installed agent no row already names: a fallback that repeats the
|
|
1324
|
+
// row above it can never fire
|
|
1325
|
+
function ncNextAgent() {
|
|
1326
|
+
const used = ncRows.map((r) => r.agent)
|
|
1327
|
+
const real = realAdapters()
|
|
1328
|
+
return real.find((a) => !used.includes(a)) || real[0] || ((state.adapters || [])[0] || {}).name || 'claude'
|
|
1100
1329
|
}
|
|
1101
1330
|
|
|
1102
|
-
|
|
1103
|
-
ui.firstControls.textContent = ''
|
|
1104
|
-
addChainRow(ui, { adapter: ui.testAdapter.value || ui.firstAgent.value, first: true })
|
|
1105
|
-
}
|
|
1331
|
+
const ncLeg = (r) => (r.model ? `${r.agent}/${r.model}` : r.agent)
|
|
1106
1332
|
|
|
1107
|
-
function
|
|
1333
|
+
function refreshFallbackSummary(ui) {
|
|
1334
|
+
const legs = ncRows.map(ncLeg)
|
|
1335
|
+
ui.fallbackSummary.textContent = legs.length > 1
|
|
1336
|
+
? `Leg starts on ${legs[0]}, and tries ${legs.slice(1).join(', then ')} only when the row before it cannot continue.`
|
|
1337
|
+
: legs.length === 1
|
|
1338
|
+
? `Leg runs ${legs[0]} and stops there. Add a fallback to hand the work on when it cannot continue.`
|
|
1339
|
+
: 'No agent is set. Add a row, or this card has nothing to run it.'
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
// `focusKey` is the control the reader should still be on after the redraw.
|
|
1343
|
+
// Every row is destroyed and rebuilt here, so the button a keyboard user just
|
|
1344
|
+
// pressed Enter on is gone and focus falls to <body>: pressing Up twice meant
|
|
1345
|
+
// tabbing back through every control above it. Same idea as sessions.js's
|
|
1346
|
+
// takeFocus/putFocus pair (data-focus-key), but the intent is passed in rather
|
|
1347
|
+
// than read off document.activeElement, because the moved row's key is known
|
|
1348
|
+
// at the press and the row it lands on is a different index.
|
|
1349
|
+
function renderChainRows(ui, focusKey) {
|
|
1108
1350
|
ui.chainRows.textContent = ''
|
|
1109
|
-
const
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1351
|
+
const keyed = new Map()
|
|
1352
|
+
ncRows.forEach((row, index) => {
|
|
1353
|
+
// every control on the row is named for the step it belongs to, so a
|
|
1354
|
+
// screen reader hears "Model for fallback 2" and not "Chain adapter"
|
|
1355
|
+
const step = index === 0 ? 'the first agent' : `fallback ${index}`
|
|
1356
|
+
const adapter = ncAdapter(row.agent)
|
|
1357
|
+
const box = el('div', { class: 'chain-row' })
|
|
1358
|
+
box.appendChild(el('span', { class: 'chain-num' }, [`${index + 1}.`]))
|
|
1359
|
+
|
|
1360
|
+
const who = el('select', { 'aria-label': `Provider for ${step}` })
|
|
1361
|
+
for (const a of [...(state.adapters || [])].sort((x, y) => Number(x.fake) - Number(y.fake))) who.appendChild(el('option', { value: a.name }, [adapterLabel(a)]))
|
|
1362
|
+
who.value = row.agent
|
|
1363
|
+
who.addEventListener('change', () => {
|
|
1364
|
+
row.agent = who.value
|
|
1365
|
+
// a model belongs to one provider: carrying gpt-5.6-luna over to claude
|
|
1366
|
+
// would post a model that CLI has never heard of
|
|
1367
|
+
row.model = ''
|
|
1368
|
+
const next = ncAdapter(row.agent)
|
|
1369
|
+
row.mode = next && next.modes ? (next.modes.default || '') : ''
|
|
1370
|
+
renderChainRows(ui)
|
|
1371
|
+
})
|
|
1372
|
+
box.appendChild(who)
|
|
1373
|
+
|
|
1374
|
+
box.appendChild(modelSelect(row.agent, row.model, `Model for ${step}`, (v) => { row.model = v; refreshFallbackSummary(ui) }))
|
|
1375
|
+
|
|
1376
|
+
const mode = el('select', { 'aria-label': `Permissions for ${step}` })
|
|
1377
|
+
for (const m of (adapter && adapter.modes ? adapter.modes.allowed : [])) mode.appendChild(el('option', { value: m }, [MODE_LABELS[m] ? `${MODE_LABELS[m]} (${m})` : m]))
|
|
1378
|
+
mode.value = row.mode || (adapter && adapter.modes ? adapter.modes.default : '')
|
|
1379
|
+
mode.addEventListener('change', () => { row.mode = mode.value })
|
|
1380
|
+
box.appendChild(mode)
|
|
1381
|
+
|
|
1382
|
+
const approve = el('input', { type: 'checkbox', 'aria-label': `Ask before ${step} starts` })
|
|
1383
|
+
approve.checked = row.approve
|
|
1384
|
+
approve.addEventListener('change', () => { row.approve = approve.checked })
|
|
1385
|
+
box.appendChild(el('label', { class: 'chain-toggle' }, [approve, ' ask before start']))
|
|
1386
|
+
|
|
1387
|
+
const turns = el('input', { type: 'number', min: '1', class: 'chain-turns', 'aria-label': `Max turns for ${step}`, placeholder: 'max turns', value: row.turns })
|
|
1388
|
+
turns.addEventListener('input', () => { row.turns = turns.value })
|
|
1389
|
+
box.appendChild(turns)
|
|
1390
|
+
|
|
1391
|
+
if (adapter && adapter.fake) {
|
|
1392
|
+
const fake = el('input', { type: 'text', class: 'chain-fake', 'aria-label': `Scripted behaviour for ${step}`, placeholder: 'test behavior', value: row.fake })
|
|
1393
|
+
fake.addEventListener('input', () => { row.fake = fake.value })
|
|
1394
|
+
box.appendChild(fake)
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
const up = el('button', { type: 'button', class: 'btn btn-secondary', 'aria-label': `Move ${step} earlier`, 'data-focus-key': `chain:${index}:up`, disabled: index === 0 ? '' : null }, ['Up'])
|
|
1398
|
+
const down = el('button', { type: 'button', class: 'btn btn-secondary', 'aria-label': `Move ${step} later`, 'data-focus-key': `chain:${index}:down`, disabled: index === ncRows.length - 1 ? '' : null }, ['Down'])
|
|
1399
|
+
const drop = el('button', { type: 'button', class: 'btn btn-danger', 'aria-label': `Remove ${step}`, 'data-focus-key': `chain:${index}:remove`, disabled: ncRows.length < 2 ? '' : null }, ['Remove'])
|
|
1400
|
+
// the key names where the row LANDS, not where it was pressed
|
|
1401
|
+
up.addEventListener('click', () => { ncRows.splice(index - 1, 0, ncRows.splice(index, 1)[0]); renderChainRows(ui, `chain:${index - 1}:up`) })
|
|
1402
|
+
down.addEventListener('click', () => { ncRows.splice(index + 1, 0, ncRows.splice(index, 1)[0]); renderChainRows(ui, `chain:${index + 1}:down`) })
|
|
1403
|
+
drop.addEventListener('click', () => { ncRows.splice(index, 1); renderChainRows(ui, `chain:${Math.min(index, ncRows.length - 1)}:remove`) })
|
|
1404
|
+
box.append(up, down, drop)
|
|
1405
|
+
keyed.set(`chain:${index}:up`, up)
|
|
1406
|
+
keyed.set(`chain:${index}:down`, down)
|
|
1407
|
+
keyed.set(`chain:${index}:remove`, drop)
|
|
1408
|
+
|
|
1409
|
+
ui.chainRows.appendChild(box)
|
|
1410
|
+
})
|
|
1113
1411
|
refreshFallbackSummary(ui)
|
|
1412
|
+
if (focusKey) restoreChainFocus(ui, keyed, focusKey)
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
// A row moved to either end loses the button that moved it, and the last row
|
|
1416
|
+
// standing cannot be removed, so the focus goes to the nearest live control on
|
|
1417
|
+
// that row and, when the row itself is gone, to Add a fallback.
|
|
1418
|
+
function restoreChainFocus(ui, keyed, focusKey) {
|
|
1419
|
+
const at = focusKey.split(':')[1]
|
|
1420
|
+
let target = keyed.get(focusKey) || null
|
|
1421
|
+
if (!target || target.disabled) {
|
|
1422
|
+
target = [`chain:${at}:up`, `chain:${at}:down`, `chain:${at}:remove`]
|
|
1423
|
+
.map((k) => keyed.get(k))
|
|
1424
|
+
.find((node) => node && !node.disabled) || ui.addRowBtn || null
|
|
1425
|
+
}
|
|
1426
|
+
if (target && typeof target.focus === 'function') target.focus({ preventScroll: true })
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
// "Save as my default ladder": the rows become preferences.handoff_ladder,
|
|
1430
|
+
// which the entry row and every new terminal read. A scripted test adapter is
|
|
1431
|
+
// never a rung and the server refuses one, so it is dropped here with a
|
|
1432
|
+
// sentence rather than sent and refused; two rows naming the same agent and
|
|
1433
|
+
// model are one rung, for the same reason.
|
|
1434
|
+
async function saveLadderFromRows(rows) {
|
|
1435
|
+
const real = realAdapters()
|
|
1436
|
+
// and of those, the agents a SAVED ladder may name. A custom adapter added
|
|
1437
|
+
// with `leg adapter add` is a real agent and runs a card, but preferences
|
|
1438
|
+
// takes a closed list and refuses the whole array over one rung it does not
|
|
1439
|
+
// know, so the claude rung beside a custom one was never written either.
|
|
1440
|
+
const saveable = ladderAgents()
|
|
1441
|
+
const dropped = []
|
|
1442
|
+
const seen = new Set()
|
|
1443
|
+
const ladder = []
|
|
1444
|
+
for (const r of rows) {
|
|
1445
|
+
if (!real.includes(r.agent)) continue
|
|
1446
|
+
if (!saveable.includes(r.agent)) { if (!dropped.includes(r.agent)) dropped.push(r.agent); continue }
|
|
1447
|
+
const key = `${r.agent}/${r.model || ''}`
|
|
1448
|
+
if (seen.has(key)) continue
|
|
1449
|
+
seen.add(key)
|
|
1450
|
+
ladder.push(asRung(r.agent, r.model || null))
|
|
1451
|
+
}
|
|
1452
|
+
const left = dropped.length ? ` ${dropped.join(', ')} ${dropped.length > 1 ? 'were' : 'was'} left off: the default ladder keeps only the agents Settings can express (${saveable.join(', ')}).` : ''
|
|
1453
|
+
if (!ladder.length) {
|
|
1454
|
+
toast(dropped.length
|
|
1455
|
+
? `The default ladder was left alone: it would keep no rung at all.${left}`
|
|
1456
|
+
: 'The default ladder was left alone: a scripted test agent cannot be a rung.')
|
|
1457
|
+
return
|
|
1458
|
+
}
|
|
1459
|
+
try {
|
|
1460
|
+
const data = await api('/api/settings', { method: 'PATCH', body: { handoff_ladder: ladder } })
|
|
1461
|
+
state.preferences = data.preferences || state.preferences
|
|
1462
|
+
entryState.ladderStart = 0
|
|
1463
|
+
entryState.model = undefined
|
|
1464
|
+
renderEntryLine()
|
|
1465
|
+
toast(`Saved as your default ladder: ${ladder.map(ladderLabel).join(' then ')}.${left}`)
|
|
1466
|
+
} catch (err) {
|
|
1467
|
+
toast(`The card was created. The default ladder was not saved: ${err.message}`)
|
|
1468
|
+
}
|
|
1114
1469
|
}
|
|
1115
1470
|
|
|
1116
1471
|
async function openNewCardDialog() {
|
|
1117
1472
|
if (!state.adapters) {
|
|
1118
1473
|
try { state.adapters = (await api('/api/adapters')).adapters } catch (err) { toast(err.message); return }
|
|
1119
1474
|
}
|
|
1475
|
+
// neither of these stops the dialog opening: without a catalog every model
|
|
1476
|
+
// select offers the provider default, and without preferences the rows fall
|
|
1477
|
+
// back to the agents that are installed
|
|
1478
|
+
if (!state.models) { try { state.models = (await api('/api/models')).models; publishModels() } catch { /* provider default only */ } }
|
|
1479
|
+
if (!state.preferences) { try { state.preferences = (await api('/api/settings')).preferences || null } catch { /* installed adapters only */ } }
|
|
1480
|
+
|
|
1120
1481
|
const ui = newCardDialogEls()
|
|
1121
1482
|
ui.form.reset()
|
|
1122
1483
|
ui.error.hidden = true
|
|
1123
1484
|
ui.error.textContent = ''
|
|
1485
|
+
// More settings is the same sentence with more fields, so the sentence
|
|
1486
|
+
// comes with it: on this page from the row above, and from /floor through
|
|
1487
|
+
// the #new-card hash, which is how that page reaches this dialog without a
|
|
1488
|
+
// second copy of its markup.
|
|
1489
|
+
if (entryState.task && entryState.task.trim()) ui.task.value = entryState.task
|
|
1490
|
+
// the workflow is one of the row's three nouns and it went the same way the
|
|
1491
|
+
// task does. form.reset() above puts the select back to the option marked
|
|
1492
|
+
// selected in the markup (build), so this has to run after it.
|
|
1493
|
+
if (entryState.pipeline) ui.pipeline.value = entryState.pipeline
|
|
1124
1494
|
ui.customPipeline.hidden = ui.pipeline.value !== 'custom'
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1495
|
+
|
|
1496
|
+
// the repo and branch the entry row would have used, and every repo this
|
|
1497
|
+
// board has seen, so the commonest case is already filled in
|
|
1498
|
+
const repo = entryRepo()
|
|
1499
|
+
ui.repoKnown.textContent = ''
|
|
1500
|
+
for (const r of knownRepos()) ui.repoKnown.appendChild(el('option', { value: r.path }, [r.name]))
|
|
1501
|
+
ui.repoKnown.appendChild(el('option', { value: '' }, ['Another path, typed below']))
|
|
1502
|
+
ui.repoKnown.value = repo ? repo.path : ''
|
|
1503
|
+
ui.repo.value = repo ? repo.path : ''
|
|
1504
|
+
ui.trunk.value = entryTrunk(repo) || 'main'
|
|
1505
|
+
|
|
1129
1506
|
ui.testAdapter.textContent = ''
|
|
1130
|
-
ui.testAdapter.appendChild(el('option', { value: '' }, ['Use the real first
|
|
1507
|
+
ui.testAdapter.appendChild(el('option', { value: '' }, ['Use the real first row above']))
|
|
1131
1508
|
for (const adapter of (state.adapters || []).filter((a) => a.fake)) ui.testAdapter.appendChild(el('option', { value: adapter.name }, [adapterLabel(adapter)]))
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1509
|
+
|
|
1510
|
+
const rungs = entryChain()
|
|
1511
|
+
ncRows = rungs.length ? rungs.map((r) => ncRow(r.agent, r.model || '')) : []
|
|
1512
|
+
if (!ncRows.length && (state.adapters || []).length) ncRows = [ncRow(ncNextAgent(), '')]
|
|
1513
|
+
ui.saveLadder.checked = false
|
|
1514
|
+
renderChainRows(ui)
|
|
1136
1515
|
ui.dialog.showModal()
|
|
1137
1516
|
}
|
|
1138
1517
|
|
|
1139
1518
|
async function submitNewCard(e) {
|
|
1140
1519
|
e.preventDefault()
|
|
1141
1520
|
const ui = newCardDialogEls()
|
|
1142
|
-
const
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1521
|
+
const fail = (msg) => { ui.error.hidden = false; ui.error.textContent = msg }
|
|
1522
|
+
const rows = ncRows.filter((r) => r.agent)
|
|
1523
|
+
if (!rows.length) return fail('Add at least one row under Who runs it: a card needs an agent to run it.')
|
|
1524
|
+
if (!ui.repo.value.trim()) return fail('Name the repository this card works in.')
|
|
1525
|
+
// One object per row, not a comma list plus four adapter-keyed strings.
|
|
1526
|
+
// The keyed form could only ever carry one mode, one turn limit and one
|
|
1527
|
+
// model PER ADAPTER, so a chain of claude/fable then claude/opus lost the
|
|
1528
|
+
// difference between its own two rows. src/pipeline.mjs normalizeChainEntry
|
|
1529
|
+
// takes every one of these fields per entry.
|
|
1530
|
+
const chain = rows.map((r) => ({
|
|
1531
|
+
adapter: r.agent,
|
|
1532
|
+
...(r.model ? { model: r.model } : {}),
|
|
1533
|
+
...(r.mode ? { mode: r.mode } : {}),
|
|
1534
|
+
...(String(r.turns).trim() ? { maxTurns: Number(String(r.turns).trim()) } : {}),
|
|
1535
|
+
...(r.approve ? { approve: true } : {}),
|
|
1536
|
+
...(String(r.fake).trim() ? { fakeMode: String(r.fake).trim() } : {}),
|
|
1537
|
+
}))
|
|
1152
1538
|
|
|
1153
1539
|
const body = {
|
|
1154
1540
|
repo: ui.repo.value.trim(),
|
|
1155
1541
|
task: ui.task.value.trim(),
|
|
1156
|
-
chain
|
|
1542
|
+
chain,
|
|
1157
1543
|
pipeline: ui.pipeline.value === 'custom' ? ui.customPipeline.value.trim() : ui.pipeline.value,
|
|
1158
1544
|
leases: ui.leases.value.trim(),
|
|
1159
1545
|
trunk: ui.trunk.value.trim() || 'main',
|
|
1160
1546
|
land_mode: ui.landMode.value,
|
|
1161
1547
|
test_command: ui.testCommand.value.trim(),
|
|
1162
1548
|
title: ui.title.value.trim(),
|
|
1163
|
-
mode: rows.filter((r) => r.mode).map((r) => `${r.adapter}=${r.mode}`).join(','),
|
|
1164
|
-
approve: rows.filter((r) => r.approve).map((r) => r.adapter).join(','),
|
|
1165
|
-
maxTurns: rows.filter((r) => r.turns).map((r) => `${r.adapter}=${r.turns}`).join(','),
|
|
1166
|
-
fake_mode: rows.filter((r) => r.fake).map((r) => `${r.adapter}=${r.fake}`).join(','),
|
|
1167
1549
|
queue: ui.queue.checked,
|
|
1168
1550
|
}
|
|
1169
1551
|
try {
|
|
1170
1552
|
const data = await api('/api/cards', { method: 'POST', body })
|
|
1553
|
+
// the card exists now: a ladder that will not save is a toast, never a
|
|
1554
|
+
// reason to leave the dialog open over a card that was already created
|
|
1555
|
+
if (ui.saveLadder.checked) await saveLadderFromRows(rows)
|
|
1171
1556
|
ui.dialog.close()
|
|
1557
|
+
// the sentence was sent, so the row that carried it here is spent: a row
|
|
1558
|
+
// left armed makes the next Start post the same card a second time.
|
|
1559
|
+
// upsertCard redraws the row, so there is no render call to add.
|
|
1560
|
+
entryState.task = ''
|
|
1561
|
+
entryState.editing = null
|
|
1172
1562
|
upsertCard(data.card)
|
|
1173
1563
|
} catch (err) {
|
|
1174
|
-
|
|
1175
|
-
ui.error.textContent = err.message
|
|
1564
|
+
fail(err.message)
|
|
1176
1565
|
}
|
|
1177
1566
|
}
|
|
1178
1567
|
|
|
@@ -1181,9 +1570,24 @@
|
|
|
1181
1570
|
ui.form.addEventListener('submit', submitNewCard)
|
|
1182
1571
|
ui.cancel.addEventListener('click', () => ui.dialog.close())
|
|
1183
1572
|
ui.pipeline.addEventListener('change', () => { ui.customPipeline.hidden = ui.pipeline.value !== 'custom' })
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1573
|
+
// the picker fills the path field rather than replacing it: the path is
|
|
1574
|
+
// what gets posted, and a reader who wants a repo the board has never seen
|
|
1575
|
+
// types it in the same box
|
|
1576
|
+
ui.repoKnown.addEventListener('change', () => {
|
|
1577
|
+
if (!ui.repoKnown.value) { ui.repo.focus(); return }
|
|
1578
|
+
ui.repo.value = ui.repoKnown.value
|
|
1579
|
+
const known = knownRepos().find((r) => r.path === ui.repoKnown.value) || { path: ui.repoKnown.value, name: ui.repoKnown.value }
|
|
1580
|
+
ui.trunk.value = entryTrunk(known) || 'main'
|
|
1581
|
+
})
|
|
1582
|
+
// unchanged meaning: the scripted adapter replaces the agent on the first
|
|
1583
|
+
// row, and clearing it puts the first real agent back
|
|
1584
|
+
ui.testAdapter.addEventListener('change', () => {
|
|
1585
|
+
if (!ncRows.length) ncRows = [ncRow(ncNextAgent(), '')]
|
|
1586
|
+
const real = realAdapters()
|
|
1587
|
+
ncRows[0] = ncRow(ui.testAdapter.value || real[0] || ncRows[0].agent, '')
|
|
1588
|
+
renderChainRows(ui)
|
|
1589
|
+
})
|
|
1590
|
+
ui.addRowBtn.addEventListener('click', () => { ncRows.push(ncRow(ncNextAgent(), '')); renderChainRows(ui) })
|
|
1187
1591
|
}
|
|
1188
1592
|
|
|
1189
1593
|
// ---- 6.10 settings: the last region of the page, in flow ----
|
|
@@ -1235,6 +1639,15 @@
|
|
|
1235
1639
|
if (meta) meta.textContent = panel.meta
|
|
1236
1640
|
}
|
|
1237
1641
|
|
|
1642
|
+
// The process behind the page is older or newer than the page itself. Said
|
|
1643
|
+
// once, as an error that stays until dismissed, because everything the reader
|
|
1644
|
+
// sees from here on is drawn by files the process does not know about.
|
|
1645
|
+
function versionSkew(processVersion) {
|
|
1646
|
+
if (!processVersion || processVersion === FILES_VERSION) return false
|
|
1647
|
+
toast(`This board process runs leg ${processVersion} and the page files are ${FILES_VERSION}. Restart it to match: leg down && leg up`)
|
|
1648
|
+
return true
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1238
1651
|
function renderBoardFacts(health) {
|
|
1239
1652
|
const box = document.querySelector('.region-settings .board-facts')
|
|
1240
1653
|
if (!box) return
|
|
@@ -1242,8 +1655,9 @@
|
|
|
1242
1655
|
const you = health.you || {}
|
|
1243
1656
|
const share = you.share || { on: false, people: 0 }
|
|
1244
1657
|
const sched = health.scheduler
|
|
1658
|
+
const skew = health.version && health.version !== FILES_VERSION ? `, page files ${FILES_VERSION}` : ''
|
|
1245
1659
|
const lines = [
|
|
1246
|
-
`leg ${health.version}, bound to ${state.bind}`,
|
|
1660
|
+
`leg ${health.version}${skew}, bound to ${state.bind}`,
|
|
1247
1661
|
`signed in as ${you.name || 'local'}, ${you.role || 'owner'}`,
|
|
1248
1662
|
share.on ? `share on, ${share.people === 1 ? '1 person' : `${share.people} people`}` : 'share off, nobody invited',
|
|
1249
1663
|
]
|
|
@@ -1292,6 +1706,39 @@
|
|
|
1292
1706
|
renderTokenMeta()
|
|
1293
1707
|
}
|
|
1294
1708
|
|
|
1709
|
+
// ---- what /floor sends over in the address bar ----
|
|
1710
|
+
// The floor starts cards from its own copy of the entry row, but the New card
|
|
1711
|
+
// dialog's markup exists once, on this page, so the floor's More settings and
|
|
1712
|
+
// its card titles are links here. `#new-card` opens the dialog, `#new-card=<task>`
|
|
1713
|
+
// opens it with the sentence the reader had already typed, and `#card=<id>`
|
|
1714
|
+
// expands that card's detail region. The hash is cleared once it is acted on:
|
|
1715
|
+
// a reload should not reopen a dialog the reader closed.
|
|
1716
|
+
async function openFromHash() {
|
|
1717
|
+
const hash = decodeURIComponent(String((location && location.hash) || '').replace(/^#/, ''))
|
|
1718
|
+
if (!hash) return
|
|
1719
|
+
const clear = () => { try { history.replaceState(null, '', location.pathname + location.search) } catch { /* a browser that refuses is still on the right page */ } }
|
|
1720
|
+
if (hash.startsWith('new-card')) {
|
|
1721
|
+
// `#new-card=<task>&pipeline=<p>` or `#new-card?pipeline=<p>`: the floor's
|
|
1722
|
+
// entry row sends both, and the dialog opens on what the reader chose
|
|
1723
|
+
const rest = hash.slice('new-card'.length)
|
|
1724
|
+
const m = rest.match(/[&?]pipeline=([a-z_-]+)$/)
|
|
1725
|
+
const task = rest.replace(/[&?]pipeline=[a-z_-]+$/, '').replace(/^=/, '')
|
|
1726
|
+
if (task) entryState.task = task
|
|
1727
|
+
// an unknown word is harmless: the dialog's select ignores a value it has no option for
|
|
1728
|
+
if (m) entryState.pipeline = m[1]
|
|
1729
|
+
if (task || m) renderEntryLine()
|
|
1730
|
+
clear()
|
|
1731
|
+
await openNewCardDialog()
|
|
1732
|
+
return
|
|
1733
|
+
}
|
|
1734
|
+
if (hash.startsWith('card=')) {
|
|
1735
|
+
const id = hash.slice('card='.length)
|
|
1736
|
+
clear()
|
|
1737
|
+
if (!state.cards.has(id)) await fetchCards()
|
|
1738
|
+
if (state.cards.has(id)) expandRow(id)
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1295
1742
|
// ---- init ----
|
|
1296
1743
|
async function init() {
|
|
1297
1744
|
initSettings()
|
|
@@ -1305,10 +1752,27 @@
|
|
|
1305
1752
|
if (state.drawerId) collapseRow()
|
|
1306
1753
|
})
|
|
1307
1754
|
// health first: it says whether this human owns the pipeline side at all
|
|
1308
|
-
await loadHealth()
|
|
1755
|
+
const owner = await loadHealth()
|
|
1309
1756
|
fetchCards()
|
|
1310
1757
|
connectSse()
|
|
1311
1758
|
setInterval(tickElapsed, 1000)
|
|
1759
|
+
// C.2: the ladder sentence on the entry line reads this; a guest never
|
|
1760
|
+
// sees the entry line, so there is nothing to fetch it for
|
|
1761
|
+
if (owner) {
|
|
1762
|
+
// three independent reads, so they go out together: the ladder the entry
|
|
1763
|
+
// line names, the adapters that are actually installed (the fallback when
|
|
1764
|
+
// there is no ladder at all), and the model catalog both selects use
|
|
1765
|
+
await Promise.all([
|
|
1766
|
+
api('/api/settings').then((d) => { state.preferences = d.preferences || null }).catch(() => {}),
|
|
1767
|
+
api('/api/adapters').then((d) => { state.adapters = d.adapters || null }).catch(() => {}),
|
|
1768
|
+
api('/api/models').then((d) => { state.models = d.models || null }).catch(() => {}),
|
|
1769
|
+
])
|
|
1770
|
+
publishModels()
|
|
1771
|
+
renderEntryLine()
|
|
1772
|
+
// last, so the dialog opens over a page that already knows its ladder,
|
|
1773
|
+
// its repos and its cards
|
|
1774
|
+
await openFromHash()
|
|
1775
|
+
}
|
|
1312
1776
|
}
|
|
1313
1777
|
|
|
1314
1778
|
document.addEventListener('DOMContentLoaded', init)
|