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
|
@@ -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
|
-
|
|
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
|
-
|
|
160
|
-
|
|
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:
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
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' },
|