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.
@@ -83,9 +83,35 @@ function runningCount() {
83
83
 
84
84
  /* ── the shell, built once ─────────────────────────────────────────────────────────────────── */
85
85
 
86
+ /* ── Why every track and every column here is `minmax(0, …)` / `min-height:0` ─────────────────
87
+ *
88
+ * The shell is 100vh and nothing in it is supposed to scroll the PAGE — a panel scrolls, the app
89
+ * never does. That held right up until a long document was opened in panel 4, and then the whole
90
+ * user interface scrolled: the top bar slid away, the status bar came up from below, and the doc
91
+ * pane's own `overflow-y:auto` never engaged.
92
+ *
93
+ * The cause is one CSS default, in two guises, and it is worth naming precisely because it is
94
+ * invisible in a style string that looks correct:
95
+ *
96
+ * • `grid-template-rows: 44px 1fr 26px` — `1fr` is shorthand for `minmax(auto, 1fr)`, and that
97
+ * `auto` MINIMUM is the item's min-content height. A tall document therefore does not overflow
98
+ * the middle row; it ENLARGES it, past the 100vh the row was supposed to be trapped inside.
99
+ * • a grid item / flex item with `min-height: auto` cannot shrink below its content either, so
100
+ * even a correctly-sized track hands the overflow straight through to the next box down.
101
+ *
102
+ * Overflow that is `visible` still contributes to the SCROLLABLE overflow region of every ancestor
103
+ * that does not clip it, all the way up to the viewport — which is exactly the reported symptom:
104
+ * the page scrolls, not the pane. So the fix is the whole chain, not one box. Each ancestor between
105
+ * the viewport and a scrolling pane declares a zero minimum, and the root clips as the backstop so
106
+ * a future panel that forgets cannot reach the page again.
107
+ *
108
+ * The design prototype had this right (`grid-template-rows:44px minmax(0,1fr) 28px`, see
109
+ * prototype/workspace-layout-variants/*.html); the port dropped the `minmax(0, …)` and read
110
+ * identically until something tall was rendered into it.
111
+ */
86
112
  const root = el('div', {
87
113
  'data-pm-theme': state.theme,
88
- style: 'display:grid;grid-template-rows:44px 1fr 26px;height:100vh;background:var(--background);color:var(--foreground);font-family:var(--sans);font-size:13px;line-height:1.5',
114
+ style: 'display:grid;grid-template-rows:44px minmax(0,1fr) 26px;height:100vh;overflow:hidden;background:var(--background);color:var(--foreground);font-family:var(--sans);font-size:13px;line-height:1.5',
89
115
  });
90
116
 
91
117
  const themeBtn = el('button', {
@@ -113,7 +139,14 @@ const statusBar = el('div', {
113
139
  el('span', { style: 'margin-left:auto' }, 'sessions survive detach · ⌘K search · Esc closes tab'),
114
140
  );
115
141
 
116
- const bodyGrid = el('div', { style: 'display:grid;grid-template-columns:' + gridCols() + ';min-height:0' });
142
+ /* The body grid's own geometry, in one place so the initial build and every repaint below cannot
143
+ * disagree. The implicit ROW is stated explicitly as `minmax(0,1fr)` for the same reason the root's
144
+ * middle track is: an auto row is floored at its items' min-content height, and one tall panel
145
+ * would push the grid taller than the box it was given. */
146
+ const BODY_GRID_STYLE = () =>
147
+ 'display:grid;grid-template-columns:' + gridCols() + ';grid-template-rows:minmax(0,1fr);min-height:0;overflow:hidden';
148
+
149
+ const bodyGrid = el('div', { style: BODY_GRID_STYLE() });
117
150
 
118
151
  /** The collapsed 36px strip: a full-height button with a rotated vertical-rl label. */
119
152
  function makeRail(flag) {
@@ -131,10 +164,14 @@ const rail1 = makeRail('col1');
131
164
  const rail2 = makeRail('col2');
132
165
  rail0.label.textContent = 'repositories ⟩';
133
166
 
134
- const hostRepos = el('div', { style: 'background:var(--sidebar);border-right:1px solid var(--border);display:flex;flex-direction:column;min-width:0' });
135
- const hostInits = el('div', { style: 'background:var(--sidebar);border-right:1px solid var(--border);overflow-y:auto;min-width:0' });
136
- const hostDetail = el('div', { style: 'background:var(--sidebar);border-right:1px solid var(--border);overflow-y:auto;min-width:0;display:flex;flex-direction:column' });
137
- const hostTabs = el('div', { style: 'background:var(--term-bg);display:flex;flex-direction:column;min-width:0' });
167
+ /* `min-height:0` on all four: a grid item's automatic minimum is its content, so a panel taller
168
+ * than the viewport would otherwise stretch the row it sits in rather than scroll inside it. The
169
+ * two that already carry `overflow-y:auto` get it for free under the spec — stated anyway, because
170
+ * "this one is safe because of a rule elsewhere in the same line" is how the next edit breaks it. */
171
+ const hostRepos = el('div', { style: 'background:var(--sidebar);border-right:1px solid var(--border);display:flex;flex-direction:column;min-width:0;min-height:0;overflow-y:auto' });
172
+ const hostInits = el('div', { style: 'background:var(--sidebar);border-right:1px solid var(--border);overflow-y:auto;min-width:0;min-height:0' });
173
+ const hostDetail = el('div', { style: 'background:var(--sidebar);border-right:1px solid var(--border);overflow-y:auto;min-width:0;min-height:0;display:flex;flex-direction:column' });
174
+ const hostTabs = el('div', { style: 'background:var(--term-bg);display:flex;flex-direction:column;min-width:0;min-height:0;overflow:hidden' });
138
175
 
139
176
  setChildren(root, [topBar, bodyGrid, statusBar]);
140
177
 
@@ -172,7 +209,7 @@ function render() {
172
209
  rail1.label.textContent = modeTitle() + ' ⟩';
173
210
  rail2.label.textContent = detailTitle() + ' ⟩';
174
211
 
175
- bodyGrid.setAttribute('style', 'display:grid;grid-template-columns:' + gridCols() + ';min-height:0');
212
+ bodyGrid.setAttribute('style', BODY_GRID_STYLE());
176
213
 
177
214
  // On a phone there are no rails to collapse into and no second column to fold away: the four
178
215
  // hosts all stay mounted, one is DISPLAYED (pm/mobile.css picks it from `data-mpanel`), and the
@@ -277,9 +277,13 @@ function evalFor(ladderSteps) {
277
277
 
278
278
  /** A live session, in the shape panel 2 and panel 4 read. `shown` is the server's own derived state. */
279
279
  function sessionRow(s) {
280
- const label = s.shown === 'quiet' && Number.isFinite(s.quietMs)
280
+ // `s.label` is the SERVER's rendering of the presented state — one join (lib/session-state's
281
+ // `label`), one clock, one sentence. This used to be re-derived here from `shown` + `quietMs`,
282
+ // which is how the sidebar and the pane could describe one session with two different words. The
283
+ // local derivation survives only as a fallback for a server that predates the field.
284
+ const label = s.label || (s.shown === 'quiet' && Number.isFinite(s.quietMs)
281
285
  ? 'quiet ' + Math.max(1, Math.round(s.quietMs / 60000)) + 'm'
282
- : (s.shown === 'needs' ? 'needs you' : s.shown);
286
+ : (s.shown === 'needs' ? 'needs you' : s.shown));
283
287
  return { name: s.name, st: s.shown, label, profile: s.profile_id || null, id: s.id };
284
288
  }
285
289
 
@@ -441,10 +445,34 @@ function groupsFromSpine(spine) {
441
445
  const products = new Map(); // product name → {name, ws, root, count, board, records}
442
446
  let productOrder = [];
443
447
 
448
+ /* workspace id → the working-tree top it sits in (null for a folder with no git).
449
+ *
450
+ * This exists so panel 4 can answer "who else is already in the tree I am about to start a session
451
+ * in?" BEFORE it starts one. The session rows carry `tree` too, but only for trees that already
452
+ * have a session — which is one session too late for a chooser that runs on the FIRST click. The
453
+ * server derives this (`git rev-parse --show-toplevel`, via `repoIdentity`) and publishes it on
454
+ * /api/workspaces; it is read here and never re-derived. */
455
+ const wsTree = new Map();
456
+
457
+ /** The working tree a workspace id sits in, or null — for a folder with no git, or an unknown id. */
458
+ export function treeOfWorkspace(id) {
459
+ return (id && wsTree.get(id)) || null;
460
+ }
461
+
462
+ /** The branch that tree is on, or null. Shown beside the tree so a chooser names what is shared. */
463
+ const wsBranch = new Map();
464
+ export function branchOfWorkspace(id) {
465
+ return (id && wsBranch.get(id)) || null;
466
+ }
467
+
444
468
  /** Group registered working trees into products: several worktrees of one repo share a `repo_key`. */
445
469
  function foldWorkspaces(rows) {
446
470
  const byRepo = new Map();
447
471
  for (const w of rows || []) {
472
+ // Recorded per REGISTERED id, before the fold — folding is by repository, and the whole point
473
+ // of the tree key is that two registered ids can share one tree while a repo's two worktrees do
474
+ // not. Reading it off the folded row would lose exactly that distinction.
475
+ if (w && w.id) { wsTree.set(w.id, w.tree || null); wsBranch.set(w.id, w.branch || null); }
448
476
  const key = w.repo_key || w.root;
449
477
  if (!byRepo.has(key)) {
450
478
  byRepo.set(key, {
@@ -552,14 +580,22 @@ async function loadBoard(prod) {
552
580
  /** Publish every loaded product's board and records into state, in one patch. */
553
581
  function publishBoards() {
554
582
  const board = {};
583
+ /* Product NAME → workspace id, for every registered repository — not only the selected one.
584
+ * panel 4 keeps every open terminal live, including those belonging to a repository the PM is not
585
+ * currently looking at, and a parked tab's session must be resolved inside ITS OWN repository.
586
+ * Scoping it by the selected workspace is how a tab in one repo attached to a shell in another
587
+ * (tab-term.js `wsForTab`). */
588
+ const productWs = {};
555
589
  let records = {};
556
590
  for (const name of productOrder) {
557
591
  const p = products.get(name);
558
- if (!p || !p.board) continue;
592
+ if (!p) continue;
593
+ if (p.ws) productWs[name] = p.ws;
594
+ if (!p.board) continue;
559
595
  board[name] = p.board;
560
596
  records = { ...records, ...p.records };
561
597
  }
562
- setState({ board, records: { ...records, ...(hydrated) } });
598
+ setState({ board, productWs, records: { ...records, ...(hydrated) } });
563
599
  }
564
600
 
565
601
  /* Hydrated (fully derived) records live apart from the light ones so that a board reload cannot
@@ -617,12 +653,48 @@ function currentProductName() {
617
653
  */
618
654
 
619
655
  let pollTimer = null;
656
+ let sessionDigest = null;
657
+
658
+ /**
659
+ * WHAT THE SURFACE ACTUALLY RENDERS from a session row, as one string.
660
+ *
661
+ * Every `setState` repaints all four panels, and panel 2 rebuilds a board that is 138 initiatives
662
+ * deep on this repository. Publishing the poll unconditionally therefore tore down and rebuilt the
663
+ * whole window every four seconds, forever, whether or not anything had changed — which is a
664
+ * hundreds-of-milliseconds stall on the main thread, on a cadence, next to a terminal the PM is
665
+ * typing into. That is the jitter: input queued behind a repaint nobody asked for.
666
+ *
667
+ * The rows themselves change constantly (`lastOutputAt` moves with every byte an agent writes), so
668
+ * comparing the payload is useless. What is compared is the PROJECTION the panels read — id, name,
669
+ * workspace, derived state, the quiet reading ROUNDED THE WAY THE LABEL ROUNDS IT (minutes), and
670
+ * the fields the sidebar prints. A working agent produces an identical projection minute after
671
+ * minute, so the window stays still while it works, and repaints the moment something it shows
672
+ * actually moves.
673
+ */
674
+ function sessionProjection(rows) {
675
+ return (rows || []).map((s) => [
676
+ s.id, s.ws, s.name, s.agent, s.state, s.shown, s.note || '', s.cwd || '',
677
+ s.profile_id || '', s.profile_line || '',
678
+ // the label's own resolution, not the millisecond behind it
679
+ s.shown === 'quiet' && Number.isFinite(s.quietMs) ? Math.max(1, Math.round(s.quietMs / 60000)) : '',
680
+ // Joined on U+0000, fields and rows both. A separator that cannot occur in any of these
681
+ // values is what stops two different lists folding into one identical string and
682
+ // suppressing a repaint that was real.
683
+ ].join('\u0000')).join('\u0000\u0000');
684
+ }
620
685
 
621
686
  async function pollSessions() {
622
687
  if (FIXTURE) return;
623
688
  const out = await getJson('/api/sessions');
624
689
  if (out && Array.isArray(out.sessions)) {
625
- setState({ sessions: out.sessions, runningCount: liveCount(out.sessions) });
690
+ const digest = sessionProjection(out.sessions);
691
+ const count = liveCount(out.sessions);
692
+ // A poll that found nothing new is not a render. `runningCount` is compared too: it folds in
693
+ // `extraSess`, so a session started in this window still publishes on the very next poll.
694
+ if (digest !== sessionDigest || count !== state.runningCount) {
695
+ sessionDigest = digest;
696
+ setState({ sessions: out.sessions, runningCount: count });
697
+ }
626
698
  }
627
699
  schedulePoll();
628
700
  }
@@ -817,6 +889,46 @@ export function startSession(slug, sess) {
817
889
  setTimeout(pollSessions, 800);
818
890
  }
819
891
 
892
+ /* ── the scratchpad ─────────────────────────────────────────────────────────────────────────────
893
+ * The pad is PER SESSION, and the server has always stored it that way: `/api/scratchpad` is keyed
894
+ * by the server-issued session id (workspace-scoped, one file per session, deleted with it). What
895
+ * was lost in the four-panel port was the CLIENT half — the pad became one in-memory string for the
896
+ * whole page, so every tab in every repository showed, and overwrote, the same text.
897
+ *
898
+ * These two functions are the whole restoration. The key is `sess:<id>` or `ws:<id>`; the panel
899
+ * derives it, this file only speaks the two shapes the endpoint accepts.
900
+ */
901
+
902
+ function padParams(key) {
903
+ const k = String(key || '');
904
+ if (k.startsWith('sess:')) return { session: k.slice(5) };
905
+ if (k.startsWith('ws:')) return { ws: k.slice(3) };
906
+ return null;
907
+ }
908
+
909
+ /** panel-tabs.js — the stored text for one pad, or '' when there is none (and on any failure). */
910
+ export async function loadScratchpad(key) {
911
+ if (FIXTURE) return '';
912
+ const params = padParams(key);
913
+ if (!params) return '';
914
+ const out = await getJson('/api/scratchpad', params);
915
+ return out && out.ok !== false && typeof out.text === 'string' ? out.text : '';
916
+ }
917
+
918
+ /**
919
+ * panel-tabs.js — write one pad. A failure is reported to the console and NOT surfaced as a lost
920
+ * buffer: the text stays in `state.scratchPads`, so the PM keeps what they typed for this page even
921
+ * when the disk write refused.
922
+ */
923
+ export async function saveScratchpad(key, text) {
924
+ if (FIXTURE) return { ok: true };
925
+ const params = padParams(key);
926
+ if (!params) return { ok: false, error: 'no session or workspace to file this pad under' };
927
+ const out = await postJson('/api/scratchpad', { ...params, text });
928
+ if (out && out.ok === false) console.warn('[pm] the scratchpad was NOT saved: ' + out.error);
929
+ return out;
930
+ }
931
+
820
932
  /** tab-doc.js — `readDoc(path)`. The raw document body, or a rejected promise carrying the reason. */
821
933
  export async function readDoc(path) {
822
934
  if (FIXTURE) return '';
@@ -131,6 +131,13 @@ export function openTerm(slug, sess) {
131
131
  openTab({
132
132
  id: 'term:' + slug + ':' + sess.name, kind: 'term', title: sess.name, slug,
133
133
  repo: slugProduct()[slug], sess: sess.name, state: sess.st, profile: sess.profile || 'claude-work',
134
+ /* The SERVER's session id, when the row that was clicked carries one. This is the exact join:
135
+ * with it, the terminal attaches to precisely the PTY the PM pointed at, and a reload attaches
136
+ * to the same one again. Without it the tab falls back to matching by NAME, and names repeat
137
+ * across repositories — which is how a click in one repo opened another repo's shell.
138
+ * `spawn` is the opposite instruction, set only by `+ session`: start one, never adopt one. */
139
+ sessionId: (sess && sess.id) || null,
140
+ spawn: !!(sess && sess.spawn),
134
141
  });
135
142
  }
136
143