create-pmos 0.153.0 → 0.155.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/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/guide/briefing-cards.md +1 -1
- package/workspace/guide/console-calendar.md +25 -2
- package/workspace/guide/console3-sessions.md +5 -1
- package/workspace/guide/document-sheet.md +18 -3
- package/workspace/lib/calendar-layers.js +130 -11
- package/workspace/lib/calendar-links.js +64 -0
- package/workspace/lib/engine/boards.js +4 -2
- package/workspace/lib/routes-boards.js +51 -1
- package/workspace/lib/routes-calendar-views.js +42 -3
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/c3-calendar.js +105 -6
- package/workspace/public/pm/c3-demo-boards.js +4 -3
- package/workspace/public/pm/c3-demo-cal4.js +43 -3
- package/workspace/public/pm/c3-demo-init.js +16 -0
- package/workspace/public/pm/c3-demo.js +1 -0
- package/workspace/public/pm/c3-doc-sheet.js +328 -21
- package/workspace/public/pm/c3-dock.js +8 -15
- package/workspace/public/pm/c3-docs.js +2 -1
- package/workspace/public/pm/c3-ib-docs.js +3 -1
- package/workspace/public/pm/c3-shell.js +5 -6
- package/workspace/public/pm/c3-terminal-icons.js +0 -1
- package/workspace/public/pm/console3.css +96 -2
- package/workspace/test-lists/c3-doc-sheet-v2.json +6 -0
- package/workspace/test-lists/c3-meeting-board.json +5 -0
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
/* pm/c3-doc-sheet.js — the DOCUMENT SHEET of /c3.html (Initiatives › Documents, an initiative's Docs, Reports). The right-hand sheet from
|
|
2
2
|
* c3-sheet.js, up to 780px, a bottom sheet on a phone. Initiative c3-doc-sheet-rich: the approved mockup (planning/prd/c3-doc-sheet-rich-prd.md).
|
|
3
3
|
*
|
|
4
|
-
* openDocSheet({ ws, doc, mode, prompt, relations(doc, openOther), onChanged, generated, secExtra }) doc = { path, name, ty, v, st, ini, iniName }
|
|
4
|
+
* openDocSheet({ ws, doc, mode, prompt, relations(doc, openOther), onChanged, generated, secExtra, library(), openGraph(doc) }) doc = { path, name, ty, v, st, ini, iniName }
|
|
5
5
|
*
|
|
6
6
|
* Six modes (the segment under the header; the keys are kept for the callers):
|
|
7
7
|
* read Read the markdown rendered (pm/c3-md.js): the front matter as properties, each section with who changed it last (from the
|
|
8
|
-
* revisions), "Show who wrote what", the Markdown source with a copy button,
|
|
9
|
-
*
|
|
8
|
+
* revisions), "Show who wrote what", the Markdown source with a copy button, the stale banner, and the open proposals
|
|
9
|
+
* pinned to the sections they change, with "Show change" for the diff in place. The OUTLINE (v2) beside it: the headings
|
|
10
|
+
* nested by level, the section in view marked as the page scrolls, groups that fold, a filter past 12 headings, a dot on
|
|
11
|
+
* stale and proposal sections; Hide is kept in this browser (localStorage); under 900px it is a button over the text.
|
|
10
12
|
* edit Edit a rich-text editor of the whole document (toolbar, Rich | Markdown over the same draft), the front matter as fields; a
|
|
11
13
|
* changed section is marked; saving needs a reason (POST /api/doc-sheet/edit). A section nobody touched is written back
|
|
12
14
|
* exactly as it was, so opening and saving without a change writes nothing.
|
|
@@ -18,13 +20,20 @@
|
|
|
18
20
|
* change that has none (POST /api/doc-sheet/why).
|
|
19
21
|
* health Health POST /api/doc-health/check: the checks, N of M; a failing Fresh check lists the sources that changed, and "Mark as still
|
|
20
22
|
* current" records a reason (POST /api/doc-sheet/mark-current).
|
|
21
|
-
* links Relations the
|
|
23
|
+
* links Relations the graph around this document (v2): its sources on the left, its initiative and goals on top, the documents that
|
|
24
|
+
* use it and its copies on the right, a drift link dashed; a node opens that document. It reads `library()` from the
|
|
25
|
+
* caller, else GET /api/docs/library; the caller's `relations` list, when given, is kept below the graph.
|
|
26
|
+
* COPIES (v2): GET /api/docs gives the document's Confluence and Google Doc links with their signal (lib/doc-sync.js): a chip per copy in
|
|
27
|
+
* the header, and the "Copies in other tools" panel: Check now (POST /api/docs/check), Open in the tool, Bring their change in as a
|
|
28
|
+
* proposal (the ask run reads the copy; PMOS keeps hashes, not the copy's text, so the proposal is the difference), Update the copy to
|
|
29
|
+
* this version (five seconds to undo, then POST /api/docs/sync with use: pmos), and Link a copy (POST /api/docs/link).
|
|
22
30
|
* Nothing is invented: a part with no data says so. The demo (?demo=1) answers the routes from fixtures.
|
|
23
31
|
*
|
|
24
32
|
* Two options for a report, which is a document with no stored file yet (report-families-view.js):
|
|
25
33
|
* generated = { text, why, health } Read shows the generated text; Edit and Proposals say why they need a stored document; Health shows `health`.
|
|
26
34
|
* secExtra(name) a node under each section in Read: the report's claims and where they come from.
|
|
27
|
-
* Pure parts (Node-testable): splitDoc, joinDoc, changedSections, wordDiffTokens, byName, dayName, parseFront, setFront, composeSections
|
|
35
|
+
* Pure parts (Node-testable): splitDoc, joinDoc, changedSections, wordDiffTokens, byName, dayName, parseFront, setFront, composeSections,
|
|
36
|
+
* copyKindOf, citesIn, outlineOf, hoodOf. */
|
|
28
37
|
import { el } from './dom.js';
|
|
29
38
|
import { pmGet, pmPost } from './http.js';
|
|
30
39
|
import { c3ic } from './pm-work-view.js';
|
|
@@ -183,10 +192,63 @@ const md = (text, extra) => el('div', { class: 'ds-md' + (extra ? ' ' + extra :
|
|
|
183
192
|
const btn = (cls, attrs, kids, onClick) => c3btn(cls, attrs, kids, onClick);
|
|
184
193
|
const headLevel = (s) => (/^(#+)/.exec(s.prefix || '') || ['', ''])[1].length;
|
|
185
194
|
|
|
186
|
-
|
|
195
|
+
/* ── v2 (c3-doc-sheet-v2): the copies in other tools, the outline, the graph ── */
|
|
196
|
+
/* a copy's state (lib/doc-sync.js signal): [label, icon, class, sentence, chip word] */
|
|
197
|
+
const SIG = {
|
|
198
|
+
same: ['In step', 'check', 'ds-good', 'Both have the same text.', 'in step'],
|
|
199
|
+
ahead: ['PMOS is ahead', 'up', 'ds-acc', 'PMOS has changes the copy does not have.', 'PMOS ahead'],
|
|
200
|
+
behind: ['Behind', 'alert', 'ds-warn', 'The copy has changes PMOS does not have.', 'behind'],
|
|
201
|
+
diverged: ['Both changed', 'alert', 'ds-bad', 'Both changed since they last matched.', 'both changed'],
|
|
202
|
+
unchecked: ['Not checked', 'info', '', 'No check has read this copy yet.', 'not checked'],
|
|
203
|
+
unreadable: ['Cannot open', 'alert', 'ds-bad', 'The last check could not open the copy.', 'cannot open'],
|
|
204
|
+
'missing-remote': ['Not found', 'alert', 'ds-bad', 'The copy is not there any more.', 'not found'],
|
|
205
|
+
'missing-repo': ['Not in PMOS', 'alert', 'ds-bad', 'This file is not in the repository any more.', 'not in PMOS'] };
|
|
206
|
+
const SYNCING = ['Updating', 'clock', 'ds-acc', 'A session is updating this copy.', 'updating'];
|
|
207
|
+
const KLABEL = { google: 'Google Doc', confluence: 'Confluence' };
|
|
208
|
+
const sigOf = (l) => (l.syncing ? SYNCING : SIG[l.signal] || SIG.unchecked);
|
|
209
|
+
const labelOf = (l) => KLABEL[l.kind] || l.label || l.kind;
|
|
210
|
+
/** Which tool a pasted address names (the server checks it again): 'confluence', 'google' or null. */
|
|
211
|
+
export function copyKindOf(target) {
|
|
212
|
+
const t = String(target || '');
|
|
213
|
+
if (/atlassian|confluence|\/wiki\/|pageId=/i.test(t)) return 'confluence';
|
|
214
|
+
if (/google|docs\.|drive\./i.test(t)) return 'google';
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
/** The names of the sections that cite a path (or its file name). */
|
|
218
|
+
export function citesIn(secs, p) {
|
|
219
|
+
const b = String(p).split('/').pop();
|
|
220
|
+
return secs.filter((s) => s.name !== 'Front matter' && (s.text.includes(p) || (b && s.text.includes(b)))).map((s) => s.name);
|
|
221
|
+
}
|
|
222
|
+
/** The outline: the named sections, nested from the top level found (1 to 3). A single title heading on top is left out. */
|
|
223
|
+
export function outlineOf(secs) {
|
|
224
|
+
let named = secs.filter((s) => s.prefix && headLevel(s) > 0);
|
|
225
|
+
if (named.length && headLevel(named[0]) === 1 && named.filter((s) => headLevel(s) === 1).length === 1) named = named.slice(1);
|
|
226
|
+
if (!named.length) return [];
|
|
227
|
+
const base = Math.min(...named.map(headLevel));
|
|
228
|
+
return named.map((s) => ({ s, lv: Math.min(3, headLevel(s) - base + 1) }));
|
|
229
|
+
}
|
|
230
|
+
/** The neighbourhood of a document in a library response: { me, src, out } with each edge's state (the Docs page's rule when it has none). */
|
|
231
|
+
export function hoodOf(lib, path) {
|
|
232
|
+
if (!lib || !Array.isArray(lib.files)) return null;
|
|
233
|
+
const me = lib.files.findIndex((f) => f.path === path);
|
|
234
|
+
const kinds = lib.kinds || []; const src = []; const out = [];
|
|
235
|
+
const st = (a, b, k, s) => s || (['link', 'cites', 'anchor'].includes(kinds[k]) && a.date && b.date && a.date > b.date ? 'drift' : 'ok');
|
|
236
|
+
if (me >= 0) for (const [a, b, k, s] of lib.edges || []) {
|
|
237
|
+
if (b === me && a !== me && lib.files[a]) src.push({ f: lib.files[a], kind: kinds[k] || '', st: st(lib.files[a], lib.files[me], k, s) });
|
|
238
|
+
else if (a === me && b !== me && lib.files[b]) out.push({ f: lib.files[b], kind: kinds[k] || '', st: st(lib.files[me], lib.files[b], k, s) });
|
|
239
|
+
}
|
|
240
|
+
return { me: me >= 0 ? lib.files[me] : null, src, out };
|
|
241
|
+
}
|
|
242
|
+
const OL_KEY = 'pmos.c3.docOutline';
|
|
243
|
+
const olGet = () => { try { return localStorage.getItem(OL_KEY) !== 'off'; } catch { return true; } };
|
|
244
|
+
const olSet = (on) => { try { localStorage.setItem(OL_KEY, on ? 'on' : 'off'); } catch { /* no storage here */ } };
|
|
245
|
+
const ESTATE = { ok: 'in step', drift: 'out of step', conflict: 'conflict', missing: 'missing', ahead: 'PMOS ahead' };
|
|
246
|
+
|
|
247
|
+
export function openDocSheet({ ws, doc, mode = 'read', prompt = '', relations, onChanged, generated, secExtra, library, openGraph }) {
|
|
187
248
|
const S = { mode: MODE[mode] || mode, prompt, text: null, err: '', revs: null, texts: {}, health: null, hAsk: false, busy: false, changed: false,
|
|
188
249
|
meta: null, metaFor: null, props: null, propsErr: '', log: null, logErr: '', asking: null, closed: false, who: false, src: false, preview: {},
|
|
189
|
-
pick: {}, rejFor: null, rejWhy: '', whyFor: null, whyText: '', staleWhy: null, logF: 'all', cmp: null, cmpNode: null, flashRev: null, ed: null, leave: null
|
|
250
|
+
pick: {}, rejFor: null, rejWhy: '', whyFor: null, whyText: '', staleWhy: null, logF: 'all', cmp: null, cmpNode: null, flashRev: null, ed: null, leave: null,
|
|
251
|
+
copies: null, copiesErr: '', syncOpen: false, linking: false, linkUrl: '', pend: {}, checkRun: null, lib: undefined, ol: olGet(), olOpen: false, olq: '', olShut: {}, cur: null };
|
|
190
252
|
const demo = isDemo();
|
|
191
253
|
const base = { ws, path: doc.path };
|
|
192
254
|
let ui = null;
|
|
@@ -237,7 +299,7 @@ export function openDocSheet({ ws, doc, mode = 'read', prompt = '', relations, o
|
|
|
237
299
|
const reloadAll = async () => {
|
|
238
300
|
S.revs = null; S.meta = null; S.metaFor = null; S.texts = {}; S.health = null; S.hAsk = false; S.log = null;
|
|
239
301
|
await loadText(); draw();
|
|
240
|
-
await Promise.all([loadRevs(), loadProps(), loadHealth()]); loadMeta(); if (S.mode === 'hist') { await loadLog(); } draw();
|
|
302
|
+
await Promise.all([loadRevs(), loadProps(), loadHealth(), loadCopies()]); loadMeta(); if (S.mode === 'hist') { await loadLog(); } draw();
|
|
241
303
|
};
|
|
242
304
|
|
|
243
305
|
/* ── drawing helpers ── */
|
|
@@ -266,7 +328,7 @@ export function openDocSheet({ ws, doc, mode = 'read', prompt = '', relations, o
|
|
|
266
328
|
return el('div', { class: 'chips2 ds-meta', 'data-doc-meta': '' },
|
|
267
329
|
el('span', { class: 'chip2 ' + st[1] }, c3ic(st[2], 'sm'), st[0]), el('span', { class: 'chip2' }, doc.ty || 'Document'), doc.v ? el('span', { class: 'chip2 mono' }, doc.v) : null,
|
|
268
330
|
h && h.total ? btn('chip2 lk ' + hcls(h), { 'data-doc-hchip': '' }, [c3ic('shield', 'sm'), h.passed + ' of ' + h.total + ' checks'], () => go('health')) : null,
|
|
269
|
-
el('span', { class: 'chip2' }, c3ic('init', 'sm'), doc.iniName || doc.ini || 'Portfolio'),
|
|
331
|
+
el('span', { class: 'chip2' }, c3ic('init', 'sm'), doc.iniName || doc.ini || 'Portfolio'), copyChips(),
|
|
270
332
|
last && !generated ? btn('chip2 lk', { 'data-doc-last': '' }, [c3ic(isAgentRev(last) ? 'spark' : 'user', 'sm'), 'Last change: ', el('b', null, whoOf(last) + ','), ' ' + dayName(last.at)], () => go('hist')) : null);
|
|
271
333
|
}
|
|
272
334
|
function seg() {
|
|
@@ -285,6 +347,7 @@ export function openDocSheet({ ws, doc, mode = 'read', prompt = '', relations, o
|
|
|
285
347
|
if (m === 'hist') { S.cmp = null; loadLog().then(() => { draw(); if (S.flashRev != null) { goEl('[data-log-rev="' + S.flashRev + '"]'); S.flashRev = null; } }); }
|
|
286
348
|
if (m === 'ask' && S.props === null) loadProps().then(draw);
|
|
287
349
|
if (m === 'read') loadMeta();
|
|
350
|
+
if (m === 'links') loadLib();
|
|
288
351
|
draw();
|
|
289
352
|
const sb = ui && ui.body.closest('.sb'); if (sb) sb.scrollTop = 0;
|
|
290
353
|
}
|
|
@@ -294,7 +357,7 @@ export function openDocSheet({ ws, doc, mode = 'read', prompt = '', relations, o
|
|
|
294
357
|
const f = fresh(); if (!f || generated) return null;
|
|
295
358
|
const secs = S.text ? splitDoc(S.text) : [];
|
|
296
359
|
const srcs = Array.isArray(f.sources) ? f.sources : [];
|
|
297
|
-
const cites = (p) =>
|
|
360
|
+
const cites = (p) => citesIn(secs, p);
|
|
298
361
|
const op = openProps();
|
|
299
362
|
if (S.staleWhy !== null) {
|
|
300
363
|
const inp = el('input', { type: 'text', id: 'dstalewhy', 'data-doc-stalewhy': '', placeholder: 'For example: the launch date is not agreed yet', 'aria-label': 'Why it is still current', value: S.staleWhy, onInput: (e) => { S.staleWhy = e.target.value; } });
|
|
@@ -318,6 +381,243 @@ export function openDocSheet({ ws, doc, mode = 'read', prompt = '', relations, o
|
|
|
318
381
|
await loadHealth(); draw(); say('Marked as current. The reason is in the edit log.');
|
|
319
382
|
}
|
|
320
383
|
|
|
384
|
+
/* ── v2: the copies in other tools (GET /api/docs: the links and their signals, lib/doc-sync.js) ── */
|
|
385
|
+
const loadCopies = async () => {
|
|
386
|
+
if (generated) { S.copies = []; return; }
|
|
387
|
+
const j = await pmGet('/api/docs?' + q({ ws })).catch(() => null);
|
|
388
|
+
if (!j || !j.ok) { S.copies = S.copies || []; S.copiesErr = (j && j.error) || 'The copies could not be read.'; return; }
|
|
389
|
+
const d = (j.docs || []).find((x) => x.path === doc.path);
|
|
390
|
+
S.copies = d ? d.links || [] : []; S.copiesErr = ''; S.checkRun = j.session || j.open || null;
|
|
391
|
+
};
|
|
392
|
+
const copyChips = () => {
|
|
393
|
+
if (generated || S.copies == null) return null;
|
|
394
|
+
if (!S.copies.length) return btn('chip2 lk', { 'data-doc-copychip': '', 'aria-expanded': String(S.syncOpen) }, [c3ic('link', 'sm'), 'Link a Confluence or Google Doc copy'], () => toggleCopies());
|
|
395
|
+
return S.copies.map((l) => { const g = sigOf(l); return btn('chip2 lk ' + g[2], { 'data-doc-copychip': l.kind, 'aria-expanded': String(S.syncOpen), title: l.words || g[3] }, [c3ic(g[1], 'sm'), labelOf(l) + ': ' + g[4]], () => toggleCopies()); });
|
|
396
|
+
};
|
|
397
|
+
function toggleCopies() {
|
|
398
|
+
S.syncOpen = !S.syncOpen;
|
|
399
|
+
if (S.syncOpen && S.mode === 'edit') { go('read'); return; }
|
|
400
|
+
draw(); const sb = ui && ui.body.closest('.sb'); if (sb && S.syncOpen) sb.scrollTop = 0;
|
|
401
|
+
}
|
|
402
|
+
function copiesPanel() {
|
|
403
|
+
if (!S.syncOpen || generated) return null;
|
|
404
|
+
const L = S.copies || [];
|
|
405
|
+
const rows = L.map((l) => {
|
|
406
|
+
const g = sigOf(l); const name = labelOf(l); const pend = S.pend[l.kind];
|
|
407
|
+
const acts = [btn('ds-btn', { 'data-copy-check': l.kind, disabled: !!S.checkRun, 'aria-disabled': S.checkRun ? 'true' : null }, [c3ic(S.checkRun ? 'clock' : 'undo', 'sm'), S.checkRun ? 'Checking' : 'Check now'], () => checkNow()),
|
|
408
|
+
l.url ? el('a', { class: 'ds-btn', href: l.url, target: '_blank', rel: 'noopener', 'data-copy-open': l.kind }, c3ic('out', 'sm'), 'Open in ' + name) : null];
|
|
409
|
+
if (!l.syncing && (l.signal === 'behind' || l.signal === 'diverged')) acts.push(btn('ds-btn ag', { 'data-copy-take': l.kind }, [c3ic('spark', 'sm'), 'Bring their change in as a proposal'], () => takeCopy(l)));
|
|
410
|
+
if (!l.syncing && l.signal === 'ahead') acts.push(pend ? btn('ds-btn', { 'data-copy-undo': l.kind }, [c3ic('undo', 'sm'), 'Undo the update'], () => undoPush(l))
|
|
411
|
+
: btn('ds-btn pri', { 'data-copy-push': l.kind }, [c3ic('up', 'sm'), 'Update the ' + name + ' to this version'], () => pushCopy(l)));
|
|
412
|
+
const changed = l.remote_modified_at ? 'changed ' + whenOf(l.remote_modified_at) + (l.remote_modified_by ? ' by ' + l.remote_modified_by : '') : '';
|
|
413
|
+
return el('li', { 'data-copy': l.kind },
|
|
414
|
+
el('div', { class: 'l1' }, c3ic('doc', 'sm'), el('b', null, l.title || name), el('span', { class: 'tag ' + g[2], 'data-copy-state': l.syncing ? 'syncing' : l.signal || 'unchecked' }, c3ic(g[1], 'sm'), g[0])),
|
|
415
|
+
el('div', { class: 'ds-mono' }, [name, l.target, changed].filter(Boolean).join(' · ')),
|
|
416
|
+
el('p', null, l.words || g[3]),
|
|
417
|
+
el('div', { class: 'ds-note' }, l.checked_at ? 'Last checked ' + whenOf(l.checked_at) + (l.checked_by ? ' by ' + byName(l.checked_by) : '') + '.' : 'Not checked yet.'),
|
|
418
|
+
l.syncing ? el('div', { class: 'fold', role: 'status', 'data-copy-syncing': l.kind }, c3ic('clock', 'sm'), 'A session is updating ' + (l.syncing.use === 'copy' ? 'PMOS from the ' + name : 'the ' + name + ' to this version') + (l.syncing.started_at ? '. It started ' + whenOf(l.syncing.started_at) + '.' : '.')) : null,
|
|
419
|
+
l.sync_failed ? el('div', { class: 'fold w', role: 'alert', 'data-copy-failed': l.kind }, c3ic('alert', 'sm'), 'The last update did not finish' + (l.sync_failed.reason ? ': ' + l.sync_failed.reason : '.')) : null,
|
|
420
|
+
pend ? el('div', { class: 'fold', role: 'status', 'data-copy-pending': l.kind }, c3ic('clock', 'sm'), 'The update starts in five seconds. Undo stops it.') : null,
|
|
421
|
+
el('div', { class: 'ds-acts' }, acts));
|
|
422
|
+
});
|
|
423
|
+
const linkRow = S.linking ? el('div', { class: 'ds-whyadd ds-linkadd' },
|
|
424
|
+
el('input', { type: 'text', id: 'dcopyurl', 'data-copy-url': '', placeholder: 'Paste a Confluence page or Google Doc link', 'aria-label': 'Address of the copy', value: S.linkUrl, onInput: (e) => { S.linkUrl = e.target.value; } }),
|
|
425
|
+
btn('ds-btn pri', { 'data-copy-linkok': '' }, 'Link', () => linkCopy()), btn('ds-btn', {}, 'Cancel', () => { S.linking = false; draw(); })) : null;
|
|
426
|
+
return el('section', { class: 'ds-sync', 'aria-label': 'Copies in other tools', 'data-doc-copies': '' },
|
|
427
|
+
el('div', { class: 'hd' }, el('b', null, 'Copies in other tools'), el('span', { class: 'ds-acts' },
|
|
428
|
+
btn('ds-btn', { 'data-copy-link': '' }, [c3ic('link', 'sm'), L.length ? 'Link another copy' : 'Link a copy'], () => { S.linking = true; S.linkUrl = ''; draw(); const i = ui.body.querySelector('#dcopyurl'); if (i) i.focus(); }),
|
|
429
|
+
btn('ds-btn', { 'aria-label': 'Close the copies', 'data-copy-close': '' }, c3ic('x', 'sm'), () => { S.syncOpen = false; draw(); }))),
|
|
430
|
+
S.copiesErr ? el('div', { class: 'fold w', role: 'alert' }, c3ic('alert', 'sm'), S.copiesErr) : null, linkRow,
|
|
431
|
+
L.length ? el('ul', null, rows) : el('p', { class: 'ds-note', 'data-copy-none': '' }, 'This document has no copy in Confluence or Google Docs yet. Link one to see whether it is in step.'),
|
|
432
|
+
el('p', { class: 'ds-note' }, 'PMOS never overwrites this document from a copy: a change in the copy comes in as a proposal you accept by section. Updating a copy starts one agent session, and you have five seconds to undo.'));
|
|
433
|
+
}
|
|
434
|
+
async function checkNow() {
|
|
435
|
+
const r = await pmPost('/api/docs/check', { ws }).catch(() => null);
|
|
436
|
+
if (!r || !r.ok) { say((r && r.error) || 'The check did not start.'); return; }
|
|
437
|
+
S.checkRun = { id: r.session }; draw();
|
|
438
|
+
say('Checking the copies. An agent session reads them with your connectors; the states change when it finishes.');
|
|
439
|
+
for (let i = 0; i < 120 && !S.closed && S.checkRun; i++) {
|
|
440
|
+
await new Promise((ok) => setTimeout(ok, 5000));
|
|
441
|
+
if (S.closed) return;
|
|
442
|
+
await loadCopies(); drawBg();
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
/* a change in the copy comes in as a proposal: the read-only ask run (#637) reads the copy and drafts it by section */
|
|
446
|
+
function takeCopy(l) {
|
|
447
|
+
S.prompt = 'Read the ' + labelOf(l) + ' copy of this document (' + l.target + ') and propose its changes here, section by section. Keep the PMOS text where only PMOS changed.';
|
|
448
|
+
S.syncOpen = false; go('ask');
|
|
449
|
+
if (demo) askDemo(); else askReal();
|
|
450
|
+
}
|
|
451
|
+
function pushCopy(l) {
|
|
452
|
+
const name = labelOf(l);
|
|
453
|
+
S.pend[l.kind] = setTimeout(async () => {
|
|
454
|
+
delete S.pend[l.kind];
|
|
455
|
+
const r = await pmPost('/api/docs/sync', { ws, path: doc.path, kind: l.kind, use: 'pmos' }).catch(() => null);
|
|
456
|
+
if (!r || !r.ok) say((r && r.error) || 'The update did not start.');
|
|
457
|
+
else say('Updating the ' + name + '. A session writes it with your connectors.');
|
|
458
|
+
await loadCopies(); drawBg();
|
|
459
|
+
}, 5000);
|
|
460
|
+
draw();
|
|
461
|
+
}
|
|
462
|
+
function undoPush(l) { clearTimeout(S.pend[l.kind]); delete S.pend[l.kind]; draw(); say('Stopped. The ' + labelOf(l) + ' is not updated.'); }
|
|
463
|
+
async function linkCopy() {
|
|
464
|
+
const t = String(S.linkUrl || '').trim(); const kind = copyKindOf(t);
|
|
465
|
+
if (!kind) { say('Paste a Confluence page or Google Doc link.'); const i = ui.body.querySelector('#dcopyurl'); if (i) i.focus(); return; }
|
|
466
|
+
const r = await pmPost('/api/docs/link', { ws, path: doc.path, kind, target: t }).catch(() => null);
|
|
467
|
+
if (!r || !r.ok) { say((r && r.error) || 'The copy was not linked.'); return; }
|
|
468
|
+
S.linking = false; S.linkUrl = ''; await loadCopies(); draw(); say('Linked the ' + KLABEL[kind] + ' copy. Check now reads it.');
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/* ── v2: the outline (Read) ── */
|
|
472
|
+
const staleNames = () => {
|
|
473
|
+
const f = fresh(); if (!f || !S.text) return new Set();
|
|
474
|
+
const secs = splitDoc(S.text); const out = new Set();
|
|
475
|
+
(Array.isArray(f.sources) ? f.sources : []).forEach((x) => citesIn(secs, x.path).forEach((n) => out.add(n)));
|
|
476
|
+
return out;
|
|
477
|
+
};
|
|
478
|
+
function outlineNav(items) {
|
|
479
|
+
const qq = S.olq.trim().toLowerCase(); const stale = staleNames(); const prop = new Set(openProps().flatMap((p) => p.sections.map((c) => c.name)));
|
|
480
|
+
const rows = []; let hideUnder = null;
|
|
481
|
+
items.forEach((it, k) => {
|
|
482
|
+
const { s, lv } = it;
|
|
483
|
+
if (hideUnder !== null && lv > hideUnder) return;
|
|
484
|
+
hideUnder = null;
|
|
485
|
+
if (qq && !s.name.toLowerCase().includes(qq)) return;
|
|
486
|
+
let n = 0; for (let j = k + 1; j < items.length && items[j].lv > lv; j++) n++;
|
|
487
|
+
const shut = !qq && !!S.olShut[s.i]; if (shut) hideUnder = lv;
|
|
488
|
+
const on = S.cur === s.i;
|
|
489
|
+
rows.push(el('li', { class: 'l' + lv + (on ? ' on' : ''), 'data-ol-i': String(s.i) },
|
|
490
|
+
n && !qq ? btn('tw', { 'aria-label': (shut ? 'Show' : 'Hide') + ' the ' + plural(n, 'heading') + ' under ' + s.name, 'aria-expanded': String(!shut), 'data-ol-fold': String(s.i) }, c3ic('next', 'sm'), () => { S.olShut[s.i] = !shut; redrawOl(); }) : el('span', { class: 'tw' }),
|
|
491
|
+
btn('go', { 'data-ol-go': String(s.i), 'aria-current': on ? 'location' : null }, [el('span', null, s.name),
|
|
492
|
+
stale.has(s.name) ? el('i', { class: 'mdot w', title: 'A source changed after this section', 'data-ol-stale': '' }) : null,
|
|
493
|
+
prop.has(s.name) ? el('i', { class: 'mdot p', title: 'The agent proposes a change here', 'data-ol-prop': '' }) : null], () => jumpTo(s.i))));
|
|
494
|
+
});
|
|
495
|
+
return el('nav', { class: 'ds-ol', 'aria-label': 'Outline', 'data-doc-outline': '' },
|
|
496
|
+
el('div', { class: 'hd' }, el('b', null, 'Outline'), el('span', { class: 'ds-mono' }, plural(items.length, 'heading'))),
|
|
497
|
+
items.length > 12 ? el('input', { type: 'text', id: 'dolq', 'data-ol-find': '', placeholder: 'Find a heading', 'aria-label': 'Find a heading', value: S.olq, onInput: (e) => { S.olq = e.target.value; redrawOl(); } }) : null,
|
|
498
|
+
el('ul', null, rows.length ? rows : el('li', { class: 'none ds-note' }, 'No heading matches.')),
|
|
499
|
+
stale.size || prop.size ? el('div', { class: 'key ds-note' }, stale.size ? el('span', null, el('i', { class: 'mdot w' }), 'stale') : null, prop.size ? el('span', null, el('i', { class: 'mdot p' }), 'proposal') : null) : null);
|
|
500
|
+
}
|
|
501
|
+
function redrawOl() {
|
|
502
|
+
const n = ui && ui.body.querySelector('.ds-ol'); if (!n || S.text == null) return;
|
|
503
|
+
const f = document.activeElement && document.activeElement.id === 'dolq' ? document.activeElement.selectionStart : null;
|
|
504
|
+
n.replaceWith(outlineNav(outlineOf(splitDoc(S.text)))); placeOl();
|
|
505
|
+
if (f != null) { const i = ui.body.querySelector('#dolq'); if (i) { i.focus(); try { i.setSelectionRange(f, f); } catch { /* not a text field */ } } }
|
|
506
|
+
}
|
|
507
|
+
/* where the sticky header ends: a section scrolled to lands below it, and the outline sticks under it */
|
|
508
|
+
/* (a sticky element sticks inside the scroller's padding: its edge from the scroller's top adds the padding) */
|
|
509
|
+
const stickBottom = () => { const t = ui && ui.body.querySelector('.ds-top'); return t ? (parseFloat(getComputedStyle(t).top) || 0) + t.offsetHeight : 0; };
|
|
510
|
+
const topEdge = () => { const sb = ui && ui.body.closest('.sb'); return stickBottom() + (sb ? parseFloat(getComputedStyle(sb).paddingTop) || 0 : 0); };
|
|
511
|
+
function placeOl() {
|
|
512
|
+
const nav = ui && ui.body.querySelector('.ds-ol'); const sb = ui && ui.body.closest('.sb'); if (!nav || !sb) return;
|
|
513
|
+
const h = stickBottom() + 8;
|
|
514
|
+
nav.style.setProperty('--in-ds-oltop', h + 'px'); nav.style.setProperty('--in-ds-olmax', Math.max(160, sb.clientHeight - h - 16) + 'px');
|
|
515
|
+
}
|
|
516
|
+
function jumpTo(i) {
|
|
517
|
+
const sb = ui && ui.body.closest('.sb'); const n = ui && ui.body.querySelector('[data-dsec-i="' + i + '"]'); if (!sb || !n) return;
|
|
518
|
+
sb.scrollTop += n.getBoundingClientRect().top - sb.getBoundingClientRect().top - topEdge() - 8;
|
|
519
|
+
n.classList.remove('flash'); void n.offsetWidth; n.classList.add('flash');
|
|
520
|
+
S.cur = i; S.pinCur = i; S.olOpen = false; const lay = ui.body.querySelector('.ds-rlay'); if (lay) lay.classList.remove('olopen');
|
|
521
|
+
const b = ui.body.querySelector('[data-ol-btn]'); if (b) b.setAttribute('aria-expanded', 'false');
|
|
522
|
+
markCur();
|
|
523
|
+
}
|
|
524
|
+
function trackCur() {
|
|
525
|
+
if (S.mode !== 'read' || !ui) return;
|
|
526
|
+
const sb = ui.body.closest('.sb'); const nav = ui.body.querySelector('.ds-ol'); if (!sb || !nav) return;
|
|
527
|
+
const ids = new Set([...nav.querySelectorAll('[data-ol-i]')].map((x) => x.getAttribute('data-ol-i')));
|
|
528
|
+
const lim = sb.getBoundingClientRect().top + topEdge() + 24; let cur = null;
|
|
529
|
+
for (const s of ui.body.querySelectorAll('.ds-rmain [data-dsec-i]')) {
|
|
530
|
+
const k = s.getAttribute('data-dsec-i');
|
|
531
|
+
if (s.getBoundingClientRect().top <= lim) { if (ids.has(k)) cur = +k; } else break;
|
|
532
|
+
}
|
|
533
|
+
if (cur == null) { const f = nav.querySelector('[data-ol-i]'); cur = f ? +f.getAttribute('data-ol-i') : null; }
|
|
534
|
+
/* a section pressed near the end cannot scroll to the top: it stays the marked one while it is on screen */
|
|
535
|
+
if (S.pinCur != null) {
|
|
536
|
+
const p = ui.body.querySelector('[data-dsec-i="' + S.pinCur + '"]'); const r = p && p.getBoundingClientRect(); const b = sb.getBoundingClientRect();
|
|
537
|
+
if (r && r.top >= b.top + topEdge() - 8 && r.top < b.bottom - 40) cur = S.pinCur; else S.pinCur = null;
|
|
538
|
+
}
|
|
539
|
+
if (cur !== S.cur) { S.cur = cur; markCur(); }
|
|
540
|
+
}
|
|
541
|
+
function markCur() {
|
|
542
|
+
const nav = ui && ui.body.querySelector('.ds-ol'); if (!nav) return;
|
|
543
|
+
nav.querySelectorAll('[data-ol-i]').forEach((li) => { const on = +li.getAttribute('data-ol-i') === S.cur; li.classList.toggle('on', on); const g = li.querySelector('[data-ol-go]'); if (g) { if (on) g.setAttribute('aria-current', 'location'); else g.removeAttribute('aria-current'); } });
|
|
544
|
+
const secs = S.text ? splitDoc(S.text) : []; const c = secs.find((s) => s.i === S.cur);
|
|
545
|
+
const ob = ui.body.querySelector('[data-ol-btn] b'); if (ob) ob.textContent = c ? c.name : '';
|
|
546
|
+
const on = nav.querySelector('li.on');
|
|
547
|
+
if (on && nav.scrollHeight > nav.clientHeight) { const t = on.offsetTop - nav.offsetTop; if (t < nav.scrollTop || t > nav.scrollTop + nav.clientHeight - 30) nav.scrollTop = t - nav.clientHeight / 3; }
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/* ── v2: Relations, the graph around this document (from the caller's library, else GET /api/docs/library) ── */
|
|
551
|
+
const loadLib = async () => {
|
|
552
|
+
if (S.lib !== undefined) return;
|
|
553
|
+
S.lib = null;
|
|
554
|
+
const j = await (library ? Promise.resolve().then(library) : pmGet('/api/docs/library?' + q({ ws }))).catch(() => null);
|
|
555
|
+
S.lib = j && j.ok ? j : { err: (j && j.error) || 'The document graph could not be read.' };
|
|
556
|
+
drawBg();
|
|
557
|
+
};
|
|
558
|
+
const catName = (c) => { const k = ((S.lib && S.lib.categories) || []).find((x) => x.key === c); return k ? k.name : c || 'Document'; };
|
|
559
|
+
const fileDoc = (f) => ({ path: f.path, name: f.title || f.path, ty: f.ty || catName(f.category), v: f.v || '', st: STATE[f.st] ? f.st : 'current', ini: f.initiative || '',
|
|
560
|
+
iniName: (S.lib && S.lib.initiatives && S.lib.initiatives[f.initiative] && (S.lib.initiatives[f.initiative].short || S.lib.initiatives[f.initiative].title)) || f.initiative || '' });
|
|
561
|
+
function graphNodes() {
|
|
562
|
+
const H = hoodOf(S.lib && !S.lib.err ? S.lib : null, doc.path) || { src: [], out: [] };
|
|
563
|
+
const isGoal = (f) => f.category === 'goal' || f.node === 'Goal';
|
|
564
|
+
const nd = (e, side) => { const f = e.f; const file = !f.node && f.st !== 'node' && f.st !== 'missing';
|
|
565
|
+
return { side, name: f.title || f.path, sub: [...new Set([e.kind, f.node || (f.date ? dayName(f.date) : '')].filter(Boolean).map((x) => x.toLowerCase() === String(e.kind).toLowerCase() ? e.kind : x))].join(' · '), st: e.st, path: f.path, open: file ? fileDoc(f) : null }; };
|
|
566
|
+
const top = H.src.filter((e) => isGoal(e.f)).map((e) => nd(e, 'top'));
|
|
567
|
+
if (doc.ini || doc.iniName) top.unshift({ side: 'top', name: doc.iniName || doc.ini, sub: 'initiative', st: 'ok', init: true });
|
|
568
|
+
const left = H.src.filter((e) => !isGoal(e.f)).map((e) => nd(e, 'left'));
|
|
569
|
+
const right = H.out.map((e) => nd(e, 'right')).concat((S.copies || []).map((l) => ({ side: 'right', name: labelOf(l) + ' copy', sub: sigOf(l)[4], url: l.url, kind: l.kind,
|
|
570
|
+
st: l.signal === 'behind' || l.signal === 'diverged' ? 'drift' : l.signal === 'ahead' ? 'ahead' : l.signal === 'same' ? 'ok' : 'missing' })));
|
|
571
|
+
return { top, left, right };
|
|
572
|
+
}
|
|
573
|
+
function graphEl(G) {
|
|
574
|
+
const CAP = 6;
|
|
575
|
+
const node = (n) => {
|
|
576
|
+
const kids = [el('b', null, n.name), el('small', null, n.sub)];
|
|
577
|
+
const a = { 'data-gn': n.side, 'data-gn-st': n.st, 'data-gn-copy': n.kind || null };
|
|
578
|
+
if (n.url) return el('a', { class: 'ds-gn ' + n.st, href: n.url, target: '_blank', rel: 'noopener', ...a }, ...kids);
|
|
579
|
+
if (n.open) return btn('ds-gn ' + n.st, { 'data-gn-path': n.path, title: 'Open ' + n.name, ...a }, kids, () => S.open(n.open));
|
|
580
|
+
return el('div', { class: 'ds-gn x ' + n.st, ...a }, ...kids);
|
|
581
|
+
};
|
|
582
|
+
const col = (list) => [list.slice(0, CAP).map(node), list.length > CAP ? el('div', { class: 'ds-note ds-gmore' }, '+' + (list.length - CAP) + ' more, listed below') : null];
|
|
583
|
+
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('class', 'ds-gsvg'); svg.setAttribute('aria-hidden', 'true');
|
|
584
|
+
const wrap = el('div', { class: 'ds-graph', role: 'group', 'aria-label': 'This document, its sources on the left, its initiative above, the documents that use it and its copies on the right', 'data-doc-graph': '' }, svg,
|
|
585
|
+
G.top.length ? el('div', { class: 'ds-gtop' }, col(G.top)) : null,
|
|
586
|
+
el('div', { class: 'ds-gmid' }, el('div', { class: 'ds-gcol' }, G.left.length ? col(G.left) : el('div', { class: 'ds-note' }, 'No source recorded')),
|
|
587
|
+
el('div', { class: 'ds-gn me', 'data-gn': 'me' }, el('b', null, doc.name), el('small', null, 'this document' + (doc.v ? ' · ' + doc.v : ''))),
|
|
588
|
+
el('div', { class: 'ds-gcol' }, G.right.length ? col(G.right) : el('div', { class: 'ds-note' }, 'Nothing uses it yet'))),
|
|
589
|
+
el('div', { class: 'key ds-note' }, el('span', null, el('i', { class: 'ln ok' }), 'in step'), el('span', null, el('i', { class: 'ln drift' }), 'out of step'), el('span', null, el('i', { class: 'ln ahead' }), 'PMOS ahead of the copy'),
|
|
590
|
+
openGraph ? btn('chip2 lk', { 'data-doc-fullgraph': '' }, [c3ic('tree', 'sm'), 'Open the full graph in Docs'], () => { ui.close(); openGraph(doc); }) : null));
|
|
591
|
+
if (typeof ResizeObserver === 'function') new ResizeObserver(() => drawEdges(wrap)).observe(wrap);
|
|
592
|
+
return wrap;
|
|
593
|
+
}
|
|
594
|
+
function drawEdges(wrap) {
|
|
595
|
+
const svg = wrap.querySelector('.ds-gsvg'); const me = wrap.querySelector('[data-gn="me"]'); if (!svg || !me || !wrap.isConnected) return;
|
|
596
|
+
const W = wrap.getBoundingClientRect(); const M = me.getBoundingClientRect();
|
|
597
|
+
svg.setAttribute('viewBox', '0 0 ' + Math.round(W.width) + ' ' + Math.round(W.height)); svg.setAttribute('width', String(Math.round(W.width))); svg.setAttribute('height', String(Math.round(W.height)));
|
|
598
|
+
const paths = [...wrap.querySelectorAll('[data-gn]:not([data-gn="me"])')].map((n) => {
|
|
599
|
+
const R = n.getBoundingClientRect(); const side = n.getAttribute('data-gn');
|
|
600
|
+
const [fx, fy, tx, ty] = side === 'left' ? [R.right, R.top + R.height / 2, M.left, M.top + M.height / 2] : side === 'right' ? [M.right, M.top + M.height / 2, R.left, R.top + R.height / 2] : [R.left + R.width / 2, R.bottom, M.left + M.width / 2, M.top];
|
|
601
|
+
const x1 = fx - W.left; const y1 = fy - W.top; const x2 = tx - W.left; const y2 = ty - W.top;
|
|
602
|
+
const d = side === 'top' ? 'M' + x1 + ' ' + y1 + ' C ' + x1 + ' ' + (y1 + y2) / 2 + ', ' + x2 + ' ' + (y1 + y2) / 2 + ', ' + x2 + ' ' + y2 : 'M' + x1 + ' ' + y1 + ' C ' + (x1 + x2) / 2 + ' ' + y1 + ', ' + (x1 + x2) / 2 + ' ' + y2 + ', ' + x2 + ' ' + y2;
|
|
603
|
+
const p = document.createElementNS('http://www.w3.org/2000/svg', 'path'); p.setAttribute('d', d); p.setAttribute('class', 'e ' + (n.getAttribute('data-gn-st') || 'ok')); return p;
|
|
604
|
+
});
|
|
605
|
+
svg.replaceChildren(...paths);
|
|
606
|
+
}
|
|
607
|
+
function linksBody() {
|
|
608
|
+
if (S.lib === undefined) loadLib();
|
|
609
|
+
if (S.lib == null) return el('div', { class: 'fold', role: 'status' }, c3ic('clock', 'sm'), 'Reading the document graph.');
|
|
610
|
+
const G = graphNodes(); const any = G.left.length || G.right.length || G.top.some((n) => !n.init);
|
|
611
|
+
const rel = (h, list) => list.length ? [el('h3', { class: 'ds-h' }, h), el('ul', { class: 'ds-rel' }, list.map((n) => el('li', { 'data-rel': n.path || n.kind || n.name },
|
|
612
|
+
c3ic(n.url ? 'out' : n.init ? 'init' : 'doc', 'sm'),
|
|
613
|
+
n.open ? btn('ds-rlk', { 'data-rel-open': n.path }, [n.name, el('small', null, n.sub)], () => S.open(n.open)) : n.url ? el('a', { class: 'ds-rlk', href: n.url, target: '_blank', rel: 'noopener' }, n.name, el('small', null, n.sub)) : el('span', { class: 'ds-rlk' }, n.name, el('small', null, n.sub)),
|
|
614
|
+
n.st && n.st !== 'ok' ? el('span', { class: 'tag ' + (n.st === 'ahead' ? 'ds-acc' : n.st === 'drift' ? 'ds-warn' : 'ds-bad') }, ESTATE[n.st] || n.st) : el('span'))))] : null;
|
|
615
|
+
return el('div', { class: 'ds' },
|
|
616
|
+
S.lib.err ? el('div', { class: 'fold w', role: 'alert' }, c3ic('alert', 'sm'), S.lib.err) : null,
|
|
617
|
+
any ? graphEl(G) : el('div', { class: 'fold', 'data-doc-norel': '' }, c3ic('info', 'sm'), 'No relation is recorded for this document.'),
|
|
618
|
+
relations ? relations(doc, (d) => { if (S.open) S.open(d); }) : [rel('Sources', G.left), rel('Goals and initiative', G.top.filter((n) => !n.init)), rel('Used by and copies', G.right)]);
|
|
619
|
+
}
|
|
620
|
+
|
|
321
621
|
/* ── Read ── */
|
|
322
622
|
function propsBlock(secs, editing) {
|
|
323
623
|
const s0 = secs.find((s) => s.prefix === ''); const fm = s0 && parseFront(s0.text);
|
|
@@ -334,11 +634,14 @@ export function openDocSheet({ ws, doc, mode = 'read', prompt = '', relations, o
|
|
|
334
634
|
if (S.text == null) return S.err ? el('div', { class: 'fold w', role: 'alert' }, c3ic('alert', 'sm'), S.err) : el('div', { class: 'fold', role: 'status' }, c3ic('clock', 'sm'), 'Reading the document.');
|
|
335
635
|
const secs = splitDoc(S.text);
|
|
336
636
|
if (!S.text.trim()) return el('div', { class: 'fold' }, c3ic('moon', 'sm'), 'The document is empty.');
|
|
337
|
-
const
|
|
338
|
-
|
|
339
|
-
|
|
637
|
+
const items = outlineOf(secs); const olOn = items.length >= 2 && !S.src;
|
|
638
|
+
if (S.cur == null && items.length) S.cur = items[0].s.i;
|
|
639
|
+
const curName = (items.find((x) => x.s.i === S.cur) || items[0] || { s: { name: '' } }).s.name;
|
|
640
|
+
const olCtl = olOn ? el('span', { class: 'ds-acts' },
|
|
641
|
+
btn('ds-btn ds-olbtn', { 'aria-expanded': String(S.olOpen), 'data-ol-btn': '' }, [c3ic('ul', 'sm'), 'Outline · ', el('b', null, curName)], () => { S.olOpen = !S.olOpen; const lay = ui.body.querySelector('.ds-rlay'); if (lay) lay.classList.toggle('olopen', S.olOpen); const b = ui.body.querySelector('[data-ol-btn]'); if (b) b.setAttribute('aria-expanded', String(S.olOpen)); }),
|
|
642
|
+
btn('ds-btn ds-olhide', { 'aria-pressed': String(!S.ol), 'data-ol-hide': '' }, [c3ic('ul', 'sm'), S.ol ? 'Hide the outline' : 'Show the outline'], () => { S.ol = !S.ol; olSet(S.ol); draw(); })) : el('span');
|
|
340
643
|
const tg = (id, label, on, set) => el('label', { class: 'ds-tg' }, el('input', { type: 'checkbox', checked: on, 'data-doc-toggle': id, onChange: (e) => { set(e.target.checked); draw(); } }), label);
|
|
341
|
-
const bar = el('div', { class: 'ds-rbar' },
|
|
644
|
+
const bar = el('div', { class: 'ds-rbar' }, olCtl, el('span', { class: 'ds-acts' },
|
|
342
645
|
generated ? null : tg('who', 'Show who wrote what', S.who, (v) => { S.who = v; }), tg('src', 'Markdown source', S.src, (v) => { S.src = v; })));
|
|
343
646
|
const legend = S.who ? el('div', { class: 'ds-legend' }, el('span', null, el('i', { class: 'h' }), 'Written by a person'), el('span', null, el('i', { class: 'a' }), 'Written by the agent, accepted by a person')) : null;
|
|
344
647
|
if (S.src) {
|
|
@@ -368,8 +671,9 @@ export function openDocSheet({ ws, doc, mode = 'read', prompt = '', relations, o
|
|
|
368
671
|
});
|
|
369
672
|
const adds = openProps().flatMap((p) => p.sections.filter((c) => c.kind === 'added' && S.preview[p.id]).map((c) => el('div', { class: 'ds-sec a prev', 'data-dsec-new': c.name },
|
|
370
673
|
el('div', { class: 'ds-pin' }, el('span', null, c3ic('spark', 'sm'), 'The proposal adds a section: ' + c.name)), el('div', { class: 'ds-sec-h' }, el('h2', null, c.name)), md(diffMd(null, c.after), 'ds-body'))));
|
|
371
|
-
|
|
372
|
-
|
|
674
|
+
const main = [generated ? el('div', { class: 'fold', role: 'note', 'data-doc-generated': '' }, c3ic('info', 'sm'), generated.why) : null, staleBanner(), propsBlock(secs, false), cells, adds];
|
|
675
|
+
return el('div', { class: 'ds' + (S.who ? ' who' : '') }, bar, legend,
|
|
676
|
+
olOn ? el('div', { class: 'ds-rlay' + (S.ol ? '' : ' nool') + (S.olOpen ? ' olopen' : '') }, outlineNav(items), el('div', { class: 'ds-rmain' }, main)) : el('div', { class: 'ds-rmain' }, main));
|
|
373
677
|
}
|
|
374
678
|
const short = (t) => { const s = String(t || '').replace(/\s+/g, ' ').trim(); return s.length > 90 ? s.slice(0, 88) + '…' : s; };
|
|
375
679
|
|
|
@@ -709,8 +1013,6 @@ export function openDocSheet({ ws, doc, mode = 'read', prompt = '', relations, o
|
|
|
709
1013
|
el('p', { class: 'ds-note' }, 'Health answers one question: can I trust this document without rereading it? The checks run again every time the text changes.'));
|
|
710
1014
|
}
|
|
711
1015
|
|
|
712
|
-
const linksBody = () => (relations ? relations(doc, (d) => { if (S.open) S.open(d); }) : el('div', { class: 'fold' }, c3ic('info', 'sm'), 'No relation is recorded for this document.'));
|
|
713
|
-
|
|
714
1016
|
/* a load that finished in the background must not redraw under a hand that is typing */
|
|
715
1017
|
const drawBg = () => { const a = document.activeElement; if (ui && a && ui.body.contains(a) && a.closest('input,textarea,[contenteditable=true]')) { refreshTop(); return; } draw(); };
|
|
716
1018
|
const top = () => el('div', { class: 'ds-top' }, head(), seg(), S.mode === 'edit' && S.ed ? toolbar() : null,
|
|
@@ -723,21 +1025,26 @@ export function openDocSheet({ ws, doc, mode = 'read', prompt = '', relations, o
|
|
|
723
1025
|
const body = S.mode === 'read' ? readBody() : S.mode === 'edit' ? editBody() : S.mode === 'ask' ? propsBody() : S.mode === 'hist' ? logBody() : S.mode === 'health' ? healthBody() : linksBody();
|
|
724
1026
|
const keep = ui.body.querySelector('textarea:focus,input[type=text]:focus');
|
|
725
1027
|
const had = keep ? { id: keep.id, s: keep.selectionStart } : null;
|
|
726
|
-
ui.body.replaceChildren(el('div', { class: 'dpn ds' }, top(), body));
|
|
1028
|
+
ui.body.replaceChildren(el('div', { class: 'dpn ds' }, top(), copiesPanel(), body));
|
|
1029
|
+
const sh = ui.body.closest('.sheet'); if (sh) sh.classList.toggle('dwx', S.mode === 'read' && S.ol && !!ui.body.querySelector('.ds-ol'));
|
|
1030
|
+
const sb = ui.body.closest('.sb');
|
|
1031
|
+
if (sb && !sb.__dsTrack) { sb.__dsTrack = true; sb.addEventListener('scroll', trackCur, { passive: true }); }
|
|
1032
|
+
if (S.mode === 'read') { placeOl(); trackCur(); }
|
|
1033
|
+
if (S.mode === 'links') { const g = ui.body.querySelector('.ds-graph'); if (g) requestAnimationFrame(() => drawEdges(g)); }
|
|
727
1034
|
if (had && had.id) { const n = ui.body.querySelector('#' + had.id); if (n) { n.focus(); try { n.setSelectionRange(had.s, had.s); } catch { /* not a text field */ } } }
|
|
728
1035
|
if (S.mode === 'ask' && S.openProp) { const id = S.openProp; S.openProp = null; goEl('[data-doc-prop-card="' + id + '"]'); }
|
|
729
1036
|
}
|
|
730
1037
|
|
|
731
1038
|
document.addEventListener('selectionchange', keepRange);
|
|
732
1039
|
ui = openSheet({ title: doc.name, icon: 'doc', dw: true, body: el('div', { class: 'dpn' }), onClose: () => { S.closed = true; document.removeEventListener('selectionchange', keepRange); if (S.changed && onChanged) onChanged(); } });
|
|
733
|
-
S.open = (d) => { ui.close(); if (S.openOther) S.openOther(d); };
|
|
1040
|
+
S.open = (d) => { ui.close(); if (S.openOther) S.openOther(d); else openDocSheet({ ws, doc: d, library, openGraph }); };
|
|
734
1041
|
ui.setOpener = (fn) => { S.openOther = fn; };
|
|
735
1042
|
draw();
|
|
736
1043
|
(async () => {
|
|
737
1044
|
await loadText();
|
|
738
1045
|
if (S.mode === 'edit') startEdit();
|
|
739
1046
|
drawBg();
|
|
740
|
-
await Promise.all([loadRevs(), loadProps(), loadHealth()]);
|
|
1047
|
+
await Promise.all([loadRevs(), loadProps(), loadHealth(), loadCopies()]);
|
|
741
1048
|
if (S.mode === 'hist') await loadLog();
|
|
742
1049
|
drawBg(); loadMeta();
|
|
743
1050
|
})();
|
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
* GET /api/runs the needs-you count only (a run that asks, holds a permission or needs the terminal); the cards are on the Terminal page
|
|
10
10
|
* GET and POST /api/scratchpad the session's pad
|
|
11
11
|
* The Message box, Send and End are DISABLED (drawn as the mockup's): no page press route sends a prompt to a session, and none is invented here.
|
|
12
|
-
* Icon language (c3-terminal-icons.js): the header's AGENT button (spark) opens the Terminal page, the full agent surfaces; the TERMINAL glyph (`>_`) is the
|
|
13
|
-
*
|
|
12
|
+
* Icon language (c3-terminal-icons.js): the header's AGENT button (spark) opens the Terminal page, the full agent surfaces; the TERMINAL glyph (`>_`) is every "Open the session" link.
|
|
13
|
+
* A folded dock leaves no rail (PM, 2026-10-12): Ctrl J and the More menu open it again. */
|
|
14
14
|
import { el } from './dom.js';
|
|
15
15
|
import { pmGet, pmPost } from './http.js';
|
|
16
16
|
import { c3ic } from './pm-work-view.js';
|
|
@@ -53,12 +53,11 @@ export function createDock(opts) {
|
|
|
53
53
|
el('a', { class: 'ib sm', href: '/', title: 'Start a branch and session in the main app. A session starts over the terminal connection; the page has no route for it.', 'aria-label': 'Start a branch and session in the main app' }, c3ic('plus', 'sm')),
|
|
54
54
|
el('button', { type: 'button', class: 'ib sm', title: 'Fold the dock (Ctrl J)', 'aria-label': 'Fold the dock', onclick: () => o.setOpen && o.setOpen(false) }, c3ic('fold'))),
|
|
55
55
|
body);
|
|
56
|
-
const rail = el('div', { class: 'c3dock dock rail', 'data-c3pg': 'dock-rail' });
|
|
57
56
|
|
|
58
57
|
const withEnd = (s) => (S.ended.has(s.id) ? { ...s, shown: 'exited', label: 'ended' } : s);
|
|
59
58
|
const mine = () => S.sessions.filter((s) => s.ws === S.ws && !s.automatic).map(withEnd);
|
|
60
59
|
const inTree = (dir) => mine().filter((s) => s.tree === dir).sort((a, b) => (a.shown === 'exited') - (b.shown === 'exited') || String(b.created).localeCompare(String(a.created)));
|
|
61
|
-
const pick = (id) => { S.sel = id; S.selTree = null; S.confirm = null; drawTree(); drawDetail();
|
|
60
|
+
const pick = (id) => { S.sel = id; S.selTree = null; S.confirm = null; drawTree(); drawDetail(); if (o.setOpen) o.setOpen(true); };
|
|
62
61
|
|
|
63
62
|
function row(s) {
|
|
64
63
|
const g = smOf(s);
|
|
@@ -129,15 +128,9 @@ export function createDock(opts) {
|
|
|
129
128
|
const r = await pmPost('/api/session/remove', { id: s.id });
|
|
130
129
|
if (r && r.ok) { S.ended.add(s.id); toast({ text: r.removed === false ? 'Asked it to end. Press End again to remove it.' : 'Ended. Any pending change stays a proposal.', timeout: 4200 }); }
|
|
131
130
|
else toast({ text: (r && r.error) ? 'Not ended: ' + r.error : NOEND, tone: 'error' });
|
|
132
|
-
drawTree(); drawDetail();
|
|
131
|
+
drawTree(); drawDetail(); refresh();
|
|
133
132
|
}
|
|
134
133
|
|
|
135
|
-
function drawRail() {
|
|
136
|
-
const all = S.sessions.filter((s) => !s.automatic); // the mockup's rail lists the first six sessions of every repository
|
|
137
|
-
const dots = all.slice(0, 6).map((s) => el('button', { type: 'button', class: 'ib sm', 'data-c3-rail': s.id, title: s.name + ' \u00b7 ' + (s.label || s.shown), 'aria-label': 'Show the session ' + s.name + ' in the dock', onclick: () => pick(s.id) }, el('span', { class: 'mk ' + smOf(s)[1] }, smOf(s)[0])));
|
|
138
|
-
/* the TERMINAL glyph: this button opens the dock (the terminals of your branches). The needs-you badge is on the agent button, not here. */
|
|
139
|
-
rail.replaceChildren(el('button', { type: 'button', class: 'ib', title: TIPS.terminalRail, 'aria-label': 'Open the sessions dock', 'data-c3pg': 'rail-open', onclick: () => o.setOpen && o.setOpen(true) }, ic('term')), ...dots);
|
|
140
|
-
}
|
|
141
134
|
|
|
142
135
|
async function refresh() {
|
|
143
136
|
const seq = ++S.seq; const ws = S.ws;
|
|
@@ -148,7 +141,7 @@ export function createDock(opts) {
|
|
|
148
141
|
if (wt && wt.ok) { S.trees = wt.worktrees || []; S.err = null; } else if (!S.trees) S.trees = [];
|
|
149
142
|
const m = mine();
|
|
150
143
|
if (!m.some((x) => x.id === S.sel)) S.sel = (m.find((x) => x.shown !== 'exited') || m[0] || {}).id || null;
|
|
151
|
-
drawTree();
|
|
144
|
+
drawTree();
|
|
152
145
|
const cur = detail.getAttribute('data-sel');
|
|
153
146
|
if (cur !== String(S.sel)) { detail.setAttribute('data-sel', String(S.sel)); drawDetail(); }
|
|
154
147
|
if (o.onCount) { const all = S.sessions.filter((x) => !x.automatic); o.onCount(all.filter((x) => x.shown === 'working').length, S.need); }
|
|
@@ -157,7 +150,7 @@ export function createDock(opts) {
|
|
|
157
150
|
/* the mockup's keys: Ctrl or Cmd J folds and opens the dock (title "Open sessions (Ctrl J)"); the arrows move along the tree */
|
|
158
151
|
const onKey = (e) => {
|
|
159
152
|
if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && (e.key === 'j' || e.key === 'J')) {
|
|
160
|
-
if (!side.isConnected
|
|
153
|
+
if (!side.isConnected) return;
|
|
161
154
|
e.preventDefault(); if (o.setOpen) o.setOpen(side.hidden); return;
|
|
162
155
|
}
|
|
163
156
|
if ((e.key === 'ArrowDown' || e.key === 'ArrowUp') && e.target.closest && e.target.closest('.dk-tree')) {
|
|
@@ -167,13 +160,13 @@ export function createDock(opts) {
|
|
|
167
160
|
};
|
|
168
161
|
let timer = null;
|
|
169
162
|
const api = {
|
|
170
|
-
side,
|
|
163
|
+
side,
|
|
171
164
|
setWorkspaces(list, ws) {
|
|
172
165
|
S.ws = ws;
|
|
173
166
|
sel.replaceChildren(...(list.length ? list : [{ id: ws, name: ws || 'workspace' }]).map((w) => el('option', { value: w.id, selected: w.id === ws }, w.name || w.id)));
|
|
174
167
|
sel.onchange = (e) => { S.ws = e.target.value; S.trees = null; S.sel = null; detail.removeAttribute('data-sel'); if (o.onWs) o.onWs(S.ws); drawTree(); refresh(); };
|
|
175
168
|
},
|
|
176
|
-
start() { document.addEventListener('keydown', onKey); drawTree();
|
|
169
|
+
start() { document.addEventListener('keydown', onKey); drawTree(); refresh(); timer = setInterval(refresh, 15000); if (source().subscribe) S.unsub = source().subscribe(refresh); },
|
|
177
170
|
stop() { if (timer) clearInterval(timer); if (S.unsub) S.unsub(); document.removeEventListener('keydown', onKey); },
|
|
178
171
|
refresh,
|
|
179
172
|
};
|
|
@@ -58,7 +58,8 @@ export async function badge(ctx) {
|
|
|
58
58
|
function openDoc(V, d, ask) {
|
|
59
59
|
if (!d || d.st === 'missing' || d.node) return;
|
|
60
60
|
const ui = openDocSheet({ ws: V.ctx.ws, doc: { path: d.path, name: d.name, ty: d.ty, v: d.v, st: d.st, ini: d.ini, iniName: iniName(V, d.ini) }, mode: ask ? 'ask' : 'read', prompt: ask || '',
|
|
61
|
-
relations: (doc, openOther) => relationsOf(V, d, openOther),
|
|
61
|
+
relations: (doc, openOther) => relationsOf(V, d, openOther), library: () => V.lib, openGraph: () => { V.view = 'graph'; V.sel = d.i; V.render(); },
|
|
62
|
+
onChanged: () => { delete V.health[d.path]; V.asking = false; load(V); } });
|
|
62
63
|
ui.setOpener((o) => openDoc(V, o));
|
|
63
64
|
}
|
|
64
65
|
const iniName = (V, id) => { const i = V.lib && V.lib.initiatives && V.lib.initiatives[id]; return i ? i.short || i.title : id || ''; };
|
|
@@ -30,7 +30,9 @@ export async function loadDocs(ctx, idSet) {
|
|
|
30
30
|
async function openDoc(ctx, d, titles) {
|
|
31
31
|
try {
|
|
32
32
|
const { openDocSheet } = await import('./c3-doc-sheet.js');
|
|
33
|
-
openDocSheet({ ws: ctx.ws, doc: { path: d.path, name: d.name, ty: d.ty, v: '', st: d.st, ini: d.ini, iniName: titles.get(d.ini) || d.ini }
|
|
33
|
+
const ui = openDocSheet({ ws: ctx.ws, doc: { path: d.path, name: d.name, ty: d.ty, v: '', st: d.st, ini: d.ini, iniName: titles.get(d.ini) || d.ini },
|
|
34
|
+
library: () => ctx.api.get('/api/docs/library', { board: ctx.board.id }) });
|
|
35
|
+
ui.setOpener((o) => openDoc(ctx, { path: o.path, name: o.name, ty: o.ty, st: o.st, ini: o.ini }, titles));
|
|
34
36
|
} catch { ctx.toast('The document sheet could not open. The file is ' + d.path + '.'); }
|
|
35
37
|
}
|
|
36
38
|
|
|
@@ -102,8 +102,8 @@ function header() {
|
|
|
102
102
|
S.ttl = el('h1', { 'data-c3pg': 'title' });
|
|
103
103
|
S.sub = el('div', { class: 'sub', 'data-c3pg': 'date' }, dateLine());
|
|
104
104
|
S.termBdg = el('span', { class: 'bdg', hidden: true, 'data-c3pg': 'term-count' }, '0');
|
|
105
|
-
/* the AGENT button (the bolt glyph, as the mockup's Sessions pill): the Terminal page, the full agent surfaces, with the needs-you badge.
|
|
106
|
-
S.termBtn = button({ variant: 'ghost', label: [ic('bolt', 'sm'),
|
|
105
|
+
/* the AGENT button (the bolt glyph, as the mockup's Sessions pill): the Terminal page, the full agent surfaces, with the needs-you badge. It is an icon only (PM, 2026-10-12: no "Sessions" label); its tooltip and aria-label name it. Ctrl J and the More menu fold and open the dock. */
|
|
106
|
+
S.termBtn = button({ variant: 'ghost', label: [ic('bolt', 'sm'), S.termBdg], onClick: () => { import('./c3-terminal.js').then((m) => m.openTerminalPage()); },
|
|
107
107
|
attrs: { class: 'psess', title: 'Agent surfaces: sessions, results to accept, questions for you', 'aria-label': 'Agent surfaces: sessions, results to accept, questions for you', 'aria-haspopup': 'dialog', 'data-c3pg': 'agent-button' } }); // the mockup's header button (agent-surfaces.html #act)
|
|
108
108
|
S.tools = el('div', { class: 'tools', 'data-c3pg': 'tools' });
|
|
109
109
|
if (S.demoMod) S.tools.append(S.demoMod.demoPill());
|
|
@@ -423,15 +423,14 @@ function dock() {
|
|
|
423
423
|
/* the needs-you badge on the agent button: runs that ask, hold a permission or need the terminal (not the running ones) */
|
|
424
424
|
onCount: (live, need) => { S.termBdg.textContent = String(need); S.termBdg.hidden = need === 0; import('./c3-terminal-icons.js').then((m) => { const t = m.TIPS.agentHeader(need); S.termBtn.title = t; S.termBtn.setAttribute('aria-label', t); }); }, // the badge counts runs that need you, never running ones
|
|
425
425
|
});
|
|
426
|
-
S.dockEl = S.dock.side;
|
|
426
|
+
S.dockEl = S.dock.side;
|
|
427
427
|
// a session opens in its own terminal sheet on this page (c3-session-term.js), not in the main app
|
|
428
428
|
window.addEventListener('pmos:open-session', (e) => { const id = e.detail && e.detail.id; if (!id) { location.assign('/'); return; } import('./c3-session-term.js').then((m) => m.openSessionTerm({ id, name: e.detail.name })); });
|
|
429
|
-
return [S.dock.side
|
|
429
|
+
return [S.dock.side];
|
|
430
430
|
}
|
|
431
|
-
/* The
|
|
431
|
+
/* The dock is open on the Briefing and folded away on the other tabs. A folded dock leaves no rail (PM, 2026-10-12): the header's Sessions button, Ctrl J and the More menu reach it. */
|
|
432
432
|
function syncDock() {
|
|
433
433
|
S.dockEl.hidden = !S.dockOpen;
|
|
434
|
-
S.rail.hidden = S.dockOpen || !S.wide;
|
|
435
434
|
}
|
|
436
435
|
|
|
437
436
|
/* ── the other tabs ── */
|