create-pmos 0.150.0 → 0.152.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/ask-the-agent.md +5 -5
- package/workspace/guide/console-calendar.md +1 -1
- package/workspace/guide/document-sheet.md +58 -0
- package/workspace/guide/index.md +1 -0
- package/workspace/lib/engine/doc-health.js +4 -2
- package/workspace/lib/engine/doc-sheet.js +49 -1
- package/workspace/lib/notes.js +31 -6
- package/workspace/lib/routes-doc-health.js +15 -4
- package/workspace/lib/routes-doc-sheet.js +154 -13
- package/workspace/lib/routes-notes.js +4 -4
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/c3-demo-boards.js +6 -1
- package/workspace/public/pm/c3-demo-init.js +44 -3
- package/workspace/public/pm/c3-demo-rep2.js +27 -6
- package/workspace/public/pm/c3-doc-sheet.js +586 -184
- package/workspace/public/pm/c3-md.js +395 -0
- package/workspace/public/pm/c3-notes.js +35 -16
- package/workspace/public/pm/console3.css +169 -0
- package/workspace/public/pm/dest-notes.js +1 -0
- package/workspace/public/pm/pm-work-view.js +1 -1
- package/workspace/public/pm/tokens.css +2 -2
- package/workspace/test-lists/c3-doc-sheet-rich.json +6 -0
|
@@ -1,24 +1,38 @@
|
|
|
1
|
-
/* pm/c3-doc-sheet.js — the DOCUMENT SHEET of /c3.html Initiatives
|
|
2
|
-
*
|
|
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
|
+
* 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 }) doc = { path, name, ty, v, st, ini }
|
|
4
|
+
* openDocSheet({ ws, doc, mode, prompt, relations(doc, openOther), onChanged, generated, secExtra }) doc = { path, name, ty, v, st, ini, iniName }
|
|
5
5
|
*
|
|
6
|
-
* Six modes (the segment under the header
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
6
|
+
* Six modes (the segment under the header; the keys are kept for the callers):
|
|
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, a Contents menu, the stale banner, and the
|
|
9
|
+
* open proposals pinned to the sections they change, with "Show change" for the diff in place.
|
|
10
|
+
* edit Edit a rich-text editor of the whole document (toolbar, Rich | Markdown over the same draft), the front matter as fields; a
|
|
11
|
+
* changed section is marked; saving needs a reason (POST /api/doc-sheet/edit). A section nobody touched is written back
|
|
12
|
+
* exactly as it was, so opening and saving without a change writes nothing.
|
|
13
|
+
* ask Proposals Ask the agent (POST /api/doc-sheet/ask: a background run that may only read; its answer becomes a proposal), the open
|
|
14
|
+
* proposals (GET /api/doc-sheet/proposals) with a tick per section, a rendered diff and the slide that accepts the ticked
|
|
15
|
+
* sections (POST /api/doc-sheet/accept with `sections`), Reject with a reason (POST /api/doc-sheet/reject), and the closed ones.
|
|
16
|
+
* hist Edit log every revision and event (GET /api/doc-sheet/log): who wrote the text, how it came in (this sheet, a proposal, outside
|
|
17
|
+
* PMOS with its commit), the reason, the sections, the words added and removed, Show the change, and a reason for an outside
|
|
18
|
+
* change that has none (POST /api/doc-sheet/why).
|
|
19
|
+
* 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
|
+
* current" records a reason (POST /api/doc-sheet/mark-current).
|
|
21
|
+
* links Relations the document graph around this document, from the caller.
|
|
22
|
+
* Nothing is invented: a part with no data says so. The demo (?demo=1) answers the routes from fixtures.
|
|
23
|
+
*
|
|
24
|
+
* Two options for a report, which is a document with no stored file yet (report-families-view.js):
|
|
25
|
+
* generated = { text, why, health } Read shows the generated text; Edit and Proposals say why they need a stored document; Health shows `health`.
|
|
26
|
+
* 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. */
|
|
15
28
|
import { el } from './dom.js';
|
|
16
29
|
import { pmGet, pmPost } from './http.js';
|
|
17
|
-
import { c3ic
|
|
30
|
+
import { c3ic } from './pm-work-view.js';
|
|
18
31
|
import { openSheet } from './c3-sheet.js';
|
|
19
|
-
import { renderCase } from './c3-case.js';
|
|
20
32
|
import { say } from './c3-init-extras.js';
|
|
21
33
|
import { c3btn } from './page-agent.js';
|
|
34
|
+
import { mountSlide } from './slide-confirm.js';
|
|
35
|
+
import { parseMd, toMd, canonMd, diffMd, toDom, fromDom, wordCounts, safeHref } from './c3-md.js';
|
|
22
36
|
|
|
23
37
|
/* ── pure: sections, round trip, word diff ── */
|
|
24
38
|
|
|
@@ -49,12 +63,12 @@ export function joinDoc(secs, edits) {
|
|
|
49
63
|
/** The sections of `after` that differ from `before`, by name: [{ name, before, after, kind: 'changed'|'added'|'removed' }]. */
|
|
50
64
|
export function changedSections(before, after) {
|
|
51
65
|
const A = splitDoc(before); const B = splitDoc(after); const out = [];
|
|
52
|
-
B.forEach((s) => { const p = A.find((q) => q.name === s.name); if (!p) out.push({ name: s.name, before: '', after: s.text, kind: 'added' }); else if (p.text !== s.text) out.push({ name: s.name, before: p.text, after: s.text, kind: 'changed' }); });
|
|
66
|
+
B.forEach((s) => { const p = A.find((q) => q.name === s.name); if (!p) out.push({ name: s.name, before: '', after: s.text, kind: 'added' }); else if (p.text !== s.text || p.prefix !== s.prefix) out.push({ name: s.name, before: p.text, after: s.text, kind: 'changed' }); });
|
|
53
67
|
A.forEach((q) => { if (!B.some((s) => s.name === q.name)) out.push({ name: q.name, before: q.text, after: '', kind: 'removed' }); });
|
|
54
68
|
return out;
|
|
55
69
|
}
|
|
56
70
|
|
|
57
|
-
/** The word diff
|
|
71
|
+
/** The word diff: [{ op: 'same'|'add'|'del', text }], whitespace kept in the tokens. */
|
|
58
72
|
export function wordDiffTokens(a, b) {
|
|
59
73
|
const x = String(a || '').split(/(\s+)/).filter((s) => s !== ''); const y = String(b || '').split(/(\s+)/).filter((s) => s !== '');
|
|
60
74
|
const n = x.length; const m = y.length;
|
|
@@ -70,7 +84,7 @@ export function wordDiffTokens(a, b) {
|
|
|
70
84
|
}
|
|
71
85
|
|
|
72
86
|
/** Who a ledger `by` is, as the page says it. */
|
|
73
|
-
export const byName = (by) => { const b = String(by || ''); return /^(agent|claude)/i.test(b) ? 'Agent' : /^(pm|human:wawan|you)$/i.test(b) || b === '' ? 'You' : b.replace(/^human:/, ''); };
|
|
87
|
+
export const byName = (by) => { const b = String(by || ''); return /^(agent|claude)/i.test(b) ? 'Agent' : /^(pm|human:wawan|human:pm|you)$/i.test(b) || b === '' ? 'You' : b.replace(/^human:/, ''); };
|
|
74
88
|
const WD = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; const MON = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
75
89
|
/** '2026-10-05T09:00:00Z' to 'Mon 5 Oct'. Anything that is not a date comes back as it is. */
|
|
76
90
|
export function dayName(v) {
|
|
@@ -78,60 +92,115 @@ export function dayName(v) {
|
|
|
78
92
|
const d = new Date(Date.UTC(+m[1], +m[2] - 1, +m[3]));
|
|
79
93
|
return WD[d.getUTCDay()] + ' ' + (+m[3]) + ' ' + MON[+m[2] - 1];
|
|
80
94
|
}
|
|
81
|
-
const
|
|
95
|
+
const timeOf = (v) => { const m = /T(\d{2}):(\d{2})/.exec(String(v || '')); return m ? m[1] + ':' + m[2] : ''; };
|
|
96
|
+
const whenOf = (v) => dayName(v) + (timeOf(v) ? ', ' + timeOf(v) : '');
|
|
97
|
+
const nextV = (v) => { const m = /^v(\d+)$/.exec(v || ''); return m ? 'v' + (+m[1] + 1) : ''; };
|
|
98
|
+
|
|
99
|
+
/** The front matter of a text: { block, rest, fields: [{ key, value, line, editable, quote }] } or null. `block` is the `---` … `---` lines
|
|
100
|
+
* without the newline after them; `rest` is everything after. A field is editable when it is one line with a plain value. */
|
|
101
|
+
export function parseFront(text) {
|
|
102
|
+
const t = String(text == null ? '' : text);
|
|
103
|
+
const m = /^---\n([\s\S]*?)\n---(?=\n|$)/.exec(t);
|
|
104
|
+
if (!m) return null;
|
|
105
|
+
const fields = []; const lines = m[1].split('\n');
|
|
106
|
+
lines.forEach((l, line) => {
|
|
107
|
+
const f = /^([A-Za-z_][\w.-]*):[ \t]*(.*)$/.exec(l);
|
|
108
|
+
if (f) {
|
|
109
|
+
const raw = f[2]; const q = /^"(.*)"\s*$/.exec(raw) || /^'(.*)'\s*$/.exec(raw);
|
|
110
|
+
const multi = raw === '' || /^[|>]/.test(raw);
|
|
111
|
+
fields.push({ key: f[1], value: q ? (raw[0] === '"' ? q[1].replace(/\\"/g, '"').replace(/\\\\/g, '\\') : q[1].replace(/''/g, "'")) : raw, line, editable: !multi && !/^[[{]/.test(raw), quote: q ? raw[0] : '' });
|
|
112
|
+
} else if (fields.length && /^\s+(-\s+)?\S/.test(l)) {
|
|
113
|
+
const last = fields[fields.length - 1];
|
|
114
|
+
last.value = (last.value ? last.value + ', ' : '') + l.trim().replace(/^-\s+/, '');
|
|
115
|
+
last.editable = false;
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
return { block: m[0], rest: t.slice(m[0].length), fields };
|
|
119
|
+
}
|
|
120
|
+
/** The front matter block with `values` ({ key: value }) written into its editable fields; every other line stays as it was. */
|
|
121
|
+
export function setFront(block, values) {
|
|
122
|
+
const p = parseFront(block); if (!p) return block;
|
|
123
|
+
const inner = block.replace(/^---\n/, '').replace(/\n---$/, '').split('\n');
|
|
124
|
+
for (const f of p.fields) {
|
|
125
|
+
if (!f.editable || !Object.prototype.hasOwnProperty.call(values, f.key) || values[f.key] === f.value) continue;
|
|
126
|
+
const v = String(values[f.key]);
|
|
127
|
+
const needs = /^[\s"'#&*!|>%@`[{-]|:\s|\s#|\s$/.test(v) || v === '';
|
|
128
|
+
const out = f.quote === "'" ? "'" + v.replace(/'/g, "''") + "'" : f.quote === '"' || needs ? JSON.stringify(v) : v;
|
|
129
|
+
inner[f.line] = f.key + ': ' + out;
|
|
130
|
+
}
|
|
131
|
+
return '---\n' + inner.join('\n') + '\n---';
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The new text of a document from the editor's sections. `base` is splitDoc of the text the editor was drawn from; each group is
|
|
135
|
+
* { sid, level, name, md } where sid is the index of the base section it was drawn from (or null for a new one) and md is the markdown
|
|
136
|
+
* the editor holds now. `intro` is the markdown before the first heading, `front` the front matter block (or null).
|
|
137
|
+
* A section whose heading and canonical text did not change is written back exactly as it was. */
|
|
138
|
+
export function composeSections(base, groups, intro, front) {
|
|
139
|
+
const levelOf = (s) => (/^(#+)/.exec(s.prefix || '') || ['', ''])[1].length;
|
|
140
|
+
const out = [];
|
|
141
|
+
const b0 = base.find((s) => s.prefix === '');
|
|
142
|
+
if (b0 || intro || front) {
|
|
143
|
+
const fm = b0 ? parseFront(b0.text) : null;
|
|
144
|
+
const restOrig = fm ? fm.rest : b0 ? b0.text : '';
|
|
145
|
+
const same = canonMd(restOrig) === canonMd(intro || '');
|
|
146
|
+
const body = same ? restOrig : (intro ? (front ? '\n\n' : '') + intro : '');
|
|
147
|
+
const text = (front || '') + body;
|
|
148
|
+
if (text || (b0 && (b0.lead || b0.tail))) out.push({ prefix: '', lead: b0 ? b0.lead : '', text, tail: b0 ? b0.tail : '\n\n' });
|
|
149
|
+
}
|
|
150
|
+
const used = new Set();
|
|
151
|
+
for (const g of groups) {
|
|
152
|
+
const B = g.sid != null && !used.has(g.sid) ? base[g.sid] : null;
|
|
153
|
+
if (B) used.add(g.sid);
|
|
154
|
+
const sameHead = B && B.name === g.name && levelOf(B) === g.level;
|
|
155
|
+
const sameText = B && canonMd(B.text) === canonMd(g.md);
|
|
156
|
+
out.push({ bi: B ? g.sid : null, prefix: sameHead ? B.prefix : '#'.repeat(g.level || 2) + ' ' + g.name + '\n', lead: B ? B.lead : (g.md ? '\n' : ''), text: sameText ? B.text : g.md, tail: B ? B.tail : '\n\n' });
|
|
157
|
+
}
|
|
158
|
+
if (out.length && out[0].prefix === '' && b0) out[0].bi = b0.i;
|
|
159
|
+
// where two parts were not next to each other before, the first ends in a blank line; a new last part ends in one newline
|
|
160
|
+
out.forEach((p, k) => {
|
|
161
|
+
const s = p.prefix + p.lead + p.text + p.tail;
|
|
162
|
+
if (k < out.length - 1) {
|
|
163
|
+
if (p.bi != null && out[k + 1].bi === p.bi + 1) return;
|
|
164
|
+
if (!/\n[ \t]*\n$/.test(s)) p.tail = p.tail.replace(/[ \t]*$/, '') + (/\n$/.test(s) ? '\n' : '\n\n');
|
|
165
|
+
} else if (p.bi !== base.length - 1 || p.bi == null) p.tail = p.tail.replace(/\s*$/, '') + '\n';
|
|
166
|
+
});
|
|
167
|
+
return out.map((p) => p.prefix + p.lead + p.text + p.tail).join('');
|
|
168
|
+
}
|
|
82
169
|
|
|
83
170
|
/* ── the sheet ── */
|
|
84
171
|
|
|
85
172
|
const NAMES = { fresh: 'Fresh', decisions: 'Matches decisions', data: 'Matches the data', complete: 'Complete', traced: 'Traced to a goal and tickets', readers: 'Readers have this version' };
|
|
86
|
-
const
|
|
87
|
-
const
|
|
88
|
-
const
|
|
173
|
+
const STATE = { current: ['Current', 'ds-good', 'check'], stale: ['Stale', 'ds-warn', 'alert'], draft: ['Draft', 'ds-pe', 'clock'], missing: ['Missing', 'ds-bad', 'plus'] };
|
|
174
|
+
const TABS = [['read', 'eye', 'Read'], ['edit', 'edit', 'Edit'], ['ask', 'spark', 'Proposals'], ['hist', 'clock', 'Edit log'], ['health', 'shield', 'Health'], ['links', 'init', 'Relations']];
|
|
175
|
+
const MODE = { proposals: 'ask', props: 'ask', log: 'hist', history: 'hist' };
|
|
89
176
|
const isDemo = () => !!window.__c3demo;
|
|
90
|
-
const q = (o) => Object.entries(o).map(([k, v]) => k + '=' + encodeURIComponent(v)).join('&');
|
|
177
|
+
const q = (o) => Object.entries(o).filter(([, v]) => v != null && v !== '').map(([k, v]) => k + '=' + encodeURIComponent(v)).join('&');
|
|
178
|
+
const plural = (n, w) => n + ' ' + w + (n === 1 ? '' : 's');
|
|
179
|
+
const esc = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
180
|
+
const hcls = (h) => (!h || h.total === 0 ? '' : h.passed === h.total ? 'ds-good' : h.total - h.passed === 1 ? 'ds-warn' : 'ds-bad');
|
|
181
|
+
const isAgentRev = (r) => !!r && (r.text_by ? r.text_by === 'agent' : byName(r.by) === 'Agent');
|
|
182
|
+
const md = (text, extra) => el('div', { class: 'ds-md' + (extra ? ' ' + extra : '') }, toDom(parseMd(text), document));
|
|
183
|
+
const btn = (cls, attrs, kids, onClick) => c3btn(cls, attrs, kids, onClick);
|
|
184
|
+
const headLevel = (s) => (/^(#+)/.exec(s.prefix || '') || ['', ''])[1].length;
|
|
91
185
|
|
|
92
|
-
/** The mockup's agentDraft (line 4077): a deterministic rewrite of the sections for the demo's request box. Real mode has no agent behind the page. */
|
|
93
|
-
function demoDraft(secs, prompt) {
|
|
94
|
-
const p = prompt.toLowerCase(); const out = secs.map((s) => ({ name: s.name, text: s.text })); let did = false;
|
|
95
|
-
if (/flow|scope|cut|three|3/.test(p)) out.forEach((s) => { if (s.name === 'Scope') { s.text = 'Three flows ship together: one-page address entry, wallet pay and guest checkout. Saved-card removal moves to the next release.'; did = true; } if (s.name === 'Risks' && /audit/i.test(s.text)) s.text += ' The cut frees the 21 Oct audit slot.'; });
|
|
96
|
-
if (/growth|forecast|6%|number|assumption/.test(p)) out.forEach((s) => { if (/4%/.test(s.text) && s.name === 'Goal') { s.text = s.text.replace('Growth assumption 4%', 'Growth assumption 6% (Q4 forecast, row 14), pending your confirmation'); did = true; } });
|
|
97
|
-
if (/summar|tl;dr|short/.test(p)) { out.unshift({ name: 'Summary', text: 'One page: raise conversion to 4.0% by fixing the address step, removing saved cards and shipping wallet pay and guest checkout.' }); did = true; }
|
|
98
|
-
if (/leadership|note|change/.test(p)) { out.push({ name: 'Change note', text: 'The next version changes the scope after the 8 Oct decision. Everything else is unchanged.' }); did = true; }
|
|
99
|
-
if (!did) out.push({ name: 'Agent note', text: 'Drafted from your request: ' + prompt.slice(0, 160) });
|
|
100
|
-
return out;
|
|
101
|
-
}
|
|
102
|
-
/** The draft's sections back into the document's text: a changed section is replaced in place, a new Summary goes first, any other new section goes last. */
|
|
103
|
-
const sectionsToText = (before, drafted) => {
|
|
104
|
-
const A = splitDoc(before); const names = new Set(A.map((s) => s.name)); const edits = {};
|
|
105
|
-
A.forEach((s) => { const d = drafted.find((x) => x.name === s.name); if (d && d.text !== s.text) edits[s.i] = d.text; });
|
|
106
|
-
let text = joinDoc(A, edits);
|
|
107
|
-
for (const d of drafted) {
|
|
108
|
-
if (names.has(d.name)) continue;
|
|
109
|
-
if (d.name === 'Summary') text = '## Summary\n' + d.text + '\n\n' + text;
|
|
110
|
-
else text = text.replace(/\s*$/, '\n\n') + '## ' + d.name + '\n' + d.text + '\n';
|
|
111
|
-
}
|
|
112
|
-
return text;
|
|
113
|
-
};
|
|
114
|
-
|
|
115
|
-
/* Two additive options for a report, which is a document in the mockup (console3.html r2doc):
|
|
116
|
-
* generated = { text, why, health } the report has no stored document yet. The sheet shows the generated sections read-only; Edit and Ask say why they
|
|
117
|
-
* need a stored document (why), History says there is none, Health shows `health` ({ passed, total, checks }) as given.
|
|
118
|
-
* secExtra(name) a node under each section in Read: the report's claims and where they come from. */
|
|
119
186
|
export function openDocSheet({ ws, doc, mode = 'read', prompt = '', relations, onChanged, generated, secExtra }) {
|
|
120
|
-
const S = {
|
|
187
|
+
const S = { mode: MODE[mode] || mode, prompt, text: null, err: '', revs: null, texts: {}, health: null, hAsk: false, busy: false, changed: false,
|
|
188
|
+
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 };
|
|
121
190
|
const demo = isDemo();
|
|
122
191
|
const base = { ws, path: doc.path };
|
|
123
192
|
let ui = null;
|
|
124
193
|
|
|
125
|
-
/* data */
|
|
194
|
+
/* ── data ── */
|
|
126
195
|
const loadText = async () => {
|
|
127
196
|
if (generated) { S.text = String(generated.text || ''); S.err = ''; return; }
|
|
128
197
|
try { const r = await fetch('/api/doc?' + q({ ws, path: doc.path })); if (!r.ok) throw new Error((await r.text()) || 'could not read the document'); S.text = await r.text(); S.err = ''; }
|
|
129
198
|
catch (e) { S.text = null; S.err = (e && e.message) || 'could not read the document'; }
|
|
130
199
|
};
|
|
131
200
|
const loadRevs = async () => {
|
|
132
|
-
if (generated) { S.revs = [];
|
|
201
|
+
if (generated) { S.revs = []; return; }
|
|
133
202
|
const j = await pmGet('/api/doc-sheet/revisions?' + q(base)).catch(() => null);
|
|
134
|
-
S.revs = j && j.ok ? j.revisions : [];
|
|
203
|
+
S.revs = j && j.ok ? j.revisions : [];
|
|
135
204
|
};
|
|
136
205
|
const textAt = async (rev) => {
|
|
137
206
|
if (S.texts[rev] != null) return S.texts[rev];
|
|
@@ -139,12 +208,23 @@ export function openDocSheet({ ws, doc, mode = 'read', prompt = '', relations, o
|
|
|
139
208
|
S.texts[rev] = j && j.ok ? j.text : null; return S.texts[rev];
|
|
140
209
|
};
|
|
141
210
|
const loadHealth = async () => {
|
|
142
|
-
S.hAsk = true;
|
|
211
|
+
S.hAsk = true;
|
|
143
212
|
if (generated) { S.health = generated.health || { passed: 0, total: 0, checks: [], err: generated.why }; drawBg(); return; }
|
|
144
|
-
const r = await pmPost('/api/doc-health/check', base);
|
|
213
|
+
const r = await pmPost('/api/doc-health/check', base).catch(() => null);
|
|
145
214
|
S.health = r && r.ok && r.health ? r.health : { passed: 0, total: 0, checks: [], err: (r && r.error) || 'The checks could not run.' }; drawBg();
|
|
146
215
|
};
|
|
147
|
-
|
|
216
|
+
const loadProps = async () => {
|
|
217
|
+
if (generated) { S.props = []; return; }
|
|
218
|
+
const j = await pmGet('/api/doc-sheet/proposals?' + q(base)).catch(() => null);
|
|
219
|
+
S.props = j && j.ok ? j.proposals || [] : []; S.propsErr = j && j.ok ? '' : (j && j.error) || 'The proposals could not be read.';
|
|
220
|
+
};
|
|
221
|
+
const loadLog = async () => {
|
|
222
|
+
if (generated) { S.log = { revisions: [], events: [] }; return; }
|
|
223
|
+
const j = await pmGet('/api/doc-sheet/log?' + q(base)).catch(() => null);
|
|
224
|
+
if (j && j.ok) { S.log = { revisions: j.revisions || [], events: j.events || [] }; S.logErr = ''; }
|
|
225
|
+
else { S.log = { revisions: S.revs || [], events: [] }; S.logErr = j && j.error ? j.error : ''; }
|
|
226
|
+
};
|
|
227
|
+
/* who changed each section last: the text of the last revisions (a few, fetched once) */
|
|
148
228
|
const loadMeta = async () => {
|
|
149
229
|
if (!S.revs || !S.revs.length || S.metaFor === S.revs.length) return;
|
|
150
230
|
S.metaFor = S.revs.length;
|
|
@@ -154,190 +234,512 @@ export function openDocSheet({ ws, doc, mode = 'read', prompt = '', relations, o
|
|
|
154
234
|
rs.forEach((r, i) => { if (texts[i] == null) return; const cur = splitDoc(texts[i]); const prev = i ? splitDoc(texts[i - 1] || '') : []; cur.forEach((s) => { const p = prev.find((x) => x.name === s.name); if (!p || p.text !== s.text) meta[s.name] = r; }); });
|
|
155
235
|
S.meta = meta; drawBg();
|
|
156
236
|
};
|
|
237
|
+
const reloadAll = async () => {
|
|
238
|
+
S.revs = null; S.meta = null; S.metaFor = null; S.texts = {}; S.health = null; S.hAsk = false; S.log = null;
|
|
239
|
+
await loadText(); draw();
|
|
240
|
+
await Promise.all([loadRevs(), loadProps(), loadHealth()]); loadMeta(); if (S.mode === 'hist') { await loadLog(); } draw();
|
|
241
|
+
};
|
|
157
242
|
|
|
158
|
-
/* ── drawing ── */
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
243
|
+
/* ── drawing helpers ── */
|
|
244
|
+
const openProps = () => (S.props || []).filter((p) => p.state === 'proposed');
|
|
245
|
+
const failing = () => (S.health && !S.health.err ? S.health.checks.filter((c) => c.pass === false) : []);
|
|
246
|
+
const fresh = () => (S.health && !S.health.err ? S.health.checks.find((c) => c.id === 'fresh' && c.pass === false) : null);
|
|
247
|
+
const lastRev = () => (S.revs && S.revs.length ? S.revs[S.revs.length - 1] : null);
|
|
248
|
+
const whoOf = (r) => {
|
|
249
|
+
if (!r) return '';
|
|
250
|
+
if (isAgentRev(r)) return 'Agent' + (r.via === 'proposal' || r.by === 'pm' ? ' · accepted by you' : '');
|
|
251
|
+
if (r.git && r.git.author) return r.git.author;
|
|
252
|
+
if (r.via === 'first' || r.via === 'outside' || r.by === 'baseline') return r.via === 'first' ? 'Before the sheet' : 'Outside PMOS';
|
|
253
|
+
return byName(r.by);
|
|
254
|
+
};
|
|
255
|
+
const byChip = (r) => {
|
|
256
|
+
if (!r) return null;
|
|
257
|
+
const ag = isAgentRev(r); const label = whoOf(r);
|
|
258
|
+
return btn('ds-by', { 'data-doc-by': String(r.rev), title: 'Open revision ' + r.rev + ' in the edit log' },
|
|
259
|
+
[el('span', { class: 'ds-dot' + (ag ? ' a' : '') }, ag ? c3ic('spark') : (label[0] || '?').toUpperCase()), label + ' · ' + (r.v || 'rev ' + r.rev) + ' · ' + dayName(r.at)],
|
|
260
|
+
() => { S.flashRev = r.rev; S.logF = 'all'; go('hist'); });
|
|
165
261
|
};
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
262
|
+
const goEl = (sel) => { const n = ui && ui.body.querySelector(sel); if (n) { n.scrollIntoView({ block: 'start' }); n.classList.remove('flash'); void n.offsetWidth; n.classList.add('flash'); } };
|
|
263
|
+
|
|
264
|
+
function head() {
|
|
265
|
+
const st = STATE[fresh() ? 'stale' : doc.st] || STATE.current; const h = S.health; const last = lastRev();
|
|
266
|
+
return el('div', { class: 'chips2 ds-meta', 'data-doc-meta': '' },
|
|
267
|
+
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
|
+
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'),
|
|
270
|
+
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
|
+
}
|
|
272
|
+
function seg() {
|
|
273
|
+
return el('div', { class: 'seg', role: 'group', 'aria-label': 'Document mode' }, TABS.map(([k, ic, l]) => {
|
|
274
|
+
const n = k === 'health' ? failing().length : k === 'ask' ? openProps().length : 0;
|
|
275
|
+
return btn('', { 'aria-pressed': String(S.mode === k), 'aria-label': l, title: l, 'data-dmode': k },
|
|
276
|
+
[c3ic(ic, 'sm'), el('span', { class: 'lbl' }, l), n ? el('span', { class: 'bdg ' + (k === 'ask' ? 'p' : 'w') }, String(n)) : null], () => go(k));
|
|
277
|
+
}));
|
|
278
|
+
}
|
|
279
|
+
function go(m) {
|
|
280
|
+
if (S.mode === 'edit' && m !== 'edit' && S.ed && dirty().length && S.leave !== m) { S.leave = m; draw(); return; }
|
|
281
|
+
if (m !== 'edit') S.ed = null;
|
|
282
|
+
S.leave = null; S.mode = m;
|
|
283
|
+
if (m === 'edit' && !S.ed) startEdit();
|
|
284
|
+
if (m === 'health' && !S.hAsk) loadHealth();
|
|
285
|
+
if (m === 'hist') { S.cmp = null; loadLog().then(() => { draw(); if (S.flashRev != null) { goEl('[data-log-rev="' + S.flashRev + '"]'); S.flashRev = null; } }); }
|
|
286
|
+
if (m === 'ask' && S.props === null) loadProps().then(draw);
|
|
287
|
+
if (m === 'read') loadMeta();
|
|
288
|
+
draw();
|
|
289
|
+
const sb = ui && ui.body.closest('.sb'); if (sb) sb.scrollTop = 0;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/* the stale banner: the Fresh check's sources, the sections that cite them, the open proposal */
|
|
293
|
+
function staleBanner() {
|
|
294
|
+
const f = fresh(); if (!f || generated) return null;
|
|
295
|
+
const secs = S.text ? splitDoc(S.text) : [];
|
|
296
|
+
const srcs = Array.isArray(f.sources) ? f.sources : [];
|
|
297
|
+
const cites = (p) => { const b = String(p).split('/').pop(); return secs.filter((s) => s.name !== 'Front matter' && (s.text.includes(p) || (b && s.text.includes(b)))).map((s) => s.name); };
|
|
298
|
+
const op = openProps();
|
|
299
|
+
if (S.staleWhy !== null) {
|
|
300
|
+
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; } });
|
|
301
|
+
return el('div', { class: 'ds-stale', role: 'note', 'data-doc-stale': '' }, el('div', { class: 't' }, c3ic('alert', 'sm'), 'Mark as still current'),
|
|
302
|
+
el('p', null, 'Say why the document is still right. The reason goes in the edit log, and the Fresh check counts it as a change.'),
|
|
303
|
+
el('div', { class: 'ds-whyadd' }, inp, btn('ds-btn pri', { 'data-doc-markok': '' }, 'Mark current', () => markCurrent()), btn('ds-btn', {}, 'Cancel', () => { S.staleWhy = null; draw(); })));
|
|
304
|
+
}
|
|
305
|
+
return el('div', { class: 'ds-stale', role: 'note', 'data-doc-stale': '' }, el('div', { class: 't' }, c3ic('alert', 'sm'), 'Stale'),
|
|
306
|
+
el('p', null, f.reason + '.' + (op.length ? ' ' + plural(op.length, 'open proposal') + ' may bring it up to date.' : ' Edit the sections they touch, or ask the agent for an update.')),
|
|
307
|
+
srcs.length ? el('ul', null, srcs.map((s) => { const c = cites(s.path); return el('li', { 'data-doc-stalesrc': s.path }, s.path + (s.changed ? ', changed ' + dayName(s.changed) : '') + (c.length ? ' (cited in ' + c.join(', ') + ')' : '')); })) : null,
|
|
308
|
+
el('div', { class: 'ds-acts' }, op.length ? btn('ds-btn ag', {}, [c3ic('spark', 'sm'), 'Review the proposals'], () => go('ask'))
|
|
309
|
+
: btn('ds-btn ag', {}, [c3ic('spark', 'sm'), 'Ask the agent'], () => { S.prompt = 'Bring this document up to date with ' + (srcs.map((s) => s.path).join(', ') || 'its sources'); go('ask'); }),
|
|
310
|
+
btn('ds-btn', { 'data-doc-mark': '' }, 'Mark as still current', () => { S.staleWhy = ''; draw(); const i = ui.body.querySelector('#dstalewhy'); if (i) i.focus(); })));
|
|
311
|
+
}
|
|
312
|
+
async function markCurrent() {
|
|
313
|
+
const w = String(S.staleWhy || '').trim();
|
|
314
|
+
if (!w) { say('Say why it is still current'); const i = ui.body.querySelector('#dstalewhy'); if (i) i.focus(); return; }
|
|
315
|
+
const r = await pmPost('/api/doc-sheet/mark-current', { ...base, why: w }).catch(() => null);
|
|
316
|
+
if (!r || !r.ok) { say((r && r.error) || 'It was not marked.'); return; }
|
|
317
|
+
S.staleWhy = null; S.changed = true; if (doc.st === 'stale') doc.st = 'current'; S.health = null; S.hAsk = false; S.log = null;
|
|
318
|
+
await loadHealth(); draw(); say('Marked as current. The reason is in the edit log.');
|
|
319
|
+
}
|
|
172
320
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
321
|
+
/* ── Read ── */
|
|
322
|
+
function propsBlock(secs, editing) {
|
|
323
|
+
const s0 = secs.find((s) => s.prefix === ''); const fm = s0 && parseFront(s0.text);
|
|
324
|
+
if (!fm || !fm.fields.length) return null;
|
|
325
|
+
if (editing) {
|
|
326
|
+
return el('dl', { class: 'ds-props', 'data-doc-props': 'edit' }, fm.fields.map((f) => [el('dt', null, el('label', { for: 'dsp-' + f.key }, f.key)),
|
|
327
|
+
el('dd', null, f.editable ? el('input', { id: 'dsp-' + f.key, type: 'text', value: S.ed.front[f.key] != null ? S.ed.front[f.key] : f.value, 'data-doc-prop': f.key, onInput: (e) => { S.ed.front[f.key] = e.target.value; mark(); } }) : f.value)]));
|
|
328
|
+
}
|
|
329
|
+
const r = S.meta && S.meta['Front matter'];
|
|
330
|
+
return el('dl', { class: 'ds-props', 'data-doc-props': '' }, fm.fields.map((f) => [el('dt', null, f.key), el('dd', null, f.value)]),
|
|
331
|
+
el('div', { class: 'sub' }, c3ic('doc', 'sm'), 'Front matter', r ? [' · ', byChip(r)] : null));
|
|
332
|
+
}
|
|
333
|
+
function readBody() {
|
|
176
334
|
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.');
|
|
177
|
-
const secs = splitDoc(S.text);
|
|
178
|
-
if (!
|
|
335
|
+
const secs = splitDoc(S.text);
|
|
336
|
+
if (!S.text.trim()) return el('div', { class: 'fold' }, c3ic('moon', 'sm'), 'The document is empty.');
|
|
337
|
+
const named = secs.filter((s) => s.prefix);
|
|
338
|
+
const toc = el('select', { class: 'ds-sel', 'aria-label': 'Contents', 'data-doc-toc': '', onChange: (e) => { const v = e.target.value; e.target.value = ''; if (v !== '') goEl('[data-dsec-i="' + v + '"]'); } },
|
|
339
|
+
el('option', { value: '' }, 'Contents'), named.map((s) => el('option', { value: String(s.i) }, (headLevel(s) > 2 ? ' ' : '') + s.name)));
|
|
340
|
+
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' }, toc, el('span', { class: 'ds-acts' },
|
|
342
|
+
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
|
+
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
|
+
if (S.src) {
|
|
345
|
+
const pre = el('pre', { 'data-doc-source': '' }, el('code', null, S.text));
|
|
346
|
+
const copy = async () => { try { await navigator.clipboard.writeText(S.text); say('Copied the markdown.'); } catch { const s = getSelection(); const rg = document.createRange(); rg.selectNodeContents(pre); s.removeAllRanges(); s.addRange(rg); say('Copying is blocked here. The text is selected.'); } };
|
|
347
|
+
return el('div', { class: 'ds' }, bar, el('div', { class: 'ds-acts' }, btn('ds-btn', { 'data-doc-copysrc': '' }, [c3ic('copy', 'sm'), 'Copy the markdown'], copy), el('span', { class: 'ds-mono' }, doc.path)), el('div', { class: 'ds-md' }, pre));
|
|
348
|
+
}
|
|
349
|
+
const meta = S.meta || {};
|
|
350
|
+
const pins = (name) => openProps().filter((p) => p.sections.some((c) => c.name === name && c.kind !== 'added'));
|
|
351
|
+
const flags = {}; failing().forEach((c) => { if (c.section && c.flag) flags[c.section] = c.flag; });
|
|
179
352
|
const cells = secs.map((s) => {
|
|
180
|
-
const
|
|
181
|
-
const
|
|
182
|
-
if (
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
353
|
+
const fm = s.prefix === '' ? parseFront(s.text) : null;
|
|
354
|
+
const body = fm ? fm.rest.trim() : s.text;
|
|
355
|
+
if (!s.prefix && !body) return null;
|
|
356
|
+
const r = meta[s.name]; const P = pins(s.name);
|
|
357
|
+
const shown = P.find((p) => S.preview[p.id]);
|
|
358
|
+
const ch = shown && shown.sections.find((c) => c.name === s.name);
|
|
359
|
+
const content = ch ? md(diffMd(ch.stale ? s.text : ch.before, ch.after), 'ds-body') : body ? md(body, 'ds-body') : headLevel(s) === 1 ? null : el('p', { class: 'ds-note' }, 'This section is empty.');
|
|
360
|
+
const lvl = Math.min(3, Math.max(2, headLevel(s)));
|
|
361
|
+
return el('div', { class: 'ds-sec ' + (r ? (isAgentRev(r) ? 'a' : 'h') : '') + (ch ? ' prev' : ''), 'data-dsec': s.name, 'data-dsec-i': String(s.i) },
|
|
362
|
+
s.prefix ? el('div', { class: 'ds-sec-h' }, headLevel(s) === 1 ? el('div', { class: 'ds-md' }, el('h1', null, s.name)) : el('h' + lvl, null, s.name), generated ? null : byChip(r)) : null,
|
|
363
|
+
P.map((p) => { const c = p.sections.find((x) => x.name === s.name); return el('div', { class: 'ds-pin', 'data-doc-pin': p.id }, el('span', null, c3ic('spark', 'sm'), (c.stale ? 'A proposal was drafted against older text here' : 'The agent proposes a change here') + ': “' + short(p.prompt) + '”'),
|
|
364
|
+
el('span', { class: 'ds-acts' }, btn('ds-btn', { 'data-doc-preview': p.id }, S.preview[p.id] ? 'Show current' : 'Show change', () => { S.preview[p.id] = !S.preview[p.id]; draw(); }),
|
|
365
|
+
btn('ds-btn ag', {}, 'Review', () => { S.openProp = p.id; go('ask'); }))); }),
|
|
366
|
+
flags[s.name] ? el('div', { class: 'fold w', role: 'note' }, c3ic('alert', 'sm'), flags[s.name]) : null,
|
|
367
|
+
content, secExtra ? secExtra(s.name) : null);
|
|
187
368
|
});
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
return el('div', { class: '
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
};
|
|
369
|
+
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
|
+
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
|
+
return el('div', { class: 'ds' + (S.who ? ' who' : '') }, bar, legend, generated ? el('div', { class: 'fold', role: 'note', 'data-doc-generated': '' }, c3ic('info', 'sm'), generated.why) : null,
|
|
372
|
+
staleBanner(), propsBlock(secs, false), cells, adds);
|
|
373
|
+
}
|
|
374
|
+
const short = (t) => { const s = String(t || '').replace(/\s+/g, ' ').trim(); return s.length > 90 ? s.slice(0, 88) + '…' : s; };
|
|
194
375
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
376
|
+
/* ── Edit ── */
|
|
377
|
+
function startEdit() {
|
|
378
|
+
if (generated || S.text == null) { S.ed = null; return; }
|
|
379
|
+
S.ed = { text: S.text, base: splitDoc(S.text), front: {}, kind: 'rich', why: '', whyBad: false, node: null, ta: null, range: null, link: false };
|
|
380
|
+
}
|
|
381
|
+
function edNode() {
|
|
382
|
+
const E = S.ed; const kids = [];
|
|
383
|
+
E.base.forEach((s, i) => {
|
|
384
|
+
if (s.prefix === '') { const fm = parseFront(s.text); const rest = fm ? fm.rest.trim() : s.text; if (rest) kids.push(...toDom(parseMd(rest), document, { editable: true })); return; }
|
|
385
|
+
const h = el('h' + Math.min(6, Math.max(1, headLevel(s))), { 'data-sid': String(i) }, s.name);
|
|
386
|
+
kids.push(h, ...toDom(parseMd(s.text), document, { editable: true }));
|
|
387
|
+
});
|
|
388
|
+
const n = el('div', { class: 'ds-md ds-ed', id: 'ded', contenteditable: 'true', role: 'textbox', 'aria-multiline': 'true', 'aria-label': 'Document', spellcheck: 'true', 'data-doc-editor': '',
|
|
389
|
+
onInput: () => { clearTimeout(E.t); E.t = setTimeout(mark, 200); }, onChange: () => mark(),
|
|
390
|
+
onKeyDown: (e) => { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { e.preventDefault(); linkOpen(); } if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); save(); } } }, kids);
|
|
391
|
+
n.querySelectorAll('input[type=checkbox]').forEach((c) => { c.disabled = false; });
|
|
392
|
+
return n;
|
|
393
|
+
}
|
|
394
|
+
/** The draft as text, from whichever editor is open. */
|
|
395
|
+
function draftText() {
|
|
396
|
+
const E = S.ed; if (!E) return S.text;
|
|
397
|
+
const s0 = E.base.find((s) => s.prefix === ''); const fm = s0 && parseFront(s0.text);
|
|
398
|
+
const front = fm ? setFront(fm.block, E.front) : null;
|
|
399
|
+
if (E.kind === 'md') {
|
|
400
|
+
let t = E.ta ? E.ta.value : E.text;
|
|
401
|
+
if (fm && front !== fm.block && t.startsWith(fm.block)) t = front + t.slice(fm.block.length);
|
|
402
|
+
return t;
|
|
403
|
+
}
|
|
404
|
+
if (!E.node) return E.text;
|
|
405
|
+
const groups = []; let intro = []; let cur = null;
|
|
406
|
+
for (const n of Array.from(E.node.childNodes)) {
|
|
407
|
+
if (n.nodeType === 1 && /^H[1-6]$/.test(n.tagName)) { cur = { sid: n.hasAttribute('data-sid') ? Number(n.getAttribute('data-sid')) : null, level: Number(n.tagName[1]), name: n.textContent.replace(/\s+/g, ' ').trim() || 'Untitled section', nodes: [] }; groups.push(cur); continue; }
|
|
408
|
+
(cur ? cur.nodes : intro).push(n);
|
|
409
|
+
}
|
|
410
|
+
const mdOf = (nodes) => { const d = document.createElement('div'); nodes.forEach((x) => d.appendChild(x.cloneNode(true))); return toMd(fromDom(d)); };
|
|
411
|
+
return composeSections(E.base, groups.map((g) => ({ sid: g.sid, level: g.level, name: g.name, md: mdOf(g.nodes) })), mdOf(intro), front);
|
|
412
|
+
}
|
|
413
|
+
const dirty = () => (S.ed ? changedSections(S.text, draftText()) : []);
|
|
414
|
+
const clashOf = (name) => openProps().find((p) => p.sections.some((c) => c.name === name));
|
|
415
|
+
function mark() {
|
|
416
|
+
const E = S.ed; if (!E) return;
|
|
417
|
+
const ch = dirty(); const names = new Set(ch.map((c) => c.name));
|
|
418
|
+
if (E.node) E.node.querySelectorAll(':scope > h1,:scope > h2,:scope > h3,:scope > h4,:scope > h5,:scope > h6').forEach((h) => {
|
|
419
|
+
const nm = h.textContent.replace(/\s+/g, ' ').trim(); const on = !h.hasAttribute('data-sid') || names.has(nm) || names.has(E.base[Number(h.getAttribute('data-sid'))]?.name);
|
|
420
|
+
h.classList.toggle('chg', on); h.classList.toggle('clash', !!(on && clashOf(nm)));
|
|
421
|
+
});
|
|
422
|
+
const s = ui.body.querySelector('[data-doc-savesum]'); if (s) s.replaceChildren(...saveSum(ch).filter(Boolean));
|
|
423
|
+
}
|
|
424
|
+
function saveSum(ch) {
|
|
425
|
+
if (!ch.length) return [c3ic('info', 'sm'), 'No changes yet. Health checks the document again after you save.'];
|
|
426
|
+
const cl = [...new Set(ch.map((c) => clashOf(c.name)).filter(Boolean))];
|
|
427
|
+
return [c3ic('edit', 'sm'), plural(ch.length, 'section') + ' changed: ' + ch.map((c) => c.name).join(', ') + '.',
|
|
428
|
+
cl.length ? el('span', { class: 'ds-warn' }, ' ' + plural(cl.length, 'open proposal') + ' also change' + (cl.length === 1 ? 's' : '') + ' them; accepting it after your save needs a new draft.') : null];
|
|
429
|
+
}
|
|
430
|
+
const keepRange = () => { const E = S.ed; const s = getSelection(); if (E && E.node && s.rangeCount && E.node.contains(s.anchorNode)) E.range = s.getRangeAt(0).cloneRange(); };
|
|
431
|
+
const restore = () => { const E = S.ed; if (E && E.range) { const s = getSelection(); s.removeAllRanges(); s.addRange(E.range); } };
|
|
432
|
+
function cmd(c, arg) {
|
|
433
|
+
const E = S.ed; if (!E || E.kind !== 'rich' || !E.node) { say('Switch to Rich to use the toolbar.'); return; }
|
|
434
|
+
E.node.focus(); restore();
|
|
435
|
+
const txt = getSelection().toString();
|
|
436
|
+
if (c === 'code') document.execCommand('insertHTML', false, '<code>' + esc(txt || 'code') + '</code>​');
|
|
437
|
+
else if (c === 'task') document.execCommand('insertHTML', false, '<ul class="tasks"><li class="task"><input type="checkbox" aria-label="Done"><span>' + esc(txt || 'New item') + '</span></li></ul>');
|
|
438
|
+
else if (c === 'table') document.execCommand('insertHTML', false, '<div class="tw"><table><thead><tr><th>Column</th><th>Column</th></tr></thead><tbody><tr><td> </td><td> </td></tr></tbody></table></div><p><br></p>');
|
|
439
|
+
else if (c === 'formatBlock') document.execCommand('formatBlock', false, arg);
|
|
440
|
+
else document.execCommand(c, false, arg);
|
|
441
|
+
keepRange(); mark();
|
|
442
|
+
}
|
|
443
|
+
function linkOpen() { keepRange(); S.ed.link = true; drawEdit(); const i = ui.body.querySelector('#dlink'); if (i) i.focus(); }
|
|
444
|
+
function linkDone(ok, url) {
|
|
445
|
+
const E = S.ed; E.link = false; const u = safeHref(String(url || '').trim());
|
|
446
|
+
drawEdit();
|
|
447
|
+
if (!ok || !u) { if (ok && url && !u) say('That link cannot be used. Use an http or https address, or a path.'); return; }
|
|
448
|
+
E.node.focus(); restore();
|
|
449
|
+
if (getSelection().toString()) document.execCommand('createLink', false, u); else document.execCommand('insertHTML', false, '<a href="' + esc(u) + '">' + esc(u) + '</a>');
|
|
450
|
+
mark();
|
|
451
|
+
}
|
|
452
|
+
function toolbar() {
|
|
453
|
+
const E = S.ed;
|
|
454
|
+
const tb = (c, ic, l, arg) => btn('', { 'aria-label': l, title: l, 'data-doc-cmd': c, onMouseDown: (e) => e.preventDefault() }, c3ic(ic, 'sm'), () => (c === 'link' ? linkOpen() : cmd(c, arg)));
|
|
455
|
+
const style = el('select', { 'aria-label': 'Paragraph style', 'data-doc-style': '', onChange: (e) => { cmd('formatBlock', e.target.value); e.target.value = ''; } },
|
|
456
|
+
el('option', { value: '' }, 'Style'), el('option', { value: 'p' }, 'Text'), el('option', { value: 'h2' }, 'Section heading'), el('option', { value: 'h3' }, 'Subheading'), el('option', { value: 'blockquote' }, 'Callout'));
|
|
457
|
+
const kind = el('div', { class: 'seg', role: 'group', 'aria-label': 'Editor' },
|
|
458
|
+
btn('', { 'aria-pressed': String(E.kind === 'rich'), 'data-doc-edkind': 'rich' }, [c3ic('eye', 'sm'), 'Rich'], () => switchKind('rich')),
|
|
459
|
+
btn('', { 'aria-pressed': String(E.kind === 'md'), 'data-doc-edkind': 'md' }, [c3ic('md', 'sm'), 'Markdown'], () => switchKind('md')));
|
|
460
|
+
const bar = el('div', { class: 'ds-tb', role: 'toolbar', 'aria-label': 'Formatting' }, style, el('span', { class: 'sep' }),
|
|
461
|
+
tb('bold', 'bold', 'Bold (Ctrl+B)'), tb('italic', 'italic', 'Italic (Ctrl+I)'), tb('code', 'code', 'Inline code'), tb('link', 'link', 'Link (Ctrl+K)'), el('span', { class: 'sep' }),
|
|
462
|
+
tb('insertUnorderedList', 'ul', 'Bulleted list'), tb('insertOrderedList', 'ol', 'Numbered list'), tb('task', 'task', 'Checklist'), tb('table', 'table', 'Table'), tb('insertHorizontalRule', 'hr', 'Divider'), el('span', { class: 'sep' }),
|
|
463
|
+
tb('undo', 'undo', 'Undo (Ctrl+Z)'), tb('redo', 'redo', 'Redo'), el('span', { class: 'sp' }), kind);
|
|
464
|
+
if (!E.link) return bar;
|
|
465
|
+
const inp = el('input', { id: 'dlink', type: 'url', placeholder: 'Paste a link, for example a Confluence page', 'aria-label': 'Link address', onKeyDown: (e) => { if (e.key === 'Enter') { e.preventDefault(); linkDone(true, e.target.value); } if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); linkDone(false); } } });
|
|
466
|
+
return [bar, el('div', { class: 'ds-pop', 'data-doc-linkpop': '' }, c3ic('link', 'sm'), inp, btn('ds-btn pri', {}, 'Add link', () => linkDone(true, inp.value)), btn('ds-btn', {}, 'Cancel', () => linkDone(false)))];
|
|
467
|
+
}
|
|
468
|
+
function switchKind(k) {
|
|
469
|
+
const E = S.ed; if (E.kind === k) return;
|
|
470
|
+
E.text = draftText(); E.kind = k;
|
|
471
|
+
if (k === 'rich') { E.base = splitDoc(E.text); E.node = null; }
|
|
472
|
+
drawEdit();
|
|
473
|
+
}
|
|
474
|
+
function editBody() {
|
|
475
|
+
if (generated) return el('div', { class: 'fold w', role: 'note', 'data-doc-nostore': 'edit' }, c3ic('lock', 'sm'), generated.why);
|
|
476
|
+
if (S.text == null) return readBody();
|
|
477
|
+
if (!S.ed) startEdit();
|
|
478
|
+
const E = S.ed;
|
|
479
|
+
const secs = splitDoc(S.text);
|
|
480
|
+
let area;
|
|
481
|
+
if (E.kind === 'rich') { if (!E.node) E.node = edNode(); area = E.node; E.ta = null; }
|
|
482
|
+
else { E.ta = E.ta || el('textarea', { class: 'ds-src', id: 'dsrc', 'aria-label': 'Markdown source', spellcheck: 'false', 'data-doc-mdsrc': '', onInput: () => { clearTimeout(E.t); E.t = setTimeout(mark, 250); } }, E.text); area = E.ta; E.node = null; }
|
|
483
|
+
const why = el('input', { type: 'text', id: 'dwhy', class: E.whyBad ? 'bad' : '', placeholder: 'Why are you changing it? Kept in the edit log.', 'aria-label': 'Why', 'data-doc-why': '', value: E.why,
|
|
484
|
+
onInput: (e) => { E.why = e.target.value; if (E.whyBad && E.why.trim()) { E.whyBad = false; e.target.classList.remove('bad'); const a = ui.body.querySelector('[data-doc-whybad]'); if (a) a.remove(); } } });
|
|
485
|
+
const save = el('div', { class: 'ds-save' }, el('div', { class: 'sum', 'data-doc-savesum': '' }, saveSum([])), why,
|
|
486
|
+
E.whyBad ? el('div', { class: 'fold w', role: 'alert', 'data-doc-whybad': '' }, c3ic('alert', 'sm'), 'Say why the document changes. The reason is kept in the edit log.') : null,
|
|
487
|
+
S.err ? el('div', { class: 'fold w', role: 'alert' }, c3ic('alert', 'sm'), S.err) : null,
|
|
488
|
+
el('div', { class: 'row' }, el('span', { class: 'ds-mono' }, c3ic('user', 'sm'), ' Saved by you' + (nextV(doc.v) ? ' as ' + nextV(doc.v) : '')),
|
|
489
|
+
el('span', { class: 'ds-acts' }, btn('ds-btn', { 'data-doc-cancel': '' }, 'Cancel', () => { S.ed = null; S.err = ''; go('read'); }),
|
|
490
|
+
btn('ds-btn pri', { 'data-doc-save': '' }, [c3ic('check', 'sm'), 'Save' + (nextV(doc.v) ? ' as ' + nextV(doc.v) : '')], () => saveDoc()))));
|
|
491
|
+
return el('div', { class: 'ds', 'data-doc-edit': '' }, staleBanner(), propsBlock(secs, true), area, save);
|
|
492
|
+
}
|
|
493
|
+
function drawEdit() { draw(); if (S.ed) setTimeout(mark, 0); }
|
|
494
|
+
async function saveDoc() {
|
|
495
|
+
const E = S.ed; if (!E || S.busy) return;
|
|
496
|
+
const after = draftText(); const ch = changedSections(S.text, after);
|
|
497
|
+
if (after === S.text || !ch.length) { say('Nothing changed.'); S.ed = null; S.err = ''; go('read'); return; }
|
|
498
|
+
if (!E.why.trim()) { E.whyBad = true; E.text = after; if (E.kind === 'rich') { E.base = splitDoc(after); E.node = null; } else if (E.ta) E.ta.value = after; drawEdit(); const w = ui.body.querySelector('#dwhy'); if (w) w.focus(); return; }
|
|
499
|
+
const before = S.text; S.busy = true;
|
|
500
|
+
const r = await pmPost('/api/doc-sheet/edit', { ...base, text: after, why: E.why.trim() }).catch(() => null);
|
|
501
|
+
S.busy = false;
|
|
502
|
+
if (!r || !r.ok) { S.err = (r && r.error) || 'The document was not saved.'; E.text = after; if (E.kind === 'rich') { E.base = splitDoc(after); E.node = null; } drawEdit(); return; }
|
|
503
|
+
const v = nextV(doc.v); if (v) doc.v = v; if (doc.st === 'stale') doc.st = 'current';
|
|
504
|
+
S.err = ''; S.ed = null; S.changed = true; S.mode = 'read';
|
|
505
|
+
await reloadAll();
|
|
506
|
+
say('Saved' + (v ? ' as ' + v : '') + '. ' + plural(ch.length, 'section') + ' changed; the reason is in the edit log.', async () => {
|
|
507
|
+
const u = await pmPost('/api/doc-sheet/edit', { ...base, text: before, why: 'Undone: restored the text from before the last edit.' }).catch(() => null);
|
|
508
|
+
if (u && u.ok) { await reloadAll(); } else say((u && u.error) || 'The edit could not be undone.');
|
|
208
509
|
});
|
|
209
510
|
}
|
|
210
511
|
|
|
211
|
-
/*
|
|
212
|
-
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
};
|
|
216
|
-
const askBody = () => {
|
|
217
|
-
if (generated) return el('div', { class: 'dpn' }, el('div', { class: 'fold w', role: 'note', 'data-doc-nostore': 'ask' }, c3ic('lock', 'sm'), generated.why));
|
|
218
|
-
const ta = el('textarea', { id: 'dpr', placeholder: 'Tell the agent what to change, for example: cut the scope to 3 flows', 'aria-label': 'Request to the agent', 'data-doc-prompt': '', onInput: (e) => { S.prompt = e.target.value; } }, S.prompt);
|
|
512
|
+
/* ── Proposals ── */
|
|
513
|
+
function askBox() {
|
|
514
|
+
const A = S.asking;
|
|
515
|
+
const ta = el('textarea', { id: 'dpr', placeholder: 'Tell the agent what to change, for example: add a one-paragraph summary at the top', 'aria-label': 'Request to the agent', 'data-doc-prompt': '', onInput: (e) => { S.prompt = e.target.value; } }, S.prompt);
|
|
219
516
|
const sug = [...new Set(failing().map((c) => c.fix).filter(Boolean))];
|
|
220
517
|
const copy = async () => { const t = 'Change ' + doc.path + ': ' + (S.prompt || '').trim(); try { await navigator.clipboard.writeText(t); say('The request is copied. Give it to your agent session.'); } catch { say('Copying is blocked here. Select the request and copy it.'); } };
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
else if (!demo && !A) kids.push(el('div', { class: 'fold', role: 'note', 'data-doc-ask-why': '' }, c3ic('info', 'sm'), 'The agent drafts the change in the background and shows you the difference. It can only read: nothing is saved until you slide to accept it.'));
|
|
232
|
-
else if (demo) kids.push(el('div', { class: 'dim', style: 'font-size:var(--fs-2)' }, 'The agent drafts the change as a new version and shows you the difference. Nothing is saved or shared until you accept it.'));
|
|
233
|
-
return el('div', { class: 'dpn' }, kids);
|
|
234
|
-
};
|
|
518
|
+
const working = A && A.state === 'working';
|
|
519
|
+
return el('div', { class: 'ds-ask' }, el('label', { class: 'ds-h', for: 'dpr' }, c3ic('spark', 'sm'), 'Ask the agent'), ta,
|
|
520
|
+
el('div', { class: 'ds-acts' },
|
|
521
|
+
btn('ds-btn ag', { 'data-doc-ask': '', disabled: working, 'aria-disabled': working ? 'true' : null }, [c3ic(working ? 'clock' : 'spark', 'sm'), working ? 'The agent is drafting' : 'Ask the agent'], () => (demo ? askDemo() : askReal())),
|
|
522
|
+
demo ? null : btn('ds-btn', { 'data-doc-copy': '' }, [c3ic('copy', 'sm'), 'Copy the request'], copy),
|
|
523
|
+
sug.map((t, i) => btn('chip2 lk', { 'data-doc-chip': String(i) }, [c3ic('spark', 'sm'), t], () => { S.prompt = t; draw(); }))),
|
|
524
|
+
working ? el('div', { class: 'fold', role: 'status', 'data-doc-asking': '' }, c3ic('clock', 'sm'), 'The agent is reading the document and drafting the change. It runs in the background and can only read; you can close this sheet and come back. Its run is listed in Sessions.')
|
|
525
|
+
: A && A.state === 'failed' ? el('div', { class: 'fold w', role: 'alert', 'data-doc-ask-err': '' }, c3ic('alert', 'sm'), A.error || 'The agent did not answer.')
|
|
526
|
+
: el('p', { class: 'ds-note', 'data-doc-ask-why': '' }, 'The agent drafts a proposal and can only read. Nothing in the document changes until you accept it, and your request is kept in the edit log.'));
|
|
527
|
+
}
|
|
235
528
|
async function askDemo() {
|
|
236
529
|
const pr = (S.prompt || '').trim(); if (!pr) { say('Tell the agent what to change'); return; }
|
|
237
|
-
const before = S.text || ''; const
|
|
238
|
-
const r = await pmPost('/api/doc-sheet/propose', { ...base, prompt: pr, after });
|
|
530
|
+
const before = S.text || ''; const after = before.replace(/\s*$/, '\n\n') + '## Agent note\n\nDrafted from your request: ' + pr.slice(0, 160) + '\n';
|
|
531
|
+
const r = await pmPost('/api/doc-sheet/propose', { ...base, prompt: pr, after }).catch(() => null);
|
|
239
532
|
if (!r || !r.ok) { say((r && r.error) || 'The agent did not answer.'); return; }
|
|
240
|
-
S.
|
|
533
|
+
S.prompt = ''; S.openProp = r.id; await loadProps(); draw();
|
|
241
534
|
}
|
|
242
|
-
/* real mode: start the run from the page, then read its state until the proposal is there */
|
|
535
|
+
/* real mode: start the run from the page (page-starts-agent), then read its state until the proposal is there */
|
|
243
536
|
async function askReal() {
|
|
244
|
-
const pr = (S.prompt || '').trim(); if (!pr) { say('Tell the agent what to change'); return; }
|
|
245
|
-
S.
|
|
246
|
-
const r = await pmPost('/api/doc-sheet/ask', { ...base, prompt: pr });
|
|
537
|
+
const pr = (S.prompt || '').trim(); if (!pr) { say('Tell the agent what to change'); const t = ui.body.querySelector('#dpr'); if (t) t.focus(); return; }
|
|
538
|
+
S.asking = { state: 'working' }; draw();
|
|
539
|
+
const r = await pmPost('/api/doc-sheet/ask', { ...base, prompt: pr }).catch(() => null);
|
|
247
540
|
if (!r || !r.ok) { S.asking = { state: 'failed', error: (r && r.error) || 'The agent could not start.' }; draw(); return; }
|
|
248
|
-
S.asking = { id: r.id, state: 'working' }; draw();
|
|
249
|
-
pollAsk(r.id
|
|
541
|
+
S.asking = { id: r.id, state: 'working' }; S.prompt = ''; S.log = null; draw();
|
|
542
|
+
pollAsk(r.id);
|
|
250
543
|
}
|
|
251
|
-
async function pollAsk(id
|
|
544
|
+
async function pollAsk(id) {
|
|
252
545
|
while (!S.closed && S.asking && S.asking.id === id) {
|
|
253
546
|
await new Promise((ok) => setTimeout(ok, 3000));
|
|
254
547
|
if (S.closed || !S.asking || S.asking.id !== id) return;
|
|
255
|
-
const r = await pmGet('/api/doc-sheet/ask?
|
|
548
|
+
const r = await pmGet('/api/doc-sheet/ask?' + q({ id, ws })).catch(() => null);
|
|
256
549
|
if (!r || !r.ok || r.state === 'working') continue;
|
|
257
|
-
if (r.state === 'proposed' && r.proposal) { S.asking = null; S.
|
|
550
|
+
if (r.state === 'proposed' && r.proposal) { S.asking = null; S.openProp = r.proposal.id; await loadProps(); }
|
|
258
551
|
else S.asking = { id, state: 'failed', error: r.error };
|
|
259
|
-
|
|
260
|
-
say(
|
|
552
|
+
S.log = null; drawBg();
|
|
553
|
+
say(r.state === 'proposed' ? 'The agent’s draft is ready in Proposals. Read it, then slide to accept.' : 'The agent did not draft the change.');
|
|
261
554
|
return;
|
|
262
555
|
}
|
|
263
556
|
}
|
|
264
|
-
function
|
|
265
|
-
const
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
{
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
557
|
+
function propCard(p) {
|
|
558
|
+
const pick = S.pick[p.id] || (S.pick[p.id] = {});
|
|
559
|
+
p.sections.forEach((c) => { if (pick[c.name] == null || c.stale) pick[c.name] = !c.stale; });
|
|
560
|
+
const n = p.sections.filter((c) => pick[c.name]).length;
|
|
561
|
+
const anyStale = p.sections.some((c) => c.stale);
|
|
562
|
+
const hd = el('div', { class: 'hd' }, el('span', { class: 'ds-av ' + (p.by === 'pm' ? 'h' : 'a') }, p.by === 'pm' ? 'Y' : c3ic('spark', 'sm')),
|
|
563
|
+
el('span', null, el('div', { class: 'ti' }, short(p.prompt) || 'A proposal'), el('div', { class: 'sub' }, el('span', null, p.by === 'pm' ? 'Drafted by you' : 'Drafted by the agent'), el('span', null, whenOf(p.at)), p.hash ? el('span', null, 'hash ' + String(p.hash).slice(0, 8)) : null)),
|
|
564
|
+
el('span', { class: 'tag ds-ag' }, plural(p.sections.length, 'section')));
|
|
565
|
+
const why = el('p', { class: 'ds-why' }, c3ic('user', 'sm'), el('span', null, 'You asked: ', el('q', null, p.prompt)));
|
|
566
|
+
const chs = p.sections.map((c) => {
|
|
567
|
+
const [a, d] = wordCounts(c.stale ? c.current || '' : c.before, c.after);
|
|
568
|
+
const box = el('input', { type: 'checkbox', checked: !!pick[c.name], disabled: c.stale, 'data-doc-pick': p.id + ':' + c.name, onChange: (e) => { pick[c.name] = e.target.checked; draw(); } });
|
|
569
|
+
return el('div', { class: 'ds-chg' + (pick[c.name] ? '' : ' off'), 'data-doc-chg': c.name },
|
|
570
|
+
el('header', null, el('label', null, box, (c.kind === 'added' ? 'New section: ' : c.kind === 'removed' ? 'Removes: ' : '') + c.name),
|
|
571
|
+
c.stale ? el('span', { class: 'ds-warn', 'data-doc-chgstale': '' }, c3ic('alert', 'sm'), ' Changed since this draft. Ask again to re-draft it.') : el('span', { class: 'dim' }, '+' + a + ' −' + d + ' words')),
|
|
572
|
+
md(c.kind === 'added' ? diffMd(null, c.after) : diffMd(c.stale ? c.current || '' : c.before, c.after)));
|
|
573
|
+
});
|
|
574
|
+
const host = el('div', { class: 'ds-slide', 'data-doc-slide': p.id });
|
|
575
|
+
if (n) mountSlide(host, { label: 'Slide to accept ' + n + ' of ' + p.sections.length, hash: p.hash, onConfirm: (h) => acceptProp(p, h) });
|
|
576
|
+
else host.append(el('div', { class: 'slt-why', role: 'note' }, c3ic('lock', 'sm'), anyStale && p.sections.every((c) => c.stale) ? 'Every section changed since this draft. Ask the agent again.' : 'Tick at least one section to accept.'));
|
|
577
|
+
const rej = S.rejFor === p.id
|
|
578
|
+
? el('div', { class: 'ds-whyadd' }, el('input', { type: 'text', id: 'drejwhy', 'data-doc-rejwhy': '', placeholder: 'Why are you rejecting it? Kept in the edit log.', 'aria-label': 'Why reject', value: S.rejWhy, onInput: (e) => { S.rejWhy = e.target.value; } }),
|
|
579
|
+
btn('ds-btn pri', { 'data-doc-rejok': p.id }, 'Reject', () => rejectProp(p)), btn('ds-btn', {}, 'Cancel', () => { S.rejFor = null; draw(); }))
|
|
580
|
+
: el('div', { class: 'ds-acts' }, btn('ds-btn', { 'data-doc-showin': p.id }, [c3ic('eye', 'sm'), 'Show it in the document'], () => { S.preview[p.id] = true; go('read'); const c = p.sections[0]; if (c) goEl(c.kind === 'added' ? '[data-dsec-new="' + c.name + '"]' : '[data-dsec="' + c.name + '"]'); }),
|
|
581
|
+
demo ? null : btn('ds-btn', { 'data-doc-refine': p.id }, [c3ic('reply', 'sm'), 'Change the request'], () => { S.prompt = p.prompt + ', and '; draw(); const t = ui.body.querySelector('#dpr'); if (t) { t.focus(); t.setSelectionRange(t.value.length, t.value.length); } }),
|
|
582
|
+
btn('ds-btn', { 'data-doc-rej': p.id }, [c3ic('x', 'sm'), 'Reject'], () => { S.rejFor = p.id; S.rejWhy = ''; draw(); const i = ui.body.querySelector('#drejwhy'); if (i) i.focus(); }));
|
|
583
|
+
return el('div', { class: 'ds-prop', id: 'dprop-' + p.id, 'data-doc-prop-card': p.id }, hd, el('div', { class: 'pb' }, why, chs, host,
|
|
584
|
+
el('p', { class: 'ds-note' }, 'Logged as text by the agent, accepted by you. Health checks the document again after.'), rej));
|
|
585
|
+
}
|
|
586
|
+
async function acceptProp(p, hash) {
|
|
587
|
+
const names = p.sections.filter((c) => (S.pick[p.id] || {})[c.name] && !c.stale).map((c) => c.name);
|
|
588
|
+
const r = await pmPost('/api/doc-sheet/accept', { ...base, id: p.id, hash, sections: names }).catch(() => null);
|
|
589
|
+
if (!r || !r.ok) { say('Not accepted: ' + ((r && (r.error || r.reason)) || 'the proposal changed.')); await loadProps(); draw(); return; }
|
|
590
|
+
const v = nextV(doc.v); if (v) doc.v = v; if (doc.st === 'stale') doc.st = 'current';
|
|
591
|
+
S.changed = true; S.preview[p.id] = false; delete S.pick[p.id];
|
|
592
|
+
await reloadAll();
|
|
593
|
+
say('Accepted ' + plural(names.length, 'section') + (v ? ' as ' + v : '') + '. The edit log names the agent and you.');
|
|
594
|
+
}
|
|
595
|
+
async function rejectProp(p) {
|
|
596
|
+
const w = String(S.rejWhy || '').trim();
|
|
597
|
+
if (!w) { say('Say why you are rejecting it'); const i = ui.body.querySelector('#drejwhy'); if (i) i.focus(); return; }
|
|
598
|
+
const r = await pmPost('/api/doc-sheet/reject', { ...base, id: p.id, why: w }).catch(() => null);
|
|
599
|
+
if (!r || !r.ok) { say((r && r.error) || 'It was not rejected.'); return; }
|
|
600
|
+
S.rejFor = null; S.rejWhy = ''; S.log = null; await loadProps(); draw(); say('Rejected. The reason is in the edit log.');
|
|
601
|
+
}
|
|
602
|
+
function propsBody() {
|
|
603
|
+
if (generated) return el('div', { class: 'fold w', role: 'note', 'data-doc-nostore': 'ask' }, c3ic('lock', 'sm'), generated.why);
|
|
604
|
+
const op = openProps(); const closed = (S.props || []).filter((p) => p.state !== 'proposed');
|
|
605
|
+
const list = S.props === null ? el('div', { class: 'fold', role: 'status' }, c3ic('clock', 'sm'), 'Reading the proposals.')
|
|
606
|
+
: S.propsErr ? el('div', { class: 'fold w', role: 'alert' }, c3ic('alert', 'sm'), S.propsErr)
|
|
607
|
+
: op.length ? op.map(propCard) : el('div', { class: 'fold', 'data-doc-noprops': '' }, c3ic('check', 'sm'), 'No open proposals.');
|
|
608
|
+
return el('div', { class: 'ds', 'data-doc-proposals': '' }, staleBanner(), askBox(), el('h3', { class: 'ds-h' }, 'Open proposals · ' + op.length), list,
|
|
609
|
+
closed.length ? [el('h3', { class: 'ds-h' }, 'Closed'), el('ul', { class: 'ds-closed hx' }, closed.map((p) => el('li', { 'data-doc-closed': p.id },
|
|
610
|
+
el('span', { class: 'chip2 ' + (p.state === 'accepted' ? 'ds-good' : 'dim') }, c3ic(p.state === 'accepted' ? 'check' : 'x', 'sm'), p.state === 'accepted' ? 'Accepted' : 'Rejected'),
|
|
611
|
+
el('span', null, el('b', null, short(p.prompt)), el('small', null, p.state === 'accepted' ? (p.taken && p.of && p.taken.length < p.of ? p.taken.length + ' of ' + p.of + ' sections: ' : 'Sections: ') + (p.taken || []).join(', ') : 'Why: ' + (p.why || 'no reason'))),
|
|
612
|
+
el('span', { class: 'w' }, dayName(p.closed_at || p.at)))))] : null);
|
|
280
613
|
}
|
|
281
614
|
|
|
282
|
-
/*
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
const
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
615
|
+
/* ── Edit log ── */
|
|
616
|
+
function logEntries() {
|
|
617
|
+
const L = S.log || { revisions: [], events: [] };
|
|
618
|
+
const out = [];
|
|
619
|
+
for (const r of L.revisions) {
|
|
620
|
+
const ag = isAgentRev(r); const outside = r.via === 'outside' || r.via === 'first' || (r.by === 'baseline' && !r.via);
|
|
621
|
+
const title = r.via === 'proposal' ? 'Proposal accepted' : r.via === 'outside' || (r.by === 'baseline' && !r.via) ? 'Changed outside the sheet' : r.via === 'first' ? 'First seen by the sheet' : 'Edited in this sheet';
|
|
622
|
+
out.push({ kind: 'rev', at: r.at, r, ag, outside, people: !ag, title: (r.v ? r.v + ' · ' : 'Revision ' + r.rev + ' · ') + title });
|
|
623
|
+
}
|
|
624
|
+
for (const e of L.events) {
|
|
625
|
+
const t = { proposed: 'The agent drafted a proposal', revised: 'The agent revised a proposal', rejected: 'Proposal rejected', asked: 'You asked the agent', 'ask-failed': 'The agent did not draft the change', marked: 'Marked as still current' }[e.kind] || e.kind;
|
|
626
|
+
out.push({ kind: e.kind, at: e.at, e, ag: e.by === 'agent' || e.kind === 'proposed' || e.kind === 'revised' || e.kind === 'ask-failed', outside: false, people: e.by === 'pm' || e.kind === 'rejected' || e.kind === 'asked' || e.kind === 'marked', title: t });
|
|
627
|
+
}
|
|
628
|
+
return out.sort((a, b) => String(b.at).localeCompare(String(a.at)));
|
|
629
|
+
}
|
|
630
|
+
function logRow(x) {
|
|
631
|
+
const r = x.r; const e = x.e;
|
|
632
|
+
const av = el('span', { class: 'ds-av ' + (x.kind === 'ask-failed' ? 'w' : x.ag ? 'a' : 'h') }, x.ag ? c3ic(x.kind === 'ask-failed' ? 'alert' : 'spark', 'sm') : (r ? (whoOf(r)[0] || '?') : 'Y').toUpperCase());
|
|
633
|
+
const who = [];
|
|
634
|
+
if (r) {
|
|
635
|
+
if (r.via === 'proposal') who.push(el('span', { class: 'ds-ag' }, c3ic('spark', 'sm'), ' Text by the agent'), el('span', { class: 'ds-pe' }, c3ic('user', 'sm'), ' Accepted by you'), el('span', null, 'Proposal ' + String(r.proposal || '').replace(/^prop-/, '') + (r.sections && r.of ? ', ' + r.sections.length + ' of ' + r.of + ' sections' : '')));
|
|
636
|
+
else if (x.outside) who.push(el('span', { class: x.ag ? 'ds-ag' : 'ds-pe' }, c3ic(x.ag ? 'spark' : 'user', 'sm'), ' ' + (r.git && r.git.author ? r.git.author : 'Unknown author')), el('span', { class: 'ds-warn' }, c3ic('git', 'sm'), ' ' + (r.git && r.git.commit ? 'commit ' + r.git.commit : 'not committed') + (r.via === 'first' ? ', before the edit log' : ', outside PMOS')));
|
|
637
|
+
else who.push(el('span', { class: 'ds-pe' }, c3ic('user', 'sm'), ' ' + byName(r.by)), el('span', null, 'This sheet'));
|
|
638
|
+
} else if (x.ag) who.push(el('span', { class: 'ds-ag' }, c3ic('spark', 'sm'), ' The agent' + (e.session ? ' · session ' + e.session : '')));
|
|
639
|
+
else who.push(el('span', { class: 'ds-pe' }, c3ic('user', 'sm'), ' You'));
|
|
640
|
+
let why = null;
|
|
641
|
+
if (r) {
|
|
642
|
+
const noReason = x.outside && !r.why_known && !r.why_added;
|
|
643
|
+
if (r.why_added) why = el('p', { class: 'why' }, r.why_added, el('span', { class: 'dim' }, ' (reason added ' + dayName(r.why_added_at) + ')'));
|
|
644
|
+
else if (noReason && S.whyFor === r.rev) why = el('div', { class: 'ds-whyadd' }, el('input', { type: 'text', id: 'daddwhy', 'data-doc-addwhy': '', placeholder: 'Why did this change?', 'aria-label': 'Reason', value: S.whyText, onInput: (ev) => { S.whyText = ev.target.value; } }),
|
|
645
|
+
btn('ds-btn pri', { 'data-doc-whyok': String(r.rev) }, 'Add the reason', () => addWhy(r)), btn('ds-btn', {}, 'Cancel', () => { S.whyFor = null; draw(); }));
|
|
646
|
+
else if (noReason) why = el('p', { class: 'why none' }, c3ic('alert', 'sm'), ' No reason recorded: this change was made outside PMOS. ', btn('chip2 lk ds-pe', { 'data-doc-whyadd': String(r.rev) }, 'Add a reason', () => { S.whyFor = r.rev; S.whyText = ''; draw(); const i = ui.body.querySelector('#daddwhy'); if (i) i.focus(); }));
|
|
647
|
+
else if (r.why) why = el('p', { class: 'why' }, r.why);
|
|
648
|
+
} else {
|
|
649
|
+
const t = e.kind === 'asked' ? '“' + e.prompt + '”' : e.kind === 'proposed' || e.kind === 'revised' ? 'For: “' + (e.prompt || '') + '”' : e.why || '';
|
|
650
|
+
if (t) why = el('p', { class: 'why' }, t);
|
|
651
|
+
}
|
|
652
|
+
const tags = [];
|
|
653
|
+
if (r) {
|
|
654
|
+
const prev = S.texts[r.rev - 1]; const cur = S.texts[r.rev];
|
|
655
|
+
const secs = r.sections || (prev != null && cur != null ? changedSections(prev, cur).map((c) => c.name) : null);
|
|
656
|
+
if (secs) secs.forEach((s) => tags.push(el('span', { class: 'tag' }, s)));
|
|
657
|
+
if (prev != null && cur != null) { const [a, d] = wordCounts(prev, cur); tags.push(el('span', { class: 'tag add' }, '+' + a), el('span', { class: 'tag del' }, '−' + d)); }
|
|
658
|
+
}
|
|
659
|
+
const acts = [];
|
|
660
|
+
if (r && r.rev > 1) acts.push(btn('chip2 lk', { 'data-doc-cmp': String(r.rev) }, [c3ic('eye', 'sm'), S.cmp === r.rev ? 'Hide the change' : 'Show the change'], () => cmpWith(r.rev)));
|
|
661
|
+
if (e && (e.kind === 'proposed' || e.kind === 'revised') && openProps().some((p) => p.id === e.id)) acts.push(btn('chip2 lk ds-ag', {}, [c3ic('spark', 'sm'), 'Review it'], () => { S.openProp = e.id; go('ask'); }));
|
|
662
|
+
const cmp = r && S.cmp === r.rev ? el('div', { class: 'ds-cmp', 'data-doc-cmpbox': '' }, S.cmpNode || el('div', { class: 'fold' }, c3ic('clock', 'sm'), 'Comparing.')) : null;
|
|
663
|
+
return el('li', { 'data-log-kind': x.kind, ...(r ? { 'data-log-rev': String(r.rev) } : {}) }, av, el('div', { class: 'ds-le' },
|
|
664
|
+
el('div', { class: 'l1' }, el('b', null, x.title), el('span', { class: 'when' }, whenOf(x.at))), el('div', { class: 'who' }, who), why,
|
|
665
|
+
tags.length ? el('div', { class: 'tags' }, tags) : null, acts.length ? el('div', { class: 'ds-acts' }, acts) : null, cmp));
|
|
666
|
+
}
|
|
295
667
|
async function cmpWith(rev) {
|
|
296
668
|
if (S.cmp === rev) { S.cmp = null; draw(); return; }
|
|
297
669
|
S.cmp = rev; S.cmpNode = null; draw();
|
|
298
670
|
const [a, b] = await Promise.all([textAt(rev - 1), textAt(rev)]);
|
|
299
671
|
if (S.cmp !== rev) return;
|
|
300
|
-
|
|
672
|
+
const D = a == null || b == null ? null : changedSections(a, b);
|
|
673
|
+
S.cmpNode = !D ? el('div', { class: 'fold w' }, c3ic('alert', 'sm'), 'That revision could not be read.')
|
|
674
|
+
: D.length ? D.map((d) => [el('h4', null, d.name), md(diffMd(d.kind === 'added' ? null : d.before, d.after))]) : el('div', { class: 'dim' }, 'No change in the text.');
|
|
301
675
|
draw();
|
|
302
676
|
}
|
|
677
|
+
async function addWhy(r) {
|
|
678
|
+
const w = String(S.whyText || '').trim(); if (!w) { const i = ui.body.querySelector('#daddwhy'); if (i) i.focus(); return; }
|
|
679
|
+
const j = await pmPost('/api/doc-sheet/why', { ...base, rev: r.rev, why: w }).catch(() => null);
|
|
680
|
+
if (!j || !j.ok) { say((j && j.error) || 'The reason was not added.'); return; }
|
|
681
|
+
S.whyFor = null; await loadLog(); draw(); say('Reason added. The revision itself is unchanged.');
|
|
682
|
+
}
|
|
683
|
+
function logBody() {
|
|
684
|
+
if (generated) return el('div', { class: 'fold', 'data-doc-nostore': 'hist' }, c3ic('info', 'sm'), generated.why);
|
|
685
|
+
if (!S.log) return el('div', { class: 'fold', role: 'status' }, c3ic('clock', 'sm'), 'Reading the edit log.');
|
|
686
|
+
const F = [['all', 'All'], ['people', 'People'], ['agent', 'Agent'], ['outside', 'Outside PMOS']];
|
|
687
|
+
const all = logEntries();
|
|
688
|
+
const rows = all.filter((x) => S.logF === 'all' || (S.logF === 'people' && x.people) || (S.logF === 'agent' && x.ag) || (S.logF === 'outside' && x.outside));
|
|
689
|
+
return el('div', { class: 'ds', 'data-doc-log': '' }, el('div', { class: 'ds-rbar' }, el('div', { class: 'seg', role: 'group', 'aria-label': 'Filter' }, F.map(([k, l]) => btn('', { 'aria-pressed': String(S.logF === k), 'data-doc-logf': k }, l, () => { S.logF = k; draw(); }))),
|
|
690
|
+
el('span', { class: 'ds-mono' }, plural(all.length, 'entry').replace('entrys', 'entries') + ' · append-only')),
|
|
691
|
+
S.logErr ? el('div', { class: 'fold w', role: 'alert' }, c3ic('alert', 'sm'), S.logErr) : null,
|
|
692
|
+
rows.length ? el('ul', { class: 'ds-log' }, rows.map(logRow)) : el('div', { class: 'fold' }, c3ic('info', 'sm'), all.length ? 'Nothing in this filter.' : 'Nothing is recorded yet. The first save through this sheet starts the log.'));
|
|
693
|
+
}
|
|
303
694
|
|
|
304
|
-
/* Health */
|
|
305
|
-
|
|
695
|
+
/* ── Health ── */
|
|
696
|
+
function healthBody() {
|
|
306
697
|
const h = S.health;
|
|
307
698
|
if (!h) return el('div', { class: 'fold', role: 'status' }, c3ic('clock', 'sm'), 'Checking this document.');
|
|
308
699
|
if (h.err) return el('div', { class: 'fold w', role: 'alert' }, c3ic('alert', 'sm'), 'The checks could not run: ' + h.err + '. Nothing is shown.');
|
|
309
700
|
const unanswered = h.checks.filter((c) => c.pass === null).length;
|
|
310
|
-
return el('div', { class: '
|
|
311
|
-
unanswered ? el('p', { class: '
|
|
701
|
+
return el('div', { class: 'ds' }, el('div', { class: 'chips2', 'data-doc-health': '' }, el('span', { class: 'ki ' + hcls(h) }, c3ic('shield')), el('b', null, h.passed + ' of ' + h.total + ' checks pass')),
|
|
702
|
+
unanswered ? el('p', { class: 'ds-note' }, plural(unanswered, 'check') + ' not counted: not enough input.') : null,
|
|
312
703
|
el('ul', { class: 'chgl' }, h.checks.map((c) => el('li', { class: c.pass === null ? '' : c.pass ? 'ok' : 'bad', 'data-check': c.id }, c3ic(c.pass === null ? 'info' : c.pass ? 'check' : 'alert', 'sm'),
|
|
313
|
-
el('span', null, (c.name || NAMES[c.id] || c.id) + ': ' + c.reason + ' ',
|
|
704
|
+
el('span', null, (c.name || NAMES[c.id] || c.id) + ': ' + c.reason + ' ',
|
|
705
|
+
c.id === 'fresh' && c.pass === false && Array.isArray(c.sources) && c.sources.length ? el('small', { class: 'ds-mono' }, ' ' + c.sources.map((s) => s.path).join(', ') + ' ') : null,
|
|
706
|
+
c.id === 'fresh' && c.pass === false && !generated ? btn('chip2 lk', { 'data-doc-hmark': '' }, 'Mark as still current', () => { S.staleWhy = ''; go('read'); const i = ui.body.querySelector('#dstalewhy'); if (i) i.focus(); }) : null,
|
|
707
|
+
c.pass === false && !generated ? btn('chip2 lk ds-ag', { 'data-doc-fix': c.id }, [c3ic('spark', 'sm'), 'Fix with the agent'], () => { S.prompt = c.fix || 'Fix: ' + (NAMES[c.id] || c.id) + ' (' + c.reason + ')'; go('ask'); }) : null),
|
|
314
708
|
el('span', { class: 'tag' }, c.pass === null ? 'no input' : c.pass ? 'pass' : 'fail')))),
|
|
315
|
-
el('
|
|
316
|
-
}
|
|
709
|
+
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
|
+
}
|
|
317
711
|
|
|
318
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.'));
|
|
319
713
|
|
|
320
714
|
/* a load that finished in the background must not redraw under a hand that is typing */
|
|
321
|
-
const drawBg = () => { const a = document.activeElement; if (
|
|
715
|
+
const drawBg = () => { const a = document.activeElement; if (ui && a && ui.body.contains(a) && a.closest('input,textarea,[contenteditable=true]')) { refreshTop(); return; } draw(); };
|
|
716
|
+
const top = () => el('div', { class: 'ds-top' }, head(), seg(), S.mode === 'edit' && S.ed ? toolbar() : null,
|
|
717
|
+
S.leave ? el('div', { class: 'fold w', role: 'alert', 'data-doc-leave': '' }, c3ic('alert', 'sm'), 'You have unsaved changes in ' + plural(dirty().length, 'section') + '. ',
|
|
718
|
+
btn('ds-btn', {}, 'Keep editing', () => { S.leave = null; drawEdit(); }), btn('ds-btn', { 'data-doc-discard': '' }, 'Discard and leave', () => { const m = S.leave; S.ed = null; S.leave = null; go(m); })) : null);
|
|
719
|
+
function refreshTop() { const t = ui && ui.body.querySelector('.ds-top'); if (t) t.replaceWith(top()); }
|
|
322
720
|
function draw() {
|
|
323
721
|
if (!ui) return;
|
|
324
|
-
|
|
325
|
-
const
|
|
326
|
-
const
|
|
327
|
-
|
|
722
|
+
if (S.mode === 'edit' && S.ed && S.ed.kind === 'rich' && S.ed.node) keepRange();
|
|
723
|
+
const body = S.mode === 'read' ? readBody() : S.mode === 'edit' ? editBody() : S.mode === 'ask' ? propsBody() : S.mode === 'hist' ? logBody() : S.mode === 'health' ? healthBody() : linksBody();
|
|
724
|
+
const keep = ui.body.querySelector('textarea:focus,input[type=text]:focus');
|
|
725
|
+
const had = keep ? { id: keep.id, s: keep.selectionStart } : null;
|
|
726
|
+
ui.body.replaceChildren(el('div', { class: 'dpn ds' }, top(), body));
|
|
328
727
|
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
|
+
if (S.mode === 'ask' && S.openProp) { const id = S.openProp; S.openProp = null; goEl('[data-doc-prop-card="' + id + '"]'); }
|
|
329
729
|
}
|
|
330
730
|
|
|
331
|
-
|
|
731
|
+
document.addEventListener('selectionchange', keepRange);
|
|
732
|
+
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(); } });
|
|
332
733
|
S.open = (d) => { ui.close(); if (S.openOther) S.openOther(d); };
|
|
333
734
|
ui.setOpener = (fn) => { S.openOther = fn; };
|
|
334
735
|
draw();
|
|
335
736
|
(async () => {
|
|
336
|
-
await loadText();
|
|
337
|
-
if (S.mode === '
|
|
338
|
-
|
|
339
|
-
loadHealth();
|
|
340
|
-
|
|
737
|
+
await loadText();
|
|
738
|
+
if (S.mode === 'edit') startEdit();
|
|
739
|
+
drawBg();
|
|
740
|
+
await Promise.all([loadRevs(), loadProps(), loadHealth()]);
|
|
741
|
+
if (S.mode === 'hist') await loadLog();
|
|
742
|
+
drawBg(); loadMeta();
|
|
341
743
|
})();
|
|
342
744
|
return ui;
|
|
343
745
|
}
|