create-pmos 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +57 -0
  2. package/package.json +1 -1
  3. package/payload/KIT-VERSION +2 -2
  4. package/workspace/README.md +44 -12
  5. package/workspace/design-parity.js +358 -0
  6. package/workspace/lib/console-state.js +48 -1
  7. package/workspace/lib/engine/workflow-state.js +13 -3
  8. package/workspace/lib/find-browser.js +60 -0
  9. package/workspace/lib/focus.js +967 -0
  10. package/workspace/lib/four-panel.js +345 -0
  11. package/workspace/lib/git-info.js +28 -1
  12. package/workspace/lib/handoff.js +280 -0
  13. package/workspace/lib/layout.js +68 -46
  14. package/workspace/lib/mobile-nav.js +157 -0
  15. package/workspace/lib/profiles.js +285 -0
  16. package/workspace/lib/repo-scan.js +469 -0
  17. package/workspace/package-lock.json +29 -2
  18. package/workspace/package.json +14 -4
  19. package/workspace/public/index.html +79 -279
  20. package/workspace/public/pm/app.js +242 -0
  21. package/workspace/public/pm/data.js +877 -0
  22. package/workspace/public/pm/dom.js +116 -0
  23. package/workspace/public/pm/fixture.js +979 -0
  24. package/workspace/public/pm/mobile.css +200 -0
  25. package/workspace/public/pm/mobile.js +403 -0
  26. package/workspace/public/pm/modes.js +1076 -0
  27. package/workspace/public/pm/panel-detail.js +648 -0
  28. package/workspace/public/pm/panel-initiatives.js +426 -0
  29. package/workspace/public/pm/panel-repos.js +460 -0
  30. package/workspace/public/pm/panel-tabs.js +487 -0
  31. package/workspace/public/pm/state.js +164 -0
  32. package/workspace/public/pm/tab-doc.js +279 -0
  33. package/workspace/public/pm/tab-task.js +83 -0
  34. package/workspace/public/pm/tab-term.js +560 -0
  35. package/workspace/public/pm/tokens.css +73 -0
  36. package/workspace/public/vendor/ibm-plex-mono-500.woff2 +0 -0
  37. package/workspace/server.js +1072 -31
  38. package/workspace/public/app.js +0 -3450
  39. package/workspace/public/styles.css +0 -1444
