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.
@@ -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
- terms: new Map(), // id -> {term, fit, el}
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
- state.sessions = m.sessions; renderSessions(); renderWorkspaces();
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
- await Promise.all([loadLibrary(), loadNeeds()]);
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
- const QUIET_AFTER_MS = 30000; // mirrors harness-parse.QUIET_AFTER_MS
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
- function subLine(s) {
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="sub"><span class="lead">opening focus</span> ${esc(title)}</span>`;
278
- return `<span class="sub">${esc(s.agent)} · ${esc(s.cwd)}</span>`;
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
- function saysLine(s) {
284
- if (s.lastWords) return `<span class="says">${esc(s.lastWords)}</span>`;
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 ? `<span class="says na">${esc(why)}</span>` : '';
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 harnessDrawer(s) {
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">${rows.join('')}</dl></details>`;
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
- const tab = $('stabs').querySelector(`.stab-wrap[data-id="${s.id}"]`);
360
- if (tab && tab.dataset.state !== st.shown) tab.dataset.state = st.shown;
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
- $('stabs').innerHTML = mySessions().map((s) => `
368
- <div class="stab-wrap" data-state="${stateLabel(s).shown}" data-id="${s.id}" data-active="${s.id === state.active}">
369
- <button class="stab" role="tab" data-id="${s.id}" aria-selected="${s.id === state.active}"
370
- aria-label="${esc(s.name)} — ${s.state}" title="double-click to rename">
371
- <span class="g" aria-hidden="true">${GLYPH[s.state] || '•'}</span>${esc(s.name)}</button>
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>`).join('');
375
- $('stabs').querySelectorAll('.stab').forEach((b) => {
376
- b.onclick = () => selectSession(b.dataset.id);
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
- $('stabs').querySelectorAll('.x').forEach((b) => b.onclick = () => send({ t: 'kill', id: b.dataset.close }));
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
- if (s) b.dataset.state = s.state;
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
- const tab = $('stabs').querySelector(`.stab-wrap[data-id="${id}"]`);
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
- function ensureTerm(id) {
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) return 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
- $('termhost').appendChild(el);
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). A single observer on
468
- // the host catches every cause — the needs strip, the reader, a folded rail pane, the tab strip
469
- // wrapping, a window resize — instead of a fitOne() call sprinkled at each call site.
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(() => { if (state.active) fitOne(state.active); }, 30);
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
- state.active = id;
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
- for (const [sid, t] of state.terms) t.el.classList.toggle('on', sid === id);
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.active = null;
501
- $('termhost').querySelectorAll('.termpane').forEach((e) => e.classList.remove('on'));
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
- // ── rail + pane folding (R12), all persisted ──
719
- function foldPane(which, open) {
720
- const pane = which === 'doc' ? $('paneDoc') : $('paneLib');
721
- const btn = which === 'doc' ? $('docFold') : $('libFold');
722
- pane.dataset.fold = open ? 'on' : 'off';
723
- btn.setAttribute('aria-expanded', String(open));
724
- store.set('fold-' + which, open ? 'on' : 'off');
725
- refit();
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
- function showRail() { if ($('bodyGrid').classList.contains('rail-off')) toggleRail(true); }
728
- function toggleRail(on) {
729
- const off = on === undefined ? !$('bodyGrid').classList.contains('rail-off') : !on;
730
- $('bodyGrid').classList.toggle('rail-off', off);
731
- $('railBtn').setAttribute('aria-pressed', String(off));
732
- store.set('rail', off ? 'off' : 'on');
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
- // restore the layout the PM last chose
736
- foldPane('lib', store.get('fold-lib') !== 'off');
737
- foldPane('doc', store.get('fold-doc') !== 'off');
738
- if (store.get('rail') === 'off') toggleRail(false);
739
- { const s = store.get('libsplit'); if (s) document.documentElement.style.setProperty('--libsplit', s); }
740
- { const w = store.get('railw'); if (w) document.documentElement.style.setProperty('--rail-w', w); }
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
- $('railBtn').onclick = () => toggleRail();
1175
+ $('wsFold').onclick = () => foldPane('ws', $('paneWs').dataset.fold === 'off');
1176
+ $('sessFold').onclick = () => foldPane('sess', $('paneSess').dataset.fold === 'off');
745
1177
 
746
- // drag the rail's left edge — the reader is where PRDs and rubrics are actually read, and a
747
- // fixed narrow column makes a table of weighted dimensions unreadable
748
- (() => {
749
- const grip = $('railGrip');
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 apply = (clientX) => {
752
- const w = Math.min(Math.max(window.innerWidth - clientX, 280), Math.min(900, window.innerWidth - 420));
753
- const v = Math.round(w) + 'px';
754
- document.documentElement.style.setProperty('--rail-w', v);
755
- store.set('railw', v);
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
- grip.addEventListener('pointerdown', (e) => { dragging = true; grip.setPointerCapture(e.pointerId); e.preventDefault(); });
758
- grip.addEventListener('pointermove', (e) => { if (dragging) apply(e.clientX); });
759
- grip.addEventListener('pointerup', (e) => { dragging = false; try { grip.releasePointerCapture(e.pointerId); } catch {} refit(); });
760
- grip.addEventListener('keydown', (e) => {
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 cur = $('rail').getBoundingClientRect().width;
764
- apply(window.innerWidth - (cur + (e.key === 'ArrowLeft' ? 24 : -24)));
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
- send({ t: 'new', agent: state.newAgent, name: name || state.newAgent, ws: state.wsId });
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.console
1217
- ? `/api/console?name=${encodeURIComponent(name)}&ws=${encodeURIComponent(state.wsId || '')}`
1218
- : `/api/surface?name=${encodeURIComponent(name)}&ws=${encodeURIComponent(state.wsId || '')}`;
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 ($('palette').style.display === 'grid') { e.stopPropagation(); closePalette(); }
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();