create-pmos 0.24.0 → 0.26.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 +27 -0
- package/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/workspace/README.md +16 -11
- package/workspace/lib/four-panel.js +201 -6
- package/workspace/lib/working-set.js +124 -28
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/data.js +95 -15
- package/workspace/public/pm/panel-detail.js +501 -95
- package/workspace/public/pm/panel-initiatives.js +124 -16
- package/workspace/public/pm/state.js +11 -0
- package/workspace/public/pm/tab-term.js +13 -1
- package/workspace/server.js +169 -6
|
@@ -35,17 +35,24 @@
|
|
|
35
35
|
* rendering in the real app is "the beautiful shell" the PRD names as a failure mode.
|
|
36
36
|
*
|
|
37
37
|
* ── The one place the design's fiction is refused ────────────────────────────────────────────
|
|
38
|
-
* The artifact sync panel
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
38
|
+
* The artifact sync panel keeps the design's SHAPE — the statement, then the two approvals — and
|
|
39
|
+
* replaces its claims. In FIXTURE mode the prototype's copy renders verbatim, because the parity
|
|
40
|
+
* comparator measures this surface against the design file and a re-worded label is a defect there.
|
|
41
|
+
* In real mode the panel says only what a row in the store says: nothing chosen yet, a direction
|
|
42
|
+
* proposed and unreported, or a named session's write at a named time. The design's
|
|
43
|
+
* `2 sections differ` and `in sync 12:41` are unreachable from the real path — a difference summary
|
|
44
|
+
* over a destination nobody contacted is precisely the watermelon this repo has a word for.
|
|
45
|
+
*
|
|
46
|
+
* The two approvals ACT. They record the direction the PM chose and show the task a session will
|
|
47
|
+
* run; they do not sync, because PMOS holds no credential and makes no outbound request (D53). The
|
|
48
|
+
* property that has to survive every future change to this file: there is no path from a click to a
|
|
49
|
+
* rendered claim about a destination without a profile in between.
|
|
43
50
|
*/
|
|
44
51
|
|
|
45
52
|
import { el, setChildren } from './dom.js';
|
|
46
53
|
import { state, setState, FIXTURE } from './state.js';
|
|
47
54
|
import * as FX from './fixture.js';
|
|
48
|
-
import { openDoc, openTab, fileNewModel } from './panel-initiatives.js';
|
|
55
|
+
import { openDoc, openTab, openTerm, fileNewModel } from './panel-initiatives.js';
|
|
49
56
|
import { addProductBody } from './panel-repos.js';
|
|
50
57
|
|
|
51
58
|
/* ── the prototype's small helpers ─────────────────────────────────────────────────────────── */
|
|
@@ -162,21 +169,27 @@ function hasConnector(kind) {
|
|
|
162
169
|
const NO_CONNECTOR = 'no connector configured — nothing was read, and nothing will be written.';
|
|
163
170
|
|
|
164
171
|
/**
|
|
165
|
-
*
|
|
172
|
+
* What the two sync approvals now do, and why they stopped being inert.
|
|
173
|
+
*
|
|
174
|
+
* They used to be dead in every real session — `const inert = FIXTURE ? !live : true` — because
|
|
175
|
+
* publishing and read-back were out of scope by name and an inert control that states its reason
|
|
176
|
+
* beats a control that lies. That was right on the day it shipped and it stopped being right when
|
|
177
|
+
* the PM asked for the capability, because the reason it gave was *waiting on a decision the PM had
|
|
178
|
+
* not made*, and they made it.
|
|
166
179
|
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
* as the fabricated identifier one level up.
|
|
180
|
+
* What a click does now is the whole design in one sentence: it records the DIRECTION the PM chose
|
|
181
|
+
* and shows the TASK a session will run to carry it out. It does not sync. PMOS holds no credential
|
|
182
|
+
* and makes no outbound request (D53) — the session holds the credential, performs the write, and
|
|
183
|
+
* reports back through a store that refuses any claim of success that cannot name the profile that
|
|
184
|
+
* made it.
|
|
173
185
|
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
* about
|
|
186
|
+
* So the surface can now say three different true things where it previously said one: nothing has
|
|
187
|
+
* been chosen; a direction was chosen and nobody has reported; a named session wrote at a named
|
|
188
|
+
* time. The word it still cannot say is `in sync`, because that is a claim about two documents PMOS
|
|
189
|
+
* has never compared.
|
|
177
190
|
*/
|
|
178
|
-
const
|
|
179
|
-
+ '
|
|
191
|
+
const SYNC_NOT_A_SYNC = 'PMOS does not perform this itself — it records which way you chose and '
|
|
192
|
+
+ 'composes the task. The session you run it in holds the credential; PMOS holds none.';
|
|
180
193
|
|
|
181
194
|
/* ── the sticky detail header (lines 332–335) ───────────────────────────────────────────────
|
|
182
195
|
* Built once and re-used, so panel 3's chrome keeps its identity across renders the way panel 2's
|
|
@@ -199,6 +212,78 @@ const stickyHeader = el('div', {
|
|
|
199
212
|
}, '⟨'),
|
|
200
213
|
);
|
|
201
214
|
|
|
215
|
+
/* ── 1b · SESSIONS — the agents running on THIS initiative ──────────────────────────────────────
|
|
216
|
+
*
|
|
217
|
+
* The PM's report: *"when I click the initiative on the initiative panel (which is column number 3),
|
|
218
|
+
* I can see the active sessions list, then when i click, either go to the tab (if tab is open), or
|
|
219
|
+
* open new tab and directly resume to the session."*
|
|
220
|
+
*
|
|
221
|
+
* Their first sentence was not that the list is unclickable — there was no list. `data.js` has been
|
|
222
|
+
* building `rec.sessions` (each row carrying the server's own id and rendered label) and `rec.noSess`
|
|
223
|
+
* since the four-panel port, and `initBody()` rendered header → phases → links → artifacts → footer.
|
|
224
|
+
* Neither field appeared anywhere in this file. The join ran on the server, survived the wire, was
|
|
225
|
+
* normalised by the data layer, and was dropped by the renderer.
|
|
226
|
+
*
|
|
227
|
+
* ── WHY THIS DOES NOT RENDER `rec.sessions` ──────────────────────────────────────────────────
|
|
228
|
+
* Because `FX.DATA` carries seed `sessions` arrays, and drawing them would light up seven fixture
|
|
229
|
+
* rows and break the design comparator's text axis on the first render. The board solved this the
|
|
230
|
+
* same way and for the same reason: read the LIVE join, which fixture mode structurally cannot fill
|
|
231
|
+
* (`pollSessions` returns early on FIXTURE). There is no server in fixture mode, so there are no
|
|
232
|
+
* live sessions to claim.
|
|
233
|
+
*
|
|
234
|
+
* ── THE CLICK ────────────────────────────────────────────────────────────────────────────────
|
|
235
|
+
* `openTerm` with the session's SERVER id and no `spawn`. Those two facts are the whole behaviour:
|
|
236
|
+
* `openTab` focuses a tab whose id already exists rather than duplicating it, and the absent `spawn`
|
|
237
|
+
* is what withholds authorisation to create a PTY — so a click either lands on the open tab or opens
|
|
238
|
+
* one attached to the running process. It cannot start a second agent.
|
|
239
|
+
*/
|
|
240
|
+
|
|
241
|
+
/** The live sessions on one initiative, from the shared join — never from the record's seed rows. */
|
|
242
|
+
function liveSessionsFor(slug) {
|
|
243
|
+
if (FIXTURE || !slug) return [];
|
|
244
|
+
const ws = typeof window !== 'undefined' ? window.PMOSWorkingSet : null;
|
|
245
|
+
const map = state.wsBranches && typeof state.wsBranches === 'object' ? state.wsBranches : {};
|
|
246
|
+
if (!ws || typeof ws.attributeSessions !== 'function') return [];
|
|
247
|
+
const attr = ws.attributeSessions(state.sessions, (id) => map[id] || null);
|
|
248
|
+
return attr.byInitiative[slug] || [];
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function sessionsBlock(slug) {
|
|
252
|
+
const rows = liveSessionsFor(slug);
|
|
253
|
+
if (!rows.length) return null; // nothing running: no heading over nothing
|
|
254
|
+
const ss = typeof window !== 'undefined' ? window.PMOSSessionState : null;
|
|
255
|
+
const openIds = new Set((state.tabs || []).filter((t) => t && t.kind === 'term' && t.sessionId).map((t) => t.sessionId));
|
|
256
|
+
|
|
257
|
+
return el('div', { style: 'padding:9px 14px 10px;border-bottom:1px solid var(--border)' },
|
|
258
|
+
el('div', { style: 'font-family:var(--mono);font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--muted-foreground);margin-bottom:6px' },
|
|
259
|
+
'sessions'),
|
|
260
|
+
rows.map((s) => {
|
|
261
|
+
const st = ss && ss.GLYPH[s.shown] ? s.shown : null;
|
|
262
|
+
const here = openIds.has(s.id);
|
|
263
|
+
return el('button', {
|
|
264
|
+
onClick: () => openTerm(slug, { name: s.name, st: s.shown, label: s.label, id: s.id, profile: s.profile_id }),
|
|
265
|
+
style: 'display:flex;align-items:center;gap:8px;width:100%;padding:5px 6px;min-height:30px',
|
|
266
|
+
hover: 'background:var(--accent)',
|
|
267
|
+
// What the click will do, said BEFORE it — the difference between landing on a tab already
|
|
268
|
+
// open and opening one that attaches. Neither ever starts an agent, and that is worth saying
|
|
269
|
+
// on the control rather than only in a comment nobody reading the surface can see.
|
|
270
|
+
title: (s.name || s.id) + ' — ' + (s.label || s.shown || '')
|
|
271
|
+
+ (s.profile_id ? '\nprofile ' + s.profile_id : '')
|
|
272
|
+
+ (here ? '\nits tab is open — this goes to it' : '\nopens a tab attached to this running session; nothing is started'),
|
|
273
|
+
},
|
|
274
|
+
el('span', { style: 'flex:none;font-size:11px;color:' + (st && ss ? ss.COLOR[st] : 'var(--muted-foreground)') },
|
|
275
|
+
st && ss ? ss.GLYPH[st] : '·'),
|
|
276
|
+
el('span', { style: 'font-family:var(--mono);font-size:11.5px;color:var(--foreground);overflow:hidden;text-overflow:ellipsis;white-space:nowrap' },
|
|
277
|
+
iv(s.name || s.id)),
|
|
278
|
+
el('span', { style: 'margin-left:auto;flex:none;font-size:10px;color:var(--muted-foreground)' },
|
|
279
|
+
iv(s.label || s.shown || '')),
|
|
280
|
+
el('span', { style: 'flex:none;font-family:var(--mono);font-size:9.5px;color:var(--muted-foreground)' },
|
|
281
|
+
iv(here ? 'open' : 'resume')),
|
|
282
|
+
);
|
|
283
|
+
}),
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
202
287
|
/* ── 1 · HEADER — slug, tag, pills (lines 338–346) ──────────────────────────────────────────── */
|
|
203
288
|
|
|
204
289
|
function headerBlock(name, tag, pills) {
|
|
@@ -344,6 +429,22 @@ function linkStateStyle(st) {
|
|
|
344
429
|
return { color: 'var(--muted-foreground)', label: 'declared' };
|
|
345
430
|
}
|
|
346
431
|
|
|
432
|
+
/* ── the small controls a recorded link needs to be the PM's ────────────────────────────────
|
|
433
|
+
* `edit` and `unlink`. Neither existed, and their absence made a paste error permanent: the store
|
|
434
|
+
* is append-only and supersedes on read, so CHANGING a link was always one row away — there was
|
|
435
|
+
* simply nothing in the UI that could write that row. Removing one had no representation at all.
|
|
436
|
+
*
|
|
437
|
+
* They are rendered only in real mode. Fixture mode is a transcription of the prototype, pinned
|
|
438
|
+
* line-for-line by the parity comparator, and the prototype has no such controls; adding them there
|
|
439
|
+
* would be a parity failure dressed as a feature.
|
|
440
|
+
*/
|
|
441
|
+
const MINI_BTN = 'font-family:var(--mono);font-size:10px;padding:3px 8px;min-height:24px;'
|
|
442
|
+
+ 'background:transparent;border:1px solid var(--border);color:var(--muted-foreground)';
|
|
443
|
+
|
|
444
|
+
function miniBtn(label, onClick, extra) {
|
|
445
|
+
return el('button', { onClick, style: MINI_BTN + (extra || ''), hover: 'border-color:var(--ring);color:var(--foreground)' }, label);
|
|
446
|
+
}
|
|
447
|
+
|
|
347
448
|
/**
|
|
348
449
|
* The one sentence a link row is allowed to say about itself, built from what was actually observed.
|
|
349
450
|
* Every branch names its evidence or admits it has none — there is deliberately no branch that
|
|
@@ -363,7 +464,13 @@ function linkStateText(lk) {
|
|
|
363
464
|
function linksRow(slug) {
|
|
364
465
|
const rec = records()[slug] || {};
|
|
365
466
|
const manual = (state.initLinks || {})[slug] || [];
|
|
366
|
-
|
|
467
|
+
// A reference the PM withdrew in this window is dropped here as well as on disk. The server
|
|
468
|
+
// already omits retracted pairs from what it publishes, so this only covers the moment between
|
|
469
|
+
// the click and the next hydration — without it the row would linger until a reload and the PM
|
|
470
|
+
// would reasonably conclude the control had failed.
|
|
471
|
+
const dropped = (state.linkGone || {})[slug] || [];
|
|
472
|
+
const gone = (lk) => dropped.some((d) => d.kind === lk.kind && d.target === (lk.target || lk.path));
|
|
473
|
+
const links = [...(rec.links || []), ...manual].filter((lk) => !gone(lk));
|
|
367
474
|
const openSrc = (state.linkForm || {})[slug] || null;
|
|
368
475
|
|
|
369
476
|
/**
|
|
@@ -378,31 +485,62 @@ function linksRow(slug) {
|
|
|
378
485
|
(s) => ({
|
|
379
486
|
initLinks: { ...s.initLinks, [slug]: [...((s.initLinks || {})[slug] || []), link] },
|
|
380
487
|
linkForm: { ...s.linkForm, [slug]: null },
|
|
488
|
+
// A pair the PM re-declares is no longer withdrawn. Clearing this is what makes R-B3's
|
|
489
|
+
// "a later declaration brings it back" true on the screen as well as in the store.
|
|
490
|
+
linkGone: { ...s.linkGone, [slug]: ((s.linkGone || {})[slug] || []).filter((d) => !(d.kind === src && d.target === target)) },
|
|
381
491
|
}),
|
|
382
492
|
() => declare('recordInitiativeLink', { slug, ...link }),
|
|
383
493
|
);
|
|
384
494
|
};
|
|
385
495
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
496
|
+
/**
|
|
497
|
+
* Withdraw a reference. This appends a `retracted` row — it does not delete anything, because the
|
|
498
|
+
* store is a history of what the PM declared and a deleted row is a declaration that never
|
|
499
|
+
* happened. The server refuses a retraction that does not come from the PM.
|
|
500
|
+
*/
|
|
501
|
+
const retract = (lk) => {
|
|
502
|
+
const target = lk.target || lk.path;
|
|
503
|
+
setState(
|
|
504
|
+
(s) => ({
|
|
505
|
+
linkGone: { ...s.linkGone, [slug]: [...((s.linkGone || {})[slug] || []), { kind: lk.kind, target }] },
|
|
506
|
+
initLinks: { ...s.initLinks, [slug]: ((s.initLinks || {})[slug] || []).filter((x) => !(x.kind === lk.kind && (x.target || x.path) === target)) },
|
|
507
|
+
}),
|
|
508
|
+
() => declare('recordInitiativeLink', {
|
|
509
|
+
slug, kind: lk.kind, label: lk.label, path: target, origin: 'typed', state: 'retracted',
|
|
510
|
+
}),
|
|
511
|
+
);
|
|
512
|
+
};
|
|
513
|
+
|
|
514
|
+
const rows = links.map((lk) => {
|
|
515
|
+
const body = el('button', {
|
|
516
|
+
onClick: () => openDoc(slug, lk.path, String(lk.kind).toUpperCase()),
|
|
517
|
+
// In fixture mode this button IS the row and carries the prototype's own negative margin. In
|
|
518
|
+
// real mode it sits inside a wrapper that carries it instead, and the button flexes to fill.
|
|
519
|
+
style: 'display:flex;gap:8px;align-items:center;padding:5px 6px;min-height:34px;'
|
|
520
|
+
+ (FIXTURE ? 'margin:0 -6px' : 'flex:1;min-width:0'),
|
|
521
|
+
hover: 'background:var(--accent)',
|
|
522
|
+
},
|
|
523
|
+
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)),
|
|
524
|
+
el('span', { style: 'font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap' }, iv(lk.label)),
|
|
525
|
+
// A row that CARRIES provenance states what was observed; a row that does not keeps whatever
|
|
526
|
+
// status copy it came with. The branch is on the row, not on the mode, which is the point: the
|
|
527
|
+
// prototype's seeded rows (`auto · session`) render unchanged so the parity comparator still
|
|
528
|
+
// sees a like-for-like surface, while every row this surface actually records — in either mode —
|
|
529
|
+
// is held to the closed vocabulary. Branching on FIXTURE instead would have left a real row
|
|
530
|
+
// in a fixture-mode session rendering `undefined`.
|
|
531
|
+
(lk.state || lk.origin)
|
|
532
|
+
? el('span', {
|
|
533
|
+
style: 'margin-left:auto;flex:none;font-family:var(--mono);font-size:10px;color:'
|
|
534
|
+
+ linkStateStyle(lk.state || 'declared').color,
|
|
535
|
+
}, iv(linkStateText(lk)))
|
|
536
|
+
: el('span', { style: 'margin-left:auto;flex:none;font-family:var(--mono);font-size:10px;color:var(--muted-foreground)' }, iv(lk.st)),
|
|
537
|
+
);
|
|
538
|
+
// The prototype's row is a single button and the comparator measures it as one. Only real mode
|
|
539
|
+
// grows the wrapper and the withdraw control.
|
|
540
|
+
if (FIXTURE) return body;
|
|
541
|
+
return el('div', { style: 'display:flex;gap:6px;align-items:center;margin:0 -6px;min-width:0' },
|
|
542
|
+
body, miniBtn('unlink', () => retract(lk)));
|
|
543
|
+
});
|
|
406
544
|
|
|
407
545
|
// Which destinations a session could actually look up, and under which profile. Derived by the
|
|
408
546
|
// server from the PM's agent profiles — never a constant, and never a claim that PMOS is connected.
|
|
@@ -484,16 +622,69 @@ function linksRow(slug) {
|
|
|
484
622
|
/* ── 5b · ARTIFACTS (lines 399–437, derivation 1779–1795) ───────────────────────────────────
|
|
485
623
|
* Repo files, each with its role and path; clicking one opens the document. An artifact may carry
|
|
486
624
|
* an external DESTINATION — recorded as a declaration, never as an outbound call — and once linked
|
|
487
|
-
* it grows the ⇄ pill, a `sync` button, and the inline sync panel behind it.
|
|
625
|
+
* it grows the ⇄ pill, a `sync` button, `edit`/`unlink`, and the inline sync panel behind it.
|
|
488
626
|
*
|
|
489
|
-
* The sync panel is where this surface is at its most careful
|
|
490
|
-
*
|
|
491
|
-
*
|
|
492
|
-
*
|
|
627
|
+
* The sync panel is where this surface is at its most careful, and the care moved rather than
|
|
628
|
+
* relaxed. It used to be careful by refusing to act; it is now careful about WHAT A CLICK MEANS. A
|
|
629
|
+
* direction is a decision the PM makes and PMOS records; the write is performed by a session
|
|
630
|
+
* holding the PM's own MCP; and the status line reports the latest recorded row and nothing else.
|
|
631
|
+
* There is no path from a click to a claim about the destination without a profile in between —
|
|
632
|
+
* that sentence is the acceptance criterion for this whole region.
|
|
493
633
|
*/
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* The status line beside the pill, derived from the latest recorded sync row.
|
|
637
|
+
*
|
|
638
|
+
* Everything it can say is something a row on disk says. It cannot say `in sync`, because that
|
|
639
|
+
* compares two documents PMOS has never read; it cannot invent a time, because the only times here
|
|
640
|
+
* are the ones the store stamped. `proposed` says the quiet true thing that the old panel had no
|
|
641
|
+
* way to express: you chose a direction, and nobody has reported doing it.
|
|
642
|
+
*/
|
|
643
|
+
function syncStatus(sy) {
|
|
644
|
+
if (!sy) return null;
|
|
645
|
+
const hm = (t) => String(t || '').slice(11, 16);
|
|
646
|
+
const arrow = sy.direction === 'push' ? 'repo → ' + sy.kind : sy.kind + ' → repo';
|
|
647
|
+
if (sy.state === 'applied') {
|
|
648
|
+
return {
|
|
649
|
+
color: 'var(--success)',
|
|
650
|
+
text: arrow + ' · applied by ' + (sy.profile || 'a session') + (hm(sy.applied_at) ? ' · ' + hm(sy.applied_at) : ''),
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
if (sy.state === 'failed') {
|
|
654
|
+
return { color: 'var(--warning)', text: arrow + ' · failed · ' + (sy.reason || 'no reason recorded') };
|
|
655
|
+
}
|
|
656
|
+
return {
|
|
657
|
+
color: 'var(--muted-foreground)',
|
|
658
|
+
text: arrow + ' · proposed' + (hm(sy.at) ? ' ' + hm(sy.at) : '') + ' · no session has reported',
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/** What the open sync panel says about where things stand. One sentence per recorded state. */
|
|
663
|
+
function syncSummary(sy, dest) {
|
|
664
|
+
const hm = (t) => String(t || '').slice(11, 16);
|
|
665
|
+
if (!sy) {
|
|
666
|
+
return 'no direction has been chosen for this document yet. nothing has been written either way,'
|
|
667
|
+
+ ' and PMOS has read nothing from ' + dest.kind + '.';
|
|
668
|
+
}
|
|
669
|
+
const way = sy.direction === 'push' ? 'push to ' + sy.kind : 'adoption from ' + sy.kind;
|
|
670
|
+
if (sy.state === 'applied') {
|
|
671
|
+
return sy.profile + ' reported applying this ' + way + (hm(sy.applied_at) ? ' at ' + hm(sy.applied_at) : '')
|
|
672
|
+
+ '.' + (sy.detail ? ' it reported: ' + sy.detail : '');
|
|
673
|
+
}
|
|
674
|
+
if (sy.state === 'failed') {
|
|
675
|
+
return 'the last attempt at this ' + way + ' failed — ' + (sy.reason || 'no reason was recorded')
|
|
676
|
+
+ '. nothing was written.';
|
|
677
|
+
}
|
|
678
|
+
return 'a ' + way + ' was proposed' + (hm(sy.at) ? ' at ' + hm(sy.at) : '')
|
|
679
|
+
+ '. the task below is what performs it, and no session has reported back.';
|
|
680
|
+
}
|
|
681
|
+
|
|
494
682
|
function artifactRow(slug, a) {
|
|
495
683
|
const key = FX.artKey(slug, a.path);
|
|
496
|
-
|
|
684
|
+
// `hasOwnProperty`, not `||`. An unlinked artifact records `null` under its key, and a falsy check
|
|
685
|
+
// would fall straight through to the server's `a.dest` and re-draw the link the PM just withdrew.
|
|
686
|
+
const over = state.artLinks || {};
|
|
687
|
+
const dest = Object.prototype.hasOwnProperty.call(over, key) ? over[key] : (a.dest || null);
|
|
497
688
|
const syncState = (state.artSyncState || {})[key] || null;
|
|
498
689
|
const syncOpen = !!(state.artSyncOpen || {})[key];
|
|
499
690
|
|
|
@@ -506,28 +697,76 @@ function artifactRow(slug, a) {
|
|
|
506
697
|
el('span', { style: 'font-family:var(--mono);font-size:10px;color:var(--muted-foreground);overflow:hidden;text-overflow:ellipsis;white-space:nowrap' }, iv(a.path)),
|
|
507
698
|
);
|
|
508
699
|
|
|
700
|
+
const openHere = (state.artLinkForm || {})[key] || null;
|
|
701
|
+
const inputId = 'artref-' + key.replace(/[^A-Za-z0-9]/g, '-');
|
|
702
|
+
const conn = (state.connectors && state.connectors.configured) || [];
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* Record a destination for this artifact. Used by BOTH the first link and an edit of one already
|
|
706
|
+
* recorded — they are the same act as far as the store is concerned, because it is append-only and
|
|
707
|
+
* supersedes on read. That is why editing needed no new endpoint and no mutation: the second row
|
|
708
|
+
* simply wins, and the first stays as the history of what the PM once believed.
|
|
709
|
+
*/
|
|
710
|
+
const submit = (kind) => {
|
|
711
|
+
const node = document.getElementById(inputId);
|
|
712
|
+
const target = String((node && node.value) || '').trim();
|
|
713
|
+
if (!target) return;
|
|
714
|
+
const d = { kind, target, origin: 'typed', state: 'declared' };
|
|
715
|
+
setState((s) => ({
|
|
716
|
+
artLinks: { ...s.artLinks, [key]: d },
|
|
717
|
+
artLinkForm: { ...s.artLinkForm, [key]: null },
|
|
718
|
+
// A re-linked pair discards whatever sync was recorded against the OLD destination in this
|
|
719
|
+
// window. Carrying it over would attach a status line to a record it was never about.
|
|
720
|
+
artSyncTask: { ...s.artSyncTask, [key]: null },
|
|
721
|
+
}));
|
|
722
|
+
declare('recordArtifactLink', { artifact_path: a.path, slug, kind, target, origin: 'typed', state: 'declared' });
|
|
723
|
+
};
|
|
724
|
+
|
|
725
|
+
/** Withdraw this artifact's destination — an appended `retracted` row, never a deletion. */
|
|
726
|
+
const retract = () => {
|
|
727
|
+
if (!dest) return;
|
|
728
|
+
setState((s) => ({
|
|
729
|
+
artLinks: { ...s.artLinks, [key]: null },
|
|
730
|
+
artSyncOpen: { ...s.artSyncOpen, [key]: false },
|
|
731
|
+
artSyncTask: { ...s.artSyncTask, [key]: null },
|
|
732
|
+
}));
|
|
733
|
+
declare('recordArtifactLink', {
|
|
734
|
+
artifact_path: a.path, slug, kind: dest.kind, target: dest.target, origin: 'typed', state: 'retracted',
|
|
735
|
+
});
|
|
736
|
+
};
|
|
737
|
+
|
|
738
|
+
/** The paste-a-reference form. `initial` pre-fills it when the PM is editing rather than adding. */
|
|
739
|
+
const destForm = (kind, initial) => el('div', { style: 'margin-top:6px;padding:8px 10px;border:1px solid var(--border);background:var(--background)' },
|
|
740
|
+
el('div', { style: 'font-size:11.5px;color:var(--muted-foreground);line-height:1.5;margin-bottom:6px' },
|
|
741
|
+
iv(conn.some((c) => c.kind === kind)
|
|
742
|
+
? 'paste the ' + kind + ' reference this file corresponds to. a session on '
|
|
743
|
+
+ (conn.find((c) => c.kind === kind) || {}).profile + ' can find it for you — PMOS reads nothing.'
|
|
744
|
+
: 'paste the ' + kind + ' reference this file corresponds to. nothing here can look it up,'
|
|
745
|
+
+ ' so what you type is recorded as your own declaration.')),
|
|
746
|
+
el('input', {
|
|
747
|
+
id: inputId,
|
|
748
|
+
value: initial || '',
|
|
749
|
+
placeholder: kind === 'jira' ? 'ATL-482 or its URL' : 'the page or document URL',
|
|
750
|
+
style: 'width:100%;font-family:var(--mono);font-size:11px;padding:5px 7px;background:var(--secondary);border:1px solid var(--border);color:var(--foreground)',
|
|
751
|
+
}),
|
|
752
|
+
el('div', { style: 'display:flex;gap:6px;margin-top:7px;flex-wrap:wrap' },
|
|
753
|
+
el('button', {
|
|
754
|
+
onClick: () => submit(kind),
|
|
755
|
+
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',
|
|
756
|
+
}, 'record'),
|
|
757
|
+
el('button', {
|
|
758
|
+
onClick: () => setState((s) => ({ artLinkForm: { ...s.artLinkForm, [key]: null } })),
|
|
759
|
+
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)',
|
|
760
|
+
}, 'cancel'),
|
|
761
|
+
),
|
|
762
|
+
);
|
|
763
|
+
|
|
509
764
|
if (!dest) {
|
|
510
765
|
// Unlinked: the three destinations it could be linked to.
|
|
511
766
|
// In FIXTURE mode the prototype's own destination copy is drawn, so the parity comparator still
|
|
512
767
|
// sees a like-for-like surface. In REAL mode a click opens a FORM: `linkArtDest` built the
|
|
513
768
|
// destination out of the artifact's own FILENAME — `PM / <basename>`, `ATL epic · <basename>` —
|
|
514
769
|
// 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
|
-
|
|
531
770
|
const linkRow = el('div', { style: 'display:flex;gap:6px;align-items:center;flex-wrap:wrap;padding:3px 0 0' },
|
|
532
771
|
el('span', { style: 'font-family:var(--mono);font-size:9.5px;letter-spacing:.1em;text-transform:uppercase;color:var(--muted-foreground)' }, 'link'),
|
|
533
772
|
FX.ART_LINK_SRCS.map((ls) => el('button', {
|
|
@@ -546,34 +785,13 @@ function artifactRow(slug, a) {
|
|
|
546
785
|
}, '+ ', iv(ls.l))),
|
|
547
786
|
);
|
|
548
787
|
|
|
549
|
-
|
|
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);
|
|
788
|
+
return el('div', { style: 'min-width:0' }, head, linkRow, openHere ? destForm(openHere, '') : null);
|
|
574
789
|
}
|
|
575
790
|
|
|
576
|
-
// Linked: the destination pill, the sync button,
|
|
791
|
+
// Linked: the destination pill, the sync button, the PM's own controls, and the status line —
|
|
792
|
+
// which in real mode is what the store recorded, and in fixture mode is the prototype's own.
|
|
793
|
+
const recorded = FIXTURE ? null : syncStatus(a.sync || null);
|
|
794
|
+
const shown = FIXTURE ? syncState : (recorded || syncState);
|
|
577
795
|
const pillRow = el('div', { style: 'display:flex;gap:7px;align-items:center;flex-wrap:wrap;padding:3px 0 0 0' },
|
|
578
796
|
el('span', { style: 'font-family:var(--mono);font-size:10px;padding:1px 6px;background:var(--brand-wash);color:var(--brand)' },
|
|
579
797
|
'⇄ ', iv(dest.kind), ' · ', iv(dest.target)),
|
|
@@ -582,15 +800,22 @@ function artifactRow(slug, a) {
|
|
|
582
800
|
style: 'font-family:var(--mono);font-size:10.5px;padding:3px 10px;min-height:26px;background:var(--secondary);border:1px solid var(--border);color:var(--foreground)',
|
|
583
801
|
hover: 'border-color:var(--ring)',
|
|
584
802
|
}, 'sync'),
|
|
585
|
-
|
|
803
|
+
// Real mode only: the prototype has no edit or unlink control and the comparator measures the
|
|
804
|
+
// fixture surface against it.
|
|
805
|
+
FIXTURE ? null : miniBtn('edit', () => setState((s) => ({
|
|
806
|
+
artLinkForm: { ...s.artLinkForm, [key]: (s.artLinkForm || {})[key] ? null : dest.kind },
|
|
807
|
+
}))),
|
|
808
|
+
FIXTURE ? null : miniBtn('unlink', retract),
|
|
809
|
+
shown && el('span', { style: 'font-family:var(--mono);font-size:10px;color:' + shown.color }, iv(shown.text)),
|
|
586
810
|
);
|
|
587
811
|
|
|
588
812
|
let panel = null;
|
|
589
|
-
if (syncOpen) {
|
|
813
|
+
if (syncOpen && FIXTURE) {
|
|
814
|
+
// The prototype's panel, transcribed. Its two approvals write the seed module's own result
|
|
815
|
+
// strings; nothing here reaches a store, and the parity comparator measures this surface against
|
|
816
|
+
// the design file character for character.
|
|
590
817
|
const live = hasConnector(dest.kind);
|
|
591
|
-
|
|
592
|
-
// surface. REAL mode never enables them: they are publishing and read-back, both out of scope.
|
|
593
|
-
const inert = FIXTURE ? !live : true;
|
|
818
|
+
const inert = !live;
|
|
594
819
|
const decide = (make) => () => {
|
|
595
820
|
if (inert) return;
|
|
596
821
|
setState((s) => ({
|
|
@@ -601,7 +826,7 @@ function artifactRow(slug, a) {
|
|
|
601
826
|
const off = inert ? ';opacity:.5;cursor:not-allowed' : '';
|
|
602
827
|
panel = el('div', { style: 'margin-top:6px;padding:9px 11px;border:1px solid var(--border);background:var(--background)' },
|
|
603
828
|
el('div', { style: 'font-size:12px;line-height:1.5;color:var(--muted-foreground)' },
|
|
604
|
-
iv(
|
|
829
|
+
iv(live ? FX.artDiffText(dest) : NO_CONNECTOR)),
|
|
605
830
|
el('div', { style: 'display:flex;gap:6px;margin-top:8px;flex-wrap:wrap' },
|
|
606
831
|
el('button', {
|
|
607
832
|
onClick: decide(FX.artSyncPushed), disabled: inert,
|
|
@@ -614,9 +839,90 @@ function artifactRow(slug, a) {
|
|
|
614
839
|
}, 'adopt ', iv(dest.kind), ' → repo'),
|
|
615
840
|
),
|
|
616
841
|
);
|
|
842
|
+
} else if (syncOpen) {
|
|
843
|
+
panel = realSyncPanel(slug, a, key, dest);
|
|
617
844
|
}
|
|
618
845
|
|
|
619
|
-
return el('div', { style: 'min-width:0' }, head, pillRow, panel);
|
|
846
|
+
return el('div', { style: 'min-width:0' }, head, pillRow, openHere ? destForm(openHere, dest.target) : null, panel);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/**
|
|
850
|
+
* The real sync panel: what is true now, who could perform the next step, the two directions, and —
|
|
851
|
+
* once a direction is chosen — the task that carries it out.
|
|
852
|
+
*
|
|
853
|
+
* The click handler is worth reading closely, because it is where this initiative could most easily
|
|
854
|
+
* have reproduced the defect it exists to remove. It records a `proposed` row and asks the server
|
|
855
|
+
* for the task text. It does NOT set a status line of its own, does not stamp a time, and has no
|
|
856
|
+
* success branch — because from in here there is no such thing as success to observe. The status
|
|
857
|
+
* line updates when the panel next reads the store, and it can only say what a row says.
|
|
858
|
+
*/
|
|
859
|
+
function realSyncPanel(slug, a, key, dest) {
|
|
860
|
+
const task = (state.artSyncTask || {})[key] || null;
|
|
861
|
+
const resolver = ((state.connectors && state.connectors.configured) || []).find((c) => c.kind === dest.kind) || null;
|
|
862
|
+
|
|
863
|
+
const choose = (direction) => () => {
|
|
864
|
+
const data = sibling('data', './data.js');
|
|
865
|
+
if (!data || typeof data.recordSync !== 'function') return;
|
|
866
|
+
setState((s) => ({ artSyncTask: { ...s.artSyncTask, [key]: { pending: true, direction } } }));
|
|
867
|
+
Promise.resolve(data.recordSync({
|
|
868
|
+
artifact_path: a.path, slug, kind: dest.kind, target: dest.target,
|
|
869
|
+
direction, profile: resolver ? resolver.profile : null,
|
|
870
|
+
}))
|
|
871
|
+
.then((out) => setState((s) => ({
|
|
872
|
+
artSyncTask: {
|
|
873
|
+
...s.artSyncTask,
|
|
874
|
+
[key]: (!out || out.ok === false)
|
|
875
|
+
// A refusal is shown, never absorbed. The store returning 500 rather than a false green
|
|
876
|
+
// is the server's half of this rule; showing what it said is the client's.
|
|
877
|
+
? { error: (out && out.error) || 'the direction could not be recorded', direction }
|
|
878
|
+
: { text: out.task, error: out.task_error || null, direction, profile: resolver ? resolver.profile : null },
|
|
879
|
+
},
|
|
880
|
+
})))
|
|
881
|
+
.catch((e) => setState((s) => ({
|
|
882
|
+
artSyncTask: { ...s.artSyncTask, [key]: { error: String((e && e.message) || e), direction } },
|
|
883
|
+
})));
|
|
884
|
+
};
|
|
885
|
+
|
|
886
|
+
const btn = (label, primary, onClick) => el('button', {
|
|
887
|
+
onClick,
|
|
888
|
+
style: 'font-family:var(--mono);font-size:10.5px;padding:5px 10px;min-height:28px;'
|
|
889
|
+
+ (primary
|
|
890
|
+
? 'background:var(--brand);color:oklch(0.987 0.002 197.1);font-weight:500'
|
|
891
|
+
: 'background:var(--secondary);border:1px solid var(--border);color:var(--foreground)'),
|
|
892
|
+
hover: primary ? null : 'border-color:var(--ring)',
|
|
893
|
+
}, label);
|
|
894
|
+
|
|
895
|
+
return el('div', { style: 'margin-top:6px;padding:9px 11px;border:1px solid var(--border);background:var(--background)' },
|
|
896
|
+
el('div', { style: 'font-size:12px;line-height:1.5;color:var(--muted-foreground)' },
|
|
897
|
+
iv(syncSummary(a.sync || null, dest))),
|
|
898
|
+
el('div', { style: 'font-size:11.5px;line-height:1.5;color:var(--muted-foreground);margin-top:5px' },
|
|
899
|
+
iv(resolver
|
|
900
|
+
? SYNC_NOT_A_SYNC + ' a session on ' + resolver.profile + ' holds your ' + dest.kind + ' MCP.'
|
|
901
|
+
: SYNC_NOT_A_SYNC + ' no agent profile declares a ' + dest.kind + ' MCP, so the task below is'
|
|
902
|
+
+ ' yours to run wherever you hold that access.')),
|
|
903
|
+
el('div', { style: 'display:flex;gap:6px;margin-top:8px;flex-wrap:wrap' },
|
|
904
|
+
btn('repo is source → push', true, choose('push')),
|
|
905
|
+
el('button', {
|
|
906
|
+
onClick: choose('adopt'),
|
|
907
|
+
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)',
|
|
908
|
+
hover: 'border-color:var(--ring)',
|
|
909
|
+
}, 'adopt ', iv(dest.kind), ' → repo'),
|
|
910
|
+
),
|
|
911
|
+
task && (task.error
|
|
912
|
+
? el('div', { style: 'margin-top:8px;font-size:11.5px;color:var(--warning);line-height:1.5' },
|
|
913
|
+
iv('the direction was not recorded — ' + task.error))
|
|
914
|
+
: task.pending
|
|
915
|
+
? el('div', { style: 'margin-top:8px;font-size:11.5px;color:var(--muted-foreground)' }, iv('recording…'))
|
|
916
|
+
: el('div', { style: 'margin-top:8px' },
|
|
917
|
+
el('div', { style: 'font-size:11px;color:var(--muted-foreground);line-height:1.5;margin-bottom:5px' },
|
|
918
|
+
iv('recorded as proposed. run this in a session on ' + (task.profile || 'a profile holding that MCP')
|
|
919
|
+
+ '. it records the outcome itself, and PMOS will refuse a success that does not name'
|
|
920
|
+
+ ' the profile that performed it.')),
|
|
921
|
+
el('pre', {
|
|
922
|
+
style: 'font-family:var(--mono);font-size:10.5px;line-height:1.5;white-space:pre-wrap;'
|
|
923
|
+
+ 'padding:8px 9px;background:var(--secondary);border:1px solid var(--border);color:var(--foreground);margin:0',
|
|
924
|
+
}, iv(task.text)))),
|
|
925
|
+
);
|
|
620
926
|
}
|
|
621
927
|
|
|
622
928
|
function artifactsRow(slug, rec) {
|
|
@@ -674,6 +980,11 @@ function initBody(slug) {
|
|
|
674
980
|
out.push(...noLadderBlock(FX.wash(ansC), ansC, (rec.answer && rec.answer.text) || '', rec.noLadder));
|
|
675
981
|
}
|
|
676
982
|
|
|
983
|
+
// What is RUNNING on this initiative, above the ladder: the loop position is the slower fact, and
|
|
984
|
+
// an agent waiting on the PM is the one they came to the panel for.
|
|
985
|
+
const sess = sessionsBlock(slug);
|
|
986
|
+
if (sess) out.push(sess);
|
|
987
|
+
|
|
677
988
|
const phases = phaseBlock(slug, rec);
|
|
678
989
|
if (phases) out.push(phases);
|
|
679
990
|
|
|
@@ -727,6 +1038,97 @@ function anchorButton(an) {
|
|
|
727
1038
|
);
|
|
728
1039
|
}
|
|
729
1040
|
|
|
1041
|
+
/* ── BIND — the PM's own word about a session already running ──────────────────────────────────
|
|
1042
|
+
*
|
|
1043
|
+
* Every session that existed before a session could record its initiative has no attribution, and so
|
|
1044
|
+
* does every session started outside PMOS. This is the only way one of them ever joins an initiative,
|
|
1045
|
+
* which is why "show them read-only" was not enough.
|
|
1046
|
+
*
|
|
1047
|
+
* The whole surface turns on one distinction, stated in the copy rather than left to a code comment:
|
|
1048
|
+
* what is recorded here is a DECLARATION. PMOS was not told at spawn and did not read it off
|
|
1049
|
+
* anything — the PM is saying so. `initiative-links.jsonl` already draws that line with
|
|
1050
|
+
* `origin`/`state`, and a binding that presented itself as a reading would be the same watermelon
|
|
1051
|
+
* this repo keeps finding.
|
|
1052
|
+
*/
|
|
1053
|
+
function bindBody() {
|
|
1054
|
+
const out = [];
|
|
1055
|
+
const rows = (state.sessions || []).filter((s) => s && s.shown !== 'exited');
|
|
1056
|
+
const picked = state.bindSess && rows.find((s) => s.id === state.bindSess);
|
|
1057
|
+
const board = state.board && state.board[state.product] ? state.board[state.product] : [];
|
|
1058
|
+
const slugs = [];
|
|
1059
|
+
for (const g of board) for (const sl of g.slugs || []) if (!slugs.includes(sl)) slugs.push(sl);
|
|
1060
|
+
const typed = String(state.bindTo || '');
|
|
1061
|
+
const matches = typed ? slugs.filter((sl) => sl.includes(typed.toLowerCase())).slice(0, 8) : [];
|
|
1062
|
+
|
|
1063
|
+
out.push(el('div', { style: 'padding:13px 14px 11px;border-bottom:1px solid var(--border)' },
|
|
1064
|
+
el('div', { style: 'font-size:14px;font-weight:600;margin-bottom:4px' }, 'bind a session'),
|
|
1065
|
+
el('div', { style: 'font-size:11.5px;line-height:1.5;color:var(--muted-foreground)' },
|
|
1066
|
+
'PMOS was never told what these sessions are for, and nothing here can read it. '
|
|
1067
|
+
+ 'What you pick is recorded as YOUR declaration — not as something PMOS observed.')));
|
|
1068
|
+
|
|
1069
|
+
out.push(el('div', { style: 'padding:10px 14px 2px;' + NEW_LABEL }, 'session'));
|
|
1070
|
+
if (!rows.length) {
|
|
1071
|
+
out.push(el('div', { style: 'padding:2px 14px 10px;font-size:11.5px;color:var(--muted-foreground);font-style:italic' },
|
|
1072
|
+
'no live sessions — a successful read of nothing'));
|
|
1073
|
+
return out;
|
|
1074
|
+
}
|
|
1075
|
+
out.push(el('div', { style: 'display:flex;flex-direction:column;gap:2px;padding:2px 14px 6px' },
|
|
1076
|
+
rows.map((s) => el('button', {
|
|
1077
|
+
onClick: () => setState({ bindSess: s.id }),
|
|
1078
|
+
style: 'display:flex;gap:8px;align-items:center;min-height:32px;padding:0 9px;background:'
|
|
1079
|
+
+ (picked && picked.id === s.id ? 'var(--secondary)' : 'transparent')
|
|
1080
|
+
+ ';border:1px solid ' + (picked && picked.id === s.id ? 'var(--input)' : 'var(--border)'),
|
|
1081
|
+
hover: 'border-color:var(--ring)',
|
|
1082
|
+
},
|
|
1083
|
+
el('span', { style: 'font-family:var(--mono);font-size:11px;color:var(--foreground)' }, iv(s.name || s.id)),
|
|
1084
|
+
el('span', { style: 'font-size:11px;color:var(--muted-foreground)' }, iv(s.label || s.shown || '')),
|
|
1085
|
+
// What it is bound to NOW, and by what. A session already attributed can be re-bound, and the
|
|
1086
|
+
// PM is entitled to see what they would be replacing before they replace it (R8).
|
|
1087
|
+
s.initiative
|
|
1088
|
+
? el('span', { style: 'margin-left:auto;font-family:var(--mono);font-size:9.5px;color:var(--muted-foreground)' },
|
|
1089
|
+
iv('→ ' + s.initiative + (s.attributed_by === 'declared' ? ' · declared' : ' · recorded')))
|
|
1090
|
+
: null,
|
|
1091
|
+
))));
|
|
1092
|
+
|
|
1093
|
+
out.push(el('div', { style: 'padding:6px 14px 2px;' + NEW_LABEL }, 'initiative'));
|
|
1094
|
+
out.push(el('div', { style: 'padding:2px 14px 4px' },
|
|
1095
|
+
newField('bindTo', typed, () => setState({}), 'type part of an initiative id',
|
|
1096
|
+
'width:100%;font-family:var(--mono);font-size:11px;padding:5px 7px;background:var(--secondary);border:1px solid var(--border);color:var(--foreground)')));
|
|
1097
|
+
if (matches.length) {
|
|
1098
|
+
out.push(el('div', { style: 'display:flex;flex-direction:column;gap:2px;padding:2px 14px 6px' },
|
|
1099
|
+
matches.map((sl) => el('button', {
|
|
1100
|
+
onClick: () => {
|
|
1101
|
+
if (!picked) return;
|
|
1102
|
+
const data = sibling('data', './data.js');
|
|
1103
|
+
if (!data || typeof data.bindSessionInitiative !== 'function') return;
|
|
1104
|
+
data.bindSessionInitiative(picked.id, sl).then((r) => {
|
|
1105
|
+
setState({
|
|
1106
|
+
bindResult: r && r.ok
|
|
1107
|
+
? '“' + (picked.name || picked.id) + '” is now on ' + sl
|
|
1108
|
+
+ (r.previous ? ' — replacing ' + r.previous : '')
|
|
1109
|
+
: 'could not bind — ' + ((r && r.error) || 'the server did not answer'),
|
|
1110
|
+
});
|
|
1111
|
+
});
|
|
1112
|
+
},
|
|
1113
|
+
style: 'display:flex;gap:8px;align-items:center;min-height:30px;padding:0 9px;background:transparent;border:1px solid var(--border)',
|
|
1114
|
+
hover: 'border-color:var(--ring)',
|
|
1115
|
+
}, el('span', { style: 'font-family:var(--mono);font-size:11px' }, iv(sl))))));
|
|
1116
|
+
} else if (typed) {
|
|
1117
|
+
out.push(el('div', { style: 'padding:2px 14px 8px;font-size:11.5px;color:var(--muted-foreground);font-style:italic' },
|
|
1118
|
+
'nothing on this board matches — a successful read of nothing'));
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
if (!picked) {
|
|
1122
|
+
out.push(el('div', { style: 'padding:4px 14px 10px;font-size:11.5px;color:var(--muted-foreground)' },
|
|
1123
|
+
'pick a session first.'));
|
|
1124
|
+
}
|
|
1125
|
+
if (state.bindResult) {
|
|
1126
|
+
out.push(el('div', { style: 'margin:4px 14px 10px;padding:8px 11px;border:1px solid var(--border);font-size:11.5px;line-height:1.5' },
|
|
1127
|
+
iv(state.bindResult)));
|
|
1128
|
+
}
|
|
1129
|
+
return out;
|
|
1130
|
+
}
|
|
1131
|
+
|
|
730
1132
|
function fileNewBody() {
|
|
731
1133
|
const nw = fileNewModel();
|
|
732
1134
|
const out = [];
|
|
@@ -839,11 +1241,15 @@ function modeDetailBody(kind) {
|
|
|
839
1241
|
|
|
840
1242
|
export function renderDetail(host) {
|
|
841
1243
|
const kind = (state.detail && state.detail.kind) || 'init';
|
|
842
|
-
|
|
1244
|
+
// `bind` is not one of the prototype's detail kinds, and `fixture.js` is its seed data — adding a
|
|
1245
|
+
// key there to title one screen would be drift into a file the parity suite exists to keep
|
|
1246
|
+
// transcribed. The override lives here instead, where the screen does.
|
|
1247
|
+
headerTitleText.textContent = kind === 'bind' ? 'bind a session' : FX.detailTitle(kind);
|
|
843
1248
|
|
|
844
1249
|
let body;
|
|
845
1250
|
if (kind === 'init') body = initBody(selectedSlug());
|
|
846
1251
|
else if (kind === 'addprod') body = addProductBody();
|
|
1252
|
+
else if (kind === 'bind') body = bindBody();
|
|
847
1253
|
else if (kind === 'new') body = fileNewBody();
|
|
848
1254
|
else if (kind === 'none') body = [noneBody()];
|
|
849
1255
|
else body = modeDetailBody(kind);
|