create-pmos 0.6.3 → 0.7.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 +54 -1
- package/package.json +1 -1
- package/payload/KIT-VERSION +2 -2
- package/payload/scripts/gate-initiative.sh +4 -1
- package/workspace/lib/apply-change.js +106 -0
- package/workspace/lib/correction-record.js +127 -0
- package/workspace/lib/finding-contract.js +162 -0
- package/workspace/lib/git-info.js +71 -0
- package/workspace/lib/harness-parse.js +5 -1
- package/workspace/lib/layout.js +171 -0
- package/workspace/lib/skill-store.js +28 -9
- package/workspace/lib/tmux-control.js +124 -0
- package/workspace/public/app.js +862 -89
- package/workspace/public/index.html +113 -23
- package/workspace/public/styles.css +232 -19
- package/workspace/server.js +384 -16
- package/workspace/skills/doc-review.skill +114 -0
package/workspace/public/app.js
CHANGED
|
@@ -21,8 +21,14 @@ const state = {
|
|
|
21
21
|
lib: null, // library payload for the selected workspace
|
|
22
22
|
lens: 'initiatives', // 'initiatives' | 'folders'
|
|
23
23
|
filter: '',
|
|
24
|
-
active: null, // active session id
|
|
25
|
-
|
|
24
|
+
active: null, // active session id — always groups[focusG].activeId
|
|
25
|
+
groups: [{ activeId: null }, { activeId: null }], // terminal groups (R6); [1] exists when split
|
|
26
|
+
focusG: 0, // which group the keyboard, ⌘J send and rename act on
|
|
27
|
+
layouts: [], // this workspace's arrangements (R1) — stock four + custom
|
|
28
|
+
layoutId: null, // the one currently applied
|
|
29
|
+
layoutSaveT: null, // debounce handle for the write-back
|
|
30
|
+
layoutDirty: false, // an adjustment not yet on disk — flushed on pagehide
|
|
31
|
+
terms: new Map(), // id -> {term, fit, el, g}
|
|
26
32
|
attached: new Set(), // ids attached ON THIS CONNECTION (cleared on reconnect)
|
|
27
33
|
needs: [],
|
|
28
34
|
newAgent: 'shell',
|
|
@@ -31,6 +37,9 @@ const state = {
|
|
|
31
37
|
docPath: null, // the document open in the reader
|
|
32
38
|
fsDir: null, // the in-app folder browser's current directory
|
|
33
39
|
ctrlArmed: false, // mobile control bar: Ctrl is a one-shot modifier for the next key
|
|
40
|
+
branch: null, // {ok,current,branches[],detached,dirty} for the selected workspace
|
|
41
|
+
newBranchMode: false, // new-session form: creating a branch vs picking an existing one
|
|
42
|
+
sessSig: null, // structural fingerprint of the last session push (see structSig)
|
|
34
43
|
};
|
|
35
44
|
|
|
36
45
|
// ── workspace state survives reloads (R7): theme + selected workspace + focused session,
|
|
@@ -84,6 +93,7 @@ function mySessions() { return state.sessions.filter((s) => s.ws === state.wsId)
|
|
|
84
93
|
|
|
85
94
|
function handle(m) {
|
|
86
95
|
if (m.t === 'hello') {
|
|
96
|
+
if (Number.isFinite(m.quietAfterMs) && m.quietAfterMs > 0) QUIET_AFTER_MS = m.quietAfterMs;
|
|
87
97
|
state.workspaces = m.workspaces; state.home = m.home; state.sessions = m.sessions;
|
|
88
98
|
// pick: the URL (a fresh `pmos` in this repo) → the last one used → home
|
|
89
99
|
const remembered = store.get('wsid');
|
|
@@ -105,12 +115,22 @@ function handle(m) {
|
|
|
105
115
|
if (!ids.has(id)) { try { t.term.dispose(); } catch {} t.el.remove(); state.terms.delete(id); state.attached.delete(id); }
|
|
106
116
|
}
|
|
107
117
|
if (state.renaming && !ids.has(state.renaming)) cancelRename();
|
|
108
|
-
|
|
118
|
+
// The liveness heartbeat pushes this message every couple of seconds, so it can no longer cost
|
|
119
|
+
// a full innerHTML rebuild of the session pane: that would drop text selection, reset scroll
|
|
120
|
+
// and flicker the sidebar twice a second. Split it — a STRUCTURAL change (a session appeared,
|
|
121
|
+
// vanished, was renamed, or exited) rebuilds; a mere liveness/harness update repaints the
|
|
122
|
+
// volatile bits in place. The openness of a drill-in and an in-flight rename already survived
|
|
123
|
+
// a rebuild; this keeps everything else intact too.
|
|
124
|
+
const sig = structSig(m.sessions);
|
|
125
|
+
const structural = sig !== state.sessSig;
|
|
126
|
+
state.sessSig = sig;
|
|
127
|
+
state.sessions = m.sessions;
|
|
128
|
+
if (structural) { renderSessions(); renderWorkspaces(); } else { paintVolatile(); }
|
|
109
129
|
if (state.active && !ids.has(state.active)) {
|
|
110
130
|
const mine = mySessions();
|
|
111
131
|
const nx = mine.find((s) => s.state !== 'exited') || mine[mine.length - 1];
|
|
112
132
|
if (nx) selectSession(nx.id); else clearActive();
|
|
113
|
-
} else { syncActiveState(); }
|
|
133
|
+
} else if (structural) { syncActiveState(); }
|
|
114
134
|
} else if (m.t === 'attached') {
|
|
115
135
|
const t = ensureTerm(m.id);
|
|
116
136
|
t.term.reset();
|
|
@@ -118,12 +138,49 @@ function handle(m) {
|
|
|
118
138
|
fitOne(m.id);
|
|
119
139
|
} else if (m.t === 'data') {
|
|
120
140
|
const t = state.terms.get(m.id); if (t) t.term.write(m.data);
|
|
141
|
+
// Bytes arriving IS the liveness signal, and it is the same fact the server stamps into
|
|
142
|
+
// lastOutputAt — so an attached session's dot flips to `working` on the byte rather than on
|
|
143
|
+
// the next heartbeat. It is a nudge, not a second source of truth: the very next heartbeat
|
|
144
|
+
// overwrites it with the server's stamp, and it only ever moves the clock FORWARD, so it can
|
|
145
|
+
// make a row read `working` a beat early but can never keep a dead row looking alive. Only
|
|
146
|
+
// attached sessions get `data` at all — which is exactly why the heartbeat is the mechanism
|
|
147
|
+
// and this is the garnish.
|
|
148
|
+
const live = state.sessions.find((x) => x.id === m.id);
|
|
149
|
+
if (live && live.state !== 'exited') { live.lastOutputAt = Date.now(); tickQuiet(); }
|
|
121
150
|
} else if (m.t === 'created') {
|
|
122
151
|
// server pushes a fresh 'sessions'; jump to the new one
|
|
123
152
|
selectSession(m.id);
|
|
153
|
+
} else if (m.t === 'branch') {
|
|
154
|
+
// a checkout changed the repo's branch — refresh the chip if it's the workspace on screen
|
|
155
|
+
if (m.ws === state.wsId) { state.branch = m; renderBranch(); }
|
|
156
|
+
} else if (m.t === 'error') {
|
|
157
|
+
// a rejected action (e.g. a failed branch checkout that must NOT spawn) — say so, don't swallow
|
|
158
|
+
flash(m.error || 'the server rejected that action');
|
|
124
159
|
}
|
|
125
160
|
}
|
|
126
161
|
|
|
162
|
+
// ── branch awareness (R2): the active workspace's current git branch, always visible. Fetched on
|
|
163
|
+
// demand (never on every broadcast) — on workspace switch, on opening the new-session form, and
|
|
164
|
+
// after a checkout the server announces. A non-repo/unreadable workspace shows NO chip, never a
|
|
165
|
+
// stale or guessed branch. ──
|
|
166
|
+
async function loadBranch() {
|
|
167
|
+
const id = state.wsId;
|
|
168
|
+
let info = { ok: false };
|
|
169
|
+
try { const r = await fetch('/api/git/branches?ws=' + encodeURIComponent(id || '')); info = await r.json(); } catch {}
|
|
170
|
+
if (id !== state.wsId) return; // a slower fetch for a workspace we've since left must not paint
|
|
171
|
+
state.branch = info; renderBranch();
|
|
172
|
+
}
|
|
173
|
+
function renderBranch() {
|
|
174
|
+
const el = $('branchChip'); if (!el) return;
|
|
175
|
+
const b = state.branch;
|
|
176
|
+
if (!b || !b.ok) { el.hidden = true; el.textContent = ''; el.title = ''; return; }
|
|
177
|
+
const label = b.detached ? 'detached HEAD' : (b.current || '');
|
|
178
|
+
el.hidden = !label;
|
|
179
|
+
el.textContent = '⎇ ' + label + (b.dirty ? '*' : '');
|
|
180
|
+
el.title = b.dirty ? 'uncommitted changes in the working tree' : 'current branch (shared by every session in this repo)';
|
|
181
|
+
el.classList.toggle('dirty', !!b.dirty);
|
|
182
|
+
}
|
|
183
|
+
|
|
127
184
|
// ── workspaces ──
|
|
128
185
|
async function selectWorkspace(id, opts = {}) {
|
|
129
186
|
if (!id) return;
|
|
@@ -138,7 +195,9 @@ async function selectWorkspace(id, opts = {}) {
|
|
|
138
195
|
const remembered = opts.restoreSession ? store.get('active') : null;
|
|
139
196
|
const pick = mine.find((s) => s.id === remembered) || mine.find((s) => s.state !== 'exited') || mine[0];
|
|
140
197
|
if (pick) selectSession(pick.id); else clearActive();
|
|
141
|
-
|
|
198
|
+
// the arrangement belongs to the repo (R7) — a different workspace brings its own layouts
|
|
199
|
+
if (changed) { state.branch = null; renderBranch(); loadScratchpad(); loadLayouts(); }
|
|
200
|
+
await Promise.all([loadLibrary(), loadNeeds(), loadBranch()]);
|
|
142
201
|
}
|
|
143
202
|
function renderWorkspaces() {
|
|
144
203
|
const html = state.workspaces.map((w) => {
|
|
@@ -186,7 +245,11 @@ const GLYPH = { working: '●', quiet: '◼', blocked: '◆', done: '✓', idle:
|
|
|
186
245
|
// The word beside the glyph. 'quiet' means no output for a while — NOT 'waiting on you'.
|
|
187
246
|
// Quiescence proves quiet, not waiting (PRD R1); the wording may not be upgraded.
|
|
188
247
|
const STATE_WORD = { working: 'working', quiet: 'quiet', exited: 'exited' };
|
|
189
|
-
|
|
248
|
+
// The threshold the server derives `quiet` from, sent in `hello`. It used to be re-typed here as a
|
|
249
|
+
// literal "mirrors harness-parse.QUIET_AFTER_MS" — two copies of one number, free to drift apart and
|
|
250
|
+
// give the sidebar and the server different opinions about the same session. The literal survives
|
|
251
|
+
// only as the value to assume before hello lands.
|
|
252
|
+
let QUIET_AFTER_MS = 30000;
|
|
190
253
|
function shortDur(ms) {
|
|
191
254
|
if (ms == null) return '';
|
|
192
255
|
const sec = Math.round(ms / 1000);
|
|
@@ -272,18 +335,30 @@ function renderSessions() {
|
|
|
272
335
|
// is doing now. When there is no resolvable title — a shell/codex/untitled/unresolved session — it
|
|
273
336
|
// falls back to the shipped `agent · cwd`, so the line is never blank and never a guessed title.
|
|
274
337
|
// When a title IS shown, `agent · cwd` moves into the harness drawer (see harnessDrawer), not lost.
|
|
275
|
-
|
|
338
|
+
// Split inner-from-wrapper so the heartbeat's in-place painter can compare and assign innerHTML.
|
|
339
|
+
// Comparing outerHTML against a source string never matches (the browser re-serialises), so it
|
|
340
|
+
// would rewrite the node on every beat — and a node rewritten twice a second is a node the PM
|
|
341
|
+
// cannot select text in.
|
|
342
|
+
function subInner(s) {
|
|
276
343
|
const title = s.hx && s.hx.title;
|
|
277
|
-
if (title) return `<span class="
|
|
278
|
-
return
|
|
344
|
+
if (title) return `<span class="lead">opening focus</span> ${esc(title)}`;
|
|
345
|
+
return `${esc(s.agent)} · ${esc(s.cwd)}`;
|
|
279
346
|
}
|
|
347
|
+
function subLine(s) { return `<span class="sub">${subInner(s)}</span>`; }
|
|
280
348
|
// The agent's own last words, verbatim — never a paraphrase (a summary would be another model
|
|
281
349
|
// call per session, and a drifting paraphrase was a leg-A defect). When the parser cannot read,
|
|
282
350
|
// it says so: an unreadable signal must never render as an empty line.
|
|
283
|
-
|
|
284
|
-
|
|
351
|
+
// {cls, html} rather than a finished span, for the same in-place-repaint reason as subInner — and
|
|
352
|
+
// here the CLASS also changes (`says` for real words, `says na` for a stated reason), so the
|
|
353
|
+
// painter has to be able to set the two independently.
|
|
354
|
+
function saysParts(s) {
|
|
355
|
+
if (s.lastWords) return { cls: 'says', html: esc(s.lastWords) };
|
|
285
356
|
const why = s.missing && s.missing.lastWords;
|
|
286
|
-
return why ?
|
|
357
|
+
return why ? { cls: 'says na', html: esc(why) } : null;
|
|
358
|
+
}
|
|
359
|
+
function saysLine(s) {
|
|
360
|
+
const p = saysParts(s);
|
|
361
|
+
return p ? `<span class="${p.cls}">${p.html}</span>` : '';
|
|
287
362
|
}
|
|
288
363
|
// The harness readout: a drill-in, not a rail. The leg-A grilling established the harness is
|
|
289
364
|
// near-static within a run, so this is read once to understand a session — not watched.
|
|
@@ -299,7 +374,7 @@ function fmtTokens(n) {
|
|
|
299
374
|
// the CLI does not PRINT its model, effort, tokens or skill set to the terminal — parsing
|
|
300
375
|
// scrollback for them returned four empty rows, which is what sent this back for a second pass.
|
|
301
376
|
// Rows that genuinely cannot be known are still drawn as unavailable, never as a plausible value.
|
|
302
|
-
function
|
|
377
|
+
function harnessRows(s) {
|
|
303
378
|
const hx = s.hx || { missing: {} };
|
|
304
379
|
const m = hx.missing || {};
|
|
305
380
|
const rows = [];
|
|
@@ -336,9 +411,50 @@ function harnessDrawer(s) {
|
|
|
336
411
|
rows.push(`<dt>gates</dt>${na('not visible from the workspace — gate state is hosted')}`);
|
|
337
412
|
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>`);
|
|
338
413
|
}
|
|
414
|
+
return rows.join('');
|
|
415
|
+
}
|
|
416
|
+
// The rows are split out of the drawer so the heartbeat can repaint the <dl> ALONE. Replacing the
|
|
417
|
+
// whole <details> would slam it shut on every push — the drill-in has to survive its own updates.
|
|
418
|
+
function harnessDrawer(s) {
|
|
339
419
|
return `<details class="hz" data-id="${s.id}">
|
|
340
420
|
<summary>model + harness<span class="sr">${esc(s.name)}</span></summary>
|
|
341
|
-
<dl class="kv">${
|
|
421
|
+
<dl class="kv">${harnessRows(s)}</dl></details>`;
|
|
422
|
+
}
|
|
423
|
+
// What a session push must REBUILD the DOM for, as opposed to repaint in place. Everything here is
|
|
424
|
+
// baked into renderSessions'/renderTabs' markup and has no in-place painter: the row's identity,
|
|
425
|
+
// its workspace, its name, its raw lifecycle state (which drives the close button's wording and the
|
|
426
|
+
// per-workspace live count), and the `scheduled` tag. Liveness and harness are deliberately ABSENT
|
|
427
|
+
// — they change on every heartbeat and are what paintVolatile exists to carry.
|
|
428
|
+
function structSig(list) {
|
|
429
|
+
// delimited with the ASCII unit/record separators, so a name ending where a state begins cannot
|
|
430
|
+
// fingerprint identically to a different split of the same characters
|
|
431
|
+
return list.map((s) => [s.id, s.ws, s.name, s.state, s.origin || ''].join('')).join('');
|
|
432
|
+
}
|
|
433
|
+
// The in-place repaint for a heartbeat push: everything on a row that changes while the run is
|
|
434
|
+
// running. Never innerHTML on the pane itself — an open drill-in, an in-flight rename, the sidebar's
|
|
435
|
+
// scroll position and any text the PM has selected all survive this.
|
|
436
|
+
function paintVolatile() {
|
|
437
|
+
tickQuiet(); // chip, glyph, data-state, accessible name (both surfaces)
|
|
438
|
+
for (const s of mySessions()) {
|
|
439
|
+
const row = $('sessions').querySelector(`.sessrow[data-id="${s.id}"]`);
|
|
440
|
+
if (!row) continue;
|
|
441
|
+
// the sub-line ("opening focus …" or the agent · cwd fallback) and the agent's last words —
|
|
442
|
+
// both come from the server and both were frozen at the last lifecycle event. Assign only on a
|
|
443
|
+
// real difference: an unconditional rewrite twice a second would drop the PM's text selection.
|
|
444
|
+
const sub = row.querySelector('.sub');
|
|
445
|
+
if (sub) { const next = subInner(s); if (sub.innerHTML !== next) sub.innerHTML = next; }
|
|
446
|
+
const says = row.querySelector('.says'), nextSays = saysParts(s);
|
|
447
|
+
if (nextSays && says) {
|
|
448
|
+
if (says.className !== nextSays.cls) says.className = nextSays.cls;
|
|
449
|
+
if (says.innerHTML !== nextSays.html) says.innerHTML = nextSays.html;
|
|
450
|
+
} else if (nextSays) {
|
|
451
|
+
row.querySelector('.sess').insertAdjacentHTML('beforeend', `<span class="${nextSays.cls}">${nextSays.html}</span>`);
|
|
452
|
+
} else if (says) { says.remove(); }
|
|
453
|
+
// the drill-in's rows — model, tokens, tools, skills. Only the <dl> is replaced, so the
|
|
454
|
+
// <details> element itself (and therefore its open/closed state) is untouched.
|
|
455
|
+
const dl = $('sessions').querySelector(`details.hz[data-id="${s.id}"] .kv`);
|
|
456
|
+
if (dl) { const next = harnessRows(s); if (dl.innerHTML !== next) dl.innerHTML = next; }
|
|
457
|
+
}
|
|
342
458
|
}
|
|
343
459
|
// A row must be able to go quiet with NO server push. Repaint the chip, glyph, data-state and
|
|
344
460
|
// accessible name only — never innerHTML — so an open drill-in and an in-flight rename survive.
|
|
@@ -356,35 +472,63 @@ function tickQuiet() {
|
|
|
356
472
|
chip.textContent = st.text;
|
|
357
473
|
btn.setAttribute('aria-label', `${s.name} — ${st.text}`);
|
|
358
474
|
}
|
|
359
|
-
|
|
360
|
-
|
|
475
|
+
// both groups list every session, so the tick has to reach every strip that shows this one
|
|
476
|
+
document.querySelectorAll(`.stabs .stab-wrap[data-id="${s.id}"]`).forEach((tab) => {
|
|
477
|
+
if (tab.dataset.state === st.shown) return;
|
|
478
|
+
tab.dataset.state = st.shown;
|
|
479
|
+
// the tab's glyph is derived too, so it has to move with the colour — otherwise the fix in
|
|
480
|
+
// renderTabs would hold only until the first tick that flipped the state without a re-render.
|
|
481
|
+
const g = tab.querySelector('.g'); if (g) g.textContent = GLYPH[st.shown] || '•';
|
|
482
|
+
const btnTab = tab.querySelector('.stab'); if (btnTab) btnTab.setAttribute('aria-label', `${s.name} — ${st.text}`);
|
|
483
|
+
});
|
|
361
484
|
}
|
|
362
485
|
}
|
|
363
486
|
try { const _q = setInterval(tickQuiet, 5000); if (_q.unref) _q.unref(); } catch {}
|
|
364
487
|
|
|
365
488
|
function renderTabs() {
|
|
489
|
+
// Both groups list EVERY session (R6): clicking a tab shows that session in THAT group, which
|
|
490
|
+
// is how the PM chooses what goes where without a drag.
|
|
491
|
+
renderTabStrip(0);
|
|
492
|
+
if (splitOn()) renderTabStrip(1);
|
|
493
|
+
paintGroups();
|
|
494
|
+
}
|
|
495
|
+
function renderTabStrip(gi) {
|
|
496
|
+
const host = $(GROUP_TABS[gi]);
|
|
497
|
+
if (!host) return;
|
|
498
|
+
const act = state.groups[gi].activeId;
|
|
366
499
|
// wrap = two SIBLING buttons (select + close) — no interactive-inside-interactive nesting
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
500
|
+
host.innerHTML = mySessions().map((s) => {
|
|
501
|
+
// the glyph and the accessible name read the DERIVED state, like the wrapper's data-state
|
|
502
|
+
// beside them. Reading the raw `s.state` here drew a `working` ● in the `quiet` colour — the
|
|
503
|
+
// shape said one thing and the colour said another, on the surface most looked at.
|
|
504
|
+
const st = stateLabel(s);
|
|
505
|
+
return `
|
|
506
|
+
<div class="stab-wrap" data-state="${st.shown}" data-id="${s.id}" data-active="${s.id === act}">
|
|
507
|
+
<button class="stab" role="tab" data-id="${s.id}" aria-selected="${s.id === act}"
|
|
508
|
+
aria-label="${esc(s.name)} — ${esc(st.text)}" title="double-click to rename">
|
|
509
|
+
<span class="g" aria-hidden="true">${GLYPH[st.shown] || '•'}</span>${esc(s.name)}</button>
|
|
372
510
|
<button class="x" data-close="${s.id}"
|
|
373
511
|
aria-label="${s.state === 'exited' ? 'remove' : 'close'} ${esc(s.name)}">✕</button>
|
|
374
|
-
</div
|
|
375
|
-
|
|
376
|
-
|
|
512
|
+
</div>`;
|
|
513
|
+
}).join('');
|
|
514
|
+
host.querySelectorAll('.stab').forEach((b) => {
|
|
515
|
+
b.onclick = () => selectSession(b.dataset.id, gi);
|
|
377
516
|
b.ondblclick = () => startRename(b.dataset.id);
|
|
378
517
|
});
|
|
379
518
|
// live tab ✕ kills the process (state flips to exited); exited tab ✕ removes it for good
|
|
380
|
-
|
|
519
|
+
host.querySelectorAll('.x').forEach((b) => b.onclick = () => send({ t: 'kill', id: b.dataset.close }));
|
|
381
520
|
}
|
|
382
521
|
function syncActiveState() {
|
|
383
522
|
document.querySelectorAll('.sess,.stab-wrap').forEach((b) => {
|
|
384
523
|
const s = state.sessions.find((x) => x.id === b.dataset.id);
|
|
385
|
-
|
|
524
|
+
// the DERIVED state (working|quiet|exited), never the raw one. Writing `s.state` here put
|
|
525
|
+
// `working` back over a row that renderSessions/tickQuiet had correctly painted `quiet`, so
|
|
526
|
+
// the dot's colour depended on which of the two ran last — a coin-flip at the old push rate,
|
|
527
|
+
// and a visible 2s/5s flicker once the liveness heartbeat started pushing on a cadence.
|
|
528
|
+
if (s) b.dataset.state = stateLabel(s).shown;
|
|
386
529
|
});
|
|
387
530
|
updateExitedOverlay();
|
|
531
|
+
refreshScratchSend(); // the scratchpad's send button follows the active session's liveness
|
|
388
532
|
}
|
|
389
533
|
|
|
390
534
|
// ── renaming a session (R11): inline, in both surfaces, live or exited. The name chosen at
|
|
@@ -409,7 +553,9 @@ function paintRename() {
|
|
|
409
553
|
const id = state.renaming;
|
|
410
554
|
if (!id) return;
|
|
411
555
|
const row = $('sessions').querySelector(`.sessrow[data-id="${id}"]`);
|
|
412
|
-
|
|
556
|
+
// rename in place wherever the PM double-clicked: the sidebar row, or either group's tab
|
|
557
|
+
const tab = $(GROUP_TABS[state.focusG]).querySelector(`.stab-wrap[data-id="${id}"]`)
|
|
558
|
+
|| document.querySelector(`.stabs .stab-wrap[data-id="${id}"]`);
|
|
413
559
|
const host = row || tab;
|
|
414
560
|
if (!host) return;
|
|
415
561
|
const input = document.createElement('input');
|
|
@@ -430,12 +576,26 @@ function paintRename() {
|
|
|
430
576
|
}
|
|
431
577
|
|
|
432
578
|
// ── terminals ──
|
|
433
|
-
|
|
579
|
+
// Sessions live in GROUPS (R6). One group is the shipped behaviour; a split layout shows a second
|
|
580
|
+
// beside it. A session is visible in at most one group at a time — its pane MOVES between hosts
|
|
581
|
+
// rather than being duplicated, so there is never a second xterm bound to the same PTY.
|
|
582
|
+
const GROUP_HOST = ['termhost', 'termhost1'];
|
|
583
|
+
const GROUP_TABS = ['stabs', 'stabs1'];
|
|
584
|
+
function groupOf(id) { const t = state.terms.get(id); return t ? t.g : 0; }
|
|
585
|
+
function groupHost(g) { return $(GROUP_HOST[g] || GROUP_HOST[0]); }
|
|
586
|
+
function splitOn() { return !$('group1').hidden; }
|
|
587
|
+
function syncActive() { state.active = state.groups[state.focusG].activeId; }
|
|
588
|
+
|
|
589
|
+
function ensureTerm(id, g) {
|
|
590
|
+
const want = typeof g === 'number' ? g : 0;
|
|
434
591
|
let t = state.terms.get(id);
|
|
435
|
-
if (t)
|
|
592
|
+
if (t) {
|
|
593
|
+
if (t.g !== want) { t.g = want; groupHost(want).appendChild(t.el); fitOne(id); }
|
|
594
|
+
return t;
|
|
595
|
+
}
|
|
436
596
|
const el = document.createElement('div');
|
|
437
597
|
el.className = 'termpane'; el.dataset.id = id;
|
|
438
|
-
|
|
598
|
+
groupHost(want).appendChild(el);
|
|
439
599
|
const term = new window.Terminal({
|
|
440
600
|
fontFamily: '"IBM Plex Mono", ui-monospace, monospace', fontSize: 13, theme: xtheme(),
|
|
441
601
|
cursorBlink: true, scrollback: 5000, allowProposedApi: true,
|
|
@@ -455,7 +615,7 @@ function ensureTerm(id) {
|
|
|
455
615
|
});
|
|
456
616
|
term.onData((d) => send({ t: 'input', id, data: applyCtrl(d) }));
|
|
457
617
|
term.onResize(({ cols, rows }) => send({ t: 'resize', id, cols, rows }));
|
|
458
|
-
t = { term, fit, el };
|
|
618
|
+
t = { term, fit, el, g: want };
|
|
459
619
|
state.terms.set(id, t);
|
|
460
620
|
return t;
|
|
461
621
|
}
|
|
@@ -464,43 +624,104 @@ function fitOne(id) {
|
|
|
464
624
|
requestAnimationFrame(() => { try { t.fit.fit(); } catch {} });
|
|
465
625
|
}
|
|
466
626
|
// Anything that changes the terminal's box must re-fit it, or xterm keeps rendering more rows
|
|
467
|
-
// than fit and the BOTTOM OF THE TERMINAL disappears under the clip (R9).
|
|
468
|
-
//
|
|
469
|
-
//
|
|
627
|
+
// than fit and the BOTTOM OF THE TERMINAL disappears under the clip (R9). Observers on the hosts
|
|
628
|
+
// catch every cause — the needs strip, the reader, a folded pane, the tab strip wrapping, a window
|
|
629
|
+
// resize, a layout change, a split — instead of a fitOne() call sprinkled at each call site.
|
|
630
|
+
// EVERY group's visible session is re-fitted: fitting only the focused one leaves the other
|
|
631
|
+
// group rendering rows for the width it had before the split.
|
|
470
632
|
let refitTimer = null;
|
|
471
633
|
function refit() {
|
|
472
634
|
clearTimeout(refitTimer);
|
|
473
|
-
refitTimer = setTimeout(() => {
|
|
635
|
+
refitTimer = setTimeout(() => {
|
|
636
|
+
for (const g of state.groups) if (g.activeId) fitOne(g.activeId);
|
|
637
|
+
}, 30);
|
|
474
638
|
}
|
|
475
|
-
if (window.ResizeObserver) new ResizeObserver(refit).observe($('termhost'));
|
|
639
|
+
if (window.ResizeObserver) { const ro = new ResizeObserver(refit); ro.observe($('termhost')); ro.observe($('termhost1')); }
|
|
476
640
|
window.addEventListener('resize', refit);
|
|
477
641
|
document.addEventListener('visibilitychange', () => { if (!document.hidden) refit(); });
|
|
478
642
|
// first paint measures the fallback font; the real metrics arrive with the webfont
|
|
479
643
|
if (document.fonts && document.fonts.ready) document.fonts.ready.then(() => { for (const [id] of state.terms) fitOne(id); });
|
|
480
644
|
|
|
481
|
-
function selectSession(id) {
|
|
645
|
+
function selectSession(id, g) {
|
|
482
646
|
const s = state.sessions.find((x) => x.id === id);
|
|
483
647
|
if (!s) return;
|
|
484
648
|
// selecting a session from the palette may cross workspaces — follow it there
|
|
485
|
-
if (s.ws !== state.wsId) { state.wsId = s.ws; store.set('wsid', s.ws); renderWorkspaces(); renderSessions(); loadLibrary(); loadNeeds(); }
|
|
486
|
-
|
|
649
|
+
if (s.ws !== state.wsId) { state.wsId = s.ws; store.set('wsid', s.ws); renderWorkspaces(); renderSessions(); loadLibrary(); loadNeeds(); state.branch = null; renderBranch(); loadBranch(); loadScratchpad(); loadLayouts(); }
|
|
650
|
+
// Which group shows it: the one asked for; else the group already showing it (so a click in the
|
|
651
|
+
// sidebar focuses where it lives rather than yanking it across); else the focused group.
|
|
652
|
+
let want = typeof g === 'number' ? g : state.groups.findIndex((gr) => gr.activeId === id);
|
|
653
|
+
if (want < 0) want = state.focusG;
|
|
654
|
+
if (want === 1 && !splitOn()) want = 0;
|
|
655
|
+
// the same session must never render in both groups — the other one gives it up
|
|
656
|
+
const other = want === 0 ? 1 : 0;
|
|
657
|
+
if (state.groups[other].activeId === id) state.groups[other].activeId = null;
|
|
658
|
+
state.groups[want].activeId = id;
|
|
659
|
+
state.focusG = want;
|
|
660
|
+
syncActive();
|
|
487
661
|
store.set('active', id); // survives reloads (R7)
|
|
488
662
|
// attach once per connection — re-selecting an attached session must not reset + re-replay
|
|
489
663
|
if (!state.attached.has(id)) { state.attached.add(id); send({ t: 'attach', id }); }
|
|
490
|
-
ensureTerm(id);
|
|
491
|
-
|
|
492
|
-
document.querySelectorAll('.sess').forEach((b) => b.setAttribute('aria-current', String(b.dataset.id === id)));
|
|
493
|
-
document.querySelectorAll('.stab').forEach((b) => b.setAttribute('aria-selected', String(b.dataset.id === id)));
|
|
494
|
-
document.querySelectorAll('.stab-wrap').forEach((b) => b.dataset.active = String(b.dataset.id === id));
|
|
664
|
+
ensureTerm(id, want);
|
|
665
|
+
paintGroups();
|
|
495
666
|
fitOne(id);
|
|
496
667
|
const t = state.terms.get(id); if (t) setTimeout(() => t.term.focus(), 0);
|
|
497
668
|
updateExitedOverlay(); updateEmptyOverlay();
|
|
498
669
|
}
|
|
670
|
+
// One place decides what is visible where: each group shows exactly its own activeId, every other
|
|
671
|
+
// pane is hidden, and the sidebar/tab selection marks follow.
|
|
672
|
+
function paintGroups() {
|
|
673
|
+
for (const [sid, t] of state.terms) {
|
|
674
|
+
const gr = state.groups[t.g];
|
|
675
|
+
t.el.classList.toggle('on', !!gr && gr.activeId === sid);
|
|
676
|
+
}
|
|
677
|
+
const shown = new Set(state.groups.map((gr) => gr.activeId).filter(Boolean));
|
|
678
|
+
document.querySelectorAll('.sess').forEach((b) => b.setAttribute('aria-current', String(shown.has(b.dataset.id))));
|
|
679
|
+
GROUP_TABS.forEach((tid, gi) => {
|
|
680
|
+
const host = $(tid); if (!host) return;
|
|
681
|
+
const act = state.groups[gi].activeId;
|
|
682
|
+
host.querySelectorAll('.stab').forEach((b) => b.setAttribute('aria-selected', String(b.dataset.id === act)));
|
|
683
|
+
host.querySelectorAll('.stab-wrap').forEach((b) => b.dataset.active = String(b.dataset.id === act));
|
|
684
|
+
});
|
|
685
|
+
$('group0').dataset.focus = String(state.focusG === 0);
|
|
686
|
+
$('group1').dataset.focus = String(state.focusG === 1);
|
|
687
|
+
$('noSess1').classList.toggle('on', splitOn() && !state.groups[1].activeId);
|
|
688
|
+
}
|
|
499
689
|
function clearActive() {
|
|
500
|
-
state.
|
|
501
|
-
|
|
690
|
+
state.groups.forEach((g) => { g.activeId = null; });
|
|
691
|
+
syncActive();
|
|
692
|
+
document.querySelectorAll('.termpane').forEach((e) => e.classList.remove('on'));
|
|
693
|
+
paintGroups();
|
|
502
694
|
updateExitedOverlay(); updateEmptyOverlay();
|
|
503
695
|
}
|
|
696
|
+
|
|
697
|
+
// ── split / un-split (R6) ──
|
|
698
|
+
function applySplit(on) {
|
|
699
|
+
const was = splitOn();
|
|
700
|
+
$('group1').hidden = !on;
|
|
701
|
+
$('groups').dataset.split = on ? 'on' : 'off';
|
|
702
|
+
if (on && !state.groups[1].activeId) {
|
|
703
|
+
// fill the second group with the next session that is not already on screen; if there is
|
|
704
|
+
// none, the empty-group card offers `+ new session` rather than a black rectangle
|
|
705
|
+
const taken = new Set([state.groups[0].activeId]);
|
|
706
|
+
const next = mySessions().find((s) => !taken.has(s.id));
|
|
707
|
+
if (next) {
|
|
708
|
+
state.groups[1].activeId = next.id;
|
|
709
|
+
if (!state.attached.has(next.id)) { state.attached.add(next.id); send({ t: 'attach', id: next.id }); }
|
|
710
|
+
ensureTerm(next.id, 1);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
if (!on && state.groups[1].activeId) {
|
|
714
|
+
// un-split does NOT close the session — it goes back to being a tab in the primary group
|
|
715
|
+
const id = state.groups[1].activeId;
|
|
716
|
+
state.groups[1].activeId = null;
|
|
717
|
+
const t = state.terms.get(id);
|
|
718
|
+
if (t) { t.g = 0; groupHost(0).appendChild(t.el); }
|
|
719
|
+
if (state.focusG === 1) state.focusG = 0;
|
|
720
|
+
}
|
|
721
|
+
if (was !== on) renderTabs();
|
|
722
|
+
syncActive();
|
|
723
|
+
paintGroups();
|
|
724
|
+
}
|
|
504
725
|
function updateExitedOverlay() {
|
|
505
726
|
const s = state.sessions.find((x) => x.id === state.active);
|
|
506
727
|
const ov = $('exited');
|
|
@@ -512,6 +733,7 @@ function updateExitedOverlay() {
|
|
|
512
733
|
$('respawnBtn').onclick = () => send({ t: 'respawn', id: s.id });
|
|
513
734
|
$('removeBtn').onclick = () => send({ t: 'remove', id: s.id });
|
|
514
735
|
} else ov.classList.remove('on');
|
|
736
|
+
refreshScratchSend(); // send-to-terminal follows whether the active session is live
|
|
515
737
|
}
|
|
516
738
|
function updateEmptyOverlay() {
|
|
517
739
|
// a workspace with no sessions must say so — not show another workspace's terminal
|
|
@@ -715,57 +937,362 @@ function closeDoc() {
|
|
|
715
937
|
refit();
|
|
716
938
|
}
|
|
717
939
|
|
|
718
|
-
//
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
940
|
+
// ══ THE LAYOUT SYSTEM (workspace-layout-system) ═════════════════════════════════════════════
|
|
941
|
+
// A layout is a named state tuple; the grid template is computed from it by lib/layout.js. The
|
|
942
|
+
// PM picks one in one act, then adjusts it freely — and the adjustment is kept against THAT
|
|
943
|
+
// layout automatically. There is no dirty state, no save and no discard (R4), which is why
|
|
944
|
+
// `restore` on a stock layout is not a nicety: it is the only way back.
|
|
945
|
+
const LAY = window.PMOSLayout;
|
|
946
|
+
const NARROW = window.matchMedia('(max-width:1100px)');
|
|
947
|
+
const PHONE = window.matchMedia('(max-width:760px)');
|
|
948
|
+
|
|
949
|
+
// Start from the stock four so the control is populated and `review` — today's arrangement — is
|
|
950
|
+
// on screen before the server's copy arrives. loadLayouts() replaces this per workspace.
|
|
951
|
+
state.layouts = LAY.stockLayouts();
|
|
952
|
+
state.layoutId = LAY.DEFAULT_ID;
|
|
953
|
+
|
|
954
|
+
function curLayout() {
|
|
955
|
+
return state.layouts.find((l) => l.id === state.layoutId) || state.layouts[0] || LAY.stockLayout(LAY.DEFAULT_ID);
|
|
726
956
|
}
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
957
|
+
|
|
958
|
+
// Write the arrangement into the DOM. Custom properties, never direct grid declarations — a
|
|
959
|
+
// direct one would outrank the ≤760px media query and take the phone's switcher with it (R9).
|
|
960
|
+
function applyLayout() {
|
|
961
|
+
const l = curLayout();
|
|
962
|
+
const body = $('bodyGrid');
|
|
963
|
+
// Below 1100px the reader floats over the terminal instead of squeezing it, so its track comes
|
|
964
|
+
// OUT of the template; below that again the phone's own system owns the whole thing.
|
|
965
|
+
const float = NARROW.matches && !PHONE.matches && (l.reader === 'left' || l.reader === 'right');
|
|
966
|
+
const eff = float ? Object.assign({}, l, { reader: 'off' }) : l;
|
|
967
|
+
const g = LAY.gridFor(eff);
|
|
968
|
+
body.style.setProperty('--lay-cols', g.columns);
|
|
969
|
+
body.style.setProperty('--lay-rows', g.rows);
|
|
970
|
+
body.style.setProperty('--lay-areas', g.areas);
|
|
971
|
+
body.dataset.side = l.side;
|
|
972
|
+
body.dataset.reader = float ? l.reader : l.reader; // the pane still exists; it just floats
|
|
973
|
+
body.dataset.float = float ? 'on' : 'off';
|
|
974
|
+
if (float) body.style.setProperty('--lay-areas', g.areas);
|
|
975
|
+
|
|
976
|
+
// folds
|
|
977
|
+
paintFold('paneWs', 'wsFold', l.folds.ws);
|
|
978
|
+
paintFold('paneSess', 'sessFold', l.folds.sess);
|
|
979
|
+
paintFold('paneLib', 'libFold', l.folds.lib);
|
|
980
|
+
paintFold('paneDoc', 'docFold', l.folds.doc);
|
|
981
|
+
|
|
982
|
+
// split is a desktop arrangement; below 1100px two groups are two unusable terminals (R9)
|
|
983
|
+
applySplit(l.split === 'on' && !NARROW.matches);
|
|
984
|
+
|
|
985
|
+
$('layName').textContent = l.name;
|
|
986
|
+
renderLayoutMenu();
|
|
733
987
|
refit();
|
|
734
988
|
}
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
989
|
+
function paintFold(paneId, btnId, open) {
|
|
990
|
+
const pane = $(paneId), btn = $(btnId);
|
|
991
|
+
if (!pane) return;
|
|
992
|
+
pane.dataset.fold = open ? 'on' : 'off';
|
|
993
|
+
if (btn) btn.setAttribute('aria-expanded', String(open));
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
// Mutate the current layout and keep it. Every adjustment path funnels through here, so there is
|
|
997
|
+
// exactly one place that decides what "kept" means — and exactly one write-back to debounce.
|
|
998
|
+
function tweak(patch) {
|
|
999
|
+
const l = curLayout();
|
|
1000
|
+
Object.assign(l, patch);
|
|
1001
|
+
l.lastUsedAt = Date.now();
|
|
1002
|
+
applyLayout();
|
|
1003
|
+
saveLayouts();
|
|
1004
|
+
}
|
|
1005
|
+
function selectLayout(id) {
|
|
1006
|
+
if (!state.layouts.some((l) => l.id === id)) return;
|
|
1007
|
+
state.layoutId = id;
|
|
1008
|
+
const l = curLayout();
|
|
1009
|
+
l.lastUsedAt = Date.now();
|
|
1010
|
+
applyLayout();
|
|
1011
|
+
saveLayouts();
|
|
1012
|
+
}
|
|
1013
|
+
// R4's escape hatch. Auto-keep is destructive: without this a flattened `focus` is gone for good.
|
|
1014
|
+
function restoreLayout(id) {
|
|
1015
|
+
const fresh = LAY.stockLayout(id);
|
|
1016
|
+
if (!fresh) return;
|
|
1017
|
+
const i = state.layouts.findIndex((l) => l.id === id);
|
|
1018
|
+
if (i < 0) return;
|
|
1019
|
+
fresh.lastUsedAt = Date.now();
|
|
1020
|
+
state.layouts[i] = fresh;
|
|
1021
|
+
applyLayout();
|
|
1022
|
+
saveLayouts();
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
// ── persistence: per workspace, server-side (R7) ──
|
|
1026
|
+
async function loadLayouts() {
|
|
1027
|
+
let doc = null;
|
|
1028
|
+
try {
|
|
1029
|
+
const r = await fetch('/api/layouts?ws=' + encodeURIComponent(state.wsId || ''));
|
|
1030
|
+
doc = await r.json();
|
|
1031
|
+
} catch { doc = null; }
|
|
1032
|
+
if (!doc || !doc.ok || !Array.isArray(doc.layouts) || !doc.layouts.length) {
|
|
1033
|
+
// R8 — a workspace with nothing stored inherits whatever the PM had already tuned by hand
|
|
1034
|
+
// in the pre-feature client, so the upgrade never costs them their rail width.
|
|
1035
|
+
const legacy = {};
|
|
1036
|
+
for (const k of ['rail', 'railw', 'libsplit', 'fold-lib', 'fold-doc']) legacy[k] = store.get(k);
|
|
1037
|
+
const migrated = LAY.migrateLegacy(legacy);
|
|
1038
|
+
state.layouts = migrated.layouts;
|
|
1039
|
+
state.layoutId = migrated.currentId;
|
|
1040
|
+
applyLayout();
|
|
1041
|
+
saveLayouts(); // write it once so the migration is not re-run every load
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
1044
|
+
const clean = LAY.sanitize(doc);
|
|
1045
|
+
state.layouts = clean.layouts;
|
|
1046
|
+
state.layoutId = clean.currentId;
|
|
1047
|
+
applyLayout();
|
|
1048
|
+
}
|
|
1049
|
+
function layoutBody() {
|
|
1050
|
+
return JSON.stringify({ ws: state.wsId, layouts: state.layouts, currentId: state.layoutId });
|
|
1051
|
+
}
|
|
1052
|
+
function saveLayouts() {
|
|
1053
|
+
state.layoutDirty = true;
|
|
1054
|
+
clearTimeout(state.layoutSaveT);
|
|
1055
|
+
state.layoutSaveT = setTimeout(async () => {
|
|
1056
|
+
if (!state.wsId) return;
|
|
1057
|
+
const body = layoutBody();
|
|
1058
|
+
try {
|
|
1059
|
+
await fetch('/api/layouts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body });
|
|
1060
|
+
state.layoutDirty = false;
|
|
1061
|
+
} catch {} // a failed write costs the arrangement, never the session — stay quiet
|
|
1062
|
+
}, 200);
|
|
1063
|
+
}
|
|
1064
|
+
// A debounce alone loses the write when the PM picks a layout and immediately closes the tab —
|
|
1065
|
+
// measured, not theorised: the browser suite caught exactly that. sendBeacon is the one send that
|
|
1066
|
+
// survives teardown, so the last thing they chose is the thing that comes back.
|
|
1067
|
+
function flushLayouts() {
|
|
1068
|
+
if (!state.layoutDirty || !state.wsId) return;
|
|
1069
|
+
clearTimeout(state.layoutSaveT);
|
|
1070
|
+
state.layoutDirty = false;
|
|
1071
|
+
try {
|
|
1072
|
+
const blob = new Blob([layoutBody()], { type: 'application/json' });
|
|
1073
|
+
if (!navigator.sendBeacon || !navigator.sendBeacon('/api/layouts', blob)) {
|
|
1074
|
+
fetch('/api/layouts', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
1075
|
+
body: layoutBody(), keepalive: true });
|
|
1076
|
+
}
|
|
1077
|
+
} catch {}
|
|
1078
|
+
}
|
|
1079
|
+
window.addEventListener('pagehide', flushLayouts);
|
|
1080
|
+
document.addEventListener('visibilitychange', () => { if (document.hidden) flushLayouts(); });
|
|
1081
|
+
|
|
1082
|
+
// ── the layout menu ──
|
|
1083
|
+
function layoutWire(l) {
|
|
1084
|
+
// a wireframe of the arrangement: the name alone does not say what you get
|
|
1085
|
+
const cells = [];
|
|
1086
|
+
if (l.side === 'left') cells.push(1);
|
|
1087
|
+
if (l.reader === 'left') cells.push(3);
|
|
1088
|
+
cells.push(l.split === 'on' ? 5 : 4);
|
|
1089
|
+
if (l.reader === 'right') cells.push(3);
|
|
1090
|
+
if (l.side === 'right') cells.push(1);
|
|
1091
|
+
const rows = l.reader === 'bottom' ? '<i style="flex:1"></i>' : '';
|
|
1092
|
+
return `<span class="lay-mini" aria-hidden="true">${cells.map((w) => `<span style="flex:${w}"></span>`).join('')}${rows}</span>`;
|
|
1093
|
+
}
|
|
1094
|
+
function renderLayoutMenu() {
|
|
1095
|
+
const list = $('layList');
|
|
1096
|
+
if (!list) return;
|
|
1097
|
+
list.innerHTML = state.layouts.map((l) => {
|
|
1098
|
+
const on = l.id === state.layoutId;
|
|
1099
|
+
const stock = LAY.isStock(l.id);
|
|
1100
|
+
return `<div class="lay-line" data-id="${esc(l.id)}" style="display:flex;gap:2px;align-items:center">
|
|
1101
|
+
<button class="lay-row" data-pick="${esc(l.id)}" aria-current="${on}"
|
|
1102
|
+
aria-label="${esc(l.name)} layout">${layoutWire(l)}<span class="nm">${esc(l.name)}</span>
|
|
1103
|
+
${l.key ? `<span class="kbd">⌃${l.key}</span>` : ''}</button>
|
|
1104
|
+
${stock
|
|
1105
|
+
? `<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>`
|
|
1106
|
+
: `<button class="lay-act" data-rename="${esc(l.id)}" title="Rename" aria-label="Rename ${esc(l.name)}">✎</button>
|
|
1107
|
+
<button class="lay-act del" data-del="${esc(l.id)}" title="Delete" aria-label="Delete ${esc(l.name)}">✕</button>`}
|
|
1108
|
+
</div>`;
|
|
1109
|
+
}).join('') + (NARROW.matches
|
|
1110
|
+
? '<p class="lay-narrow">This window is too narrow for two terminal groups — split is off until it is wider.</p>'
|
|
1111
|
+
: '');
|
|
1112
|
+
list.querySelectorAll('[data-pick]').forEach((b) => b.onclick = () => { selectLayout(b.dataset.pick); closeLayMenu(); });
|
|
1113
|
+
list.querySelectorAll('[data-restore]').forEach((b) => b.onclick = () => restoreLayout(b.dataset.restore));
|
|
1114
|
+
list.querySelectorAll('[data-del]').forEach((b) => b.onclick = () => {
|
|
1115
|
+
state.layouts = state.layouts.filter((l) => l.id !== b.dataset.del);
|
|
1116
|
+
if (state.layoutId === b.dataset.del) state.layoutId = LAY.DEFAULT_ID;
|
|
1117
|
+
applyLayout(); saveLayouts();
|
|
1118
|
+
});
|
|
1119
|
+
list.querySelectorAll('[data-rename]').forEach((b) => b.onclick = () => {
|
|
1120
|
+
const l = state.layouts.find((x) => x.id === b.dataset.rename);
|
|
1121
|
+
if (!l) return;
|
|
1122
|
+
openLayNew(l.name, l.id);
|
|
1123
|
+
});
|
|
1124
|
+
const w = state.workspaces.find((x) => x.id === state.wsId);
|
|
1125
|
+
if ($('layScope')) $('layScope').textContent = w ? w.name : 'this repo';
|
|
1126
|
+
}
|
|
1127
|
+
function openLayMenu() { $('layMenu').hidden = false; $('layBtn').setAttribute('aria-expanded', 'true'); renderLayoutMenu(); }
|
|
1128
|
+
function closeLayMenu() { $('layMenu').hidden = true; $('layBtn').setAttribute('aria-expanded', 'false'); closeLayNew(); }
|
|
1129
|
+
function openLayNew(value, renameId) {
|
|
1130
|
+
$('layNewForm').hidden = false;
|
|
1131
|
+
$('layNewForm').dataset.rename = renameId || '';
|
|
1132
|
+
$('layNewName').value = value || '';
|
|
1133
|
+
$('layNewName').focus(); $('layNewName').select();
|
|
1134
|
+
}
|
|
1135
|
+
function closeLayNew() { $('layNewForm').hidden = true; $('layNewForm').dataset.rename = ''; $('layNewName').value = ''; }
|
|
1136
|
+
$('layBtn').onclick = () => ($('layMenu').hidden ? openLayMenu() : closeLayMenu());
|
|
1137
|
+
$('layNewBtn').onclick = () => openLayNew(newLayoutName());
|
|
1138
|
+
$('layNewCancel').onclick = () => closeLayNew();
|
|
1139
|
+
$('layNewForm').onsubmit = (e) => {
|
|
1140
|
+
e.preventDefault();
|
|
1141
|
+
const name = $('layNewName').value.trim().slice(0, 40);
|
|
1142
|
+
if (!name) return;
|
|
1143
|
+
const renameId = $('layNewForm').dataset.rename;
|
|
1144
|
+
if (renameId) {
|
|
1145
|
+
const l = state.layouts.find((x) => x.id === renameId);
|
|
1146
|
+
if (l && !LAY.isStock(l.id)) l.name = name;
|
|
1147
|
+
} else {
|
|
1148
|
+
const l = Object.assign({}, curLayout(), { id: 'c' + Date.now().toString(36), name, key: null, lastUsedAt: Date.now() });
|
|
1149
|
+
l.folds = Object.assign({}, curLayout().folds);
|
|
1150
|
+
state.layouts.push(l);
|
|
1151
|
+
state.layoutId = l.id;
|
|
1152
|
+
}
|
|
1153
|
+
closeLayNew(); applyLayout(); saveLayouts();
|
|
1154
|
+
};
|
|
1155
|
+
function newLayoutName() {
|
|
1156
|
+
const base = curLayout().name + ' (mine)';
|
|
1157
|
+
let n = base, i = 2;
|
|
1158
|
+
while (state.layouts.some((l) => l.name === n)) n = base + ' ' + (i++);
|
|
1159
|
+
return n;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
// ── folds: both columns, same behaviour (R11) ──
|
|
1163
|
+
function foldPane(which, open) {
|
|
1164
|
+
const map = { doc: 'paneDoc', lib: 'paneLib', ws: 'paneWs', sess: 'paneSess' };
|
|
1165
|
+
if (!map[which]) return;
|
|
1166
|
+
const l = curLayout();
|
|
1167
|
+
const folds = Object.assign({}, l.folds);
|
|
1168
|
+
folds[which] = open;
|
|
1169
|
+
tweak({ folds });
|
|
1170
|
+
}
|
|
1171
|
+
function showRail() { if (curLayout().reader === 'off') tweak({ reader: 'right' }); }
|
|
741
1172
|
|
|
742
1173
|
$('libFold').onclick = () => foldPane('lib', $('paneLib').dataset.fold === 'off');
|
|
743
1174
|
$('docFold').onclick = () => foldPane('doc', $('paneDoc').dataset.fold === 'off');
|
|
744
|
-
$('
|
|
1175
|
+
$('wsFold').onclick = () => foldPane('ws', $('paneWs').dataset.fold === 'off');
|
|
1176
|
+
$('sessFold').onclick = () => foldPane('sess', $('paneSess').dataset.fold === 'off');
|
|
745
1177
|
|
|
746
|
-
//
|
|
747
|
-
//
|
|
748
|
-
(
|
|
749
|
-
|
|
1178
|
+
// ── the edges: drag a column's outer edge to resize it. Keeps to the layout, like everything
|
|
1179
|
+
// else — a width the PM chose is part of the arrangement, not a separate preference. ──
|
|
1180
|
+
function wireEdge(el) {
|
|
1181
|
+
if (!el) return;
|
|
1182
|
+
const which = el.dataset.edge;
|
|
750
1183
|
let dragging = false;
|
|
751
|
-
const
|
|
752
|
-
const
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
1184
|
+
const applyPx = (px) => {
|
|
1185
|
+
const l = curLayout();
|
|
1186
|
+
if (which === 'side') tweak({ sideW: Math.round(Math.min(Math.max(px, LAY.SIDE_MIN), LAY.SIDE_MAX)) });
|
|
1187
|
+
else tweak({ readW: Math.round(Math.min(Math.max(px, LAY.READ_MIN), Math.min(LAY.READ_MAX, window.innerWidth - 360))) });
|
|
1188
|
+
void l;
|
|
756
1189
|
};
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
1190
|
+
const fromPointer = (clientX) => {
|
|
1191
|
+
const l = curLayout();
|
|
1192
|
+
const r = $('bodyGrid').getBoundingClientRect();
|
|
1193
|
+
if (which === 'side') return l.side === 'right' ? r.right - clientX : clientX - r.left;
|
|
1194
|
+
return l.reader === 'left' ? clientX - r.left : r.right - clientX;
|
|
1195
|
+
};
|
|
1196
|
+
el.addEventListener('pointerdown', (e) => { dragging = true; el.classList.add('on'); el.setPointerCapture(e.pointerId); e.preventDefault(); });
|
|
1197
|
+
el.addEventListener('pointermove', (e) => { if (dragging) applyPx(fromPointer(e.clientX)); });
|
|
1198
|
+
el.addEventListener('pointerup', (e) => { dragging = false; el.classList.remove('on'); try { el.releasePointerCapture(e.pointerId); } catch {} refit(); });
|
|
1199
|
+
el.addEventListener('keydown', (e) => {
|
|
761
1200
|
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
|
|
762
1201
|
e.preventDefault();
|
|
763
|
-
const
|
|
764
|
-
|
|
1202
|
+
const l = curLayout();
|
|
1203
|
+
const cur = which === 'side' ? l.sideW : l.readW;
|
|
1204
|
+
const grow = which === 'side' ? (l.side === 'right' ? e.key === 'ArrowLeft' : e.key === 'ArrowRight')
|
|
1205
|
+
: (l.reader === 'left' ? e.key === 'ArrowRight' : e.key === 'ArrowLeft');
|
|
1206
|
+
applyPx(cur + (grow ? 24 : -24));
|
|
765
1207
|
refit();
|
|
766
1208
|
});
|
|
1209
|
+
}
|
|
1210
|
+
wireEdge($('railGrip'));
|
|
1211
|
+
wireEdge($('sideEdge'));
|
|
1212
|
+
|
|
1213
|
+
// ── the grips: drag a pane to a new home. An ACCELERATOR — every position a drop zone offers is
|
|
1214
|
+
// also reachable from the layout menu, so a pointer is never required to arrange the window. ──
|
|
1215
|
+
(() => {
|
|
1216
|
+
const body = $('bodyGrid');
|
|
1217
|
+
let drag = null, ghost = null;
|
|
1218
|
+
const zones = () => [...document.querySelectorAll('.dz')];
|
|
1219
|
+
function allowed(pane, z) { return !(pane === 'side' && z === 'bottom'); } // the sidebar has no bottom home
|
|
1220
|
+
function hit(e) {
|
|
1221
|
+
let found = null;
|
|
1222
|
+
zones().forEach((d) => {
|
|
1223
|
+
const r = d.getBoundingClientRect();
|
|
1224
|
+
const inside = e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom;
|
|
1225
|
+
const ok = inside && allowed(drag, d.dataset.z);
|
|
1226
|
+
d.classList.toggle('hot', ok);
|
|
1227
|
+
if (ok) found = d.dataset.z;
|
|
1228
|
+
});
|
|
1229
|
+
return found;
|
|
1230
|
+
}
|
|
1231
|
+
function move(e) {
|
|
1232
|
+
if (!drag) return;
|
|
1233
|
+
if (ghost) { ghost.style.left = (e.clientX + 12) + 'px'; ghost.style.top = (e.clientY + 12) + 'px'; }
|
|
1234
|
+
hit(e);
|
|
1235
|
+
}
|
|
1236
|
+
function end(e) {
|
|
1237
|
+
if (!drag) return;
|
|
1238
|
+
const z = hit(e);
|
|
1239
|
+
if (z) tweak(drag === 'side' ? { side: z === 'bottom' ? curLayout().side : z } : { reader: z });
|
|
1240
|
+
body.classList.remove('dragging');
|
|
1241
|
+
zones().forEach((d) => { d.classList.remove('hot'); d.classList.remove('no'); });
|
|
1242
|
+
if (ghost) { ghost.remove(); ghost = null; }
|
|
1243
|
+
drag = null;
|
|
1244
|
+
}
|
|
1245
|
+
document.querySelectorAll('.cap .grip').forEach((g) => {
|
|
1246
|
+
g.addEventListener('pointerdown', (e) => {
|
|
1247
|
+
if (PHONE.matches) return; // the switcher owns layout on touch
|
|
1248
|
+
e.preventDefault();
|
|
1249
|
+
drag = g.dataset.pane;
|
|
1250
|
+
body.classList.add('dragging');
|
|
1251
|
+
zones().forEach((d) => d.classList.toggle('no', !allowed(drag, d.dataset.z)));
|
|
1252
|
+
ghost = document.createElement('div');
|
|
1253
|
+
ghost.className = 'laghost';
|
|
1254
|
+
ghost.textContent = drag === 'side' ? 'workspaces · running agents' : 'library · document';
|
|
1255
|
+
document.body.appendChild(ghost);
|
|
1256
|
+
move(e);
|
|
1257
|
+
try { g.setPointerCapture(e.pointerId); } catch {}
|
|
1258
|
+
});
|
|
1259
|
+
g.addEventListener('pointermove', move);
|
|
1260
|
+
g.addEventListener('pointerup', end);
|
|
1261
|
+
// keyboard: the grip is a button that opens the same menu the drag reaches (R12)
|
|
1262
|
+
g.addEventListener('keydown', (e) => {
|
|
1263
|
+
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openLayMenu(); $('layBtn').focus(); }
|
|
1264
|
+
});
|
|
1265
|
+
});
|
|
767
1266
|
})();
|
|
768
1267
|
|
|
1268
|
+
// re-apply when the window crosses a breakpoint: the float rule and the split rule are both
|
|
1269
|
+
// width-dependent, and a template computed for the other side of the line clips the terminal
|
|
1270
|
+
[NARROW, PHONE].forEach((mq) => {
|
|
1271
|
+
const on = () => { applyLayout(); };
|
|
1272
|
+
if (mq.addEventListener) mq.addEventListener('change', on); else if (mq.addListener) mq.addListener(on);
|
|
1273
|
+
});
|
|
1274
|
+
|
|
1275
|
+
// the empty second group's own controls — a split with nothing in it offers the next step
|
|
1276
|
+
$('newHere1').onclick = () => { state.focusG = 1; openNewForm(); };
|
|
1277
|
+
$('unsplitBtn').onclick = () => tweak({ split: 'off' });
|
|
1278
|
+
// clicking anywhere in a group focuses it, so ⌘J's send and F2's rename act on what you last touched
|
|
1279
|
+
[0, 1].forEach((gi) => $(GROUP_HOST[gi]).addEventListener('pointerdown', () => {
|
|
1280
|
+
if (state.focusG === gi) return;
|
|
1281
|
+
state.focusG = gi; syncActive(); paintGroups();
|
|
1282
|
+
}));
|
|
1283
|
+
// a click outside the layout menu closes it — it is a popover, not a mode
|
|
1284
|
+
document.addEventListener('pointerdown', (e) => {
|
|
1285
|
+
if ($('layMenu').hidden) return;
|
|
1286
|
+
if ($('layMenu').contains(e.target) || $('layBtn').contains(e.target)) return;
|
|
1287
|
+
closeLayMenu();
|
|
1288
|
+
}, true);
|
|
1289
|
+
|
|
1290
|
+
applyLayout(); // paint the default arrangement before the server's layouts arrive
|
|
1291
|
+
|
|
1292
|
+
// the library/document divider is INSIDE the reader, not part of the arrangement — it stays a
|
|
1293
|
+
// client preference like the theme, and keeps its original storage
|
|
1294
|
+
{ const s = store.get('libsplit'); if (s) document.documentElement.style.setProperty('--libsplit', s); }
|
|
1295
|
+
|
|
769
1296
|
// drag the divider between the library and the document
|
|
770
1297
|
(() => {
|
|
771
1298
|
const split = $('railSplit');
|
|
@@ -1062,7 +1589,7 @@ function resolveDocPath(ctx, u) {
|
|
|
1062
1589
|
return stack.join('/');
|
|
1063
1590
|
}
|
|
1064
1591
|
|
|
1065
|
-
// ── new session: name it before it starts ──
|
|
1592
|
+
// ── new session: name it, pick its branch, before it starts ──
|
|
1066
1593
|
function openNewForm() {
|
|
1067
1594
|
const w = state.workspaces.find((x) => x.id === state.wsId);
|
|
1068
1595
|
$('newForm').hidden = false;
|
|
@@ -1070,6 +1597,7 @@ function openNewForm() {
|
|
|
1070
1597
|
// pre-filled but editable — a default that reads better than "shell" in a list of shells
|
|
1071
1598
|
const n = mySessions().length + 1;
|
|
1072
1599
|
$('newName').value = `${w ? w.name : 'session'} ${n}`;
|
|
1600
|
+
fillBranchPicker(); // populate from the workspace's live branch info
|
|
1073
1601
|
const inp = $('newName');
|
|
1074
1602
|
setTimeout(() => { inp.focus(); inp.select(); }, 0);
|
|
1075
1603
|
}
|
|
@@ -1077,8 +1605,29 @@ function closeNewForm() {
|
|
|
1077
1605
|
$('newForm').hidden = true;
|
|
1078
1606
|
$('newBtn').setAttribute('aria-expanded', 'false');
|
|
1079
1607
|
}
|
|
1608
|
+
// Populate the branch control from state.branch. A non-repo workspace hides the whole control —
|
|
1609
|
+
// there is no honest branch to pick. The default is "stay on the current branch" (no checkout).
|
|
1610
|
+
function fillBranchPicker() {
|
|
1611
|
+
const wrap = $('newBranchWrap'), sel = $('newBranch');
|
|
1612
|
+
const b = state.branch;
|
|
1613
|
+
if (!b || !b.ok) { wrap.hidden = true; return; }
|
|
1614
|
+
wrap.hidden = false;
|
|
1615
|
+
const cur = b.detached ? '' : (b.current || '');
|
|
1616
|
+
const opts = [`<option value="">stay on current${cur ? ' (' + esc(cur) + ')' : ''}</option>`]
|
|
1617
|
+
.concat((b.branches || []).map((n) => `<option value="${esc(n)}"${n === cur ? ' disabled' : ''}>${esc(n)}${n === cur ? ' — current' : ''}</option>`))
|
|
1618
|
+
.concat([`<option value="__new__">+ new branch…</option>`]);
|
|
1619
|
+
sel.innerHTML = opts.join('');
|
|
1620
|
+
sel.value = '';
|
|
1621
|
+
setNewBranchMode(false);
|
|
1622
|
+
}
|
|
1623
|
+
function setNewBranchMode(on) {
|
|
1624
|
+
state.newBranchMode = on;
|
|
1625
|
+
$('newBranchName').hidden = !on;
|
|
1626
|
+
if (on) setTimeout(() => $('newBranchName').focus(), 0);
|
|
1627
|
+
}
|
|
1080
1628
|
$('newBtn').onclick = () => ($('newForm').hidden ? openNewForm() : closeNewForm());
|
|
1081
1629
|
$('newCancel').onclick = closeNewForm;
|
|
1630
|
+
$('newBranch').onchange = (e) => setNewBranchMode(e.target.value === '__new__');
|
|
1082
1631
|
$('newAgents').querySelectorAll('button').forEach((b) => b.onclick = () => {
|
|
1083
1632
|
state.newAgent = b.dataset.agent;
|
|
1084
1633
|
$('newAgents').querySelectorAll('button').forEach((x) => x.setAttribute('aria-checked', String(x === b)));
|
|
@@ -1086,10 +1635,85 @@ $('newAgents').querySelectorAll('button').forEach((b) => b.onclick = () => {
|
|
|
1086
1635
|
$('newForm').addEventListener('submit', (e) => {
|
|
1087
1636
|
e.preventDefault();
|
|
1088
1637
|
const name = $('newName').value.trim();
|
|
1089
|
-
|
|
1638
|
+
const msg = { t: 'new', agent: state.newAgent, name: name || state.newAgent, ws: state.wsId };
|
|
1639
|
+
// branch: either create a new one, or check out an existing pick. Empty = stay put (no checkout).
|
|
1640
|
+
const sel = $('newBranch');
|
|
1641
|
+
if (!$('newBranchWrap').hidden && sel) {
|
|
1642
|
+
if (sel.value === '__new__') {
|
|
1643
|
+
const nb = $('newBranchName').value.trim();
|
|
1644
|
+
if (!nb) { flash('type a name for the new branch, or pick "stay on current"'); return; }
|
|
1645
|
+
msg.branch = nb; msg.createBranch = true;
|
|
1646
|
+
} else if (sel.value) {
|
|
1647
|
+
msg.branch = sel.value;
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
send(msg);
|
|
1090
1651
|
closeNewForm();
|
|
1091
1652
|
});
|
|
1092
1653
|
|
|
1654
|
+
// ── scratchpad (R4): per-workspace staging text; copy or send-to-terminal without auto-running ──
|
|
1655
|
+
let scratchTimer = null;
|
|
1656
|
+
function setScratchStatus(s) { const el = $('scratchStatus'); if (el) el.textContent = s; }
|
|
1657
|
+
async function loadScratchpad() {
|
|
1658
|
+
const id = state.wsId;
|
|
1659
|
+
let text = '';
|
|
1660
|
+
try { const r = await fetch('/api/scratchpad?ws=' + encodeURIComponent(id || '')); const j = await r.json(); if (j && j.ok) text = j.text || ''; } catch {}
|
|
1661
|
+
if (id !== state.wsId) return; // a slower fetch for a workspace we've left must not paint
|
|
1662
|
+
$('scratchText').value = text; setScratchStatus('');
|
|
1663
|
+
}
|
|
1664
|
+
function saveScratchpad() {
|
|
1665
|
+
const ws = state.wsId, text = $('scratchText').value;
|
|
1666
|
+
clearTimeout(scratchTimer);
|
|
1667
|
+
scratchTimer = setTimeout(async () => {
|
|
1668
|
+
setScratchStatus('saving…');
|
|
1669
|
+
try {
|
|
1670
|
+
const r = await fetch('/api/scratchpad', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ws, text }) });
|
|
1671
|
+
const j = await r.json();
|
|
1672
|
+
setScratchStatus(j && j.ok ? 'saved' : 'save failed'); // a broken save is never a silent green
|
|
1673
|
+
} catch { setScratchStatus('save failed'); }
|
|
1674
|
+
}, 500);
|
|
1675
|
+
}
|
|
1676
|
+
// Open/close the drawer. It lives in the flex column below the terminal, so toggling it resizes
|
|
1677
|
+
// termhost — the existing ResizeObserver refits the terminal; nothing is ever covered.
|
|
1678
|
+
function openScratch(on) {
|
|
1679
|
+
const el = $('scratch');
|
|
1680
|
+
const open = on == null ? el.dataset.open !== 'true' : !!on;
|
|
1681
|
+
el.dataset.open = String(open);
|
|
1682
|
+
$('scratchToggle').setAttribute('aria-expanded', String(open));
|
|
1683
|
+
$('scratchBtn').setAttribute('aria-pressed', String(open));
|
|
1684
|
+
store.set('scratch-open', open ? '1' : '0');
|
|
1685
|
+
if (open) setTimeout(() => $('scratchText').focus(), 0);
|
|
1686
|
+
}
|
|
1687
|
+
// send-to-terminal writes the text into the active session's PTY WITHOUT a trailing newline — the
|
|
1688
|
+
// PM reviews and presses enter. Disabled when there is no live session to receive it.
|
|
1689
|
+
function activeLiveSession() {
|
|
1690
|
+
const s = state.sessions.find((x) => x.id === state.active);
|
|
1691
|
+
return s && s.state !== 'exited' ? s : null;
|
|
1692
|
+
}
|
|
1693
|
+
function refreshScratchSend() {
|
|
1694
|
+
const btn = $('scratchSend'); if (!btn) return;
|
|
1695
|
+
const live = !!activeLiveSession();
|
|
1696
|
+
btn.disabled = !live;
|
|
1697
|
+
btn.title = live ? 'Paste into the active terminal (does not press enter)' : 'no live session to send to';
|
|
1698
|
+
}
|
|
1699
|
+
$('scratchToggle').onclick = () => openScratch();
|
|
1700
|
+
$('scratchBtn').onclick = () => openScratch();
|
|
1701
|
+
$('scratchText').addEventListener('input', saveScratchpad);
|
|
1702
|
+
$('scratchCopy').onclick = async () => {
|
|
1703
|
+
const text = $('scratchText').value;
|
|
1704
|
+
try { await navigator.clipboard.writeText(text); setScratchStatus('copied'); }
|
|
1705
|
+
catch { $('scratchText').select(); setScratchStatus('press ⌘C to copy'); }
|
|
1706
|
+
};
|
|
1707
|
+
$('scratchSend').onclick = () => {
|
|
1708
|
+
const s = activeLiveSession();
|
|
1709
|
+
if (!s) { flash('no live session to send to — start or select one'); return; }
|
|
1710
|
+
const text = $('scratchText').value;
|
|
1711
|
+
if (!text) return;
|
|
1712
|
+
send({ t: 'input', id: s.id, data: text }); // no trailing newline: nothing auto-runs
|
|
1713
|
+
setScratchStatus('sent → terminal');
|
|
1714
|
+
};
|
|
1715
|
+
{ const open = store.get('scratch-open') === '1'; if (open) openScratch(true); }
|
|
1716
|
+
|
|
1093
1717
|
// ── add a workspace: pick a folder (the GUI twin of running `pmos` in that repo) ──
|
|
1094
1718
|
function closeAddWs() {
|
|
1095
1719
|
$('addWsForm').hidden = true; $('addWsBtn').setAttribute('aria-expanded', 'false'); $('addWsPath').value = '';
|
|
@@ -1197,6 +1821,11 @@ const SURFACES = [
|
|
|
1197
1821
|
{ name: 'needs', label: 'needs you', phase: 'deliver', console: true },
|
|
1198
1822
|
{ name: 'radar', label: 'radar', phase: 'measure', console: true },
|
|
1199
1823
|
{ name: 'metrics', label: 'metrics', phase: 'measure', console: true },
|
|
1824
|
+
// The summoned document review (workspace-review-and-edit). Summoned like every surface here and
|
|
1825
|
+
// never resident — nothing reviews the PM's documents until they ask. The only INTERACTIVE surface:
|
|
1826
|
+
// each finding is disposed of by Apply / Edit / Dismiss, and each disposition writes a correction
|
|
1827
|
+
// record. Whether an Apply control exists at all is decided by the finding contract, never here.
|
|
1828
|
+
{ name: 'review', label: 'review', phase: 'deliver', review: true },
|
|
1200
1829
|
];
|
|
1201
1830
|
const SURFACE_BY_NAME = {}; SURFACES.forEach((v) => { SURFACE_BY_NAME[v.name] = v; });
|
|
1202
1831
|
const SF_STAT = { planning: ['○', 'planning'], building: ['◐', 'building'], blocked: ['◆', 'blocked'], landed: ['▲', 'landed'] };
|
|
@@ -1213,15 +1842,18 @@ async function openSurface(name) {
|
|
|
1213
1842
|
$('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>`;
|
|
1214
1843
|
wireSurface();
|
|
1215
1844
|
const meta = SURFACE_BY_NAME[name] || {};
|
|
1216
|
-
const endpoint = meta.
|
|
1217
|
-
? `/api/
|
|
1218
|
-
:
|
|
1845
|
+
const endpoint = meta.review
|
|
1846
|
+
? `/api/review?ws=${encodeURIComponent(state.wsId || '')}`
|
|
1847
|
+
: meta.console
|
|
1848
|
+
? `/api/console?name=${encodeURIComponent(name)}&ws=${encodeURIComponent(state.wsId || '')}`
|
|
1849
|
+
: `/api/surface?name=${encodeURIComponent(name)}&ws=${encodeURIComponent(state.wsId || '')}`;
|
|
1219
1850
|
try {
|
|
1220
1851
|
const r = await fetch(endpoint);
|
|
1221
1852
|
const payload = await r.json();
|
|
1222
|
-
const body = meta.console ? consoleBody(name, payload) : surfaceBody(name, payload);
|
|
1853
|
+
const body = meta.review ? reviewBody(payload) : meta.console ? consoleBody(name, payload) : surfaceBody(name, payload);
|
|
1223
1854
|
const okFlag = payload.ok !== false;
|
|
1224
1855
|
$('docBody').innerHTML = `<div class="sf-wrap">${sfHead(name, okFlag)}<div class="sf-body">${body}</div></div>`;
|
|
1856
|
+
if (meta.review) { state.review = payload; wireReview(); }
|
|
1225
1857
|
} catch {
|
|
1226
1858
|
// A failed FETCH is a broken read — render RED, never a calm "nothing here" (honesty by construction).
|
|
1227
1859
|
$('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>`;
|
|
@@ -1247,6 +1879,138 @@ function wireSurface() {
|
|
|
1247
1879
|
document.querySelectorAll('[data-open-surface]').forEach((b) => b.onclick = () => openSurface(b.dataset.openSurface));
|
|
1248
1880
|
$('docBody').querySelectorAll('.sf-art[data-doc]').forEach((b) => b.onclick = () => openDoc(b.dataset.doc));
|
|
1249
1881
|
}
|
|
1882
|
+
// ── the summoned review (workspace-review-and-edit) ───────────────────────────────────────────
|
|
1883
|
+
// Renders findings in the quiet register the PM validated on the prototype: no cards, no severity
|
|
1884
|
+
// colours, no taxonomy headers, no explanation of its own guardrails. Each finding is a title and a
|
|
1885
|
+
// source line; the argument and the actions appear on expansion. Detail is AVAILABLE, not ambient.
|
|
1886
|
+
//
|
|
1887
|
+
// The load-bearing line is `f.applyable`. It comes from the SERVER, which asks finding-contract.js —
|
|
1888
|
+
// the client never re-derives it. A withheld finding renders no Apply control at all: the missing
|
|
1889
|
+
// button IS the guard (R7), and a disabled-but-present button would be the failure it exists to stop.
|
|
1890
|
+
function reviewBody(p) {
|
|
1891
|
+
if (!p || p.ok === false) {
|
|
1892
|
+
return csError('review', (p && p.error) || 'the review could not be read')
|
|
1893
|
+
+ (p && p.errors ? `<ul class="rv-errs">${p.errors.map((e) => `<li>${esc(e)}</li>`).join('')}</ul>` : '');
|
|
1894
|
+
}
|
|
1895
|
+
if (p.empty) {
|
|
1896
|
+
return `<div class="rv-empty"><p>No review filed yet.</p>
|
|
1897
|
+
<p class="rv-dim">Run the <b>Review documents</b> skill on the documents you want read.
|
|
1898
|
+
Nothing reviews them until you ask.</p></div>`;
|
|
1899
|
+
}
|
|
1900
|
+
const open = p.findings.filter((f) => !f.settled);
|
|
1901
|
+
const lede = open.length === 0
|
|
1902
|
+
? 'Nothing here that these documents cannot already tell you about themselves.'
|
|
1903
|
+
: `${open.length} thing${open.length === 1 ? '' : 's'} ${open.length === 1 ? 'this document cannot' : 'these documents cannot'} tell you about ${open.length === 1 ? 'itself' : 'themselves'}.`;
|
|
1904
|
+
return `<div class="rv">
|
|
1905
|
+
<div class="rv-lede">${esc(lede)}</div>
|
|
1906
|
+
<div class="rv-sub">Read ${p.read.map((r) => `<span class="mono">${esc(r)}</span>`).join(', ')}.</div>
|
|
1907
|
+
${p.findings.map((f, i) => rvFinding(f, i)).join('')}
|
|
1908
|
+
<div class="rv-tail">Read ${p.read.length} document${p.read.length === 1 ? '' : 's'}. <b>Nothing written.</b></div>
|
|
1909
|
+
</div>`;
|
|
1910
|
+
}
|
|
1911
|
+
function rvFinding(f, i) {
|
|
1912
|
+
const ev = (f.evidence || []).map((e) => `${e.file}:${e.line}`).join(' · ');
|
|
1913
|
+
// Three states, and only one of them offers a write. `settled` is terminal for this review.
|
|
1914
|
+
const acts = f.settled
|
|
1915
|
+
? `<span class="rv-only">${esc(f.settled === 'dismiss' ? 'Dismissed.' : f.settled === 'edit' ? 'Edited and applied.' : 'Applied.')}</span>`
|
|
1916
|
+
: f.applyable
|
|
1917
|
+
? `<button data-rv-apply="${i}">Apply</button><button data-rv-edit="${i}">Edit</button><button class="q" data-rv-dismiss="${i}">Dismiss</button>`
|
|
1918
|
+
: `<span class="rv-only">${esc(f.withheld ? 'Yours to settle.' : 'Nothing to apply.')}</span><button class="q" data-rv-dismiss="${i}">Dismiss</button>`;
|
|
1919
|
+
return `<div class="rv-f${f.settled ? ' rv-done' : ''}${f.applyable ? '' : ' rv-quiet'}" data-rv="${i}">
|
|
1920
|
+
<div class="rv-h">${esc(f.title)}</div>
|
|
1921
|
+
<div class="rv-src">${esc(ev || f.file)}</div>
|
|
1922
|
+
<div class="rv-d">
|
|
1923
|
+
<p>${esc(f.argument)}</p>
|
|
1924
|
+
${f.withheld_reason ? `<p class="rv-why">${esc(f.withheld_reason)}</p>` : ''}
|
|
1925
|
+
<div class="rv-acts">${acts}</div>
|
|
1926
|
+
</div>
|
|
1927
|
+
<div class="rv-ed" data-rv-edbox="${i}"></div>
|
|
1928
|
+
</div>`;
|
|
1929
|
+
}
|
|
1930
|
+
// The edit surface: opens IN PLACE under the finding — no modal, no mode switch, no navigation.
|
|
1931
|
+
// Gutter change bars are the VS Code cue; the replaced lines sit struck through above the new ones.
|
|
1932
|
+
// Cancel needs no write path at all — it just closes, which is the whole of "Cancel writes nothing".
|
|
1933
|
+
function rvEditor(f, i) {
|
|
1934
|
+
const before = (f.proposal && f.proposal.before) || '';
|
|
1935
|
+
const after = (f.proposal && f.proposal.after) || '';
|
|
1936
|
+
const nl = (s) => (s ? s.split('\n').length : 0);
|
|
1937
|
+
return `<div class="rv-edfile"><span class="mono">${esc(f.file)}</span>
|
|
1938
|
+
<span class="rv-n"><b>+${nl(after)}</b> <i>−${nl(before)}</i></span></div>
|
|
1939
|
+
<div class="rv-code">
|
|
1940
|
+
${before.split('\n').map((l) => `<div class="rv-l was"><span class="g"></span><span class="t">${esc(l)}</span></div>`).join('')}
|
|
1941
|
+
</div>
|
|
1942
|
+
<textarea class="rv-ta" data-rv-ta="${i}" spellcheck="false" rows="${Math.min(12, Math.max(3, nl(after) + 1))}">${esc(after)}</textarea>
|
|
1943
|
+
<div class="rv-acts">
|
|
1944
|
+
<button data-rv-save="${i}">Apply</button>
|
|
1945
|
+
<button class="q" data-rv-cancel="${i}">Cancel</button>
|
|
1946
|
+
<span class="rv-only">You are editing the change, not the file.</span>
|
|
1947
|
+
</div>`;
|
|
1948
|
+
}
|
|
1949
|
+
function wireReview() {
|
|
1950
|
+
const rv = state.review || {};
|
|
1951
|
+
const F = (n) => (rv.findings || [])[Number(n)];
|
|
1952
|
+
const box = (n) => $('docBody').querySelector(`[data-rv-edbox="${n}"]`);
|
|
1953
|
+
const card = (n) => $('docBody').querySelector(`[data-rv="${n}"]`);
|
|
1954
|
+
|
|
1955
|
+
$('docBody').querySelectorAll('.rv-f').forEach((el) => {
|
|
1956
|
+
el.addEventListener('click', (e) => {
|
|
1957
|
+
if (e.target.closest('button') || e.target.closest('textarea')) return;
|
|
1958
|
+
if (el.dataset.editing === '1') return; // never collapse an open editor by clicking it
|
|
1959
|
+
el.classList.toggle('on');
|
|
1960
|
+
});
|
|
1961
|
+
});
|
|
1962
|
+
$('docBody').querySelectorAll('[data-rv-edit]').forEach((b) => b.onclick = () => {
|
|
1963
|
+
const n = b.dataset.rvEdit;
|
|
1964
|
+
box(n).innerHTML = rvEditor(F(n), n);
|
|
1965
|
+
card(n).dataset.editing = '1';
|
|
1966
|
+
wireReview(); // re-wire the freshly rendered controls
|
|
1967
|
+
const ta = $('docBody').querySelector(`[data-rv-ta="${n}"]`); if (ta) ta.focus();
|
|
1968
|
+
});
|
|
1969
|
+
$('docBody').querySelectorAll('[data-rv-cancel]').forEach((b) => b.onclick = () => {
|
|
1970
|
+
const n = b.dataset.rvCancel;
|
|
1971
|
+
box(n).innerHTML = ''; card(n).dataset.editing = '0'; // closing IS the whole of "writes nothing"
|
|
1972
|
+
});
|
|
1973
|
+
$('docBody').querySelectorAll('[data-rv-apply]').forEach((b) => b.onclick = () => rvDispose(F(b.dataset.rvApply), 'apply'));
|
|
1974
|
+
$('docBody').querySelectorAll('[data-rv-dismiss]').forEach((b) => b.onclick = () => rvDispose(F(b.dataset.rvDismiss), 'dismiss'));
|
|
1975
|
+
$('docBody').querySelectorAll('[data-rv-save]').forEach((b) => b.onclick = () => {
|
|
1976
|
+
const n = b.dataset.rvSave;
|
|
1977
|
+
const ta = $('docBody').querySelector(`[data-rv-ta="${n}"]`);
|
|
1978
|
+
rvDispose(F(n), 'edit', ta ? ta.value : '');
|
|
1979
|
+
});
|
|
1980
|
+
}
|
|
1981
|
+
async function rvDispose(f, disposition, pmText) {
|
|
1982
|
+
if (!f) return;
|
|
1983
|
+
const rv = state.review || {};
|
|
1984
|
+
// The client sends back the exact text it rendered; the server re-derives the diff and refuses if
|
|
1985
|
+
// they differ (sprint criterion 4). That is what makes a stale or tampered client safe.
|
|
1986
|
+
let renderedAfter = null;
|
|
1987
|
+
if (disposition !== 'dismiss') {
|
|
1988
|
+
try {
|
|
1989
|
+
const r = await fetch(`/api/doc?ws=${encodeURIComponent(state.wsId || '')}&path=${encodeURIComponent(f.file)}`);
|
|
1990
|
+
const cur = await r.text();
|
|
1991
|
+
const prop = disposition === 'edit' ? { before: f.proposal.before, after: pmText } : f.proposal;
|
|
1992
|
+
renderedAfter = window.ApplyChange.applyChange(cur, prop).text;
|
|
1993
|
+
} catch { /* leave null — the server refuses rather than guessing */ }
|
|
1994
|
+
}
|
|
1995
|
+
const body = {
|
|
1996
|
+
ws: state.wsId, disposition, finding: f, rendered_after: renderedAfter,
|
|
1997
|
+
pm_text: disposition === 'edit' ? pmText : undefined,
|
|
1998
|
+
reason: disposition === 'edit' ? 'incomplete' : undefined, // TODO: the reason tap (PRD open q2)
|
|
1999
|
+
run_id: rv.id, initiative_id: 'workspace-review-and-edit',
|
|
2000
|
+
};
|
|
2001
|
+
let out = {};
|
|
2002
|
+
try { out = await (await fetch('/api/review/dispose', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })).json(); }
|
|
2003
|
+
catch { out = { ok: false, error: 'could not reach the workspace server' }; }
|
|
2004
|
+
if (!out.ok) { rvSay(out.error || 'refused', out.detail); return; }
|
|
2005
|
+
openSurface('review'); // re-read: the record is the source of truth
|
|
2006
|
+
}
|
|
2007
|
+
function rvSay(msg, detail) {
|
|
2008
|
+
const el = document.createElement('div');
|
|
2009
|
+
el.className = 'rv-say';
|
|
2010
|
+
el.textContent = detail ? `${msg} — ${detail}` : msg;
|
|
2011
|
+
$('docBody').querySelector('.rv') ? $('docBody').querySelector('.rv').prepend(el) : $('docBody').prepend(el);
|
|
2012
|
+
}
|
|
2013
|
+
|
|
1250
2014
|
function surfaceBody(name, payload) {
|
|
1251
2015
|
if (!payload || !payload.ok) return sfCantProduce(name, payload && payload.contract);
|
|
1252
2016
|
const s = payload.surface || {};
|
|
@@ -1657,9 +2421,18 @@ document.addEventListener('keydown', (e) => {
|
|
|
1657
2421
|
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
|
1658
2422
|
e.preventDefault(); e.stopPropagation(); togglePalette(); return;
|
|
1659
2423
|
}
|
|
2424
|
+
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'j') {
|
|
2425
|
+
e.preventDefault(); e.stopPropagation(); openScratch(); return;
|
|
2426
|
+
}
|
|
1660
2427
|
if (e.key === 'F2' && state.active && !state.renaming) { e.preventDefault(); startRename(state.active); return; }
|
|
2428
|
+
// ⌃1–⌃4 — the named layouts, in one act. Ctrl only (⌘1-4 are the browser's tab switches).
|
|
2429
|
+
if (e.ctrlKey && !e.metaKey && !e.altKey && /^[1-9]$/.test(e.key)) {
|
|
2430
|
+
const l = state.layouts.find((x) => x.key === Number(e.key));
|
|
2431
|
+
if (l) { e.preventDefault(); e.stopPropagation(); selectLayout(l.id); return; }
|
|
2432
|
+
}
|
|
1661
2433
|
if (e.key === 'Escape') {
|
|
1662
|
-
if (
|
|
2434
|
+
if (!$('layMenu').hidden) { e.stopPropagation(); closeLayMenu(); }
|
|
2435
|
+
else if ($('palette').style.display === 'grid') { e.stopPropagation(); closePalette(); }
|
|
1663
2436
|
else if ($('fsPicker').style.display === 'grid') { e.stopPropagation(); closeFsPicker(); }
|
|
1664
2437
|
else if (state.renaming) { e.stopPropagation(); cancelRename(); renderSessions(); }
|
|
1665
2438
|
else if (!$('newForm').hidden) closeNewForm();
|