create-pmos 0.18.0 → 0.19.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.
@@ -146,20 +146,104 @@ export function closeTab(id) {
146
146
  /* The data layer's optional session-start hook. In fixture mode nothing is ever started; in real
147
147
  * mode the PTY is data.js's to create, and panel 4's part is only to show the tab for it. */
148
148
  let dataMod = null;
149
- import('./data.js').then((m) => { dataMod = m; }).catch(() => { dataMod = null; });
149
+ /* The repaint matters: `syncPad` below cannot name a pad before this module exists, so the render
150
+ * that lands it is the render that first loads the PM's staged text. */
151
+ import('./data.js').then((m) => { dataMod = m; setState({}); }).catch(() => { dataMod = null; });
152
+
153
+ /**
154
+ * The next free `agent <n>`, counted against EVERY name the window can see — the server's live
155
+ * sessions, the ones started in this window, and the tabs already open — not just this page's
156
+ * counter.
157
+ *
158
+ * `startN` resets to 1 on every reload, and the name is what the terminal used to join on and what
159
+ * the tab id is built from. So after a reload the first `+ session` proposed `agent 1`, which was
160
+ * already running from before the reload: the click attached to that old session instead of
161
+ * starting a new one, and a second click collided with its own tab id. Counting past what exists
162
+ * is what makes `+ session` mean "a new one" again.
163
+ */
164
+ function nextSessionName() {
165
+ let max = state.startN - 1;
166
+ const bump = (name) => {
167
+ const m = /^agent (\d+)$/.exec(String(name || ''));
168
+ if (m) max = Math.max(max, Number(m[1]));
169
+ };
170
+ for (const s of (state.sessions || [])) bump(s && s.name);
171
+ for (const k in (state.extraSess || {})) for (const s of (state.extraSess[k] || [])) bump(s && s.name);
172
+ for (const t of (state.tabs || [])) if (t && t.kind === 'term') bump(t.sess);
173
+ return { n: max + 1, name: 'agent ' + (max + 1) };
174
+ }
175
+
176
+ /* ── who is already in the tree this session would land in ───────────────────────────────────
177
+ *
178
+ * The workspace used to answer this AFTER the fact: it started the session, then wrote "2 sessions
179
+ * already running in this working tree" into the new pane. That sentence is true and arrives one
180
+ * step too late — the PM has already made the thing they might not have wanted, and the sentence
181
+ * names a count they cannot act on. Worse, the answer they usually want is not "start another"
182
+ * at all; it is "which of those is the one I was already working in?".
183
+ *
184
+ * So the same facts are computed BEFORE the spawn, from data the page already holds:
185
+ * • the tree the new session would land in — data.js publishes it per registered workspace id
186
+ * (`/api/workspaces` → `tree`), which is available even when that tree has no session yet;
187
+ * • the live sessions in it — `state.sessions`, each row carrying the server's own `tree`,
188
+ * `tree_role`, presented state and rendered label;
189
+ * • what ending each one would cost — lib/tree-share's `endRisk`, the same module the server uses
190
+ * for the pane advisory, so the two readings are one reading.
191
+ *
192
+ * Nothing is derived twice and nothing is guessed. When any input is missing — fixture mode, no
193
+ * workspace selected, a folder with no git, the shared module not loaded — this answers "nobody",
194
+ * and `+ session` behaves exactly as it always did. A chooser that appears on a bad guess would be
195
+ * worse than no chooser.
196
+ */
197
+ function treePeersNow() {
198
+ if (FIXTURE) return null;
199
+ const TS = typeof window !== 'undefined' ? window.PMOSTreeShare : null;
200
+ if (!TS) return null;
201
+ const ws = state.wsId || null;
202
+ const tree = ws && dataMod && typeof dataMod.treeOfWorkspace === 'function'
203
+ ? dataMod.treeOfWorkspace(ws) : null;
204
+ if (!tree) return null; // no git, or nothing selected — no index to share
205
+ const rows = Array.isArray(state.sessions) ? state.sessions : [];
206
+ // The map coTenants() keys on. Built from each row's OWN `tree`, which the server derived — the
207
+ // client never runs git and must not pretend to. The target workspace is added explicitly so a
208
+ // tree whose only registration has no session yet still resolves.
209
+ const treeOfWs = new Map([[ws, tree]]);
210
+ for (const r of rows) if (r && r.ws && !treeOfWs.has(r.ws)) treeOfWs.set(r.ws, r.tree || null);
211
+ const mates = TS.coTenants(rows, treeOfWs, tree, null);
212
+ if (!mates.length) return null;
213
+ // The declared writer, read off the row the server already marked rather than recomputed.
214
+ const held = rows.find((r) => r && r.tree === tree && r.tree_role === 'writer');
215
+ return {
216
+ tree,
217
+ branch: (dataMod && typeof dataMod.branchOfWorkspace === 'function' && dataMod.branchOfWorkspace(ws)) || null,
218
+ peers: mates.map((m) => TS.describeTenant(m, held ? held.id : null)),
219
+ };
220
+ }
150
221
 
151
222
  /**
152
223
  * `+ session` (line 2087): file a new session against the selected initiative and open its tab.
153
224
  * The session lands in `extraSess` — the prototype's own list of sessions started in this window —
154
225
  * and the terminal tab is opened in the callback, i.e. after the render that added it.
226
+ *
227
+ * `opts.force` skips the chooser. It is what the chooser's own "start another session here anyway"
228
+ * calls, so the guard is asked exactly once per click and a PM who has already answered the
229
+ * question is never asked it twice.
155
230
  */
156
- export function newSession(slug) {
231
+ export function newSession(slug, opts) {
157
232
  const target = slug || selSlug();
158
233
  if (!target) return;
159
- const n = state.startN;
160
- const sess = { name: 'agent ' + n, st: 'working', label: 'working', profile: state.newSessProfile };
234
+ if (!(opts && opts.force)) {
235
+ const found = treePeersNow();
236
+ if (found) { setState({ startPrompt: { slug: target, ...found }, endConfirm: null }); return; }
237
+ }
238
+ const { n, name } = nextSessionName();
239
+ /* `spawn` is carried on the session and thence onto the tab: it is an INSTRUCTION to start a PTY,
240
+ * and it is the only thing that authorises one. Without it the terminal resolves by name, which
241
+ * is a question about what already exists, not a request for something new. */
242
+ const sess = { name, st: 'working', label: 'working', profile: state.newSessProfile, spawn: true };
161
243
  setState((s) => ({
162
244
  startN: n + 1,
245
+ startPrompt: null,
246
+ endConfirm: null,
163
247
  extraSess: { ...s.extraSess, [target]: [...(s.extraSess[target] || []), sess] },
164
248
  }), () => openTermTab(target, sess));
165
249
  if (!FIXTURE && dataMod && typeof dataMod.startSession === 'function') {
@@ -167,6 +251,25 @@ export function newSession(slug) {
167
251
  }
168
252
  }
169
253
 
254
+ /** Join a session that is already running instead of starting one: open its tab, start nothing. */
255
+ export function joinSession(slug, peer) {
256
+ if (!peer) return;
257
+ setState({ startPrompt: null, endConfirm: null }, () => openTermTab(slug, {
258
+ name: peer.name, st: peer.shown, profile: peer.profile || null,
259
+ /* The SERVER's id, and NO `spawn`. Both halves matter and they are the exact difference
260
+ * between joining and starting: `id` is the join, so the tab attaches to precisely the PTY the
261
+ * PM pointed at rather than matching by a name that repeats across repositories; the absent
262
+ * `spawn` is what withholds authorisation to create one. A peer described by
263
+ * treeShare.describeTenant always carries its id, so this is never a name-match in practice. */
264
+ id: peer.id || null,
265
+ }));
266
+ }
267
+
268
+ /** Dismiss the chooser without starting or joining anything. */
269
+ export function dismissStartPrompt() {
270
+ setState({ startPrompt: null, endConfirm: null });
271
+ }
272
+
170
273
  /** `⫽ split` / `⫽ unsplit` (lines 2088–2092). */
171
274
  export function toggleSplit() {
172
275
  setState((s) => {
@@ -198,19 +301,124 @@ const emptyState = el('div', {
198
301
  style: 'flex:1;display:grid;place-items:center;color:oklch(0.55 0.02 213);font-size:12.5px;padding:20px;text-align:center',
199
302
  }, "nothing open — click a task's output or an artifact, or + session above");
200
303
 
201
- const panesRow = el('div', { style: 'flex:1;display:flex;min-height:0' });
304
+ const panesRow = el('div', { style: 'flex:1;display:flex;min-height:0;min-width:0;overflow:hidden' });
202
305
 
203
306
  /* ── the scratchpad (lines 983–991) ──────────────────────────────────────────────────────────
204
307
  * One textarea, created once. Its text is written back as a CHILD TEXT NODE as well as a value,
205
308
  * because that is what the prototype's runtime does and the comparator reads text nodes — but only
206
309
  * when the two differ, so a repaint mid-sentence never moves the PM's caret.
310
+ *
311
+ * ── ONE PAD PER SESSION ─────────────────────────────────────────────────────────────────────
312
+ * The pad stages the next thing to type into a PARTICULAR agent, so it belongs to that agent's
313
+ * session. The four-panel port shipped it as one in-memory string for the whole page, which meant
314
+ * every terminal — in every repository, in every window — showed and overwrote the same buffer.
315
+ *
316
+ * The store is `state.scratchPads`, keyed by:
317
+ * `sess:<server session id>` — a terminal tab bound to a live PTY. The id is the SERVER's, so two
318
+ * sessions both called `agent 1` in two repositories are two pads.
319
+ * `ws:<workspace id>` — everything else (a doc, a task, nothing open): the repository pad.
320
+ * That is exactly the key space `/api/scratchpad` already stores under — the endpoint has been
321
+ * per-session all along, and it is the client half that went missing. `state.scratchText` remains
322
+ * the text ON SCREEN, so the copy button, `⇥ send to terminal` and the fixture render are unchanged.
323
+ *
324
+ * FIXTURE MODE IS UNTOUCHED: no key, no fetch, one pad, the prototype's own placeholder. The offline
325
+ * demo path must make no network call, and the comparator must see the design's string.
207
326
  */
327
+ const SCRATCH_PLACEHOLDER = FIXTURE
328
+ ? 'scratchpad — survives tab switches, dies with the page'
329
+ : 'scratchpad — one pad per session, saved on this machine';
330
+
208
331
  const scratchTa = el('textarea', {
209
- placeholder: 'scratchpad — survives tab switches, dies with the page',
332
+ placeholder: SCRATCH_PLACEHOLDER,
210
333
  style: 'height:96px;background:oklch(0.11 0.004 228.8);color:#c4ccce;padding:10px 14px;font-family:var(--mono);font-size:12px',
211
- onInput: (e) => setState({ scratchText: e.target.value }),
334
+ onInput: (e) => onScratchInput(e.target.value),
212
335
  });
213
336
 
337
+ /* ── pad identity, loading and autosave ─────────────────────────────────────────────────────── */
338
+
339
+ const padLoading = new Set(); // keys with a read in flight — one read per key, ever
340
+ let padTimer = null;
341
+ let padPending = null; // {key, text} not yet written
342
+
343
+ /** The pad the ACTIVE tab stages against, or null when it cannot be named yet. */
344
+ function padKey() {
345
+ if (FIXTURE) return null;
346
+ const act = activeTab();
347
+ if (act && act.kind === 'term') {
348
+ const id = (termMod && typeof termMod.sessionIdOf === 'function')
349
+ ? termMod.sessionIdOf(act) : (act.sessionId || null);
350
+ // A terminal whose session has not resolved yet has no pad of its own YET. Null holds the
351
+ // drawer on whatever it was showing rather than flashing an empty pad and then SAVING it over
352
+ // the real one the moment the session lands.
353
+ return id ? 'sess:' + id : null;
354
+ }
355
+ return state.wsId ? 'ws:' + state.wsId : null;
356
+ }
357
+
358
+ /** Write the pending pad now. Called before leaving a pad, and when the page goes away. */
359
+ function flushPad() {
360
+ if (padTimer) { clearTimeout(padTimer); padTimer = null; }
361
+ if (!padPending) return;
362
+ const { key, text } = padPending;
363
+ padPending = null;
364
+ if (dataMod && typeof dataMod.saveScratchpad === 'function') {
365
+ try { dataMod.saveScratchpad(key, text); } catch { /* the text stays in state either way */ }
366
+ }
367
+ }
368
+
369
+ function schedulePadSave(key, text) {
370
+ padPending = { key, text };
371
+ if (padTimer) clearTimeout(padTimer);
372
+ padTimer = setTimeout(flushPad, 500); // one write per pause, not one per keystroke
373
+ }
374
+
375
+ function onScratchInput(text) {
376
+ if (FIXTURE) { setState({ scratchText: text }); return; }
377
+ const key = state.scratchKey;
378
+ setState((s) => ({
379
+ scratchText: text,
380
+ scratchPads: key ? { ...s.scratchPads, [key]: text } : s.scratchPads,
381
+ }));
382
+ if (key) schedulePadSave(key, text);
383
+ }
384
+
385
+ /**
386
+ * Bring the drawer onto the pad the active tab belongs to, reading it from the server the first
387
+ * time that pad is seen. Called at the top of every render; a no-op unless the pad CHANGED.
388
+ */
389
+ function syncPad() {
390
+ if (FIXTURE || !dataMod) return; // no data layer yet — retried on the next render
391
+ const key = padKey();
392
+ if (!key || key === state.scratchKey) return;
393
+ flushPad(); // the pad being left is written before it is left
394
+ if (Object.prototype.hasOwnProperty.call(state.scratchPads, key)) {
395
+ setState({ scratchKey: key, scratchText: state.scratchPads[key] });
396
+ return;
397
+ }
398
+ setState({ scratchKey: key, scratchText: '' });
399
+ if (padLoading.has(key)) return;
400
+ padLoading.add(key);
401
+ const landed = (text) => {
402
+ padLoading.delete(key);
403
+ setState((s) => ({
404
+ scratchPads: { ...s.scratchPads, [key]: text },
405
+ // …and onto the screen only if this is still the pad on screen. A PM who switched tabs while
406
+ // the read was in flight must not have the arriving text land in the pad they moved to.
407
+ ...(s.scratchKey === key ? { scratchText: text } : {}),
408
+ }));
409
+ };
410
+ try { dataMod.loadScratchpad(key).then(landed, () => landed('')); }
411
+ catch { padLoading.delete(key); }
412
+ }
413
+
414
+ /* A pad edited in the last half-second is still worth keeping when the tab is closed or the machine
415
+ * sleeps. `pagehide` fires where `beforeunload` is unreliable (bfcache, mobile), and both are cheap. */
416
+ if (typeof window !== 'undefined' && !FIXTURE) {
417
+ window.addEventListener('pagehide', flushPad);
418
+ window.addEventListener('beforeunload', flushPad);
419
+ document.addEventListener('visibilitychange', () => { if (document.hidden) flushPad(); });
420
+ }
421
+
214
422
  const scratchCopyLabel = iv('⧉ copy');
215
423
  const scratchCopyBtn = el('button', {
216
424
  onClick: () => {
@@ -299,9 +507,117 @@ function copyActive() {
299
507
  setTimeout(() => setState({ copied: false }), 1200);
300
508
  }
301
509
 
510
+ /* ── the + session chooser ──────────────────────────────────────────────────────────────────
511
+ *
512
+ * Shown INSTEAD of a spawn when the working tree already has live sessions. It answers, in this
513
+ * order, the three questions the old one-line notice left the PM holding:
514
+ *
515
+ * what is running? — every peer named, with the sidebar's own word for its state, the
516
+ * agent's last words, and the writer claim where one is held.
517
+ * is it safe to end? — one sentence per peer from lib/tree-share's `endRisk`, worded from the
518
+ * EVIDENCE. Only `needs you` gets a plain "nothing is in flight"; quiet
519
+ * and maybe-blocked say, in as many words, that the workspace cannot
520
+ * tell — because a confident "safe" that discards a running turn is
521
+ * exactly the false confidence this repo calls a watermelon.
522
+ * can I just use it? — `open it`, which attaches a tab to that session and starts NOTHING.
523
+ *
524
+ * `end it` is the one destructive control on this surface and it is two clicks: the first arms it
525
+ * and the button says what will happen, the second sends it. Nothing else here can lose work.
526
+ *
527
+ * Rebuilt on each render, deliberately: it holds no PTY and no caret, so there is nothing for a
528
+ * persistent node to protect, and a chooser rebuilt from state cannot show a session that has
529
+ * since exited.
530
+ */
531
+ const RISK_COLOR = {
532
+ 'in-flight': 'var(--warning)',
533
+ settled: 'var(--success)',
534
+ unknown: 'var(--muted-foreground)',
535
+ gone: 'var(--muted-foreground)',
536
+ };
537
+ const CH_BTN = 'flex:none;font-family:var(--mono);font-size:10.5px;padding:4px 10px;min-height:26px;'
538
+ + 'background:oklch(1 0 0 / 6%);border:1px solid oklch(1 0 0 / 12%);color:oklch(0.85 0.01 214);white-space:nowrap';
539
+
540
+ function peerRow(prompt, p) {
541
+ const armed = state.endConfirm === p.id;
542
+ return el('div', {
543
+ style: 'display:flex;gap:10px;align-items:flex-start;padding:8px 10px;border:1px solid oklch(1 0 0 / 10%);background:oklch(1 0 0 / 3%)',
544
+ },
545
+ el('span', { style: 'flex:none;font-size:9px;line-height:18px;color:' + RISK_COLOR[p.risk.level] }, '●'),
546
+ el('div', { style: 'flex:1;min-width:0;display:flex;flex-direction:column;gap:2px' },
547
+ el('div', { style: 'display:flex;gap:8px;align-items:baseline;flex-wrap:wrap' },
548
+ el('span', { style: 'font-family:var(--mono);font-size:11.5px;color:oklch(0.95 0.01 214)' }, iv(p.name)),
549
+ el('span', { style: 'font-family:var(--mono);font-size:10.5px;color:oklch(0.72 0.014 214)' }, iv(p.label)),
550
+ p.agent ? el('span', { style: 'font-family:var(--mono);font-size:10px;color:oklch(0.55 0.02 213)' }, iv(p.agent)) : null,
551
+ p.writer ? el('span', {
552
+ style: 'font-family:var(--mono);font-size:9.5px;color:var(--brand);border:1px solid oklch(1 0 0 / 12%);padding:1px 5px',
553
+ }, 'writer') : null,
554
+ ),
555
+ // The agent's own last words, when the workspace could read them. Absent rather than
556
+ // substituted: a placeholder here would be a claim about a session nobody read.
557
+ p.doing ? el('div', {
558
+ style: 'font-family:var(--mono);font-size:10.5px;color:oklch(0.62 0.02 213);overflow:hidden;text-overflow:ellipsis;white-space:nowrap',
559
+ }, iv(p.doing)) : null,
560
+ el('div', { style: 'font-size:11px;color:' + RISK_COLOR[p.risk.level] + ';line-height:1.45' }, iv(p.risk.why)),
561
+ ),
562
+ el('div', { style: 'flex:none;display:flex;gap:6px;align-items:center' },
563
+ el('button', {
564
+ onClick: () => joinSession(prompt.slug, p),
565
+ style: CH_BTN + ';color:var(--brand)', hover: CHIP_HOVER,
566
+ }, 'open it'),
567
+ el('button', {
568
+ onClick: () => {
569
+ if (!armed) { setState({ endConfirm: p.id }); return; }
570
+ if (termMod && typeof termMod.killSession === 'function') termMod.killSession(p.id);
571
+ setState({ endConfirm: null });
572
+ },
573
+ style: CH_BTN + (armed ? ';color:var(--destructive);border-color:var(--destructive)' : ''),
574
+ hover: CHIP_HOVER,
575
+ }, iv(armed ? 'end “' + p.name + '” — click again' : 'end it')),
576
+ ),
577
+ );
578
+ }
579
+
580
+ function startPromptBlock() {
581
+ const prompt = state.startPrompt;
582
+ if (!prompt) return null;
583
+ const peers = prompt.peers || [];
584
+ const n = peers.length;
585
+ return el('div', {
586
+ style: 'flex:none;max-height:52vh;overflow-y:auto;display:flex;flex-direction:column;gap:8px;'
587
+ + 'padding:12px 14px;border-bottom:1px solid oklch(1 0 0 / 10%);background:oklch(0.11 0.004 228.8)',
588
+ },
589
+ el('div', { style: 'display:flex;gap:8px;align-items:baseline;flex-wrap:wrap' },
590
+ el('span', { style: 'font-size:12.5px;color:oklch(0.95 0.01 214)' },
591
+ iv('⑂ ' + n + ' session' + (n === 1 ? '' : 's') + ' already running in this working tree')),
592
+ el('span', { style: 'font-family:var(--mono);font-size:10.5px;color:oklch(0.55 0.02 213);overflow-wrap:anywhere' },
593
+ iv(prompt.tree + (prompt.branch ? ' · ' + prompt.branch : ''))),
594
+ ),
595
+ el('div', { style: 'font-size:11.5px;color:oklch(0.72 0.014 214);line-height:1.5' },
596
+ iv('You would share one branch, one index and one set of files with '
597
+ + (n === 1 ? 'it' : 'them') + '. Open one instead, end one, or start another anyway.')),
598
+ peers.map((p) => peerRow(prompt, p)),
599
+ el('div', { style: 'display:flex;gap:6px;flex-wrap:wrap;padding-top:2px' },
600
+ el('button', {
601
+ onClick: () => newSession(prompt.slug, { force: true }),
602
+ style: CH_BTN, hover: CHIP_HOVER,
603
+ }, '+ start another session here anyway'),
604
+ el('button', { onClick: () => dismissStartPrompt(), style: CH_BTN, hover: CHIP_HOVER }, 'cancel'),
605
+ ),
606
+ // The limit, where the PM meets the feature — never only in a comment. PMOS hosts the terminal;
607
+ // it does not sit between an agent and the files, and no button above changes that.
608
+ el('div', { style: 'font-size:10.5px;color:oklch(0.52 0.02 213);line-height:1.5' },
609
+ 'Nothing here is enforced: PMOS hosts your agent’s terminal, it does not sit between it and '
610
+ + 'the files. To work in parallel for real, start on a new branch with its own folder (⑂).'),
611
+ );
612
+ }
613
+
302
614
  /* ── pane hosts ────────────────────────────────────────────────────────────────────────────── */
303
615
 
304
- const PANE_STYLE = 'flex:1;min-width:0;display:flex;flex-direction:column;border-right:1px solid oklch(1 0 0 / 10%)';
616
+ /* `min-height:0` is load-bearing, not tidiness. A pane is a flex COLUMN whose body declares
617
+ * `flex:1;overflow-y:auto` — and a flex item's automatic minimum is its content, so without this
618
+ * the pane grows to fit a long document instead of letting the document scroll inside it, and the
619
+ * growth escapes upward until the whole page scrolls. See the chain documented in pm/app.js. */
620
+ const PANE_STYLE = 'flex:1;min-width:0;min-height:0;display:flex;flex-direction:column;border-right:1px solid oklch(1 0 0 / 10%)';
305
621
  const paneHosts = new Map(); // tab id -> the pane element, kept for the tab's whole life
306
622
 
307
623
  function paneFor(tab) {
@@ -396,7 +712,13 @@ function tabNode(t, act, splitTab) {
396
712
  const bc = selected ? FX.TAB_COLORS.activeBorder : FX.TAB_COLORS.idleBorder;
397
713
  const color = act && act.id === t.id ? FX.TAB_COLORS.activeText : FX.TAB_COLORS.idleText;
398
714
  const dotColor = FX.TAB_COLORS.dot[t.kind] || FX.TAB_COLORS.dot.doc;
399
- const meta = t.kind === 'term' ? 'session' : (t.kind === 'task' ? 'task' : t.role);
715
+ /* A terminal's sub-line names the SESSION IT IS ON, not the word "session". After a reload the PM
716
+ * could not tell which session a re-opened tab had landed on, and the answer was on screen
717
+ * nowhere; the server's id is the one identifier that does not repeat across repositories. The
718
+ * fixture render keeps the design's literal — the comparator reads this text node. */
719
+ const meta = t.kind === 'term'
720
+ ? ((!FIXTURE && t.sessionId) ? 'session ' + t.sessionId : 'session')
721
+ : (t.kind === 'task' ? 'task' : t.role);
400
722
 
401
723
  return el('div', {
402
724
  style: 'flex:none;display:flex;align-items:center;gap:8px;padding:0 10px;background:' + bg
@@ -433,6 +755,8 @@ function tabNode(t, act, splitTab) {
433
755
  * bar. The panes row renders even when nothing is open, exactly as the design does.
434
756
  */
435
757
  export function renderTabs(host) {
758
+ // Which pad the drawer is on is decided from the active tab, before anything is painted from it.
759
+ syncPad();
436
760
  const visTabs = visibleTabs();
437
761
  const act = visTabs.find((t) => t.id === state.active) || null;
438
762
  const splitTab = state.splitId
@@ -477,7 +801,12 @@ export function renderTabs(host) {
477
801
 
478
802
  setChildren(host, [
479
803
  tabBar,
480
- visTabs.length === 0 ? emptyState : null,
804
+ // Above the panes and below the tab bar: the chooser is about the click that was just made in
805
+ // that bar, and putting it here leaves every open pane — and every live PTY — exactly where it
806
+ // was. Replacing the panes with it would re-parent a terminal, which is the quiet regression
807
+ // the persistent pane hosts exist to prevent.
808
+ startPromptBlock(),
809
+ visTabs.length === 0 && !state.startPrompt ? emptyState : null,
481
810
  panesRow,
482
811
  state.scratchOpen ? scratchBlock : null,
483
812
  bottomBar,
@@ -38,18 +38,46 @@ function initial() {
38
38
  tabs: [], active: null, splitId: null,
39
39
  initLinks: {}, artLinks: {}, artSyncOpen: {}, artSyncState: {},
40
40
  extraSess: {}, termHist: {}, startN: 1, docEdits: {}, docModes: {},
41
+ /* `+ session` into a working tree that already has sessions in it opens a CHOOSER instead of
42
+ * spawning (workspace-session-choice): `{slug, tree, branch, peers}` while it is open, null
43
+ * otherwise. `endConfirm` is the id of the one peer whose `end it` has been armed — a
44
+ * second click ends it, and anything else disarms. Neither is persisted: a chooser that
45
+ * reappeared on reload would be a question about a moment that has passed. */
46
+ startPrompt: null, endConfirm: null,
41
47
  col0: true, col1: true, col2: true, scratchOpen: false, scratchText: '', copied: false,
42
48
  flowsMap: null, schedList: null, agentList: null, memory: null,
43
49
  savedDocs: {}, scratchCopied: false,
50
+ /* The scratchpad is ONE PAD PER SESSION, not one per page (see panel-tabs.js). `scratchPads`
51
+ * holds every pad this window has loaded, keyed by the pad key the panel derives — `sess:<id>`
52
+ * for a terminal bound to a PTY, `ws:<id>` for the repository pad a non-terminal tab stages
53
+ * against. `scratchText` remains the text ON SCREEN, so every existing reader (the copy button,
54
+ * ⇥ send to terminal, the fixture render) is unchanged. `scratchKey` is which pad that is. */
55
+ scratchPads: {}, scratchKey: null,
44
56
  };
45
57
  }
46
58
 
47
59
  /* ── persistence ──────────────────────────────────────────────────────────────────────────────
48
- * Only the PM's *arrangement* is persisted — never content, never tabs, never a live session.
49
- * Each key carries its own validator. A stored value that fails its validator is DROPPED and the
50
- * rest of the record is still applied: "layout amnesia" in the PRD's failure-mode list means a
51
- * stale record from an older build throwing, or silently wiping a layout the PM is working in.
52
- * An unknown key is simply not read, so an older or newer schema costs nothing.
60
+ * The PM's *arrangement*, and — since the reload defect — WHICH VIEWS WERE OPEN. Never content,
61
+ * never a pad's text, never a live PTY: a tab is a view of a session the server owns, and what is
62
+ * stored here is the identity of the view, not the thing it looks at.
63
+ *
64
+ * Each key carries its own validator, and optionally a `pack` that decides what is WRITTEN. A
65
+ * stored value that fails its validator is DROPPED and the rest of the record is still applied:
66
+ * "layout amnesia" in the PRD's failure-mode list means a stale record from an older build
67
+ * throwing, or silently wiping a layout the PM is working in. An unknown key is simply not read,
68
+ * so an older or newer schema costs nothing.
69
+ *
70
+ * ── Why `tabs` is here, and why it was not ──────────────────────────────────────────────────
71
+ * It read "never tabs" because a tab was thought to be a live thing. It is not: the PTY lives in
72
+ * the server and survives both detach and a server restart (R7), so a window that forgot its tabs
73
+ * was not protecting anything — it was throwing away the only record of which sessions the PM had
74
+ * open. The PM then had to re-find them by hand, one click at a time, with no way to tell which
75
+ * session a re-opened tab had landed on. Restoring the views is what makes the surviving sessions
76
+ * visible again on reload.
77
+ *
78
+ * The stored tab is PACKED, not the live record: a whitelist of scalar fields, capped at 40 tabs.
79
+ * A doc tab's edited buffer, a task's derived prose, and anything a future field might carry stay
80
+ * out of localStorage by construction rather than by remembering to exclude them.
53
81
  */
54
82
  const isBool = (v) => typeof v === 'boolean';
55
83
  const isSlug = (v) => v === null || typeof v === 'string';
@@ -57,6 +85,28 @@ const isFlagMap = (v) =>
57
85
  !!v && typeof v === 'object' && !Array.isArray(v) &&
58
86
  Object.keys(v).every((k) => typeof k === 'string' && typeof v[k] === 'boolean');
59
87
 
88
+ /* The only tab fields that are written. `sessionId` is the load-bearing one: it is the SERVER's own
89
+ * id, so a restored terminal re-attaches to exactly the PTY it was showing rather than re-running
90
+ * the name join — which is what made a reloaded tab land on a session the PM could not identify. */
91
+ const TAB_FIELDS = ['id', 'kind', 'title', 'slug', 'repo', 'sess', 'sessionId', 'state', 'profile',
92
+ 'path', 'role', 'phase', 'status'];
93
+ const MAX_TABS = 40;
94
+
95
+ function packTab(t) {
96
+ if (!t || typeof t !== 'object' || typeof t.id !== 'string' || typeof t.kind !== 'string') return null;
97
+ const out = {};
98
+ for (const k of TAB_FIELDS) {
99
+ const v = t[k];
100
+ if (v === null || typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') out[k] = v;
101
+ }
102
+ return out;
103
+ }
104
+
105
+ const isTabList = (v) =>
106
+ Array.isArray(v) && v.length <= MAX_TABS &&
107
+ v.every((t) => t && typeof t === 'object' && !Array.isArray(t)
108
+ && typeof t.id === 'string' && typeof t.kind === 'string');
109
+
60
110
  const PERSIST = {
61
111
  theme: (v) => v === 'dark' || v === 'light',
62
112
  col0: isBool, col1: isBool, col2: isBool,
@@ -65,6 +115,15 @@ const PERSIST = {
65
115
  sel: isSlug,
66
116
  collapsedGroups: isFlagMap,
67
117
  collapsedPhases: isFlagMap,
118
+ tabs: isTabList,
119
+ active: isSlug,
120
+ splitId: isSlug,
121
+ scratchOpen: isBool,
122
+ };
123
+
124
+ /** What is WRITTEN for a key, when it is not the live value verbatim. */
125
+ const PACK = {
126
+ tabs: (v) => (Array.isArray(v) ? v.slice(0, MAX_TABS).map(packTab).filter(Boolean) : []),
68
127
  };
69
128
 
70
129
  /** The exact set of keys written to localStorage, in write order. */
@@ -84,13 +143,19 @@ function restore(target) {
84
143
  try { ok = PERSIST[k](saved[k]); } catch { ok = false; }
85
144
  if (ok) target[k] = saved[k];
86
145
  }
146
+ /* `active` and `splitId` name tabs. A record whose tab list was dropped (or that names a tab the
147
+ * list no longer holds) must not restore a pointer into nothing — panel 4 would render an empty
148
+ * pane and the PM would see a window with no tab selected and no way to guess why. */
149
+ const ids = new Set((target.tabs || []).map((t) => t.id));
150
+ if (!ids.has(target.active)) target.active = target.tabs.length ? target.tabs[target.tabs.length - 1].id : null;
151
+ if (!ids.has(target.splitId)) target.splitId = null;
87
152
  return target;
88
153
  }
89
154
 
90
155
  function persist() {
91
156
  if (FIXTURE) return;
92
157
  const out = {};
93
- for (const k of persistKeys) out[k] = state[k];
158
+ for (const k of persistKeys) out[k] = PACK[k] ? PACK[k](state[k]) : state[k];
94
159
  try { localStorage.setItem(STORE_KEY, JSON.stringify(out)); } catch { /* quota / private mode */ }
95
160
  }
96
161
 
@@ -148,7 +148,7 @@ function viewBody(text) {
148
148
  const fm = [];
149
149
  doc.fm.forEach((fp) => { fm.push(el('span', { style: FM_K }, iv(fp.k)), el('span', { style: FM_V }, iv(fp.v))); });
150
150
  return el('div', {
151
- style: 'flex:1;overflow-y:auto;background:var(--background);color:var(--foreground);padding:20px 24px 60px;font-size:14px;line-height:1.65',
151
+ style: 'flex:1;min-height:0;overflow-y:auto;background:var(--background);color:var(--foreground);padding:20px 24px 60px;font-size:14px;line-height:1.65',
152
152
  },
153
153
  el('div', { style: 'max-width:72ch' },
154
154
  el('div', {
@@ -171,7 +171,7 @@ function viewBody(text) {
171
171
 
172
172
  function rawBody(text) {
173
173
  return el('div', {
174
- style: 'flex:1;overflow-y:auto;background:var(--background);padding:14px 16px;font-family:var(--mono);font-size:12px;line-height:1.7',
174
+ style: 'flex:1;min-height:0;overflow-y:auto;background:var(--background);padding:14px 16px;font-family:var(--mono);font-size:12px;line-height:1.7',
175
175
  }, text.split('\n').map((l) => {
176
176
  const ln = FX.rawLine(l);
177
177
  return el('div', {
@@ -183,7 +183,7 @@ function rawBody(text) {
183
183
  function diffBody(raw, text, dirty) {
184
184
  const rows = dirty ? FX.diffLines(raw, text) : [FX.NO_DIFF_ROW];
185
185
  return el('div', {
186
- style: 'flex:1;overflow-y:auto;background:var(--background);padding:14px 0;font-family:var(--mono);font-size:12px;line-height:1.7',
186
+ style: 'flex:1;min-height:0;overflow-y:auto;background:var(--background);padding:14px 0;font-family:var(--mono);font-size:12px;line-height:1.7',
187
187
  }, rows.map((dr) => el('div', {
188
188
  style: 'color:' + dr.c + ';background:' + dr.bg + ';padding:0 16px;white-space:pre-wrap;overflow-wrap:anywhere;min-height:1.2em',
189
189
  }, iv(dr.t))));
@@ -197,7 +197,7 @@ function editBox(tab) {
197
197
  let ta = boxes.get(tab.id);
198
198
  if (ta) return ta;
199
199
  ta = el('textarea', {
200
- style: 'flex:1;background:var(--background);color:var(--foreground);padding:14px 16px;font-family:var(--mono);font-size:12px;line-height:1.7',
200
+ style: 'flex:1;min-height:0;background:var(--background);color:var(--foreground);padding:14px 16px;font-family:var(--mono);font-size:12px;line-height:1.7',
201
201
  onInput: (e) => {
202
202
  const v = e.target.value;
203
203
  saveErr.delete(tab.path);
@@ -253,7 +253,7 @@ export function mountDoc(host, tab) {
253
253
  const dirtyColor = err ? 'var(--destructive)' : (dirty ? 'var(--warning)' : 'var(--muted-foreground)');
254
254
 
255
255
  const modeBar = el('div', {
256
- style: 'display:flex;gap:2px;align-items:center;padding:6px 10px;border-bottom:1px solid var(--border);background:var(--background)',
256
+ style: 'flex:none;display:flex;gap:2px;align-items:center;padding:6px 10px;border-bottom:1px solid var(--border);background:var(--background)',
257
257
  },
258
258
  modeBtns, saveBtn, revertBtn,
259
259
  el('span', { style: 'margin-left:auto;font-family:var(--mono);font-size:10px;color:' + dirtyColor }, iv(dirtyLabel)),
@@ -52,7 +52,7 @@ export function mountTask(host, tab) {
52
52
  'no artifact yet — produced when the agent runs this task');
53
53
 
54
54
  const body = el('div', {
55
- style: 'flex:1;overflow-y:auto;background:var(--background);color:var(--foreground);padding:22px 26px 60px;font-size:14px;line-height:1.65',
55
+ style: 'flex:1;min-height:0;overflow-y:auto;background:var(--background);color:var(--foreground);padding:22px 26px 60px;font-size:14px;line-height:1.65',
56
56
  },
57
57
  el('div', { style: 'max-width:76ch' },
58
58
  el('div', { style: 'display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:5px' },