create-pmos 0.10.1 → 0.11.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/README.md +64 -0
- package/package.json +1 -1
- package/payload/KIT-VERSION +2 -2
- package/workspace/lib/board-cache.js +116 -0
- package/workspace/lib/console-state.js +59 -6
- package/workspace/lib/engine/anchor-resolve.js +277 -0
- package/workspace/lib/engine/workflow-state.js +878 -0
- package/workspace/lib/layout.js +28 -9
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/app.js +563 -79
- package/workspace/public/index.html +39 -35
- package/workspace/public/styles.css +121 -2
- package/workspace/server.js +200 -21
package/workspace/public/app.js
CHANGED
|
@@ -19,7 +19,10 @@ const state = {
|
|
|
19
19
|
ws: null, sessions: [], workspaces: [], home: null, openHarness: new Set(),
|
|
20
20
|
wsId: null, // selected workspace
|
|
21
21
|
lib: null, // library payload for the selected workspace
|
|
22
|
-
lens: '
|
|
22
|
+
lens: 'dossier', // the current sub-view: dossier | folders | scheduled | history
|
|
23
|
+
detail: { kind: 'repo', id: null, wsId: null }, // R7 — what the rail is showing, from the selection
|
|
24
|
+
libByWs: {}, // ws id -> library payload, so a dossier can be read for a tree that
|
|
25
|
+
// is NOT selected (R9: opening a dossier is a read, never a checkout)
|
|
23
26
|
filter: '',
|
|
24
27
|
active: null, // active session id — always groups[focusG].activeId
|
|
25
28
|
groups: [{ activeId: null }, { activeId: null }], // terminal groups (R6); [1] exists when split
|
|
@@ -44,6 +47,13 @@ const state = {
|
|
|
44
47
|
worktrees: [], // the selected workspace's repo's working trees, with removability
|
|
45
48
|
newBranchMode: false, // new-session form: creating a branch vs picking an existing one
|
|
46
49
|
sessSig: null, // structural fingerprint of the last session push (see structSig)
|
|
50
|
+
boards: {}, // repo_key -> cached workflow board (lib/board-cache envelope)
|
|
51
|
+
boardWanted: new Set(), // repo_keys a fetch is already in flight for
|
|
52
|
+
boardTries: {}, // repo_key -> re-polls spent waiting for a pending board
|
|
53
|
+
boardsStopped: false, // set on unload — nothing may keep polling a page that is going away
|
|
54
|
+
needsByWs: {}, // ws id -> {count,error} — what a COLLAPSED tree row shows (R12)
|
|
55
|
+
openTrees: new Set(), // trees the PM opened explicitly — beats the derived default (R6)
|
|
56
|
+
closedTrees: new Set(), // …and the ones they closed explicitly
|
|
47
57
|
};
|
|
48
58
|
|
|
49
59
|
// ── workspace state survives reloads (R7): theme + selected workspace + focused session,
|
|
@@ -53,7 +63,12 @@ const store = {
|
|
|
53
63
|
set: (k, v) => { try { localStorage.setItem('pmos-ws:' + k, v); } catch {} },
|
|
54
64
|
};
|
|
55
65
|
{ const t = store.get('theme'); if (t === 'light' || t === 'dark') document.documentElement.dataset.theme = t; }
|
|
56
|
-
|
|
66
|
+
// R13 — the lens key stays where it was (global localStorage, not per-workspace) rather than being
|
|
67
|
+
// moved silently; only its vocabulary changes. `initiatives` is retired and maps to the dossier,
|
|
68
|
+
// which is the same intent expressed against the new structure.
|
|
69
|
+
{ const l = store.get('lens');
|
|
70
|
+
if (['folders', 'scheduled', 'history', 'dossier'].includes(l)) state.lens = l;
|
|
71
|
+
else if (l === 'initiatives') state.lens = 'dossier'; }
|
|
57
72
|
// ?ws=<id> is how `pmos` in another repo lands you in THAT repo, not the last one you opened.
|
|
58
73
|
const urlWs = new URLSearchParams(location.search).get('ws');
|
|
59
74
|
|
|
@@ -107,7 +122,7 @@ function handle(m) {
|
|
|
107
122
|
} else if (m.t === 'workspaces') {
|
|
108
123
|
state.workspaces = m.workspaces;
|
|
109
124
|
if (!state.workspaces.some((w) => w.id === state.wsId)) selectWorkspace(state.home, { restoreSession: true });
|
|
110
|
-
else renderWorkspaces();
|
|
125
|
+
else { renderWorkspaces(); renderTree(); loadBoards(); }
|
|
111
126
|
} else if (m.t === 'focus') {
|
|
112
127
|
// `pmos` was run in another repo while this tab was open — go there, which is the whole
|
|
113
128
|
// point: the browser refocusing this tab must not strand the PM on the previous repo.
|
|
@@ -206,6 +221,7 @@ async function selectWorkspace(id, opts = {}) {
|
|
|
206
221
|
if (changed) closeDoc();
|
|
207
222
|
renderWorkspaces();
|
|
208
223
|
renderSessions();
|
|
224
|
+
loadBoards();
|
|
209
225
|
// focus a session in THIS workspace: the remembered one if it belongs here, else its first live one
|
|
210
226
|
const mine = mySessions();
|
|
211
227
|
const remembered = opts.restoreSession ? store.get('active') : null;
|
|
@@ -215,8 +231,255 @@ async function selectWorkspace(id, opts = {}) {
|
|
|
215
231
|
// the scratchpad follows the ACTIVE TAB, and selectSession/clearActive above have already
|
|
216
232
|
// settled which that is — syncActive() reloaded it, so there is nothing to do here
|
|
217
233
|
if (changed) { state.branch = null; renderBranch(); state.worktrees = []; renderWorktrees(); loadLayouts(); }
|
|
234
|
+
// R7 — the rail follows the selection. A tree on an initiative branch opens its dossier; anything
|
|
235
|
+
// else falls back to repo detail rather than showing a dossier for an initiative that is not there.
|
|
236
|
+
{
|
|
237
|
+
const w = state.workspaces.find((x) => x.id === id);
|
|
238
|
+
const init = w ? initiativeOf(w) : null;
|
|
239
|
+
if (init && init.id) { state.detail = { kind: 'initiative', id: init.id, wsId: id }; if (state.lens === 'dossier') ensureLib(id); }
|
|
240
|
+
else { state.detail = { kind: 'repo', id: w ? w.repo_key : null, wsId: id }; if (state.lens === 'dossier') state.lens = 'folders'; }
|
|
241
|
+
}
|
|
218
242
|
await Promise.all([loadLibrary(), loadNeeds(), loadBranch(), loadWorktrees()]);
|
|
219
243
|
}
|
|
244
|
+
/* ── The tree (R1·R2·R3·R4·R5·R6) ────────────────────────────────────────────────────────────────
|
|
245
|
+
*
|
|
246
|
+
* One structure where there were two panes: REPOSITORY → its working trees → their sessions.
|
|
247
|
+
*
|
|
248
|
+
* A row is a working TREE, labelled by the INITIATIVE on its branch. Those are the same level, not
|
|
249
|
+
* two: git allows exactly one working tree per branch, and every session in a tree shares that
|
|
250
|
+
* branch (lib/git-info.js), so nesting them separately would produce a middle row with exactly one
|
|
251
|
+
* child forever. The DIRECTORY name stays on the row permanently — the shared-tree checkout path
|
|
252
|
+
* moves every session in a repo, which relabels a row under its running sessions, and without a
|
|
253
|
+
* stable identity that reads as a row vanishing and another appearing (R2).
|
|
254
|
+
*
|
|
255
|
+
* Everything a row asserts about workflow is DERIVED and dated. `board` arrives from the cache
|
|
256
|
+
* (lib/board-cache.js), never computed here, and a stage is never drawn without the branch it came
|
|
257
|
+
* from (R5) — on this repo the ladder is scoped to merge-base(origin/main,HEAD)..HEAD, so a shipped
|
|
258
|
+
* initiative reads `spec` on every branch that did not build it.
|
|
259
|
+
*/
|
|
260
|
+
const TREE_GLYPH = { working: '●', quiet: '◼', blocked: '✗', idle: '—', exited: '○', none: '·' };
|
|
261
|
+
|
|
262
|
+
// The initiative a branch names, and the record behind it — three cases, all of them stated. A
|
|
263
|
+
// branch with no record is NOT an initiative with a missing file; it is a branch, and the row says
|
|
264
|
+
// so rather than inventing an id (R2).
|
|
265
|
+
function initiativeOf(w) {
|
|
266
|
+
const br = w.branch;
|
|
267
|
+
if (!br) return { kind: 'nobranch', label: w.branch_error ? 'branch unreadable' : 'detached HEAD', id: null };
|
|
268
|
+
const m = /^initiative\/(.+)$/.exec(br);
|
|
269
|
+
if (!m) return { kind: 'branch', label: br, id: null };
|
|
270
|
+
const id = m[1];
|
|
271
|
+
const row = boardRow(w, id);
|
|
272
|
+
if (!row) return { kind: 'norecord', label: id, id };
|
|
273
|
+
return { kind: 'initiative', label: id, id, row };
|
|
274
|
+
}
|
|
275
|
+
// The board for a repository, by group key — one cached read serves every tree under it.
|
|
276
|
+
function boardFor(w) { return state.boards[w.repo_key] || null; }
|
|
277
|
+
function boardRow(w, id) {
|
|
278
|
+
const b = boardFor(w);
|
|
279
|
+
if (!b || !b.data || !Array.isArray(b.data.live)) return null;
|
|
280
|
+
return b.data.live.find((r) => r.id === id) || null;
|
|
281
|
+
}
|
|
282
|
+
// A tree has something to say when it is working, has gone quiet past the threshold, is blocked, or
|
|
283
|
+
// something needs the PM. Rows without a signal collapse to a count (R6). The PM's own toggle always
|
|
284
|
+
// wins — `state.openTrees` holds it, and it is re-applied after every render for the same reason
|
|
285
|
+
// `state.openHarness` is: this pane is rebuilt by innerHTML on every push.
|
|
286
|
+
function treeHasSignal(w) {
|
|
287
|
+
const mine = state.sessions.filter((s) => s.ws === w.id);
|
|
288
|
+
if (state.needsByWs[w.id] && state.needsByWs[w.id].count) return true;
|
|
289
|
+
if (state.needsByWs[w.id] && state.needsByWs[w.id].error) return true;
|
|
290
|
+
const init = initiativeOf(w);
|
|
291
|
+
if (init.row && init.row.blockers && init.row.blockers.length) return true;
|
|
292
|
+
return mine.some((s) => {
|
|
293
|
+
const shown = stateLabel(s).shown;
|
|
294
|
+
return shown === 'working' || shown === 'blocked' || shown === 'exited';
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
function treeOpen(w) {
|
|
298
|
+
if (state.openTrees.has(w.id)) return true;
|
|
299
|
+
if (state.closedTrees.has(w.id)) return false;
|
|
300
|
+
return w.id === state.wsId || treeHasSignal(w);
|
|
301
|
+
}
|
|
302
|
+
// The worst thing true of a collapsed tree, so the glyph never reads calmer than the tree is.
|
|
303
|
+
function treeGlyph(w) {
|
|
304
|
+
const mine = state.sessions.filter((s) => s.ws === w.id);
|
|
305
|
+
const n = state.needsByWs[w.id];
|
|
306
|
+
const init = initiativeOf(w);
|
|
307
|
+
if ((n && n.error) || (init.row && init.row.blockers && init.row.blockers.length)) return 'blocked';
|
|
308
|
+
if (mine.some((s) => stateLabel(s).shown === 'blocked')) return 'blocked';
|
|
309
|
+
if (mine.some((s) => stateLabel(s).shown === 'working')) return 'working';
|
|
310
|
+
if (mine.some((s) => stateLabel(s).shown === 'quiet')) return 'quiet';
|
|
311
|
+
if (mine.length) return 'exited';
|
|
312
|
+
return 'idle';
|
|
313
|
+
}
|
|
314
|
+
function renderTree() {
|
|
315
|
+
const host = $('tree');
|
|
316
|
+
if (!host) return;
|
|
317
|
+
// group by repository (R3): `pmos` and `pmos ⑂ initiative/foo` are a parent and its child, not
|
|
318
|
+
// two peers. The key comes from the server — `git worktree list` names the main worktree first.
|
|
319
|
+
const groups = new Map();
|
|
320
|
+
for (const w of state.workspaces) {
|
|
321
|
+
const key = w.repo_key || w.root;
|
|
322
|
+
if (!groups.has(key)) groups.set(key, { key, name: w.repo_name || w.name, trees: [] });
|
|
323
|
+
groups.get(key).trees.push(w);
|
|
324
|
+
}
|
|
325
|
+
const totalNeeds = Object.values(state.needsByWs).reduce((a, v) => a + (v && v.count ? v.count : 0), 0);
|
|
326
|
+
const badRead = Object.values(state.needsByWs).some((v) => v && v.error);
|
|
327
|
+
const nc = $('needCount');
|
|
328
|
+
if (nc) {
|
|
329
|
+
nc.textContent = badRead ? 'needs unreadable' : (totalNeeds ? `${totalNeeds} need you` : '');
|
|
330
|
+
nc.dataset.bad = badRead ? '1' : '0';
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const html = [...groups.values()].map((g) => {
|
|
334
|
+
const trees = g.trees.map((w) => treeRow(w, g)).join('');
|
|
335
|
+
return `<div class="repo-group" data-repo="${esc(g.key)}">
|
|
336
|
+
<button class="repo-head" data-repo-pick="${esc(g.key)}" title="${esc(g.key)}">
|
|
337
|
+
<span class="cue" aria-hidden="true">▾</span><span class="nm">${esc(g.name)}</span>
|
|
338
|
+
<small>${g.trees.length} tree${g.trees.length === 1 ? '' : 's'}</small>
|
|
339
|
+
</button>${trees}</div>`;
|
|
340
|
+
}).join('');
|
|
341
|
+
host.innerHTML = html || `<div class="empty">No repositories — run <b>pmos</b> inside one.</div>`;
|
|
342
|
+
host.querySelectorAll('[data-pick]').forEach((b) => b.onclick = () => selectWorkspace(b.dataset.pick));
|
|
343
|
+
host.querySelectorAll('[data-repo-pick]').forEach((b) => b.onclick = () => openRepoDetail(b.dataset.repoPick));
|
|
344
|
+
host.querySelectorAll('[data-forget]').forEach((b) => b.onclick = (e) => { e.stopPropagation(); forgetWorkspace(b.dataset.forget); });
|
|
345
|
+
host.querySelectorAll('[data-toggle]').forEach((b) => b.onclick = (e) => {
|
|
346
|
+
e.stopPropagation();
|
|
347
|
+
const id = b.dataset.toggle;
|
|
348
|
+
// an explicit choice is remembered against the derived default, either way
|
|
349
|
+
if (treeOpen(state.workspaces.find((x) => x.id === id) || { id })) { state.openTrees.delete(id); state.closedTrees.add(id); }
|
|
350
|
+
else { state.closedTrees.delete(id); state.openTrees.add(id); }
|
|
351
|
+
renderTree();
|
|
352
|
+
});
|
|
353
|
+
host.querySelectorAll('.sess').forEach((b) => {
|
|
354
|
+
b.onclick = () => selectSession(b.dataset.id);
|
|
355
|
+
b.ondblclick = () => startRename(b.dataset.id);
|
|
356
|
+
b.onkeydown = (e) => { if (e.key === 'F2') { e.preventDefault(); startRename(b.dataset.id); } };
|
|
357
|
+
});
|
|
358
|
+
host.querySelectorAll('[data-rename]').forEach((b) => b.onclick = (e) => { e.stopPropagation(); startRename(b.dataset.rename); });
|
|
359
|
+
host.querySelectorAll('details.hz').forEach((d) => {
|
|
360
|
+
d.open = state.openHarness.has(d.dataset.id);
|
|
361
|
+
d.addEventListener('toggle', () => {
|
|
362
|
+
if (d.open) state.openHarness.add(d.dataset.id); else state.openHarness.delete(d.dataset.id);
|
|
363
|
+
});
|
|
364
|
+
});
|
|
365
|
+
paintRename();
|
|
366
|
+
}
|
|
367
|
+
function treeRow(w, g) {
|
|
368
|
+
const cur = w.id === state.wsId;
|
|
369
|
+
const open = treeOpen(w);
|
|
370
|
+
const init = initiativeOf(w);
|
|
371
|
+
const mine = state.sessions.filter((s) => s.ws === w.id);
|
|
372
|
+
const live = mine.filter((s) => s.state !== 'exited').length;
|
|
373
|
+
const n = state.needsByWs[w.id];
|
|
374
|
+
const gl = treeGlyph(w);
|
|
375
|
+
const board = boardFor(w);
|
|
376
|
+
|
|
377
|
+
// R5 — a stage is never shown without the branch it was derived from, and never without saying
|
|
378
|
+
// how old the derivation is. A board still deriving says so instead of showing nothing.
|
|
379
|
+
let wf = '';
|
|
380
|
+
if (init.row) {
|
|
381
|
+
const r = init.row;
|
|
382
|
+
const blocked = r.blockers && r.blockers.length;
|
|
383
|
+
const nextTxt = blocked ? `✗ ${r.blockers.length} blocked`
|
|
384
|
+
: (r.next && r.next.step ? `→ ${esc(r.next.step)}` : '');
|
|
385
|
+
wf = `<span class="wf"><span class="lane">${esc(r.lane || '')}</span>
|
|
386
|
+
<span class="stg">${esc(r.stage || '')}</span>
|
|
387
|
+
<span class="${blocked ? 'bl' : 'nx'}">${nextTxt}</span></span>`;
|
|
388
|
+
} else if (board && board.pending) {
|
|
389
|
+
wf = `<span class="wf"><span class="drv">deriving the ladder…</span></span>`;
|
|
390
|
+
} else if (board && board.ok === false) {
|
|
391
|
+
wf = `<span class="wf"><span class="bl">ladder unreadable</span></span>`;
|
|
392
|
+
} else if (init.kind === 'norecord') {
|
|
393
|
+
wf = `<span class="wf"><span class="drv">no initiative record</span></span>`;
|
|
394
|
+
} else if (init.kind === 'branch') {
|
|
395
|
+
wf = `<span class="wf"><span class="drv">no initiative</span></span>`;
|
|
396
|
+
}
|
|
397
|
+
// the attribution line: which branch this ladder came from, and as of when
|
|
398
|
+
const stamp = board && board.as_of ? new Date(board.as_of).toLocaleTimeString() : null;
|
|
399
|
+
const attrib = init.row
|
|
400
|
+
? `<span class="attrib">derived on <b>${esc(w.branch || '?')}</b>${stamp ? ` · as of ${esc(stamp)}` : ''}${board && board.stale ? ' · refreshing' : ''}</span>`
|
|
401
|
+
: '';
|
|
402
|
+
|
|
403
|
+
const sessHtml = open ? mine.map((s) => sessionRow(s)).join('')
|
|
404
|
+
+ `<button class="tree-new" data-pick="${esc(w.id)}" title="select this tree, then start a session">+ session here</button>`
|
|
405
|
+
: '';
|
|
406
|
+
const collapsed = !open && mine.length
|
|
407
|
+
? `<span class="rollup">${mine.length} session${mine.length === 1 ? '' : 's'}${live ? '' : ' · all exited'}</span>` : '';
|
|
408
|
+
|
|
409
|
+
return `<div class="treerow" data-id="${esc(w.id)}" data-open="${open}" data-st="${gl}">
|
|
410
|
+
<div class="tr-head">
|
|
411
|
+
<button class="tr-toggle" data-toggle="${esc(w.id)}" aria-expanded="${open}"
|
|
412
|
+
aria-label="${open ? 'Collapse' : 'Expand'} ${esc(init.label)}"><span class="cue" aria-hidden="true">${open ? '▾' : '▸'}</span></button>
|
|
413
|
+
<button class="tr" data-pick="${esc(w.id)}" aria-current="${cur}" title="${esc(w.root)}">
|
|
414
|
+
<span class="r1"><span class="g" aria-hidden="true">${TREE_GLYPH[gl] || '·'}</span>
|
|
415
|
+
<span class="nm">${esc(init.label)}</span>
|
|
416
|
+
${w.home ? '<span class="tag">home</span>' : ''}
|
|
417
|
+
<span class="dir">${esc(w.name === g.name ? 'main tree' : w.name)}</span></span>
|
|
418
|
+
<span class="r2">${wf}${n && n.count ? `<span class="needs-n">${n.count} need you</span>` : ''}${n && n.error ? '<span class="bl">needs unreadable</span>' : ''}${collapsed}</span>
|
|
419
|
+
${attrib}
|
|
420
|
+
</button>
|
|
421
|
+
${w.home ? '' : `<button class="forget" data-forget="${esc(w.id)}" aria-label="Stop hosting ${esc(w.name)}" title="${live ? 'end its sessions first' : 'stop hosting this repo (the repo is untouched)'}">✕</button>`}
|
|
422
|
+
</div>
|
|
423
|
+
<div class="tr-sessions">${sessHtml}</div>
|
|
424
|
+
</div>`;
|
|
425
|
+
}
|
|
426
|
+
// One session, with everything the old pane carried — nothing about a row's CONTENT changes here,
|
|
427
|
+
// only where it sits (R1). Glyph, state word, quiet duration, open-call age, opening focus, the
|
|
428
|
+
// agent's last words, the harness drill-in and rename all come through unchanged.
|
|
429
|
+
function sessionRow(s) {
|
|
430
|
+
const st = stateLabel(s);
|
|
431
|
+
return `<div class="sessrow" data-id="${s.id}">
|
|
432
|
+
<button class="sess" data-id="${s.id}" data-state="${st.shown}" aria-current="${s.id === state.active}"
|
|
433
|
+
aria-label="${esc(s.name)} — ${esc(st.text)}">
|
|
434
|
+
<span class="r1"><span class="st" aria-hidden="true">${GLYPH[st.shown] || '•'}</span>
|
|
435
|
+
<span class="nm">${esc(s.name)}${s.origin && s.origin.startsWith('schedule:') ? '<span class="schtag">scheduled</span>' : ''}</span>
|
|
436
|
+
<span class="chip">${esc(st.text)}</span></span>
|
|
437
|
+
${subLine(s)}
|
|
438
|
+
${saysLine(s)}
|
|
439
|
+
</button>
|
|
440
|
+
<button class="ren" data-rename="${s.id}" aria-label="Rename ${esc(s.name)}" title="rename (F2)">✎</button>
|
|
441
|
+
</div>${harnessDrawer(s)}`;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/* The board arrives from the server's cache, one read per REPOSITORY (not per tree, not per row).
|
|
445
|
+
* A cold read answers `pending` in milliseconds and the derivation runs in a child process — it
|
|
446
|
+
* costs ~30s on this repo and is fully synchronous, so it must never sit on a request path. While
|
|
447
|
+
* it is pending the rows say "deriving the ladder…" rather than showing nothing: a blank stage is
|
|
448
|
+
* indistinguishable from "this tree has no initiative", and those are different facts. */
|
|
449
|
+
window.addEventListener('pagehide', () => { state.boardsStopped = true; });
|
|
450
|
+
async function loadBoards() {
|
|
451
|
+
if (state.boardsStopped) return;
|
|
452
|
+
const wanted = new Map();
|
|
453
|
+
for (const w of state.workspaces) if (w.repo_key && !wanted.has(w.repo_key)) wanted.set(w.repo_key, w.id);
|
|
454
|
+
for (const [key, wsId] of wanted) {
|
|
455
|
+
if (state.boardWanted.has(key)) continue;
|
|
456
|
+
state.boardWanted.add(key);
|
|
457
|
+
(async () => {
|
|
458
|
+
try {
|
|
459
|
+
const r = await fetch('/api/console?name=workflow&ws=' + encodeURIComponent(wsId));
|
|
460
|
+
const payload = await r.json();
|
|
461
|
+
state.boards[key] = payload;
|
|
462
|
+
renderTree();
|
|
463
|
+
// pending is not failed — come back for it, rather than leaving a row saying "deriving"
|
|
464
|
+
// forever if the child never lands
|
|
465
|
+
if (payload && payload.pending && !state.boardsStopped) {
|
|
466
|
+
const tries = (state.boardTries[key] || 0) + 1;
|
|
467
|
+
state.boardTries[key] = tries;
|
|
468
|
+
// bounded: a derivation that never lands leaves the row saying so, rather than a request
|
|
469
|
+
// every 3s forever — which in the browser suite showed up as failed requests after teardown
|
|
470
|
+
if (tries <= 40) setTimeout(() => { state.boardWanted.delete(key); loadBoards(); }, 3000);
|
|
471
|
+
else state.boardWanted.delete(key);
|
|
472
|
+
}
|
|
473
|
+
else state.boardWanted.delete(key);
|
|
474
|
+
} catch {
|
|
475
|
+
state.boards[key] = { ok: false, error: 'could not reach the workspace server for the ladder' };
|
|
476
|
+
state.boardWanted.delete(key);
|
|
477
|
+
renderTree();
|
|
478
|
+
}
|
|
479
|
+
})();
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
220
483
|
function renderWorkspaces() {
|
|
221
484
|
const html = state.workspaces.map((w) => {
|
|
222
485
|
const n = state.sessions.filter((s) => s.ws === w.id && s.state !== 'exited').length;
|
|
@@ -318,41 +581,9 @@ function stateLabel(s) {
|
|
|
318
581
|
return { shown, text: SS.label(shown, { quietMs, call: openCallOf(s), fmtDur: shortDur }) };
|
|
319
582
|
}
|
|
320
583
|
function renderSessions() {
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
$('needCount').textContent = needN ? `${needN} need you` : '';
|
|
325
|
-
$('sessions').innerHTML = mine.length ? mine.map((s) => {
|
|
326
|
-
const st = stateLabel(s);
|
|
327
|
-
return `
|
|
328
|
-
<div class="sessrow" data-id="${s.id}">
|
|
329
|
-
<button class="sess" data-id="${s.id}" data-state="${st.shown}" aria-current="${s.id === state.active}"
|
|
330
|
-
aria-label="${esc(s.name)} — ${esc(st.text)}">
|
|
331
|
-
<span class="r1"><span class="st" aria-hidden="true">${GLYPH[st.shown] || '•'}</span>
|
|
332
|
-
<span class="nm">${esc(s.name)}${s.origin && s.origin.startsWith('schedule:') ? '<span class="schtag">scheduled</span>' : ''}</span>
|
|
333
|
-
<span class="chip">${esc(st.text)}</span></span>
|
|
334
|
-
${subLine(s)}
|
|
335
|
-
${saysLine(s)}
|
|
336
|
-
</button>
|
|
337
|
-
<button class="ren" data-rename="${s.id}" aria-label="Rename ${esc(s.name)}" title="rename (F2)">✎</button>
|
|
338
|
-
</div>${harnessDrawer(s)}`;
|
|
339
|
-
}).join('')
|
|
340
|
-
: `<div class="empty">None running here — <b>+ new session</b> to run an agent in this repo.</div>`;
|
|
341
|
-
$('sessions').querySelectorAll('.sess').forEach((b) => {
|
|
342
|
-
b.onclick = () => selectSession(b.dataset.id);
|
|
343
|
-
b.ondblclick = () => startRename(b.dataset.id);
|
|
344
|
-
b.onkeydown = (e) => { if (e.key === 'F2') { e.preventDefault(); startRename(b.dataset.id); } };
|
|
345
|
-
});
|
|
346
|
-
$('sessions').querySelectorAll('[data-rename]').forEach((b) => b.onclick = () => startRename(b.dataset.rename));
|
|
347
|
-
// Restore each drill-in's openness AFTER render. renderSessions() replaces the pane's
|
|
348
|
-
// innerHTML on every server push, so a bare <details open> closes itself; openness is held
|
|
349
|
-
// in state and re-applied here — the same shape as paintRename() below.
|
|
350
|
-
$('sessions').querySelectorAll('details.hz').forEach((d) => {
|
|
351
|
-
d.open = state.openHarness.has(d.dataset.id);
|
|
352
|
-
d.addEventListener('toggle', () => {
|
|
353
|
-
if (d.open) state.openHarness.add(d.dataset.id); else state.openHarness.delete(d.dataset.id);
|
|
354
|
-
});
|
|
355
|
-
});
|
|
584
|
+
// The rows themselves live in the tree now (R1). This stays as the single entry point every
|
|
585
|
+
// session push already calls, so the tree, the tab strips and the overlay all repaint together.
|
|
586
|
+
renderTree();
|
|
356
587
|
renderTabs();
|
|
357
588
|
updateEmptyOverlay();
|
|
358
589
|
paintRename();
|
|
@@ -464,7 +695,7 @@ function structSig(list) {
|
|
|
464
695
|
function paintVolatile() {
|
|
465
696
|
tickQuiet(); // chip, glyph, data-state, accessible name (both surfaces)
|
|
466
697
|
for (const s of mySessions()) {
|
|
467
|
-
const row =
|
|
698
|
+
const row = document.querySelector(`#tree .sessrow[data-id="${s.id}"]`);
|
|
468
699
|
if (!row) continue;
|
|
469
700
|
// the sub-line ("opening focus …" or the agent · cwd fallback) and the agent's last words —
|
|
470
701
|
// both come from the server and both were frozen at the last lifecycle event. Assign only on a
|
|
@@ -480,7 +711,7 @@ function paintVolatile() {
|
|
|
480
711
|
} else if (says) { says.remove(); }
|
|
481
712
|
// the drill-in's rows — model, tokens, tools, skills. Only the <dl> is replaced, so the
|
|
482
713
|
// <details> element itself (and therefore its open/closed state) is untouched.
|
|
483
|
-
const dl =
|
|
714
|
+
const dl = document.querySelector(`#tree details.hz[data-id="${s.id}"] .kv`);
|
|
484
715
|
if (dl) { const next = harnessRows(s); if (dl.innerHTML !== next) dl.innerHTML = next; }
|
|
485
716
|
}
|
|
486
717
|
}
|
|
@@ -488,7 +719,7 @@ function paintVolatile() {
|
|
|
488
719
|
// accessible name only — never innerHTML — so an open drill-in and an in-flight rename survive.
|
|
489
720
|
function tickQuiet() {
|
|
490
721
|
for (const s of mySessions()) {
|
|
491
|
-
const btn =
|
|
722
|
+
const btn = document.querySelector(`.sess[data-id="${s.id}"]`);
|
|
492
723
|
if (!btn) continue;
|
|
493
724
|
const st = stateLabel(s);
|
|
494
725
|
if (btn.dataset.state !== st.shown) {
|
|
@@ -581,7 +812,7 @@ function commitRename() {
|
|
|
581
812
|
function paintRename() {
|
|
582
813
|
const id = state.renaming;
|
|
583
814
|
if (!id) return;
|
|
584
|
-
const row =
|
|
815
|
+
const row = document.querySelector(`#tree .sessrow[data-id="${id}"]`);
|
|
585
816
|
// rename in place wherever the PM double-clicked: the sidebar row, or either group's tab
|
|
586
817
|
const tab = $(GROUP_TABS[state.focusG]).querySelector(`.stab-wrap[data-id="${id}"]`)
|
|
587
818
|
|| document.querySelector(`.stabs .stab-wrap[data-id="${id}"]`);
|
|
@@ -792,16 +1023,217 @@ function refreshLibrary() { loadLibrary(); }
|
|
|
792
1023
|
function maybeAutoRefreshLibrary() { if (state.wsId && Date.now() - lastLibLoad > 4000) loadLibrary(); }
|
|
793
1024
|
window.addEventListener('focus', maybeAutoRefreshLibrary);
|
|
794
1025
|
document.addEventListener('visibilitychange', () => { if (!document.hidden) maybeAutoRefreshLibrary(); });
|
|
1026
|
+
|
|
1027
|
+
/* ── The rail's detail (R7·R8·R9) ────────────────────────────────────────────────────────────────
|
|
1028
|
+
*
|
|
1029
|
+
* The four standing lenses are replaced by ONE context-driven detail: a repository group gives repo
|
|
1030
|
+
* detail (the complete folder map, its schedules, its history — the three views that were never
|
|
1031
|
+
* initiative-scoped), a tree row gives that initiative's DOSSIER.
|
|
1032
|
+
*
|
|
1033
|
+
* The dossier is the promoted leg-A specimen (prototype/workspace-initiative-nav, Option B r2). Its
|
|
1034
|
+
* one non-obvious rule: THE LANE OWNS THE DENOMINATOR. A chore owes an intent, not a PRD; a
|
|
1035
|
+
* discovery spike owes a brief and nothing else. Measuring every initiative against one universal
|
|
1036
|
+
* slate — r1 of the specimen did exactly that — makes a complete discovery read as nine failures.
|
|
1037
|
+
* `n/a` here is never inferred: it comes from the engine's own step status.
|
|
1038
|
+
*/
|
|
1039
|
+
const DOSSIER_GROUPS = [
|
|
1040
|
+
{ key: 'strategy', label: 'strategy', roles: ['record', 'brief', 'research', 'scoping', 'intake', 'OKR'] },
|
|
1041
|
+
{ key: 'spec', label: 'spec', roles: ['PRD', 'rubric', 'user stories', 'explainer'] },
|
|
1042
|
+
{ key: 'design', label: 'design', roles: ['design brief', 'design', 'copy brief', 'copy', 'specimen', 'grilling'] },
|
|
1043
|
+
{ key: 'build', label: 'build', roles: ['reconcile', 'harness', 'fixture'] },
|
|
1044
|
+
{ key: 'learning', label: 'learning', roles: ['eval', 'findings', 'dogfood', 'rejected'] },
|
|
1045
|
+
];
|
|
1046
|
+
// Which ladder steps speak for which group, so a group can say "n/a for this lane" in the engine's
|
|
1047
|
+
// own words instead of a guess of our own.
|
|
1048
|
+
const GROUP_STEPS = {
|
|
1049
|
+
strategy: ['route', 'anchor'],
|
|
1050
|
+
spec: ['understand', 'spec', 'prototype'],
|
|
1051
|
+
design: ['prototype'],
|
|
1052
|
+
build: ['priors', 'contract', 'build', 'quality'],
|
|
1053
|
+
learning: ['review', 'explain', 'accept'],
|
|
1054
|
+
};
|
|
1055
|
+
|
|
1056
|
+
function detailWs() {
|
|
1057
|
+
const id = state.detail.wsId || state.wsId;
|
|
1058
|
+
return state.workspaces.find((w) => w.id === id) || null;
|
|
1059
|
+
}
|
|
1060
|
+
// The library for whichever tree the detail is about — NOT necessarily the selected one. Fetched on
|
|
1061
|
+
// demand and cached, because a dossier must be openable for a tree the PM is not standing in.
|
|
1062
|
+
async function ensureLib(wsId) {
|
|
1063
|
+
if (!wsId) return null;
|
|
1064
|
+
if (wsId === state.wsId && state.lib) { state.libByWs[wsId] = state.lib; return state.lib; }
|
|
1065
|
+
if (state.libByWs[wsId]) return state.libByWs[wsId];
|
|
1066
|
+
try {
|
|
1067
|
+
const r = await fetch('/api/library?ws=' + encodeURIComponent(wsId));
|
|
1068
|
+
const j = await r.json();
|
|
1069
|
+
state.libByWs[wsId] = j;
|
|
1070
|
+
return j;
|
|
1071
|
+
} catch { return null; }
|
|
1072
|
+
}
|
|
1073
|
+
/* Open an initiative's dossier. THIS IS A READ: it never switches working tree, never changes
|
|
1074
|
+
* state.wsId, and never touches the branch. Only clicking a tree row does that (R9). */
|
|
1075
|
+
async function openInitiative(id, wsId) {
|
|
1076
|
+
state.detail = { kind: 'initiative', id, wsId: wsId || state.wsId };
|
|
1077
|
+
state.lens = 'dossier';
|
|
1078
|
+
showRail();
|
|
1079
|
+
renderLibrary();
|
|
1080
|
+
await ensureLib(state.detail.wsId);
|
|
1081
|
+
renderLibrary();
|
|
1082
|
+
}
|
|
1083
|
+
function openRepoDetail(repoKey) {
|
|
1084
|
+
state.detail = { kind: 'repo', id: repoKey, wsId: state.wsId };
|
|
1085
|
+
if (state.lens === 'dossier') state.lens = 'folders';
|
|
1086
|
+
renderLibrary();
|
|
1087
|
+
}
|
|
1088
|
+
// The sub-views available for what is currently selected. `dossier` only exists when an initiative
|
|
1089
|
+
// is in view — a dead tab that cannot be filled is worse than an absent one.
|
|
1090
|
+
function renderLensTabs() {
|
|
1091
|
+
const host = $('lensTabs');
|
|
1092
|
+
if (!host) return;
|
|
1093
|
+
const views = state.detail.kind === 'initiative'
|
|
1094
|
+
? [['dossier', 'dossier'], ['folders', 'folders'], ['scheduled', 'scheduled'], ['history', 'history']]
|
|
1095
|
+
: [['folders', 'folders'], ['scheduled', 'scheduled'], ['history', 'history']];
|
|
1096
|
+
host.innerHTML = views.map(([k, label]) =>
|
|
1097
|
+
`<button role="tab" data-lens="${k}" aria-selected="${state.lens === k}">${esc(label)}</button>`).join('');
|
|
1098
|
+
host.querySelectorAll('button').forEach((b) => b.onclick = () => {
|
|
1099
|
+
state.lens = b.dataset.lens; store.set('lens', state.lens);
|
|
1100
|
+
$('libFind').placeholder = FIND_PLACEHOLDER[state.lens] || 'filter the library…';
|
|
1101
|
+
renderLibrary();
|
|
1102
|
+
});
|
|
1103
|
+
}
|
|
1104
|
+
// R9 — name the repository these documents come from. getWs() silently falls back to the home
|
|
1105
|
+
// workspace on an id it cannot resolve, so a rail that does not say what it is reading can show
|
|
1106
|
+
// another repository's files with no visual difference at all.
|
|
1107
|
+
function renderReading() {
|
|
1108
|
+
const el = $('libWho');
|
|
1109
|
+
if (!el) return;
|
|
1110
|
+
const w = detailWs();
|
|
1111
|
+
if (!w) { el.textContent = ''; return; }
|
|
1112
|
+
const away = w.id !== state.wsId;
|
|
1113
|
+
el.textContent = w.name + (away ? ' · not the selected tree' : '');
|
|
1114
|
+
el.dataset.away = away ? '1' : '0';
|
|
1115
|
+
el.title = w.root;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
function dossierRow(a) {
|
|
1119
|
+
return `<button class="dz-art" data-doc="${esc(a.path)}">
|
|
1120
|
+
<span class="role">${esc(a.role || 'doc')}</span>
|
|
1121
|
+
<span class="pth">${esc(a.path)}</span></button>`;
|
|
1122
|
+
}
|
|
1123
|
+
function renderDossier() {
|
|
1124
|
+
const host = $('library');
|
|
1125
|
+
const id = state.detail.id;
|
|
1126
|
+
const w = detailWs();
|
|
1127
|
+
const board = w ? boardFor(w) : null;
|
|
1128
|
+
const row = board && board.data && Array.isArray(board.data.live)
|
|
1129
|
+
? board.data.live.find((r) => r.id === id) : null;
|
|
1130
|
+
const lib = (w && state.libByWs[w.id]) || (w && w.id === state.wsId ? state.lib : null);
|
|
1131
|
+
const group = lib && Array.isArray(lib.initiatives) ? lib.initiatives.find((g) => g.id === id) : null;
|
|
1132
|
+
|
|
1133
|
+
if (board && board.ok === false) {
|
|
1134
|
+
host.innerHTML = csError('the initiative ladder', board.error || 'the board could not be derived');
|
|
1135
|
+
return;
|
|
1136
|
+
}
|
|
1137
|
+
const head = `<div class="dz-head">
|
|
1138
|
+
<div class="dz-id">${esc(id)}</div>
|
|
1139
|
+
${group && group.title ? `<div class="dz-t">${esc(group.title)}</div>` : ''}
|
|
1140
|
+
<div class="dz-meta">
|
|
1141
|
+
${row ? `<span class="pill lane">${esc(row.lane || '')}</span>` : ''}
|
|
1142
|
+
${row ? `<span class="pill">${row.stage === 'accepted' ? 'accepted' : 'stage ' + esc(row.stage || '')}</span>` : ''}
|
|
1143
|
+
${row && row.anchor ? `<span class="pill">${esc(String(row.anchor).split(/[(—]/)[0].trim())}</span>` : ''}
|
|
1144
|
+
${row && row.legacy ? '<span class="pill">legacy</span>' : ''}
|
|
1145
|
+
</div>
|
|
1146
|
+
${row ? dossierAttrib(w, board) : ''}
|
|
1147
|
+
</div>`;
|
|
1148
|
+
|
|
1149
|
+
if (board && board.pending) {
|
|
1150
|
+
host.innerHTML = head + `<div class="dz-wait">Deriving this initiative's ladder…
|
|
1151
|
+
<span class="sub">the derivation shells git once per initiative and runs in a child process,
|
|
1152
|
+
so the workspace stays responsive while it does.</span></div>` + dossierArtifacts(group, null);
|
|
1153
|
+
wireDossier();
|
|
1154
|
+
return;
|
|
1155
|
+
}
|
|
1156
|
+
if (!row) {
|
|
1157
|
+
host.innerHTML = head + `<div class="dz-none">No ladder for <b>${esc(id)}</b> in this repository's board —
|
|
1158
|
+
it has no initiative record, or the record is not readable. The artifacts below are what the
|
|
1159
|
+
library can see; nothing about its position in the loop is being claimed.</div>`
|
|
1160
|
+
+ dossierArtifacts(group, null);
|
|
1161
|
+
wireDossier();
|
|
1162
|
+
return;
|
|
1163
|
+
}
|
|
1164
|
+
host.innerHTML = head + dossierNext(row) + dossierArtifacts(group, row);
|
|
1165
|
+
wireDossier();
|
|
1166
|
+
}
|
|
1167
|
+
// R5 in the rail: the same attribution the tree rows carry. A stage without its branch is a claim
|
|
1168
|
+
// the data does not support.
|
|
1169
|
+
function dossierAttrib(w, board) {
|
|
1170
|
+
const stamp = board && board.as_of ? new Date(board.as_of).toLocaleTimeString() : null;
|
|
1171
|
+
return `<div class="dz-attrib">derived on <b>${esc((w && w.branch) || '?')}</b>${stamp ? ` · as of ${esc(stamp)}` : ''}${board && board.stale ? ' · refreshing' : ''}
|
|
1172
|
+
<span class="sub">build and quality are scoped to this branch's commits, not to all of history</span></div>`;
|
|
1173
|
+
}
|
|
1174
|
+
// Accepted and owing render TOGETHER — an initiative can be accepted with steps still open, and a
|
|
1175
|
+
// green badge alone would be the watermelon.
|
|
1176
|
+
function dossierNext(row) {
|
|
1177
|
+
const open = (row.steps || []).filter((s) => s.status === 'pending' || s.status === 'blocked');
|
|
1178
|
+
const accepted = row.stage === 'accepted';
|
|
1179
|
+
if (accepted && open.length) {
|
|
1180
|
+
return `<div class="dz-next owed"><b>still owed · ${open.length} step${open.length === 1 ? '' : 's'}</b>
|
|
1181
|
+
Accepted, and these never closed: ${open.map((s) => esc(s.key)).join(' · ')}.</div>`;
|
|
1182
|
+
}
|
|
1183
|
+
if (row.next && row.next.action) {
|
|
1184
|
+
return `<div class="dz-next"><b>next · ${esc(row.next.step || '')}</b>${esc(row.next.action)}</div>`;
|
|
1185
|
+
}
|
|
1186
|
+
return `<div class="dz-next done"><b>nothing owed</b>Every step this lane asks for is closed.</div>`;
|
|
1187
|
+
}
|
|
1188
|
+
function dossierArtifacts(group, row) {
|
|
1189
|
+
const arts = (group && group.artifacts) || [];
|
|
1190
|
+
const used = new Set();
|
|
1191
|
+
const stepBy = {};
|
|
1192
|
+
for (const st of (row && row.steps) || []) stepBy[st.key] = st;
|
|
1193
|
+
|
|
1194
|
+
const sections = DOSSIER_GROUPS.map((g) => {
|
|
1195
|
+
const mine = arts.filter((a) => g.roles.includes(a.role));
|
|
1196
|
+
mine.forEach((a) => used.add(a.path));
|
|
1197
|
+
// the lane's own verdict on this group, in the engine's words — never our inference
|
|
1198
|
+
const naStep = (GROUP_STEPS[g.key] || []).map((k) => stepBy[k]).filter(Boolean);
|
|
1199
|
+
const allNa = naStep.length && naStep.every((st) => st.status === 'na');
|
|
1200
|
+
if (!mine.length && allNa) {
|
|
1201
|
+
return `<div class="dz-sec"><h5>${esc(g.label)} <span class="c">n/a</span></h5>
|
|
1202
|
+
<div class="dz-na">${esc(naStep[0].evidence || 'not required in this lane')}</div></div>`;
|
|
1203
|
+
}
|
|
1204
|
+
if (!mine.length) {
|
|
1205
|
+
return `<div class="dz-sec"><h5>${esc(g.label)} <span class="c">nothing yet</span></h5>
|
|
1206
|
+
<div class="dz-na">No ${esc(g.label)} artifact is filed for this initiative.</div></div>`;
|
|
1207
|
+
}
|
|
1208
|
+
return `<div class="dz-sec"><h5>${esc(g.label)} <span class="c">${mine.length}</span></h5>
|
|
1209
|
+
${mine.map(dossierRow).join('')}</div>`;
|
|
1210
|
+
}).join('');
|
|
1211
|
+
|
|
1212
|
+
const rest = arts.filter((a) => !used.has(a.path));
|
|
1213
|
+
const other = rest.length
|
|
1214
|
+
? `<div class="dz-sec"><h5>other <span class="c">${rest.length}</span></h5>${rest.map(dossierRow).join('')}</div>`
|
|
1215
|
+
: '';
|
|
1216
|
+
const none = !arts.length
|
|
1217
|
+
? `<div class="dz-na">The library sees no artifacts for this initiative yet. This is a verified
|
|
1218
|
+
empty — the read succeeded.</div>` : '';
|
|
1219
|
+
return `<div class="dz-arts">${sections}${other}${none}</div>`;
|
|
1220
|
+
}
|
|
1221
|
+
function wireDossier() {
|
|
1222
|
+
$('library').querySelectorAll('[data-doc]').forEach((b) => b.onclick = () => openDoc(b.dataset.doc));
|
|
1223
|
+
}
|
|
1224
|
+
|
|
795
1225
|
function renderLibrary() {
|
|
1226
|
+
renderLensTabs();
|
|
1227
|
+
renderReading();
|
|
1228
|
+
if (state.lens === 'dossier' && state.detail.kind === 'initiative') { renderDossier(); return; }
|
|
1229
|
+
if (state.lens === 'dossier') { state.lens = 'folders'; }
|
|
796
1230
|
// a third, persistent lens beside initiatives + folders: the scheduled tasks for this workspace
|
|
797
1231
|
if (state.lens === 'scheduled') {
|
|
798
|
-
document.querySelectorAll('.lens button').forEach((b) => b.setAttribute('aria-selected', String(b.dataset.lens === 'scheduled')));
|
|
799
1232
|
renderScheduled();
|
|
800
1233
|
return;
|
|
801
1234
|
}
|
|
802
1235
|
// a fourth: what past sessions in THIS repo actually printed. The filter box becomes the query.
|
|
803
1236
|
if (state.lens === 'history') {
|
|
804
|
-
document.querySelectorAll('.lens button').forEach((b) => b.setAttribute('aria-selected', String(b.dataset.lens === 'history')));
|
|
805
1237
|
renderHistory();
|
|
806
1238
|
return;
|
|
807
1239
|
}
|
|
@@ -810,8 +1242,7 @@ function renderLibrary() {
|
|
|
810
1242
|
// BOTH lenses are always available: initiatives (the PMOS projection — works whenever the repo
|
|
811
1243
|
// has PRDs, even in a nested bundle) and folders (the complete repo map). A repo with no
|
|
812
1244
|
// initiatives shows an empty-but-honest initiative view — the toggle is never dead, never hidden.
|
|
813
|
-
const effLens =
|
|
814
|
-
document.querySelectorAll('.lens button').forEach((b) => b.setAttribute('aria-selected', String(b.dataset.lens === effLens)));
|
|
1245
|
+
const effLens = 'folders';
|
|
815
1246
|
if (!lib) { host.innerHTML = `<div class="empty">Library unavailable.</div>`; return; }
|
|
816
1247
|
const f = state.filter.trim().toLowerCase();
|
|
817
1248
|
const hit = (s) => !f || String(s).toLowerCase().includes(f);
|
|
@@ -1005,13 +1436,19 @@ function applyLayout() {
|
|
|
1005
1436
|
|
|
1006
1437
|
// folds
|
|
1007
1438
|
paintFold('paneWs', 'wsFold', l.folds.ws);
|
|
1008
|
-
|
|
1439
|
+
// `sess` retired with the pane it folded (R1/R13); a stored value is ignored, not migrated.
|
|
1009
1440
|
paintFold('paneLib', 'libFold', l.folds.lib);
|
|
1010
1441
|
paintFold('paneDoc', 'docFold', l.folds.doc);
|
|
1011
1442
|
|
|
1012
1443
|
// split is a desktop arrangement; below 1100px two groups are two unusable terminals (R9)
|
|
1013
1444
|
applySplit(l.split === 'on' && !NARROW.matches);
|
|
1014
1445
|
|
|
1446
|
+
// The library/document divider is part of the ARRANGEMENT (R8), driven from the tuple. It used
|
|
1447
|
+
// to be "a client preference like the theme" in a global localStorage key — which R8 and rubric
|
|
1448
|
+
// dimension 1 both contradict: they require it carried per workspace and the old key not re-read
|
|
1449
|
+
// on later loads. `libsplit` is now read exactly once, by migrateLegacy.
|
|
1450
|
+
document.documentElement.style.setProperty('--libsplit', l.libSplit + '%');
|
|
1451
|
+
|
|
1015
1452
|
$('layName').textContent = l.name;
|
|
1016
1453
|
renderLayoutMenu();
|
|
1017
1454
|
refit();
|
|
@@ -1159,9 +1596,34 @@ function renderLayoutMenu() {
|
|
|
1159
1596
|
if (!l) return;
|
|
1160
1597
|
openLayNew(l.name, l.id);
|
|
1161
1598
|
});
|
|
1599
|
+
renderLayoutPositions();
|
|
1162
1600
|
const w = state.workspaces.find((x) => x.id === state.wsId);
|
|
1163
1601
|
if ($('layScope')) $('layScope').textContent = w ? w.name : 'this repo';
|
|
1164
1602
|
}
|
|
1603
|
+
|
|
1604
|
+
/* R12 — keyboard parity with the drag. Every value `side` and `reader` can hold is a real button
|
|
1605
|
+
here, so no position is pointer-only. This is what makes the "no dead ends" property true rather
|
|
1606
|
+
than merely argued: from ANY arrangement, including side:off + reader:off, the header control is
|
|
1607
|
+
in the bar (never inside the grid it rearranges), and these buttons bring a pane back. */
|
|
1608
|
+
const POS_GROUPS = [
|
|
1609
|
+
{ key: 'side', label: 'sidebar', values: [['left', '◧'], ['right', '◨'], ['off', '∅']] },
|
|
1610
|
+
{ key: 'reader', label: 'reader', values: [['left', '◧'], ['right', '◨'], ['bottom', '⬓'], ['off', '∅']] },
|
|
1611
|
+
];
|
|
1612
|
+
function renderLayoutPositions() {
|
|
1613
|
+
const host = $('layPos');
|
|
1614
|
+
if (!host) return;
|
|
1615
|
+
const l = curLayout();
|
|
1616
|
+
host.innerHTML = POS_GROUPS.map((g) => `
|
|
1617
|
+
<div class="pos-row" role="group" aria-label="${g.label} position">
|
|
1618
|
+
<span class="pos-lbl">${g.label}</span>
|
|
1619
|
+
${g.values.map(([v, glyph]) => `<button class="pos-btn" data-pos="${g.key}" data-val="${v}"
|
|
1620
|
+
aria-pressed="${l[g.key] === v}" title="${g.label} ${v}"
|
|
1621
|
+
aria-label="${g.label} ${v}"><i aria-hidden="true">${glyph}</i><span>${v}</span></button>`).join('')}
|
|
1622
|
+
</div>`).join('');
|
|
1623
|
+
host.querySelectorAll('[data-pos]').forEach((b) => b.onclick = () => {
|
|
1624
|
+
tweak({ [b.dataset.pos]: b.dataset.val }); // menu stays open: adjusting is not choosing
|
|
1625
|
+
});
|
|
1626
|
+
}
|
|
1165
1627
|
function openLayMenu() { $('layMenu').hidden = false; $('layBtn').setAttribute('aria-expanded', 'true'); renderLayoutMenu(); }
|
|
1166
1628
|
function closeLayMenu() { $('layMenu').hidden = true; $('layBtn').setAttribute('aria-expanded', 'false'); closeLayNew(); }
|
|
1167
1629
|
function openLayNew(value, renameId) {
|
|
@@ -1199,7 +1661,7 @@ function newLayoutName() {
|
|
|
1199
1661
|
|
|
1200
1662
|
// ── folds: both columns, same behaviour (R11) ──
|
|
1201
1663
|
function foldPane(which, open) {
|
|
1202
|
-
const map = { doc: 'paneDoc', lib: 'paneLib', ws: 'paneWs'
|
|
1664
|
+
const map = { doc: 'paneDoc', lib: 'paneLib', ws: 'paneWs' };
|
|
1203
1665
|
if (!map[which]) return;
|
|
1204
1666
|
const l = curLayout();
|
|
1205
1667
|
const folds = Object.assign({}, l.folds);
|
|
@@ -1211,7 +1673,6 @@ function showRail() { if (curLayout().reader === 'off') tweak({ reader: 'right'
|
|
|
1211
1673
|
$('libFold').onclick = () => foldPane('lib', $('paneLib').dataset.fold === 'off');
|
|
1212
1674
|
$('docFold').onclick = () => foldPane('doc', $('paneDoc').dataset.fold === 'off');
|
|
1213
1675
|
$('wsFold').onclick = () => foldPane('ws', $('paneWs').dataset.fold === 'off');
|
|
1214
|
-
$('sessFold').onclick = () => foldPane('sess', $('paneSess').dataset.fold === 'off');
|
|
1215
1676
|
|
|
1216
1677
|
// ── the edges: drag a column's outer edge to resize it. Keeps to the layout, like everything
|
|
1217
1678
|
// else — a width the PM chose is part of the arrangement, not a separate preference. ──
|
|
@@ -1296,9 +1757,15 @@ wireEdge($('sideEdge'));
|
|
|
1296
1757
|
});
|
|
1297
1758
|
g.addEventListener('pointermove', move);
|
|
1298
1759
|
g.addEventListener('pointerup', end);
|
|
1299
|
-
// keyboard: the grip
|
|
1760
|
+
// keyboard: the grip opens the layout menu and lands focus on the position controls, which can
|
|
1761
|
+
// reach every value the drag can (R12). It previously focused #layBtn — a menu whose only
|
|
1762
|
+
// contents were layout rows, so Enter here could not actually move anything.
|
|
1300
1763
|
g.addEventListener('keydown', (e) => {
|
|
1301
|
-
if (e.key
|
|
1764
|
+
if (e.key !== 'Enter' && e.key !== ' ') return;
|
|
1765
|
+
e.preventDefault();
|
|
1766
|
+
openLayMenu();
|
|
1767
|
+
const first = $('layPos') && $('layPos').querySelector(`[data-pos="${g.dataset.pane === 'side' ? 'side' : 'reader'}"]`);
|
|
1768
|
+
(first || $('layBtn')).focus();
|
|
1302
1769
|
});
|
|
1303
1770
|
});
|
|
1304
1771
|
})();
|
|
@@ -1327,38 +1794,37 @@ document.addEventListener('pointerdown', (e) => {
|
|
|
1327
1794
|
|
|
1328
1795
|
applyLayout(); // paint the default arrangement before the server's layouts arrive
|
|
1329
1796
|
|
|
1330
|
-
// the
|
|
1331
|
-
//
|
|
1332
|
-
{ const s = store.get('libsplit'); if (s) document.documentElement.style.setProperty('--libsplit', s); }
|
|
1333
|
-
|
|
1334
|
-
// drag the divider between the library and the document
|
|
1797
|
+
// drag the divider between the library and the document. It is part of the arrangement (R8), so
|
|
1798
|
+
// it is kept against the current layout like every other adjustment — not in a global client key.
|
|
1335
1799
|
(() => {
|
|
1336
1800
|
const split = $('railSplit');
|
|
1337
1801
|
let dragging = false;
|
|
1338
1802
|
const apply = (clientY) => {
|
|
1339
1803
|
const r = $('rail').getBoundingClientRect();
|
|
1340
|
-
const pct = Math.min(
|
|
1341
|
-
|
|
1342
|
-
document.documentElement.style.setProperty('--libsplit', v);
|
|
1343
|
-
store.set('libsplit', v);
|
|
1804
|
+
const pct = Math.min(LAY.LIB_MAX, Math.max(LAY.LIB_MIN, ((clientY - r.top) / r.height) * 100));
|
|
1805
|
+
tweak({ libSplit: Math.round(pct * 10) / 10 });
|
|
1344
1806
|
};
|
|
1345
1807
|
split.addEventListener('pointerdown', (e) => { dragging = true; split.setPointerCapture(e.pointerId); e.preventDefault(); });
|
|
1346
1808
|
split.addEventListener('pointermove', (e) => { if (dragging) apply(e.clientY); });
|
|
1347
1809
|
split.addEventListener('pointerup', (e) => { dragging = false; try { split.releasePointerCapture(e.pointerId); } catch {} refit(); });
|
|
1348
1810
|
split.addEventListener('keydown', (e) => {
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
document.documentElement.style.setProperty('--libsplit', v); store.set('libsplit', v); refit();
|
|
1354
|
-
}
|
|
1811
|
+
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
|
|
1812
|
+
e.preventDefault();
|
|
1813
|
+
const cur = curLayout().libSplit;
|
|
1814
|
+
tweak({ libSplit: Math.round(Math.min(LAY.LIB_MAX, Math.max(LAY.LIB_MIN, cur + (e.key === 'ArrowUp' ? -4 : 4))) * 10) / 10 });
|
|
1355
1815
|
});
|
|
1356
1816
|
})();
|
|
1357
1817
|
|
|
1358
1818
|
// ── needs-you + gates (the PMOS workflow: Review/Acceptance in file mode, never automatic) ──
|
|
1359
1819
|
async function loadNeeds() {
|
|
1360
|
-
try {
|
|
1361
|
-
|
|
1820
|
+
try {
|
|
1821
|
+
const r = await fetch('/api/needs?ws=' + encodeURIComponent(state.wsId || ''));
|
|
1822
|
+
const j = await r.json();
|
|
1823
|
+
state.needs = j.needs || [];
|
|
1824
|
+
// R12 — what a COLLAPSED tree row shows. R10 — the count comes from the strict reader, so a
|
|
1825
|
+
// corrupt queue arrives as {count:null,error}, never as a calm zero.
|
|
1826
|
+
state.needsByWs = j.by_ws || {};
|
|
1827
|
+
} catch { state.needs = []; state.needsByWs = {}; }
|
|
1362
1828
|
renderNeeds(); renderSessions();
|
|
1363
1829
|
}
|
|
1364
1830
|
function renderNeeds() {
|
|
@@ -1992,11 +2458,6 @@ $('fsPicker').addEventListener('click', (e) => { if (e.target === $('fsPicker'))
|
|
|
1992
2458
|
|
|
1993
2459
|
// ── library lens + filter ──
|
|
1994
2460
|
const FIND_PLACEHOLDER = { history: 'search past sessions…', scheduled: 'filter the library…' };
|
|
1995
|
-
document.querySelectorAll('.lens button').forEach((b) => b.onclick = () => {
|
|
1996
|
-
state.lens = b.dataset.lens; store.set('lens', state.lens);
|
|
1997
|
-
$('libFind').placeholder = FIND_PLACEHOLDER[state.lens] || 'filter the library…';
|
|
1998
|
-
renderLibrary();
|
|
1999
|
-
});
|
|
2000
2461
|
$('libFind').addEventListener('input', (e) => { state.filter = e.target.value; renderLibrary(); });
|
|
2001
2462
|
$('libRefresh').onclick = () => {
|
|
2002
2463
|
const b = $('libRefresh'); b.classList.add('spin');
|
|
@@ -2307,6 +2768,10 @@ function sfHead(active, ok) {
|
|
|
2307
2768
|
${chip}</div>`;
|
|
2308
2769
|
}
|
|
2309
2770
|
function wireSurface() {
|
|
2771
|
+
// the workflow surface's initiative ids open the DOSSIER (R9, route 2) — a read, not a checkout
|
|
2772
|
+
document.querySelectorAll('#docBody .wf-id[data-init]').forEach((b) => b.onclick = (e) => {
|
|
2773
|
+
e.preventDefault(); openInitiative(b.dataset.init, state.wsId);
|
|
2774
|
+
});
|
|
2310
2775
|
document.querySelectorAll('[data-surface]').forEach((b) => b.onclick = () => openSurface(b.dataset.surface));
|
|
2311
2776
|
document.querySelectorAll('[data-open-surface]').forEach((b) => b.onclick = () => openSurface(b.dataset.openSurface));
|
|
2312
2777
|
$('docBody').querySelectorAll('.sf-art[data-doc],.wf-id[data-doc]').forEach((b) => b.onclick = () => openDoc(b.dataset.doc));
|
|
@@ -2376,10 +2841,31 @@ function sfRoadmap(s) {
|
|
|
2376
2841
|
</div>`).join('') + (s.note ? `<div class="sf-note">${esc(s.note)}</div>` : '');
|
|
2377
2842
|
}
|
|
2378
2843
|
function sfOkr(s) {
|
|
2379
|
-
|
|
2844
|
+
// A FAILED read renders RED and nothing else — never a list that silently omits what could not be
|
|
2845
|
+
// parsed. Reusing .cs-error deliberately: the workspace has exactly one way to say "broken read",
|
|
2846
|
+
// and a second visual vocabulary for the same meaning is how one of them ends up looking calm.
|
|
2847
|
+
if (s.unreadable) return csError('okr', s.unreadable);
|
|
2848
|
+
|
|
2849
|
+
const h = s.anchor_health;
|
|
2850
|
+
// Unresolvable ANCHORS are the defect this surface exists to expose: an anchor naming a KR no
|
|
2851
|
+
// document declares sat unseen for two days precisely because nothing rendered it. It is RED, and
|
|
2852
|
+
// it is placed ABOVE the objectives so it cannot be scrolled past.
|
|
2853
|
+
const bad = (h && h.unresolvable) || [];
|
|
2854
|
+
const anchorBand = bad.length
|
|
2855
|
+
? `<div class="cs-error" role="alert">
|
|
2856
|
+
<div class="cs-err-head"><span class="mk" aria-hidden="true">▲</span>
|
|
2857
|
+
<b>${bad.length} anchor${bad.length > 1 ? 's' : ''} name${bad.length > 1 ? '' : 's'} something these documents don’t declare</b></div>
|
|
2858
|
+
${bad.map((u) => `<p class="cs-err-why"><b>${esc(u.initiative)}</b> — <code>${esc(u.raw)}</code><br>${esc(u.reason)}</p>`).join('')}
|
|
2859
|
+
<p class="cs-err-note">An anchor that resolves to nothing is not an anchor. Either write one the
|
|
2860
|
+
OKR documents declare, or add the KR — adding a KR is the PM’s act, never an agent’s.</p></div>`
|
|
2861
|
+
: (h ? `<div class="sf-pulse"><span class="n"><b>${h.checked}</b> anchors resolve</span>
|
|
2862
|
+
<span class="sf-legend"><span>every non-empty parent_kr names an objective or KR written in these documents${h.empty ? ` · ${h.empty} unanchored` : ''}</span></span></div>` : '');
|
|
2863
|
+
|
|
2864
|
+
const body = (s.objectives || []).map((o) => `<div class="sf-kr"><div class="obj">${esc(o.id)} · ${esc(o.name)}</div>
|
|
2380
2865
|
${(o.krs || []).map((k) => `<div class="krrow"><div class="krh"><span class="lab">${esc(k.id)}</span><span>${esc(k.name)}</span></div>
|
|
2381
2866
|
<div class="lev"><span class="untr">current · target · levers — not tracked</span></div></div>`).join('') || '<span class="untr">no KRs parsed</span>'}
|
|
2382
|
-
</div>`).join('')
|
|
2867
|
+
</div>`).join('');
|
|
2868
|
+
return anchorBand + body + (s.note ? `<div class="sf-note">${esc(s.note)}</div>` : '');
|
|
2383
2869
|
}
|
|
2384
2870
|
function sfReport(s) {
|
|
2385
2871
|
return `<div class="sf-rep"><p class="lead">${esc(s.cadence)} report · derived on demand</p>
|
|
@@ -2463,7 +2949,7 @@ function csWorkflow(d) {
|
|
|
2463
2949
|
`<span class="wf-st ${i < at ? 'past' : i === at ? 'at' : ''}">${esc(s)}</span>`).join('<i class="wf-sep" aria-hidden="true"></i>');
|
|
2464
2950
|
return `<div class="wf-card" data-blocked="${r.blockers.length ? '1' : '0'}">
|
|
2465
2951
|
<div class="wf-top">
|
|
2466
|
-
<button class="wf-id" data-doc="${esc(r.record)}" title="open the initiative record">${esc(r.id)}</button>
|
|
2952
|
+
<button class="wf-id" data-init="${esc(r.id)}" data-doc="${esc(r.record)}" title="open the initiative record">${esc(r.id)}</button>
|
|
2467
2953
|
<span class="wf-lane">${esc(r.lane)}</span>
|
|
2468
2954
|
${r.in_change ? '<span class="wf-here">this change</span>' : ''}
|
|
2469
2955
|
<span class="wf-anchor" title="${esc(r.anchor || '')}">${esc((r.anchor || 'unanchored').split(/[(—]/)[0].trim())}</span>
|
|
@@ -2754,9 +3240,7 @@ function choosePalette(n) {
|
|
|
2754
3240
|
else if (it.kind === 'workspace') selectWorkspace(it.wsid);
|
|
2755
3241
|
else if (it.kind === 'initiative') {
|
|
2756
3242
|
// jump to the initiative's primary artifact (PRD first — its own ordering guarantees it)
|
|
2757
|
-
|
|
2758
|
-
state.lens = 'initiatives'; state.filter = it.init.id; $('libFind').value = it.init.id; renderLibrary();
|
|
2759
|
-
if (first) openDoc(first.path);
|
|
3243
|
+
openInitiative(it.init.id, state.wsId);
|
|
2760
3244
|
} else openDoc(it.path);
|
|
2761
3245
|
}
|
|
2762
3246
|
$('palInput')?.addEventListener('input', (e) => renderPalette(e.target.value));
|