@ucsandman/legcli 0.12.0 → 0.13.1
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 +88 -0
- package/README.md +1 -1
- package/docs/DECISIONS.md +8 -0
- package/docs/ERRORS.md +42 -0
- package/docs/board-guide.md +129 -31
- package/docs/cli-contracts.md +42 -0
- package/docs/configuration.md +8 -2
- package/docs/faq.md +3 -1
- package/docs/screenshots/board-details-open.png +0 -0
- package/docs/screenshots/floor.png +0 -0
- package/docs/screenshots/new-card-dialog.png +0 -0
- package/fixtures/verified.json +1 -1
- package/package.json +1 -1
- package/scripts/board-jump-probe.mjs +335 -0
- package/scripts/build-docs-site.mjs +3 -3
- package/src/attach.mjs +60 -57
- package/src/board/board.css +84 -2
- package/src/board/board.js +349 -261
- 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 +55 -38
- package/src/board/sessions.js +342 -144
- package/src/board/strip.js +163 -0
- package/src/models.mjs +265 -0
- package/src/preferences.mjs +69 -5
- package/src/server.mjs +81 -33
- package/src/taps/claude-usage.mjs +16 -1
- package/src/usage-poll.mjs +260 -0
- package/src/usage.mjs +33 -1
package/src/board/board.js
CHANGED
|
@@ -63,6 +63,12 @@
|
|
|
63
63
|
}
|
|
64
64
|
const AGENT_IDS = ['claude', 'codex', 'agy', 'grok']
|
|
65
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.1'
|
|
66
72
|
const TIMELINE_CAP = 12
|
|
67
73
|
// mirrors LOOPBACK in src/auth.mjs; state.bind is "<host>:<port>" and an IPv6
|
|
68
74
|
// host arrives bracketed
|
|
@@ -112,6 +118,11 @@
|
|
|
112
118
|
// one entry per repo with a live terminal: { repo, repo_name, branch }
|
|
113
119
|
repoTrunks: [],
|
|
114
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,
|
|
115
126
|
}
|
|
116
127
|
// a card push while an agent writes its log only moves these two
|
|
117
128
|
const VOLATILE_CARD_FIELDS = ['last_event', 'elapsed_ms']
|
|
@@ -420,6 +431,7 @@
|
|
|
420
431
|
state.isOwner = !(data.you && data.you.role && data.you.role !== 'owner')
|
|
421
432
|
state.healthKnown = true
|
|
422
433
|
renderBoardFacts(data)
|
|
434
|
+
versionSkew(data.version)
|
|
423
435
|
renderTokenMeta()
|
|
424
436
|
// a guest is told who they are by health, which is open to them
|
|
425
437
|
if (data.you && data.you.role && data.you.role !== 'owner') { guestMode(); return false }
|
|
@@ -1214,190 +1226,70 @@
|
|
|
1214
1226
|
}
|
|
1215
1227
|
|
|
1216
1228
|
// ---- 6.15 step 6: the one-line background entry (C.2) ----
|
|
1217
|
-
//
|
|
1218
|
-
//
|
|
1219
|
-
//
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
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
|
-
}
|
|
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 }
|
|
1383
1269
|
}
|
|
1384
1270
|
|
|
1385
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.
|
|
1386
1278
|
function newCardDialogEls() {
|
|
1387
1279
|
return {
|
|
1388
1280
|
dialog: document.getElementById('new-card-dialog'),
|
|
1389
1281
|
form: document.getElementById('new-card-form'),
|
|
1390
1282
|
error: document.getElementById('new-card-error'),
|
|
1391
1283
|
repo: document.getElementById('nc-repo'),
|
|
1284
|
+
repoKnown: document.getElementById('nc-repo-known'),
|
|
1392
1285
|
task: document.getElementById('nc-task'),
|
|
1393
|
-
firstAgent: document.getElementById('nc-first-agent'),
|
|
1394
|
-
firstControls: document.getElementById('nc-first-controls'),
|
|
1395
1286
|
testAdapter: document.getElementById('nc-test-adapter'),
|
|
1396
1287
|
fallbackSummary: document.getElementById('nc-fallback-summary'),
|
|
1397
1288
|
pipeline: document.getElementById('nc-pipeline'),
|
|
1398
1289
|
customPipeline: document.getElementById('nc-custom-pipeline'),
|
|
1399
1290
|
chainRows: document.getElementById('nc-chain-rows'),
|
|
1400
1291
|
addRowBtn: document.getElementById('nc-add-row'),
|
|
1292
|
+
saveLadder: document.getElementById('nc-save-ladder'),
|
|
1401
1293
|
leases: document.getElementById('nc-leases'),
|
|
1402
1294
|
trunk: document.getElementById('nc-trunk'),
|
|
1403
1295
|
landMode: document.getElementById('nc-land-mode'),
|
|
@@ -1410,139 +1302,266 @@
|
|
|
1410
1302
|
|
|
1411
1303
|
function adapterLabel(adapter) { return adapter.fake ? `${adapter.name} (test/demo)` : adapter.name }
|
|
1412
1304
|
|
|
1413
|
-
//
|
|
1414
|
-
//
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
return used.filter(Boolean)
|
|
1419
|
-
}
|
|
1420
|
-
|
|
1421
|
-
function addChainRow(ui, { adapter: preferred = null, first = false } = {}) {
|
|
1422
|
-
const adapterSelect = el('select', { 'aria-label': 'Chain adapter' })
|
|
1423
|
-
const adapters = [...(state.adapters || [])].sort((a, b) => Number(a.fake) - Number(b.fake))
|
|
1424
|
-
for (const a of adapters) adapterSelect.appendChild(el('option', { value: a.name }, [adapterLabel(a)]))
|
|
1425
|
-
// Add fallback agent used to default to the first option in the list, which
|
|
1426
|
-
// is normally the agent already chosen as First agent: the summary line then
|
|
1427
|
-
// read "Leg tries claude, then agy, then claude", a fallback that cannot
|
|
1428
|
-
// fire. rebuildDefaultFallbacks already applies this filter.
|
|
1429
|
-
if (!preferred && !first) {
|
|
1430
|
-
const used = chosenAdapters(ui)
|
|
1431
|
-
preferred = adapters.filter((a) => !a.fake).map((a) => a.name).find((name) => !used.includes(name)) || null
|
|
1432
|
-
}
|
|
1433
|
-
if (preferred && adapters.some((a) => a.name === preferred)) adapterSelect.value = preferred
|
|
1434
|
-
const modeSelect = el('select', { 'aria-label': 'Chain mode' })
|
|
1435
|
-
const approveCheckbox = el('input', { type: 'checkbox', 'aria-label': 'Approve before this leg' })
|
|
1436
|
-
const approveLabel = el('label', {}, [approveCheckbox, ' approval before start'])
|
|
1437
|
-
const turnsInput = el('input', { type: 'number', min: '0', 'aria-label': 'Max turns', placeholder: 'max turns' })
|
|
1438
|
-
const fakeInput = el('input', { type: 'text', 'aria-label': 'Scripted test behavior', placeholder: 'test behavior' })
|
|
1439
|
-
const removeBtn = first ? null : el('button', { type: 'button', class: 'btn btn-danger', 'aria-label': 'Remove fallback agent' }, ['Remove'])
|
|
1440
|
-
const title = el('span', { class: 'fallback-row-title' }, [first ? `First: ${preferred}` : `Fallback ${ui.chainRows.children.length + 1}`])
|
|
1441
|
-
const row = el('div', { class: `chain-row${first ? '' : ' fallback-row'}` }, [title, adapterSelect, modeSelect, approveLabel, turnsInput, fakeInput, removeBtn])
|
|
1442
|
-
if (first) adapterSelect.hidden = true
|
|
1443
|
-
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 = []
|
|
1444
1310
|
|
|
1445
|
-
|
|
1446
|
-
modeSelect.textContent = ''
|
|
1447
|
-
const adapter = (state.adapters || []).find((a) => a.name === adapterSelect.value)
|
|
1448
|
-
const allowed = adapter ? adapter.modes.allowed : []
|
|
1449
|
-
for (const m of allowed) modeSelect.appendChild(el('option', { value: m }, [MODE_LABELS[m] ? `${MODE_LABELS[m]} (${m})` : m]))
|
|
1450
|
-
if (adapter && adapter.modes.default) modeSelect.value = adapter.modes.default
|
|
1451
|
-
fakeInput.hidden = !(adapter && adapter.fake)
|
|
1452
|
-
}
|
|
1453
|
-
adapterSelect.addEventListener('change', populateModes)
|
|
1454
|
-
// the summary sentence names the fallback agents by row, so it has to
|
|
1455
|
-
// follow a row whose agent the reader changed after it was added
|
|
1456
|
-
if (!first) adapterSelect.addEventListener('change', () => refreshFallbackSummary(ui))
|
|
1457
|
-
populateModes()
|
|
1311
|
+
function ncAdapter(name) { return (state.adapters || []).find((a) => a.name === name) || null }
|
|
1458
1312
|
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
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
|
+
}
|
|
1463
1321
|
}
|
|
1464
1322
|
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
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'
|
|
1470
1329
|
}
|
|
1471
1330
|
|
|
1472
|
-
|
|
1473
|
-
ui.firstControls.textContent = ''
|
|
1474
|
-
addChainRow(ui, { adapter: ui.testAdapter.value || ui.firstAgent.value, first: true })
|
|
1475
|
-
}
|
|
1331
|
+
const ncLeg = (r) => (r.model ? `${r.agent}/${r.model}` : r.agent)
|
|
1476
1332
|
|
|
1477
|
-
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) {
|
|
1478
1350
|
ui.chainRows.textContent = ''
|
|
1479
|
-
const
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
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
|
+
})
|
|
1483
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
|
+
}
|
|
1484
1469
|
}
|
|
1485
1470
|
|
|
1486
1471
|
async function openNewCardDialog() {
|
|
1487
1472
|
if (!state.adapters) {
|
|
1488
1473
|
try { state.adapters = (await api('/api/adapters')).adapters } catch (err) { toast(err.message); return }
|
|
1489
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
|
+
|
|
1490
1481
|
const ui = newCardDialogEls()
|
|
1491
1482
|
ui.form.reset()
|
|
1492
1483
|
ui.error.hidden = true
|
|
1493
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
|
|
1494
1494
|
ui.customPipeline.hidden = ui.pipeline.value !== 'custom'
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
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
|
+
|
|
1499
1506
|
ui.testAdapter.textContent = ''
|
|
1500
|
-
ui.testAdapter.appendChild(el('option', { value: '' }, ['Use the real first
|
|
1507
|
+
ui.testAdapter.appendChild(el('option', { value: '' }, ['Use the real first row above']))
|
|
1501
1508
|
for (const adapter of (state.adapters || []).filter((a) => a.fake)) ui.testAdapter.appendChild(el('option', { value: adapter.name }, [adapterLabel(adapter)]))
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
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)
|
|
1506
1515
|
ui.dialog.showModal()
|
|
1507
1516
|
}
|
|
1508
1517
|
|
|
1509
1518
|
async function submitNewCard(e) {
|
|
1510
1519
|
e.preventDefault()
|
|
1511
1520
|
const ui = newCardDialogEls()
|
|
1512
|
-
const
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
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
|
+
}))
|
|
1522
1538
|
|
|
1523
1539
|
const body = {
|
|
1524
1540
|
repo: ui.repo.value.trim(),
|
|
1525
1541
|
task: ui.task.value.trim(),
|
|
1526
|
-
chain
|
|
1542
|
+
chain,
|
|
1527
1543
|
pipeline: ui.pipeline.value === 'custom' ? ui.customPipeline.value.trim() : ui.pipeline.value,
|
|
1528
1544
|
leases: ui.leases.value.trim(),
|
|
1529
1545
|
trunk: ui.trunk.value.trim() || 'main',
|
|
1530
1546
|
land_mode: ui.landMode.value,
|
|
1531
1547
|
test_command: ui.testCommand.value.trim(),
|
|
1532
1548
|
title: ui.title.value.trim(),
|
|
1533
|
-
mode: rows.filter((r) => r.mode).map((r) => `${r.adapter}=${r.mode}`).join(','),
|
|
1534
|
-
approve: rows.filter((r) => r.approve).map((r) => r.adapter).join(','),
|
|
1535
|
-
maxTurns: rows.filter((r) => r.turns).map((r) => `${r.adapter}=${r.turns}`).join(','),
|
|
1536
|
-
fake_mode: rows.filter((r) => r.fake).map((r) => `${r.adapter}=${r.fake}`).join(','),
|
|
1537
1549
|
queue: ui.queue.checked,
|
|
1538
1550
|
}
|
|
1539
1551
|
try {
|
|
1540
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)
|
|
1541
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
|
|
1542
1562
|
upsertCard(data.card)
|
|
1543
1563
|
} catch (err) {
|
|
1544
|
-
|
|
1545
|
-
ui.error.textContent = err.message
|
|
1564
|
+
fail(err.message)
|
|
1546
1565
|
}
|
|
1547
1566
|
}
|
|
1548
1567
|
|
|
@@ -1551,9 +1570,24 @@
|
|
|
1551
1570
|
ui.form.addEventListener('submit', submitNewCard)
|
|
1552
1571
|
ui.cancel.addEventListener('click', () => ui.dialog.close())
|
|
1553
1572
|
ui.pipeline.addEventListener('change', () => { ui.customPipeline.hidden = ui.pipeline.value !== 'custom' })
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
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) })
|
|
1557
1591
|
}
|
|
1558
1592
|
|
|
1559
1593
|
// ---- 6.10 settings: the last region of the page, in flow ----
|
|
@@ -1605,6 +1639,15 @@
|
|
|
1605
1639
|
if (meta) meta.textContent = panel.meta
|
|
1606
1640
|
}
|
|
1607
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
|
+
|
|
1608
1651
|
function renderBoardFacts(health) {
|
|
1609
1652
|
const box = document.querySelector('.region-settings .board-facts')
|
|
1610
1653
|
if (!box) return
|
|
@@ -1612,8 +1655,9 @@
|
|
|
1612
1655
|
const you = health.you || {}
|
|
1613
1656
|
const share = you.share || { on: false, people: 0 }
|
|
1614
1657
|
const sched = health.scheduler
|
|
1658
|
+
const skew = health.version && health.version !== FILES_VERSION ? `, page files ${FILES_VERSION}` : ''
|
|
1615
1659
|
const lines = [
|
|
1616
|
-
`leg ${health.version}, bound to ${state.bind}`,
|
|
1660
|
+
`leg ${health.version}${skew}, bound to ${state.bind}`,
|
|
1617
1661
|
`signed in as ${you.name || 'local'}, ${you.role || 'owner'}`,
|
|
1618
1662
|
share.on ? `share on, ${share.people === 1 ? '1 person' : `${share.people} people`}` : 'share off, nobody invited',
|
|
1619
1663
|
]
|
|
@@ -1662,6 +1706,39 @@
|
|
|
1662
1706
|
renderTokenMeta()
|
|
1663
1707
|
}
|
|
1664
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
|
+
|
|
1665
1742
|
// ---- init ----
|
|
1666
1743
|
async function init() {
|
|
1667
1744
|
initSettings()
|
|
@@ -1682,8 +1759,19 @@
|
|
|
1682
1759
|
// C.2: the ladder sentence on the entry line reads this; a guest never
|
|
1683
1760
|
// sees the entry line, so there is nothing to fetch it for
|
|
1684
1761
|
if (owner) {
|
|
1685
|
-
|
|
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()
|
|
1686
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()
|
|
1687
1775
|
}
|
|
1688
1776
|
}
|
|
1689
1777
|
|