@ucsandman/legcli 0.10.0 → 0.12.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 +212 -0
- package/README.md +158 -67
- package/bin/leg.mjs +168 -18
- package/docs/DECISIONS.md +10 -0
- package/docs/DEMO.md +20 -14
- package/docs/DEVIATIONS.md +1 -0
- package/docs/ERRORS.md +94 -0
- package/docs/ROADMAP-v2.md +69 -11
- package/docs/VOCABULARY.md +27 -0
- package/docs/adapters.md +93 -11
- package/docs/board-guide.md +401 -66
- package/docs/cli-contracts.md +235 -22
- package/docs/concepts.md +167 -19
- package/docs/configuration.md +113 -5
- 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/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/limits/grok/grok-balance-exhausted.json +11 -0
- package/fixtures/live/claude/resume-model-probe.json +20 -0
- package/fixtures/live/claude/usage-oauth.json +87 -0
- package/fixtures/live/grok/cmd.txt +1 -1
- package/fixtures/live/grok/parsed.json +6 -3
- package/fixtures/live/grok/run.json +22 -10
- package/fixtures/verified.json +8 -1
- package/package.json +3 -2
- package/scripts/build-docs-site.mjs +4 -4
- package/scripts/probe.mjs +2 -1
- package/scripts/seed-fake-cards.mjs +59 -6
- package/scripts/seed-wes-board.mjs +81 -12
- package/src/accounts.mjs +6 -1
- package/src/adapters/cli.mjs +130 -0
- package/src/adapters/custom.mjs +271 -0
- package/src/adapters/grok.mjs +51 -10
- package/src/adapters/index.mjs +34 -7
- package/src/attach.mjs +350 -42
- package/src/audit.mjs +118 -0
- package/src/board/audit.js +123 -0
- package/src/board/board.css +134 -9
- package/src/board/board.js +482 -106
- package/src/board/index.html +89 -7
- package/src/board/sessions.js +1371 -113
- 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/orchestrator.mjs +13 -4
- package/src/preferences.mjs +214 -5
- package/src/scheduler.mjs +24 -1
- package/src/server.mjs +615 -50
- package/src/sessions.mjs +17 -1
- package/src/share.mjs +66 -6
- package/src/taps/claude-usage.mjs +91 -2
- package/src/taps/claude.mjs +144 -5
- package/src/taps/codex.mjs +23 -3
- package/src/taps/grok.mjs +4 -0
- package/src/usage.mjs +424 -13
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 = {
|
|
@@ -101,6 +106,12 @@
|
|
|
101
106
|
healthKnown: false,
|
|
102
107
|
bindKnown: false,
|
|
103
108
|
lastHello: null,
|
|
109
|
+
// C.2: the one-line entry infers its repo from the most recently focused
|
|
110
|
+
// terminal, published by sessions.js on the leg:sessions window event.
|
|
111
|
+
sessions: [],
|
|
112
|
+
// one entry per repo with a live terminal: { repo, repo_name, branch }
|
|
113
|
+
repoTrunks: [],
|
|
114
|
+
preferences: null,
|
|
104
115
|
}
|
|
105
116
|
// a card push while an agent writes its log only moves these two
|
|
106
117
|
const VOLATILE_CARD_FIELDS = ['last_event', 'elapsed_ms']
|
|
@@ -216,20 +227,19 @@
|
|
|
216
227
|
}
|
|
217
228
|
|
|
218
229
|
// 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
|
-
//
|
|
230
|
+
// not a zero-length run: both print the placeholder rather than a number.
|
|
231
|
+
// A running card counts up from its current run, in the column the terminal
|
|
232
|
+
// rows above hold their run clock in. A live card BETWEEN runs has no run
|
|
233
|
+
// clock at all: created_at to updated_at is the card's whole age, which for
|
|
234
|
+
// a card that sat in the backlog for a day reads `24:05:00` in a column that
|
|
235
|
+
// means "this run", so it is labelled for what it is instead: how long it
|
|
236
|
+
// has been sitting in the state it is in.
|
|
224
237
|
function runElapsed(card) {
|
|
225
238
|
const from = card.active_run ? Date.parse(card.active_run.started_at) : NaN
|
|
226
239
|
if (Number.isFinite(from)) return elapsedClock(Date.now() - from)
|
|
227
240
|
if (!card.runs_count) return '--:--'
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
return Number.isFinite(started) && Number.isFinite(ended) && ended >= started
|
|
231
|
-
? elapsedClock(ended - started)
|
|
232
|
-
: '--:--'
|
|
241
|
+
const since = Date.parse(card.updated_at)
|
|
242
|
+
return Number.isFinite(since) && since <= Date.now() ? `idle ${agoShort(Date.now() - since)}` : '--:--'
|
|
233
243
|
}
|
|
234
244
|
|
|
235
245
|
function clockAt(ms) {
|
|
@@ -301,6 +311,21 @@
|
|
|
301
311
|
window.dispatchEvent(new CustomEvent('baton:sessions', { detail }))
|
|
302
312
|
}
|
|
303
313
|
|
|
314
|
+
// the entry line's repo inference reads the latest sessions payload without
|
|
315
|
+
// owning the terminals region: sessions.js still renders it, this file only
|
|
316
|
+
// keeps a copy for "the most recently focused terminal's repo". Guarded the
|
|
317
|
+
// same way window.legMessage is above: the pure-logic test harness passes a
|
|
318
|
+
// window stub with no addEventListener at all.
|
|
319
|
+
if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
|
|
320
|
+
window.addEventListener('leg:sessions', (e) => {
|
|
321
|
+
state.sessions = (e.detail && e.detail.sessions) || []
|
|
322
|
+
// each repo's own default branch, read once per push by the server: the
|
|
323
|
+
// entry line's `on <branch>` and the trunk it posts come from here
|
|
324
|
+
state.repoTrunks = (e.detail && e.detail.trunk) || []
|
|
325
|
+
renderEntryLine()
|
|
326
|
+
})
|
|
327
|
+
}
|
|
328
|
+
|
|
304
329
|
function connectSse() {
|
|
305
330
|
if (state.es) { try { state.es.close() } catch { /* ignore */ } }
|
|
306
331
|
const request = ++state.sseRequest
|
|
@@ -416,60 +441,15 @@
|
|
|
416
441
|
}
|
|
417
442
|
}
|
|
418
443
|
|
|
419
|
-
// ---- 6.12 step
|
|
420
|
-
//
|
|
421
|
-
//
|
|
422
|
-
//
|
|
444
|
+
// ---- 6.12 step 6: liveness split, cards reborn ----
|
|
445
|
+
// C.1: a live card is a row in the Background panel, right under Terminals.
|
|
446
|
+
// A finished one falls into one ledger cell. `cardsOpen` now gates the
|
|
447
|
+
// finished-cards drawer, not a background-tasks drawer: there is no drawer
|
|
448
|
+
// for live cards, they are always on the page.
|
|
423
449
|
let cardsOpen = false
|
|
424
450
|
function toggleEmptyState() {
|
|
425
451
|
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)
|
|
452
|
+
if (empty) empty.hidden = state.cards.size > 0
|
|
473
453
|
}
|
|
474
454
|
|
|
475
455
|
function rowRank(card) {
|
|
@@ -487,11 +467,102 @@
|
|
|
487
467
|
return [...state.cards.values()].sort((a, b) => rowRank(a) - rowRank(b) || startedAt(a) - startedAt(b))
|
|
488
468
|
}
|
|
489
469
|
|
|
470
|
+
function liveCards() { return orderedCards().filter((c) => isLive(c)) }
|
|
471
|
+
|
|
472
|
+
// C.5: finished cards, newest first, for the ledger's [View] drawer.
|
|
473
|
+
function finishedCards() {
|
|
474
|
+
return [...state.cards.values()]
|
|
475
|
+
.filter((c) => FINISHED_STATUSES.includes(c.status))
|
|
476
|
+
.sort((a, b) => (Date.parse(b.updated_at) || 0) - (Date.parse(a.updated_at) || 0))
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// C.5: "1 running, 1 waiting on you", the same predicate as needsYou on the
|
|
480
|
+
// terminals region. A zero clause is omitted rather than printed as 0.
|
|
481
|
+
function backgroundMeta(live) {
|
|
482
|
+
const running = live.filter((c) => ['running', 'handing_off'].includes(c.status)).length
|
|
483
|
+
const waiting = live.filter((c) => ['needs_approval', 'waiting_human'].includes(c.status)).length
|
|
484
|
+
const parts = []
|
|
485
|
+
if (running) parts.push(`${running} running`)
|
|
486
|
+
if (waiting) parts.push(`${waiting} waiting on you`)
|
|
487
|
+
return parts.join(', ')
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function renderBackgroundRegion(live) {
|
|
491
|
+
const section = document.getElementById('background')
|
|
492
|
+
if (section) section.hidden = live.length === 0
|
|
493
|
+
const meta = document.querySelector('#background .region-meta')
|
|
494
|
+
if (meta) meta.textContent = backgroundMeta(live)
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// C.1, C.5: "3 finished cards, 2 done, 1 failed, last 11:02 PM", one line,
|
|
498
|
+
// never a count of the live cards (they are their own region now).
|
|
499
|
+
function renderFinishedLedger() {
|
|
500
|
+
const finished = finishedCards()
|
|
501
|
+
const head = document.getElementById('cards-head')
|
|
502
|
+
const meta = document.querySelector('#board .region-meta')
|
|
503
|
+
if (head) {
|
|
504
|
+
if (!finished.length) head.textContent = 'No finished cards'
|
|
505
|
+
else {
|
|
506
|
+
const done = finished.filter((c) => c.status === 'done').length
|
|
507
|
+
const failed = finished.filter((c) => c.status === 'failed').length
|
|
508
|
+
const killed = finished.filter((c) => c.status === 'killed').length
|
|
509
|
+
const parts = []
|
|
510
|
+
if (done) parts.push(`${done} done`)
|
|
511
|
+
if (failed) parts.push(`${failed} failed`)
|
|
512
|
+
if (killed) parts.push(`${killed} killed`)
|
|
513
|
+
head.textContent = `${plural(finished.length, 'finished card')}, ${parts.join(', ')}, last ${clock(finished[0].updated_at)}`
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
if (meta) meta.textContent = ''
|
|
517
|
+
const slot = document.querySelector('#board .ledger-actions')
|
|
518
|
+
if (slot) {
|
|
519
|
+
let view = document.getElementById('cards-view-toggle')
|
|
520
|
+
if (!finished.length) { if (view) view.remove() } else {
|
|
521
|
+
if (!view) {
|
|
522
|
+
view = el('button', { type: 'button', class: 'btn btn-secondary', id: 'cards-view-toggle', 'aria-controls': 'cards-drawer' }, ['View'])
|
|
523
|
+
view.addEventListener('click', () => { cardsOpen = !cardsOpen; renderFinishedLedger() })
|
|
524
|
+
slot.appendChild(view)
|
|
525
|
+
}
|
|
526
|
+
view.textContent = cardsOpen ? 'Hide' : 'View'
|
|
527
|
+
view.setAttribute('aria-expanded', cardsOpen ? 'true' : 'false')
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
const panel = document.getElementById('cards-drawer')
|
|
531
|
+
if (panel) panel.hidden = !(cardsOpen && finished.length)
|
|
532
|
+
renderFinishedList(finished)
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// C.5: each drawer row is one line, never the full interactive row a live
|
|
536
|
+
// card gets: "done after 3 runs, landed 7f3a2c1" or "failed at station
|
|
537
|
+
// build after 2 runs: <last event summary>".
|
|
538
|
+
function finishedLine(card) {
|
|
539
|
+
const runs = plural(card.runs_count || 0, 'run')
|
|
540
|
+
const last = card.last_event ? card.last_event.summary : 'no events yet'
|
|
541
|
+
if (card.status === 'done') {
|
|
542
|
+
const landed = card.land && card.land.state === 'landed' && card.land.sha ? `landed ${String(card.land.sha).slice(0, 7)}` : last
|
|
543
|
+
return `done after ${runs}, ${landed}`
|
|
544
|
+
}
|
|
545
|
+
if (card.status === 'failed') return `failed at station ${card.station} after ${runs}: ${last}`
|
|
546
|
+
return `killed after ${runs}`
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function renderFinishedList(finished) {
|
|
550
|
+
const list = document.getElementById('columns')
|
|
551
|
+
if (!list) return
|
|
552
|
+
list.textContent = ''
|
|
553
|
+
for (const card of finished) {
|
|
554
|
+
list.appendChild(el('div', { class: 'finished-line' }, [
|
|
555
|
+
el('span', { class: 'row-meta mono' }, [card.title || card.card_id]),
|
|
556
|
+
el('span', { class: 'finished-names' }, [finishedLine(card)]),
|
|
557
|
+
]))
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
490
561
|
// Re-appending every row costs one DOM move each and can move a node out from
|
|
491
562
|
// under the cursor, so it happens only when the order string actually changed.
|
|
492
563
|
function orderRows() {
|
|
493
|
-
const list = document.getElementById('
|
|
494
|
-
const order =
|
|
564
|
+
const list = document.getElementById('background-grid')
|
|
565
|
+
const order = liveCards().map((c) => c.card_id)
|
|
495
566
|
const key = order.join(',')
|
|
496
567
|
if (key === state.rowOrder) return
|
|
497
568
|
state.rowOrder = key
|
|
@@ -502,19 +573,29 @@
|
|
|
502
573
|
if (state.drawerId) placeDetail(state.drawerId)
|
|
503
574
|
}
|
|
504
575
|
|
|
576
|
+
// called after anything that can change which cards are live, finished, or
|
|
577
|
+
// how many of each: cheap text/count work, safe to run in full every time.
|
|
578
|
+
function refreshCardMeta() {
|
|
579
|
+
const live = liveCards()
|
|
580
|
+
renderBackgroundRegion(live)
|
|
581
|
+
renderFinishedLedger()
|
|
582
|
+
placeEntryLine(live.length > 0)
|
|
583
|
+
renderEntryLine()
|
|
584
|
+
toggleEmptyState()
|
|
585
|
+
}
|
|
586
|
+
|
|
505
587
|
function renderBoard() {
|
|
506
|
-
const
|
|
588
|
+
const grid = document.getElementById('background-grid')
|
|
507
589
|
// the detail region is a child of this list while a row is expanded: park it
|
|
508
590
|
// back on the body so the wipe below does not take it out of the document
|
|
509
591
|
document.body.appendChild(document.getElementById('drawer'))
|
|
510
|
-
|
|
592
|
+
if (grid) grid.textContent = ''
|
|
511
593
|
state.cardNodes = new Map()
|
|
512
|
-
const
|
|
513
|
-
for (const card of
|
|
514
|
-
state.rowOrder =
|
|
594
|
+
const live = liveCards()
|
|
595
|
+
for (const card of live) renderRow(card)
|
|
596
|
+
state.rowOrder = live.map((c) => c.card_id).join(',')
|
|
515
597
|
if (state.drawerId) placeDetail(state.drawerId)
|
|
516
|
-
|
|
517
|
-
toggleEmptyState()
|
|
598
|
+
refreshCardMeta()
|
|
518
599
|
}
|
|
519
600
|
|
|
520
601
|
// the board is pushed a card for every write under its directory, log bytes
|
|
@@ -541,14 +622,22 @@
|
|
|
541
622
|
return cache.expanded ? 200 : 8
|
|
542
623
|
}
|
|
543
624
|
|
|
625
|
+
// a card that fell off liveness (running -> done, say) loses its row; the
|
|
626
|
+
// finished ledger picks it up on the next refreshCardMeta() instead
|
|
627
|
+
function removeLiveRow(id) {
|
|
628
|
+
const root = state.cardNodes.get(id)
|
|
629
|
+
if (root) { root.remove(); state.cardNodes.delete(id) }
|
|
630
|
+
state.rowOrder = ''
|
|
631
|
+
if (state.drawerId === id) closeDrawer()
|
|
632
|
+
}
|
|
633
|
+
|
|
544
634
|
function upsertCard(card) {
|
|
545
635
|
state.boardRevision += 1
|
|
546
636
|
const prev = state.cards.get(card.card_id)
|
|
547
637
|
const tail = staleLogTail(state.logState.get(card.card_id), card, Date.now())
|
|
548
|
-
|
|
549
|
-
orderRows()
|
|
550
|
-
|
|
551
|
-
toggleEmptyState()
|
|
638
|
+
state.cards.set(card.card_id, card)
|
|
639
|
+
if (isLive(card)) { renderRow(card); orderRows() } else { removeLiveRow(card.card_id) }
|
|
640
|
+
refreshCardMeta()
|
|
552
641
|
if (tail) ensureLogLoaded(card.card_id, tail)
|
|
553
642
|
if (state.drawerId === card.card_id && drawerRefreshNeeded(prev, card)) scheduleDrawerRefresh()
|
|
554
643
|
}
|
|
@@ -557,12 +646,8 @@
|
|
|
557
646
|
state.boardRevision += 1
|
|
558
647
|
state.cards.delete(id)
|
|
559
648
|
state.logState.delete(id)
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
state.rowOrder = ''
|
|
563
|
-
renderCardsMeta()
|
|
564
|
-
toggleEmptyState()
|
|
565
|
-
if (state.drawerId === id) closeDrawer()
|
|
649
|
+
removeLiveRow(id)
|
|
650
|
+
refreshCardMeta()
|
|
566
651
|
}
|
|
567
652
|
|
|
568
653
|
function onLedgerEvent(ev) {
|
|
@@ -618,16 +703,14 @@
|
|
|
618
703
|
return { tone: 'warn', text: `waiting on you at station ${card.station} since ${clock(card.updated_at)}` }
|
|
619
704
|
}
|
|
620
705
|
if (card.status === 'done') return { tone: 'ok', text: `done after ${plural(card.runs_count || 0, 'run')}, ${last ? last.summary : 'no events yet'}` }
|
|
706
|
+
// C.3: a card runs -p --output-format json, mute until the leg exits. Once
|
|
707
|
+
// it has said anything real the generic branch below still carries it.
|
|
708
|
+
if (card.status === 'running' && last && (last.type === 'leg_started' || last.summary === 'leg started')) {
|
|
709
|
+
return { tone: 'muted', text: `no message until this leg ends, started ${clock(last.ts)}` }
|
|
710
|
+
}
|
|
621
711
|
return { tone: 'muted', text: `${formatLastEvent(last)}${last ? `, ${clock(last.ts)}` : ''}` }
|
|
622
712
|
}
|
|
623
713
|
|
|
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
714
|
// 6.11: flat inline tokens, middot-separated by CSS, each agent name printed
|
|
632
715
|
// beside its own colour so identity never rides on hue alone.
|
|
633
716
|
function buildChainRail(card) {
|
|
@@ -690,11 +773,17 @@
|
|
|
690
773
|
row.cancelBtn.focus()
|
|
691
774
|
}
|
|
692
775
|
|
|
693
|
-
|
|
694
|
-
|
|
776
|
+
// C.3: at most four buttons in the row's 2x2 grid, fixed order; anything
|
|
777
|
+
// left over moves into the expansion instead of a fifth slot.
|
|
778
|
+
function splitActions(card) {
|
|
695
779
|
const available = card.actions || []
|
|
696
|
-
|
|
697
|
-
|
|
780
|
+
const ordered = ACTION_ORDER.filter((a) => available.includes(a) && ACTION_LABELS[a])
|
|
781
|
+
return { shown: ordered.slice(0, 4), overflow: ordered.slice(4) }
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
function buildActions(card, actions) {
|
|
785
|
+
const wrap = el('div', { class: 'row-actions' })
|
|
786
|
+
for (const action of actions) {
|
|
698
787
|
const label = ACTION_LABELS[action]
|
|
699
788
|
const cls = `btn ${ACTION_CLASS[action] || 'btn-secondary'}`
|
|
700
789
|
const run = action === 'reassign' ? () => openReassign(card, wrap) : () => runAction(card, action)
|
|
@@ -706,6 +795,82 @@
|
|
|
706
795
|
return wrap
|
|
707
796
|
}
|
|
708
797
|
|
|
798
|
+
// C.3: a short relative age ("6m ago"), for the work stat line only. The
|
|
799
|
+
// pinned time grammar in sessions.js (elapsedClock/clockAt) is unrelated:
|
|
800
|
+
// this is a duration since a timestamp, not an elapsed-run clock.
|
|
801
|
+
function agoShort(ms) {
|
|
802
|
+
const s = Math.max(0, Math.floor(ms / 1000))
|
|
803
|
+
if (s < 60) return `${s}s`
|
|
804
|
+
const m = Math.floor(s / 60)
|
|
805
|
+
if (m < 60) return `${m}m`
|
|
806
|
+
const h = Math.floor(m / 60)
|
|
807
|
+
if (h < 48) return `${h}h`
|
|
808
|
+
return `${Math.floor(h / 24)}d`
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
function agoSince(ts) {
|
|
812
|
+
const at = ts ? Date.parse(ts) : NaN
|
|
813
|
+
return Number.isFinite(at) ? agoShort(Date.now() - at) : 'an unknown time'
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// C.3: the branch a live card works on is leg/<card_id> once it has a
|
|
817
|
+
// worktree (src/worktree.mjs branchName), else it has not started and the
|
|
818
|
+
// register falls back to the trunk it will branch from.
|
|
819
|
+
// A terminal's short id is the random tail of its session id. A card id has
|
|
820
|
+
// no hash on the end, only a slug, and `card-20260917-2300-seeded-live-card-3`
|
|
821
|
+
// ends in `3`: a bare digit identifies nothing on a board with four cards. So
|
|
822
|
+
// the short id is the last segment, grown leftwards until it is a token a
|
|
823
|
+
// reader can match back to the row (4 characters or more).
|
|
824
|
+
function cardShortId(id) {
|
|
825
|
+
const parts = String(id || '').split('-').filter(Boolean)
|
|
826
|
+
if (!parts.length) return ''
|
|
827
|
+
let out = parts[parts.length - 1]
|
|
828
|
+
for (let i = parts.length - 2; i >= 0 && out.length < 4; i--) out = `${parts[i]}-${out}`
|
|
829
|
+
return out
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// The branch this card's checkout is really on, for a reader who is going to
|
|
833
|
+
// paste it into `git checkout`. A card that cut its own worktree is on
|
|
834
|
+
// `leg/<card-id>`; one that adopted a terminal's is on that TERMINAL's
|
|
835
|
+
// branch, which no reader can derive from the card id, so the server records
|
|
836
|
+
// it with the worktree (`worktree_branch`). Shortened for the row only when
|
|
837
|
+
// the full name does not fit, and the full name is on the element's title.
|
|
838
|
+
function cardBranch(card) {
|
|
839
|
+
if (card.worktree_branch) return card.worktree_branch
|
|
840
|
+
if (card.worktree) return `leg/${card.card_id}`
|
|
841
|
+
return card.trunk || 'main'
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
function cardAgent(card) { return (card.agent_model && card.agent_model.agent) || card.active_adapter || null }
|
|
845
|
+
|
|
846
|
+
function cardAgentModelText(card) {
|
|
847
|
+
const agent = cardAgent(card)
|
|
848
|
+
if (!agent) return null
|
|
849
|
+
const model = card.agent_model && card.agent_model.model
|
|
850
|
+
return model ? `${agent}/${model}` : agent
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// C.3: "4 files, +212 -18, tests green 6m ago", built only from the parts
|
|
854
|
+
// the server actually measured. Nothing measured prints nothing.
|
|
855
|
+
function workStatLine(card) {
|
|
856
|
+
const parts = []
|
|
857
|
+
const w = card.work
|
|
858
|
+
if (w) {
|
|
859
|
+
if (Number.isFinite(w.files)) parts.push(plural(w.files, 'file'))
|
|
860
|
+
const ins = Number.isFinite(w.insertions) ? `+${w.insertions}` : ''
|
|
861
|
+
const del = Number.isFinite(w.deletions) ? `-${w.deletions}` : ''
|
|
862
|
+
const diff = [ins, del].filter(Boolean).join(' ')
|
|
863
|
+
if (diff) parts.push(diff)
|
|
864
|
+
}
|
|
865
|
+
if (card.tests) parts.push(`tests ${card.tests.state} ${agoSince(card.tests.at)} ago`)
|
|
866
|
+
if (card.land) {
|
|
867
|
+
if (card.land.state === 'bounced') parts.push(`land bounced: ${card.land.reason || 'unknown reason'}`)
|
|
868
|
+
else if (card.land.state === 'landed' && card.land.sha) parts.push(`landed ${String(card.land.sha).slice(0, 7)}`)
|
|
869
|
+
else if (card.land.state === 'failed') parts.push(`land failed${card.land.reason ? `: ${card.land.reason}` : ''}`)
|
|
870
|
+
}
|
|
871
|
+
return parts.join(', ')
|
|
872
|
+
}
|
|
873
|
+
|
|
709
874
|
// the reassign picker replaces the buttons in place, like the confirm row
|
|
710
875
|
async function openReassign(card, wrap) {
|
|
711
876
|
if (!state.adapters) {
|
|
@@ -734,33 +899,37 @@
|
|
|
734
899
|
wrap.appendChild(el('div', { class: 'reassign-picker' }, [adapterSelect, modeSelect, apply, cancel]))
|
|
735
900
|
}
|
|
736
901
|
|
|
737
|
-
//
|
|
738
|
-
//
|
|
739
|
-
//
|
|
902
|
+
// C.3: R1 register (state, station, repo/branch, agent/model), R2 title +
|
|
903
|
+
// sentence, R3 the work stat line (only what is measured), R4 elapsed, the
|
|
904
|
+
// short id, and at most four action buttons. A card row reads down the same
|
|
905
|
+
// four columns as a terminal (.row already carries that shape, see
|
|
906
|
+
// board.css's own comment on it: "the same shape as .term-row").
|
|
740
907
|
function buildRow(card) {
|
|
741
908
|
const said = cardSentence(card)
|
|
742
|
-
const
|
|
743
|
-
const
|
|
909
|
+
const agentText = cardAgentModelText(card)
|
|
910
|
+
const agentBase = cardAgent(card)
|
|
744
911
|
const title = el('button', {
|
|
745
912
|
type: 'button', class: 'row-title', 'aria-expanded': state.drawerId === card.card_id ? 'true' : 'false',
|
|
746
913
|
onclick: () => expandRow(card.card_id),
|
|
747
914
|
}, [card.title || truncate(card.task, 60) || card.card_id])
|
|
748
915
|
const sentence = el('p', { class: `sentence tone-${said.tone}` }, [said.text])
|
|
749
916
|
const elapsed = el('span', { class: 'elapsed' }, [runElapsed(card)])
|
|
917
|
+
const statLine = workStatLine(card)
|
|
918
|
+
const { shown } = splitActions(card)
|
|
750
919
|
const cells = [
|
|
751
920
|
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
921
|
statusWord(card),
|
|
922
|
+
card.station && card.station !== '-' ? el('span', { class: 'chip' }, [card.station]) : null,
|
|
923
|
+
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)}`]),
|
|
924
|
+
agentText ? el('span', { class: `chip chip-id-${agentClass(agentBase)}` }, [agentText]) : null,
|
|
755
925
|
]),
|
|
756
926
|
el('div', { class: 'r2' }, [title, sentence]),
|
|
757
|
-
el('div', { class: 'r3' }, [
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
927
|
+
el('div', { class: 'r3' }, statLine ? [el('p', { class: 'row-meta' }, [statLine])] : []),
|
|
928
|
+
el('div', { class: 'r4' }, [
|
|
929
|
+
elapsed,
|
|
930
|
+
el('span', { class: 'row-meta mono', title: card.card_id }, [cardShortId(card.card_id)]),
|
|
931
|
+
buildActions(card, shown),
|
|
762
932
|
]),
|
|
763
|
-
el('div', { class: 'r4' }, [elapsed, buildActions(card)]),
|
|
764
933
|
]
|
|
765
934
|
return { cells, slots: { title, sentence, elapsed } }
|
|
766
935
|
}
|
|
@@ -771,7 +940,7 @@
|
|
|
771
940
|
if (!root) {
|
|
772
941
|
root = el('article', { class: 'row', 'data-card-id': card.card_id })
|
|
773
942
|
state.cardNodes.set(card.card_id, root)
|
|
774
|
-
document.getElementById('
|
|
943
|
+
document.getElementById('background-grid').appendChild(root)
|
|
775
944
|
state.rowOrder = ''
|
|
776
945
|
} else {
|
|
777
946
|
while (root.firstChild) root.removeChild(root.firstChild)
|
|
@@ -854,6 +1023,27 @@
|
|
|
854
1023
|
return [el('div', { class: 'kv' }, rows), copy]
|
|
855
1024
|
}
|
|
856
1025
|
|
|
1026
|
+
// C.4: card -> terminal. Take over pauses the card and hands back one
|
|
1027
|
+
// copyable command; a terminal cannot be opened from a browser tab, so the
|
|
1028
|
+
// board says that plainly rather than pretending it can.
|
|
1029
|
+
function buildTakeOver(card) {
|
|
1030
|
+
const box = el('div', { class: 'kv' })
|
|
1031
|
+
const btn = el('button', { type: 'button', class: 'btn btn-secondary', onclick: async () => {
|
|
1032
|
+
try {
|
|
1033
|
+
const data = await api(`/api/cards/${encodeURIComponent(card.card_id)}/take-over`, { method: 'POST', body: {} })
|
|
1034
|
+
box.textContent = ''
|
|
1035
|
+
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.']))
|
|
1036
|
+
const row = el('div', { class: 'reassign-picker' }, [
|
|
1037
|
+
el('span', { class: 'row-meta mono' }, [data.command]),
|
|
1038
|
+
el('button', { type: 'button', class: 'btn btn-secondary', onclick: () => copyToClipboard(data.command) }, ['Copy']),
|
|
1039
|
+
])
|
|
1040
|
+
box.appendChild(row)
|
|
1041
|
+
} catch (err) { toast(err.message) }
|
|
1042
|
+
} }, ['Take over'])
|
|
1043
|
+
box.appendChild(btn)
|
|
1044
|
+
return detailSection('Take over', 'pauses the card and starts an interactive terminal from its bundle', box)
|
|
1045
|
+
}
|
|
1046
|
+
|
|
857
1047
|
function eventRow(e) {
|
|
858
1048
|
return el('div', { class: 'turn' }, [
|
|
859
1049
|
el('span', { class: 'turn-when' }, [clock(e.ts)]),
|
|
@@ -901,6 +1091,17 @@
|
|
|
901
1091
|
for (const st of stations) pipeline.push(...kvRow(st.name, `${st.kind}${st.name === card.station ? ', this station' : ''}`))
|
|
902
1092
|
content.appendChild(detailSection('Pipeline', at ? `station ${at} of ${stations.length}` : `${plural(stations.length, 'station')}, none started`, el('div', { class: 'kv' }, pipeline)))
|
|
903
1093
|
|
|
1094
|
+
// C.3: the chain rail, the leases and the pipeline above all moved out of
|
|
1095
|
+
// the row and into this expansion; the row itself carries only the model
|
|
1096
|
+
// token and the station name now.
|
|
1097
|
+
content.appendChild(detailSection('Chain', null, buildChainRail(card)))
|
|
1098
|
+
content.appendChild(detailSection('Leases', null, buildLeases(card)))
|
|
1099
|
+
|
|
1100
|
+
const { overflow } = splitActions(card)
|
|
1101
|
+
if (overflow.length) content.appendChild(detailSection('More actions', 'past the four on the row', buildActions(card, overflow)))
|
|
1102
|
+
|
|
1103
|
+
content.appendChild(buildTakeOver(card))
|
|
1104
|
+
|
|
904
1105
|
const runs = detail.runs || []
|
|
905
1106
|
const runRows = []
|
|
906
1107
|
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,6 +1213,175 @@
|
|
|
1012
1213
|
if (root && root.slots) root.slots.title.focus()
|
|
1013
1214
|
}
|
|
1014
1215
|
|
|
1216
|
+
// ---- 6.15 step 6: the one-line background entry (C.2) ----
|
|
1217
|
+
// Always visible to the owner, one row above or below the Background panel.
|
|
1218
|
+
// Its three nouns (repo, ladder, workflow) are inferred and are buttons that
|
|
1219
|
+
// swap for a select in place; nothing here persists past a page reload.
|
|
1220
|
+
let entryState = { task: '', repo: null, ladderStart: 0, pipeline: 'build', editing: null }
|
|
1221
|
+
const PIPELINE_WORDS = { build: 'build only', 'build-land': 'build and land', factory: 'plan, build, review and land' }
|
|
1222
|
+
|
|
1223
|
+
// Only a terminal that carries a `repo` is offered: /api/cards refuses a path
|
|
1224
|
+
// that is not a git repository root, so a terminal whose cwd is a plain
|
|
1225
|
+
// folder would put a value in this field that Start can only ever reject.
|
|
1226
|
+
function knownRepos() {
|
|
1227
|
+
const seen = new Map()
|
|
1228
|
+
for (const s of state.sessions || []) if (s.repo && !seen.has(s.repo)) seen.set(s.repo, s.repo_name || s.repo)
|
|
1229
|
+
for (const c of state.cards.values()) if (c.repo && !seen.has(c.repo)) seen.set(c.repo, c.repo_name || c.repo)
|
|
1230
|
+
return [...seen].map(([path, name]) => ({ path, name }))
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
// C.2: the most recently focused terminal's repo, else the last card's, else
|
|
1234
|
+
// the first terminal's.
|
|
1235
|
+
function inferRepo() {
|
|
1236
|
+
const sessions = (state.sessions || []).filter((s) => s.repo)
|
|
1237
|
+
const byFocus = sessions.length ? [...sessions].sort((a, b) => (Date.parse(b.last_activity) || 0) - (Date.parse(a.last_activity) || 0))[0] : null
|
|
1238
|
+
if (byFocus) return { path: byFocus.repo, name: byFocus.repo_name || byFocus.repo }
|
|
1239
|
+
const cards = [...state.cards.values()].sort((a, b) => (Date.parse(b.updated_at) || 0) - (Date.parse(a.updated_at) || 0))
|
|
1240
|
+
if (cards[0] && cards[0].repo) return { path: cards[0].repo, name: cards[0].repo_name || cards[0].repo }
|
|
1241
|
+
if (sessions[0]) return { path: sessions[0].repo, name: sessions[0].repo_name || sessions[0].repo }
|
|
1242
|
+
return null
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
function entryRepo() { return entryState.repo || inferRepo() }
|
|
1246
|
+
|
|
1247
|
+
// the saved ladder, skipping a credits/metered rung while may_spend is off:
|
|
1248
|
+
// a card never starts on a rung that bills without asking (-p mode does)
|
|
1249
|
+
function ladderRungs() {
|
|
1250
|
+
const prefs = state.preferences
|
|
1251
|
+
if (!prefs || !Array.isArray(prefs.handoff_ladder)) return []
|
|
1252
|
+
const maySpend = !!prefs.may_spend
|
|
1253
|
+
return prefs.handoff_ladder.filter((r) => maySpend || !['credits', 'metered'].includes(r.cost))
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
function ladderLabel(r) { return r.model ? `${r.agent}/${r.model}` : r.agent }
|
|
1257
|
+
function ladderSentence(rungs) { return rungs.length ? rungs.map(ladderLabel).join(' then ') : 'no agent is configured' }
|
|
1258
|
+
|
|
1259
|
+
// Exactly what Start posts: one chain entry per rung, carrying that rung's
|
|
1260
|
+
// model, from the rung the reader picked downward. De-duplicated by
|
|
1261
|
+
// (adapter, model) and not by adapter, because `claude/fable` then
|
|
1262
|
+
// `claude/opus` are two real legs and a hand-off between them is the whole
|
|
1263
|
+
// point; two rungs that name the same adapter AND the same model are one leg
|
|
1264
|
+
// twice, and a hand-off from a leg to its own twin buys nothing.
|
|
1265
|
+
function entryChain() {
|
|
1266
|
+
const seen = new Set()
|
|
1267
|
+
const out = []
|
|
1268
|
+
for (const r of ladderRungs().slice(entryState.ladderStart || 0)) {
|
|
1269
|
+
const key = `${r.agent}/${r.model || ''}`
|
|
1270
|
+
if (seen.has(key)) continue
|
|
1271
|
+
seen.add(key)
|
|
1272
|
+
out.push(r)
|
|
1273
|
+
}
|
|
1274
|
+
return out
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
// The branch a card in this repo should be cut from. The sessions payload
|
|
1278
|
+
// carries the repo's own default branch (the server reads origin/HEAD, then
|
|
1279
|
+
// main/master/trunk, then the current branch) and each terminal's branch, so
|
|
1280
|
+
// neither the sentence nor the body has to assume `main` in a repo whose
|
|
1281
|
+
// default is `master` or `develop`: Start could only ever fail there.
|
|
1282
|
+
function entryTrunk(repo) {
|
|
1283
|
+
if (!repo) return null
|
|
1284
|
+
const known = (state.repoTrunks || []).find((t) => t.repo === repo.path || t.repo_name === repo.name)
|
|
1285
|
+
if (known && known.branch) return known.branch
|
|
1286
|
+
for (const s of state.sessions || []) {
|
|
1287
|
+
if (s.repo !== repo.path) continue
|
|
1288
|
+
const b = (s.worktree && s.worktree.base) || (!s.worktree && s.branch)
|
|
1289
|
+
if (b) return b
|
|
1290
|
+
}
|
|
1291
|
+
return null
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
async function submitEntry() {
|
|
1295
|
+
const repo = entryRepo()
|
|
1296
|
+
const task = (entryState.task || '').trim()
|
|
1297
|
+
if (!task || !repo) return
|
|
1298
|
+
const rungs = entryChain()
|
|
1299
|
+
const trunk = entryTrunk(repo)
|
|
1300
|
+
const body = {
|
|
1301
|
+
repo: repo.path, task,
|
|
1302
|
+
chain: rungs.map((r) => ({ adapter: r.agent, ...(r.model ? { model: r.model } : {}) })),
|
|
1303
|
+
...(trunk ? { trunk } : {}),
|
|
1304
|
+
pipeline: entryState.pipeline, queue: true,
|
|
1305
|
+
}
|
|
1306
|
+
try {
|
|
1307
|
+
const data = await api('/api/cards', { method: 'POST', body })
|
|
1308
|
+
entryState.task = ''
|
|
1309
|
+
entryState.editing = null
|
|
1310
|
+
upsertCard(data.card)
|
|
1311
|
+
} catch (err) { toast(err.message) }
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
function nounSelect(options, current, onPick) {
|
|
1315
|
+
const select = el('select', { 'aria-label': 'Change' })
|
|
1316
|
+
for (const o of options) select.appendChild(el('option', { value: o.value }, [o.label]))
|
|
1317
|
+
select.value = current
|
|
1318
|
+
select.addEventListener('change', () => { onPick(select.value); entryState.editing = null; renderEntryLine() })
|
|
1319
|
+
return select
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
function nounButton(text, key) {
|
|
1323
|
+
return el('button', { type: 'button', class: 'btn btn-text', onclick: () => { entryState.editing = key; renderEntryLine() } }, [text])
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
function renderEntryLine() {
|
|
1327
|
+
const box = document.getElementById('card-entry')
|
|
1328
|
+
if (!box) return
|
|
1329
|
+
if (isGuest()) { box.hidden = true; return }
|
|
1330
|
+
box.hidden = false
|
|
1331
|
+
box.textContent = ''
|
|
1332
|
+
const repo = entryRepo()
|
|
1333
|
+
const task = el('input', { type: 'text', placeholder: 'Describe the task', 'aria-label': 'Task to run in the background', value: entryState.task })
|
|
1334
|
+
const reason = el('span', { class: 'field-help' }, ['Describe the task to start it.'])
|
|
1335
|
+
reason.hidden = Boolean(entryState.task.trim())
|
|
1336
|
+
const start = el('button', { type: 'button', class: 'btn btn-primary', disabled: entryState.task.trim() ? null : '' }, ['Start'])
|
|
1337
|
+
task.addEventListener('input', () => { entryState.task = task.value; start.disabled = !task.value.trim(); reason.hidden = Boolean(task.value.trim()) })
|
|
1338
|
+
start.addEventListener('click', () => submitEntry())
|
|
1339
|
+
// `.confirm-row` is the board's existing sentence-plus-controls strip: a
|
|
1340
|
+
// flex row with a gap on the raised surface, which is exactly this line.
|
|
1341
|
+
box.appendChild(el('div', { class: 'confirm-row' }, [el('span', {}, ['Run in the background:']), task, start, reason]))
|
|
1342
|
+
|
|
1343
|
+
const line = el('p', { class: 'field-help' })
|
|
1344
|
+
line.appendChild(document.createTextNode('in '))
|
|
1345
|
+
if (entryState.editing === 'repo') {
|
|
1346
|
+
line.appendChild(nounSelect(knownRepos().map((r) => ({ value: r.path, label: r.name })), repo ? repo.path : '', (v) => { entryState.repo = knownRepos().find((r) => r.path === v) || null }))
|
|
1347
|
+
} else {
|
|
1348
|
+
line.appendChild(nounButton(repo ? repo.name : 'no repo', 'repo'))
|
|
1349
|
+
}
|
|
1350
|
+
line.appendChild(document.createTextNode(` on ${entryTrunk(repo) || 'main'}, with `))
|
|
1351
|
+
const rungs = ladderRungs()
|
|
1352
|
+
if (entryState.editing === 'ladder') {
|
|
1353
|
+
line.appendChild(nounSelect(rungs.map((r, i) => ({ value: String(i), label: ladderLabel(r) })), String(entryState.ladderStart || 0), (v) => { entryState.ladderStart = Number(v) }))
|
|
1354
|
+
} else {
|
|
1355
|
+
// the sentence names the legs Start posts, models and all
|
|
1356
|
+
line.appendChild(nounButton(ladderSentence(entryChain()), 'ladder'))
|
|
1357
|
+
}
|
|
1358
|
+
line.appendChild(document.createTextNode(', '))
|
|
1359
|
+
if (entryState.editing === 'pipeline') {
|
|
1360
|
+
line.appendChild(nounSelect(Object.keys(PIPELINE_WORDS).map((v) => ({ value: v, label: PIPELINE_WORDS[v] })), entryState.pipeline, (v) => { entryState.pipeline = v }))
|
|
1361
|
+
} else {
|
|
1362
|
+
line.appendChild(nounButton(PIPELINE_WORDS[entryState.pipeline] || 'build only', 'pipeline'))
|
|
1363
|
+
}
|
|
1364
|
+
line.appendChild(document.createTextNode('. '))
|
|
1365
|
+
line.appendChild(el('button', { type: 'button', class: 'btn btn-text', onclick: () => openNewCardDialog() }, ['More settings']))
|
|
1366
|
+
box.appendChild(line)
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
// C.2: always visible under the Background panel; when there are no live
|
|
1370
|
+
// cards the panel is hidden, so the entry moves to sit under Terminals
|
|
1371
|
+
// instead. The fake DOM in the tests has no after(), so this is a no-op
|
|
1372
|
+
// there and a real move in the browser.
|
|
1373
|
+
function placeEntryLine(hasLive) {
|
|
1374
|
+
const entry = document.getElementById('card-entry')
|
|
1375
|
+
if (!entry) return
|
|
1376
|
+
if (hasLive) {
|
|
1377
|
+
const bg = document.getElementById('background')
|
|
1378
|
+
if (bg && typeof bg.appendChild === 'function') bg.appendChild(entry)
|
|
1379
|
+
} else {
|
|
1380
|
+
const terms = document.querySelector('.region-terminals')
|
|
1381
|
+
if (terms && typeof terms.after === 'function') terms.after(entry)
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1015
1385
|
// ---- new card dialog ----
|
|
1016
1386
|
function newCardDialogEls() {
|
|
1017
1387
|
return {
|
|
@@ -1305,10 +1675,16 @@
|
|
|
1305
1675
|
if (state.drawerId) collapseRow()
|
|
1306
1676
|
})
|
|
1307
1677
|
// health first: it says whether this human owns the pipeline side at all
|
|
1308
|
-
await loadHealth()
|
|
1678
|
+
const owner = await loadHealth()
|
|
1309
1679
|
fetchCards()
|
|
1310
1680
|
connectSse()
|
|
1311
1681
|
setInterval(tickElapsed, 1000)
|
|
1682
|
+
// C.2: the ladder sentence on the entry line reads this; a guest never
|
|
1683
|
+
// sees the entry line, so there is nothing to fetch it for
|
|
1684
|
+
if (owner) {
|
|
1685
|
+
try { state.preferences = (await api('/api/settings')).preferences || null } catch { /* entry line falls back to "no agent is configured" */ }
|
|
1686
|
+
renderEntryLine()
|
|
1687
|
+
}
|
|
1312
1688
|
}
|
|
1313
1689
|
|
|
1314
1690
|
document.addEventListener('DOMContentLoaded', init)
|