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.
@@ -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',
@@ -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
- 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(); }
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
- if (changed) { state.branch = null; renderBranch(); loadScratchpad(); }
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
- 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;
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
- 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) {
307
343
  const title = s.hx && s.hx.title;
308
- if (title) return `<span class="sub"><span class="lead">opening focus</span> ${esc(title)}</span>`;
309
- 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)}`;
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
- function saysLine(s) {
315
- 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) };
316
356
  const why = s.missing && s.missing.lastWords;
317
- 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>` : '';
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 harnessDrawer(s) {
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">${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
+ }
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
- const tab = $('stabs').querySelector(`.stab-wrap[data-id="${s.id}"]`);
391
- 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
+ });
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
- $('stabs').innerHTML = mySessions().map((s) => `
399
- <div class="stab-wrap" data-state="${stateLabel(s).shown}" data-id="${s.id}" data-active="${s.id === state.active}">
400
- <button class="stab" role="tab" data-id="${s.id}" aria-selected="${s.id === state.active}"
401
- aria-label="${esc(s.name)} — ${s.state}" title="double-click to rename">
402
- <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>
403
510
  <button class="x" data-close="${s.id}"
404
511
  aria-label="${s.state === 'exited' ? 'remove' : 'close'} ${esc(s.name)}">✕</button>
405
- </div>`).join('');
406
- $('stabs').querySelectorAll('.stab').forEach((b) => {
407
- b.onclick = () => selectSession(b.dataset.id);
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
- $('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 }));
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
- 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;
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
- 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}"]`);
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
- 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;
466
591
  let t = state.terms.get(id);
467
- 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
+ }
468
596
  const el = document.createElement('div');
469
597
  el.className = 'termpane'; el.dataset.id = id;
470
- $('termhost').appendChild(el);
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). A single observer on
500
- // the host catches every cause — the needs strip, the reader, a folded rail pane, the tab strip
501
- // 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.
502
632
  let refitTimer = null;
503
633
  function refit() {
504
634
  clearTimeout(refitTimer);
505
- 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);
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
- 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();
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
- for (const [sid, t] of state.terms) t.el.classList.toggle('on', sid === id);
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.active = null;
533
- $('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();
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
- // ── rail + pane folding (R12), all persisted ──
752
- function foldPane(which, open) {
753
- const pane = which === 'doc' ? $('paneDoc') : $('paneLib');
754
- const btn = which === 'doc' ? $('docFold') : $('libFold');
755
- pane.dataset.fold = open ? 'on' : 'off';
756
- btn.setAttribute('aria-expanded', String(open));
757
- store.set('fold-' + which, open ? 'on' : 'off');
758
- 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);
759
956
  }
760
- function showRail() { if ($('bodyGrid').classList.contains('rail-off')) toggleRail(true); }
761
- function toggleRail(on) {
762
- const off = on === undefined ? !$('bodyGrid').classList.contains('rail-off') : !on;
763
- $('bodyGrid').classList.toggle('rail-off', off);
764
- $('railBtn').setAttribute('aria-pressed', String(off));
765
- 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();
766
987
  refit();
767
988
  }
768
- // restore the layout the PM last chose
769
- foldPane('lib', store.get('fold-lib') !== 'off');
770
- foldPane('doc', store.get('fold-doc') !== 'off');
771
- if (store.get('rail') === 'off') toggleRail(false);
772
- { const s = store.get('libsplit'); if (s) document.documentElement.style.setProperty('--libsplit', s); }
773
- { 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' }); }
774
1172
 
775
1173
  $('libFold').onclick = () => foldPane('lib', $('paneLib').dataset.fold === 'off');
776
1174
  $('docFold').onclick = () => foldPane('doc', $('paneDoc').dataset.fold === 'off');
777
- $('railBtn').onclick = () => toggleRail();
1175
+ $('wsFold').onclick = () => foldPane('ws', $('paneWs').dataset.fold === 'off');
1176
+ $('sessFold').onclick = () => foldPane('sess', $('paneSess').dataset.fold === 'off');
778
1177
 
779
- // drag the rail's left edge — the reader is where PRDs and rubrics are actually read, and a
780
- // fixed narrow column makes a table of weighted dimensions unreadable
781
- (() => {
782
- 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;
783
1183
  let dragging = false;
784
- const apply = (clientX) => {
785
- const w = Math.min(Math.max(window.innerWidth - clientX, 280), Math.min(900, window.innerWidth - 420));
786
- const v = Math.round(w) + 'px';
787
- document.documentElement.style.setProperty('--rail-w', v);
788
- 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;
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
- grip.addEventListener('pointerdown', (e) => { dragging = true; grip.setPointerCapture(e.pointerId); e.preventDefault(); });
791
- grip.addEventListener('pointermove', (e) => { if (dragging) apply(e.clientX); });
792
- grip.addEventListener('pointerup', (e) => { dragging = false; try { grip.releasePointerCapture(e.pointerId); } catch {} refit(); });
793
- grip.addEventListener('keydown', (e) => {
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 cur = $('rail').getBoundingClientRect().width;
797
- 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));
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.console
1347
- ? `/api/console?name=${encodeURIComponent(name)}&ws=${encodeURIComponent(state.wsId || '')}`
1348
- : `/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 || '')}`;
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 ($('palette').style.display === 'grid') { e.stopPropagation(); closePalette(); }
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();