create-pmos 0.18.1 → 0.20.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 +30 -0
- package/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/workspace/README.md +21 -1
- package/workspace/lib/four-panel.js +259 -24
- package/workspace/lib/tree-share.js +171 -13
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +4 -4
- package/workspace/public/index.html +6 -0
- package/workspace/public/pm/app.js +44 -7
- package/workspace/public/pm/data.js +42 -2
- package/workspace/public/pm/fixture.js +24 -1
- package/workspace/public/pm/panel-detail.js +226 -20
- package/workspace/public/pm/panel-tabs.js +192 -4
- package/workspace/public/pm/state.js +6 -0
- 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 +17 -1
- package/workspace/server.js +87 -7
|
@@ -151,12 +151,33 @@ function hasConnector(kind) {
|
|
|
151
151
|
return c.some((x) => (typeof x === 'string' ? x : x && x.kind) === kind
|
|
152
152
|
&& (typeof x === 'string' || x.configured !== false));
|
|
153
153
|
}
|
|
154
|
+
// The server's shape: `{configured:[{kind, profile, ...}], note}`. Checked BEFORE the plain-map
|
|
155
|
+
// branch below, which would otherwise read `c['jira']` on this object, find undefined, and report
|
|
156
|
+
// "no connector" on a machine whose profiles declare one — a false negative dressed as caution.
|
|
157
|
+
if (c && Array.isArray(c.configured)) return c.configured.some((x) => x && x.kind === kind);
|
|
154
158
|
if (c && typeof c === 'object') return !!c[kind];
|
|
155
159
|
return false;
|
|
156
160
|
}
|
|
157
161
|
|
|
158
162
|
const NO_CONNECTOR = 'no connector configured — nothing was read, and nothing will be written.';
|
|
159
163
|
|
|
164
|
+
/**
|
|
165
|
+
* Why the two sync approvals are inert in real mode even when a destination IS resolvable.
|
|
166
|
+
*
|
|
167
|
+
* They are publishing and read-back, and both are out of scope by name (PRD non-goals 1–2).
|
|
168
|
+
* Publishing puts PMOS content in two places and needs a fork/drift ruling the PM has not made;
|
|
169
|
+
* read-back has no surviving architecture at all, because D53's inbound half was a hosted-Supabase
|
|
170
|
+
* webhook and D64 deprecated that path. The buttons' old results — `pushed · repo → jira · in sync
|
|
171
|
+
* 12:41` — were a hardcoded clock time presented as a sync confirmation, which is the same defect
|
|
172
|
+
* as the fabricated identifier one level up.
|
|
173
|
+
*
|
|
174
|
+
* Deleting the buttons was the other option and is worse: the PM would be left wondering whether
|
|
175
|
+
* PMOS forgot how to sync. An inert control that states the reason is a surface telling the truth
|
|
176
|
+
* about its own limits.
|
|
177
|
+
*/
|
|
178
|
+
const SYNC_OUT_OF_SCOPE = 'publishing and read-back are not built. PMOS records which record this '
|
|
179
|
+
+ 'corresponds to; it does not write to that record, and it has read nothing from it.';
|
|
180
|
+
|
|
160
181
|
/* ── the sticky detail header (lines 332–335) ───────────────────────────────────────────────
|
|
161
182
|
* Built once and re-used, so panel 3's chrome keeps its identity across renders the way panel 2's
|
|
162
183
|
* header and search box do.
|
|
@@ -276,24 +297,89 @@ function phaseBlock(slug, rec) {
|
|
|
276
297
|
}
|
|
277
298
|
|
|
278
299
|
/* ── 5a · LINKS (lines 380–398, derivation 1757–1766, 1797–1798) ────────────────────────────
|
|
279
|
-
* External references — NOT repo files.
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
300
|
+
* External references — NOT repo files.
|
|
301
|
+
*
|
|
302
|
+
* WHAT CHANGED HERE, AND WHY IT IS THE POINT. This used to append a reference the moment a + button
|
|
303
|
+
* was clicked, built out of the initiative slug and a counter: `ATL-501`, then `ATL-502`. The PM was
|
|
304
|
+
* never asked which epic. The row then labelled itself `connected`, and after a reload the same row
|
|
305
|
+
* read `declared · not connected` — one row, two labels, and the flattering one was the one visible
|
|
306
|
+
* at the moment of the click. Six clicks recorded six references to six epics that did not exist.
|
|
307
|
+
*
|
|
308
|
+
* A + button now opens a FORM. Nothing is recorded until the PM supplies the reference or a session
|
|
309
|
+
* resolves it, because a reference nobody chose is not a reference — it is a guess written down in
|
|
310
|
+
* a place the PM will later trust. `makeLink` is gone rather than bypassed: a helper that can
|
|
311
|
+
* fabricate an identifier is a helper someone re-wires later.
|
|
312
|
+
*/
|
|
313
|
+
/**
|
|
314
|
+
* Hand the lookup to a session that can actually perform it.
|
|
315
|
+
*
|
|
316
|
+
* This is the seam the whole initiative turns on, so it is worth being explicit about what it does
|
|
317
|
+
* NOT do: it does not contact Jira, Confluence or Google, and it does not record a link. It asks
|
|
318
|
+
* the server to compose a task and shows that task to the PM to run in a session on the named
|
|
319
|
+
* profile — the session holds the credential, PMOS does not (D53). The session then records the
|
|
320
|
+
* resolved reference through the same validated endpoint the PM's own typing goes through, where
|
|
321
|
+
* it will be refused unless it names the profile that read it.
|
|
322
|
+
*
|
|
323
|
+
* The alternative — having the workspace perform the search itself — is the design D53 rejected and
|
|
324
|
+
* that `no-network.test.js` fails by construction. There is no version of this function that calls out.
|
|
283
325
|
*/
|
|
326
|
+
function askSessionToResolve(slug, kind, resolver, hint) {
|
|
327
|
+
const data = sibling('data', './data.js');
|
|
328
|
+
if (!data || typeof data.lookupTask !== 'function') return;
|
|
329
|
+
Promise.resolve(data.lookupTask({ kind, subject: slug, hint, profile: resolver.profile }))
|
|
330
|
+
.then((out) => {
|
|
331
|
+
if (!out || out.ok === false) {
|
|
332
|
+
setState((s) => ({ linkTask: { ...s.linkTask, [slug]: { error: (out && out.error) || 'the task could not be composed' } } }));
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
setState((s) => ({ linkTask: { ...s.linkTask, [slug]: { text: out.text, profile: resolver.profile, kind } } }));
|
|
336
|
+
})
|
|
337
|
+
.catch((e) => setState((s) => ({ linkTask: { ...s.linkTask, [slug]: { error: String((e && e.message) || e) } } })));
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function linkStateStyle(st) {
|
|
341
|
+
if (st === 'resolved') return { color: 'var(--success)', label: 'resolved' };
|
|
342
|
+
if (st === 'unreachable') return { color: 'var(--warning)', label: 'unreachable' };
|
|
343
|
+
if (st === 'unattributed') return { color: 'var(--muted-foreground)', label: 'unattributed' };
|
|
344
|
+
return { color: 'var(--muted-foreground)', label: 'declared' };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* The one sentence a link row is allowed to say about itself, built from what was actually observed.
|
|
349
|
+
* Every branch names its evidence or admits it has none — there is deliberately no branch that
|
|
350
|
+
* returns a bare status word, because a bare status word is what `connected` was.
|
|
351
|
+
*/
|
|
352
|
+
function linkStateText(lk) {
|
|
353
|
+
const st = lk.state || (lk.origin ? 'declared' : 'unattributed');
|
|
354
|
+
if (st === 'resolved') {
|
|
355
|
+
const when = String(lk.resolved_at || '').slice(11, 16);
|
|
356
|
+
return 'resolved by ' + (lk.profile || 'a session') + (when ? ' · read ' + when : '');
|
|
357
|
+
}
|
|
358
|
+
if (st === 'unreachable') return 'unreachable · ' + (lk.reason || 'the lookup failed');
|
|
359
|
+
if (st === 'unattributed') return 'recorded before provenance — origin unknown';
|
|
360
|
+
return 'declared · nothing read it';
|
|
361
|
+
}
|
|
362
|
+
|
|
284
363
|
function linksRow(slug) {
|
|
285
364
|
const rec = records()[slug] || {};
|
|
286
365
|
const manual = (state.initLinks || {})[slug] || [];
|
|
287
366
|
const links = [...(rec.links || []), ...manual];
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
367
|
+
const openSrc = (state.linkForm || {})[slug] || null;
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Record what the PM typed. `origin: 'typed'` is sent explicitly and the server will refuse to
|
|
371
|
+
* store it as anything stronger — a typed reference is the PM's word, and the row says so.
|
|
372
|
+
*/
|
|
373
|
+
const submitTyped = (src, ref) => {
|
|
374
|
+
const target = String(ref || '').trim();
|
|
375
|
+
if (!target) return;
|
|
376
|
+
const link = { kind: src, label: target, path: target, target, origin: 'typed', state: 'declared' };
|
|
291
377
|
setState(
|
|
292
|
-
(s) => ({
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
},
|
|
378
|
+
(s) => ({
|
|
379
|
+
initLinks: { ...s.initLinks, [slug]: [...((s.initLinks || {})[slug] || []), link] },
|
|
380
|
+
linkForm: { ...s.linkForm, [slug]: null },
|
|
381
|
+
}),
|
|
382
|
+
() => declare('recordInitiativeLink', { slug, ...link }),
|
|
297
383
|
);
|
|
298
384
|
};
|
|
299
385
|
|
|
@@ -304,13 +390,80 @@ function linksRow(slug) {
|
|
|
304
390
|
},
|
|
305
391
|
el('span', { style: 'flex:none;width:76px;font-family:var(--mono);font-size:10px;letter-spacing:.1em;text-transform:uppercase;color:var(--info)' }, iv(lk.kind)),
|
|
306
392
|
el('span', { style: 'font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap' }, iv(lk.label)),
|
|
307
|
-
|
|
393
|
+
// A row that CARRIES provenance states what was observed; a row that does not keeps whatever
|
|
394
|
+
// status copy it came with. The branch is on the row, not on the mode, which is the point: the
|
|
395
|
+
// prototype's seeded rows (`auto · session`) render unchanged so the parity comparator still
|
|
396
|
+
// sees a like-for-like surface, while every row this surface actually records — in either mode —
|
|
397
|
+
// is held to the three-word vocabulary. Branching on FIXTURE instead would have left a real row
|
|
398
|
+
// in a fixture-mode session rendering `undefined`.
|
|
399
|
+
(lk.state || lk.origin)
|
|
400
|
+
? el('span', {
|
|
401
|
+
style: 'margin-left:auto;flex:none;font-family:var(--mono);font-size:10px;color:'
|
|
402
|
+
+ linkStateStyle(lk.state || 'declared').color,
|
|
403
|
+
}, iv(linkStateText(lk)))
|
|
404
|
+
: el('span', { style: 'margin-left:auto;flex:none;font-family:var(--mono);font-size:10px;color:var(--muted-foreground)' }, iv(lk.st)),
|
|
308
405
|
));
|
|
309
406
|
|
|
407
|
+
// Which destinations a session could actually look up, and under which profile. Derived by the
|
|
408
|
+
// server from the PM's agent profiles — never a constant, and never a claim that PMOS is connected.
|
|
409
|
+
const conn = (state.connectors && state.connectors.configured) || [];
|
|
410
|
+
const resolverFor = (kind) => conn.find((c) => c.kind === kind) || null;
|
|
411
|
+
|
|
412
|
+
const form = openSrc ? (() => {
|
|
413
|
+
const resolver = resolverFor(openSrc);
|
|
414
|
+
const task = (state.linkTask || {})[slug] || null;
|
|
415
|
+
const inputId = 'linkref-' + slug + '-' + openSrc;
|
|
416
|
+
const read = () => {
|
|
417
|
+
const node = document.getElementById(inputId);
|
|
418
|
+
return node ? node.value : '';
|
|
419
|
+
};
|
|
420
|
+
return el('div', { style: 'margin-top:6px;padding:9px 11px;border:1px solid var(--border);background:var(--background)' },
|
|
421
|
+
el('div', { style: 'font-size:11.5px;color:var(--muted-foreground);line-height:1.5;margin-bottom:7px' },
|
|
422
|
+
iv(resolver
|
|
423
|
+
? 'paste the reference, or ask ' + resolver.profile + ' to find it — that session holds your '
|
|
424
|
+
+ openSrc + ' MCP. PMOS itself reads nothing.'
|
|
425
|
+
: 'paste the reference. No agent profile declares a ' + openSrc
|
|
426
|
+
+ ' MCP, so nothing here can look it up for you — what you type is recorded as your own declaration.')),
|
|
427
|
+
el('input', {
|
|
428
|
+
id: inputId,
|
|
429
|
+
placeholder: openSrc === 'jira' ? 'ATL-482 or its URL' : 'the page or document URL',
|
|
430
|
+
style: 'width:100%;font-family:var(--mono);font-size:11.5px;padding:6px 8px;background:var(--secondary);border:1px solid var(--border);color:var(--foreground)',
|
|
431
|
+
}),
|
|
432
|
+
el('div', { style: 'display:flex;gap:6px;margin-top:8px;flex-wrap:wrap' },
|
|
433
|
+
el('button', {
|
|
434
|
+
onClick: () => submitTyped(openSrc, read()),
|
|
435
|
+
style: 'font-family:var(--mono);font-size:10.5px;padding:5px 10px;min-height:28px;background:var(--brand);color:oklch(0.987 0.002 197.1);font-weight:500',
|
|
436
|
+
}, 'record this reference'),
|
|
437
|
+
resolver && el('button', {
|
|
438
|
+
onClick: () => askSessionToResolve(slug, openSrc, resolver, read()),
|
|
439
|
+
style: 'font-family:var(--mono);font-size:10.5px;padding:5px 10px;min-height:28px;background:var(--secondary);border:1px solid var(--border);color:var(--foreground)',
|
|
440
|
+
hover: 'border-color:var(--ring)',
|
|
441
|
+
}, 'ask ', iv(resolver.profile), ' to find it'),
|
|
442
|
+
el('button', {
|
|
443
|
+
onClick: () => setState((s) => ({ linkForm: { ...s.linkForm, [slug]: null } })),
|
|
444
|
+
style: 'font-family:var(--mono);font-size:10.5px;padding:5px 10px;min-height:28px;background:transparent;border:1px solid var(--border);color:var(--muted-foreground)',
|
|
445
|
+
}, 'cancel'),
|
|
446
|
+
),
|
|
447
|
+
task && (task.error
|
|
448
|
+
? el('div', { style: 'margin-top:8px;font-size:11.5px;color:var(--warning);line-height:1.5' },
|
|
449
|
+
iv('the lookup task could not be composed — ' + task.error))
|
|
450
|
+
: el('div', { style: 'margin-top:8px' },
|
|
451
|
+
el('div', { style: 'font-size:11px;color:var(--muted-foreground);line-height:1.5;margin-bottom:5px' },
|
|
452
|
+
iv('run this in a session on ' + task.profile + '. it records the reference itself, and PMOS'
|
|
453
|
+
+ ' will refuse the result unless it names the profile that read it.')),
|
|
454
|
+
el('pre', {
|
|
455
|
+
style: 'font-family:var(--mono);font-size:10.5px;line-height:1.5;white-space:pre-wrap;'
|
|
456
|
+
+ 'padding:8px 9px;background:var(--secondary);border:1px solid var(--border);color:var(--foreground);margin:0',
|
|
457
|
+
}, iv(task.text)))),
|
|
458
|
+
);
|
|
459
|
+
})() : null;
|
|
460
|
+
|
|
310
461
|
const addRow = el('div', { style: 'display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-top:4px' },
|
|
311
462
|
el('span', { style: 'font-family:var(--mono);font-size:9.5px;letter-spacing:.1em;text-transform:uppercase;color:var(--muted-foreground)' }, 'link'),
|
|
312
463
|
FX.LINK_SRCS.map((ls) => el('button', {
|
|
313
|
-
onClick: () =>
|
|
464
|
+
onClick: () => setState((s) => ({
|
|
465
|
+
linkForm: { ...s.linkForm, [slug]: (s.linkForm || {})[slug] === ls.src ? null : ls.src },
|
|
466
|
+
})),
|
|
314
467
|
style: 'display:flex;gap:5px;align-items:center;font-family:var(--mono);font-size:10.5px;padding:4px 9px;min-height:28px;background:var(--secondary);border:1px solid var(--border);color:var(--foreground)',
|
|
315
468
|
hover: 'border-color:var(--ring)',
|
|
316
469
|
}, '+ ', iv(ls.l))),
|
|
@@ -321,6 +474,7 @@ function linksRow(slug) {
|
|
|
321
474
|
el('div', { style: 'display:flex;flex-direction:column;gap:2px;min-width:0' },
|
|
322
475
|
rows,
|
|
323
476
|
addRow,
|
|
477
|
+
form,
|
|
324
478
|
el('span', { style: 'font-size:11px;color:var(--muted-foreground);margin-top:3px;line-height:1.5' },
|
|
325
479
|
'references — not in the repo. sessions add these automatically; link one yourself when you need it.'),
|
|
326
480
|
),
|
|
@@ -354,19 +508,69 @@ function artifactRow(slug, a) {
|
|
|
354
508
|
|
|
355
509
|
if (!dest) {
|
|
356
510
|
// Unlinked: the three destinations it could be linked to.
|
|
511
|
+
// In FIXTURE mode the prototype's own destination copy is drawn, so the parity comparator still
|
|
512
|
+
// sees a like-for-like surface. In REAL mode a click opens a FORM: `linkArtDest` built the
|
|
513
|
+
// destination out of the artifact's own FILENAME — `PM / <basename>`, `ATL epic · <basename>` —
|
|
514
|
+
// which is a name for a page nobody created, recorded as though the PM had chosen it.
|
|
515
|
+
const openHere = (state.artLinkForm || {})[key] || null;
|
|
516
|
+
const inputId = 'artref-' + key.replace(/[^A-Za-z0-9]/g, '-');
|
|
517
|
+
const conn = (state.connectors && state.connectors.configured) || [];
|
|
518
|
+
|
|
519
|
+
const submit = (kind) => {
|
|
520
|
+
const node = document.getElementById(inputId);
|
|
521
|
+
const target = String((node && node.value) || '').trim();
|
|
522
|
+
if (!target) return;
|
|
523
|
+
const d = { kind, target, origin: 'typed', state: 'declared' };
|
|
524
|
+
setState((s) => ({
|
|
525
|
+
artLinks: { ...s.artLinks, [key]: d },
|
|
526
|
+
artLinkForm: { ...s.artLinkForm, [key]: null },
|
|
527
|
+
}));
|
|
528
|
+
declare('recordArtifactLink', { artifact_path: a.path, slug, kind, target, origin: 'typed', state: 'declared' });
|
|
529
|
+
};
|
|
530
|
+
|
|
357
531
|
const linkRow = el('div', { style: 'display:flex;gap:6px;align-items:center;flex-wrap:wrap;padding:3px 0 0' },
|
|
358
532
|
el('span', { style: 'font-family:var(--mono);font-size:9.5px;letter-spacing:.1em;text-transform:uppercase;color:var(--muted-foreground)' }, 'link'),
|
|
359
533
|
FX.ART_LINK_SRCS.map((ls) => el('button', {
|
|
360
534
|
onClick: () => {
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
535
|
+
if (FIXTURE) {
|
|
536
|
+
const d = FX.linkArtDest(key, ls.kind);
|
|
537
|
+
setState((s) => ({ artLinks: { ...s.artLinks, [key]: d } }));
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
setState((s) => ({
|
|
541
|
+
artLinkForm: { ...s.artLinkForm, [key]: (s.artLinkForm || {})[key] === ls.kind ? null : ls.kind },
|
|
542
|
+
}));
|
|
364
543
|
},
|
|
365
544
|
style: 'font-family:var(--mono);font-size:10px;padding:3px 8px;min-height:24px;background:var(--secondary);border:1px solid var(--border);color:var(--foreground)',
|
|
366
545
|
hover: 'border-color:var(--ring)',
|
|
367
546
|
}, '+ ', iv(ls.l))),
|
|
368
547
|
);
|
|
369
|
-
|
|
548
|
+
|
|
549
|
+
const form = openHere ? el('div', { style: 'margin-top:6px;padding:8px 10px;border:1px solid var(--border);background:var(--background)' },
|
|
550
|
+
el('div', { style: 'font-size:11.5px;color:var(--muted-foreground);line-height:1.5;margin-bottom:6px' },
|
|
551
|
+
iv(conn.some((c) => c.kind === openHere)
|
|
552
|
+
? 'paste the ' + openHere + ' reference this file corresponds to. a session on '
|
|
553
|
+
+ (conn.find((c) => c.kind === openHere) || {}).profile + ' can find it for you — PMOS reads nothing.'
|
|
554
|
+
: 'paste the ' + openHere + ' reference this file corresponds to. nothing here can look it up,'
|
|
555
|
+
+ ' so what you type is recorded as your own declaration.')),
|
|
556
|
+
el('input', {
|
|
557
|
+
id: inputId,
|
|
558
|
+
placeholder: openHere === 'jira' ? 'ATL-482 or its URL' : 'the page or document URL',
|
|
559
|
+
style: 'width:100%;font-family:var(--mono);font-size:11px;padding:5px 7px;background:var(--secondary);border:1px solid var(--border);color:var(--foreground)',
|
|
560
|
+
}),
|
|
561
|
+
el('div', { style: 'display:flex;gap:6px;margin-top:7px;flex-wrap:wrap' },
|
|
562
|
+
el('button', {
|
|
563
|
+
onClick: () => submit(openHere),
|
|
564
|
+
style: 'font-family:var(--mono);font-size:10px;padding:4px 9px;min-height:26px;background:var(--brand);color:oklch(0.987 0.002 197.1);font-weight:500',
|
|
565
|
+
}, 'record'),
|
|
566
|
+
el('button', {
|
|
567
|
+
onClick: () => setState((s) => ({ artLinkForm: { ...s.artLinkForm, [key]: null } })),
|
|
568
|
+
style: 'font-family:var(--mono);font-size:10px;padding:4px 9px;min-height:26px;background:transparent;border:1px solid var(--border);color:var(--muted-foreground)',
|
|
569
|
+
}, 'cancel'),
|
|
570
|
+
),
|
|
571
|
+
) : null;
|
|
572
|
+
|
|
573
|
+
return el('div', { style: 'min-width:0' }, head, linkRow, form);
|
|
370
574
|
}
|
|
371
575
|
|
|
372
576
|
// Linked: the destination pill, the sync button, and the persisted result of the last approval.
|
|
@@ -384,7 +588,9 @@ function artifactRow(slug, a) {
|
|
|
384
588
|
let panel = null;
|
|
385
589
|
if (syncOpen) {
|
|
386
590
|
const live = hasConnector(dest.kind);
|
|
387
|
-
|
|
591
|
+
// FIXTURE mode draws the prototype's two working approvals so the comparator sees a like-for-like
|
|
592
|
+
// surface. REAL mode never enables them: they are publishing and read-back, both out of scope.
|
|
593
|
+
const inert = FIXTURE ? !live : true;
|
|
388
594
|
const decide = (make) => () => {
|
|
389
595
|
if (inert) return;
|
|
390
596
|
setState((s) => ({
|
|
@@ -395,7 +601,7 @@ function artifactRow(slug, a) {
|
|
|
395
601
|
const off = inert ? ';opacity:.5;cursor:not-allowed' : '';
|
|
396
602
|
panel = el('div', { style: 'margin-top:6px;padding:9px 11px;border:1px solid var(--border);background:var(--background)' },
|
|
397
603
|
el('div', { style: 'font-size:12px;line-height:1.5;color:var(--muted-foreground)' },
|
|
398
|
-
iv(live ? FX.artDiffText(dest) : NO_CONNECTOR)),
|
|
604
|
+
iv(FIXTURE ? (live ? FX.artDiffText(dest) : NO_CONNECTOR) : SYNC_OUT_OF_SCOPE)),
|
|
399
605
|
el('div', { style: 'display:flex;gap:6px;margin-top:8px;flex-wrap:wrap' },
|
|
400
606
|
el('button', {
|
|
401
607
|
onClick: decide(FX.artSyncPushed), disabled: inert,
|
|
@@ -173,14 +173,68 @@ function nextSessionName() {
|
|
|
173
173
|
return { n: max + 1, name: 'agent ' + (max + 1) };
|
|
174
174
|
}
|
|
175
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
|
+
}
|
|
221
|
+
|
|
176
222
|
/**
|
|
177
223
|
* `+ session` (line 2087): file a new session against the selected initiative and open its tab.
|
|
178
224
|
* The session lands in `extraSess` — the prototype's own list of sessions started in this window —
|
|
179
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.
|
|
180
230
|
*/
|
|
181
|
-
export function newSession(slug) {
|
|
231
|
+
export function newSession(slug, opts) {
|
|
182
232
|
const target = slug || selSlug();
|
|
183
233
|
if (!target) return;
|
|
234
|
+
if (!(opts && opts.force)) {
|
|
235
|
+
const found = treePeersNow();
|
|
236
|
+
if (found) { setState({ startPrompt: { slug: target, ...found }, endConfirm: null }); return; }
|
|
237
|
+
}
|
|
184
238
|
const { n, name } = nextSessionName();
|
|
185
239
|
/* `spawn` is carried on the session and thence onto the tab: it is an INSTRUCTION to start a PTY,
|
|
186
240
|
* and it is the only thing that authorises one. Without it the terminal resolves by name, which
|
|
@@ -188,6 +242,8 @@ export function newSession(slug) {
|
|
|
188
242
|
const sess = { name, st: 'working', label: 'working', profile: state.newSessProfile, spawn: true };
|
|
189
243
|
setState((s) => ({
|
|
190
244
|
startN: n + 1,
|
|
245
|
+
startPrompt: null,
|
|
246
|
+
endConfirm: null,
|
|
191
247
|
extraSess: { ...s.extraSess, [target]: [...(s.extraSess[target] || []), sess] },
|
|
192
248
|
}), () => openTermTab(target, sess));
|
|
193
249
|
if (!FIXTURE && dataMod && typeof dataMod.startSession === 'function') {
|
|
@@ -195,6 +251,25 @@ export function newSession(slug) {
|
|
|
195
251
|
}
|
|
196
252
|
}
|
|
197
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
|
+
|
|
198
273
|
/** `⫽ split` / `⫽ unsplit` (lines 2088–2092). */
|
|
199
274
|
export function toggleSplit() {
|
|
200
275
|
setState((s) => {
|
|
@@ -226,7 +301,7 @@ const emptyState = el('div', {
|
|
|
226
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',
|
|
227
302
|
}, "nothing open — click a task's output or an artifact, or + session above");
|
|
228
303
|
|
|
229
|
-
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' });
|
|
230
305
|
|
|
231
306
|
/* ── the scratchpad (lines 983–991) ──────────────────────────────────────────────────────────
|
|
232
307
|
* One textarea, created once. Its text is written back as a CHILD TEXT NODE as well as a value,
|
|
@@ -432,9 +507,117 @@ function copyActive() {
|
|
|
432
507
|
setTimeout(() => setState({ copied: false }), 1200);
|
|
433
508
|
}
|
|
434
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
|
+
|
|
435
614
|
/* ── pane hosts ────────────────────────────────────────────────────────────────────────────── */
|
|
436
615
|
|
|
437
|
-
|
|
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%)';
|
|
438
621
|
const paneHosts = new Map(); // tab id -> the pane element, kept for the tab's whole life
|
|
439
622
|
|
|
440
623
|
function paneFor(tab) {
|
|
@@ -618,7 +801,12 @@ export function renderTabs(host) {
|
|
|
618
801
|
|
|
619
802
|
setChildren(host, [
|
|
620
803
|
tabBar,
|
|
621
|
-
|
|
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,
|
|
622
810
|
panesRow,
|
|
623
811
|
state.scratchOpen ? scratchBlock : null,
|
|
624
812
|
bottomBar,
|
|
@@ -38,6 +38,12 @@ 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,
|
|
@@ -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' },
|