create-pmos 0.6.4 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +68 -0
- 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/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 +784 -87
- package/workspace/public/index.html +88 -23
- package/workspace/public/styles.css +214 -19
- package/workspace/server.js +337 -14
- 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',
|
|
@@ -33,6 +39,7 @@ const state = {
|
|
|
33
39
|
ctrlArmed: false, // mobile control bar: Ctrl is a one-shot modifier for the next key
|
|
34
40
|
branch: null, // {ok,current,branches[],detached,dirty} for the selected workspace
|
|
35
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)
|
|
36
43
|
};
|
|
37
44
|
|
|
38
45
|
// ── workspace state survives reloads (R7): theme + selected workspace + focused session,
|
|
@@ -86,6 +93,7 @@ function mySessions() { return state.sessions.filter((s) => s.ws === state.wsId)
|
|
|
86
93
|
|
|
87
94
|
function handle(m) {
|
|
88
95
|
if (m.t === 'hello') {
|
|
96
|
+
if (Number.isFinite(m.quietAfterMs) && m.quietAfterMs > 0) QUIET_AFTER_MS = m.quietAfterMs;
|
|
89
97
|
state.workspaces = m.workspaces; state.home = m.home; state.sessions = m.sessions;
|
|
90
98
|
// pick: the URL (a fresh `pmos` in this repo) → the last one used → home
|
|
91
99
|
const remembered = store.get('wsid');
|
|
@@ -107,12 +115,22 @@ function handle(m) {
|
|
|
107
115
|
if (!ids.has(id)) { try { t.term.dispose(); } catch {} t.el.remove(); state.terms.delete(id); state.attached.delete(id); }
|
|
108
116
|
}
|
|
109
117
|
if (state.renaming && !ids.has(state.renaming)) cancelRename();
|
|
110
|
-
|
|
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(); }
|
|
111
129
|
if (state.active && !ids.has(state.active)) {
|
|
112
130
|
const mine = mySessions();
|
|
113
131
|
const nx = mine.find((s) => s.state !== 'exited') || mine[mine.length - 1];
|
|
114
132
|
if (nx) selectSession(nx.id); else clearActive();
|
|
115
|
-
} else { syncActiveState(); }
|
|
133
|
+
} else if (structural) { syncActiveState(); }
|
|
116
134
|
} else if (m.t === 'attached') {
|
|
117
135
|
const t = ensureTerm(m.id);
|
|
118
136
|
t.term.reset();
|
|
@@ -120,6 +138,15 @@ function handle(m) {
|
|
|
120
138
|
fitOne(m.id);
|
|
121
139
|
} else if (m.t === 'data') {
|
|
122
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(); }
|
|
123
150
|
} else if (m.t === 'created') {
|
|
124
151
|
// server pushes a fresh 'sessions'; jump to the new one
|
|
125
152
|
selectSession(m.id);
|
|
@@ -168,7 +195,8 @@ async function selectWorkspace(id, opts = {}) {
|
|
|
168
195
|
const remembered = opts.restoreSession ? store.get('active') : null;
|
|
169
196
|
const pick = mine.find((s) => s.id === remembered) || mine.find((s) => s.state !== 'exited') || mine[0];
|
|
170
197
|
if (pick) selectSession(pick.id); else clearActive();
|
|
171
|
-
|
|
198
|
+
// the arrangement belongs to the repo (R7) — a different workspace brings its own layouts
|
|
199
|
+
if (changed) { state.branch = null; renderBranch(); loadScratchpad(); loadLayouts(); }
|
|
172
200
|
await Promise.all([loadLibrary(), loadNeeds(), loadBranch()]);
|
|
173
201
|
}
|
|
174
202
|
function renderWorkspaces() {
|
|
@@ -217,7 +245,11 @@ const GLYPH = { working: '●', quiet: '◼', blocked: '◆', done: '✓', idle:
|
|
|
217
245
|
// The word beside the glyph. 'quiet' means no output for a while — NOT 'waiting on you'.
|
|
218
246
|
// Quiescence proves quiet, not waiting (PRD R1); the wording may not be upgraded.
|
|
219
247
|
const STATE_WORD = { working: 'working', quiet: 'quiet', exited: 'exited' };
|
|
220
|
-
|
|
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;
|
|
221
253
|
function shortDur(ms) {
|
|
222
254
|
if (ms == null) return '';
|
|
223
255
|
const sec = Math.round(ms / 1000);
|
|
@@ -303,18 +335,30 @@ function renderSessions() {
|
|
|
303
335
|
// is doing now. When there is no resolvable title — a shell/codex/untitled/unresolved session — it
|
|
304
336
|
// falls back to the shipped `agent · cwd`, so the line is never blank and never a guessed title.
|
|
305
337
|
// When a title IS shown, `agent · cwd` moves into the harness drawer (see harnessDrawer), not lost.
|
|
306
|
-
|
|
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) {
|
|
307
343
|
const title = s.hx && s.hx.title;
|
|
308
|
-
if (title) return `<span class="
|
|
309
|
-
return
|
|
344
|
+
if (title) return `<span class="lead">opening focus</span> ${esc(title)}`;
|
|
345
|
+
return `${esc(s.agent)} · ${esc(s.cwd)}`;
|
|
310
346
|
}
|
|
347
|
+
function subLine(s) { return `<span class="sub">${subInner(s)}</span>`; }
|
|
311
348
|
// The agent's own last words, verbatim — never a paraphrase (a summary would be another model
|
|
312
349
|
// call per session, and a drifting paraphrase was a leg-A defect). When the parser cannot read,
|
|
313
350
|
// it says so: an unreadable signal must never render as an empty line.
|
|
314
|
-
|
|
315
|
-
|
|
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) };
|
|
316
356
|
const why = s.missing && s.missing.lastWords;
|
|
317
|
-
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>` : '';
|
|
318
362
|
}
|
|
319
363
|
// The harness readout: a drill-in, not a rail. The leg-A grilling established the harness is
|
|
320
364
|
// near-static within a run, so this is read once to understand a session — not watched.
|
|
@@ -330,7 +374,7 @@ function fmtTokens(n) {
|
|
|
330
374
|
// the CLI does not PRINT its model, effort, tokens or skill set to the terminal — parsing
|
|
331
375
|
// scrollback for them returned four empty rows, which is what sent this back for a second pass.
|
|
332
376
|
// Rows that genuinely cannot be known are still drawn as unavailable, never as a plausible value.
|
|
333
|
-
function
|
|
377
|
+
function harnessRows(s) {
|
|
334
378
|
const hx = s.hx || { missing: {} };
|
|
335
379
|
const m = hx.missing || {};
|
|
336
380
|
const rows = [];
|
|
@@ -367,9 +411,50 @@ function harnessDrawer(s) {
|
|
|
367
411
|
rows.push(`<dt>gates</dt>${na('not visible from the workspace — gate state is hosted')}`);
|
|
368
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>`);
|
|
369
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) {
|
|
370
419
|
return `<details class="hz" data-id="${s.id}">
|
|
371
420
|
<summary>model + harness<span class="sr">${esc(s.name)}</span></summary>
|
|
372
|
-
<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
|
+
}
|
|
373
458
|
}
|
|
374
459
|
// A row must be able to go quiet with NO server push. Repaint the chip, glyph, data-state and
|
|
375
460
|
// accessible name only — never innerHTML — so an open drill-in and an in-flight rename survive.
|
|
@@ -387,33 +472,60 @@ function tickQuiet() {
|
|
|
387
472
|
chip.textContent = st.text;
|
|
388
473
|
btn.setAttribute('aria-label', `${s.name} — ${st.text}`);
|
|
389
474
|
}
|
|
390
|
-
|
|
391
|
-
|
|
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
|
+
});
|
|
392
484
|
}
|
|
393
485
|
}
|
|
394
486
|
try { const _q = setInterval(tickQuiet, 5000); if (_q.unref) _q.unref(); } catch {}
|
|
395
487
|
|
|
396
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;
|
|
397
499
|
// wrap = two SIBLING buttons (select + close) — no interactive-inside-interactive nesting
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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>
|
|
403
510
|
<button class="x" data-close="${s.id}"
|
|
404
511
|
aria-label="${s.state === 'exited' ? 'remove' : 'close'} ${esc(s.name)}">✕</button>
|
|
405
|
-
</div
|
|
406
|
-
|
|
407
|
-
|
|
512
|
+
</div>`;
|
|
513
|
+
}).join('');
|
|
514
|
+
host.querySelectorAll('.stab').forEach((b) => {
|
|
515
|
+
b.onclick = () => selectSession(b.dataset.id, gi);
|
|
408
516
|
b.ondblclick = () => startRename(b.dataset.id);
|
|
409
517
|
});
|
|
410
518
|
// live tab ✕ kills the process (state flips to exited); exited tab ✕ removes it for good
|
|
411
|
-
|
|
519
|
+
host.querySelectorAll('.x').forEach((b) => b.onclick = () => send({ t: 'kill', id: b.dataset.close }));
|
|
412
520
|
}
|
|
413
521
|
function syncActiveState() {
|
|
414
522
|
document.querySelectorAll('.sess,.stab-wrap').forEach((b) => {
|
|
415
523
|
const s = state.sessions.find((x) => x.id === b.dataset.id);
|
|
416
|
-
|
|
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;
|
|
417
529
|
});
|
|
418
530
|
updateExitedOverlay();
|
|
419
531
|
refreshScratchSend(); // the scratchpad's send button follows the active session's liveness
|
|
@@ -441,7 +553,9 @@ function paintRename() {
|
|
|
441
553
|
const id = state.renaming;
|
|
442
554
|
if (!id) return;
|
|
443
555
|
const row = $('sessions').querySelector(`.sessrow[data-id="${id}"]`);
|
|
444
|
-
|
|
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}"]`);
|
|
445
559
|
const host = row || tab;
|
|
446
560
|
if (!host) return;
|
|
447
561
|
const input = document.createElement('input');
|
|
@@ -462,12 +576,26 @@ function paintRename() {
|
|
|
462
576
|
}
|
|
463
577
|
|
|
464
578
|
// ── terminals ──
|
|
465
|
-
|
|
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;
|
|
466
591
|
let t = state.terms.get(id);
|
|
467
|
-
if (t)
|
|
592
|
+
if (t) {
|
|
593
|
+
if (t.g !== want) { t.g = want; groupHost(want).appendChild(t.el); fitOne(id); }
|
|
594
|
+
return t;
|
|
595
|
+
}
|
|
468
596
|
const el = document.createElement('div');
|
|
469
597
|
el.className = 'termpane'; el.dataset.id = id;
|
|
470
|
-
|
|
598
|
+
groupHost(want).appendChild(el);
|
|
471
599
|
const term = new window.Terminal({
|
|
472
600
|
fontFamily: '"IBM Plex Mono", ui-monospace, monospace', fontSize: 13, theme: xtheme(),
|
|
473
601
|
cursorBlink: true, scrollback: 5000, allowProposedApi: true,
|
|
@@ -487,7 +615,7 @@ function ensureTerm(id) {
|
|
|
487
615
|
});
|
|
488
616
|
term.onData((d) => send({ t: 'input', id, data: applyCtrl(d) }));
|
|
489
617
|
term.onResize(({ cols, rows }) => send({ t: 'resize', id, cols, rows }));
|
|
490
|
-
t = { term, fit, el };
|
|
618
|
+
t = { term, fit, el, g: want };
|
|
491
619
|
state.terms.set(id, t);
|
|
492
620
|
return t;
|
|
493
621
|
}
|
|
@@ -496,43 +624,104 @@ function fitOne(id) {
|
|
|
496
624
|
requestAnimationFrame(() => { try { t.fit.fit(); } catch {} });
|
|
497
625
|
}
|
|
498
626
|
// Anything that changes the terminal's box must re-fit it, or xterm keeps rendering more rows
|
|
499
|
-
// than fit and the BOTTOM OF THE TERMINAL disappears under the clip (R9).
|
|
500
|
-
//
|
|
501
|
-
//
|
|
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.
|
|
502
632
|
let refitTimer = null;
|
|
503
633
|
function refit() {
|
|
504
634
|
clearTimeout(refitTimer);
|
|
505
|
-
refitTimer = setTimeout(() => {
|
|
635
|
+
refitTimer = setTimeout(() => {
|
|
636
|
+
for (const g of state.groups) if (g.activeId) fitOne(g.activeId);
|
|
637
|
+
}, 30);
|
|
506
638
|
}
|
|
507
|
-
if (window.ResizeObserver) new ResizeObserver(refit).observe($('termhost'));
|
|
639
|
+
if (window.ResizeObserver) { const ro = new ResizeObserver(refit); ro.observe($('termhost')); ro.observe($('termhost1')); }
|
|
508
640
|
window.addEventListener('resize', refit);
|
|
509
641
|
document.addEventListener('visibilitychange', () => { if (!document.hidden) refit(); });
|
|
510
642
|
// first paint measures the fallback font; the real metrics arrive with the webfont
|
|
511
643
|
if (document.fonts && document.fonts.ready) document.fonts.ready.then(() => { for (const [id] of state.terms) fitOne(id); });
|
|
512
644
|
|
|
513
|
-
function selectSession(id) {
|
|
645
|
+
function selectSession(id, g) {
|
|
514
646
|
const s = state.sessions.find((x) => x.id === id);
|
|
515
647
|
if (!s) return;
|
|
516
648
|
// selecting a session from the palette may cross workspaces — follow it there
|
|
517
|
-
if (s.ws !== state.wsId) { state.wsId = s.ws; store.set('wsid', s.ws); renderWorkspaces(); renderSessions(); loadLibrary(); loadNeeds(); state.branch = null; renderBranch(); loadBranch(); loadScratchpad(); }
|
|
518
|
-
|
|
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();
|
|
519
661
|
store.set('active', id); // survives reloads (R7)
|
|
520
662
|
// attach once per connection — re-selecting an attached session must not reset + re-replay
|
|
521
663
|
if (!state.attached.has(id)) { state.attached.add(id); send({ t: 'attach', id }); }
|
|
522
|
-
ensureTerm(id);
|
|
523
|
-
|
|
524
|
-
document.querySelectorAll('.sess').forEach((b) => b.setAttribute('aria-current', String(b.dataset.id === id)));
|
|
525
|
-
document.querySelectorAll('.stab').forEach((b) => b.setAttribute('aria-selected', String(b.dataset.id === id)));
|
|
526
|
-
document.querySelectorAll('.stab-wrap').forEach((b) => b.dataset.active = String(b.dataset.id === id));
|
|
664
|
+
ensureTerm(id, want);
|
|
665
|
+
paintGroups();
|
|
527
666
|
fitOne(id);
|
|
528
667
|
const t = state.terms.get(id); if (t) setTimeout(() => t.term.focus(), 0);
|
|
529
668
|
updateExitedOverlay(); updateEmptyOverlay();
|
|
530
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
|
+
}
|
|
531
689
|
function clearActive() {
|
|
532
|
-
state.
|
|
533
|
-
|
|
690
|
+
state.groups.forEach((g) => { g.activeId = null; });
|
|
691
|
+
syncActive();
|
|
692
|
+
document.querySelectorAll('.termpane').forEach((e) => e.classList.remove('on'));
|
|
693
|
+
paintGroups();
|
|
534
694
|
updateExitedOverlay(); updateEmptyOverlay();
|
|
535
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
|
+
}
|
|
536
725
|
function updateExitedOverlay() {
|
|
537
726
|
const s = state.sessions.find((x) => x.id === state.active);
|
|
538
727
|
const ov = $('exited');
|
|
@@ -748,57 +937,362 @@ function closeDoc() {
|
|
|
748
937
|
refit();
|
|
749
938
|
}
|
|
750
939
|
|
|
751
|
-
//
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
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);
|
|
759
956
|
}
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
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();
|
|
766
987
|
refit();
|
|
767
988
|
}
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
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' }); }
|
|
774
1172
|
|
|
775
1173
|
$('libFold').onclick = () => foldPane('lib', $('paneLib').dataset.fold === 'off');
|
|
776
1174
|
$('docFold').onclick = () => foldPane('doc', $('paneDoc').dataset.fold === 'off');
|
|
777
|
-
$('
|
|
1175
|
+
$('wsFold').onclick = () => foldPane('ws', $('paneWs').dataset.fold === 'off');
|
|
1176
|
+
$('sessFold').onclick = () => foldPane('sess', $('paneSess').dataset.fold === 'off');
|
|
778
1177
|
|
|
779
|
-
//
|
|
780
|
-
//
|
|
781
|
-
(
|
|
782
|
-
|
|
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;
|
|
783
1183
|
let dragging = false;
|
|
784
|
-
const
|
|
785
|
-
const
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
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;
|
|
1189
|
+
};
|
|
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;
|
|
789
1195
|
};
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
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) => {
|
|
794
1200
|
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
|
|
795
1201
|
e.preventDefault();
|
|
796
|
-
const
|
|
797
|
-
|
|
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));
|
|
798
1207
|
refit();
|
|
799
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
|
+
});
|
|
800
1266
|
})();
|
|
801
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
|
+
|
|
802
1296
|
// drag the divider between the library and the document
|
|
803
1297
|
(() => {
|
|
804
1298
|
const split = $('railSplit');
|
|
@@ -1327,6 +1821,11 @@ const SURFACES = [
|
|
|
1327
1821
|
{ name: 'needs', label: 'needs you', phase: 'deliver', console: true },
|
|
1328
1822
|
{ name: 'radar', label: 'radar', phase: 'measure', console: true },
|
|
1329
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 },
|
|
1330
1829
|
];
|
|
1331
1830
|
const SURFACE_BY_NAME = {}; SURFACES.forEach((v) => { SURFACE_BY_NAME[v.name] = v; });
|
|
1332
1831
|
const SF_STAT = { planning: ['○', 'planning'], building: ['◐', 'building'], blocked: ['◆', 'blocked'], landed: ['▲', 'landed'] };
|
|
@@ -1343,15 +1842,18 @@ async function openSurface(name) {
|
|
|
1343
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>`;
|
|
1344
1843
|
wireSurface();
|
|
1345
1844
|
const meta = SURFACE_BY_NAME[name] || {};
|
|
1346
|
-
const endpoint = meta.
|
|
1347
|
-
? `/api/
|
|
1348
|
-
:
|
|
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 || '')}`;
|
|
1349
1850
|
try {
|
|
1350
1851
|
const r = await fetch(endpoint);
|
|
1351
1852
|
const payload = await r.json();
|
|
1352
|
-
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);
|
|
1353
1854
|
const okFlag = payload.ok !== false;
|
|
1354
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(); }
|
|
1355
1857
|
} catch {
|
|
1356
1858
|
// A failed FETCH is a broken read — render RED, never a calm "nothing here" (honesty by construction).
|
|
1357
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>`;
|
|
@@ -1377,6 +1879,195 @@ function wireSurface() {
|
|
|
1377
1879
|
document.querySelectorAll('[data-open-surface]').forEach((b) => b.onclick = () => openSurface(b.dataset.openSurface));
|
|
1378
1880
|
$('docBody').querySelectorAll('.sf-art[data-doc]').forEach((b) => b.onclick = () => openDoc(b.dataset.doc));
|
|
1379
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>${rvStats(p.stats)}</div>
|
|
1909
|
+
</div>`;
|
|
1910
|
+
}
|
|
1911
|
+
// The PRD's success signal, on the face of the surface. Action rate comes straight off the correction
|
|
1912
|
+
// records. Quiet-rate CANNOT — it needs a corpus with a labelled clean class, which does not exist —
|
|
1913
|
+
// so it says "not measured" rather than rendering a flattering zero. An unmeasured number shown as a
|
|
1914
|
+
// number is the watermelon this whole surface exists to catch.
|
|
1915
|
+
function rvStats(s) {
|
|
1916
|
+
if (!s || !s.dispositions) return '';
|
|
1917
|
+
const pct = (v) => (v == null ? null : `${Math.round(v * 100)}%`);
|
|
1918
|
+
const bits = [
|
|
1919
|
+
`${s.dispositions} disposition${s.dispositions === 1 ? '' : 's'}`,
|
|
1920
|
+
`${s.corrections} correction${s.corrections === 1 ? '' : 's'}`,
|
|
1921
|
+
`acted on ${pct(s.action_rate)}`,
|
|
1922
|
+
`quiet on clean <i>not measured</i>`,
|
|
1923
|
+
];
|
|
1924
|
+
const warn = s.inferred_attributions
|
|
1925
|
+
? ` <b class="rv-bad">${s.inferred_attributions} inferred attribution${s.inferred_attributions === 1 ? '' : 's'}</b>`
|
|
1926
|
+
: '';
|
|
1927
|
+
return `<div class="rv-stats">${bits.join(' · ')}${warn}</div>`;
|
|
1928
|
+
}
|
|
1929
|
+
function rvFinding(f, i) {
|
|
1930
|
+
const ev = (f.evidence || []).map((e) => `${e.file}:${e.line}`).join(' · ');
|
|
1931
|
+
// Three states, and only one of them offers a write. `settled` is terminal for this review.
|
|
1932
|
+
const acts = f.settled
|
|
1933
|
+
? `<span class="rv-only">${esc(f.settled === 'dismiss' ? 'Dismissed.' : f.settled === 'edit' ? 'Edited and applied.' : 'Applied.')}</span>`
|
|
1934
|
+
: f.applyable
|
|
1935
|
+
? `<button data-rv-apply="${i}">Apply</button><button data-rv-edit="${i}">Edit</button><button class="q" data-rv-dismiss="${i}">Dismiss</button>`
|
|
1936
|
+
: `<span class="rv-only">${esc(f.withheld ? 'Yours to settle.' : 'Nothing to apply.')}</span><button class="q" data-rv-dismiss="${i}">Dismiss</button>`;
|
|
1937
|
+
return `<div class="rv-f${f.settled ? ' rv-done' : ''}${f.applyable ? '' : ' rv-quiet'}" data-rv="${i}">
|
|
1938
|
+
<div class="rv-h">${esc(f.title)}</div>
|
|
1939
|
+
<div class="rv-src">${esc(ev || f.file)}</div>
|
|
1940
|
+
<div class="rv-d">
|
|
1941
|
+
<p>${esc(f.argument)}</p>
|
|
1942
|
+
${f.withheld_reason ? `<p class="rv-why">${esc(f.withheld_reason)}</p>` : ''}
|
|
1943
|
+
<div class="rv-acts">${acts}</div>
|
|
1944
|
+
</div>
|
|
1945
|
+
<div class="rv-ed" data-rv-edbox="${i}"></div>
|
|
1946
|
+
</div>`;
|
|
1947
|
+
}
|
|
1948
|
+
// The edit surface: opens IN PLACE under the finding — no modal, no mode switch, no navigation.
|
|
1949
|
+
// Gutter change bars are the VS Code cue; the replaced lines sit struck through above the new ones.
|
|
1950
|
+
// Cancel needs no write path at all — it just closes, which is the whole of "Cancel writes nothing".
|
|
1951
|
+
// The reason taxonomy, deliberately short. A vocabulary nobody can hold in their head gets skipped,
|
|
1952
|
+
// and a skipped reason is a record that teaches nothing. Values match correction-record.js's REASONS;
|
|
1953
|
+
// the server rejects anything outside it, so this list can never drift into inventing one.
|
|
1954
|
+
const RV_REASONS = [
|
|
1955
|
+
['factually-wrong', 'factually wrong'], ['incomplete', 'incomplete'],
|
|
1956
|
+
['wrong-scope', 'wrong scope'], ['style', 'style'], ['other', 'other…'],
|
|
1957
|
+
];
|
|
1958
|
+
function rvEditor(f, i) {
|
|
1959
|
+
const before = (f.proposal && f.proposal.before) || '';
|
|
1960
|
+
const after = (f.proposal && f.proposal.after) || '';
|
|
1961
|
+
const nl = (s) => (s ? s.split('\n').length : 0);
|
|
1962
|
+
return `<div class="rv-edfile"><span class="mono">${esc(f.file)}</span>
|
|
1963
|
+
<span class="rv-n"><b>+${nl(after)}</b> <i>−${nl(before)}</i></span></div>
|
|
1964
|
+
<div class="rv-code">
|
|
1965
|
+
${before.split('\n').map((l) => `<div class="rv-l was"><span class="g"></span><span class="t">${esc(l)}</span></div>`).join('')}
|
|
1966
|
+
</div>
|
|
1967
|
+
<textarea class="rv-ta" data-rv-ta="${i}" spellcheck="false" rows="${Math.min(12, Math.max(3, nl(after) + 1))}">${esc(after)}</textarea>
|
|
1968
|
+
<div class="rv-reason" data-rv-reason="${i}">
|
|
1969
|
+
<span class="rv-only">What was wrong with it?</span>
|
|
1970
|
+
${RV_REASONS.map((r) => `<button class="rv-rz" data-rv-rz="${i}" data-val="${r[0]}">${esc(r[1])}</button>`).join('')}
|
|
1971
|
+
</div>
|
|
1972
|
+
<input class="rv-note" data-rv-note="${i}" hidden placeholder="in a few words…" aria-label="Why" autocomplete="off" />
|
|
1973
|
+
<div class="rv-acts">
|
|
1974
|
+
<button data-rv-save="${i}">Apply</button>
|
|
1975
|
+
<button class="q" data-rv-cancel="${i}">Cancel</button>
|
|
1976
|
+
<span class="rv-only">You are editing the change, not the file.</span>
|
|
1977
|
+
</div>`;
|
|
1978
|
+
}
|
|
1979
|
+
function wireReview() {
|
|
1980
|
+
const rv = state.review || {};
|
|
1981
|
+
const F = (n) => (rv.findings || [])[Number(n)];
|
|
1982
|
+
const box = (n) => $('docBody').querySelector(`[data-rv-edbox="${n}"]`);
|
|
1983
|
+
const card = (n) => $('docBody').querySelector(`[data-rv="${n}"]`);
|
|
1984
|
+
|
|
1985
|
+
$('docBody').querySelectorAll('.rv-f').forEach((el) => {
|
|
1986
|
+
el.addEventListener('click', (e) => {
|
|
1987
|
+
if (e.target.closest('button') || e.target.closest('textarea')) return;
|
|
1988
|
+
if (el.dataset.editing === '1') return; // never collapse an open editor by clicking it
|
|
1989
|
+
el.classList.toggle('on');
|
|
1990
|
+
});
|
|
1991
|
+
});
|
|
1992
|
+
$('docBody').querySelectorAll('[data-rv-edit]').forEach((b) => b.onclick = () => {
|
|
1993
|
+
const n = b.dataset.rvEdit;
|
|
1994
|
+
box(n).innerHTML = rvEditor(F(n), n);
|
|
1995
|
+
card(n).dataset.editing = '1';
|
|
1996
|
+
wireReview(); // re-wire the freshly rendered controls
|
|
1997
|
+
const ta = $('docBody').querySelector(`[data-rv-ta="${n}"]`); if (ta) ta.focus();
|
|
1998
|
+
});
|
|
1999
|
+
$('docBody').querySelectorAll('[data-rv-cancel]').forEach((b) => b.onclick = () => {
|
|
2000
|
+
const n = b.dataset.rvCancel;
|
|
2001
|
+
box(n).innerHTML = ''; card(n).dataset.editing = '0'; // closing IS the whole of "writes nothing"
|
|
2002
|
+
});
|
|
2003
|
+
$('docBody').querySelectorAll('[data-rv-apply]').forEach((b) => b.onclick = () => rvDispose(F(b.dataset.rvApply), 'apply'));
|
|
2004
|
+
$('docBody').querySelectorAll('[data-rv-dismiss]').forEach((b) => b.onclick = () => rvDispose(F(b.dataset.rvDismiss), 'dismiss'));
|
|
2005
|
+
// The reason tap. Required on an edit (R8 / correction-record.js) — an edit is where the agent was
|
|
2006
|
+
// wrong in a way worth knowing, and a record that cannot say HOW is the self-report this feature
|
|
2007
|
+
// exists to replace. Dismiss stays one click: friction belongs on the informative action, not the
|
|
2008
|
+
// common one (PRD open question 2).
|
|
2009
|
+
$('docBody').querySelectorAll('[data-rv-rz]').forEach((b) => b.onclick = () => {
|
|
2010
|
+
const n = b.dataset.rvRz;
|
|
2011
|
+
const row = $('docBody').querySelector(`[data-rv-reason="${n}"]`);
|
|
2012
|
+
row.querySelectorAll('.rv-rz').forEach((x) => x.classList.toggle('on', x === b));
|
|
2013
|
+
row.dataset.picked = b.dataset.val;
|
|
2014
|
+
const note = $('docBody').querySelector(`[data-rv-note="${n}"]`);
|
|
2015
|
+
note.hidden = b.dataset.val !== 'other'; // 'other' is only useful with words
|
|
2016
|
+
if (b.dataset.val === 'other') note.focus();
|
|
2017
|
+
const nudge = row.querySelector('.rv-nudge'); if (nudge) nudge.remove();
|
|
2018
|
+
});
|
|
2019
|
+
$('docBody').querySelectorAll('[data-rv-save]').forEach((b) => b.onclick = () => {
|
|
2020
|
+
const n = b.dataset.rvSave;
|
|
2021
|
+
const row = $('docBody').querySelector(`[data-rv-reason="${n}"]`);
|
|
2022
|
+
const picked = row && row.dataset.picked;
|
|
2023
|
+
if (!picked) {
|
|
2024
|
+
// An inline nudge, not a modal and not a silent default. Defaulting is what produced the bug
|
|
2025
|
+
// this replaces: every edit recorded "incomplete" whether or not it was.
|
|
2026
|
+
if (!row.querySelector('.rv-nudge')) {
|
|
2027
|
+
const s = document.createElement('span');
|
|
2028
|
+
s.className = 'rv-nudge'; s.textContent = 'Pick one — the record is worth nothing without it.';
|
|
2029
|
+
row.appendChild(s);
|
|
2030
|
+
}
|
|
2031
|
+
return;
|
|
2032
|
+
}
|
|
2033
|
+
const note = $('docBody').querySelector(`[data-rv-note="${n}"]`);
|
|
2034
|
+
const ta = $('docBody').querySelector(`[data-rv-ta="${n}"]`);
|
|
2035
|
+
rvDispose(F(n), 'edit', ta ? ta.value : '', picked, note && !note.hidden ? note.value : undefined);
|
|
2036
|
+
});
|
|
2037
|
+
}
|
|
2038
|
+
async function rvDispose(f, disposition, pmText, reason, reasonNote) {
|
|
2039
|
+
if (!f) return;
|
|
2040
|
+
const rv = state.review || {};
|
|
2041
|
+
// The client sends back the exact text it rendered; the server re-derives the diff and refuses if
|
|
2042
|
+
// they differ (sprint criterion 4). That is what makes a stale or tampered client safe.
|
|
2043
|
+
let renderedAfter = null;
|
|
2044
|
+
if (disposition !== 'dismiss') {
|
|
2045
|
+
try {
|
|
2046
|
+
const r = await fetch(`/api/doc?ws=${encodeURIComponent(state.wsId || '')}&path=${encodeURIComponent(f.file)}`);
|
|
2047
|
+
const cur = await r.text();
|
|
2048
|
+
const prop = disposition === 'edit' ? { before: f.proposal.before, after: pmText } : f.proposal;
|
|
2049
|
+
renderedAfter = window.ApplyChange.applyChange(cur, prop).text;
|
|
2050
|
+
} catch { /* leave null — the server refuses rather than guessing */ }
|
|
2051
|
+
}
|
|
2052
|
+
const body = {
|
|
2053
|
+
ws: state.wsId, disposition, finding: f, rendered_after: renderedAfter,
|
|
2054
|
+
pm_text: disposition === 'edit' ? pmText : undefined,
|
|
2055
|
+
reason, reason_note: reasonNote, // the PM's own word — never defaulted, never inferred
|
|
2056
|
+
run_id: rv.id, initiative_id: 'workspace-review-and-edit',
|
|
2057
|
+
};
|
|
2058
|
+
let out = {};
|
|
2059
|
+
try { out = await (await fetch('/api/review/dispose', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })).json(); }
|
|
2060
|
+
catch { out = { ok: false, error: 'could not reach the workspace server' }; }
|
|
2061
|
+
if (!out.ok) { rvSay(out.error || 'refused', out.detail); return; }
|
|
2062
|
+
openSurface('review'); // re-read: the record is the source of truth
|
|
2063
|
+
}
|
|
2064
|
+
function rvSay(msg, detail) {
|
|
2065
|
+
const el = document.createElement('div');
|
|
2066
|
+
el.className = 'rv-say';
|
|
2067
|
+
el.textContent = detail ? `${msg} — ${detail}` : msg;
|
|
2068
|
+
$('docBody').querySelector('.rv') ? $('docBody').querySelector('.rv').prepend(el) : $('docBody').prepend(el);
|
|
2069
|
+
}
|
|
2070
|
+
|
|
1380
2071
|
function surfaceBody(name, payload) {
|
|
1381
2072
|
if (!payload || !payload.ok) return sfCantProduce(name, payload && payload.contract);
|
|
1382
2073
|
const s = payload.surface || {};
|
|
@@ -1791,8 +2482,14 @@ document.addEventListener('keydown', (e) => {
|
|
|
1791
2482
|
e.preventDefault(); e.stopPropagation(); openScratch(); return;
|
|
1792
2483
|
}
|
|
1793
2484
|
if (e.key === 'F2' && state.active && !state.renaming) { e.preventDefault(); startRename(state.active); return; }
|
|
2485
|
+
// ⌃1–⌃4 — the named layouts, in one act. Ctrl only (⌘1-4 are the browser's tab switches).
|
|
2486
|
+
if (e.ctrlKey && !e.metaKey && !e.altKey && /^[1-9]$/.test(e.key)) {
|
|
2487
|
+
const l = state.layouts.find((x) => x.key === Number(e.key));
|
|
2488
|
+
if (l) { e.preventDefault(); e.stopPropagation(); selectLayout(l.id); return; }
|
|
2489
|
+
}
|
|
1794
2490
|
if (e.key === 'Escape') {
|
|
1795
|
-
if (
|
|
2491
|
+
if (!$('layMenu').hidden) { e.stopPropagation(); closeLayMenu(); }
|
|
2492
|
+
else if ($('palette').style.display === 'grid') { e.stopPropagation(); closePalette(); }
|
|
1796
2493
|
else if ($('fsPicker').style.display === 'grid') { e.stopPropagation(); closeFsPicker(); }
|
|
1797
2494
|
else if (state.renaming) { e.stopPropagation(); cancelRename(); renderSessions(); }
|
|
1798
2495
|
else if (!$('newForm').hidden) closeNewForm();
|