@@ -1,3450 +0,0 @@
1
- /* PMOS Terminal Workspace — GUI client.
2
- * Attaches to the persistent session server over WebSocket; renders each session's REAL
3
- * PTY in an xterm.js terminal (the agent harness runs untouched inside). Sessions survive
4
- * detach — closing/reloading this client never kills a run.
5
- *
6
- * One server hosts MANY workspaces (repo roots). Everything on screen — sessions, tabs,
7
- * library, needs-you — is scoped to the selected workspace; switching never disturbs a
8
- * session running in another one.
9
- *
10
- * Layout (round 2): the left column IS the workspace — repos over running agents, equal
11
- * halves. The terminal is the middle. The right rail carries the library over the document
12
- * reader; either pane folds away and the whole rail hides. */
13
- 'use strict';
14
- const $ = (id) => document.getElementById(id);
15
- const FitAddon = (window.FitAddon && window.FitAddon.FitAddon) || null;
16
- const WebLinks = (window.WebLinksAddon && window.WebLinksAddon.WebLinksAddon) || null;
17
-
18
- const state = {
19
- ws: null, sessions: [], workspaces: [], home: null, openHarness: new Set(),
20
- wsId: null, // selected workspace
21
- lib: null, // library payload for the selected workspace
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)
26
- filter: '',
27
- active: null, // active session id — always groups[focusG].activeId
28
- groups: [{ activeId: null }, { activeId: null }], // terminal groups (R6); [1] exists when split
29
- focusG: 0, // which group the keyboard, ⌘J send and rename act on
30
- layouts: [], // this workspace's arrangements (R1) — stock four + custom
31
- layoutId: null, // the one currently applied
32
- layoutSaveT: null, // debounce handle for the write-back
33
- layoutDirty: false, // an adjustment not yet on disk — flushed on pagehide
34
- scratchKey: null, // whose scratchpad is on screen — 'session:<id>' or 'ws:<id>'
35
- scratchT: null, // debounce handle for the pad's write-back (like layoutSaveT)
36
- scratchPending: null, // {session,ws,text} typed but not yet written — flushed on a switch
37
- terms: new Map(), // id -> {term, fit, el, g}
38
- attached: new Set(), // ids attached ON THIS CONNECTION (cleared on reconnect)
39
- needs: [],
40
- newAgent: 'shell',
41
- renaming: null, // session id being renamed inline
42
- renameVal: '', // survives a re-render mid-edit
43
- docPath: null, // the document open in the reader
44
- fsDir: null, // the in-app folder browser's current directory
45
- ctrlArmed: false, // mobile control bar: Ctrl is a one-shot modifier for the next key
46
- branch: null, // {ok,current,branches[],detached,dirty} for the selected workspace
47
- worktrees: [], // the selected workspace's repo's working trees, with removability
48
- newBranchMode: false, // new-session form: creating a branch vs picking an existing one
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
57
- };
58
-
59
- // ── workspace state survives reloads (R7): theme + selected workspace + focused session,
60
- // and now the rail/pane layout the PM chose (R12). ──
61
- const store = {
62
- get: (k) => { try { return localStorage.getItem('pmos-ws:' + k); } catch { return null; } },
63
- set: (k, v) => { try { localStorage.setItem('pmos-ws:' + k, v); } catch {} },
64
- };
65
- { const t = store.get('theme'); if (t === 'light' || t === 'dark') document.documentElement.dataset.theme = t; }
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'; }
72
- // ?ws=<id> is how `pmos` in another repo lands you in THAT repo, not the last one you opened.
73
- const urlWs = new URLSearchParams(location.search).get('ws');
74
-
75
- // ── xterm theme, single-sourced from the CSS token layer (no duplicated hex). The
76
- // terminal ground is dark by design (--term-* tokens hold the same value in both themes). ──
77
- function xtheme() {
78
- const cs = getComputedStyle(document.documentElement);
79
- const v = (n, f) => (cs.getPropertyValue(n).trim() || f);
80
- return {
81
- background: v('--xt-bg', '#07090c'), foreground: v('--xt-fg', '#b9c2ce'),
82
- cursor: v('--xt-cursor', '#48d597'), cursorAccent: v('--xt-bg', '#07090c'),
83
- selectionBackground: v('--xt-sel', 'rgba(79,147,247,.3)'),
84
- black: v('--xt-black', '#0a0c10'), red: v('--xt-red', '#f2655f'), green: v('--xt-green', '#48d597'),
85
- yellow: v('--xt-yellow', '#f1c052'), blue: v('--xt-blue', '#4f93f7'), magenta: v('--xt-magenta', '#b39cf7'),
86
- cyan: v('--xt-cyan', '#6fd6c4'), white: v('--xt-white', '#cdd6e2'),
87
- brightBlack: v('--xt-brblack', '#5d6675'), brightRed: v('--xt-red', '#f2655f'), brightGreen: v('--xt-brgreen', '#7fd9a8'),
88
- brightYellow: v('--xt-yellow', '#f1c052'), brightBlue: v('--xt-accent-text', '#9cc2fb'),
89
- brightMagenta: v('--xt-magenta', '#b39cf7'), brightCyan: v('--xt-cyan', '#6fd6c4'), brightWhite: v('--xt-brwhite', '#eef1f5'),
90
- };
91
- }
92
-
93
- // ── WebSocket attach lifecycle (auto-reconnect). ──
94
- function connect() {
95
- const proto = location.protocol === 'https:' ? 'wss' : 'ws';
96
- const ws = new WebSocket(`${proto}://${location.host}`);
97
- state.ws = ws;
98
- ws.onopen = () => setConn('live');
99
- ws.onclose = () => { setConn('off'); state.attached.clear(); setTimeout(connect, 1200); }; // survive server blips
100
- ws.onmessage = (ev) => handle(JSON.parse(ev.data));
101
- }
102
- function send(obj) { if (state.ws && state.ws.readyState === 1) state.ws.send(JSON.stringify(obj)); }
103
- function setConn(s) {
104
- const el = $('conn');
105
- el.className = 'conn ' + s;
106
- el.textContent = s === 'live' ? 'LIVE · local' : 'reconnecting…';
107
- $('wsFoot').textContent = s === 'live' ? 'local · persistent server · sessions running' : 'server unreachable — retrying';
108
- }
109
-
110
- // sessions belonging to the selected workspace — the whole UI reads through this
111
- function mySessions() { return state.sessions.filter((s) => s.ws === state.wsId); }
112
-
113
- function handle(m) {
114
- if (m.t === 'hello') {
115
- if (Number.isFinite(m.quietAfterMs) && m.quietAfterMs > 0) QUIET_AFTER_MS = m.quietAfterMs;
116
- state.workspaces = m.workspaces; state.home = m.home; state.sessions = m.sessions;
117
- // pick: the URL (a fresh `pmos` in this repo) → the last one used → home
118
- const remembered = store.get('wsid');
119
- const pick = [urlWs, remembered, m.home].find((id) => id && m.workspaces.some((w) => w.id === id))
120
- || (m.workspaces[0] && m.workspaces[0].id);
121
- selectWorkspace(pick, { restoreSession: true });
122
- } else if (m.t === 'workspaces') {
123
- state.workspaces = m.workspaces;
124
- if (!state.workspaces.some((w) => w.id === state.wsId)) selectWorkspace(state.home, { restoreSession: true });
125
- else { renderWorkspaces(); renderTree(); loadBoards(); }
126
- } else if (m.t === 'focus') {
127
- // `pmos` was run in another repo while this tab was open — go there, which is the whole
128
- // point: the browser refocusing this tab must not strand the PM on the previous repo.
129
- if (m.ws && m.ws !== state.wsId) selectWorkspace(m.ws, { restoreSession: true });
130
- } else if (m.t === 'sessions') {
131
- // a removed session's terminal must not leak — drop its pane + term instance
132
- const ids = new Set(m.sessions.map((s) => s.id));
133
- for (const [id, t] of [...state.terms]) {
134
- if (!ids.has(id)) { try { t.term.dispose(); } catch {} t.el.remove(); state.terms.delete(id); state.attached.delete(id); }
135
- }
136
- if (state.renaming && !ids.has(state.renaming)) cancelRename();
137
- // The liveness heartbeat pushes this message every couple of seconds, so it can no longer cost
138
- // a full innerHTML rebuild of the session pane: that would drop text selection, reset scroll
139
- // and flicker the sidebar twice a second. Split it — a STRUCTURAL change (a session appeared,
140
- // vanished, was renamed, or exited) rebuilds; a mere liveness/harness update repaints the
141
- // volatile bits in place. The openness of a drill-in and an in-flight rename already survived
142
- // a rebuild; this keeps everything else intact too.
143
- const sig = structSig(m.sessions);
144
- const structural = sig !== state.sessSig;
145
- state.sessSig = sig;
146
- state.sessions = m.sessions;
147
- if (structural) { renderSessions(); renderWorkspaces(); } else { paintVolatile(); }
148
- if (state.active && !ids.has(state.active)) {
149
- const mine = mySessions();
150
- const nx = mine.find((s) => s.state !== 'exited') || mine[mine.length - 1];
151
- if (nx) selectSession(nx.id); else clearActive();
152
- } else if (structural) { syncActiveState(); }
153
- } else if (m.t === 'attached') {
154
- const t = ensureTerm(m.id);
155
- t.term.reset();
156
- if (m.buf) t.term.write(m.buf); // replay scrollback on (re)attach
157
- fitOne(m.id);
158
- } else if (m.t === 'data') {
159
- const t = state.terms.get(m.id); if (t) t.term.write(m.data);
160
- // Bytes arriving IS the liveness signal, and it is the same fact the server stamps into
161
- // lastOutputAt — so an attached session's dot flips to `working` on the byte rather than on
162
- // the next heartbeat. It is a nudge, not a second source of truth: the very next heartbeat
163
- // overwrites it with the server's stamp, and it only ever moves the clock FORWARD, so it can
164
- // make a row read `working` a beat early but can never keep a dead row looking alive. Only
165
- // attached sessions get `data` at all — which is exactly why the heartbeat is the mechanism
166
- // and this is the garnish.
167
- const live = state.sessions.find((x) => x.id === m.id);
168
- if (live && live.state !== 'exited') { live.lastOutputAt = Date.now(); tickQuiet(); }
169
- } else if (m.t === 'created') {
170
- // server pushes a fresh 'sessions'; jump to the new one
171
- selectSession(m.id);
172
- } else if (m.t === 'branch') {
173
- // a checkout changed the repo's branch — refresh the chip if it's the workspace on screen,
174
- // and the picker too when the form is open (a stale branch list is how a PM picks wrong)
175
- if (m.ws === state.wsId) { state.branch = m; renderBranch(); loadWorktrees(); if (!$('newForm').hidden) fillBranchPicker(); }
176
- } else if (m.t === 'notice') {
177
- // something non-fatal but worth knowing: where a worktree landed, or that a branch was
178
- // ignored because the workspace has no git. Never a blocked session, never a silent one.
179
- flash(m.notice, 12000);
180
- } else if (m.t === 'error') {
181
- // a rejected action (e.g. a failed branch checkout that must NOT spawn) — say so, don't swallow
182
- flash(m.error || 'the server rejected that action');
183
- }
184
- }
185
-
186
- // ── branch awareness (R2): the active workspace's current git branch, always visible. Fetched on
187
- // demand (never on every broadcast) — on workspace switch, on opening the new-session form, and
188
- // after a checkout the server announces. A non-repo/unreadable workspace shows NO chip, never a
189
- // stale or guessed branch. ──
190
- async function loadBranch() {
191
- const id = state.wsId;
192
- let info = { ok: false };
193
- try { const r = await fetch('/api/git/branches?ws=' + encodeURIComponent(id || '')); info = await r.json(); } catch {}
194
- if (id !== state.wsId) return; // a slower fetch for a workspace we've since left must not paint
195
- state.branch = info; renderBranch();
196
- // Refill the open form ONLY while the PM has not chosen anything yet — a refresh landing a beat
197
- // after a quick selection must never silently reset it back to "stay on current".
198
- if (!$('newForm').hidden && !$('newBranch').value && !state.newBranchMode) fillBranchPicker();
199
- }
200
- function renderBranch() {
201
- const el = $('branchChip'); if (!el) return;
202
- const b = state.branch;
203
- if (!b || !b.ok) { el.hidden = true; el.textContent = ''; el.title = ''; return; }
204
- const label = b.detached ? 'detached HEAD' : (b.current || '');
205
- el.hidden = !label;
206
- // `⑂` marks a workspace that IS an isolated worktree — the PM should be able to see at a glance
207
- // that this window's branch is its own, not the shared one.
208
- el.textContent = (b.linked ? '⑂ ' : '⎇ ') + label + (b.dirty ? '*' : '');
209
- const where = b.linked ? 'this workspace has its own working tree — switching here moves only its sessions'
210
- : 'shared working tree — switching here moves every session in this repo';
211
- el.title = (b.dirty ? 'uncommitted changes · ' : '') + where;
212
- el.classList.toggle('dirty', !!b.dirty);
213
- }
214
-
215
- // ── workspaces ──
216
- async function selectWorkspace(id, opts = {}) {
217
- if (!id) return;
218
- const changed = id !== state.wsId;
219
- state.wsId = id;
220
- store.set('wsid', id);
221
- if (changed) closeDoc();
222
- renderWorkspaces();
223
- renderSessions();
224
- renderNewTarget(); // R5 — "here" moved; the composer must say so even if it is already open
225
- loadBoards();
226
- // focus a session in THIS workspace: the remembered one if it belongs here, else its first live one
227
- const mine = mySessions();
228
- const remembered = opts.restoreSession ? store.get('active') : null;
229
- const pick = mine.find((s) => s.id === remembered) || mine.find((s) => s.state !== 'exited') || mine[0];
230
- if (pick) selectSession(pick.id); else clearActive();
231
- // the arrangement belongs to the repo (R7) — a different workspace brings its own layouts
232
- // the scratchpad follows the ACTIVE TAB, and selectSession/clearActive above have already
233
- // settled which that is — syncActive() reloaded it, so there is nothing to do here
234
- if (changed) { state.branch = null; renderBranch(); state.worktrees = []; renderWorktrees(); loadLayouts(); }
235
- // R7 — the rail follows the selection. A tree on an initiative branch opens its dossier; anything
236
- // else falls back to repo detail rather than showing a dossier for an initiative that is not there.
237
- {
238
- const w = state.workspaces.find((x) => x.id === id);
239
- const init = w ? initiativeOf(w) : null;
240
- if (init && init.id) { state.detail = { kind: 'initiative', id: init.id, wsId: id }; if (state.lens === 'dossier') ensureLib(id); }
241
- else { state.detail = { kind: 'repo', id: w ? w.repo_key : null, wsId: id }; if (state.lens === 'dossier') state.lens = 'folders'; }
242
- }
243
- await Promise.all([loadLibrary(), loadNeeds(), loadBranch(), loadWorktrees()]);
244
- }
245
- /* ── The tree (R1·R2·R3·R4·R5·R6) ────────────────────────────────────────────────────────────────
246
- *
247
- * One structure where there were two panes: REPOSITORY → its working trees → their sessions.
248
- *
249
- * A row is a working TREE, labelled by the INITIATIVE on its branch. Those are the same level, not
250
- * two: git allows exactly one working tree per branch, and every session in a tree shares that
251
- * branch (lib/git-info.js), so nesting them separately would produce a middle row with exactly one
252
- * child forever. The DIRECTORY name stays on the row permanently — the shared-tree checkout path
253
- * moves every session in a repo, which relabels a row under its running sessions, and without a
254
- * stable identity that reads as a row vanishing and another appearing (R2).
255
- *
256
- * Everything a row asserts about workflow is DERIVED and dated. `board` arrives from the cache
257
- * (lib/board-cache.js), never computed here, and a stage is never drawn without the branch it came
258
- * from (R5) — on this repo the ladder is scoped to merge-base(origin/main,HEAD)..HEAD, so a shipped
259
- * initiative reads `spec` on every branch that did not build it.
260
- */
261
- const TREE_GLYPH = { working: '●', quiet: '◼', blocked: '✗', idle: '—', exited: '○', none: '·' };
262
-
263
- /* THE THREE-VALUED RULE (R2, and the veto of this initiative's rubric).
264
- *
265
- * Every derived cell in this tree is one of three things, and they get three renderings:
266
- *
267
- * value — the fact, rendered plainly
268
- * verified absent— the read SUCCEEDED and found nothing. Quiet, specific, never red. This is the
269
- * common case for anything that is not a PMOS repo, and it is the one the first
270
- * version had no rendering for at all.
271
- * unreadable — the read FAILED. Red, and it keeps the word.
272
- *
273
- * The first version had two renderings for three facts, so one was always mislabelled: a plain
274
- * folder reported `branch unreadable`, which is an alarm about a completely ordinary directory, on
275
- * the same surface that uses red for a corrupt state queue and a blocked initiative. A surface that
276
- * cries wolf on the normal case teaches the PM to ignore red where red is real.
277
- *
278
- * `absent()` and `broken()` are the only two ways to say it, so a fourth site cannot invent a
279
- * fifth vocabulary. */
280
- const absent = (txt, title) => `<span class="gone"${title ? ` title="${esc(title)}"` : ''}>${esc(txt)}</span>`;
281
- const broken = (txt, title) => `<span class="bl" role="alert"${title ? ` title="${esc(title)}"` : ''}>${esc(txt)}</span>`;
282
-
283
- // The initiative a branch names, and the record behind it — four cases now, all of them stated. A
284
- // branch with no record is NOT an initiative with a missing file; it is a branch, and the row says
285
- // so rather than inventing an id (R2). A workspace with no branch is either an ordinary folder that
286
- // is not a repository (quiet) or a git read that failed (a fault) — never the same word for both.
287
- // A ROW ALWAYS HAS AN IDENTITY. The first version used this label for both the row's name and its
288
- // condition, so a plain folder rendered as a row literally titled `branch unreadable` — a row with
289
- // no name at all, which is unfindable in a tree of them. The name is what the row IS; the condition
290
- // is what is true of it, and it belongs on the second line with the other derived facts.
291
- function initiativeOf(w) {
292
- const br = w.branch;
293
- if (!br) {
294
- if (!w.branch_error) return { kind: 'detached', label: w.name, id: null };
295
- // 'no-git' is not a failure to report a branch; it is a folder that HAS no branch. The message
296
- // is branchInfo's own ("not a git repository") — this renderer does not compose its own prose.
297
- if (w.branch_code === 'no-git') return { kind: 'nogit', label: w.name, id: null };
298
- return { kind: 'branchbroken', label: w.name, id: null, condition: w.branch_error };
299
- }
300
- const m = /^initiative\/(.+)$/.exec(br);
301
- if (!m) return { kind: 'branch', label: br, id: null };
302
- const id = m[1];
303
- const row = boardRow(w, id);
304
- if (!row) return { kind: 'norecord', label: id, id };
305
- return { kind: 'initiative', label: id, id, row };
306
- }
307
- // The board for a repository, by group key — one cached read serves every tree under it.
308
- function boardFor(w) { return state.boards[w.repo_key] || null; }
309
- function boardRow(w, id) {
310
- const b = boardFor(w);
311
- if (!b || !b.data || !Array.isArray(b.data.live)) return null;
312
- return b.data.live.find((r) => r.id === id) || null;
313
- }
314
- // A tree has something to say when it is working, has gone quiet past the threshold, is blocked, or
315
- // something needs the PM. Rows without a signal collapse to a count (R6). The PM's own toggle always
316
- // wins — `state.openTrees` holds it, and it is re-applied after every render for the same reason
317
- // `state.openHarness` is: this pane is rebuilt by innerHTML on every push.
318
- // R7 — EXPANSION MUST BE EARNED. Signals divide in two, and the first version conflated them:
319
- // * a signal about the TREE (a needs count, a blocker) belongs ON the row and is already rendered
320
- // there — opening the container shows the PM nothing they cannot already see;
321
- // * a signal about a SESSION is only visible once the container is open, so it earns the open.
322
- // Conflating them is why a repository with one queued need and no sessions expanded to reveal a
323
- // lone "+ session here" button. A row that opens onto nothing teaches you to distrust its glyphs.
324
- function treeHasSignal(w) {
325
- const mine = state.sessions.filter((s) => s.ws === w.id);
326
- if (!mine.length) return false; // nothing to reveal, so nothing to open for
327
- return mine.some((s) => {
328
- const shown = stateLabel(s).shown;
329
- return shown === 'working' || shown === 'blocked' || shown === 'exited';
330
- });
331
- }
332
- function treeOpen(w) {
333
- if (state.openTrees.has(w.id)) return true;
334
- if (state.closedTrees.has(w.id)) return false;
335
- return w.id === state.wsId || treeHasSignal(w);
336
- }
337
- // The worst thing true of a collapsed tree, so the glyph never reads calmer than the tree is.
338
- function treeGlyph(w) {
339
- const mine = state.sessions.filter((s) => s.ws === w.id);
340
- const n = state.needsByWs[w.id];
341
- const init = initiativeOf(w);
342
- if ((n && n.error) || (init.row && init.row.blockers && init.row.blockers.length)) return 'blocked';
343
- if (mine.some((s) => stateLabel(s).shown === 'blocked')) return 'blocked';
344
- if (mine.some((s) => stateLabel(s).shown === 'working')) return 'working';
345
- if (mine.some((s) => stateLabel(s).shown === 'quiet')) return 'quiet';
346
- if (mine.length) return 'exited';
347
- return 'idle';
348
- }
349
- function renderTree() {
350
- const host = $('tree');
351
- if (!host) return;
352
- // group by repository (R3): `pmos` and `pmos ⑂ initiative/foo` are a parent and its child, not
353
- // two peers. The key comes from the server — `git worktree list` names the main worktree first.
354
- const groups = new Map();
355
- for (const w of state.workspaces) {
356
- const key = w.repo_key || w.root;
357
- if (!groups.has(key)) groups.set(key, { key, name: w.repo_name || w.name, trees: [] });
358
- groups.get(key).trees.push(w);
359
- }
360
- const totalNeeds = Object.values(state.needsByWs).reduce((a, v) => a + (v && v.count ? v.count : 0), 0);
361
- const badRead = Object.values(state.needsByWs).some((v) => v && v.error);
362
- const nc = $('needCount');
363
- if (nc) {
364
- nc.textContent = badRead ? 'needs unreadable' : (totalNeeds ? `${totalNeeds} need you` : '');
365
- nc.dataset.bad = badRead ? '1' : '0';
366
- }
367
-
368
- const html = [...groups.values()].map((g) => {
369
- const trees = g.trees.map((w) => treeRow(w, g)).join('');
370
- return `<div class="repo-group" data-repo="${esc(g.key)}">
371
- <button class="repo-head" data-repo-pick="${esc(g.key)}" title="${esc(g.key)}">
372
- <span class="cue" aria-hidden="true">▾</span><span class="nm">${esc(g.name)}</span>
373
- <small>${g.trees.length} tree${g.trees.length === 1 ? '' : 's'}</small>
374
- </button>${trees}</div>`;
375
- }).join('');
376
- host.innerHTML = html || `<div class="empty">No repositories — run <b>pmos</b> inside one.</div>`;
377
- host.querySelectorAll('[data-pick]').forEach((b) => b.onclick = () => selectWorkspace(b.dataset.pick));
378
- // R6 — select the tree AND open the composer against it. Deliberately NOT awaited: selectWorkspace
379
- // ends on four network reads (library · needs · branch · worktrees), and making the composer wait
380
- // on them puts a variable delay between a click and any visible response — the failure mode the
381
- // board cache was built to remove elsewhere. `state.wsId` is assigned synchronously at the top of
382
- // selectWorkspace, so the form opens naming the RIGHT tree immediately; loadBranch() refills the
383
- // branch picker when it lands, which is the same path the + button already relies on.
384
- host.querySelectorAll('[data-start]').forEach((b) => b.onclick = (e) => {
385
- e.stopPropagation();
386
- const p = selectWorkspace(b.dataset.start);
387
- openNewForm();
388
- p.then(() => { if (!$('newForm').hidden) renderNewTarget(); });
389
- });
390
- host.querySelectorAll('[data-repo-pick]').forEach((b) => b.onclick = () => openRepoDetail(b.dataset.repoPick));
391
- host.querySelectorAll('[data-forget]').forEach((b) => b.onclick = (e) => { e.stopPropagation(); forgetWorkspace(b.dataset.forget); });
392
- host.querySelectorAll('[data-toggle]').forEach((b) => b.onclick = (e) => {
393
- e.stopPropagation();
394
- const id = b.dataset.toggle;
395
- // an explicit choice is remembered against the derived default, either way
396
- if (treeOpen(state.workspaces.find((x) => x.id === id) || { id })) { state.openTrees.delete(id); state.closedTrees.add(id); }
397
- else { state.closedTrees.delete(id); state.openTrees.add(id); }
398
- renderTree();
399
- });
400
- host.querySelectorAll('.sess').forEach((b) => {
401
- b.onclick = () => selectSession(b.dataset.id);
402
- b.ondblclick = () => startRename(b.dataset.id);
403
- b.onkeydown = (e) => { if (e.key === 'F2') { e.preventDefault(); startRename(b.dataset.id); } };
404
- });
405
- host.querySelectorAll('[data-rename]').forEach((b) => b.onclick = (e) => { e.stopPropagation(); startRename(b.dataset.rename); });
406
- host.querySelectorAll('details.hz').forEach((d) => {
407
- d.open = state.openHarness.has(d.dataset.id);
408
- d.addEventListener('toggle', () => {
409
- if (d.open) state.openHarness.add(d.dataset.id); else state.openHarness.delete(d.dataset.id);
410
- });
411
- });
412
- paintRename();
413
- }
414
- function treeRow(w, g) {
415
- const cur = w.id === state.wsId;
416
- const open = treeOpen(w);
417
- const init = initiativeOf(w);
418
- const mine = state.sessions.filter((s) => s.ws === w.id);
419
- const live = mine.filter((s) => s.state !== 'exited').length;
420
- const n = state.needsByWs[w.id];
421
- const gl = treeGlyph(w);
422
- const board = boardFor(w);
423
-
424
- // R5 — a stage is never shown without the branch it was derived from, and never without saying
425
- // how old the derivation is. A board still deriving says so instead of showing nothing.
426
- let wf = '';
427
- if (init.row) {
428
- const r = init.row;
429
- const blocked = r.blockers && r.blockers.length;
430
- const nextTxt = blocked ? `✗ ${r.blockers.length} blocked`
431
- : (r.next && r.next.step ? `→ ${esc(r.next.step)}` : '');
432
- wf = `<span class="wf"><span class="lane">${esc(r.lane || '')}</span>
433
- <span class="stg">${esc(r.stage || '')}</span>
434
- <span class="${blocked ? 'bl' : 'nx'}">${nextTxt}</span></span>`;
435
- } else if (board && board.pending) {
436
- wf = `<span class="wf"><span class="drv">deriving the ladder…</span></span>`;
437
- } else if (board && board.ok === false) {
438
- // The one case that stays RED. The engine's own message says which fault it is — an install
439
- // that carries no engine reads differently from one whose engine threw — so it is carried
440
- // through as the tooltip rather than compressed into two words (incident
441
- // kit-workflow-engine-missing: "ladder unreadable" was every adopter's entire experience of
442
- // this surface, and it named neither the cause nor anything the PM could act on).
443
- wf = `<span class="wf">${broken('ladder unreadable', board.error || '')}</span>`;
444
- } else if (board && board.ok === true && board.empty === true) {
445
- // VERIFIED ABSENT, and the sentence is about the REPOSITORY, not the branch: the engine read
446
- // this repo successfully and it has no initiative records. Pointing the workspace at an
447
- // ordinary repo is a supported thing to do, so this is the calm common case, not a defect.
448
- wf = `<span class="wf">${absent('no initiatives yet', 'this repository has no planning/initiatives/ records — the workspace read it successfully')}</span>`;
449
- } else if (init.kind === 'norecord') {
450
- // An initiative BRANCH whose record is missing — genuinely a gap in the repo, but not a failed
451
- // read, so it stays quiet and says precisely which of the two it is.
452
- wf = `<span class="wf">${absent('no record for this branch', `the branch names initiative "${init.id}" but planning/initiatives/${init.id}.md is not there`)}</span>`;
453
- } else if (init.kind === 'branch') {
454
- wf = `<span class="wf">${absent('not an initiative branch', `"${w.branch}" does not match initiative/<id>, so no ladder is derived for it`)}</span>`;
455
- } else if (init.kind === 'nogit') {
456
- wf = `<span class="wf">${absent('not a git repository', 'an ordinary folder — sessions run here, but nothing git-derived applies to it')}</span>`;
457
- } else if (init.kind === 'branchbroken') {
458
- // The other half of the split: git IS here and the read FAILED. This one keeps the alarm.
459
- wf = `<span class="wf">${broken('branch unreadable', init.condition || '')}</span>`;
460
- } else if (init.kind === 'detached') {
461
- wf = `<span class="wf">${absent('detached HEAD', 'no branch is checked out, so no initiative is derived from one')}</span>`;
462
- }
463
- // the attribution line: which branch this ladder came from, and as of when
464
- const stamp = board && board.as_of ? new Date(board.as_of).toLocaleTimeString() : null;
465
- const attrib = init.row
466
- ? `<span class="attrib">derived on <b>${esc(w.branch || '?')}</b>${stamp ? ` · as of ${esc(stamp)}` : ''}${board && board.stale ? ' · refreshing' : ''}</span>`
467
- : '';
468
-
469
- // R6 — the verb is performed. This used to call selectWorkspace() and stop, and its own tooltip
470
- // admitted it ("select this tree, THEN start a session"). `data-start` selects the tree AND opens
471
- // the composer against it, focused.
472
- const sessHtml = open ? mine.map((s) => sessionRow(s)).join('')
473
- + `<button class="tree-new" data-start="${esc(w.id)}" title="start a session in ${esc(w.name)}">+ session here</button>`
474
- : '';
475
- const collapsed = !open && mine.length
476
- ? `<span class="rollup">${mine.length} session${mine.length === 1 ? '' : 's'}${live ? '' : ' · all exited'}</span>` : '';
477
-
478
- return `<div class="treerow" data-id="${esc(w.id)}" data-open="${open}" data-st="${gl}">
479
- <div class="tr-head">
480
- <button class="tr-toggle" data-toggle="${esc(w.id)}" aria-expanded="${open}"
481
- aria-label="${open ? 'Collapse' : 'Expand'} ${esc(init.label)}"><span class="cue" aria-hidden="true">${open ? '▾' : '▸'}</span></button>
482
- <button class="tr" data-pick="${esc(w.id)}" aria-current="${cur}" title="${esc(w.root)}">
483
- <span class="r1"><span class="g" aria-hidden="true">${TREE_GLYPH[gl] || '·'}</span>
484
- <span class="nm">${esc(init.label)}</span>
485
- ${w.home ? `<span class="tag" title="the folder pmos was launched in — it is always hosted and cannot be removed">home</span>` : ''}
486
- <span class="dir">${esc(w.name === g.name ? 'main tree' : w.name)}</span></span>
487
- <span class="r2">${wf}${n && n.count ? `<span class="needs-n">${n.count} need you</span>` : ''}${n && n.error ? broken('needs unreadable', n.error) : ''}${collapsed}</span>
488
- ${attrib}
489
- </button>
490
- ${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>`}
491
- </div>
492
- <div class="tr-sessions">${sessHtml}</div>
493
- </div>`;
494
- }
495
- // One session, with everything the old pane carried — nothing about a row's CONTENT changes here,
496
- // only where it sits (R1). Glyph, state word, quiet duration, open-call age, opening focus, the
497
- // agent's last words, the harness drill-in and rename all come through unchanged.
498
- function sessionRow(s) {
499
- const st = stateLabel(s);
500
- return `<div class="sessrow" data-id="${s.id}">
501
- <button class="sess" data-id="${s.id}" data-state="${st.shown}" aria-current="${s.id === state.active}"
502
- aria-label="${esc(s.name)} — ${esc(st.text)}">
503
- <span class="r1"><span class="st" aria-hidden="true">${GLYPH[st.shown] || '•'}</span>
504
- <span class="nm">${esc(s.name)}${s.origin && s.origin.startsWith('schedule:') ? '<span class="schtag">scheduled</span>' : ''}</span>
505
- <span class="chip">${esc(st.text)}</span></span>
506
- ${subLine(s)}
507
- ${saysLine(s)}
508
- </button>
509
- <button class="ren" data-rename="${s.id}" aria-label="Rename ${esc(s.name)}" title="rename (F2)">✎</button>
510
- </div>${harnessDrawer(s)}`;
511
- }
512
-
513
- /* The board arrives from the server's cache, one read per REPOSITORY (not per tree, not per row).
514
- * A cold read answers `pending` in milliseconds and the derivation runs in a child process — it
515
- * costs ~30s on this repo and is fully synchronous, so it must never sit on a request path. While
516
- * it is pending the rows say "deriving the ladder…" rather than showing nothing: a blank stage is
517
- * indistinguishable from "this tree has no initiative", and those are different facts. */
518
- window.addEventListener('pagehide', () => { state.boardsStopped = true; });
519
- async function loadBoards() {
520
- if (state.boardsStopped) return;
521
- const wanted = new Map();
522
- for (const w of state.workspaces) if (w.repo_key && !wanted.has(w.repo_key)) wanted.set(w.repo_key, w.id);
523
- for (const [key, wsId] of wanted) {
524
- if (state.boardWanted.has(key)) continue;
525
- state.boardWanted.add(key);
526
- (async () => {
527
- try {
528
- const r = await fetch('/api/console?name=workflow&ws=' + encodeURIComponent(wsId));
529
- const payload = await r.json();
530
- state.boards[key] = payload;
531
- renderTree();
532
- // pending is not failed — come back for it, rather than leaving a row saying "deriving"
533
- // forever if the child never lands
534
- if (payload && payload.pending && !state.boardsStopped) {
535
- const tries = (state.boardTries[key] || 0) + 1;
536
- state.boardTries[key] = tries;
537
- // bounded: a derivation that never lands leaves the row saying so, rather than a request
538
- // every 3s forever — which in the browser suite showed up as failed requests after teardown
539
- if (tries <= 40) setTimeout(() => { state.boardWanted.delete(key); loadBoards(); }, 3000);
540
- else state.boardWanted.delete(key);
541
- }
542
- else state.boardWanted.delete(key);
543
- } catch {
544
- state.boards[key] = { ok: false, error: 'could not reach the workspace server for the ladder' };
545
- state.boardWanted.delete(key);
546
- renderTree();
547
- }
548
- })();
549
- }
550
- }
551
-
552
- function renderWorkspaces() {
553
- const html = state.workspaces.map((w) => {
554
- const n = state.sessions.filter((s) => s.ws === w.id && s.state !== 'exited').length;
555
- const cur = w.id === state.wsId;
556
- return `<div class="wsrow" data-id="${esc(w.id)}">
557
- <button class="ws" data-pick="${esc(w.id)}" aria-current="${cur}" title="${esc(w.root)}">
558
- <span class="cue" aria-hidden="true">${cur ? '▾' : '▸'}</span>
559
- <span class="nm">${esc(w.name)}</span>
560
- ${w.home ? '<span class="tag">home</span>' : ''}
561
- <small>${n ? `${n} live` : ''}</small>
562
- </button>
563
- ${w.home ? '' : `<button class="forget" data-forget="${esc(w.id)}" aria-label="Stop hosting ${esc(w.name)}" title="${n ? 'end its sessions first' : 'stop hosting this repo (the repo is untouched)'}">✕</button>`}
564
- </div>`;
565
- }).join('');
566
- $('workspaces').innerHTML = html || `<div class="empty">No workspaces — run <b>pmos</b> inside a repo.</div>`;
567
- $('workspaces').querySelectorAll('[data-pick]').forEach((b) => b.onclick = () => selectWorkspace(b.dataset.pick));
568
- $('workspaces').querySelectorAll('[data-forget]').forEach((b) => b.onclick = () => forgetWorkspace(b.dataset.forget));
569
- }
570
- async function forgetWorkspace(id) {
571
- const w = state.workspaces.find((x) => x.id === id);
572
- const live = state.sessions.filter((s) => s.ws === id);
573
- if (live.length) { flash(`“${w ? w.name : 'that workspace'}” still has ${live.length} session(s) — close them first.`); return; }
574
- try { await fetch('/api/workspaces/forget', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }) }); } catch {}
575
- }
576
- async function addWorkspace(root) {
577
- if (!root || !root.trim()) return false;
578
- try {
579
- const r = await fetch('/api/workspaces', { method: 'POST', headers: { 'Content-Type': 'application/json' },
580
- body: JSON.stringify({ root: root.trim(), focus: false }) });
581
- const j = await r.json();
582
- if (!j.ok) { flash(j.error || 'could not add that path'); return false; }
583
- selectWorkspace(j.workspace.id);
584
- closeAddWs();
585
- return true;
586
- } catch { flash('could not reach the server'); return false; }
587
- }
588
- // `ms` is longer for messages carrying something to READ — a worktree's path is useless if it
589
- // vanishes before the PM can copy it out.
590
- function flash(msg, ms = 4000) {
591
- const el = $('wsFoot'); const prev = el.textContent;
592
- el.textContent = msg;
593
- setTimeout(() => { el.textContent = prev; }, ms);
594
- }
595
-
596
- // The glyph and the word both come from the SHARED join module (/lib/session-state.js), never
597
- // re-typed here. `quiet` still means no output for a while and still NEVER means "waiting on you"
598
- // (harness-tower R1) — the stronger words exist only where the transcript corroborates the
599
- // silence, which is a different cell of the table, not a relabelling of this one.
600
- const SS = window.PMOSSessionState;
601
- const GLYPH = SS.GLYPH;
602
- const STATE_WORD = SS.WORD;
603
- // The threshold the server derives `quiet` from, sent in `hello`. It used to be re-typed here as a
604
- // literal "mirrors harness-parse.QUIET_AFTER_MS" — two copies of one number, free to drift apart and
605
- // give the sidebar and the server different opinions about the same session. The literal survives
606
- // only as the value to assume before hello lands.
607
- let QUIET_AFTER_MS = 30000;
608
- function shortDur(ms) {
609
- if (ms == null) return '';
610
- const sec = Math.round(ms / 1000);
611
- if (sec < 60) return sec + 's';
612
- const min = Math.round(sec / 60);
613
- return min < 60 ? min + 'm' : Math.round(min / 60) + 'h';
614
- }
615
- // The CADENCE clock, derived client-side too so a row can change WITHOUT a server push. The server
616
- // only pushes on lifecycle events plus the liveness heartbeat; a periodic full re-render would
617
- // destroy an open drill-in and the rename input (the defect the pre-promotion grilling caught), so
618
- // the ticker below repaints chips only. This is one of the join's two inputs — never the answer.
619
- function cadenceState(s) {
620
- if (s.state === 'exited') return { cadence: 'exited', quietMs: null };
621
- if (!s.lastOutputAt) return { cadence: 'working', quietMs: null };
622
- const q = Math.max(0, Date.now() - s.lastOutputAt);
623
- return q >= QUIET_AFTER_MS ? { cadence: 'quiet', quietMs: q } : { cadence: 'working', quietMs: q };
624
- }
625
- // The open call's verb + AGE. `working · Bash 4m` is the first honest signal for HUNG the workspace
626
- // has; the weak claim's `· Bash 6m` is the same fact once the bytes stop, and is the whole content
627
- // of that claim. WHICH states may carry it is the shared module's call (CARRIES_CALL_AGE), not a
628
- // condition re-typed here — as the words themselves are its call, never literals in this file.
629
- //
630
- // The honesty bound is structural, not a check bolted on: the age rides states that require a LIVE
631
- // cadence or an explicitly-suspect one, and `needs` is not among them — so a crashed session's
632
- // dangling tool_use (a permanent phantom) can never dress itself as a live age on the strong claim.
633
- // `sinceTs` comes from the transcript record; we tick it client-side like the quiet clock.
634
- function openCallOf(s) {
635
- const oc = s.hx && s.hx.openCall;
636
- if (!oc || !oc.sinceTs) return null;
637
- const ms = Date.parse(oc.sinceTs);
638
- if (!Number.isFinite(ms)) return null;
639
- const age = Date.now() - ms;
640
- if (age < 0) return null;
641
- return { verb: oc.verb, ageMs: age };
642
- }
643
- // THE JOIN, client side — the same module the server uses (R6). Two inputs: the cadence clock above
644
- // and the transcript's answer to whose turn it is. Neither the table nor the words are duplicated
645
- // here; if they were, the sidebar and the server could drift into different opinions about one
646
- // session, which is exactly what shipping QUIET_AFTER_MS from `hello` was done to prevent.
647
- function stateLabel(s) {
648
- const { cadence, quietMs } = cadenceState(s);
649
- const shown = SS.presented(cadence, s.hx && s.hx.turn);
650
- return { shown, text: SS.label(shown, { quietMs, call: openCallOf(s), fmtDur: shortDur }) };
651
- }
652
- function renderSessions() {
653
- // The rows themselves live in the tree now (R1). This stays as the single entry point every
654
- // session push already calls, so the tree, the tab strips and the overlay all repaint together.
655
- renderTree();
656
- renderTabs();
657
- updateEmptyOverlay();
658
- paintRename();
659
- }
660
- // The sub-line: the run's OWN focus, not `agent · cwd` (which was identical on every row in a
661
- // workspace — zero information). The CLI's `ai-title` is FROZEN at the first prompt (it carries no
662
- // timestamp and never updates), so it is labelled "opening focus" and never framed as what the run
663
- // is doing now. When there is no resolvable title — a shell/codex/untitled/unresolved session — it
664
- // falls back to the shipped `agent · cwd`, so the line is never blank and never a guessed title.
665
- // When a title IS shown, `agent · cwd` moves into the harness drawer (see harnessDrawer), not lost.
666
- // Split inner-from-wrapper so the heartbeat's in-place painter can compare and assign innerHTML.
667
- // Comparing outerHTML against a source string never matches (the browser re-serialises), so it
668
- // would rewrite the node on every beat — and a node rewritten twice a second is a node the PM
669
- // cannot select text in.
670
- function subInner(s) {
671
- const title = s.hx && s.hx.title;
672
- if (title) return `<span class="lead">opening focus</span> ${esc(title)}`;
673
- return `${esc(s.agent)} · ${esc(s.cwd)}`;
674
- }
675
- function subLine(s) { return `<span class="sub">${subInner(s)}</span>`; }
676
- // The agent's own last words, verbatim — never a paraphrase (a summary would be another model
677
- // call per session, and a drifting paraphrase was a leg-A defect). When the parser cannot read,
678
- // it says so: an unreadable signal must never render as an empty line.
679
- // {cls, html} rather than a finished span, for the same in-place-repaint reason as subInner — and
680
- // here the CLASS also changes (`says` for real words, `says na` for a stated reason), so the
681
- // painter has to be able to set the two independently.
682
- function saysParts(s) {
683
- if (s.lastWords) return { cls: 'says', html: esc(s.lastWords) };
684
- const why = s.missing && s.missing.lastWords;
685
- return why ? { cls: 'says na', html: esc(why) } : null;
686
- }
687
- function saysLine(s) {
688
- const p = saysParts(s);
689
- return p ? `<span class="${p.cls}">${p.html}</span>` : '';
690
- }
691
- // The harness readout: a drill-in, not a rail. The leg-A grilling established the harness is
692
- // near-static within a run, so this is read once to understand a session — not watched.
693
- // Unavailable facts are drawn as unavailable, never as a plausible constant.
694
- function fmtTokens(n) {
695
- if (n == null) return '—';
696
- if (n >= 1e9) return (n / 1e9).toFixed(1) + 'B';
697
- if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M';
698
- if (n >= 1e3) return Math.round(n / 1e3) + 'k';
699
- return String(n);
700
- }
701
- // The harness readout. Everything here comes from the CLI's own structured transcript, because
702
- // the CLI does not PRINT its model, effort, tokens or skill set to the terminal — parsing
703
- // scrollback for them returned four empty rows, which is what sent this back for a second pass.
704
- // Rows that genuinely cannot be known are still drawn as unavailable, never as a plausible value.
705
- function harnessRows(s) {
706
- const hx = s.hx || { missing: {} };
707
- const m = hx.missing || {};
708
- const rows = [];
709
- const na = (why) => `<dd class="na">${esc(why)}</dd>`;
710
-
711
- if (m.all) {
712
- // one honest line beats six apologies
713
- rows.push(`<dt>harness</dt>${na(m.all)}`);
714
- } else {
715
- rows.push(hx.model
716
- ? `<dt>model</dt><dd><b>${esc(hx.model)}</b>${hx.effort ? ' · effort ' + esc(hx.effort) : ''}` +
717
- `${hx.turns ? ` <span class="src">${hx.turns} turns</span>` : ''}</dd>`
718
- : `<dt>model</dt>${na(m.model || 'not available')}`);
719
- rows.push(hx.tokens
720
- ? `<dt>tokens</dt><dd><b>${fmtTokens(hx.tokens.output)}</b> out · ${fmtTokens(hx.tokens.input)} in` +
721
- ` <span class="src">${fmtTokens(hx.tokens.cacheRead)} cached — no % (the window size is not in the record)</span></dd>`
722
- : `<dt>tokens</dt>${na(m.tokens || 'not available')}`);
723
- rows.push(hx.tools
724
- ? `<dt>tools</dt><dd>${Object.entries(hx.tools.counts).sort((a, b) => b[1] - a[1]).slice(0, 6)
725
- .map(([n, c]) => `${esc(n)} ×${c}`).join(' · ')} <span class="src">${hx.tools.total} calls</span></dd>`
726
- : `<dt>tools</dt>${na(m.tools || 'not available')}`);
727
- rows.push(hx.skills
728
- ? `<dt>skills</dt><dd><b>${hx.skills.count} available</b>` +
729
- `${hx.skills.names.length ? ` <span class="src">${esc(hx.skills.names.slice(0, 5).join(', '))}…</span>` : ''}` +
730
- ` <span class="src">available, not “in use” — the CLI records no such event</span></dd>`
731
- : `<dt>skills</dt>${na(m.skills || 'not available')}`);
732
- // agent · cwd moved here from the row's sub-line (which now carries the run's title). Useful
733
- // once, when reading a session — not worth a per-row line where it repeated on every session.
734
- rows.push(`<dt>where</dt><dd>${esc(s.agent)} <span class="src">${esc(s.cwd)}</span></dd>`);
735
- rows.push('<dt>instructions</dt><dd>CLAUDE.md · AGENTS.md <span class="src">identical in every session here</span></dd>');
736
- // The one row that stays unavailable, and it is architectural rather than a parsing limit:
737
- // Quality gate state is GitHub CI, Review gate state is hosted Supabase, and the workspace
738
- // is deliberately backend-free. No parser reaches it.
739
- rows.push(`<dt>gates</dt>${na('not visible from the workspace — gate state is hosted')}`);
740
- if (hx.cliVersion) rows.push(`<dt>cli</dt><dd><span class="src">${esc(hx.cliVersion)} — undocumented layout; a CLI upgrade may blank these rows</span></dd>`);
741
- }
742
- return rows.join('');
743
- }
744
- // The rows are split out of the drawer so the heartbeat can repaint the <dl> ALONE. Replacing the
745
- // whole <details> would slam it shut on every push — the drill-in has to survive its own updates.
746
- function harnessDrawer(s) {
747
- return `<details class="hz" data-id="${s.id}">
748
- <summary>model + harness<span class="sr">${esc(s.name)}</span></summary>
749
- <dl class="kv">${harnessRows(s)}</dl></details>`;
750
- }
751
- // What a session push must REBUILD the DOM for, as opposed to repaint in place. Everything here is
752
- // baked into renderSessions'/renderTabs' markup and has no in-place painter: the row's identity,
753
- // its workspace, its name, its raw lifecycle state (which drives the close button's wording and the
754
- // per-workspace live count), and the `scheduled` tag. Liveness and harness are deliberately ABSENT
755
- // — they change on every heartbeat and are what paintVolatile exists to carry.
756
- function structSig(list) {
757
- // delimited with the ASCII unit/record separators, so a name ending where a state begins cannot
758
- // fingerprint identically to a different split of the same characters
759
- return list.map((s) => [s.id, s.ws, s.name, s.state, s.origin || ''].join('')).join('');
760
- }
761
- // The in-place repaint for a heartbeat push: everything on a row that changes while the run is
762
- // running. Never innerHTML on the pane itself — an open drill-in, an in-flight rename, the sidebar's
763
- // scroll position and any text the PM has selected all survive this.
764
- function paintVolatile() {
765
- tickQuiet(); // chip, glyph, data-state, accessible name (both surfaces)
766
- for (const s of mySessions()) {
767
- const row = document.querySelector(`#tree .sessrow[data-id="${s.id}"]`);
768
- if (!row) continue;
769
- // the sub-line ("opening focus …" or the agent · cwd fallback) and the agent's last words —
770
- // both come from the server and both were frozen at the last lifecycle event. Assign only on a
771
- // real difference: an unconditional rewrite twice a second would drop the PM's text selection.
772
- const sub = row.querySelector('.sub');
773
- if (sub) { const next = subInner(s); if (sub.innerHTML !== next) sub.innerHTML = next; }
774
- const says = row.querySelector('.says'), nextSays = saysParts(s);
775
- if (nextSays && says) {
776
- if (says.className !== nextSays.cls) says.className = nextSays.cls;
777
- if (says.innerHTML !== nextSays.html) says.innerHTML = nextSays.html;
778
- } else if (nextSays) {
779
- row.querySelector('.sess').insertAdjacentHTML('beforeend', `<span class="${nextSays.cls}">${nextSays.html}</span>`);
780
- } else if (says) { says.remove(); }
781
- // the drill-in's rows — model, tokens, tools, skills. Only the <dl> is replaced, so the
782
- // <details> element itself (and therefore its open/closed state) is untouched.
783
- const dl = document.querySelector(`#tree details.hz[data-id="${s.id}"] .kv`);
784
- if (dl) { const next = harnessRows(s); if (dl.innerHTML !== next) dl.innerHTML = next; }
785
- }
786
- }
787
- // A row must be able to go quiet with NO server push. Repaint the chip, glyph, data-state and
788
- // accessible name only — never innerHTML — so an open drill-in and an in-flight rename survive.
789
- function tickQuiet() {
790
- for (const s of mySessions()) {
791
- const btn = document.querySelector(`.sess[data-id="${s.id}"]`);
792
- if (!btn) continue;
793
- const st = stateLabel(s);
794
- if (btn.dataset.state !== st.shown) {
795
- btn.dataset.state = st.shown;
796
- const g = btn.querySelector('.st'); if (g) g.textContent = GLYPH[st.shown] || '•';
797
- }
798
- const chip = btn.querySelector('.chip');
799
- if (chip && chip.textContent !== st.text) {
800
- chip.textContent = st.text;
801
- btn.setAttribute('aria-label', `${s.name} — ${st.text}`);
802
- }
803
- // both groups list every session, so the tick has to reach every strip that shows this one
804
- document.querySelectorAll(`.stabs .stab-wrap[data-id="${s.id}"]`).forEach((tab) => {
805
- if (tab.dataset.state === st.shown) return;
806
- tab.dataset.state = st.shown;
807
- // the tab's glyph is derived too, so it has to move with the colour — otherwise the fix in
808
- // renderTabs would hold only until the first tick that flipped the state without a re-render.
809
- const g = tab.querySelector('.g'); if (g) g.textContent = GLYPH[st.shown] || '•';
810
- const btnTab = tab.querySelector('.stab'); if (btnTab) btnTab.setAttribute('aria-label', `${s.name} — ${st.text}`);
811
- });
812
- }
813
- }
814
- try { const _q = setInterval(tickQuiet, 5000); if (_q.unref) _q.unref(); } catch {}
815
-
816
- function renderTabs() {
817
- // Both groups list EVERY session (R6): clicking a tab shows that session in THAT group, which
818
- // is how the PM chooses what goes where without a drag.
819
- renderTabStrip(0);
820
- if (splitOn()) renderTabStrip(1);
821
- paintGroups();
822
- }
823
- function renderTabStrip(gi) {
824
- const host = $(GROUP_TABS[gi]);
825
- if (!host) return;
826
- const act = state.groups[gi].activeId;
827
- // wrap = two SIBLING buttons (select + close) — no interactive-inside-interactive nesting
828
- host.innerHTML = mySessions().map((s) => {
829
- // the glyph and the accessible name read the DERIVED state, like the wrapper's data-state
830
- // beside them. Reading the raw `s.state` here drew a `working` ● in the `quiet` colour — the
831
- // shape said one thing and the colour said another, on the surface most looked at.
832
- const st = stateLabel(s);
833
- return `
834
- <div class="stab-wrap" data-state="${st.shown}" data-id="${s.id}" data-active="${s.id === act}">
835
- <button class="stab" role="tab" data-id="${s.id}" aria-selected="${s.id === act}"
836
- aria-label="${esc(s.name)} — ${esc(st.text)}" title="double-click to rename">
837
- <span class="g" aria-hidden="true">${GLYPH[st.shown] || '•'}</span>${esc(s.name)}</button>
838
- <button class="x" data-close="${s.id}"
839
- aria-label="${s.state === 'exited' ? 'remove' : 'close'} ${esc(s.name)}">✕</button>
840
- </div>`;
841
- }).join('');
842
- host.querySelectorAll('.stab').forEach((b) => {
843
- b.onclick = () => selectSession(b.dataset.id, gi);
844
- b.ondblclick = () => startRename(b.dataset.id);
845
- });
846
- // live tab ✕ kills the process (state flips to exited); exited tab ✕ removes it for good
847
- host.querySelectorAll('.x').forEach((b) => b.onclick = () => send({ t: 'kill', id: b.dataset.close }));
848
- }
849
- function syncActiveState() {
850
- document.querySelectorAll('.sess,.stab-wrap').forEach((b) => {
851
- const s = state.sessions.find((x) => x.id === b.dataset.id);
852
- // the DERIVED state (working|quiet|exited), never the raw one. Writing `s.state` here put
853
- // `working` back over a row that renderSessions/tickQuiet had correctly painted `quiet`, so
854
- // the dot's colour depended on which of the two ran last — a coin-flip at the old push rate,
855
- // and a visible 2s/5s flicker once the liveness heartbeat started pushing on a cadence.
856
- if (s) b.dataset.state = stateLabel(s).shown;
857
- });
858
- updateExitedOverlay();
859
- refreshScratchSend(); // the scratchpad's send button follows the active session's liveness
860
- renderScratchWho(); // …and its label follows the active tab's name, including a rename
861
- }
862
-
863
- // ── renaming a session (R11): inline, in both surfaces, live or exited. The name chosen at
864
- // creation is a guess; the PM learns what the run actually is minutes later. Server pushes
865
- // rebuild these lists, so the edit is held in state and re-painted after every render. ──
866
- function startRename(id) {
867
- const s = state.sessions.find((x) => x.id === id);
868
- if (!s) return;
869
- state.renaming = id;
870
- state.renameVal = s.name;
871
- paintRename();
872
- }
873
- function cancelRename() { state.renaming = null; state.renameVal = ''; }
874
- function commitRename() {
875
- const id = state.renaming, val = state.renameVal;
876
- cancelRename();
877
- const s = state.sessions.find((x) => x.id === id);
878
- if (id && val.trim() && s && val.trim() !== s.name) send({ t: 'rename', id, name: val.trim() });
879
- renderSessions();
880
- }
881
- function paintRename() {
882
- const id = state.renaming;
883
- if (!id) return;
884
- const row = document.querySelector(`#tree .sessrow[data-id="${id}"]`);
885
- // rename in place wherever the PM double-clicked: the sidebar row, or either group's tab
886
- const tab = $(GROUP_TABS[state.focusG]).querySelector(`.stab-wrap[data-id="${id}"]`)
887
- || document.querySelector(`.stabs .stab-wrap[data-id="${id}"]`);
888
- const host = row || tab;
889
- if (!host) return;
890
- const input = document.createElement('input');
891
- input.className = 'renbox';
892
- input.value = state.renameVal;
893
- input.setAttribute('aria-label', 'Rename session');
894
- input.maxLength = 60;
895
- input.oninput = () => { state.renameVal = input.value; };
896
- input.onkeydown = (e) => {
897
- e.stopPropagation();
898
- if (e.key === 'Enter') { e.preventDefault(); commitRename(); }
899
- else if (e.key === 'Escape') { e.preventDefault(); cancelRename(); renderSessions(); }
900
- };
901
- input.onblur = () => { if (state.renaming === id) commitRename(); };
902
- host.innerHTML = '';
903
- host.appendChild(input);
904
- input.focus(); input.select();
905
- }
906
-
907
- // ── terminals ──
908
- // Sessions live in GROUPS (R6). One group is the shipped behaviour; a split layout shows a second
909
- // beside it. A session is visible in at most one group at a time — its pane MOVES between hosts
910
- // rather than being duplicated, so there is never a second xterm bound to the same PTY.
911
- const GROUP_HOST = ['termhost', 'termhost1'];
912
- const GROUP_TABS = ['stabs', 'stabs1'];
913
- function groupOf(id) { const t = state.terms.get(id); return t ? t.g : 0; }
914
- function groupHost(g) { return $(GROUP_HOST[g] || GROUP_HOST[0]); }
915
- function splitOn() { return !$('group1').hidden; }
916
- function syncActive() { state.active = state.groups[state.focusG].activeId; syncScratchpad(); }
917
-
918
- function ensureTerm(id, g) {
919
- const want = typeof g === 'number' ? g : 0;
920
- let t = state.terms.get(id);
921
- if (t) {
922
- if (t.g !== want) { t.g = want; groupHost(want).appendChild(t.el); fitOne(id); }
923
- return t;
924
- }
925
- const el = document.createElement('div');
926
- el.className = 'termpane'; el.dataset.id = id;
927
- groupHost(want).appendChild(el);
928
- const term = new window.Terminal({
929
- fontFamily: '"IBM Plex Mono", ui-monospace, monospace', fontSize: 13, theme: xtheme(),
930
- cursorBlink: true, scrollback: 5000, allowProposedApi: true,
931
- });
932
- let fit = null;
933
- if (FitAddon) { fit = new FitAddon(); term.loadAddon(fit); }
934
- if (WebLinks) term.loadAddon(new WebLinks());
935
- term.open(el);
936
- // ⌘K must open the palette even while the terminal holds focus — intercept before xterm
937
- // sends it to the PTY (return false = don't pass the key through).
938
- term.attachCustomKeyEventHandler((e) => {
939
- if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
940
- if (e.type === 'keydown') { e.preventDefault(); togglePalette(); }
941
- return false;
942
- }
943
- return true;
944
- });
945
- term.onData((d) => send({ t: 'input', id, data: applyCtrl(d) }));
946
- term.onResize(({ cols, rows }) => send({ t: 'resize', id, cols, rows }));
947
- t = { term, fit, el, g: want };
948
- state.terms.set(id, t);
949
- return t;
950
- }
951
- function fitOne(id) {
952
- const t = state.terms.get(id); if (!t || !t.fit) return;
953
- requestAnimationFrame(() => { try { t.fit.fit(); } catch {} });
954
- }
955
- // Anything that changes the terminal's box must re-fit it, or xterm keeps rendering more rows
956
- // than fit and the BOTTOM OF THE TERMINAL disappears under the clip (R9). Observers on the hosts
957
- // catch every cause — the needs strip, the reader, a folded pane, the tab strip wrapping, a window
958
- // resize, a layout change, a split — instead of a fitOne() call sprinkled at each call site.
959
- // EVERY group's visible session is re-fitted: fitting only the focused one leaves the other
960
- // group rendering rows for the width it had before the split.
961
- let refitTimer = null;
962
- function refit() {
963
- clearTimeout(refitTimer);
964
- refitTimer = setTimeout(() => {
965
- for (const g of state.groups) if (g.activeId) fitOne(g.activeId);
966
- }, 30);
967
- }
968
- if (window.ResizeObserver) { const ro = new ResizeObserver(refit); ro.observe($('termhost')); ro.observe($('termhost1')); }
969
- window.addEventListener('resize', refit);
970
- document.addEventListener('visibilitychange', () => { if (!document.hidden) refit(); });
971
- // first paint measures the fallback font; the real metrics arrive with the webfont
972
- if (document.fonts && document.fonts.ready) document.fonts.ready.then(() => { for (const [id] of state.terms) fitOne(id); });
973
-
974
- function selectSession(id, g) {
975
- const s = state.sessions.find((x) => x.id === id);
976
- if (!s) return;
977
- // selecting a session from the palette may cross workspaces — follow it there
978
- if (s.ws !== state.wsId) { state.wsId = s.ws; store.set('wsid', s.ws); renderWorkspaces(); renderSessions(); loadLibrary(); loadNeeds(); state.branch = null; renderBranch(); loadBranch(); loadLayouts(); }
979
- // Which group shows it: the one asked for; else the group already showing it (so a click in the
980
- // sidebar focuses where it lives rather than yanking it across); else the focused group.
981
- let want = typeof g === 'number' ? g : state.groups.findIndex((gr) => gr.activeId === id);
982
- if (want < 0) want = state.focusG;
983
- if (want === 1 && !splitOn()) want = 0;
984
- // the same session must never render in both groups — the other one gives it up
985
- const other = want === 0 ? 1 : 0;
986
- if (state.groups[other].activeId === id) state.groups[other].activeId = null;
987
- state.groups[want].activeId = id;
988
- state.focusG = want;
989
- syncActive();
990
- store.set('active', id); // survives reloads (R7)
991
- // attach once per connection — re-selecting an attached session must not reset + re-replay
992
- if (!state.attached.has(id)) { state.attached.add(id); send({ t: 'attach', id }); }
993
- ensureTerm(id, want);
994
- paintGroups();
995
- fitOne(id);
996
- const t = state.terms.get(id); if (t) setTimeout(() => t.term.focus(), 0);
997
- updateExitedOverlay(); updateEmptyOverlay();
998
- }
999
- // One place decides what is visible where: each group shows exactly its own activeId, every other
1000
- // pane is hidden, and the sidebar/tab selection marks follow.
1001
- function paintGroups() {
1002
- for (const [sid, t] of state.terms) {
1003
- const gr = state.groups[t.g];
1004
- t.el.classList.toggle('on', !!gr && gr.activeId === sid);
1005
- }
1006
- const shown = new Set(state.groups.map((gr) => gr.activeId).filter(Boolean));
1007
- document.querySelectorAll('.sess').forEach((b) => b.setAttribute('aria-current', String(shown.has(b.dataset.id))));
1008
- GROUP_TABS.forEach((tid, gi) => {
1009
- const host = $(tid); if (!host) return;
1010
- const act = state.groups[gi].activeId;
1011
- host.querySelectorAll('.stab').forEach((b) => b.setAttribute('aria-selected', String(b.dataset.id === act)));
1012
- host.querySelectorAll('.stab-wrap').forEach((b) => b.dataset.active = String(b.dataset.id === act));
1013
- });
1014
- $('group0').dataset.focus = String(state.focusG === 0);
1015
- $('group1').dataset.focus = String(state.focusG === 1);
1016
- $('noSess1').classList.toggle('on', splitOn() && !state.groups[1].activeId);
1017
- }
1018
- function clearActive() {
1019
- state.groups.forEach((g) => { g.activeId = null; });
1020
- syncActive();
1021
- document.querySelectorAll('.termpane').forEach((e) => e.classList.remove('on'));
1022
- paintGroups();
1023
- updateExitedOverlay(); updateEmptyOverlay();
1024
- }
1025
-
1026
- // ── split / un-split (R6) ──
1027
- function applySplit(on) {
1028
- const was = splitOn();
1029
- $('group1').hidden = !on;
1030
- $('groups').dataset.split = on ? 'on' : 'off';
1031
- if (on && !state.groups[1].activeId) {
1032
- // fill the second group with the next session that is not already on screen; if there is
1033
- // none, the empty-group card offers `+ new session` rather than a black rectangle
1034
- const taken = new Set([state.groups[0].activeId]);
1035
- const next = mySessions().find((s) => !taken.has(s.id));
1036
- if (next) {
1037
- state.groups[1].activeId = next.id;
1038
- if (!state.attached.has(next.id)) { state.attached.add(next.id); send({ t: 'attach', id: next.id }); }
1039
- ensureTerm(next.id, 1);
1040
- }
1041
- }
1042
- if (!on && state.groups[1].activeId) {
1043
- // un-split does NOT close the session — it goes back to being a tab in the primary group
1044
- const id = state.groups[1].activeId;
1045
- state.groups[1].activeId = null;
1046
- const t = state.terms.get(id);
1047
- if (t) { t.g = 0; groupHost(0).appendChild(t.el); }
1048
- if (state.focusG === 1) state.focusG = 0;
1049
- }
1050
- if (was !== on) renderTabs();
1051
- syncActive();
1052
- paintGroups();
1053
- }
1054
- function updateExitedOverlay() {
1055
- const s = state.sessions.find((x) => x.id === state.active);
1056
- const ov = $('exited');
1057
- if (s && s.state === 'exited') {
1058
- ov.classList.add('on');
1059
- // show WHY it exited (exit code / spawn error), not just "has exited"
1060
- $('exitedMsg').innerHTML = `<b>${esc(s.name)}</b> has exited.` +
1061
- (s.note ? `<span class="reason">${esc(s.note)}</span>` : '');
1062
- $('respawnBtn').onclick = () => send({ t: 'respawn', id: s.id });
1063
- $('removeBtn').onclick = () => send({ t: 'remove', id: s.id });
1064
- } else ov.classList.remove('on');
1065
- refreshScratchSend(); // send-to-terminal follows whether the active session is live
1066
- }
1067
- function updateEmptyOverlay() {
1068
- // a workspace with no sessions must say so — not show another workspace's terminal
1069
- const w = state.workspaces.find((x) => x.id === state.wsId);
1070
- const on = !state.active && mySessions().length === 0;
1071
- $('noSessWs').textContent = w ? w.name : 'this workspace';
1072
- $('noSess').classList.toggle('on', on);
1073
- }
1074
-
1075
- // ── library ──
1076
- // Two lenses over the same file set: the initiative lens (how the PM thinks — one unit of
1077
- // work, all its artifacts) and the folder lens (how the repo is laid out). Every document
1078
- // reachable in one is reachable in the other.
1079
- let lastLibLoad = 0;
1080
- async function loadLibrary() {
1081
- lastLibLoad = Date.now();
1082
- try {
1083
- const r = await fetch('/api/library?ws=' + encodeURIComponent(state.wsId || ''));
1084
- state.lib = await r.json();
1085
- } catch { state.lib = null; }
1086
- renderLibrary();
1087
- }
1088
- // The library is a filesystem SNAPSHOT — a folder or file added after it loaded won't appear on
1089
- // its own (the reported defect). Re-scan on an explicit ↻, and automatically when the PM returns
1090
- // to the tab (throttled), so switching to an editor, adding a folder, and switching back shows it.
1091
- function refreshLibrary() { loadLibrary(); }
1092
- function maybeAutoRefreshLibrary() { if (state.wsId && Date.now() - lastLibLoad > 4000) loadLibrary(); }
1093
- window.addEventListener('focus', maybeAutoRefreshLibrary);
1094
- document.addEventListener('visibilitychange', () => { if (!document.hidden) maybeAutoRefreshLibrary(); });
1095
-
1096
- /* ── The rail's detail (R7·R8·R9) ────────────────────────────────────────────────────────────────
1097
- *
1098
- * The four standing lenses are replaced by ONE context-driven detail: a repository group gives repo
1099
- * detail (the complete folder map, its schedules, its history — the three views that were never
1100
- * initiative-scoped), a tree row gives that initiative's DOSSIER.
1101
- *
1102
- * The dossier is the promoted leg-A specimen (prototype/workspace-initiative-nav, Option B r2). Its
1103
- * one non-obvious rule: THE LANE OWNS THE DENOMINATOR. A chore owes an intent, not a PRD; a
1104
- * discovery spike owes a brief and nothing else. Measuring every initiative against one universal
1105
- * slate — r1 of the specimen did exactly that — makes a complete discovery read as nine failures.
1106
- * `n/a` here is never inferred: it comes from the engine's own step status.
1107
- */
1108
- const DOSSIER_GROUPS = [
1109
- { key: 'strategy', label: 'strategy', roles: ['record', 'brief', 'research', 'scoping', 'intake', 'OKR'] },
1110
- { key: 'spec', label: 'spec', roles: ['PRD', 'rubric', 'user stories', 'explainer'] },
1111
- { key: 'design', label: 'design', roles: ['design brief', 'design', 'copy brief', 'copy', 'specimen', 'grilling'] },
1112
- { key: 'build', label: 'build', roles: ['reconcile', 'harness', 'fixture'] },
1113
- { key: 'learning', label: 'learning', roles: ['eval', 'findings', 'dogfood', 'rejected'] },
1114
- ];
1115
- // Which ladder steps speak for which group, so a group can say "n/a for this lane" in the engine's
1116
- // own words instead of a guess of our own.
1117
- const GROUP_STEPS = {
1118
- strategy: ['route', 'anchor'],
1119
- spec: ['understand', 'spec', 'prototype'],
1120
- design: ['prototype'],
1121
- build: ['priors', 'contract', 'build', 'quality'],
1122
- learning: ['review', 'explain', 'accept'],
1123
- };
1124
-
1125
- function detailWs() {
1126
- const id = state.detail.wsId || state.wsId;
1127
- return state.workspaces.find((w) => w.id === id) || null;
1128
- }
1129
- // The library for whichever tree the detail is about — NOT necessarily the selected one. Fetched on
1130
- // demand and cached, because a dossier must be openable for a tree the PM is not standing in.
1131
- async function ensureLib(wsId) {
1132
- if (!wsId) return null;
1133
- if (wsId === state.wsId && state.lib) { state.libByWs[wsId] = state.lib; return state.lib; }
1134
- if (state.libByWs[wsId]) return state.libByWs[wsId];
1135
- try {
1136
- const r = await fetch('/api/library?ws=' + encodeURIComponent(wsId));
1137
- const j = await r.json();
1138
- state.libByWs[wsId] = j;
1139
- return j;
1140
- } catch { return null; }
1141
- }
1142
- /* Open an initiative's dossier. THIS IS A READ: it never switches working tree, never changes
1143
- * state.wsId, and never touches the branch. Only clicking a tree row does that (R9). */
1144
- async function openInitiative(id, wsId) {
1145
- state.detail = { kind: 'initiative', id, wsId: wsId || state.wsId };
1146
- state.lens = 'dossier';
1147
- showRail();
1148
- renderLibrary();
1149
- await ensureLib(state.detail.wsId);
1150
- renderLibrary();
1151
- }
1152
- function openRepoDetail(repoKey) {
1153
- state.detail = { kind: 'repo', id: repoKey, wsId: state.wsId };
1154
- if (state.lens === 'dossier') state.lens = 'folders';
1155
- renderLibrary();
1156
- }
1157
- // The sub-views available for what is currently selected. `dossier` only exists when an initiative
1158
- // is in view — a dead tab that cannot be filled is worse than an absent one.
1159
- function renderLensTabs() {
1160
- const host = $('lensTabs');
1161
- if (!host) return;
1162
- const views = state.detail.kind === 'initiative'
1163
- ? [['dossier', 'dossier'], ['folders', 'folders'], ['scheduled', 'scheduled'], ['history', 'history']]
1164
- : [['folders', 'folders'], ['scheduled', 'scheduled'], ['history', 'history']];
1165
- host.innerHTML = views.map(([k, label]) =>
1166
- `<button role="tab" data-lens="${k}" aria-selected="${state.lens === k}">${esc(label)}</button>`).join('');
1167
- host.querySelectorAll('button').forEach((b) => b.onclick = () => {
1168
- state.lens = b.dataset.lens; store.set('lens', state.lens);
1169
- $('libFind').placeholder = FIND_PLACEHOLDER[state.lens] || 'filter the library…';
1170
- renderLibrary();
1171
- });
1172
- }
1173
- // R9 — name the repository these documents come from. getWs() silently falls back to the home
1174
- // workspace on an id it cannot resolve, so a rail that does not say what it is reading can show
1175
- // another repository's files with no visual difference at all.
1176
- function renderReading() {
1177
- const el = $('libWho');
1178
- if (!el) return;
1179
- const w = detailWs();
1180
- if (!w) { el.textContent = ''; return; }
1181
- const away = w.id !== state.wsId;
1182
- el.textContent = w.name + (away ? ' · not the selected tree' : '');
1183
- el.dataset.away = away ? '1' : '0';
1184
- el.title = w.root;
1185
- }
1186
-
1187
- function dossierRow(a) {
1188
- return `<button class="dz-art" data-doc="${esc(a.path)}">
1189
- <span class="role">${esc(a.role || 'doc')}</span>
1190
- <span class="pth">${esc(a.path)}</span></button>`;
1191
- }
1192
- function renderDossier() {
1193
- const host = $('library');
1194
- const id = state.detail.id;
1195
- const w = detailWs();
1196
- const board = w ? boardFor(w) : null;
1197
- const row = board && board.data && Array.isArray(board.data.live)
1198
- ? board.data.live.find((r) => r.id === id) : null;
1199
- const lib = (w && state.libByWs[w.id]) || (w && w.id === state.wsId ? state.lib : null);
1200
- const group = lib && Array.isArray(lib.initiatives) ? lib.initiatives.find((g) => g.id === id) : null;
1201
-
1202
- if (board && board.ok === false) {
1203
- host.innerHTML = csError('the initiative ladder', board.error || 'the board could not be derived');
1204
- return;
1205
- }
1206
- const head = `<div class="dz-head">
1207
- <div class="dz-id">${esc(id)}</div>
1208
- ${group && group.title ? `<div class="dz-t">${esc(group.title)}</div>` : ''}
1209
- <div class="dz-meta">
1210
- ${row ? `<span class="pill lane">${esc(row.lane || '')}</span>` : ''}
1211
- ${row ? `<span class="pill">${row.stage === 'accepted' ? 'accepted' : 'stage ' + esc(row.stage || '')}</span>` : ''}
1212
- ${row && row.anchor ? `<span class="pill">${esc(String(row.anchor).split(/[(—]/)[0].trim())}</span>` : ''}
1213
- ${row && row.legacy ? '<span class="pill">legacy</span>' : ''}
1214
- </div>
1215
- ${row ? dossierAttrib(w, board) : ''}
1216
- </div>`;
1217
-
1218
- if (board && board.pending) {
1219
- host.innerHTML = head + `<div class="dz-wait">Deriving this initiative's ladder…
1220
- <span class="sub">the derivation shells git once per initiative and runs in a child process,
1221
- so the workspace stays responsive while it does.</span></div>` + dossierArtifacts(group, null);
1222
- wireDossier();
1223
- return;
1224
- }
1225
- if (!row) {
1226
- host.innerHTML = head + `<div class="dz-none">No ladder for <b>${esc(id)}</b> in this repository's board —
1227
- it has no initiative record, or the record is not readable. The artifacts below are what the
1228
- library can see; nothing about its position in the loop is being claimed.</div>`
1229
- + dossierArtifacts(group, null);
1230
- wireDossier();
1231
- return;
1232
- }
1233
- host.innerHTML = head + dossierNext(row) + dossierArtifacts(group, row);
1234
- wireDossier();
1235
- }
1236
- // R5 in the rail: the same attribution the tree rows carry. A stage without its branch is a claim
1237
- // the data does not support.
1238
- function dossierAttrib(w, board) {
1239
- const stamp = board && board.as_of ? new Date(board.as_of).toLocaleTimeString() : null;
1240
- return `<div class="dz-attrib">derived on <b>${esc((w && w.branch) || '?')}</b>${stamp ? ` · as of ${esc(stamp)}` : ''}${board && board.stale ? ' · refreshing' : ''}
1241
- <span class="sub">build and quality are scoped to this branch's commits, not to all of history</span></div>`;
1242
- }
1243
- // Accepted and owing render TOGETHER — an initiative can be accepted with steps still open, and a
1244
- // green badge alone would be the watermelon.
1245
- function dossierNext(row) {
1246
- const open = (row.steps || []).filter((s) => s.status === 'pending' || s.status === 'blocked');
1247
- const accepted = row.stage === 'accepted';
1248
- if (accepted && open.length) {
1249
- return `<div class="dz-next owed"><b>still owed · ${open.length} step${open.length === 1 ? '' : 's'}</b>
1250
- Accepted, and these never closed: ${open.map((s) => esc(s.key)).join(' · ')}.</div>`;
1251
- }
1252
- if (row.next && row.next.action) {
1253
- return `<div class="dz-next"><b>next · ${esc(row.next.step || '')}</b>${esc(row.next.action)}</div>`;
1254
- }
1255
- return `<div class="dz-next done"><b>nothing owed</b>Every step this lane asks for is closed.</div>`;
1256
- }
1257
- function dossierArtifacts(group, row) {
1258
- const arts = (group && group.artifacts) || [];
1259
- const used = new Set();
1260
- const stepBy = {};
1261
- for (const st of (row && row.steps) || []) stepBy[st.key] = st;
1262
-
1263
- const sections = DOSSIER_GROUPS.map((g) => {
1264
- const mine = arts.filter((a) => g.roles.includes(a.role));
1265
- mine.forEach((a) => used.add(a.path));
1266
- // the lane's own verdict on this group, in the engine's words — never our inference
1267
- const naStep = (GROUP_STEPS[g.key] || []).map((k) => stepBy[k]).filter(Boolean);
1268
- const allNa = naStep.length && naStep.every((st) => st.status === 'na');
1269
- if (!mine.length && allNa) {
1270
- return `<div class="dz-sec"><h5>${esc(g.label)} <span class="c">n/a</span></h5>
1271
- <div class="dz-na">${esc(naStep[0].evidence || 'not required in this lane')}</div></div>`;
1272
- }
1273
- if (!mine.length) {
1274
- return `<div class="dz-sec"><h5>${esc(g.label)} <span class="c">nothing yet</span></h5>
1275
- <div class="dz-na">No ${esc(g.label)} artifact is filed for this initiative.</div></div>`;
1276
- }
1277
- return `<div class="dz-sec"><h5>${esc(g.label)} <span class="c">${mine.length}</span></h5>
1278
- ${mine.map(dossierRow).join('')}</div>`;
1279
- }).join('');
1280
-
1281
- const rest = arts.filter((a) => !used.has(a.path));
1282
- const other = rest.length
1283
- ? `<div class="dz-sec"><h5>other <span class="c">${rest.length}</span></h5>${rest.map(dossierRow).join('')}</div>`
1284
- : '';
1285
- const none = !arts.length
1286
- ? `<div class="dz-na">The library sees no artifacts for this initiative yet. This is a verified
1287
- empty — the read succeeded.</div>` : '';
1288
- return `<div class="dz-arts">${sections}${other}${none}</div>`;
1289
- }
1290
- function wireDossier() {
1291
- $('library').querySelectorAll('[data-doc]').forEach((b) => b.onclick = () => openDoc(b.dataset.doc));
1292
- }
1293
-
1294
- function renderLibrary() {
1295
- renderLensTabs();
1296
- renderReading();
1297
- if (state.lens === 'dossier' && state.detail.kind === 'initiative') { renderDossier(); return; }
1298
- if (state.lens === 'dossier') { state.lens = 'folders'; }
1299
- // a third, persistent lens beside initiatives + folders: the scheduled tasks for this workspace
1300
- if (state.lens === 'scheduled') {
1301
- renderScheduled();
1302
- return;
1303
- }
1304
- // a fourth: what past sessions in THIS repo actually printed. The filter box becomes the query.
1305
- if (state.lens === 'history') {
1306
- renderHistory();
1307
- return;
1308
- }
1309
- const lib = state.lib;
1310
- const host = $('library');
1311
- // BOTH lenses are always available: initiatives (the PMOS projection — works whenever the repo
1312
- // has PRDs, even in a nested bundle) and folders (the complete repo map). A repo with no
1313
- // initiatives shows an empty-but-honest initiative view — the toggle is never dead, never hidden.
1314
- const effLens = 'folders';
1315
- if (!lib) { host.innerHTML = `<div class="empty">Library unavailable.</div>`; return; }
1316
- const f = state.filter.trim().toLowerCase();
1317
- const hit = (s) => !f || String(s).toLowerCase().includes(f);
1318
-
1319
- if (effLens === 'folders') {
1320
- const roots = (lib.roots || []).map((r) => treeNode(r, true, f)).join('');
1321
- host.innerHTML = roots || `<div class="empty">${f ? 'No document matches that filter.' : 'No documents found in this repo.'}</div>`;
1322
- wireDocs(host);
1323
- return;
1324
- }
1325
-
1326
- // initiative lens
1327
- const inits = (lib.initiatives || []).filter((g) =>
1328
- hit(g.id) || hit(g.title || '') || g.artifacts.some((a) => hit(a.name)));
1329
- const initHtml = inits.map((g) => {
1330
- const arts = g.artifacts.filter((a) => !f || hit(g.id) || hit(g.title || '') || hit(a.name));
1331
- return `<details class="init"${f ? ' open' : ''}>
1332
- <summary><span class="iid">${esc(g.id)}</span><span class="n">${arts.length}</span>
1333
- ${g.title ? `<span class="ititle">${esc(g.title)}</span>` : ''}</summary>
1334
- ${arts.map((a) => `<button class="doc art" data-path="${esc(a.path)}" title="${esc(a.path)}">
1335
- <span class="role" data-role="${esc(a.role)}">${esc(a.role)}</span>
1336
- <span class="dn">${esc(a.name)}</span></button>`).join('')}
1337
- </details>`;
1338
- }).join('');
1339
-
1340
- const section = (title, body, open) => body
1341
- ? `<details class="tree"${open ? ' open' : ''}><summary>${esc(title)}</summary>${body}</details>` : '';
1342
- const ungrouped = (lib.ungrouped || []).filter((a) => hit(a.path));
1343
- const gov = (lib.governance || []).filter((d) => hit(d.name));
1344
-
1345
- host.innerHTML = [
1346
- initHtml ? `<div class="grp">initiatives</div>${initHtml}` : '',
1347
- // never silently dropped (R5) — planning docs that declare no initiative are named here
1348
- section(`other planning docs (${ungrouped.length})`,
1349
- ungrouped.map((a) => `<button class="doc" data-path="${esc(a.path)}" title="${esc(a.path)}">${esc(a.path.replace(/^planning\//, ''))}</button>`).join('')),
1350
- section('knowledge — okf · skills',
1351
- (lib.knowledge || []).map((r) => treeNode(r, false, f)).join('')),
1352
- section('governance',
1353
- gov.map((d) => `<button class="doc" data-path="${esc(d.path)}">${esc(d.name)}</button>`).join(''), true),
1354
- ].filter(Boolean).join('')
1355
- || `<div class="empty">${f ? 'No initiative or document matches that filter.' : 'No planning documents in this repo.'}</div>`;
1356
- wireDocs(host);
1357
- }
1358
- function treeNode(node, open, f) {
1359
- const dirs = (node.dirs || []).map((d) => treeNode(d, false, f)).join('');
1360
- // Every file is SHOWN so the map stays complete (R8). What the reader can PREVIEW — markdown
1361
- // (incl. .skill), text/config source, HTML, images — opens; a truly non-previewable file (a
1362
- // binary) stays inert with the editor affordance. `kind` is the server's single classifier.
1363
- const docs = (node.docs || []).filter((d) => !f || d.path.toLowerCase().includes(f))
1364
- .map((d) => {
1365
- const k = d.kind || (d.md === false ? 'inert' : 'markdown');
1366
- return k === 'inert'
1367
- ? `<span class="file" title="${esc(d.path)} — open in your editor (no in-app preview for this file type)">${esc(d.name)}</span>`
1368
- : `<button class="doc" data-path="${esc(d.path)}" data-kind="${esc(k)}" title="${esc(d.path)}">${esc(d.name)}</button>`;
1369
- }).join('');
1370
- if (node.path === '' && node.name === '(root)') return docs; // loose root files, no folder
1371
- if (f && !dirs && !docs) return ''; // filtered-out branch
1372
- return `<details class="tree"${open || f ? ' open' : ''}><summary>${esc(node.name)}/</summary>${dirs}${docs}</details>`;
1373
- }
1374
- function wireDocs(scope) {
1375
- scope.querySelectorAll('.doc').forEach((b) => {
1376
- b.onclick = () => openDoc(b.dataset.path, b.dataset.kind);
1377
- b.classList.toggle('on', b.dataset.path === state.docPath);
1378
- });
1379
- }
1380
-
1381
- // ── the document reader — bottom of the right rail, under the library ──
1382
- // Renders by KIND: markdown (incl. .skill) through the parser, text/config as escaped source,
1383
- // HTML as source with an opt-in sandboxed render, images straight from the guarded /api/asset.
1384
- // `kind` comes from the tree button; it is only REQUIRED for images (which never hit /api/doc) —
1385
- // for everything else the server's X-Doc-Kind header is authoritative, so a link with no kind works.
1386
- async function openDoc(pth, kind) {
1387
- if (!pth) return;
1388
- state.docPath = pth;
1389
- showRail();
1390
- $('paneDoc').hidden = false;
1391
- $('railSplit').hidden = false;
1392
- foldPane('doc', true);
1393
- $('crumb').innerHTML = `<bdi>${esc(pth)}</bdi>`;
1394
- $('docBody').scrollTop = 0;
1395
- const body = $('docBody');
1396
- // Images bypass /api/doc entirely — served as bytes from the guarded, MIME-allowlisted endpoint.
1397
- if (kind === 'image') {
1398
- const url = `/api/asset?ws=${encodeURIComponent(state.wsId || '')}&path=${encodeURIComponent(pth)}`;
1399
- body.innerHTML = `<div class="img-view"><img class="doc-img" src="${url}" alt="${esc(pth)}" /></div>`;
1400
- wireImgFallback(body); markActiveDoc(pth); refit(); return;
1401
- }
1402
- try {
1403
- const res = await fetch(`/api/doc?ws=${encodeURIComponent(state.wsId || '')}&path=${encodeURIComponent(pth)}`);
1404
- if (!res.ok) { body.innerHTML = `<p class="doc-empty">Could not read <code>${esc(pth)}</code>.</p>`; markActiveDoc(pth); edControls(); refit(); return; }
1405
- const k = res.headers.get('X-Doc-Kind') || 'markdown';
1406
- const text = await res.text();
1407
- if (k === 'html') renderHtmlDoc(body, text);
1408
- else if (k === 'text') body.innerHTML = `<pre class="doc-src">${esc(text)}</pre>`;
1409
- else body.innerHTML = renderMd(text, pth);
1410
- } catch { body.innerHTML = `<p class="doc-empty">Read failed.</p>`; }
1411
- // a relative .md link inside a document opens that document here (still server-guarded)
1412
- body.querySelectorAll('[data-doc]').forEach((a) => a.onclick = (e) => { e.preventDefault(); openDoc(a.dataset.doc); });
1413
- wireImgFallback(body);
1414
- markActiveDoc(pth);
1415
- edControls();
1416
- refit();
1417
- }
1418
- function markActiveDoc(pth) {
1419
- document.querySelectorAll('.doc').forEach((b) => b.classList.toggle('on', b.dataset.path === pth));
1420
- }
1421
- // a repo-relative image that 404s (missing/renamed) degrades to its alt text, never a broken icon
1422
- function wireImgFallback(scope) {
1423
- scope.querySelectorAll('img.doc-img').forEach((img) => {
1424
- img.onerror = () => { const em = document.createElement('em'); em.textContent = img.getAttribute('alt') || 'image'; img.replaceWith(em); };
1425
- });
1426
- }
1427
- // HTML preview (R5): escaped SOURCE by default; an opt-in RENDER in a fully sandboxed iframe —
1428
- // scripts + same-origin denied (empty sandbox) AND a restrictive CSP that blocks all network, so a
1429
- // poisoned explainer can neither script nor beacon out (holds R4 even for rendered HTML). Never auto.
1430
- function renderHtmlDoc(body, text) {
1431
- body.innerHTML =
1432
- `<div class="html-view">`
1433
- + `<div class="html-bar" role="tablist">`
1434
- + `<button class="html-tab on" data-mode="src" role="tab" aria-selected="true">source</button>`
1435
- + `<button class="html-tab" data-mode="render" role="tab" aria-selected="false">render <span class="sbx">sandboxed</span></button>`
1436
- + `</div>`
1437
- + `<pre class="doc-src html-pane" data-pane="src"></pre>`
1438
- + `<div class="html-pane" data-pane="render" hidden></div>`
1439
- + `</div>`;
1440
- body.querySelector('[data-pane="src"]').textContent = text; // textContent, never innerHTML — no live markup
1441
- const srcPane = body.querySelector('[data-pane="src"]');
1442
- const rndPane = body.querySelector('[data-pane="render"]');
1443
- const tabs = body.querySelectorAll('.html-tab');
1444
- const activate = (mode) => {
1445
- tabs.forEach((t) => { const on = t.dataset.mode === mode; t.classList.toggle('on', on); t.setAttribute('aria-selected', String(on)); });
1446
- if (mode === 'render' && !rndPane.firstChild) {
1447
- const csp = `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data:; style-src 'unsafe-inline'; font-src data:">`;
1448
- const ifr = document.createElement('iframe');
1449
- ifr.className = 'html-frame';
1450
- ifr.setAttribute('sandbox', ''); // no allow-scripts, no allow-same-origin
1451
- ifr.setAttribute('referrerpolicy', 'no-referrer');
1452
- ifr.srcdoc = csp + text;
1453
- rndPane.appendChild(ifr);
1454
- }
1455
- srcPane.hidden = mode !== 'src';
1456
- rndPane.hidden = mode !== 'render';
1457
- };
1458
- tabs.forEach((t) => t.onclick = () => activate(t.dataset.mode));
1459
- }
1460
- function closeDoc() {
1461
- state.docPath = null; state.surface = null;
1462
- $('paneDoc').hidden = true;
1463
- $('railSplit').hidden = true;
1464
- $('docBody').innerHTML = '';
1465
- $('crumb').textContent = '';
1466
- document.querySelectorAll('.doc').forEach((b) => b.classList.remove('on'));
1467
- refit();
1468
- }
1469
-
1470
- // ══ THE LAYOUT SYSTEM (workspace-layout-system) ═════════════════════════════════════════════
1471
- // A layout is a named state tuple; the grid template is computed from it by lib/layout.js. The
1472
- // PM picks one in one act, then adjusts it freely — and the adjustment is kept against THAT
1473
- // layout automatically. There is no dirty state, no save and no discard (R4), which is why
1474
- // `restore` on a stock layout is not a nicety: it is the only way back.
1475
- const LAY = window.PMOSLayout;
1476
- const NARROW = window.matchMedia('(max-width:1100px)');
1477
- const PHONE = window.matchMedia('(max-width:760px)');
1478
-
1479
- // Start from the stock four so the control is populated and `review` — today's arrangement — is
1480
- // on screen before the server's copy arrives. loadLayouts() replaces this per workspace.
1481
- state.layouts = LAY.stockLayouts();
1482
- state.layoutId = LAY.DEFAULT_ID;
1483
-
1484
- function curLayout() {
1485
- return state.layouts.find((l) => l.id === state.layoutId) || state.layouts[0] || LAY.stockLayout(LAY.DEFAULT_ID);
1486
- }
1487
-
1488
- // Write the arrangement into the DOM. Custom properties, never direct grid declarations — a
1489
- // direct one would outrank the ≤760px media query and take the phone's switcher with it (R9).
1490
- function applyLayout() {
1491
- const l = curLayout();
1492
- const body = $('bodyGrid');
1493
- // Below 1100px the reader floats over the terminal instead of squeezing it, so its track comes
1494
- // OUT of the template; below that again the phone's own system owns the whole thing.
1495
- const float = NARROW.matches && !PHONE.matches && (l.reader === 'left' || l.reader === 'right');
1496
- const eff = float ? Object.assign({}, l, { reader: 'off' }) : l;
1497
- const g = LAY.gridFor(eff);
1498
- body.style.setProperty('--lay-cols', g.columns);
1499
- body.style.setProperty('--lay-rows', g.rows);
1500
- body.style.setProperty('--lay-areas', g.areas);
1501
- body.dataset.side = l.side;
1502
- body.dataset.reader = float ? l.reader : l.reader; // the pane still exists; it just floats
1503
- body.dataset.float = float ? 'on' : 'off';
1504
- if (float) body.style.setProperty('--lay-areas', g.areas);
1505
-
1506
- // folds
1507
- paintFold('paneWs', 'wsFold', l.folds.ws);
1508
- // `sess` retired with the pane it folded (R1/R13); a stored value is ignored, not migrated.
1509
- paintFold('paneLib', 'libFold', l.folds.lib);
1510
- paintFold('paneDoc', 'docFold', l.folds.doc);
1511
-
1512
- // split is a desktop arrangement; below 1100px two groups are two unusable terminals (R9)
1513
- applySplit(l.split === 'on' && !NARROW.matches);
1514
-
1515
- // The library/document divider is part of the ARRANGEMENT (R8), driven from the tuple. It used
1516
- // to be "a client preference like the theme" in a global localStorage key — which R8 and rubric
1517
- // dimension 1 both contradict: they require it carried per workspace and the old key not re-read
1518
- // on later loads. `libsplit` is now read exactly once, by migrateLegacy.
1519
- document.documentElement.style.setProperty('--libsplit', l.libSplit + '%');
1520
-
1521
- $('layName').textContent = l.name;
1522
- renderLayoutMenu();
1523
- refit();
1524
- }
1525
- function paintFold(paneId, btnId, open) {
1526
- const pane = $(paneId), btn = $(btnId);
1527
- if (!pane) return;
1528
- pane.dataset.fold = open ? 'on' : 'off';
1529
- if (btn) btn.setAttribute('aria-expanded', String(open));
1530
- }
1531
-
1532
- // Mutate the current layout and keep it. Every adjustment path funnels through here, so there is
1533
- // exactly one place that decides what "kept" means — and exactly one write-back to debounce.
1534
- function tweak(patch) {
1535
- const l = curLayout();
1536
- Object.assign(l, patch);
1537
- l.lastUsedAt = Date.now();
1538
- applyLayout();
1539
- saveLayouts();
1540
- }
1541
- function selectLayout(id) {
1542
- if (!state.layouts.some((l) => l.id === id)) return;
1543
- state.layoutId = id;
1544
- const l = curLayout();
1545
- l.lastUsedAt = Date.now();
1546
- applyLayout();
1547
- saveLayouts();
1548
- }
1549
- // R4's escape hatch. Auto-keep is destructive: without this a flattened `focus` is gone for good.
1550
- function restoreLayout(id) {
1551
- const fresh = LAY.stockLayout(id);
1552
- if (!fresh) return;
1553
- const i = state.layouts.findIndex((l) => l.id === id);
1554
- if (i < 0) return;
1555
- fresh.lastUsedAt = Date.now();
1556
- state.layouts[i] = fresh;
1557
- applyLayout();
1558
- saveLayouts();
1559
- }
1560
-
1561
- // ── persistence: per workspace, server-side (R7) ──
1562
- async function loadLayouts() {
1563
- let doc = null;
1564
- try {
1565
- const r = await fetch('/api/layouts?ws=' + encodeURIComponent(state.wsId || ''));
1566
- doc = await r.json();
1567
- } catch { doc = null; }
1568
- // A read failure must never author a write. loadAction() is the one place that decides, and it
1569
- // only says 'migrate' when the SERVER affirmatively reports nothing stored — never because the
1570
- // answer was unreadable. Anything unusable is 'keep': show the boot defaults, persist nothing,
1571
- // and leave whatever is on disk exactly as it is until a load succeeds.
1572
- const act = LAY.loadAction(doc);
1573
- if (act === 'keep') return;
1574
- if (act === 'migrate') {
1575
- // R8 — a workspace with nothing stored inherits whatever the PM had already tuned by hand
1576
- // in the pre-feature client, so the upgrade never costs them their rail width.
1577
- const legacy = {};
1578
- for (const k of ['rail', 'railw', 'libsplit', 'fold-lib', 'fold-doc']) legacy[k] = store.get(k);
1579
- const migrated = LAY.migrateLegacy(legacy);
1580
- state.layouts = migrated.layouts;
1581
- state.layoutId = migrated.currentId;
1582
- applyLayout();
1583
- saveLayouts(); // write it once so the migration is not re-run every load
1584
- return;
1585
- }
1586
- const clean = LAY.sanitize(doc);
1587
- state.layouts = clean.layouts;
1588
- state.layoutId = clean.currentId;
1589
- applyLayout();
1590
- }
1591
- function layoutBody() {
1592
- return JSON.stringify({ ws: state.wsId, layouts: state.layouts, currentId: state.layoutId });
1593
- }
1594
- function saveLayouts() {
1595
- state.layoutDirty = true;
1596
- clearTimeout(state.layoutSaveT);
1597
- state.layoutSaveT = setTimeout(async () => {
1598
- if (!state.wsId) return;
1599
- const body = layoutBody();
1600
- try {
1601
- await fetch('/api/layouts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body });
1602
- state.layoutDirty = false;
1603
- } catch {} // a failed write costs the arrangement, never the session — stay quiet
1604
- }, 200);
1605
- }
1606
- // A debounce alone loses the write when the PM picks a layout and immediately closes the tab —
1607
- // measured, not theorised: the browser suite caught exactly that. sendBeacon is the one send that
1608
- // survives teardown, so the last thing they chose is the thing that comes back.
1609
- function flushLayouts() {
1610
- if (!state.layoutDirty || !state.wsId) return;
1611
- clearTimeout(state.layoutSaveT);
1612
- state.layoutDirty = false;
1613
- try {
1614
- const blob = new Blob([layoutBody()], { type: 'application/json' });
1615
- if (!navigator.sendBeacon || !navigator.sendBeacon('/api/layouts', blob)) {
1616
- fetch('/api/layouts', { method: 'POST', headers: { 'Content-Type': 'application/json' },
1617
- body: layoutBody(), keepalive: true });
1618
- }
1619
- } catch {}
1620
- }
1621
- window.addEventListener('pagehide', flushLayouts);
1622
- // a pad typed into and closed within the debounce window is still the PM's text — write it
1623
- window.addEventListener('pagehide', () => flushScratchpad());
1624
- document.addEventListener('visibilitychange', () => { if (document.hidden) flushLayouts(); });
1625
-
1626
- // ── the layout menu ──
1627
- function layoutWire(l) {
1628
- // a wireframe of the arrangement: the name alone does not say what you get
1629
- const cells = [];
1630
- if (l.side === 'left') cells.push(1);
1631
- if (l.reader === 'left') cells.push(3);
1632
- cells.push(l.split === 'on' ? 5 : 4);
1633
- if (l.reader === 'right') cells.push(3);
1634
- if (l.side === 'right') cells.push(1);
1635
- const rows = l.reader === 'bottom' ? '<i style="flex:1"></i>' : '';
1636
- return `<span class="lay-mini" aria-hidden="true">${cells.map((w) => `<span style="flex:${w}"></span>`).join('')}${rows}</span>`;
1637
- }
1638
- function renderLayoutMenu() {
1639
- const list = $('layList');
1640
- if (!list) return;
1641
- list.innerHTML = state.layouts.map((l) => {
1642
- const on = l.id === state.layoutId;
1643
- const stock = LAY.isStock(l.id);
1644
- return `<div class="lay-line" data-id="${esc(l.id)}" style="display:flex;gap:2px;align-items:center">
1645
- <button class="lay-row" data-pick="${esc(l.id)}" aria-current="${on}"
1646
- aria-label="${esc(l.name)} layout">${layoutWire(l)}<span class="nm">${esc(l.name)}</span>
1647
- ${l.key ? `<span class="kbd">⌃${l.key}</span>` : ''}</button>
1648
- ${stock
1649
- ? `<button class="lay-act" data-restore="${esc(l.id)}" title="Restore ${esc(l.name)} to its default arrangement" aria-label="Restore ${esc(l.name)}">↺</button>`
1650
- : `<button class="lay-act" data-rename="${esc(l.id)}" title="Rename" aria-label="Rename ${esc(l.name)}">✎</button>
1651
- <button class="lay-act del" data-del="${esc(l.id)}" title="Delete" aria-label="Delete ${esc(l.name)}">✕</button>`}
1652
- </div>`;
1653
- }).join('') + (NARROW.matches
1654
- ? '<p class="lay-narrow">This window is too narrow for two terminal groups — split is off until it is wider.</p>'
1655
- : '');
1656
- list.querySelectorAll('[data-pick]').forEach((b) => b.onclick = () => { selectLayout(b.dataset.pick); closeLayMenu(); });
1657
- list.querySelectorAll('[data-restore]').forEach((b) => b.onclick = () => restoreLayout(b.dataset.restore));
1658
- list.querySelectorAll('[data-del]').forEach((b) => b.onclick = () => {
1659
- state.layouts = state.layouts.filter((l) => l.id !== b.dataset.del);
1660
- if (state.layoutId === b.dataset.del) state.layoutId = LAY.DEFAULT_ID;
1661
- applyLayout(); saveLayouts();
1662
- });
1663
- list.querySelectorAll('[data-rename]').forEach((b) => b.onclick = () => {
1664
- const l = state.layouts.find((x) => x.id === b.dataset.rename);
1665
- if (!l) return;
1666
- openLayNew(l.name, l.id);
1667
- });
1668
- renderLayoutPositions();
1669
- const w = state.workspaces.find((x) => x.id === state.wsId);
1670
- if ($('layScope')) $('layScope').textContent = w ? w.name : 'this repo';
1671
- }
1672
-
1673
- /* R12 — keyboard parity with the drag. Every value `side` and `reader` can hold is a real button
1674
- here, so no position is pointer-only. This is what makes the "no dead ends" property true rather
1675
- than merely argued: from ANY arrangement, including side:off + reader:off, the header control is
1676
- in the bar (never inside the grid it rearranges), and these buttons bring a pane back. */
1677
- const POS_GROUPS = [
1678
- { key: 'side', label: 'sidebar', values: [['left', '◧'], ['right', '◨'], ['off', '∅']] },
1679
- { key: 'reader', label: 'reader', values: [['left', '◧'], ['right', '◨'], ['bottom', '⬓'], ['off', '∅']] },
1680
- ];
1681
- function renderLayoutPositions() {
1682
- const host = $('layPos');
1683
- if (!host) return;
1684
- const l = curLayout();
1685
- host.innerHTML = POS_GROUPS.map((g) => `
1686
- <div class="pos-row" role="group" aria-label="${g.label} position">
1687
- <span class="pos-lbl">${g.label}</span>
1688
- ${g.values.map(([v, glyph]) => `<button class="pos-btn" data-pos="${g.key}" data-val="${v}"
1689
- aria-pressed="${l[g.key] === v}" title="${g.label} ${v}"
1690
- aria-label="${g.label} ${v}"><i aria-hidden="true">${glyph}</i><span>${v}</span></button>`).join('')}
1691
- </div>`).join('');
1692
- host.querySelectorAll('[data-pos]').forEach((b) => b.onclick = () => {
1693
- tweak({ [b.dataset.pos]: b.dataset.val }); // menu stays open: adjusting is not choosing
1694
- });
1695
- }
1696
- function openLayMenu() { $('layMenu').hidden = false; $('layBtn').setAttribute('aria-expanded', 'true'); renderLayoutMenu(); }
1697
- function closeLayMenu() { $('layMenu').hidden = true; $('layBtn').setAttribute('aria-expanded', 'false'); closeLayNew(); }
1698
- function openLayNew(value, renameId) {
1699
- $('layNewForm').hidden = false;
1700
- $('layNewForm').dataset.rename = renameId || '';
1701
- $('layNewName').value = value || '';
1702
- $('layNewName').focus(); $('layNewName').select();
1703
- }
1704
- function closeLayNew() { $('layNewForm').hidden = true; $('layNewForm').dataset.rename = ''; $('layNewName').value = ''; }
1705
- $('layBtn').onclick = () => ($('layMenu').hidden ? openLayMenu() : closeLayMenu());
1706
- $('layNewBtn').onclick = () => openLayNew(newLayoutName());
1707
- $('layNewCancel').onclick = () => closeLayNew();
1708
- $('layNewForm').onsubmit = (e) => {
1709
- e.preventDefault();
1710
- const name = $('layNewName').value.trim().slice(0, 40);
1711
- if (!name) return;
1712
- const renameId = $('layNewForm').dataset.rename;
1713
- if (renameId) {
1714
- const l = state.layouts.find((x) => x.id === renameId);
1715
- if (l && !LAY.isStock(l.id)) l.name = name;
1716
- } else {
1717
- const l = Object.assign({}, curLayout(), { id: 'c' + Date.now().toString(36), name, key: null, lastUsedAt: Date.now() });
1718
- l.folds = Object.assign({}, curLayout().folds);
1719
- state.layouts.push(l);
1720
- state.layoutId = l.id;
1721
- }
1722
- closeLayNew(); applyLayout(); saveLayouts();
1723
- };
1724
- function newLayoutName() {
1725
- const base = curLayout().name + ' (mine)';
1726
- let n = base, i = 2;
1727
- while (state.layouts.some((l) => l.name === n)) n = base + ' ' + (i++);
1728
- return n;
1729
- }
1730
-
1731
- // ── folds: both columns, same behaviour (R11) ──
1732
- function foldPane(which, open) {
1733
- const map = { doc: 'paneDoc', lib: 'paneLib', ws: 'paneWs' };
1734
- if (!map[which]) return;
1735
- const l = curLayout();
1736
- const folds = Object.assign({}, l.folds);
1737
- folds[which] = open;
1738
- tweak({ folds });
1739
- }
1740
- function showRail() { if (curLayout().reader === 'off') tweak({ reader: 'right' }); }
1741
-
1742
- $('libFold').onclick = () => foldPane('lib', $('paneLib').dataset.fold === 'off');
1743
- $('docFold').onclick = () => foldPane('doc', $('paneDoc').dataset.fold === 'off');
1744
- $('wsFold').onclick = () => foldPane('ws', $('paneWs').dataset.fold === 'off');
1745
-
1746
- // ── the edges: drag a column's outer edge to resize it. Keeps to the layout, like everything
1747
- // else — a width the PM chose is part of the arrangement, not a separate preference. ──
1748
- function wireEdge(el) {
1749
- if (!el) return;
1750
- const which = el.dataset.edge;
1751
- let dragging = false;
1752
- const applyPx = (px) => {
1753
- const l = curLayout();
1754
- if (which === 'side') tweak({ sideW: Math.round(Math.min(Math.max(px, LAY.SIDE_MIN), LAY.SIDE_MAX)) });
1755
- else tweak({ readW: Math.round(Math.min(Math.max(px, LAY.READ_MIN), Math.min(LAY.READ_MAX, window.innerWidth - 360))) });
1756
- void l;
1757
- };
1758
- const fromPointer = (clientX) => {
1759
- const l = curLayout();
1760
- const r = $('bodyGrid').getBoundingClientRect();
1761
- if (which === 'side') return l.side === 'right' ? r.right - clientX : clientX - r.left;
1762
- return l.reader === 'left' ? clientX - r.left : r.right - clientX;
1763
- };
1764
- el.addEventListener('pointerdown', (e) => { dragging = true; el.classList.add('on'); el.setPointerCapture(e.pointerId); e.preventDefault(); });
1765
- el.addEventListener('pointermove', (e) => { if (dragging) applyPx(fromPointer(e.clientX)); });
1766
- el.addEventListener('pointerup', (e) => { dragging = false; el.classList.remove('on'); try { el.releasePointerCapture(e.pointerId); } catch {} refit(); });
1767
- el.addEventListener('keydown', (e) => {
1768
- if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
1769
- e.preventDefault();
1770
- const l = curLayout();
1771
- const cur = which === 'side' ? l.sideW : l.readW;
1772
- const grow = which === 'side' ? (l.side === 'right' ? e.key === 'ArrowLeft' : e.key === 'ArrowRight')
1773
- : (l.reader === 'left' ? e.key === 'ArrowRight' : e.key === 'ArrowLeft');
1774
- applyPx(cur + (grow ? 24 : -24));
1775
- refit();
1776
- });
1777
- }
1778
- wireEdge($('railGrip'));
1779
- wireEdge($('sideEdge'));
1780
-
1781
- // ── the grips: drag a pane to a new home. An ACCELERATOR — every position a drop zone offers is
1782
- // also reachable from the layout menu, so a pointer is never required to arrange the window. ──
1783
- (() => {
1784
- const body = $('bodyGrid');
1785
- let drag = null, ghost = null;
1786
- const zones = () => [...document.querySelectorAll('.dz')];
1787
- function allowed(pane, z) { return !(pane === 'side' && z === 'bottom'); } // the sidebar has no bottom home
1788
- function hit(e) {
1789
- let found = null;
1790
- zones().forEach((d) => {
1791
- const r = d.getBoundingClientRect();
1792
- const inside = e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom;
1793
- const ok = inside && allowed(drag, d.dataset.z);
1794
- d.classList.toggle('hot', ok);
1795
- if (ok) found = d.dataset.z;
1796
- });
1797
- return found;
1798
- }
1799
- function move(e) {
1800
- if (!drag) return;
1801
- if (ghost) { ghost.style.left = (e.clientX + 12) + 'px'; ghost.style.top = (e.clientY + 12) + 'px'; }
1802
- hit(e);
1803
- }
1804
- function end(e) {
1805
- if (!drag) return;
1806
- const z = hit(e);
1807
- if (z) tweak(drag === 'side' ? { side: z === 'bottom' ? curLayout().side : z } : { reader: z });
1808
- body.classList.remove('dragging');
1809
- zones().forEach((d) => { d.classList.remove('hot'); d.classList.remove('no'); });
1810
- if (ghost) { ghost.remove(); ghost = null; }
1811
- drag = null;
1812
- }
1813
- document.querySelectorAll('.cap .grip').forEach((g) => {
1814
- g.addEventListener('pointerdown', (e) => {
1815
- if (PHONE.matches) return; // the switcher owns layout on touch
1816
- e.preventDefault();
1817
- drag = g.dataset.pane;
1818
- body.classList.add('dragging');
1819
- zones().forEach((d) => d.classList.toggle('no', !allowed(drag, d.dataset.z)));
1820
- ghost = document.createElement('div');
1821
- ghost.className = 'laghost';
1822
- ghost.textContent = drag === 'side' ? 'workspaces · running agents' : 'library · document';
1823
- document.body.appendChild(ghost);
1824
- move(e);
1825
- try { g.setPointerCapture(e.pointerId); } catch {}
1826
- });
1827
- g.addEventListener('pointermove', move);
1828
- g.addEventListener('pointerup', end);
1829
- // keyboard: the grip opens the layout menu and lands focus on the position controls, which can
1830
- // reach every value the drag can (R12). It previously focused #layBtn — a menu whose only
1831
- // contents were layout rows, so Enter here could not actually move anything.
1832
- g.addEventListener('keydown', (e) => {
1833
- if (e.key !== 'Enter' && e.key !== ' ') return;
1834
- e.preventDefault();
1835
- openLayMenu();
1836
- const first = $('layPos') && $('layPos').querySelector(`[data-pos="${g.dataset.pane === 'side' ? 'side' : 'reader'}"]`);
1837
- (first || $('layBtn')).focus();
1838
- });
1839
- });
1840
- })();
1841
-
1842
- // re-apply when the window crosses a breakpoint: the float rule and the split rule are both
1843
- // width-dependent, and a template computed for the other side of the line clips the terminal
1844
- [NARROW, PHONE].forEach((mq) => {
1845
- const on = () => { applyLayout(); };
1846
- if (mq.addEventListener) mq.addEventListener('change', on); else if (mq.addListener) mq.addListener(on);
1847
- });
1848
-
1849
- // the empty second group's own controls — a split with nothing in it offers the next step
1850
- $('newHere1').onclick = () => { state.focusG = 1; openNewForm(); };
1851
- $('unsplitBtn').onclick = () => tweak({ split: 'off' });
1852
- // clicking anywhere in a group focuses it, so ⌘J's send and F2's rename act on what you last touched
1853
- [0, 1].forEach((gi) => $(GROUP_HOST[gi]).addEventListener('pointerdown', () => {
1854
- if (state.focusG === gi) return;
1855
- state.focusG = gi; syncActive(); paintGroups();
1856
- }));
1857
- // a click outside the layout menu closes it — it is a popover, not a mode
1858
- document.addEventListener('pointerdown', (e) => {
1859
- if ($('layMenu').hidden) return;
1860
- if ($('layMenu').contains(e.target) || $('layBtn').contains(e.target)) return;
1861
- closeLayMenu();
1862
- }, true);
1863
-
1864
- applyLayout(); // paint the default arrangement before the server's layouts arrive
1865
-
1866
- // drag the divider between the library and the document. It is part of the arrangement (R8), so
1867
- // it is kept against the current layout like every other adjustment — not in a global client key.
1868
- (() => {
1869
- const split = $('railSplit');
1870
- let dragging = false;
1871
- const apply = (clientY) => {
1872
- const r = $('rail').getBoundingClientRect();
1873
- const pct = Math.min(LAY.LIB_MAX, Math.max(LAY.LIB_MIN, ((clientY - r.top) / r.height) * 100));
1874
- tweak({ libSplit: Math.round(pct * 10) / 10 });
1875
- };
1876
- split.addEventListener('pointerdown', (e) => { dragging = true; split.setPointerCapture(e.pointerId); e.preventDefault(); });
1877
- split.addEventListener('pointermove', (e) => { if (dragging) apply(e.clientY); });
1878
- split.addEventListener('pointerup', (e) => { dragging = false; try { split.releasePointerCapture(e.pointerId); } catch {} refit(); });
1879
- split.addEventListener('keydown', (e) => {
1880
- if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
1881
- e.preventDefault();
1882
- const cur = curLayout().libSplit;
1883
- tweak({ libSplit: Math.round(Math.min(LAY.LIB_MAX, Math.max(LAY.LIB_MIN, cur + (e.key === 'ArrowUp' ? -4 : 4))) * 10) / 10 });
1884
- });
1885
- })();
1886
-
1887
- // ── needs-you + gates (the PMOS workflow: Review/Acceptance in file mode, never automatic) ──
1888
- async function loadNeeds() {
1889
- try {
1890
- const r = await fetch('/api/needs?ws=' + encodeURIComponent(state.wsId || ''));
1891
- const j = await r.json();
1892
- state.needs = j.needs || [];
1893
- // R12 — what a COLLAPSED tree row shows. R10 — the count comes from the strict reader, so a
1894
- // corrupt queue arrives as {count:null,error}, never as a calm zero.
1895
- state.needsByWs = j.by_ws || {};
1896
- } catch { state.needs = []; state.needsByWs = {}; }
1897
- renderNeeds(); renderSessions();
1898
- }
1899
- function renderNeeds() {
1900
- const n = state.needs[0];
1901
- const box = $('needs');
1902
- if (!n) { box.style.display = 'none'; refit(); return; }
1903
- if (n.kind === 'report') {
1904
- // a scheduled run finished — reach the PM here, openable in the reader (R5)
1905
- box.className = 'needs report'; box.style.display = 'block';
1906
- box.innerHTML = `<span class="k">report ready</span><div class="q">${esc(n.question)}</div>
1907
- <div class="row">${n.deliverable ? `<button class="gbtn primary" data-deliv="${esc(n.deliverable)}">▸ open report</button>` : ''}
1908
- <button class="gbtn" data-gate="seen">dismiss</button></div>`;
1909
- box.querySelector('[data-deliv]')?.addEventListener('click', (e) => openDeliverable(e.currentTarget.dataset.deliv));
1910
- box.querySelectorAll('[data-gate]').forEach((b) => b.onclick = () => gate(n, b.dataset.gate));
1911
- refit(); return;
1912
- }
1913
- if (n.kind === 'outcome') {
1914
- // A D57 outcome check past its horizon. There is deliberately NO button here that would
1915
- // close it: disposal is validated|refuted|inconclusive with evidence, recorded by the PM
1916
- // through record_outcome_check — auto-disposal is prohibited, and a button that looked
1917
- // like disposal but only wrote an acceptance would be exactly the fake green D57 forbids.
1918
- box.className = 'needs accept'; box.style.display = 'block';
1919
- box.innerHTML = `<span class="k">outcome check due</span><div class="q">${esc(n.question)}</div>
1920
- <div class="row"><span class="wpath">dispose it with record_outcome_check
1921
- (validated | refuted | inconclusive, with evidence) — it stays here until you do</span></div>`;
1922
- refit(); return;
1923
- }
1924
- const accept = n.kind === 'accept';
1925
- box.className = 'needs' + (accept ? ' accept' : '');
1926
- box.style.display = 'block';
1927
- box.innerHTML = `
1928
- <span class="k">${accept ? 'needs your acceptance' : 'needs you'}</span>
1929
- <div class="q">${esc(n.question)}</div>
1930
- <div class="row"><span>${accept ? 'evidence' : 'blocked on'} →</span>
1931
- ${n.doc ? `<button class="dlink" data-doc="${esc(n.doc)}">${esc(n.doc)}</button>` : ''}
1932
- <button class="gbtn primary" data-gate="${accept ? 'accept' : 'approve'}">✓ ${accept ? 'accept' : 'approve'}</button>
1933
- <button class="gbtn" data-gate="return">↩ return with note</button>
1934
- <span class="wpath">${accept
1935
- ? 'accept → records the Acceptance into this repo’s own acceptances.jsonl — your verdict, never automatic'
1936
- : 'approve/return → records a correction into this repo’s own acceptances.jsonl — attributable to you'}</span>
1937
- </div>`;
1938
- box.querySelector('[data-doc]')?.addEventListener('click', (e) => openDoc(e.target.dataset.doc));
1939
- box.querySelectorAll('[data-gate]').forEach((b) => b.onclick = () => gate(n, b.dataset.gate));
1940
- refit();
1941
- }
1942
- async function gate(n, action) {
1943
- let note = '';
1944
- if (action === 'return') { note = prompt('Return with a note (what needs correcting):') || ''; if (note === '') return; }
1945
- try {
1946
- await fetch('/api/gate', { method: 'POST', headers: { 'Content-Type': 'application/json' },
1947
- body: JSON.stringify({ action, run_id: n.run_id, initiative_id: n.initiative_id, note, ws: state.wsId }) });
1948
- } catch {}
1949
- loadNeeds(); // needs.json is updated server-side; re-pull
1950
- }
1951
-
1952
- // ── markdown: a real block parser (no deps). ──────────────────────────────────────────
1953
- // Escaping happens at the LEAF — every piece of file text passes through esc() before it is
1954
- // put into HTML, and nothing else ever does. That keeps the old renderer's one good property
1955
- // while fixing what it could not do: soft-wrapped paragraphs, ordered/nested/task lists,
1956
- // tables (rubrics ARE tables), quotes, rules, and the identity block PMOS artifacts open with.
1957
- function esc(s) { return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])); }
1958
-
1959
- const RE_ITEM = /^(\s*)([-*+]|\d{1,9}[.)])\s+(.*)$/;
1960
- const RE_HR = /^\s{0,3}(?:-{3,}|\*{3,}|_{3,})\s*$/;
1961
-
1962
- function renderMd(src, docPath) {
1963
- const lines = String(src == null ? '' : src).replace(/\r\n?/g, '\n').split('\n');
1964
- const ctx = { dir: (docPath || '').split('/').slice(0, -1) };
1965
- let out = '', i = 0, blocks = 0, lastHead = '';
1966
-
1967
- // YAML frontmatter → identity card
1968
- if (lines[0] !== undefined && lines[0].trim() === '---') {
1969
- let j = 1;
1970
- while (j < lines.length && lines[j].trim() !== '---') j++;
1971
- if (j < lines.length) { out += metaCard(lines.slice(1, j)); i = j + 1; blocks++; }
1972
- }
1973
-
1974
- while (i < lines.length) {
1975
- const ln = lines[i];
1976
- if (!ln.trim()) { i++; continue; }
1977
-
1978
- // fenced code
1979
- const fence = ln.match(/^\s{0,3}(`{3,}|~{3,})\s*([^\s`]*)/);
1980
- if (fence) {
1981
- const close = new RegExp('^\\s{0,3}' + fence[1][0] + '{' + fence[1].length + ',}\\s*$');
1982
- const body = [];
1983
- i++;
1984
- while (i < lines.length && !close.test(lines[i])) body.push(lines[i++]);
1985
- i++; // consume the closing fence (or hit EOF)
1986
- out += `<pre>${fence[2] ? `<span class="lang">${esc(fence[2])}</span>` : ''}` +
1987
- `<code>${esc(body.join('\n'))}</code></pre>`;
1988
- blocks++; continue;
1989
- }
1990
-
1991
- const h = ln.match(/^\s{0,3}(#{1,6})\s+(.*?)\s*#*\s*$/);
1992
- if (h) {
1993
- const n = h[1].length;
1994
- out += `<h${n} id="${slug(h[2])}">${inline(h[2], ctx)}</h${n}>`;
1995
- lastHead = h[2].trim(); i++; blocks++; continue;
1996
- }
1997
-
1998
- if (RE_HR.test(ln)) { out += '<hr />'; i++; blocks++; continue; }
1999
-
2000
- if (/^\s{0,3}>/.test(ln)) {
2001
- const body = [];
2002
- while (i < lines.length && (/^\s{0,3}>/.test(lines[i]) || (body.length && lines[i].trim()))) {
2003
- body.push(lines[i].replace(/^\s{0,3}>\s?/, '')); i++;
2004
- }
2005
- out += `<blockquote>${renderMd(body.join('\n'), docPath)}</blockquote>`;
2006
- blocks++; continue;
2007
- }
2008
-
2009
- // GFM table: a header row followed by a delimiter row
2010
- if (ln.includes('|') && /^\s*\|?[\s:|-]*-[\s:|-]*\|?\s*$/.test(lines[i + 1] || '') && (lines[i + 1] || '').includes('-')) {
2011
- const head = cells(ln);
2012
- const align = cells(lines[i + 1]).map((c) => (/^:.*:$/.test(c) ? 'center' : /:$/.test(c) ? 'right' : /^:/.test(c) ? 'left' : ''));
2013
- i += 2;
2014
- const rows = [];
2015
- while (i < lines.length && lines[i].includes('|') && lines[i].trim()) rows.push(cells(lines[i++]));
2016
- const th = head.map((c, n) => `<th${align[n] ? ` style="text-align:${align[n]}"` : ''}>${inline(c, ctx)}</th>`).join('');
2017
- const tb = rows.map((r) => `<tr>${head.map((_, n) =>
2018
- `<td${align[n] ? ` style="text-align:${align[n]}"` : ''}>${inline(r[n] || '', ctx)}</td>`).join('')}</tr>`).join('');
2019
- out += `<div class="tablewrap"><table><thead><tr>${th}</tr></thead><tbody>${tb}</tbody></table></div>`;
2020
- blocks++; continue;
2021
- }
2022
-
2023
- if (RE_ITEM.test(ln)) {
2024
- const { items, next } = listBlock(lines, i);
2025
- i = next;
2026
- // PMOS artifacts open with an identity block written as `- key: value` bullets, not YAML —
2027
- // either straight after the H1 or under an `## Identity` heading. That is metadata: show
2028
- // it as a card, not as a bullet list.
2029
- const meta = (blocks <= 1 || /^identity$/i.test(lastHead)) && isMetaList(items);
2030
- out += meta ? metaCard(items.map((it) => it.lines.join(' '))) : renderLists(items, 0, ctx).html;
2031
- lastHead = ''; blocks++; continue;
2032
- }
2033
-
2034
- // paragraph — consecutive lines are ONE paragraph (this is the soft-wrap fix)
2035
- const para = [];
2036
- while (i < lines.length && lines[i].trim() && !RE_ITEM.test(lines[i]) && !RE_HR.test(lines[i]) &&
2037
- !/^\s{0,3}>/.test(lines[i]) && !/^\s{0,3}(#{1,6})\s/.test(lines[i]) && !/^\s{0,3}(`{3,}|~{3,})/.test(lines[i])) {
2038
- para.push(lines[i].trim()); i++;
2039
- }
2040
- if (para.length) { out += `<p>${inline(para.join(' '), ctx)}</p>`; blocks++; }
2041
- else i++; // nothing consumed — never spin
2042
- }
2043
- return out;
2044
- }
2045
- function cells(row) {
2046
- return String(row).trim().replace(/^\|/, '').replace(/\|$/, '').split(/(?<!\\)\|/).map((c) => c.trim());
2047
- }
2048
- function slug(s) { return String(s).toLowerCase().replace(/[^\w]+/g, '-').replace(/^-|-$/g, '').slice(0, 60); }
2049
-
2050
- // collect one list block (items + their wrapped continuation lines), preserving indent
2051
- function listBlock(lines, start) {
2052
- const items = [];
2053
- let i = start;
2054
- while (i < lines.length) {
2055
- const ln = lines[i];
2056
- const m = ln.match(RE_ITEM);
2057
- if (m) {
2058
- items.push({ indent: m[1].replace(/\t/g, ' ').length, ordered: /\d/.test(m[2]), lines: [m[3]] });
2059
- i++; continue;
2060
- }
2061
- if (!ln.trim()) {
2062
- const nx = lines[i + 1] || '';
2063
- if (RE_ITEM.test(nx) || /^\s{2,}\S/.test(nx)) { i++; continue; } // loose list
2064
- break;
2065
- }
2066
- if (items.length && /^\s+\S/.test(ln)) { items[items.length - 1].lines.push(ln.trim()); i++; continue; }
2067
- break;
2068
- }
2069
- return { items, next: i };
2070
- }
2071
- // A run of items at one indent is one list; a change of marker type (bullet ↔ number, or the
2072
- // first `- [ ]`) starts a sibling list, the way every markdown renderer does it.
2073
- function renderLists(items, start, ctx) {
2074
- const base = items[start].indent;
2075
- let html = '', i = start;
2076
- while (i < items.length && items[i].indent >= base) {
2077
- const r = renderList(items, i, ctx);
2078
- html += r.html;
2079
- if (r.next === i) break; // never spin
2080
- i = r.next;
2081
- }
2082
- return { html, next: i };
2083
- }
2084
- function renderList(items, start, ctx) {
2085
- const base = items[start].indent;
2086
- const ordered = items[start].ordered;
2087
- const task = /^\[[ xX]\]\s/.test(items[start].lines[0]);
2088
- const lis = [];
2089
- let i = start;
2090
- while (i < items.length && items[i].indent >= base) {
2091
- if (items[i].indent === base &&
2092
- (items[i].ordered !== ordered || /^\[[ xX]\]\s/.test(items[i].lines[0]) !== task)) break;
2093
- if (items[i].indent > base) {
2094
- const sub = renderLists(items, i, ctx);
2095
- if (lis.length) lis[lis.length - 1] = lis[lis.length - 1].replace(/<\/li>$/, sub.html + '</li>');
2096
- else lis.push(`<li>${sub.html}</li>`);
2097
- i = sub.next; continue;
2098
- }
2099
- let body = items[i].lines.join(' ');
2100
- const t = body.match(/^\[([ xX])\]\s+(.*)$/);
2101
- if (t) lis.push(`<li><span class="box${t[1] === ' ' ? '' : ' done'}" aria-hidden="true">${t[1] === ' ' ? '☐' : '☑'}</span><span>${inline(t[2], ctx)}</span></li>`);
2102
- else lis.push(`<li>${inline(body, ctx)}</li>`);
2103
- i++;
2104
- }
2105
- const tag = ordered ? 'ol' : 'ul';
2106
- return { html: `<${tag}${task ? ' class="task"' : ''}>${lis.join('')}</${tag}>`, next: i };
2107
- }
2108
- // the identity block: `key: value` lines, all at the same level, at least three of them
2109
- function isMetaList(items) {
2110
- return items.length >= 3 && items.every((it) => it.indent === items[0].indent &&
2111
- /^[A-Za-z][\w .-]{0,30}:\s*\S/.test(it.lines.join(' ')));
2112
- }
2113
- function metaCard(rawLines) {
2114
- const rows = [];
2115
- for (const raw of rawLines) {
2116
- const m = String(raw).trim().match(/^-?\s*([A-Za-z][\w .-]{0,30}):\s*(.*)$/);
2117
- if (!m) continue;
2118
- rows.push(`<dt>${esc(m[1])}</dt><dd>${inline(m[2].replace(/^["']|["']$/g, ''), { dir: [] })}</dd>`);
2119
- }
2120
- return rows.length ? `<dl class="fm">${rows.join('')}</dl>` : '';
2121
- }
2122
- // Code spans are lifted out behind a sentinel first, so the emphasis rules never chew
2123
- // through a path like `a_b_c`. SENT is a control char no document text can contain.
2124
- const SENT = '\u0001';
2125
- function inline(s, ctx) {
2126
- const codes = [];
2127
- let t = esc(s).replace(/`([^`]+)`/g, (m, c) => { codes.push(c); return SENT + (codes.length - 1) + SENT; });
2128
- t = t.replace(/!\[([^\]]*)\]\(([^)\s]*)[^)]*\)/g, (m, alt, src) => mdImage(alt, src, ctx));
2129
- t = t.replace(/\[([^\]]+)\]\(([^)\s]+)[^)]*\)/g, (m, txt, url) => mdLink(txt, url, ctx));
2130
- t = t.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
2131
- t = t.replace(/(^|[^\w*])\*([^*\n]+)\*/g, '$1<em>$2</em>');
2132
- t = t.replace(/(^|[^\w_])_([^_\n]+)_(?!\w)/g, '$1<em>$2</em>');
2133
- t = t.replace(/~~([^~]+)~~/g, '<del>$1</del>');
2134
- return t.replace(/\u0001(\d+)\u0001/g, (m, n) => `<code>${codes[n]}</code>`);
2135
- }
2136
- function mdLink(txt, url, ctx) {
2137
- const u = String(url).trim();
2138
- if (/^(https?:)?\/\//i.test(u) || /^mailto:/i.test(u)) return `<a href="${u}" target="_blank" rel="noopener">${txt}</a>`;
2139
- if (/\.md(#[^)]*)?$/i.test(u)) return `<a href="#" data-doc="${esc(resolveDocPath(ctx, u))}">${txt}</a>`;
2140
- return txt; // a relative non-document target is not clickable here
2141
- }
2142
- // A markdown image. Repo-relative → inline via the guarded /api/asset (R3). Remote / data: / any
2143
- // scheme → NEVER fetched; stays alt-text (R4, the no-beacon invariant). alt/src arrive already
2144
- // HTML-escaped from inline(), so they are safe to interpolate as-is.
2145
- function mdImage(alt, src, ctx) {
2146
- const label = alt || 'image';
2147
- const repoRel = window.PreviewKind ? window.PreviewKind.isRepoRelativeSrc(src) : false;
2148
- if (!repoRel) return `<em>${label}</em>`;
2149
- const rel = resolveDocPath(ctx, src);
2150
- if (!rel) return `<em>${label}</em>`;
2151
- const url = `/api/asset?ws=${encodeURIComponent(state.wsId || '')}&path=${encodeURIComponent(rel)}`;
2152
- return `<img class="doc-img" src="${url}" alt="${label}" loading="lazy" />`;
2153
- }
2154
- // bundle-relative (`/planning/…`, the OKF convention) or relative to the open document
2155
- function resolveDocPath(ctx, u) {
2156
- const clean = String(u).split('#')[0];
2157
- const stack = clean.startsWith('/') ? [] : [...((ctx && ctx.dir) || [])];
2158
- for (const p of clean.split('/')) {
2159
- if (!p || p === '.') continue;
2160
- if (p === '..') stack.pop(); else stack.push(p);
2161
- }
2162
- return stack.join('/');
2163
- }
2164
-
2165
- // ── new session: name it, pick its branch, before it starts ──
2166
- /* R5 — "HERE" MUST HAVE A VISIBLE REFERENT.
2167
- *
2168
- * The composer acts on `state.wsId` and always did. That was legible when it lived underneath a
2169
- * single selected workspace pane; once the sidebar became one tree showing every repository at
2170
- * once, "run an agent here" named nothing on screen. This does not change WHAT it acts on (that
2171
- * would be a behaviour change — PRD assumption A3); it makes the target sayable before the click.
2172
- *
2173
- * Called from three places, because the target changes without the form being reopened: on open, on
2174
- * every workspace selection, and on every workspace push (a rename or a checkout moves the branch). */
2175
- function newTargetText() {
2176
- const w = state.workspaces.find((x) => x.id === state.wsId);
2177
- if (!w) return { text: 'no tree selected — choose one below first', none: true };
2178
- const init = initiativeOf(w);
2179
- const where = init.kind === 'nogit' ? 'a plain folder' : (w.branch ? `on ${w.branch}` : 'no branch checked out');
2180
- return { text: `in ${w.name} · ${where}`, none: false };
2181
- }
2182
- function renderNewTarget() {
2183
- const el = $('newWhere');
2184
- if (!el) return;
2185
- const t = newTargetText();
2186
- el.textContent = t.text;
2187
- el.dataset.none = t.none ? '1' : '0';
2188
- }
2189
- function openNewForm() {
2190
- const w = state.workspaces.find((x) => x.id === state.wsId);
2191
- $('newForm').hidden = false;
2192
- $('newBtn').setAttribute('aria-expanded', 'true');
2193
- renderNewTarget();
2194
- // pre-filled but editable — a default that reads better than "shell" in a list of shells
2195
- const n = mySessions().length + 1;
2196
- $('newName').value = `${w ? w.name : 'session'} ${n}`;
2197
- fillBranchPicker(); // paint immediately from what we already know…
2198
- loadBranch(); // …then re-fetch: branches made in a terminal must show up here
2199
- const inp = $('newName');
2200
- setTimeout(() => { inp.focus(); inp.select(); }, 0);
2201
- }
2202
- function closeNewForm() {
2203
- $('newForm').hidden = true;
2204
- $('newBtn').setAttribute('aria-expanded', 'false');
2205
- }
2206
- // Populate the branch control from state.branch. A workspace with no git hides the whole control
2207
- // and says so once, plainly — a silently missing control reads as "broken", and a PM working in a
2208
- // tenant product repo that has no git should never be asked a branch question at all. The default
2209
- // is always "stay on current": starting a session must never require a git decision.
2210
- function fillBranchPicker() {
2211
- const wrap = $('newBranchWrap'), sel = $('newBranch'), nogit = $('newNoGit');
2212
- const b = state.branch;
2213
- if (!b || !b.ok) {
2214
- wrap.hidden = true;
2215
- // `no-git` is an ordinary folder, not a fault: one muted line, no warning colour. A real git
2216
- // error (unreadable HEAD, git missing) says what it was instead of pretending all is well.
2217
- nogit.hidden = !b || !b.code;
2218
- nogit.textContent = b && b.code === 'git-error'
2219
- ? `git could not read this folder (${b.error}) — the session starts here, with no branch applied`
2220
- : 'no git in this folder — nothing to branch, the session just starts here';
2221
- nogit.classList.toggle('warn', !!b && b.code === 'git-error');
2222
- return;
2223
- }
2224
- nogit.hidden = true;
2225
- wrap.hidden = false;
2226
- const cur = b.detached ? '' : (b.current || '');
2227
- // a branch checked out in another worktree cannot be checked out again — git refuses, so the
2228
- // picker says where it lives rather than letting the PM discover it as an error
2229
- const elsewhere = new Map((b.worktrees || [])
2230
- .filter((w) => w.branch && w.branch !== cur).map((w) => [w.branch, w.dir]));
2231
- const opts = [`<option value="">stay on current${cur ? ' (' + esc(cur) + ')' : ''}</option>`]
2232
- .concat((b.branches || []).map((n) => {
2233
- const where = elsewhere.get(n);
2234
- const tail = n === cur ? ' — current' : (where ? ' — open in ' + shortPath(where) : '');
2235
- return `<option value="${esc(n)}"${n === cur ? ' disabled' : ''}>${esc(n)}${esc(tail)}</option>`;
2236
- }))
2237
- .concat([`<option value="__new__">+ new branch…</option>`]);
2238
- sel.innerHTML = opts.join('');
2239
- sel.value = '';
2240
- // Isolation protects OTHER sessions — so default it on only when there are others to protect.
2241
- // Alone in a workspace a plain checkout disturbs nobody and leaves no folder behind, which is
2242
- // what stops a directory accumulating per branch. Either way the PM can flip it.
2243
- $('newIsolate').checked = mySessions().some((s) => s.state !== 'exited');
2244
- setNewBranchMode(false);
2245
- }
2246
- // last two segments of a path — enough to recognise, short enough for a <option> or a hint line
2247
- function shortPath(p) { const parts = String(p).split('/').filter(Boolean); return parts.slice(-2).join('/'); }
2248
-
2249
- // ── worktree housekeeping (R7): isolation buys "your other sessions keep running" at the price of
2250
- // a folder per branch. That price is only fair if the folders are visible and reclaimable from
2251
- // here — a delete is never a guess, so a tree that is busy, dirty, or holds commits nothing else
2252
- // holds is refused with the reason spelled out, not silently greyed. ──
2253
- async function loadWorktrees() {
2254
- const id = state.wsId;
2255
- let r = { ok: false };
2256
- try { r = await (await fetch('/api/git/worktrees?ws=' + encodeURIComponent(id || ''))).json(); } catch {}
2257
- if (id !== state.wsId) return;
2258
- state.worktrees = r.ok ? (r.worktrees || []) : [];
2259
- renderWorktrees();
2260
- }
2261
- function renderWorktrees() {
2262
- const box = $('wtBox'); if (!box) return;
2263
- const list = (state.worktrees || []).filter((t) => !t.main);
2264
- box.hidden = list.length === 0; // a repo with one working tree has nothing to manage
2265
- if (!list.length) return;
2266
- $('wtSummary').textContent = `⑂ ${list.length} worktree${list.length === 1 ? '' : 's'}`;
2267
- $('wtList').innerHTML = list.map((t) => `
2268
- <div class="wtrow" data-dir="${esc(t.dir)}">
2269
- <span class="wtb" title="${esc(t.dir)}">${esc(t.branch || 'detached')}</span>
2270
- <button type="button" data-rm="${esc(t.dir)}"${t.removable ? '' : ' disabled'}
2271
- title="${esc(t.removable ? 'Remove ' + t.dir : t.reasons.join('; '))}">remove</button>
2272
- ${t.removable ? '' : `<span class="wtwhy">kept — ${esc(t.reasons.join('; '))}</span>`}
2273
- </div>`).join('');
2274
- $('wtList').querySelectorAll('[data-rm]').forEach((b) => { b.onclick = () => removeWorktree(b.dataset.rm); });
2275
- }
2276
- async function removeWorktree(dir) {
2277
- // The server re-checks every hazard; this confirm exists because a folder disappearing from
2278
- // under the PM should be something they said yes to, not a side effect of a mis-click.
2279
- if (!confirm(`Remove the worktree at\n${dir}\n\nThe branch itself is kept — only the folder goes.`)) return;
2280
- let r = { ok: false, error: 'request failed' };
2281
- try {
2282
- r = await (await fetch('/api/git/worktrees/remove', { method: 'POST',
2283
- headers: { 'Content-Type': 'application/json' },
2284
- body: JSON.stringify({ ws: state.wsId, dir }) })).json();
2285
- } catch {}
2286
- flash(r.ok ? (r.pruned ? 'cleared git’s record of a folder that was already gone' : 'worktree removed — the branch is still there')
2287
- : (r.error || 'the worktree was not removed'), r.ok ? 6000 : 12000);
2288
- loadWorktrees();
2289
- }
2290
-
2291
- function setNewBranchMode(on) {
2292
- state.newBranchMode = on;
2293
- $('newBranchName').hidden = !on;
2294
- if (on) setTimeout(() => $('newBranchName').focus(), 0);
2295
- renderBranchHint();
2296
- }
2297
-
2298
- // The one line that decides whether the PM trusts this control. It changes with the choice, because
2299
- // the three choices do genuinely different things:
2300
- // stay → nothing happens to git at all
2301
- // isolate (default)→ the branch gets its own folder; running sessions are untouched
2302
- // switch this repo → every session in this repo moves, and so does every other workspace on it
2303
- function renderBranchHint() {
2304
- const hint = $('newBranchHint'), iso = $('newIsolate'), isoWrap = $('newIsolateWrap');
2305
- const b = state.branch; if (!hint || !b || !b.ok) return;
2306
- const sel = $('newBranch');
2307
- const picked = sel.value === '__new__' ? ($('newBranchName').value.trim() || 'the new branch') : sel.value;
2308
- const cur = b.detached ? 'a detached HEAD' : (b.current || 'the current branch');
2309
-
2310
- if (!sel.value) { // "stay on current" — no git action, so no warning
2311
- isoWrap.hidden = true;
2312
- hint.hidden = false; hint.classList.remove('warn');
2313
- hint.textContent = `runs on ${cur} — nothing is checked out, nothing else moves`;
2314
- return;
2315
- }
2316
- isoWrap.hidden = false;
2317
- hint.hidden = false;
2318
- if (iso.checked) {
2319
- const dir = worktreeHint(picked);
2320
- hint.classList.remove('warn');
2321
- hint.textContent = `${picked} gets its own folder${dir ? ' at ' + dir : ''} — every running session stays on its own branch`;
2322
- } else {
2323
- // How alarming this is depends entirely on who is here. Warning about "every session" when
2324
- // the PM is the only one running teaches them to ignore the warning that matters.
2325
- const others = mySessions().filter((s) => s.state !== 'exited').length;
2326
- const extra = b.subdir ? ` (the repo is ${shortPath(b.repoRoot || '')}, this workspace is only ${b.subdir}/ inside it)` : '';
2327
- hint.classList.toggle('warn', others > 0);
2328
- hint.textContent = others > 0
2329
- ? `switches this whole repo to ${picked} — ${others} running session${others === 1 ? '' : 's'} here move${others === 1 ? 's' : ''} with it${extra}`
2330
- : `checks out ${picked} here — nothing else is running in this workspace, and no new folder is made${extra}`;
2331
- }
2332
- }
2333
- // Mirror of the server's worktreePath, for the hint only — the server is the authority and reports
2334
- // the real path back in its notice. Kept in sync deliberately: a PM deciding where work lands
2335
- // should see the answer before committing to it, not after.
2336
- function worktreeHint(branch) {
2337
- const b = state.branch;
2338
- if (!b || !b.ok || !b.repoRoot || !branch || branch === 'the new branch') return '';
2339
- const parts = String(b.repoRoot).split('/').filter(Boolean);
2340
- const name = parts[parts.length - 1] || 'repo';
2341
- return `../${name}-worktrees/${branch.replace(/\//g, '-')}`;
2342
- }
2343
- $('newBtn').onclick = () => ($('newForm').hidden ? openNewForm() : closeNewForm());
2344
- $('newCancel').onclick = closeNewForm;
2345
- $('newBranch').onchange = (e) => setNewBranchMode(e.target.value === '__new__');
2346
- $('newIsolate').onchange = renderBranchHint;
2347
- $('newBranchName').oninput = renderBranchHint; // the hint names the folder as the PM types it
2348
- $('newAgents').querySelectorAll('button').forEach((b) => b.onclick = () => {
2349
- state.newAgent = b.dataset.agent;
2350
- $('newAgents').querySelectorAll('button').forEach((x) => x.setAttribute('aria-checked', String(x === b)));
2351
- });
2352
- $('newForm').addEventListener('submit', (e) => {
2353
- e.preventDefault();
2354
- const name = $('newName').value.trim();
2355
- const msg = { t: 'new', agent: state.newAgent, name: name || state.newAgent, ws: state.wsId };
2356
- // branch: either create a new one, or check out an existing pick. Empty = stay put (no git call
2357
- // at all) — and that is what a hidden control means too, so a non-git workspace can never have a
2358
- // stale pick from a previous repo smuggled onto it.
2359
- const sel = $('newBranch');
2360
- if (!$('newBranchWrap').hidden && sel) {
2361
- if (sel.value === '__new__') {
2362
- const nb = $('newBranchName').value.trim();
2363
- if (!nb) { flash('type a name for the new branch, or pick "stay on current"'); return; }
2364
- msg.branch = nb; msg.createBranch = true;
2365
- } else if (sel.value) {
2366
- msg.branch = sel.value;
2367
- }
2368
- // isolate → the branch gets its own worktree and running sessions are left alone. Only
2369
- // meaningful when a branch was actually chosen.
2370
- if (msg.branch) msg.isolate = !!$('newIsolate').checked;
2371
- }
2372
- send(msg);
2373
- closeNewForm();
2374
- });
2375
-
2376
- // ── scratchpad (R4): PER-TAB staging text; copy or send-to-terminal without auto-running. The pad
2377
- // belongs to the session it is staged against, not to the workspace: moving between tabs brings
2378
- // each tab's own text, and typing in one never overwrites another's. ──
2379
- // The debounce handle and the not-yet-written text live on `state` (like layoutSaveT): syncActive()
2380
- // can run before this section of the module has evaluated, and module-level `let`s would be in
2381
- // their temporal dead zone at that point — which blanked the whole app on first paint.
2382
- function setScratchStatus(s) { const el = $('scratchStatus'); if (el) el.textContent = s; }
2383
- // Which pad is on screen: the active tab's, or — when the workspace has no tab at all — the
2384
- // workspace's own. Encoded as one key so a switch is a simple inequality.
2385
- function scratchKey() { return state.active ? 'session:' + state.active : 'ws:' + (state.wsId || ''); }
2386
- function renderScratchWho() {
2387
- const el = $('scratchWho'); if (!el) return;
2388
- const s = state.sessions.find((x) => x.id === state.active);
2389
- el.textContent = s ? s.name : '';
2390
- el.title = s ? `staged for “${s.name}” — each tab keeps its own scratchpad` : '';
2391
- }
2392
- async function loadScratchpad() {
2393
- const key = scratchKey();
2394
- state.scratchKey = key;
2395
- const q = state.active
2396
- ? 'session=' + encodeURIComponent(state.active)
2397
- : 'ws=' + encodeURIComponent(state.wsId || '');
2398
- let text = '';
2399
- try { const r = await fetch('/api/scratchpad?' + q); const j = await r.json(); if (j && j.ok) text = j.text || ''; } catch {}
2400
- if (key !== scratchKey()) return; // a slower fetch for a tab we've since left must not paint
2401
- $('scratchText').value = text; setScratchStatus('');
2402
- renderScratchWho();
2403
- }
2404
- // Called on every active-session change. It flushes whatever the PM had just typed into the tab
2405
- // they are LEAVING before loading the one they are arriving at — without this, the debounce could
2406
- // still be holding text that would land after the new tab's pad had already painted.
2407
- function syncScratchpad() {
2408
- const key = scratchKey();
2409
- if (key === state.scratchKey) { renderScratchWho(); return; }
2410
- flushScratchpad();
2411
- loadScratchpad();
2412
- }
2413
- function postScratchpad(p) {
2414
- return fetch('/api/scratchpad', { method: 'POST', headers: { 'Content-Type': 'application/json' },
2415
- body: JSON.stringify(p.session ? { session: p.session, text: p.text } : { ws: p.ws, text: p.text }) });
2416
- }
2417
- function flushScratchpad() {
2418
- clearTimeout(state.scratchT);
2419
- const p = state.scratchPending;
2420
- state.scratchPending = null;
2421
- if (!p) return;
2422
- postScratchpad(p).catch(() => {}); // fire-and-forget: the switch must not wait on the network
2423
- }
2424
- function saveScratchpad() {
2425
- // The target is captured NOW, with the text — a save that lands after a tab switch must still
2426
- // write to the tab it was typed in.
2427
- state.scratchPending = { session: state.active, ws: state.wsId, text: $('scratchText').value };
2428
- clearTimeout(state.scratchT);
2429
- state.scratchT = setTimeout(async () => {
2430
- const p = state.scratchPending;
2431
- state.scratchPending = null;
2432
- if (!p) return;
2433
- setScratchStatus('saving…');
2434
- try {
2435
- const j = await (await postScratchpad(p)).json();
2436
- if (p.session && p.session !== state.active) return; // a pad we've since left: saved, quietly
2437
- setScratchStatus(j && j.ok ? 'saved' : 'save failed'); // a broken save is never a silent green
2438
- } catch { setScratchStatus('save failed'); }
2439
- }, 500);
2440
- }
2441
- // Open/close the drawer. It lives in the flex column below the terminal, so toggling it resizes
2442
- // termhost — the existing ResizeObserver refits the terminal; nothing is ever covered.
2443
- function openScratch(on) {
2444
- const el = $('scratch');
2445
- const open = on == null ? el.dataset.open !== 'true' : !!on;
2446
- el.dataset.open = String(open);
2447
- $('scratchToggle').setAttribute('aria-expanded', String(open));
2448
- $('scratchBtn').setAttribute('aria-pressed', String(open));
2449
- store.set('scratch-open', open ? '1' : '0');
2450
- if (open) setTimeout(() => $('scratchText').focus(), 0);
2451
- }
2452
- // send-to-terminal writes the text into the active session's PTY WITHOUT a trailing newline — the
2453
- // PM reviews and presses enter. Disabled when there is no live session to receive it.
2454
- function activeLiveSession() {
2455
- const s = state.sessions.find((x) => x.id === state.active);
2456
- return s && s.state !== 'exited' ? s : null;
2457
- }
2458
- function refreshScratchSend() {
2459
- const btn = $('scratchSend'); if (!btn) return;
2460
- const live = !!activeLiveSession();
2461
- btn.disabled = !live;
2462
- btn.title = live ? 'Paste into the active terminal (does not press enter)' : 'no live session to send to';
2463
- }
2464
- $('scratchToggle').onclick = () => openScratch();
2465
- $('scratchBtn').onclick = () => openScratch();
2466
- $('scratchText').addEventListener('input', saveScratchpad);
2467
- $('scratchCopy').onclick = async () => {
2468
- const text = $('scratchText').value;
2469
- try { await navigator.clipboard.writeText(text); setScratchStatus('copied'); }
2470
- catch { $('scratchText').select(); setScratchStatus('press ⌘C to copy'); }
2471
- };
2472
- $('scratchSend').onclick = () => {
2473
- const s = activeLiveSession();
2474
- if (!s) { flash('no live session to send to — start or select one'); return; }
2475
- const text = $('scratchText').value;
2476
- if (!text) return;
2477
- send({ t: 'input', id: s.id, data: text }); // no trailing newline: nothing auto-runs
2478
- setScratchStatus('sent → terminal');
2479
- };
2480
- { const open = store.get('scratch-open') === '1'; if (open) openScratch(true); }
2481
-
2482
- // ── add a workspace: pick a folder (the GUI twin of running `pmos` in that repo) ──
2483
- function closeAddWs() {
2484
- $('addWsForm').hidden = true; $('addWsBtn').setAttribute('aria-expanded', 'false'); $('addWsPath').value = '';
2485
- }
2486
- $('addWsBtn').onclick = () => {
2487
- const f = $('addWsForm');
2488
- f.hidden = !f.hidden;
2489
- $('addWsBtn').setAttribute('aria-expanded', String(!f.hidden));
2490
- };
2491
- $('addWsCancel').onclick = closeAddWs;
2492
- $('addWsForm').addEventListener('submit', (e) => { e.preventDefault(); addWorkspace($('addWsPath').value); });
2493
- // The browser's own directory picker cannot yield an absolute path, so the LOCAL server opens
2494
- // the machine's native chooser. Where there is none (a headless Linux box), the in-app browser
2495
- // stands in — the PM is never left with only a text field (R10).
2496
- $('pickFolderBtn').onclick = async () => {
2497
- const btn = $('pickFolderBtn'), label = btn.textContent;
2498
- btn.disabled = true; btn.textContent = 'choosing…';
2499
- let j = null, status = 0;
2500
- try {
2501
- const r = await fetch('/api/pick-folder', { method: 'POST', headers: { 'Content-Type': 'application/json' },
2502
- body: JSON.stringify({ start: currentRoot() }) });
2503
- status = r.status;
2504
- j = await r.json();
2505
- } catch {}
2506
- btn.disabled = false; btn.textContent = label;
2507
- if (j && j.ok && j.root) { addWorkspace(j.root); return; }
2508
- if (j && j.cancelled) return;
2509
- if (j && j.busy) { flash('a folder chooser is already open'); return; }
2510
- // 404 = an OLDER server without these routes is still running — the browser reload alone
2511
- // didn't pick up the new code. Say so, instead of falling into a folder browser that 404s too.
2512
- if (status === 404) { flash('restart the server to enable the folder picker: pmos stop && pmos'); return; }
2513
- openFsPicker(currentRoot()); // no native chooser on this system, or the call failed
2514
- };
2515
- function currentRoot() {
2516
- const w = state.workspaces.find((x) => x.id === state.wsId);
2517
- return w ? w.root : '';
2518
- }
2519
-
2520
- // ── in-app folder browser (the fallback chooser) ──
2521
- async function openFsPicker(start) {
2522
- $('fsPicker').style.display = 'grid';
2523
- await loadFs(start);
2524
- }
2525
- function closeFsPicker() { $('fsPicker').style.display = 'none'; }
2526
- async function loadFs(dir) {
2527
- let j = null, status = 0;
2528
- try { const r = await fetch('/api/fs/list?path=' + encodeURIComponent(dir || '')); status = r.status; j = await r.json(); } catch {}
2529
- if (!j || !j.ok) {
2530
- $('fsList').innerHTML = status === 404
2531
- ? `<div class="empty">This folder browser needs the newer server. Restart it: <b>pmos stop &amp;&amp; pmos</b>.</div>`
2532
- : `<div class="empty">Could not read <b>${esc(dir || 'that folder')}</b>.</div>`;
2533
- return;
2534
- }
2535
- state.fsDir = j.path;
2536
- // the crumb truncates from the LEFT (rtl) — <bdi> keeps the path's own characters in order,
2537
- // so an absolute path does not come back with its leading slash on the wrong end
2538
- $('fsCrumb').innerHTML = `<bdi>${esc(j.path)}</bdi>`;
2539
- $('fsUp').disabled = !j.parent;
2540
- $('fsList').innerHTML = j.entries.length
2541
- ? j.entries.map((e) => `<button class="pitem" data-dir="${esc(e.path)}">
2542
- <span class="g" aria-hidden="true">▸</span><span>${esc(e.name)}</span>
2543
- ${e.isRepo ? '<span class="badge">git</span>' : ''}</button>`).join('')
2544
- : `<div class="empty">${j.denied ? 'Not readable.' : 'No sub-folders here — “use this folder” to add it.'}</div>`;
2545
- $('fsList').querySelectorAll('[data-dir]').forEach((b) => b.onclick = () => loadFs(b.dataset.dir));
2546
- }
2547
- $('fsUp').onclick = () => { const p = state.fsDir && state.fsDir.replace(/[/\\][^/\\]+$/, ''); loadFs(p || '/'); };
2548
- $('fsUse').onclick = async () => { if (await addWorkspace(state.fsDir)) closeFsPicker(); };
2549
- $('fsClose').onclick = closeFsPicker;
2550
- $('fsPicker').addEventListener('click', (e) => { if (e.target === $('fsPicker')) closeFsPicker(); });
2551
-
2552
- // ── library lens + filter ──
2553
- const FIND_PLACEHOLDER = { history: 'search past sessions…', scheduled: 'filter the library…' };
2554
- $('libFind').addEventListener('input', (e) => { state.filter = e.target.value; renderLibrary(); });
2555
- $('libRefresh').onclick = () => {
2556
- const b = $('libRefresh'); b.classList.add('spin');
2557
- refreshLibrary(); setTimeout(() => b.classList.remove('spin'), 500);
2558
- };
2559
-
2560
- // ── chrome ──
2561
- $('themeBtn').onclick = () => {
2562
- const r = document.documentElement;
2563
- const next = r.dataset.theme === 'dark' ? 'light' : 'dark';
2564
- r.dataset.theme = next;
2565
- store.set('theme', next); // survives reloads (R7)
2566
- for (const [id, t] of state.terms) { try { t.term.options.theme = xtheme(); } catch {} fitOne(id); }
2567
- };
2568
- $('closeDoc').onclick = closeDoc;
2569
-
2570
- // ── the document editor (workspace-doc-editor) ────────────────────────────────────────────────
2571
- // The reader gains an edit mode. Nothing an agent produced can reach this path: the PM opens a
2572
- // document, changes it, and saves. Alignment happens AFTER, from the change — never before it.
2573
- //
2574
- // `base` is the text the editor loaded. It travels with the save so the server can refuse if the
2575
- // file moved on disk in the meantime, rather than quietly discarding whoever changed it.
2576
- const ed = { on: false, path: null, base: '', dirty: false };
2577
-
2578
- function edControls() {
2579
- const isMd = !!(state.docPath && /\.(md|markdown|mdown|mkd|mdx|skill)$/i.test(state.docPath));
2580
- $('docEdit').hidden = !isMd || ed.on;
2581
- $('docHistory').hidden = !isMd || ed.on;
2582
- $('docSave').hidden = !ed.on;
2583
- $('docCancel').hidden = !ed.on;
2584
- }
2585
-
2586
- async function edOpen() {
2587
- const pth = state.docPath;
2588
- if (!pth) return;
2589
- let text = '';
2590
- try {
2591
- const r = await fetch(`/api/doc?ws=${encodeURIComponent(state.wsId || '')}&path=${encodeURIComponent(pth)}`);
2592
- if (!r.ok) { flash('could not open this document for editing'); return; }
2593
- text = await r.text();
2594
- } catch { flash('could not reach the workspace server'); return; }
2595
-
2596
- ed.on = true; ed.path = pth; ed.base = text; ed.dirty = false;
2597
- $('docBody').innerHTML = `<div class="ed-wrap">
2598
- <div class="ed-split" data-diff="off">
2599
- <textarea class="ed-ta" id="edTa" spellcheck="false"></textarea>
2600
- <div class="ed-diff" id="edDiff"></div>
2601
- </div>
2602
- <div class="ed-bar">
2603
- <span class="n" id="edStat">no changes yet</span>
2604
- <input id="edNote" placeholder="why (optional — it is what makes the log worth reading)" autocomplete="off" />
2605
- </div>
2606
- </div>`;
2607
- $('edTa').value = text;
2608
- $('edTa').oninput = edRender;
2609
- $('edTa').focus();
2610
- edControls();
2611
- refit();
2612
- }
2613
-
2614
- // The diff is a VIEW of the edit in progress, computed from the same module the server saves
2615
- // through — so what is shown and what is written cannot disagree. It is never stored (R4: git holds
2616
- // the repository's content; the change log holds what changed and why).
2617
- function edRender() {
2618
- const next = $('edTa').value;
2619
- ed.dirty = next !== ed.base;
2620
- const r = window.ApplyChange.changedRegion(ed.base, next);
2621
- const split = $('docBody').querySelector('.ed-split');
2622
- split.dataset.diff = ed.dirty ? 'on' : 'off';
2623
- $('edStat').innerHTML = ed.dirty
2624
- ? `<b>+${r.added}</b> <i>−${r.removed}</i> · from line ${r.at_line} · <span class="dirty">unsaved</span>`
2625
- : 'no changes yet';
2626
- if (!ed.dirty) { $('edDiff').innerHTML = ''; return; }
2627
- const rows = [];
2628
- if (r.before) r.before.split('\n').forEach((l) => rows.push(`<div class="ed-l del"><span class="g"></span><span class="t">${esc(l) || '&nbsp;'}</span></div>`));
2629
- if (r.after) r.after.split('\n').forEach((l) => rows.push(`<div class="ed-l add"><span class="g"></span><span class="t">${esc(l) || '&nbsp;'}</span></div>`));
2630
- $('edDiff').innerHTML = rows.join('') || '<div class="ed-none">whitespace only</div>';
2631
- }
2632
-
2633
- async function edClose(reload) {
2634
- ed.on = false; ed.path = null; ed.base = ''; ed.dirty = false;
2635
- edControls();
2636
- // awaited: the reload replaces docBody, so anything that renders into it afterwards (the
2637
- // alignment offer) must not race the replacement.
2638
- if (reload && state.docPath) await openDoc(state.docPath);
2639
- }
2640
-
2641
- async function edSave() {
2642
- if (!ed.on) return;
2643
- const text = $('edTa').value;
2644
- if (text === ed.base) { flash('nothing changed'); return; }
2645
- const btn = $('docSave'); btn.disabled = true;
2646
- let out = {};
2647
- try {
2648
- out = await (await fetch('/api/doc/save', { method: 'POST', headers: { 'Content-Type': 'application/json' },
2649
- body: JSON.stringify({ ws: state.wsId, path: ed.path, base: ed.base, text, note: $('edNote').value }) })).json();
2650
- } catch { out = { ok: false, error: 'could not reach the workspace server' }; }
2651
- btn.disabled = false;
2652
- if (!out.ok) {
2653
- // Refused — say so where the PM is looking, and DO NOT close: their text is still in the box.
2654
- const box = $('docBody').querySelector('.ed-wrap');
2655
- let e = box.querySelector('.ed-err');
2656
- if (!e) { e = document.createElement('div'); e.className = 'ed-err'; box.insertBefore(e, box.querySelector('.ed-bar')); }
2657
- e.textContent = out.error === 'stale'
2658
- ? (out.detail || 'this document changed on disk since you opened it') + ' — your text is still here; copy it before reopening.'
2659
- : (out.detail || out.error || 'the save was refused');
2660
- return;
2661
- }
2662
- flash(out.warning ? 'saved — but the change could not be logged' : `saved · +${out.change.added} −${out.change.removed}`);
2663
- const saved = ed.path;
2664
- await edClose(true);
2665
- alOffer(saved); // R7: offered, never blocking — the save is already done
2666
- }
2667
-
2668
-
2669
- // ── alignment (workspace-doc-editor R5–R7) ────────────────────────────────────────────────────
2670
- // Strictly downstream of a save. Nothing here runs until the PM has changed something, and what it
2671
- // looks at is scoped by that change — not the repository. It is offered, never blocking: the save
2672
- // completed before any of this, and ignoring it costs nothing.
2673
- async function alOffer(pth) {
2674
- if (!pth) return;
2675
- let out = {};
2676
- try { out = await (await fetch(`/api/align/candidates?ws=${encodeURIComponent(state.wsId || '')}&path=${encodeURIComponent(pth)}`)).json(); }
2677
- catch { return; }
2678
- if (!out.ok || out.empty || !out.candidates || !out.candidates.length) return;
2679
- state.alCand = out;
2680
- const n = out.candidates.length;
2681
- const bar = document.createElement('div');
2682
- bar.className = 'al-offer';
2683
- bar.innerHTML = `<span>${n} other document${n === 1 ? '' : 's'} may now be out of step with that change.</span>
2684
- <button data-al-see>see them</button><button class="q" data-al-no>not now</button>`;
2685
- const body = $('docBody');
2686
- body.prepend(bar);
2687
- bar.querySelector('[data-al-no]').onclick = () => bar.remove();
2688
- bar.querySelector('[data-al-see]').onclick = () => alShow();
2689
- }
2690
-
2691
- // The candidate list, each with the concrete reason it qualified. The PM chooses which to hand to
2692
- // an agent — so even the drafting step is something they asked for, not something that happened.
2693
- function alShow() {
2694
- const c = state.alCand; if (!c) return;
2695
- $('docBody').innerHTML = `<div class="al">
2696
- <div class="al-lede">Your change to <span class="mono">${esc(c.change.file)}</span> may leave these out of step.</div>
2697
- <div class="al-sub">Each one has a concrete tie to what you changed — a link, a shared initiative, or text it quotes. Considered ${c.considered} documents.</div>
2698
- ${c.candidates.map((x, i) => `<label class="al-c">
2699
- <input type="checkbox" data-al-pick="${i}" checked />
2700
- <span><span class="al-p mono">${esc(x.path)}</span>
2701
- <span class="al-why">${x.reasons.map((r) => esc(r)).join(' · ')}</span></span>
2702
- </label>`).join('')}
2703
- <div class="al-acts">
2704
- <button data-al-go>Draft the edits</button>
2705
- <button class="q" data-al-close>Close</button>
2706
- <span class="al-note">An agent reads only the documents you tick, and only against this change.</span>
2707
- </div></div>`;
2708
- $('docBody').querySelector('[data-al-close]').onclick = () => openDoc(c.change.file);
2709
- $('docBody').querySelector('[data-al-go]').onclick = async () => {
2710
- const targets = [...$('docBody').querySelectorAll('[data-al-pick]')]
2711
- .filter((b) => b.checked).map((b) => c.candidates[Number(b.dataset.alPick)].path);
2712
- if (!targets.length) { flash('nothing ticked'); return; }
2713
- let out = {};
2714
- try {
2715
- out = await (await fetch('/api/align/start', { method: 'POST', headers: { 'Content-Type': 'application/json' },
2716
- body: JSON.stringify({ ws: state.wsId, path: c.change.file, change: c.change, targets }) })).json();
2717
- } catch { out = { ok: false, error: 'could not reach the workspace server' }; }
2718
- if (!out.ok) { flash(out.error || 'could not start'); return; }
2719
- flash(`drafting against ${targets.length} document${targets.length === 1 ? '' : 's'} — watch the session, then open drafts`);
2720
- if (out.sessionId) selectSession(out.sessionId);
2721
- };
2722
- }
2723
-
2724
- // The drafts an agent produced, each an applyable diff. Apply uses the same anchored guard as every
2725
- // other write here: the anchor must appear exactly once or the write is refused.
2726
- async function alDrafts() {
2727
- let out = {};
2728
- try { out = await (await fetch(`/api/align/drafts?ws=${encodeURIComponent(state.wsId || '')}`)).json(); }
2729
- catch { out = { ok: false, error: 'could not reach the workspace server' }; }
2730
- showRail(); $('paneDoc').hidden = false; foldPane('doc', true);
2731
- $('crumb').innerHTML = '<bdi>alignment drafts</bdi>';
2732
- if (!out.ok) { $('docBody').innerHTML = `<div class="al"><div class="al-lede">${esc(out.error || 'could not read the drafts')}</div></div>`; return; }
2733
- if (out.empty || !out.drafts.length) {
2734
- $('docBody').innerHTML = `<div class="al"><div class="al-lede">No drafts yet.</div>
2735
- <div class="al-sub">Edit a document, then ask for the alignment when it offers.</div></div>`;
2736
- return;
2737
- }
2738
- state.alDrafts = out;
2739
- $('docBody').innerHTML = `<div class="al">
2740
- <div class="al-lede">${out.drafts.length} edit${out.drafts.length === 1 ? '' : 's'} to bring other documents back in line.</div>
2741
- <div class="al-sub">Drafted against your change to <span class="mono">${esc(out.source || '')}</span>. Nothing is written until you apply it.</div>
2742
- ${out.drafts.map((d, i) => `<div class="al-d" data-al-d="${i}">
2743
- <div class="al-p mono">${esc(d.file)}</div>
2744
- <div class="al-why">${esc(d.why || '')}</div>
2745
- <div class="al-code">
2746
- ${String(d.before || '').split('\n').map((l) => `<div class="ed-l del"><span class="g"></span><span class="t">${esc(l) || '&nbsp;'}</span></div>`).join('')}
2747
- ${String(d.after || '').split('\n').map((l) => `<div class="ed-l add"><span class="g"></span><span class="t">${esc(l) || '&nbsp;'}</span></div>`).join('')}
2748
- </div>
2749
- <div class="al-acts"><button data-al-apply="${i}">Apply</button>
2750
- <button class="q" data-al-skip="${i}">Skip</button></div>
2751
- </div>`).join('')}</div>`;
2752
- $('docBody').querySelectorAll('[data-al-skip]').forEach((b) => b.onclick = () => {
2753
- $('docBody').querySelector(`[data-al-d="${b.dataset.alSkip}"]`).classList.add('done');
2754
- });
2755
- $('docBody').querySelectorAll('[data-al-apply]').forEach((b) => b.onclick = async () => {
2756
- const d = state.alDrafts.drafts[Number(b.dataset.alApply)];
2757
- b.disabled = true;
2758
- let r = {};
2759
- try {
2760
- r = await (await fetch('/api/align/apply', { method: 'POST', headers: { 'Content-Type': 'application/json' },
2761
- body: JSON.stringify({ ws: state.wsId, file: d.file, before: d.before, after: d.after, why: d.why, source: state.alDrafts.source }) })).json();
2762
- } catch { r = { ok: false, error: 'could not reach the workspace server' }; }
2763
- b.disabled = false;
2764
- const card = $('docBody').querySelector(`[data-al-d="${b.dataset.alApply}"]`);
2765
- if (!r.ok) {
2766
- let e = card.querySelector('.al-err');
2767
- if (!e) { e = document.createElement('div'); e.className = 'al-err'; card.appendChild(e); }
2768
- e.textContent = r.error === 'stale' ? 'that text is no longer in the file — the draft is out of date'
2769
- : r.error === 'ambiguous' ? (r.detail || 'that text appears more than once — refusing to guess')
2770
- : (r.detail || r.error || 'refused');
2771
- return;
2772
- }
2773
- card.classList.add('done');
2774
- flash('aligned ' + d.file.split('/').pop());
2775
- });
2776
- refit();
2777
- }
2778
-
2779
- $('docEdit').onclick = edOpen;
2780
- $('docCancel').onclick = () => edClose(true); // closing IS the whole of "cancel writes nothing"
2781
- $('docSave').onclick = edSave;
2782
- $('docHistory').onclick = async () => {
2783
- const pth = state.docPath; if (!pth) return;
2784
- let out = {};
2785
- try { out = await (await fetch(`/api/doc/changes?ws=${encodeURIComponent(state.wsId || '')}&path=${encodeURIComponent(pth)}`)).json(); }
2786
- catch { out = { ok: false, error: 'could not reach the workspace server' }; }
2787
- if (!out.ok) { flash(out.error || 'could not read the change log'); return; }
2788
- const rows = (out.changes || []).map((c) => `<div class="hist-row">
2789
- <div class="hist-when"><b>+${c.added}</b> <i>−${c.removed}</i> · line ${c.at_line} · ${esc((c.at || '').replace('T', ' ').replace(/\..*$/, ''))}</div>
2790
- ${c.note ? `<div class="hist-note">${esc(c.note)}</div>` : ''}
2791
- </div>`).join('');
2792
- $('docBody').innerHTML = `<div class="hist"><h3>Your changes to this document</h3>
2793
- <div class="sub">Recorded here as you saved them. The document's own history is <span class="mono">git log</span>; this is what changed and why.</div>
2794
- ${rows || '<div class="hist-none">You have not changed this document here yet.</div>'}</div>`;
2795
- refit();
2796
- };
2797
- document.addEventListener('keydown', (e) => {
2798
- if (ed.on && (e.metaKey || e.ctrlKey) && e.key === 's') { e.preventDefault(); edSave(); }
2799
- });
2800
-
2801
-
2802
- // ── ⌘K / Ctrl-K jump palette (client-side: workspaces + sessions + initiatives + docs) ──
2803
- // ── on-demand surfaces (Plan · Deliver · Measure · Report) ─────────────────────────────────────
2804
- // Summoned from ⌘K, rendered in the reader, derived through the server's local state adapter and
2805
- // validated by the deliverable contract. Never resident: closing the reader (✕/Esc) dismisses it.
2806
- const SURFACES = [
2807
- { name: 'initiatives', label: 'initiatives', phase: 'plan' },
2808
- { name: 'roadmap', label: 'roadmap', phase: 'plan' },
2809
- { name: 'okr', label: 'okr + levers', phase: 'measure' },
2810
- { name: 'report', label: 'weekly report', phase: 'report' },
2811
- // Read-only console surfaces (D64 Track B): file-mode state, honest-by-construction (a broken
2812
- // read renders RED, never a green empty). Sourced from /api/console, not the deliverable contract.
2813
- { name: 'workflow', label: 'workflow', phase: 'plan', console: true },
2814
- { name: 'needs', label: 'needs you', phase: 'deliver', console: true },
2815
- { name: 'radar', label: 'radar', phase: 'measure', console: true },
2816
- { name: 'metrics', label: 'metrics', phase: 'measure', console: true },
2817
- ];
2818
- const SURFACE_BY_NAME = {}; SURFACES.forEach((v) => { SURFACE_BY_NAME[v.name] = v; });
2819
- const SF_STAT = { planning: ['○', 'planning'], building: ['◐', 'building'], blocked: ['◆', 'blocked'], landed: ['▲', 'landed'] };
2820
-
2821
- async function openSurface(name) {
2822
- showRail();
2823
- $('paneDoc').hidden = false; $('railSplit').hidden = false; foldPane('doc', true);
2824
- state.surface = name; state.docPath = null;
2825
- ed.on = false; edControls();
2826
- document.querySelectorAll('.doc').forEach((b) => b.classList.remove('on'));
2827
- $('crumb').innerHTML = `<bdi>surface · ${esc(name)}</bdi>`;
2828
- $('docBody').scrollTop = 0;
2829
- // The skeleton IS the loading state — visually distinct from a calm empty and a red error, so a
2830
- // slow read never looks like "nothing awaits" (D64 B2).
2831
- $('docBody').innerHTML = `<div class="sf-wrap">${sfHead(name)}<div class="sf-body"><div class="sf-skel"><i></i><i></i><i></i></div></div></div>`;
2832
- wireSurface();
2833
- const meta = SURFACE_BY_NAME[name] || {};
2834
- const endpoint = meta.console
2835
- ? `/api/console?name=${encodeURIComponent(name)}&ws=${encodeURIComponent(state.wsId || '')}`
2836
- : `/api/surface?name=${encodeURIComponent(name)}&ws=${encodeURIComponent(state.wsId || '')}`;
2837
- try {
2838
- const r = await fetch(endpoint);
2839
- const payload = await r.json();
2840
- const body = meta.console ? consoleBody(name, payload) : surfaceBody(name, payload);
2841
- const okFlag = payload.ok !== false;
2842
- $('docBody').innerHTML = `<div class="sf-wrap">${sfHead(name, okFlag)}<div class="sf-body">${body}</div></div>`;
2843
- } catch {
2844
- // A failed FETCH is a broken read — render RED, never a calm "nothing here" (honesty by construction).
2845
- $('docBody').innerHTML = `<div class="sf-wrap">${sfHead(name, false)}<div class="sf-body">${csError(name, 'could not reach the workspace server to build this surface')}</div></div>`;
2846
- }
2847
- wireSurface();
2848
- refit();
2849
- }
2850
- function sfHead(active, ok) {
2851
- const meta = SURFACE_BY_NAME[active] || {};
2852
- let chip = '';
2853
- if (ok !== false) {
2854
- chip = meta.console
2855
- ? `<span class="sf-chip src" title="derived live from local file-mode state (D64)">file-mode</span>`
2856
- : `<span class="sf-chip" title="the deliverable contract passed before this rendered">contract&nbsp;✓</span>`;
2857
- }
2858
- return `<div class="sf-head">
2859
- <span class="sf-views" role="tablist" aria-label="Surface">${SURFACES.map((v) =>
2860
- `<button role="tab" data-surface="${v.name}" data-phase="${v.phase}" aria-selected="${v.name === active}" class="${v.name === active ? 'on' : ''}">${esc(v.label)}</button>`).join('')}</span>
2861
- ${chip}</div>`;
2862
- }
2863
- function wireSurface() {
2864
- // the workflow surface's initiative ids open the DOSSIER (R9, route 2) — a read, not a checkout
2865
- document.querySelectorAll('#docBody .wf-id[data-init]').forEach((b) => b.onclick = (e) => {
2866
- e.preventDefault(); openInitiative(b.dataset.init, state.wsId);
2867
- });
2868
- document.querySelectorAll('[data-surface]').forEach((b) => b.onclick = () => openSurface(b.dataset.surface));
2869
- document.querySelectorAll('[data-open-surface]').forEach((b) => b.onclick = () => openSurface(b.dataset.openSurface));
2870
- $('docBody').querySelectorAll('.sf-art[data-doc],.wf-id[data-doc]').forEach((b) => b.onclick = () => openDoc(b.dataset.doc));
2871
- }
2872
- function surfaceBody(name, payload) {
2873
- if (!payload || !payload.ok) return sfCantProduce(name, payload && payload.contract);
2874
- const s = payload.surface || {};
2875
- if (name === 'initiatives') return sfInitiatives(s);
2876
- if (name === 'roadmap') return sfRoadmap(s);
2877
- if (name === 'okr') return sfOkr(s);
2878
- if (name === 'report') return sfReport(s);
2879
- return '';
2880
- }
2881
- function sfCantProduce(name, contract) {
2882
- const miss = ((contract && contract.missing) || []).join(', ') || 'the required data';
2883
- return `<div class="sf-fail"><div class="mk" aria-hidden="true">◇</div>
2884
- <h3>Can’t produce the ${esc(name)} surface here</h3>
2885
- <p>This workspace has no state to build it from — so there’s nothing to show, and the surface won’t invent one.</p>
2886
- <p class="miss">contract: missing ${esc(miss)}</p>
2887
- <p class="hint">Open a repo with a <code>planning/</code> tree, or start work with <code>create_initiative</code>.</p></div>`;
2888
- }
2889
- function sfInitiatives(s) {
2890
- const needs = (s.needs && s.needs.length)
2891
- ? `<div class="sf-needs"><div class="nh"><span class="dot" aria-hidden="true"></span><b>${s.needs.length} need${s.needs.length > 1 ? '' : 's'} you</b><span class="sub">acted on here, not buried</span></div>
2892
- ${s.needs.map((n) => `<div class="need"><span class="ph" aria-hidden="true"></span><div class="txt"><div class="t">${esc(n.t)}</div><div class="m">${esc(n.kind)}${n.initiative_id ? ' · ' + esc(n.initiative_id) : ''}</div></div>
2893
- <button class="go" data-open-surface="report">▸ report</button></div>`).join('')}</div>`
2894
- : `<div class="sf-needs calm"><div class="nh"><span class="dot" aria-hidden="true"></span><b>Nothing needs you</b><span class="sub">${s.pulse.total} initiatives</span></div>
2895
- <div class="calm-body">No blocked work, no pending acceptance. The list is here when you want it — the workspace won’t nag.</div></div>`;
2896
- const p = s.pulse || { total: 0 };
2897
- const dist = [['plan', 'planning', p.planning], ['deliver', 'building', p.building], ['deliver', 'blocked', p.blocked], ['report', 'landed', p.landed]];
2898
- const pulse = `<div class="sf-pulse"><span class="n"><b>${p.total}</b> initiatives</span>
2899
- <span class="sf-dist" aria-hidden="true">${dist.map(([ph, k, c]) => c ? `<i data-phase="${ph}" style="width:${c / (p.total || 1) * 100}%"></i>` : '').join('')}</span>
2900
- <span class="sf-legend">${['planning', 'building', 'blocked', 'landed'].map((k) => `<span>${SF_STAT[k][0]} ${k} ${p[k] || 0}</span>`).join('')}</span></div>`;
2901
- const order = [['needs you', (i) => i.status === 'blocked'], ['in flight', (i) => i.status === 'building'],
2902
- ['planned', (i) => i.status === 'planning'], ['landed', (i) => i.status === 'landed']];
2903
- const list = order.map(([lab, f]) => {
2904
- const items = (s.initiatives || []).filter(f); if (!items.length) return '';
2905
- return `<div class="sf-grp">${lab} · ${items.length}</div><div class="sf-list">${items.map(sfRow).join('')}</div>`;
2906
- }).join('');
2907
- return needs + pulse + list;
2908
- }
2909
- function sfRow(i) {
2910
- const [gl, glab] = SF_STAT[i.status] || ['•', i.status];
2911
- const metrics = i.tracked
2912
- ? `<span class="m"><span class="k">RICE</span><b>${i.rice}</b></span><span class="m"><span class="k">effort</span>${i.effort}pt</span>
2913
- <span class="m"><span class="k">progress</span><span class="sf-prog"><i style="width:${i.progress || 0}%"></i></span></span>`
2914
- : `<span class="m"><span class="k">RICE</span><span class="untr">— not tracked</span></span>
2915
- <span class="m"><span class="k">progress</span><span class="untr">— not tracked</span></span>`;
2916
- return `<details class="sf-row" data-phase="${i.phase}"${i.status === 'blocked' ? ' open' : ''}>
2917
- <summary>
2918
- <span class="sf-spine" aria-hidden="true"></span>
2919
- <span class="sf-stat"><span class="g">${gl} ${glab}</span></span>
2920
- <span class="sf-id">${esc(i.id)}${i.title ? `<span class="ti">${esc(i.title)}</span>` : ''}</span>
2921
- <span class="sf-metrics">${metrics}</span>
2922
- <span class="sf-chev" aria-hidden="true">▸</span>
2923
- </summary>
2924
- <div class="sf-detail">
2925
- ${i.kr ? `<div><h4>Anchor</h4><p>${esc(i.kr)}</p></div>` : ''}
2926
- <div><h4>Status</h4><p>${esc(i.statusText || i.status)}${i.stage ? ` · stage ${esc(i.stage)}` : ''}${i.accepted ? ' · accepted' : ''}</p></div>
2927
- <div><h4>Artifacts</h4><div class="sf-arts">${(i.artifacts || []).map((a) =>
2928
- `<button class="sf-art" data-doc="${esc(a.path)}"><span class="role">${esc(a.role)}</span>${esc(a.name)}</button>`).join('') || '<span class="untr">none on disk</span>'}</div></div>
2929
- </div></details>`;
2930
- }
2931
- function sfRoadmap(s) {
2932
- return (s.groups || []).map((g) => `<div class="sf-rc"><h4>${esc(g.label)} · ${g.items.length}</h4>
2933
- ${g.items.map((i) => `<div class="sf-rcrow" data-phase="${i.phase}"><span class="sf-spine" aria-hidden="true"></span><span class="nm">${esc(i.id)}</span><span class="st">${(SF_STAT[i.status] || ['•'])[0]} ${esc(i.status)}</span></div>`).join('')}
2934
- </div>`).join('') + (s.note ? `<div class="sf-note">${esc(s.note)}</div>` : '');
2935
- }
2936
- function sfOkr(s) {
2937
- // A FAILED read renders RED and nothing else — never a list that silently omits what could not be
2938
- // parsed. Reusing .cs-error deliberately: the workspace has exactly one way to say "broken read",
2939
- // and a second visual vocabulary for the same meaning is how one of them ends up looking calm.
2940
- if (s.unreadable) return csError('okr', s.unreadable);
2941
-
2942
- const h = s.anchor_health;
2943
- // Unresolvable ANCHORS are the defect this surface exists to expose: an anchor naming a KR no
2944
- // document declares sat unseen for two days precisely because nothing rendered it. It is RED, and
2945
- // it is placed ABOVE the objectives so it cannot be scrolled past.
2946
- const bad = (h && h.unresolvable) || [];
2947
- const anchorBand = bad.length
2948
- ? `<div class="cs-error" role="alert">
2949
- <div class="cs-err-head"><span class="mk" aria-hidden="true">▲</span>
2950
- <b>${bad.length} anchor${bad.length > 1 ? 's' : ''} name${bad.length > 1 ? '' : 's'} something these documents don’t declare</b></div>
2951
- ${bad.map((u) => `<p class="cs-err-why"><b>${esc(u.initiative)}</b> — <code>${esc(u.raw)}</code><br>${esc(u.reason)}</p>`).join('')}
2952
- <p class="cs-err-note">An anchor that resolves to nothing is not an anchor. Either write one the
2953
- OKR documents declare, or add the KR — adding a KR is the PM’s act, never an agent’s.</p></div>`
2954
- : (h ? `<div class="sf-pulse"><span class="n"><b>${h.checked}</b> anchors resolve</span>
2955
- <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>` : '');
2956
-
2957
- const body = (s.objectives || []).map((o) => `<div class="sf-kr"><div class="obj">${esc(o.id)} · ${esc(o.name)}</div>
2958
- ${(o.krs || []).map((k) => `<div class="krrow"><div class="krh"><span class="lab">${esc(k.id)}</span><span>${esc(k.name)}</span></div>
2959
- <div class="lev"><span class="untr">current · target · levers — not tracked</span></div></div>`).join('') || '<span class="untr">no KRs parsed</span>'}
2960
- </div>`).join('');
2961
- return anchorBand + body + (s.note ? `<div class="sf-note">${esc(s.note)}</div>` : '');
2962
- }
2963
- function sfReport(s) {
2964
- return `<div class="sf-rep"><p class="lead">${esc(s.cadence)} report · derived on demand</p>
2965
- ${(s.sections || []).map((sec) => `<h3>${esc(sec.heading)}</h3><ul>${((sec.items && sec.items.length) ? sec.items : ['—']).map((x) => `<li>${esc(x)}</li>`).join('')}</ul>`).join('')}</div>`;
2966
- }
2967
-
2968
- // ── console surfaces (D64 Track B): needs-you · radar · metrics over file-mode state ────────────
2969
- // HONESTY BY CONSTRUCTION: three visually distinct outcomes —
2970
- // error → a RED banner (broken/missing/corrupt read, or a fetch failure)
2971
- // empty → a CALM state (successfully read, genuinely nothing)
2972
- // data → the surface.
2973
- // A broken read must NEVER render the calm empty. csError is the only thing a failed read may show.
2974
- function consoleBody(name, p) {
2975
- if (!p || p.ok === false) return csError(name, (p && p.error) || 'the file-mode read failed');
2976
- if (p.empty) return csEmpty(name);
2977
- if (name === 'needs') return csNeeds(p.data);
2978
- if (name === 'radar') return csRadar(p.data, p.source);
2979
- if (name === 'metrics') return csMetrics(p.data, p.source);
2980
- if (name === 'workflow') return csWorkflow(p.data);
2981
- return csError(name, 'unknown console surface');
2982
- }
2983
- function csError(name, why) {
2984
- return `<div class="cs-error" role="alert">
2985
- <div class="cs-err-head"><span class="mk" aria-hidden="true">▲</span><b>Can’t read the ${esc(name)} surface</b></div>
2986
- <p class="cs-err-why">${esc(why)}</p>
2987
- <p class="cs-err-note">This is a broken read, not an all-clear. The workspace refuses to show a green
2988
- empty state over a failed read — fix the file-mode state, then reopen.</p></div>`;
2989
- }
2990
- function csEmpty(name) {
2991
- const msg = name === 'needs'
2992
- ? 'Nothing needs your decision right now. No open Acceptance Gate, no overdue outcome check.'
2993
- : name === 'workflow'
2994
- ? 'No initiative records in this repo — the read succeeded and there is genuinely nothing in flight.'
2995
- : `No ${esc(name)} data yet — the state read succeeded and there is genuinely nothing to show.`;
2996
- return `<div class="cs-empty"><span class="dot" aria-hidden="true"></span><b>Nothing awaits</b>
2997
- <p>${msg}</p><p class="cs-empty-note">This is a real, verified empty — not a failed read.</p></div>`;
2998
- }
2999
- function csNeeds(d) {
3000
- const items = (d && d.items) || [];
3001
- return `<div class="cs-needs"><div class="cs-h"><span class="dot" aria-hidden="true"></span>
3002
- <b>${items.length} need${items.length === 1 ? '' : 's'} you</b><span class="src">file-mode · Acceptance Gate is non-delegable (D12)</span></div>
3003
- ${items.map((n) => `<div class="cs-need" data-kind="${esc(n.kind)}">
3004
- <span class="ph" aria-hidden="true"></span>
3005
- <div class="txt"><div class="t">${esc(n.question)}</div>
3006
- <div class="m">${esc(n.kind)}${n.initiative_id ? ' · ' + esc(n.initiative_id) : ''}${n.derived ? ' · derived' : ''}${n.run_id ? ' · ' + esc(n.run_id) : ''}</div></div>
3007
- </div>`).join('')}</div>`;
3008
- }
3009
- // The workflow ladder — the same steps, glyphs and next-actions the CLI and the Initiative Gate
3010
- // print, from the same engine. Parity is the point: a panel that disagreed with CI would be worse
3011
- // than no panel, because the PM would trust the one that is about to fail them.
3012
- const WF_GLYPH = { done: '✓', current: '●', blocked: '✗', pending: '·', na: '─', unknown: '?' };
3013
- const WF_STAGES = ['intake', 'spec', 'contract', 'build', 'eval', 'accepted'];
3014
- function csWorkflow(d) {
3015
- const live = (d && d.live) || [];
3016
- const bad = (d && d.unreadable) || [];
3017
- // "Open" is named for what it is derived from — no recorded acceptance — never as "in flight".
3018
- // Most of these are finished work whose acceptance was never logged, and the count IS the finding.
3019
- const head = `<div class="cs-h"><span class="dot ${d.blocked ? 'warn' : 'ok'}" aria-hidden="true"></span>
3020
- <b>${d.total} without a recorded acceptance${d.blocked ? ` · ${d.blocked} blocked` : ''}</b>
3021
- <span class="src">derived — no status field to keep updated${d.accepted ? ` · ${d.accepted} accepted` : ''}</span></div>`;
3022
- // A degraded read is stated, never absorbed: without history the order rules are unknown, and an
3023
- // unknown that renders like a tick is exactly the false green this surface exists to prevent.
3024
- const degraded = d.degraded
3025
- ? `<div class="wf-degraded" role="alert"><b>Order checks degraded</b> — ${esc(d.degraded)}.
3026
- Steps that depend on commit order show <span class="mono">?</span>, not a tick.</div>` : '';
3027
- const unreadable = bad.length
3028
- ? `<div class="wf-degraded" role="alert"><b>${bad.length} record${bad.length === 1 ? '' : 's'} unreadable</b> —
3029
- ${bad.map((b) => esc(b.id)).join(', ')}. Shown here rather than dropped from the count.</div>` : '';
3030
- if (!live.length) {
3031
- return `<div class="cs-workflow">${head}${degraded}${unreadable}
3032
- <div class="cs-none">Every initiative in this repo has a recorded acceptance.</div></div>`;
3033
- }
3034
- // Never a silent cap: a truncated list that does not say so reads as "that is everything".
3035
- const more = d.hidden
3036
- ? `<div class="wf-more">+ ${d.hidden} more without a recorded acceptance, not shown —
3037
- <span class="mono">node scripts/workflow-state.js --all</span></div>` : '';
3038
- return `<div class="cs-workflow">${head}${degraded}${unreadable}
3039
- ${live.map((r) => {
3040
- const at = Math.max(0, WF_STAGES.indexOf(r.stage));
3041
- const rail = WF_STAGES.map((s, i) =>
3042
- `<span class="wf-st ${i < at ? 'past' : i === at ? 'at' : ''}">${esc(s)}</span>`).join('<i class="wf-sep" aria-hidden="true"></i>');
3043
- return `<div class="wf-card" data-blocked="${r.blockers.length ? '1' : '0'}">
3044
- <div class="wf-top">
3045
- <button class="wf-id" data-init="${esc(r.id)}" data-doc="${esc(r.record)}" title="open the initiative record">${esc(r.id)}</button>
3046
- <span class="wf-lane">${esc(r.lane)}</span>
3047
- ${r.in_change ? '<span class="wf-here">this change</span>' : ''}
3048
- <span class="wf-anchor" title="${esc(r.anchor || '')}">${esc((r.anchor || 'unanchored').split(/[(—]/)[0].trim())}</span>
3049
- </div>
3050
- <div class="wf-rail">${rail}</div>
3051
- <div class="wf-steps">${r.steps.map((s) => `
3052
- <div class="wf-step" data-st="${esc(s.status)}">
3053
- <span class="wf-g" aria-hidden="true">${WF_GLYPH[s.status] || '?'}</span>
3054
- <span class="wf-k">${esc(s.key)}</span>
3055
- <span class="wf-e">${esc(s.evidence || '')}${s.rule && s.status === 'blocked' ? ` <span class="wf-rule">${esc(s.rule)}</span>` : ''}</span>
3056
- </div>${s.next ? `<div class="wf-next" data-st="${esc(s.status)}">→ ${esc(s.next)}</div>` : ''}`).join('')}</div>
3057
- ${r.next ? `<div class="wf-do"><b>next</b> ${esc(r.next.action || '')}</div>`
3058
- : `<div class="wf-do ok"><b>nothing blocking</b> — the ladder is complete</div>`}
3059
- </div>`;
3060
- }).join('')}${more}</div>`;
3061
- }
3062
- function csRadar(d, source) {
3063
- const g = d.eval_gate || {};
3064
- const cal = d.calibration;
3065
- const wms = d.watermelons || [];
3066
- const flags = d.flags || 0;
3067
- const ragCls = (r) => 'rag-' + (String(r || 'grey').toLowerCase());
3068
- const gate = `<div class="cs-card"><h4>Review Gate</h4>
3069
- <div class="cs-big">${g.pass_rate == null ? '—' : Math.round(g.pass_rate * 100) + '%'}<span class="u">pass</span></div>
3070
- <div class="cs-sub">${g.passing || 0}/${g.total || 0} evals pass · rule verdict ILIKE 'pass%' OR score ≥ 0.85</div></div>`;
3071
- const calCard = cal ? `<div class="cs-card ${cal.meets_d40 ? '' : 'warnedge'}"><h4>Calibration (D40)</h4>
3072
- <div class="cs-big">${cal.kappa == null ? '—' : (+cal.kappa).toFixed(2)}<span class="u">κ</span></div>
3073
- <div class="cs-sub">n=${cal.n == null ? '—' : cal.n} · TNR ${cal.tnr == null ? 'n/a (leniency blind spot)' : Math.round(cal.tnr * 100) + '%'} · ${esc(cal.status || '')}${cal.meets_d40 ? '' : ' · below κ≥0.60'}</div>
3074
- ${cal.caveat ? `<div class="cs-caveat">${esc(cal.caveat)}</div>` : ''}</div>` : '';
3075
- const watermelon = `<div class="cs-wm"><div class="cs-h"><span class="dot ${flags ? 'warn' : 'ok'}" aria-hidden="true"></span>
3076
- <b>${flags} watermelon flag${flags === 1 ? '' : 's'}</b><span class="src">green-on-metrics / red-on-strategy · anti-drift</span></div>
3077
- ${wms.map((w) => `<div class="cs-wmrow"><span class="rag ${ragCls(w.rag)}" title="${esc(w.rag || '')}"></span>
3078
- <span class="nm">${esc(w.id)}${w.title ? `<span class="ti">${esc(w.title)}</span>` : ''}</span>
3079
- <span class="st">${esc(w.verdict || '—')}${w.score != null ? ' · ' + w.score : ''}${w.corrections != null ? ' · ' + w.corrections + ' corr' : ''}</span></div>`).join('') || '<div class="cs-none">no initiatives on the radar</div>'}</div>`;
3080
- return `<div class="cs-radar"><div class="cs-src">source: ${esc(source || 'file-mode')}</div>
3081
- <div class="cs-cards">${gate}${calCard}</div>${watermelon}</div>`;
3082
- }
3083
- function csMetrics(d, source) {
3084
- const pct = (x) => x == null ? '—' : Math.round(x * 100) + '%';
3085
- const ns = d.avg_corrections_per_run == null ? '—' : (+d.avg_corrections_per_run).toFixed(2);
3086
- const per = (d.per_initiative || []).slice(0, 12);
3087
- return `<div class="cs-metrics"><div class="cs-src">source: ${esc(source || 'file-mode')}</div>
3088
- <div class="cs-cards">
3089
- <div class="cs-card"><h4>North star</h4><div class="cs-big">${ns}<span class="u">corr/run</span></div>
3090
- <div class="cs-sub">human corrections per run · lower is better (D10)</div></div>
3091
- <div class="cs-card"><h4>Throughput</h4><div class="cs-big">${d.total_runs == null ? '—' : d.total_runs}<span class="u">runs</span></div>
3092
- <div class="cs-sub">${d.runs_needing_correction == null ? '—' : d.runs_needing_correction} needed a correction · ${pct(d.share_runs_corrected)} share</div></div>
3093
- </div>
3094
- ${per.length ? `<div class="cs-per"><div class="cs-h"><b>per initiative</b><span class="src">avg corrections/run</span></div>
3095
- ${per.map((r) => `<div class="cs-perrow"><span class="nm">${esc(r.initiative_id)}</span>
3096
- <span class="bar" aria-hidden="true"><i style="width:${Math.min(100, (Number(r.avg_corrections_per_run) || 0) / 7 * 100)}%"></i></span>
3097
- <span class="v">${Number(r.avg_corrections_per_run || 0).toFixed(1)}</span></div>`).join('')}</div>` : ''}</div>`;
3098
- }
3099
-
3100
- // ── the history lens (fourth): episodic recall over this workspace's own past sessions. ─────
3101
- // The scrollback the server already mirrors to disk IS the record of what earlier runs tried, so
3102
- // searching it is how the PM — or an agent in a pane, hitting /api/search itself — avoids
3103
- // re-earning a correction someone already paid for. Secrets are scrubbed server-side, never here.
3104
- let histSeq = 0;
3105
- async function renderHistory() {
3106
- const host = $('library');
3107
- const q = (state.filter || '').trim();
3108
- if (q.length < 2) {
3109
- host.innerHTML = `<div class="hi-hint">Search what past sessions in this repo printed —
3110
- type at least 2 characters in the box above.<br><br>
3111
- <span class="mono">GET /api/search?q=…&amp;ws=…</span> answers the same question for an
3112
- agent running in a pane.</div>`;
3113
- return;
3114
- }
3115
- const seq = ++histSeq;
3116
- try {
3117
- const r = await fetch(`/api/search?q=${encodeURIComponent(q)}&ws=${encodeURIComponent(state.wsId || '')}`);
3118
- const j = await r.json();
3119
- if (seq !== histSeq) return; // a later keystroke already won the race
3120
- const hits = j.hits || [];
3121
- if (!hits.length) { host.innerHTML = `<div class="hi-hint">No session in this repo printed “${esc(q)}”.</div>`; return; }
3122
- host.innerHTML = `<div class="hi-list">${hits.map((h) => `
3123
- <div class="hi-row">
3124
- <div class="hi-head"><b>${esc(h.session || h.session_id)}</b>
3125
- <span class="hi-meta">${esc(h.agent || '')}${h.at ? ' · ' + esc(h.at.slice(0, 10)) : ''}</span></div>
3126
- ${h.before ? `<div class="hi-ctx">${esc(h.before)}</div>` : ''}
3127
- <div class="hi-hit">${esc(h.line)}</div>
3128
- ${h.after ? `<div class="hi-ctx">${esc(h.after)}</div>` : ''}
3129
- </div>`).join('')}</div>` +
3130
- // never a silent cap — say when the list was cut
3131
- (j.truncated ? `<div class="hi-hint">showing the first ${hits.length} matches — narrow the query for the rest</div>` : '');
3132
- } catch {
3133
- if (seq === histSeq) host.innerHTML = `<div class="hi-hint">Could not reach the server.</div>`;
3134
- }
3135
- }
3136
-
3137
- // ── the scheduled lens (third library lens; a persistent pane beside initiatives + folders) ──────
3138
- let SCHED_TASKS = null;
3139
- const SC_STAT = { armed: ['◐', 'armed'], running: ['●', 'running'], failed: ['◆', 'failed'], off: ['—', 'off'] };
3140
- function schHealth(s) { return !s.enabled ? 'off' : s.lastStatus === 'fail' ? 'failed' : 'armed'; }
3141
- function relTime(iso) {
3142
- if (!iso) return '—';
3143
- const d = new Date(iso) - Date.now();
3144
- if (d < 0) return 'now';
3145
- const h = d / 3.6e6; if (h < 1) return `in ${Math.max(1, Math.round(d / 6e4))}m`;
3146
- if (h < 24) return `in ${Math.round(h)}h`;
3147
- return `in ${Math.round(h / 24)}d`;
3148
- }
3149
- function humanCad(s) {
3150
- const t = s.time || '09:00';
3151
- return { daily: `every day ${t}`, weekly: `every ${s.day} ${t}`, 'bi-weekly': `every other ${s.day} ${t}`, monthly: `monthly d${s.dayOfMonth} ${t}` }[s.cadence] || s.cadence;
3152
- }
3153
- async function renderScheduled() {
3154
- const host = $('library');
3155
- if (!host.querySelector('.sc-list')) host.innerHTML = `<div class="sc-load mono">loading schedules…</div>`;
3156
- try {
3157
- if (!SCHED_TASKS) { const tr = await fetch('/api/tasks'); SCHED_TASKS = (await tr.json()).tasks || []; }
3158
- const r = await fetch('/api/schedules?ws=' + encodeURIComponent(state.wsId || ''));
3159
- host.innerHTML = scheduledHtml(((await r.json()).schedules) || []);
3160
- wireScheduled();
3161
- } catch { host.innerHTML = `<div class="empty">Scheduler unavailable.</div>`; }
3162
- }
3163
- function scheduledHtml(list) {
3164
- const armed = list.filter((s) => s.enabled);
3165
- const failed = list.find((s) => s.lastStatus === 'fail' && s.enabled);
3166
- const soon = armed.map((s) => s.next).filter(Boolean).sort()[0];
3167
- const btns = `<button class="sc-new ghost" data-scnewskill title="Author a new skill">+ skill</button><button class="sc-new" data-scnew>+ new</button>`;
3168
- const summary = failed
3169
- ? `<div class="sc-sum warn"><span class="dot"></span><div class="t"><b>${esc(failed.name)}</b> failed its last run</div><span class="sc-acts">${btns}</span></div>`
3170
- : `<div class="sc-sum"><span class="dot"></span><div class="t"><b>${armed.length} armed</b>${soon ? ` · next ${esc(relTime(soon))}` : ''}</div><span class="sc-acts">${btns}</span></div>`;
3171
- const rows = list.length ? list.map(scRow).join('') : `<div class="sc-empty"><div class="mk">◷</div><p>No schedules yet. Set a cadence and pmos runs the skill itself — output filed to needs-you.</p><button class="sc-new" data-scnew>+ new schedule</button></div>`;
3172
- return `<div class="sc-wrap">${summary}<div class="sc-list">${rows}</div>
3173
- <div class="sc-note">Every task is a <b>skill</b> — a named, repeatable job with a consistent-output contract. <b>+ skill</b> authors one; each run is <b>contract-checked</b>, the same shape every time. Local; fires while the server runs.</div></div>`;
3174
- }
3175
- function scRow(s) {
3176
- const health = schHealth(s); const [gl, glab] = SC_STAT[health];
3177
- const run = health === 'failed' ? `<span class="fail">failed ✗</span>`
3178
- : !s.enabled ? `<span class="off">disabled</span>`
3179
- : `<span class="next">${esc(relTime(s.next))}</span>`;
3180
- return `<div class="sc-row" data-health="${health}" data-id="${esc(s.id)}">
3181
- <div class="sc-main">
3182
- <span class="sc-rail"></span>
3183
- <span class="sc-tog" role="switch" aria-checked="${!!s.enabled}" tabindex="0" data-tog="${esc(s.id)}" aria-label="Enable ${esc(s.name)}"><span class="knob"></span></span>
3184
- <div class="sc-id"><div class="nm"><span class="g" aria-hidden="true">${gl}</span><span class="name">${esc(s.name)}</span><span class="skill">${esc(s.skill)}</span></div>
3185
- <div class="cad">${esc(humanCad(s))} · ${esc(glab)}${s.lastRun ? ` · last ${esc(relTime2(s.lastRun))}` : ''}</div></div>
3186
- <div class="sc-run">${run}</div>
3187
- </div>
3188
- <div class="sc-more"><button class="rn" data-run="${esc(s.id)}">▸ run now</button><button data-del="${esc(s.id)}">delete</button></div>
3189
- </div>`;
3190
- }
3191
- function relTime2(iso) { const d = Date.now() - new Date(iso); const h = d / 3.6e6; return h < 1 ? 'just now' : h < 24 ? `${Math.round(h)}h ago` : `${Math.round(h / 24)}d ago`; }
3192
- function scForm() {
3193
- const wsName = (state.workspaces.find((w) => w.id === state.wsId) || {}).name || 'this repo';
3194
- const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
3195
- return `<form class="sc-form" id="scForm">
3196
- <div><label for="scfN">name</label><input id="scfN" placeholder="e.g. Friday status report" /></div>
3197
- <div><label>cadence</label><div class="sc-seg" id="scfC">${['daily', 'weekly', 'bi-weekly', 'monthly'].map((c) => `<button type="button" data-c="${c}" aria-pressed="${c === 'weekly'}">${c}</button>`).join('')}</div></div>
3198
- <div class="sc-f2"><div><label for="scfD">day</label><select id="scfD">${days.map((d) => `<option${d === 'Fri' ? ' selected' : ''}>${d}</option>`).join('')}</select></div>
3199
- <div><label for="scfT">time</label><input id="scfT" type="time" value="09:00" /></div></div>
3200
- <div><label for="scfK">task — skill-backed</label><select id="scfK">${SCHED_TASKS.map((t) => `<option value="${esc(t.id)}">${esc(t.label)} · skill ${esc(t.skill)} · contract ✓</option>`).join('')}</select></div>
3201
- <div class="sc-hint">runs in <b>${esc(wsName)}</b> · output is contract-checked for a consistent shape</div>
3202
- <div class="sc-frow"><button type="submit" class="save">save schedule</button><button type="button" class="cancel" id="scfX">cancel</button></div></form>`;
3203
- }
3204
- let scCadence = 'weekly';
3205
- function wireScheduled() {
3206
- const host = $('library');
3207
- host.querySelectorAll('[data-scnew]').forEach((b) => b.onclick = () => { scCadence = 'weekly'; host.querySelector('.sc-list').insertAdjacentHTML('beforebegin', scForm()); wireScForm(); });
3208
- host.querySelectorAll('[data-scnewskill]').forEach((b) => b.onclick = () => { if ($('skForm')) return; host.querySelector('.sc-list').insertAdjacentHTML('beforebegin', skForm()); wireSkForm(); });
3209
- host.querySelectorAll('[data-tog]').forEach((b) => b.onclick = () => scToggle(b.dataset.tog, b.getAttribute('aria-checked') !== 'true'));
3210
- host.querySelectorAll('[data-run]').forEach((b) => b.onclick = () => scRun(b.dataset.run));
3211
- host.querySelectorAll('[data-del]').forEach((b) => b.onclick = () => scDelete(b.dataset.del));
3212
- }
3213
- // The New Skill flow: a GUI replaces editing server code. One concept — no "kind". name + "what it
3214
- // should do" + the output shape (the section keys = the consistency contract). Create scaffolds a
3215
- // well-formed .skill file; AI-draft additionally spawns a LOCAL claude session to sharpen it in place.
3216
- function skForm() {
3217
- return `<form class="sc-form sk-form" id="skForm">
3218
- <div class="sk-head">+ new skill<span class="sk-sub">a named, repeatable task — one concept, no “kind” to pick</span></div>
3219
- <div><label for="skfN">name</label><input id="skfN" placeholder="e.g. release-notes" autocomplete="off" spellcheck="false" /></div>
3220
- <div><label for="skfD">what it should do</label><textarea id="skfD" rows="3" placeholder="Draft release notes from the merged PRs since the last report…"></textarea></div>
3221
- <div><label for="skfO">output shape — one section per line (the consistency contract)</label><textarea id="skfO" rows="3" placeholder="Highlights&#10;Fixes&#10;Known issues"></textarea></div>
3222
- <div class="sc-hint">saved as a <b>skill file</b> · runs as a local claude session · appears in the schedule picker</div>
3223
- <div class="sc-frow"><button type="submit" class="save">create skill</button><button type="button" class="sc-ai" id="skfAI" title="Spawn a local claude session to draft this skill">✦ AI-draft</button><button type="button" class="cancel" id="skfX">cancel</button></div></form>`;
3224
- }
3225
- function skBody() {
3226
- return { name: ($('skfN').value || '').trim(), description: ($('skfD').value || '').trim(),
3227
- output: ($('skfO').value || '').split('\n').map((x) => x.trim()).filter(Boolean), ws: state.wsId };
3228
- }
3229
- function wireSkForm() {
3230
- const f = $('skForm'); if (!f) return;
3231
- $('skfX').onclick = () => renderScheduled();
3232
- $('skfAI').onclick = async () => {
3233
- const body = skBody();
3234
- if (!body.name) { flash('name the skill first'); $('skfN').focus(); return; }
3235
- flash('spawning a claude session to draft it…');
3236
- try {
3237
- const r = await fetch('/api/skills/draft', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
3238
- const j = await r.json();
3239
- if (!j.ok) { flash(j.error || 'could not draft'); return; }
3240
- SCHED_TASKS = null;
3241
- if (j.started && j.sessionId) { flash('claude is drafting the skill'); selectSession(j.sessionId); }
3242
- else { flash(j.reason || 'skill scaffolded (claude not available to draft)'); renderScheduled(); }
3243
- } catch { flash('could not reach the server'); }
3244
- };
3245
- f.onsubmit = async (e) => {
3246
- e.preventDefault();
3247
- const body = skBody();
3248
- if (!body.name) { flash('name the skill first'); $('skfN').focus(); return; }
3249
- try {
3250
- const r = await fetch('/api/skills', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
3251
- const j = await r.json();
3252
- if (!j.ok) { flash(j.error || 'could not create'); return; }
3253
- SCHED_TASKS = null; flash('skill created');
3254
- } catch { flash('could not reach the server'); return; }
3255
- renderScheduled();
3256
- };
3257
- $('skfN').focus();
3258
- }
3259
- function wireScForm() {
3260
- const f = $('scForm'); if (!f) return;
3261
- f.querySelectorAll('[data-c]').forEach((b) => b.onclick = () => { scCadence = b.dataset.c; f.querySelectorAll('[data-c]').forEach((x) => x.setAttribute('aria-pressed', String(x === b))); });
3262
- $('scfX').onclick = () => renderScheduled();
3263
- f.onsubmit = async (e) => {
3264
- e.preventDefault();
3265
- const body = { name: $('scfN').value, cadence: scCadence, day: $('scfD').value, time: $('scfT').value, task: $('scfK').value, skill: $('scfK').value, ws: state.wsId };
3266
- try { const r = await fetch('/api/schedules', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
3267
- const j = await r.json(); if (!j.ok) { flash(j.error || 'could not save'); return; } } catch { flash('could not reach the server'); return; }
3268
- renderScheduled();
3269
- };
3270
- $('scfN').focus();
3271
- }
3272
- async function scPost(path, body) { try { await fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); } catch {} }
3273
- async function scToggle(id, enabled) { await scPost('/api/schedules/update', { id, enabled }); renderScheduled(); }
3274
- async function scDelete(id) { await scPost('/api/schedules/delete', { id }); renderScheduled(); }
3275
- async function scRun(id) { flash('running…'); await scPost('/api/schedules/run', { id }); renderScheduled(); loadNeeds(); }
3276
- async function openDeliverable(id) {
3277
- if (!id) return;
3278
- state.docPath = null; state.surface = null;
3279
- showRail(); $('paneDoc').hidden = false; $('railSplit').hidden = false; foldPane('doc', true);
3280
- $('crumb').innerHTML = `<bdi>report · ${esc(id)}</bdi>`; $('docBody').scrollTop = 0;
3281
- try { const r = await fetch('/api/deliverable?ws=' + encodeURIComponent(state.wsId || '') + '&id=' + encodeURIComponent(id));
3282
- $('docBody').innerHTML = r.ok ? renderMd(await r.text(), '') : `<p class="doc-empty">Report not found.</p>`;
3283
- } catch { $('docBody').innerHTML = `<p class="doc-empty">Read failed.</p>`; }
3284
- refit();
3285
- }
3286
-
3287
- let palItems = [], palSel = 0;
3288
- function flattenDocs(node, acc) {
3289
- (node.docs || []).forEach((d) => { if (d.md !== false) acc.push({ kind: 'doc', label: d.path, path: d.path }); });
3290
- (node.dirs || []).forEach((d) => flattenDocs(d, acc));
3291
- return acc;
3292
- }
3293
- function openPalette() {
3294
- const lib = state.lib || {};
3295
- const docs = (lib.roots || []).reduce((a, r) => flattenDocs(r, a), []);
3296
- const inits = (lib.initiatives || []).map((g) => ({ kind: 'initiative', label: g.id, init: g }));
3297
- // every session, including other workspaces' — selecting one follows it to its workspace
3298
- const sess = state.sessions.map((s) => {
3299
- const w = state.workspaces.find((x) => x.id === s.ws);
3300
- return { kind: 'session', label: s.ws === state.wsId ? s.name : `${w ? w.name : '?'} / ${s.name}`, id: s.id, state: s.state };
3301
- });
3302
- const wss = state.workspaces.filter((w) => w.id !== state.wsId).map((w) => ({ kind: 'workspace', label: w.name, wsid: w.id }));
3303
- // summon a P/D/M/R surface on demand — listed first so "initiatives"/"roadmap"/"okr"/"report" are reachable
3304
- const views = SURFACES.map((v) => ({ kind: 'view', label: v.label, view: v.name }));
3305
- views.push({ kind: 'align', label: 'alignment drafts' }); // downstream of a save, never resident
3306
- palItems = [...views, ...wss, ...sess, ...inits, ...docs];
3307
- $('palette').style.display = 'grid';
3308
- const inp = $('palInput'); inp.value = ''; renderPalette('');
3309
- setTimeout(() => inp.focus(), 0);
3310
- }
3311
- function closePalette() { $('palette').style.display = 'none'; if (state.active) { const t = state.terms.get(state.active); if (t) t.term.focus(); } }
3312
- function togglePalette() { $('palette').style.display === 'grid' ? closePalette() : openPalette(); }
3313
- function renderPalette(q) {
3314
- const f = q.toLowerCase();
3315
- const items = palItems.filter((i) => i.label.toLowerCase().includes(f)).slice(0, 40);
3316
- palSel = 0;
3317
- const g = (i) => i.kind === 'view' ? '▤' : i.kind === 'session' ? (GLYPH[i.state] || '•') : i.kind === 'workspace' ? '▣' : i.kind === 'initiative' ? '◈' : '◦';
3318
- $('palList').innerHTML = items.map((i, n) => `
3319
- <button class="pitem ${n === 0 ? 'sel' : ''}" data-n="${n}" role="option">
3320
- <span class="g" aria-hidden="true">${g(i)}</span>
3321
- <span>${esc(i.label)}</span><span class="kind">${i.kind}</span></button>`).join('')
3322
- || `<div class="empty">No match.</div>`;
3323
- $('palList')._items = items;
3324
- $('palList').querySelectorAll('.pitem').forEach((b) => b.onclick = () => choosePalette(Number(b.dataset.n)));
3325
- }
3326
- function choosePalette(n) {
3327
- const items = $('palList')._items || [];
3328
- const it = items[n]; if (!it) return;
3329
- closePalette();
3330
- if (it.kind === 'view') openSurface(it.view);
3331
- else if (it.kind === 'align') alDrafts();
3332
- else if (it.kind === 'session') selectSession(it.id);
3333
- else if (it.kind === 'workspace') selectWorkspace(it.wsid);
3334
- else if (it.kind === 'initiative') {
3335
- // jump to the initiative's primary artifact (PRD first — its own ordering guarantees it)
3336
- openInitiative(it.init.id, state.wsId);
3337
- } else openDoc(it.path);
3338
- }
3339
- $('palInput')?.addEventListener('input', (e) => renderPalette(e.target.value));
3340
- $('palInput')?.addEventListener('keydown', (e) => {
3341
- const items = $('palList').querySelectorAll('.pitem'); if (!items.length) { if (e.key === 'Escape') closePalette(); return; }
3342
- if (e.key === 'ArrowDown') { e.preventDefault(); palSel = Math.min(palSel + 1, items.length - 1); }
3343
- else if (e.key === 'ArrowUp') { e.preventDefault(); palSel = Math.max(palSel - 1, 0); }
3344
- else if (e.key === 'Enter') { e.preventDefault(); choosePalette(palSel); return; }
3345
- else if (e.key === 'Escape') { closePalette(); return; }
3346
- else return;
3347
- items.forEach((b, i) => b.classList.toggle('sel', i === palSel));
3348
- items[palSel].scrollIntoView({ block: 'nearest' });
3349
- });
3350
- $('palette').addEventListener('click', (e) => { if (e.target === $('palette')) closePalette(); });
3351
-
3352
- // CAPTURE phase on document: fires BEFORE the focused xterm textarea's own keydown, so ⌘K
3353
- // works even while a terminal holds focus (xterm otherwise swallows the key). The xterm
3354
- // custom handler above is belt-and-suspenders so the K never reaches the PTY.
3355
- document.addEventListener('keydown', (e) => {
3356
- if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
3357
- e.preventDefault(); e.stopPropagation(); togglePalette(); return;
3358
- }
3359
- if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'j') {
3360
- e.preventDefault(); e.stopPropagation(); openScratch(); return;
3361
- }
3362
- if (e.key === 'F2' && state.active && !state.renaming) { e.preventDefault(); startRename(state.active); return; }
3363
- // ⌃1–⌃4 — the named layouts, in one act. Ctrl only (⌘1-4 are the browser's tab switches).
3364
- if (e.ctrlKey && !e.metaKey && !e.altKey && /^[1-9]$/.test(e.key)) {
3365
- const l = state.layouts.find((x) => x.key === Number(e.key));
3366
- if (l) { e.preventDefault(); e.stopPropagation(); selectLayout(l.id); return; }
3367
- }
3368
- if (e.key === 'Escape') {
3369
- if (!$('layMenu').hidden) { e.stopPropagation(); closeLayMenu(); }
3370
- else if ($('palette').style.display === 'grid') { e.stopPropagation(); closePalette(); }
3371
- else if ($('fsPicker').style.display === 'grid') { e.stopPropagation(); closeFsPicker(); }
3372
- else if (state.renaming) { e.stopPropagation(); cancelRename(); renderSessions(); }
3373
- else if (!$('newForm').hidden) closeNewForm();
3374
- else if (!$('addWsForm').hidden) closeAddWs();
3375
- else if (!$('paneDoc').hidden) closeDoc();
3376
- }
3377
- }, true);
3378
-
3379
- // ── mobile (≤760px): single-column pane switcher + a terminal control-key accessory bar ──────
3380
- // Phones can't span three columns and lack Esc/Tab/Ctrl/arrows, so the workspace collapses to one
3381
- // pane at a time (terminal is the priority pane) with a bottom switcher, and the control bar sends
3382
- // the missing key sequences straight to the active session's PTY. Desktop is untouched (CSS-gated).
3383
-
3384
- // Ctrl one-shot: when the mobile Ctrl key is armed, the NEXT byte from the terminal (a soft-keyboard
3385
- // character) is converted to its control code (Ctrl-C = 0x03, …), then Ctrl disarms. Desktop typing
3386
- // never arms it, so this is a no-op everywhere except after a Ctrl tap.
3387
- function setCtrlArmed(on) {
3388
- state.ctrlArmed = on;
3389
- const b = $('ctrlKey'); if (b) b.setAttribute('aria-pressed', String(on));
3390
- }
3391
- function applyCtrl(d) {
3392
- if (!state.ctrlArmed) return d;
3393
- setCtrlArmed(false);
3394
- if (d.length === 1) {
3395
- const c = d.charCodeAt(0);
3396
- if (c >= 97 && c <= 122) return String.fromCharCode(c - 96); // a–z → 0x01–0x1a
3397
- if (c >= 65 && c <= 90) return String.fromCharCode(c - 64); // A–Z → 0x01–0x1a
3398
- if (c >= 91 && c <= 95) return String.fromCharCode(c - 64); // [ \ ] ^ _ → 0x1b–0x1f
3399
- if (c === 32) return '\x00'; // Ctrl-Space → NUL
3400
- }
3401
- return d;
3402
- }
3403
-
3404
- (function mobile() {
3405
- const grid = $('bodyGrid');
3406
- const menu = $('mobMenu');
3407
- const MQ = window.matchMedia('(max-width:760px)');
3408
-
3409
- // send a raw sequence to the active PTY, then keep focus in the terminal (raises the soft keyboard)
3410
- function key(seq) {
3411
- if (!state.active) return;
3412
- send({ t: 'input', id: state.active, data: seq });
3413
- const t = state.terms.get(state.active); if (t) t.term.focus();
3414
- }
3415
- const SEQ = { esc: '\x1b', tab: '\t', up: '\x1b[A', down: '\x1b[B', left: '\x1b[D', right: '\x1b[C' };
3416
- document.querySelectorAll('#ctrlBar [data-seq]').forEach((b) => b.onclick = () => key(SEQ[b.dataset.seq]));
3417
- $('ctrlKey').onclick = () => {
3418
- setCtrlArmed(!state.ctrlArmed);
3419
- if (state.active) { const t = state.terms.get(state.active); if (t) t.term.focus(); }
3420
- };
3421
- $('mobPalette').onclick = () => togglePalette();
3422
-
3423
- // the switcher: one column at a time, terminal is the default (priority) pane, choice persisted
3424
- function setView(v) {
3425
- grid.dataset.mview = v;
3426
- menu.dataset.view = v;
3427
- document.querySelectorAll('.mobtabs [data-mview]').forEach((b) => b.setAttribute('aria-selected', String(b.dataset.mview === v)));
3428
- store.set('mview', v);
3429
- if (v === 'terminal') {
3430
- refit();
3431
- if (state.active) { const t = state.terms.get(state.active); if (t) setTimeout(() => t.term.focus(), 0); }
3432
- }
3433
- }
3434
- const remembered = store.get('mview');
3435
- setView(['sessions', 'terminal', 'library'].includes(remembered) ? remembered : 'terminal');
3436
- document.querySelectorAll('.mobtabs [data-mview]').forEach((b) => b.onclick = () => setView(b.dataset.mview));
3437
-
3438
- // FitAddon must re-fit when the soft keyboard shows/hides (visualViewport) and on rotation, or
3439
- // xterm renders more rows than fit and the bottom is clipped (the R9 failure, on a phone).
3440
- if (window.visualViewport) {
3441
- window.visualViewport.addEventListener('resize', refit);
3442
- window.visualViewport.addEventListener('scroll', refit);
3443
- }
3444
- window.addEventListener('orientationchange', () => setTimeout(refit, 250));
3445
- // crossing the breakpoint (rotate a tablet, resize a window) must re-fit the terminal
3446
- const onMQ = () => refit();
3447
- if (MQ.addEventListener) MQ.addEventListener('change', onMQ); else if (MQ.addListener) MQ.addListener(onMQ);
3448
- })();
3449
-
3450
- connect();