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.
- package/README.md +57 -0
- package/package.json +1 -1
- package/payload/KIT-VERSION +2 -2
- package/workspace/README.md +44 -12
- package/workspace/design-parity.js +358 -0
- package/workspace/lib/console-state.js +48 -1
- package/workspace/lib/engine/workflow-state.js +13 -3
- package/workspace/lib/find-browser.js +60 -0
- package/workspace/lib/focus.js +967 -0
- package/workspace/lib/four-panel.js +345 -0
- package/workspace/lib/git-info.js +28 -1
- package/workspace/lib/handoff.js +280 -0
- package/workspace/lib/layout.js +68 -46
- package/workspace/lib/mobile-nav.js +157 -0
- package/workspace/lib/profiles.js +285 -0
- package/workspace/lib/repo-scan.js +469 -0
- package/workspace/package-lock.json +29 -2
- package/workspace/package.json +14 -4
- package/workspace/public/index.html +79 -279
- package/workspace/public/pm/app.js +242 -0
- package/workspace/public/pm/data.js +877 -0
- package/workspace/public/pm/dom.js +116 -0
- package/workspace/public/pm/fixture.js +979 -0
- package/workspace/public/pm/mobile.css +200 -0
- package/workspace/public/pm/mobile.js +403 -0
- package/workspace/public/pm/modes.js +1076 -0
- package/workspace/public/pm/panel-detail.js +648 -0
- package/workspace/public/pm/panel-initiatives.js +426 -0
- package/workspace/public/pm/panel-repos.js +460 -0
- package/workspace/public/pm/panel-tabs.js +487 -0
- package/workspace/public/pm/state.js +164 -0
- package/workspace/public/pm/tab-doc.js +279 -0
- package/workspace/public/pm/tab-task.js +83 -0
- package/workspace/public/pm/tab-term.js +560 -0
- package/workspace/public/pm/tokens.css +73 -0
- package/workspace/public/vendor/ibm-plex-mono-500.woff2 +0 -0
- package/workspace/server.js +1072 -31
- package/workspace/public/app.js +0 -3450
- package/workspace/public/styles.css +0 -1444
package/workspace/public/app.js
DELETED
|
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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 && 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) || ' '}</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) || ' '}</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) || ' '}</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) || ' '}</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 ✓</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=…&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 Fixes 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();
|