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.
- package/README.md +57 -0
- package/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/scripts/metrics.py +9 -2
- package/workspace/README.md +21 -1
- package/workspace/lib/file-mode-parse.js +12 -1
- package/workspace/lib/start-policy.js +118 -0
- package/workspace/lib/tree-share.js +171 -13
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +2 -2
- package/workspace/public/index.html +6 -0
- package/workspace/public/pm/app.js +44 -7
- package/workspace/public/pm/data.js +117 -5
- package/workspace/public/pm/panel-initiatives.js +7 -0
- package/workspace/public/pm/panel-tabs.js +339 -10
- package/workspace/public/pm/state.js +71 -6
- package/workspace/public/pm/tab-doc.js +5 -5
- package/workspace/public/pm/tab-task.js +1 -1
- package/workspace/public/pm/tab-term.js +151 -19
- package/workspace/server.js +186 -9
|
@@ -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
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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',
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|