create-pmos 0.123.0 → 0.125.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 +2 -2
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/scripts/nine-stages.js +81 -0
- package/payload/scripts/workflow-state.js +2 -0
- package/payload/state/discoveries.md +1 -1
- package/payload/state/log.md +1 -1
- package/workspace/c3-one-home-test.js +131 -0
- package/workspace/fixtures/initiatives-api/list.json +171 -3
- package/workspace/fixtures/initiatives-api/roadmap.json +228 -4
- package/workspace/fixtures/initiatives-api/sheet.json +57 -1
- package/workspace/fixtures/one-home-baseline.json +83 -0
- package/workspace/guide/layout.md +2 -0
- package/workspace/guide/notes.md +1 -0
- package/workspace/lib/engine/nine-stages.js +81 -0
- package/workspace/lib/engine/workflow-state.js +2 -0
- package/workspace/lib/facts-async.js +51 -0
- package/workspace/lib/item-steps.js +52 -0
- package/workspace/lib/jira-status.js +8 -3
- package/workspace/lib/jobs-to-be-done.js +52 -0
- package/workspace/lib/outbox-store.js +87 -0
- package/workspace/lib/pm-items.js +115 -0
- package/workspace/lib/route-modules.js +21 -0
- package/workspace/lib/routes-briefing.js +100 -0
- package/workspace/lib/routes-c3-init.js +53 -0
- package/workspace/lib/routes-c3-life.js +119 -0
- package/workspace/lib/routes-c3-strategy.js +169 -0
- package/workspace/lib/routes-c3-timeline.js +191 -0
- package/workspace/lib/routes-calendar-import.js +60 -0
- package/workspace/lib/routes-calendar-views.js +61 -0
- package/workspace/lib/routes-doc-health.js +79 -0
- package/workspace/lib/routes-doc-sheet.js +169 -0
- package/workspace/lib/routes-forecast.js +85 -0
- package/workspace/lib/routes-hooks.js +121 -0
- package/workspace/lib/routes-initiatives.js +3 -0
- package/workspace/lib/routes-intake.js +24 -0
- package/workspace/lib/routes-outbox.js +97 -0
- package/workspace/lib/routes-pm-items.js +216 -0
- package/workspace/lib/routes-records.js +61 -0
- package/workspace/lib/routes-releases.js +129 -0
- package/workspace/lib/routes-report-facts.js +84 -0
- package/workspace/lib/routes-report-families.js +32 -0
- package/workspace/lib/routes-roadmap-ops.js +141 -0
- package/workspace/lib/routes-runs.js +119 -0
- package/workspace/lib/routes-signals.js +50 -0
- package/workspace/lib/run-answers.js +69 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/c3.html +26 -0
- package/workspace/public/pm/briefing-deck.js +415 -0
- package/workspace/public/pm/briefing-model.js +176 -0
- package/workspace/public/pm/c3-breakdown.js +240 -0
- package/workspace/public/pm/c3-brief.js +278 -0
- package/workspace/public/pm/c3-cal-strategy.js +528 -0
- package/workspace/public/pm/c3-case.js +173 -0
- package/workspace/public/pm/c3-demo-brief-ix.js +350 -0
- package/workspace/public/pm/c3-demo-cal-ix.js +74 -0
- package/workspace/public/pm/c3-demo-cal2.js +66 -0
- package/workspace/public/pm/c3-demo-cal3.js +173 -0
- package/workspace/public/pm/c3-demo-data.js +560 -0
- package/workspace/public/pm/c3-demo-init.js +373 -0
- package/workspace/public/pm/c3-demo-life.js +77 -0
- package/workspace/public/pm/c3-demo-misc2.js +46 -0
- package/workspace/public/pm/c3-demo-plan2.js +30 -0
- package/workspace/public/pm/c3-demo-rep-ix.js +126 -0
- package/workspace/public/pm/c3-demo-rep2.js +92 -0
- package/workspace/public/pm/c3-demo-term.js +153 -0
- package/workspace/public/pm/c3-demo-time.js +49 -0
- package/workspace/public/pm/c3-demo-work-ix.js +80 -0
- package/workspace/public/pm/c3-demo-work2.js +13 -0
- package/workspace/public/pm/c3-demo.js +113 -0
- package/workspace/public/pm/c3-doc-sheet.js +314 -0
- package/workspace/public/pm/c3-dock.js +180 -0
- package/workspace/public/pm/c3-docs.js +290 -0
- package/workspace/public/pm/c3-expand.js +96 -0
- package/workspace/public/pm/c3-init-extras.js +170 -0
- package/workspace/public/pm/c3-lifecycle-panels.js +476 -0
- package/workspace/public/pm/c3-misc-panels.js +89 -0
- package/workspace/public/pm/c3-plan-panels.js +163 -0
- package/workspace/public/pm/c3-plan.js +192 -0
- package/workspace/public/pm/c3-roadmap.js +249 -0
- package/workspace/public/pm/c3-sheet.js +107 -0
- package/workspace/public/pm/c3-shell.js +637 -0
- package/workspace/public/pm/c3-signals.js +196 -0
- package/workspace/public/pm/c3-strategy-panels.js +45 -0
- package/workspace/public/pm/c3-swipe.js +138 -0
- package/workspace/public/pm/c3-terminal-data.js +26 -0
- package/workspace/public/pm/c3-terminal-edge.js +91 -0
- package/workspace/public/pm/c3-terminal-icons.js +41 -0
- package/workspace/public/pm/c3-terminal-results.js +141 -0
- package/workspace/public/pm/c3-terminal.js +52 -0
- package/workspace/public/pm/c3-tickets.js +163 -0
- package/workspace/public/pm/c3-timeline.js +201 -0
- package/workspace/public/pm/c3-toast.js +16 -0
- package/workspace/public/pm/calendar-views.js +1015 -0
- package/workspace/public/pm/console3-lift.md +220 -0
- package/workspace/public/pm/console3.css +1480 -0
- package/workspace/public/pm/dest-initiatives.js +16 -3
- package/workspace/public/pm/dest-mywork.js +8 -1
- package/workspace/public/pm/dest-notes.js +25 -3
- package/workspace/public/pm/dest-reports.js +47 -1
- package/workspace/public/pm/dest-today.js +55 -1
- package/workspace/public/pm/doc-health-panel.js +110 -0
- package/workspace/public/pm/doc-rail.js +9 -3
- package/workspace/public/pm/lifecycle-lens-model.js +228 -0
- package/workspace/public/pm/lifecycle-lens.js +261 -0
- package/workspace/public/pm/new-initiative-sheet.js +116 -21
- package/workspace/public/pm/outbox-panel.js +125 -0
- package/workspace/public/pm/pm-work-view.js +919 -0
- package/workspace/public/pm/releases-view.js +248 -0
- package/workspace/public/pm/report-families-view.js +503 -0
- package/workspace/public/pm/run-card.js +244 -0
- package/workspace/public/pm/sessions-panel.js +149 -0
- package/workspace/public/pm/shell-bell.js +162 -0
- package/workspace/public/pm/shell-model.js +83 -0
- package/workspace/public/pm/shell-phone.js +124 -0
- package/workspace/public/pm/shell-readaloud.js +53 -0
- package/workspace/public/pm/shell-recording.js +110 -0
- package/workspace/public/pm/shell-ticker.js +90 -0
- package/workspace/public/pm/slide-confirm.js +163 -0
- package/workspace/public/pm/timeline-forecast-view.js +164 -0
- package/workspace/public/pm/today-day.js +8 -2
- package/workspace/public/pm/tokens.css +1 -0
- package/workspace/server.js +5 -2
- package/workspace/test-files.json +8 -0
- package/workspace/test-lists/browser-suites-green.json +5 -0
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/* pm/c3-doc-sheet.js — the DOCUMENT SHEET of /c3.html Initiatives > Documents (console3.html: docPanel line 4090, dpHead 4089, the diff 4075 and 4076, the sheet 4104,
|
|
2
|
+
* the handlers dmode, dask, dsave 4136 to 4143, relHtml 4180 to 4190). The right-hand sheet from c3-sheet.js, up to 780px, a bottom sheet on a phone.
|
|
3
|
+
*
|
|
4
|
+
* openDocSheet({ ws, doc, mode, prompt, relations(doc, openOther), onChanged }) doc = { path, name, ty, v, st, ini }
|
|
5
|
+
*
|
|
6
|
+
* Six modes (the segment under the header): Read (one `.dsec` per heading with who changed it last), Edit by hand (sections become editable; a WHY is required;
|
|
7
|
+
* POST /api/doc-sheet/edit), Ask the agent (the word diff of what the agent proposes and Accept as a slide bound to the proposal's hash: POST /api/doc-sheet/accept),
|
|
8
|
+
* History (GET /api/doc-sheet/revisions and /at: the revisions and a compare of any one with the one before), Health (POST /api/doc-health/check: the six checks, N of M),
|
|
9
|
+
* Relations (the document graph around this document, from the library's edges).
|
|
10
|
+
* What the page cannot do, it says so: the agent runs in a terminal session, not here, and there is no route that lists its proposals. So in real mode Ask the agent shows
|
|
11
|
+
* the request and offers a copy of it, with the Ask button disabled and the reason beside it; the demo (?demo=1) answers the propose and accept routes from fixtures, so the
|
|
12
|
+
* whole flow can be seen. Nothing is invented: an edit writes the file through the route, and every save keeps its why in the revisions.
|
|
13
|
+
* Pure parts (Node-testable): splitDoc, joinDoc, changedSections, wordDiffTokens, byName, dayName. */
|
|
14
|
+
import { el } from './dom.js';
|
|
15
|
+
import { pmGet, pmPost } from './http.js';
|
|
16
|
+
import { c3ic, c3ib } from './pm-work-view.js';
|
|
17
|
+
import { openSheet } from './c3-sheet.js';
|
|
18
|
+
import { renderCase } from './c3-case.js';
|
|
19
|
+
import { say } from './c3-init-extras.js';
|
|
20
|
+
|
|
21
|
+
/* ── pure: sections, round trip, word diff ── */
|
|
22
|
+
|
|
23
|
+
/** Split a markdown text into sections at its headings. Each section keeps the exact text it came from, so joinDoc(splitDoc(t)) === t. */
|
|
24
|
+
export function splitDoc(text) {
|
|
25
|
+
const t = String(text == null ? '' : text);
|
|
26
|
+
const heads = []; let off = 0; let fence = false;
|
|
27
|
+
for (const line of t.split('\n')) {
|
|
28
|
+
if (/^\s*(```|~~~)/.test(line)) fence = !fence;
|
|
29
|
+
if (!fence && /^#{1,6}\s+\S/.test(line)) heads.push({ at: off, line, end: off + line.length + 1 });
|
|
30
|
+
off += line.length + 1;
|
|
31
|
+
}
|
|
32
|
+
const raw = [];
|
|
33
|
+
const first = heads.length ? heads[0].at : t.length;
|
|
34
|
+
if (first > 0 || !heads.length) raw.push({ prefix: '', name: /^---\n/.test(t) ? 'Front matter' : 'Overview', body: t.slice(0, first) });
|
|
35
|
+
heads.forEach((h, i) => raw.push({ prefix: t.slice(h.at, Math.min(h.end, t.length)), name: h.line.replace(/^#+\s+/, '').replace(/\s+#+\s*$/, '').trim(), body: t.slice(Math.min(h.end, t.length), i + 1 < heads.length ? heads[i + 1].at : t.length) }));
|
|
36
|
+
return raw.map((s, i) => {
|
|
37
|
+
const lead = s.body.match(/^\s*/)[0]; const rest = s.body.slice(lead.length); const tail = rest.match(/\s*$/)[0];
|
|
38
|
+
return { i, prefix: s.prefix, name: s.name, lead, tail, text: rest.slice(0, rest.length - tail.length) };
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Put the sections back together. `edits` (optional) maps a section index to its new text; every other section is written as it was. */
|
|
43
|
+
export function joinDoc(secs, edits) {
|
|
44
|
+
return secs.map((s) => s.prefix + s.lead + (edits && Object.prototype.hasOwnProperty.call(edits, s.i) ? edits[s.i] : s.text) + s.tail).join('');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The sections of `after` that differ from `before`, by name: [{ name, before, after, kind: 'changed'|'added'|'removed' }]. */
|
|
48
|
+
export function changedSections(before, after) {
|
|
49
|
+
const A = splitDoc(before); const B = splitDoc(after); const out = [];
|
|
50
|
+
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' }); });
|
|
51
|
+
A.forEach((q) => { if (!B.some((s) => s.name === q.name)) out.push({ name: q.name, before: q.text, after: '', kind: 'removed' }); });
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The word diff (the mockup's wdiff, line 4065): [{ op: 'same'|'add'|'del', text }], whitespace kept in the tokens. */
|
|
56
|
+
export function wordDiffTokens(a, b) {
|
|
57
|
+
const x = String(a || '').split(/(\s+)/).filter((s) => s !== ''); const y = String(b || '').split(/(\s+)/).filter((s) => s !== '');
|
|
58
|
+
const n = x.length; const m = y.length;
|
|
59
|
+
if (n * m > 600000) return [...(a ? [{ op: 'del', text: a }] : []), ...(b ? [{ op: 'add', text: b }] : [])];
|
|
60
|
+
const L = []; for (let i = 0; i <= n; i++) L.push(new Array(m + 1).fill(0));
|
|
61
|
+
for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--) L[i][j] = x[i] === y[j] ? L[i + 1][j + 1] + 1 : Math.max(L[i + 1][j], L[i][j + 1]);
|
|
62
|
+
const out = []; const push = (op, text) => { const l = out[out.length - 1]; if (l && l.op === op) l.text += text; else out.push({ op, text }); };
|
|
63
|
+
let i = 0; let j = 0;
|
|
64
|
+
while (i < n && j < m) { if (x[i] === y[j]) { push('same', x[i]); i++; j++; } else if (L[i + 1][j] >= L[i][j + 1]) push('del', x[i++]); else push('add', y[j++]); }
|
|
65
|
+
while (i < n) push('del', x[i++]);
|
|
66
|
+
while (j < m) push('add', y[j++]);
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Who a ledger `by` is, as the page says it. */
|
|
71
|
+
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:/, ''); };
|
|
72
|
+
const WD = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; const MON = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
73
|
+
/** '2026-10-05T09:00:00Z' to 'Mon 5 Oct'. Anything that is not a date comes back as it is. */
|
|
74
|
+
export function dayName(v) {
|
|
75
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(v || '')); if (!m) return String(v || '');
|
|
76
|
+
const d = new Date(Date.UTC(+m[1], +m[2] - 1, +m[3]));
|
|
77
|
+
return WD[d.getUTCDay()] + ' ' + (+m[3]) + ' ' + MON[+m[2] - 1];
|
|
78
|
+
}
|
|
79
|
+
const nextV = (v) => { const m = /^v(\d+)$/.exec(v || ''); return m ? 'v' + (+m[1] + 1) : 'v1'; };
|
|
80
|
+
|
|
81
|
+
/* ── the sheet ── */
|
|
82
|
+
|
|
83
|
+
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' };
|
|
84
|
+
const hcol = (h) => (!h || h.total === 0 ? 'var(--muted-foreground)' : h.passed === h.total ? 'var(--success)' : h.total - h.passed === 1 ? 'var(--warning)' : 'var(--destructive)');
|
|
85
|
+
const STATE = { current: ['Current', 'var(--success)', 'check'], stale: ['Stale', 'var(--warning)', 'alert'], draft: ['Draft', 'var(--acc)', 'clock'], missing: ['Missing', 'var(--destructive)', 'plus'] };
|
|
86
|
+
const TABS = [['read', 'eye', 'Read'], ['edit', 'notes', 'Edit by hand'], ['ask', 'spark', 'Ask the agent'], ['hist', 'clock', 'History'], ['health', 'shield', 'Health'], ['links', 'init', 'Relations']];
|
|
87
|
+
const isDemo = () => !!window.__c3demo;
|
|
88
|
+
const q = (o) => Object.entries(o).map(([k, v]) => k + '=' + encodeURIComponent(v)).join('&');
|
|
89
|
+
|
|
90
|
+
/** 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. */
|
|
91
|
+
function demoDraft(secs, prompt) {
|
|
92
|
+
const p = prompt.toLowerCase(); const out = secs.map((s) => ({ name: s.name, text: s.text })); let did = false;
|
|
93
|
+
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.'; });
|
|
94
|
+
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; } });
|
|
95
|
+
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; }
|
|
96
|
+
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; }
|
|
97
|
+
if (!did) out.push({ name: 'Agent note', text: 'Drafted from your request: ' + prompt.slice(0, 160) });
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
/** 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. */
|
|
101
|
+
const sectionsToText = (before, drafted) => {
|
|
102
|
+
const A = splitDoc(before); const names = new Set(A.map((s) => s.name)); const edits = {};
|
|
103
|
+
A.forEach((s) => { const d = drafted.find((x) => x.name === s.name); if (d && d.text !== s.text) edits[s.i] = d.text; });
|
|
104
|
+
let text = joinDoc(A, edits);
|
|
105
|
+
for (const d of drafted) {
|
|
106
|
+
if (names.has(d.name)) continue;
|
|
107
|
+
if (d.name === 'Summary') text = '## Summary\n' + d.text + '\n\n' + text;
|
|
108
|
+
else text = text.replace(/\s*$/, '\n\n') + '## ' + d.name + '\n' + d.text + '\n';
|
|
109
|
+
}
|
|
110
|
+
return text;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/* Two additive options for a report, which is a document in the mockup (console3.html r2doc):
|
|
114
|
+
* 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
|
|
115
|
+
* need a stored document (why), History says there is none, Health shows `health` ({ passed, total, checks }) as given.
|
|
116
|
+
* secExtra(name) a node under each section in Read: the report's claims and where they come from. */
|
|
117
|
+
export function openDocSheet({ ws, doc, mode = 'read', prompt = '', relations, onChanged, generated, secExtra }) {
|
|
118
|
+
const S = { draft: {}, mode, prompt, text: null, err: '', revs: null, texts: {}, cmp: null, health: null, hAsk: false, prop: null, busy: false, changed: false, why: '', metaFor: null };
|
|
119
|
+
const demo = isDemo();
|
|
120
|
+
const base = { ws, path: doc.path };
|
|
121
|
+
let ui = null;
|
|
122
|
+
|
|
123
|
+
/* data */
|
|
124
|
+
const loadText = async () => {
|
|
125
|
+
if (generated) { S.text = String(generated.text || ''); S.err = ''; return; }
|
|
126
|
+
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 = ''; }
|
|
127
|
+
catch (e) { S.text = null; S.err = (e && e.message) || 'could not read the document'; }
|
|
128
|
+
};
|
|
129
|
+
const loadRevs = async () => {
|
|
130
|
+
if (generated) { S.revs = []; S.revsErr = generated.why; return; }
|
|
131
|
+
const j = await pmGet('/api/doc-sheet/revisions?' + q(base)).catch(() => null);
|
|
132
|
+
S.revs = j && j.ok ? j.revisions : []; S.revsErr = j && j.ok ? '' : (j && j.error) || 'The history could not be read.';
|
|
133
|
+
};
|
|
134
|
+
const textAt = async (rev) => {
|
|
135
|
+
if (S.texts[rev] != null) return S.texts[rev];
|
|
136
|
+
const j = await pmGet('/api/doc-sheet/at?' + q({ ...base, rev })).catch(() => null);
|
|
137
|
+
S.texts[rev] = j && j.ok ? j.text : null; return S.texts[rev];
|
|
138
|
+
};
|
|
139
|
+
const loadHealth = async () => {
|
|
140
|
+
S.hAsk = true; draw();
|
|
141
|
+
if (generated) { S.health = generated.health || { passed: 0, total: 0, checks: [], err: generated.why }; drawBg(); return; }
|
|
142
|
+
const r = await pmPost('/api/doc-health/check', base);
|
|
143
|
+
S.health = r && r.ok && r.health ? r.health : { passed: 0, total: 0, checks: [], err: (r && r.error) || 'The checks could not run.' }; drawBg();
|
|
144
|
+
};
|
|
145
|
+
/* who changed each section last: needs the text of every revision (a few, fetched once) */
|
|
146
|
+
const loadMeta = async () => {
|
|
147
|
+
if (!S.revs || !S.revs.length || S.metaFor === S.revs.length) return;
|
|
148
|
+
S.metaFor = S.revs.length;
|
|
149
|
+
const rs = S.revs.slice(-12);
|
|
150
|
+
const texts = await Promise.all(rs.map((r) => textAt(r.rev)));
|
|
151
|
+
const meta = {};
|
|
152
|
+
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; }); });
|
|
153
|
+
S.meta = meta; drawBg();
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
/* ── drawing ── */
|
|
157
|
+
const head = () => {
|
|
158
|
+
const st = STATE[doc.st] || STATE.current; const h = S.health;
|
|
159
|
+
return el('div', { class: 'chips2', style: 'flex-wrap:wrap', 'data-doc-meta': '' },
|
|
160
|
+
el('span', { class: 'chip2', style: 'color:' + 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,
|
|
161
|
+
h && h.total ? el('span', { class: 'chip2', style: 'color:' + hcol(h) }, c3ic('shield', 'sm'), h.passed + ' of ' + h.total + ' checks') : null,
|
|
162
|
+
el('span', { class: 'chip2' }, doc.iniName || doc.ini || 'Portfolio'));
|
|
163
|
+
};
|
|
164
|
+
const failing = () => (S.health ? S.health.checks.filter((c) => c.pass === false) : []);
|
|
165
|
+
const seg = () => el('div', { class: 'seg', role: 'group', 'aria-label': 'Document mode' }, TABS.map(([k, ic, l]) => {
|
|
166
|
+
const bad = k === 'health' ? failing().length : 0;
|
|
167
|
+
return el('button', { type: 'button', 'aria-pressed': String(S.mode === k), 'aria-label': l, title: l, 'data-dmode': k, onclick: () => go(k) }, c3ic(ic, 'sm'), S.mode === k ? l : null, bad ? [' ', el('span', { class: 'bdg' }, String(bad))] : null);
|
|
168
|
+
}));
|
|
169
|
+
const go = (m) => { S.mode = m; if (m !== 'hist') S.cmp = null; if (m === 'health' && !S.hAsk) loadHealth(); if (m === 'hist' && S.revs === null) loadRevs().then(draw); if (m === 'read' || m === 'edit') loadMeta(); draw(); };
|
|
170
|
+
|
|
171
|
+
const flags = () => { const f = {}; failing().forEach((c) => { if (c.section && c.flag) f[c.section] = c.flag; }); return f; };
|
|
172
|
+
const readBody = (editing, quiet) => {
|
|
173
|
+
if (editing && generated) return el('div', { class: 'dpn' }, el('div', { class: 'fold w', role: 'note', 'data-doc-nostore': 'edit' }, c3ic('lock', 'sm'), generated.why), readBody(false, true));
|
|
174
|
+
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.');
|
|
175
|
+
const secs = splitDoc(S.text); const fl = flags(); const meta = S.meta || {};
|
|
176
|
+
if (!secs.some((s) => s.text.trim() || s.name !== 'Overview')) return el('div', { class: 'fold' }, c3ic('moon', 'sm'), 'The document is empty.');
|
|
177
|
+
const cells = secs.map((s) => {
|
|
178
|
+
const mt = meta[s.name];
|
|
179
|
+
const dt = el('div', { class: 'dt', 'data-sec': s.i, ...(editing ? { contenteditable: 'plaintext-only', role: 'textbox', 'aria-label': s.name, 'aria-multiline': 'true', style: 'white-space:pre-wrap' } : { style: 'white-space:pre-wrap' }) }, editing && S.draft[s.i] != null ? S.draft[s.i] : s.text);
|
|
180
|
+
if (editing && dt.contentEditable !== 'plaintext-only') dt.setAttribute('contenteditable', 'true');
|
|
181
|
+
if (editing) dt.addEventListener('input', () => { S.draft[s.i] = dt.contentEditable === 'plaintext-only' ? dt.textContent : dt.innerText; });
|
|
182
|
+
return el('div', { class: 'dsec' + (fl[s.name] ? ' warn' : ''), 'data-dsec': s.name }, el('h4', null, s.name), dt,
|
|
183
|
+
el('div', { class: 'mt' }, mt ? el('span', { style: 'color:' + (byName(mt.by) === 'Agent' ? 'var(--purple)' : 'var(--acc)') }, c3ic(byName(mt.by) === 'Agent' ? 'spark' : 'user', 'sm'), 'Last changed by ' + byName(mt.by) + ', ' + dayName(mt.at)) : null,
|
|
184
|
+
fl[s.name] ? el('span', { style: 'color:var(--warning)' }, c3ic('alert', 'sm'), fl[s.name]) : null), !editing && secExtra ? secExtra(s.name) : null);
|
|
185
|
+
});
|
|
186
|
+
if (!editing) return el('div', { class: 'dpn' }, generated && !quiet ? el('div', { class: 'fold', role: 'note', 'data-doc-generated': '' }, c3ic('info', 'sm'), generated.why) : null, cells);
|
|
187
|
+
const why = el('input', { type: 'text', id: 'dwhy', placeholder: 'Why are you changing it? (kept in the history)', 'aria-label': 'Why', 'data-doc-why': '', value: S.why, onInput: (e) => { S.why = e.target.value; } });
|
|
188
|
+
return el('div', { class: 'dpn' }, cells, why,
|
|
189
|
+
S.err ? el('div', { class: 'fold w', role: 'alert' }, c3ic('alert', 'sm'), S.err) : null,
|
|
190
|
+
el('div', { class: 'chips2' }, c3ib('Save as ' + nextV(doc.v), 'check', { 'data-doc-save': '', onClick: () => save(secs) }, ''), c3ib('Cancel', 'x', { onClick: () => { S.err = ''; S.draft = {}; go('read'); } }, '')));
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
async function save(secs) {
|
|
194
|
+
const edits = {};
|
|
195
|
+
ui.body.querySelectorAll('[data-sec]').forEach((n) => { const i = Number(n.dataset.sec); const v = n.contentEditable === 'plaintext-only' ? n.textContent : n.innerText; if (v !== secs[i].text) edits[i] = v; });
|
|
196
|
+
if (!Object.keys(edits).length) { say('Nothing changed'); S.err = ''; S.draft = {}; go('read'); return; }
|
|
197
|
+
if (!S.why.trim()) { S.err = 'Say why the document changes: the reason is kept in the history.'; draw(); ui.body.querySelector('#dwhy') && ui.body.querySelector('#dwhy').focus(); return; }
|
|
198
|
+
const before = S.text; const after = joinDoc(secs, edits);
|
|
199
|
+
const r = await pmPost('/api/doc-sheet/edit', { ...base, text: after, why: S.why.trim() });
|
|
200
|
+
if (!r || !r.ok) { S.err = (r && r.error) || 'The document was not saved.'; draw(); return; }
|
|
201
|
+
S.err = ''; S.why = ''; S.draft = {}; S.changed = true; S.revs = null; S.meta = null; S.metaFor = null; S.texts = {}; S.health = null; S.hAsk = false;
|
|
202
|
+
await loadText(); go('read');
|
|
203
|
+
say('Saved as ' + nextV(doc.v) + (r.rev ? ' (revision ' + r.rev + ')' : ''), async () => {
|
|
204
|
+
const u = await pmPost('/api/doc-sheet/edit', { ...base, text: before, why: 'Undone: restored the text from before the last edit.' });
|
|
205
|
+
if (u && u.ok) { S.revs = null; S.meta = null; S.metaFor = null; S.texts = {}; S.health = null; S.hAsk = false; await loadText(); draw(); } else say((u && u.error) || 'The edit could not be undone.');
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/* Ask the agent */
|
|
210
|
+
const diffNode = (list, all) => {
|
|
211
|
+
const rows = list.filter((d) => all || d.kind !== 'same');
|
|
212
|
+
return el('div', { class: 'dpn dfi', 'data-doc-diff': '' }, rows.length ? rows.map((d) => el('div', { class: 'dsec warn' }, el('h4', null, d.name), el('p', null, wordDiffTokens(d.before, d.after).map((t) => (t.op === 'add' ? el('ins', null, t.text) : t.op === 'del' ? el('del', null, t.text) : t.text))))) : el('div', { class: 'dim' }, 'No change.'));
|
|
213
|
+
};
|
|
214
|
+
const askBody = () => {
|
|
215
|
+
if (generated) return el('div', { class: 'dpn' }, el('div', { class: 'fold w', role: 'note', 'data-doc-nostore': 'ask' }, c3ic('lock', 'sm'), generated.why));
|
|
216
|
+
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);
|
|
217
|
+
const sug = [...new Set(failing().map((c) => c.fix).filter(Boolean))];
|
|
218
|
+
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.'); } };
|
|
219
|
+
const kids = [ta, el('div', { class: 'chips2' },
|
|
220
|
+
demo ? c3ib('Ask the agent', 'spark', { 'data-doc-ask': '', onClick: () => askDemo() }, '') : el('button', { type: 'button', class: 'pill', disabled: true, 'aria-disabled': 'true', 'data-doc-ask': '', title: 'The agent runs in your terminal session. This page cannot start it.' }, c3ic('lock', 'sm'), 'Ask the agent'),
|
|
221
|
+
!demo ? el('button', { type: 'button', class: 'chip2 lk', 'data-doc-copy': '', onclick: copy }, c3ic('doc', 'sm'), 'Copy the request') : null,
|
|
222
|
+
...sug.map((t, i) => el('button', { type: 'button', class: 'chip2 lk', 'data-doc-chip': String(i), onclick: () => { S.prompt = t; draw(); } }, c3ic('spark', 'sm'), t)))];
|
|
223
|
+
if (!demo) kids.push(el('div', { class: 'fold', role: 'note', 'data-doc-ask-why': '' }, c3ic('lock', 'sm'), 'The agent works in your terminal session, so this page cannot start it, and no route lists what it proposes. Copy the request to the session; its proposal is recorded through the proposal routes and is accepted here only by your slide, bound to its hash.'));
|
|
224
|
+
if (S.prop) kids.push(propCase());
|
|
225
|
+
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.'));
|
|
226
|
+
return el('div', { class: 'dpn' }, kids);
|
|
227
|
+
};
|
|
228
|
+
async function askDemo() {
|
|
229
|
+
const pr = (S.prompt || '').trim(); if (!pr) { say('Tell the agent what to change'); return; }
|
|
230
|
+
const before = S.text || ''; const drafted = demoDraft(splitDoc(before), pr); const after = sectionsToText(before, drafted);
|
|
231
|
+
const r = await pmPost('/api/doc-sheet/propose', { ...base, prompt: pr, after });
|
|
232
|
+
if (!r || !r.ok) { say((r && r.error) || 'The agent did not answer.'); return; }
|
|
233
|
+
S.prop = { id: r.id, hash: r.hash, prompt: pr, before, after, nextV: nextV(doc.v) }; draw();
|
|
234
|
+
}
|
|
235
|
+
function propCase() {
|
|
236
|
+
const P = S.prop; const D = changedSections(P.before, P.after);
|
|
237
|
+
const c = renderCase({ id: 'docedit:' + doc.path, sit: 'You asked: “' + P.prompt + '”. The draft changes ' + D.length + ' section' + (D.length === 1 ? '' : 's') + ' and saves as ' + P.nextV + '. Nothing is shared until you send it.', conf: 76,
|
|
238
|
+
chg: D.map((x) => ['notes', 'Section: ' + x.name, x.kind, 'warn']), cta: 'Slide to accept ' + P.nextV,
|
|
239
|
+
opts: [{ id: 'ac', l: 'Accept as ' + P.nextV, rec: 1, fx: [['check', 'Health is re-checked', 'ok'], ['lock', 'Bound to proposal ' + P.hash.slice(0, 8), '']] }, { id: 'rf', l: 'Change the request', fx: [] }, { id: 'ds', l: 'Discard', fx: [] }] },
|
|
240
|
+
{ onConfirm: async ({ option }) => {
|
|
241
|
+
if (option.id === 'ds') { S.prop = null; say('Discarded.'); draw(); return; }
|
|
242
|
+
if (option.id === 'rf') { S.prop = null; draw(); const t = ui.body.querySelector('#dpr'); if (t) t.focus(); return; }
|
|
243
|
+
const r = await pmPost('/api/doc-sheet/accept', { ...base, id: P.id, hash: P.hash });
|
|
244
|
+
if (!r || !r.ok) { say('Not accepted: ' + ((r && (r.error || r.reason)) || 'the proposal changed.')); return; }
|
|
245
|
+
S.prop = null; S.changed = true; S.revs = null; S.meta = null; S.metaFor = null; S.texts = {}; S.health = null; S.hAsk = false; doc.v = P.nextV; if (doc.st !== 'current') doc.st = 'current';
|
|
246
|
+
await loadText(); go('read'); say('Saved as ' + P.nextV + '. History records your request.');
|
|
247
|
+
} });
|
|
248
|
+
const viz = diffNode(D.map((x) => ({ ...x })));
|
|
249
|
+
c.insertBefore(viz, c.querySelector('.chgl') || c.querySelector('.opts'));
|
|
250
|
+
return c;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/* History */
|
|
254
|
+
const histBody = () => {
|
|
255
|
+
if (S.revs === null) return el('div', { class: 'fold', role: 'status' }, c3ic('clock', 'sm'), 'Reading the history.');
|
|
256
|
+
if (!S.revs.length) return el('div', { class: 'fold' }, c3ic('info', 'sm'), S.revsErr || 'No revision is recorded yet. The first save through this sheet starts the history.');
|
|
257
|
+
const rs = S.revs.slice().reverse();
|
|
258
|
+
const cmp = S.cmp != null ? S.revs.find((r) => r.rev === S.cmp) : null;
|
|
259
|
+
return el('div', { class: 'dpn' }, el('ul', { class: 'hx' }, rs.map((r) => {
|
|
260
|
+
const prev = S.revs.find((x) => x.rev === r.rev - 1);
|
|
261
|
+
return el('li', null, el('span', { class: 'chip2', style: 'color:' + (byName(r.by) === 'Agent' ? 'var(--purple)' : 'var(--acc)') }, c3ic(byName(r.by) === 'Agent' ? 'spark' : 'user', 'sm'), byName(r.by)),
|
|
262
|
+
el('span', null, el('b', null, r.v || 'Revision ' + r.rev), ' ' + (r.why || ''), el('small', null, prev ? el('button', { type: 'button', class: 'chip2 lk', 'data-doc-cmp': String(r.rev), onclick: () => cmpWith(r.rev) }, c3ic('eye', 'sm'), 'Compare with ' + (prev.v || 'revision ' + prev.rev)) : 'First version')),
|
|
263
|
+
el('span', { class: 'w' }, dayName(r.at)));
|
|
264
|
+
})), cmp ? el('div', { class: 'dpn' }, el('h4', { style: 'margin:0;font-size:var(--fs-3)' }, ((S.revs.find((x) => x.rev === cmp.rev - 1) || {}).v || 'Revision ' + (cmp.rev - 1)) + ' to ' + (cmp.v || 'revision ' + cmp.rev)), S.cmpNode || el('div', { class: 'fold' }, c3ic('clock', 'sm'), 'Comparing.')) : null);
|
|
265
|
+
};
|
|
266
|
+
async function cmpWith(rev) {
|
|
267
|
+
if (S.cmp === rev) { S.cmp = null; draw(); return; }
|
|
268
|
+
S.cmp = rev; S.cmpNode = null; draw();
|
|
269
|
+
const [a, b] = await Promise.all([textAt(rev - 1), textAt(rev)]);
|
|
270
|
+
if (S.cmp !== rev) return;
|
|
271
|
+
S.cmpNode = a == null || b == null ? el('div', { class: 'fold w' }, c3ic('alert', 'sm'), 'That revision could not be read.') : diffNode(changedSections(a, b));
|
|
272
|
+
draw();
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/* Health */
|
|
276
|
+
const healthBody = () => {
|
|
277
|
+
const h = S.health;
|
|
278
|
+
if (!h) return el('div', { class: 'fold', role: 'status' }, c3ic('clock', 'sm'), 'Checking this document.');
|
|
279
|
+
if (h.err) return el('div', { class: 'fold w', role: 'alert' }, c3ic('alert', 'sm'), 'The checks could not run: ' + h.err + '. Nothing is shown.');
|
|
280
|
+
const unanswered = h.checks.filter((c) => c.pass === null).length;
|
|
281
|
+
return el('div', { class: 'dpn' }, el('div', { class: 'chips2', 'data-doc-health': '' }, el('span', { class: 'ki', style: 'color:' + hcol(h) }, c3ic('shield')), el('b', null, h.passed + ' of ' + h.total + ' checks pass')),
|
|
282
|
+
unanswered ? el('p', { class: 'dim', style: 'font-size:var(--fs-2);margin:0' }, unanswered + ' check' + (unanswered === 1 ? '' : 's') + ' not counted: not enough input.') : null,
|
|
283
|
+
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'),
|
|
284
|
+
el('span', null, (c.name || NAMES[c.id] || c.id) + ': ' + c.reason + ' ', c.pass === false && !generated ? el('button', { type: 'button', class: 'chip2 lk', 'data-doc-fix': c.id, onclick: () => { S.prompt = c.fix || 'Fix: ' + (NAMES[c.id] || c.id) + ' (' + c.reason + ')'; go('ask'); } }, c3ic('spark', 'sm'), 'Fix with the agent') : null),
|
|
285
|
+
el('span', { class: 'tag' }, c.pass === null ? 'no input' : c.pass ? 'pass' : 'fail')))),
|
|
286
|
+
el('div', { class: 'dim', style: 'font-size:var(--fs-2)' }, 'Health answers one question: can I trust this document without rereading it? Checks are recomputed from the text every time it changes.'));
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
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.'));
|
|
290
|
+
|
|
291
|
+
/* a load that finished in the background must not redraw under a hand that is typing */
|
|
292
|
+
const drawBg = () => { const a = document.activeElement; if (S.mode === 'edit' && a && ui && ui.body.contains(a) && a.closest('[data-sec],#dwhy')) return; draw(); };
|
|
293
|
+
function draw() {
|
|
294
|
+
if (!ui) return;
|
|
295
|
+
const body = S.mode === 'read' ? readBody(false) : S.mode === 'edit' ? readBody(true) : S.mode === 'ask' ? askBody() : S.mode === 'hist' ? histBody() : S.mode === 'health' ? healthBody() : linksBody();
|
|
296
|
+
const keep = ui.body.querySelector('textarea,input');
|
|
297
|
+
const had = keep && document.activeElement === keep ? { id: keep.id, s: keep.selectionStart } : null;
|
|
298
|
+
ui.body.replaceChildren(el('div', { class: 'dpn' }, head(), seg(), body));
|
|
299
|
+
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 */ } } }
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
ui = openSheet({ title: doc.name, icon: 'doc', dw: true, body: el('div', { class: 'dpn' }), onClose: () => { if (S.changed && onChanged) onChanged(); } });
|
|
303
|
+
S.open = (d) => { ui.close(); if (S.openOther) S.openOther(d); };
|
|
304
|
+
ui.setOpener = (fn) => { S.openOther = fn; };
|
|
305
|
+
draw();
|
|
306
|
+
(async () => {
|
|
307
|
+
await loadText(); drawBg();
|
|
308
|
+
if (S.mode === 'health' && !S.hAsk) loadHealth();
|
|
309
|
+
if (S.mode === 'hist') loadRevs().then(draw);
|
|
310
|
+
loadHealth();
|
|
311
|
+
await loadRevs(); loadMeta();
|
|
312
|
+
})();
|
|
313
|
+
return ui;
|
|
314
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/* pm/c3-dock.js — the console3 dock on /c3.html: the mockup's repository, branch and session tree (console3.html renderDock, line 1957).
|
|
2
|
+
* Header: the repository select, a start link and the fold button. Tree: one .br per working tree of the repository, its branch on
|
|
3
|
+
* top and the sessions that run in that tree under it, each with a state glyph, a provider icon and a name. Below the tree: the
|
|
4
|
+
* selected session's detail (what it is, where it runs, how long, the output tail, the Message box) and a scratchpad.
|
|
5
|
+
* Real data only:
|
|
6
|
+
* GET /api/git/worktrees?ws= the working trees and their branches (the tree is these, not every branch of the repository)
|
|
7
|
+
* GET /api/sessions the sessions; one belongs to a tree through its `tree` path and to a repository through `ws`
|
|
8
|
+
* GET /api/sessions/transcript the output tail, already stripped of terminal codes
|
|
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
|
+
* GET and POST /api/scratchpad the session's pad
|
|
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 rail's
|
|
13
|
+
* button (it opens this dock, the terminals of your branches) and every "Open the session" link. The rail draws one glyph button per session below it. */
|
|
14
|
+
import { el } from './dom.js';
|
|
15
|
+
import { pmGet, pmPost } from './http.js';
|
|
16
|
+
import { c3ic } from './pm-work-view.js';
|
|
17
|
+
import { ic, TIPS } from './c3-terminal-icons.js';
|
|
18
|
+
import { source, needsYou } from './c3-terminal-data.js';
|
|
19
|
+
import { toast } from './c3-toast.js';
|
|
20
|
+
|
|
21
|
+
/* the mockup's SM (line 1953): glyph and class by state. The server's `shown` words map onto them. */
|
|
22
|
+
const SM = { working: ['●', 'w'], needs: ['◆', 'n'], quiet: ['◼', ''], blocked: ['◇', 'b'], exited: ['○', ''] };
|
|
23
|
+
const smOf = (s) => SM[s.shown] || SM[s.state] || ['●', ''];
|
|
24
|
+
const shownOf = (s, ended) => (ended && ended.has(s.id) ? 'exited' : s.shown);
|
|
25
|
+
const PI = { claude: 'term', cloud: 'cloud', codex: 'bolt', shell: 'term', local: 'term' }; // spark is reserved for the agent surfaces
|
|
26
|
+
const PN = { claude: 'Claude', cloud: 'Claude cloud', codex: 'Codex', shell: 'Shell', local: 'Local' };
|
|
27
|
+
const NOEND = 'Ending a session needs the page route /api/session/remove, which did not answer. Use the terminal in the main app.';
|
|
28
|
+
const MORE = 'Sending a prompt is not available on this page: no safe route sends one. Use the session\'s terminal in the main app.';
|
|
29
|
+
const MAX_TREES = 10;
|
|
30
|
+
|
|
31
|
+
const ago = (ms) => {
|
|
32
|
+
if (!Number.isFinite(ms)) return null;
|
|
33
|
+
const m = Math.max(0, Math.round(ms / 60000));
|
|
34
|
+
return m < 1 ? 'just now' : m < 60 ? m + ' min' : m < 1440 ? Math.round(m / 60) + ' h' : Math.round(m / 1440) + ' d';
|
|
35
|
+
};
|
|
36
|
+
const clean = (t) => String(t || '').replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '').replace(/[ \t]+$/gm, '');
|
|
37
|
+
const tail = (t, n) => clean(t).split('\n').map((l) => l.trimEnd()).filter((l, i, a) => l.trim() || (a[i - 1] && a[i - 1].trim())).slice(-n).join('\n').slice(-900);
|
|
38
|
+
|
|
39
|
+
export function createDock(opts) {
|
|
40
|
+
const o = opts || {};
|
|
41
|
+
const S = { ws: '', sessions: [], trees: null, sel: null, err: null, seq: 0, ended: new Set(), confirm: null, need: 0 };
|
|
42
|
+
const sel = el('select', { 'aria-label': 'Repository', 'data-c3pg': 'ws-select' });
|
|
43
|
+
const tree = el('div', { class: 'dk-tree', 'data-c3pg': 'dock-tree' });
|
|
44
|
+
const detail = el('div', { class: 'dk-p', 'data-c3pg': 'dock-detail' });
|
|
45
|
+
const body = el('div', { class: 'dk-body' }, tree, detail);
|
|
46
|
+
/* the AGENT button: the full agent surfaces (the Terminal page), not a terminal. It carries the needs-you count; the header's button opens the same page. */
|
|
47
|
+
const agentBdg = el('span', { class: 'bdg', hidden: true, 'data-c3pg': 'dock-agent-count' }, '0');
|
|
48
|
+
const listBtn = el('button', { type: 'button', class: 'ib sm', title: TIPS.agentDock, 'aria-label': TIPS.agentDock, 'aria-haspopup': 'dialog', 'data-c3pg': 'dock-agent', style: 'position:relative',
|
|
49
|
+
onclick: () => { import('./c3-terminal.js').then((m) => m.openTerminalPage()); } }, ic('pulse'), agentBdg);
|
|
50
|
+
const side = el('aside', { class: 'c3dock dock', 'aria-label': 'Sessions', 'data-c3pg': 'dock' },
|
|
51
|
+
el('div', { class: 'dk-h' }, c3ic('repo', 'sm'), sel,
|
|
52
|
+
listBtn,
|
|
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
|
+
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
|
+
body);
|
|
56
|
+
const rail = el('div', { class: 'c3dock dock rail', 'data-c3pg': 'dock-rail' });
|
|
57
|
+
|
|
58
|
+
const withEnd = (s) => (S.ended.has(s.id) ? { ...s, shown: 'exited', label: 'ended' } : s);
|
|
59
|
+
const mine = () => S.sessions.filter((s) => s.ws === S.ws && !s.automatic).map(withEnd);
|
|
60
|
+
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(); drawRail(); if (o.setOpen) o.setOpen(true); };
|
|
62
|
+
|
|
63
|
+
function row(s) {
|
|
64
|
+
const g = smOf(s);
|
|
65
|
+
return el('button', { type: 'button', class: 'sr', 'data-c3-sess': s.id, 'aria-current': String(s.id === S.sel), title: (PN[s.agent] || s.agent) + ' · ' + (s.label || s.shown), onclick: () => pick(s.id) },
|
|
66
|
+
el('span', { class: 'mk ' + g[1] }, g[0]), c3ic(PI[s.agent] || 'term', 'sm'), el('span', { class: 'nm' }, s.name));
|
|
67
|
+
}
|
|
68
|
+
function drawTree() {
|
|
69
|
+
if (S.err) { tree.replaceChildren(el('p', { class: 'dim mono', style: 'font-size:var(--fs-2);padding:4px', role: 'status' }, S.err)); return; }
|
|
70
|
+
if (!S.trees) { tree.replaceChildren(el('p', { class: 'dim mono', style: 'font-size:var(--fs-2);padding:4px', role: 'status' }, 'Reading the repository')); return; }
|
|
71
|
+
const all = S.trees.filter((t) => !t.missing && (t.ws === S.ws || t.main));
|
|
72
|
+
const list = all.slice(0, MAX_TREES);
|
|
73
|
+
const nodes = list.map((t) => {
|
|
74
|
+
const ss = inTree(t.dir);
|
|
75
|
+
const body = ss.length ? ss.slice(0, 4).map(row)
|
|
76
|
+
: [t.main ? el('span', { class: 'dim mono', style: 'font-size:var(--fs-1);padding:2px 6px' }, 'no session')
|
|
77
|
+
: el('a', { class: 'chip2 lk', href: '/', title: 'Start a session on this branch in the main app' }, c3ic('plus', 'sm'), 'session')];
|
|
78
|
+
if (ss.length > 4) body.push(el('span', { class: 'dim mono', style: 'font-size:var(--fs-1);padding:2px 6px' }, '+' + (ss.length - 4) + ' more'));
|
|
79
|
+
return el('div', { class: 'br', 'data-c3-tree': t.dir }, el('button', { type: 'button', class: 'bn', 'data-c3-branch': t.dir, 'aria-current': String(!!S.sel && ss.some((x) => x.id === S.sel)), title: ss.length ? 'Open the session on this branch' : 'This branch has no session', onclick: () => { if (ss[0]) pick((ss.find((x) => x.shown !== 'exited') || ss[0]).id); else { S.sel = null; S.selTree = t.dir; drawTree(); drawDetail(); } },
|
|
80
|
+
style: 'border:0;background:none;text-align:left;width:100%;cursor:pointer' }, c3ic('branch', 'sm'), t.branch || '(detached)'), el('div', { class: 'ss' }, ...body));
|
|
81
|
+
});
|
|
82
|
+
// a session whose tree is not one of the listed ones still shows, under its own path
|
|
83
|
+
const known = new Set(list.map((t) => t.dir));
|
|
84
|
+
const stray = mine().filter((s) => !known.has(s.tree));
|
|
85
|
+
if (stray.length) nodes.push(el('div', { class: 'br' }, el('div', { class: 'bn' }, c3ic('branch', 'sm'), 'other trees'), el('div', { class: 'ss' }, ...stray.slice(0, 4).map(row))));
|
|
86
|
+
if (all.length > MAX_TREES) nodes.push(el('div', { class: 'dim mono', style: 'font-size:var(--fs-1);padding:4px' }, '+' + (all.length - MAX_TREES) + ' more branches in the main app'));
|
|
87
|
+
tree.replaceChildren(...(nodes.length ? nodes : [el('p', { class: 'dim mono', style: 'font-size:var(--fs-2);padding:4px' }, 'No working tree is registered for this repository.')]));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function drawDetail() {
|
|
91
|
+
const s = mine().find((x) => x.id === S.sel);
|
|
92
|
+
if (!s) { const bt = S.selTree && S.trees && S.trees.find((x) => x.dir === S.selTree); detail.replaceChildren(el('p', { class: 'dim mono', style: 'font-size:var(--fs-2)', 'data-c3pg': 'dock-empty' }, bt ? (bt.branch || 'This branch') + ' has no session. A session starts in the main app.' : mine().length ? 'Pick a session above.' : 'No session runs in this repository.')); return; }
|
|
93
|
+
const t = S.trees && S.trees.find((x) => x.dir === s.tree);
|
|
94
|
+
const started = ago(Date.now() - Date.parse(s.created));
|
|
95
|
+
const last = Number.isFinite(s.lastOutputAt) ? ago(Date.now() - s.lastOutputAt) : null;
|
|
96
|
+
const facts = [started, s.shown === 'working' ? null : (s.label || s.shown), last ? 'last output ' + last + ' ago' : null, s.model && s.model_line ? s.model_line : null].filter(Boolean).join(' · ');
|
|
97
|
+
const term = el('div', { class: 'term', 'data-c3pg': 'dock-term', tabindex: '0', 'aria-label': 'Output tail' }, 'Reading the output');
|
|
98
|
+
const pad = el('textarea', { 'aria-label': 'Scratchpad', style: 'width:100%;min-height:56px;margin-top:6px', 'data-c3pg': 'dock-pad' });
|
|
99
|
+
const saveBtn = el('button', { type: 'button', class: 'ib sm', title: 'Save the scratchpad', 'aria-label': 'Save the scratchpad', onclick: async () => {
|
|
100
|
+
const r = await pmPost('/api/scratchpad', { session: s.id, text: pad.value });
|
|
101
|
+
saveBtn.title = r && r.ok ? 'Saved' : 'Not saved: ' + ((r && r.error) || 'unreadable');
|
|
102
|
+
} }, c3ic('notes', 'sm'));
|
|
103
|
+
detail.replaceChildren(...[
|
|
104
|
+
el('div', { class: 'chips2' }, el('b', { style: 'font-size:var(--fs-3)' }, s.name), el('span', { class: 'chips2 dim mono', style: 'font-size:var(--fs-1)' }, c3ic(PI[s.agent] || 'term', 'sm'), (PN[s.agent] || s.agent) + ' · ' + ((t && t.branch) || 'no branch'))),
|
|
105
|
+
el('div', { class: 'dim mono', style: 'font-size:var(--fs-1)' }, facts),
|
|
106
|
+
s.shown === 'needs' ? el('div', { class: 'fold a' }, c3ic('alert', 'sm'), 'needs you') : null,
|
|
107
|
+
el('a', { class: 'chip2 lk', href: '/', 'data-c3pg': 'dock-open-session', title: TIPS.terminalOpen, 'aria-label': 'Open the session ' + s.name + ' in the main app' }, ic('term', 'sm'), 'Open the session'),
|
|
108
|
+
term,
|
|
109
|
+
el('div', { class: 'form' },
|
|
110
|
+
el('input', { type: 'text', readonly: true, 'data-off': '', placeholder: s.shown === 'needs' ? 'Your answer' : 'Message', 'aria-label': 'Message', title: MORE, 'data-c3pg': 'dock-msg' }),
|
|
111
|
+
el('button', { type: 'button', class: 'ib pri sm', disabled: true, title: MORE, 'aria-label': 'Send (not available here)', 'data-c3pg': 'dock-send' }, c3ic('out', 'sm')),
|
|
112
|
+
s.shown === 'exited' ? null : el('button', { type: 'button', class: 'ib sm', title: 'End the session', 'aria-label': 'End the session', 'aria-expanded': String(S.confirm === s.id), 'data-c3pg': 'dock-end', onclick: () => { S.confirm = S.confirm === s.id ? null : s.id; drawDetail(); } }, c3ic('stop', 'sm'))),
|
|
113
|
+
S.confirm === s.id ? el('div', { class: 'dk-confirm', role: 'group', 'aria-label': 'End this session?', 'data-c3pg': 'dock-confirm' }, el('span', { class: 'dim mono' }, 'End it? Any pending change stays a proposal.'),
|
|
114
|
+
el('button', { type: 'button', class: 'chip2 lk', 'data-c3pg': 'dock-end-yes', onclick: () => endSession(s) }, c3ic('stop', 'sm'), 'End'),
|
|
115
|
+
el('button', { type: 'button', class: 'chip2 lk', onclick: () => { S.confirm = null; drawDetail(); } }, 'Keep it')) : null,
|
|
116
|
+
s.shown === 'exited' ? el('div', { class: 'chips2' }, el('span', { class: 'dim mono', style: 'font-size:var(--fs-2)' }, 'ended')) : null,
|
|
117
|
+
el('details', null, el('summary', { class: 'chips2 dim', style: 'cursor:pointer;font-size:var(--fs-2)' }, c3ic('notes', 'sm'), 'scratchpad'), pad, saveBtn)].filter(Boolean));
|
|
118
|
+
const [tr, sp] = await Promise.all([pmGet('/api/sessions/transcript?id=' + encodeURIComponent(s.id) + '&max=2400'), pmGet('/api/scratchpad?session=' + encodeURIComponent(s.id))]);
|
|
119
|
+
if (S.sel !== s.id) return;
|
|
120
|
+
const txt = tr && tr.ok ? tail(tr.text, 10) : '';
|
|
121
|
+
term.textContent = txt || (tr && tr.ok ? 'No output yet.' : 'The output could not be read.');
|
|
122
|
+
if (sp && sp.ok && typeof sp.text === 'string') pad.value = sp.text;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/* End: the page's own route POST /api/session/remove (it ends the process and removes the session). In demo the write is answered locally and the session is only marked ended. */
|
|
126
|
+
async function endSession(s) {
|
|
127
|
+
S.confirm = null;
|
|
128
|
+
const r = await pmPost('/api/session/remove', { id: s.id });
|
|
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 }); }
|
|
130
|
+
else toast({ text: (r && r.error) ? 'Not ended: ' + r.error : NOEND, tone: 'error' });
|
|
131
|
+
drawTree(); drawDetail(); drawRail(); refresh();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function drawRail() {
|
|
135
|
+
const all = S.sessions.filter((s) => !s.automatic); // the mockup's rail lists the first six sessions of every repository
|
|
136
|
+
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])));
|
|
137
|
+
/* the TERMINAL glyph: this button opens the dock (the terminals of your branches). The needs-you badge is on the agent button, not here. */
|
|
138
|
+
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);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function refresh() {
|
|
142
|
+
const seq = ++S.seq; const ws = S.ws;
|
|
143
|
+
const [ss, wt, rn] = await Promise.all([pmGet('/api/sessions'), ws ? pmGet('/api/git/worktrees?ws=' + encodeURIComponent(ws)) : Promise.resolve(null), source().getRuns().catch(() => null)]);
|
|
144
|
+
if (seq !== S.seq) return;
|
|
145
|
+
S.need = needsYou(rn); agentBdg.textContent = String(S.need); agentBdg.hidden = !S.need;
|
|
146
|
+
if (ss && Array.isArray(ss.sessions)) S.sessions = ss.sessions; else S.err = 'The sessions could not be read.';
|
|
147
|
+
if (wt && wt.ok) { S.trees = wt.worktrees || []; S.err = null; } else if (!S.trees) S.trees = [];
|
|
148
|
+
const m = mine();
|
|
149
|
+
if (!m.some((x) => x.id === S.sel)) S.sel = (m.find((x) => x.shown !== 'exited') || m[0] || {}).id || null;
|
|
150
|
+
drawTree(); drawRail();
|
|
151
|
+
const cur = detail.getAttribute('data-sel');
|
|
152
|
+
if (cur !== String(S.sel)) { detail.setAttribute('data-sel', String(S.sel)); drawDetail(); }
|
|
153
|
+
if (o.onCount) { const all = S.sessions.filter((x) => !x.automatic); o.onCount(all.filter((x) => x.shown === 'working').length, S.need); }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/* the mockup's keys: Ctrl or Cmd J folds and opens the dock (title "Open sessions (Ctrl J)"); the arrows move along the tree */
|
|
157
|
+
const onKey = (e) => {
|
|
158
|
+
if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && (e.key === 'j' || e.key === 'J')) {
|
|
159
|
+
if (!side.isConnected && !rail.isConnected) return;
|
|
160
|
+
e.preventDefault(); if (o.setOpen) o.setOpen(side.hidden); return;
|
|
161
|
+
}
|
|
162
|
+
if ((e.key === 'ArrowDown' || e.key === 'ArrowUp') && e.target.closest && e.target.closest('.dk-tree')) {
|
|
163
|
+
const items = [...tree.querySelectorAll('button.sr,button.bn')]; const i = items.indexOf(e.target.closest('button'));
|
|
164
|
+
if (i >= 0) { e.preventDefault(); const n = items[i + (e.key === 'ArrowDown' ? 1 : -1)]; if (n) n.focus(); }
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
let timer = null;
|
|
168
|
+
const api = {
|
|
169
|
+
side, rail,
|
|
170
|
+
setWorkspaces(list, ws) {
|
|
171
|
+
S.ws = ws;
|
|
172
|
+
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)));
|
|
173
|
+
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(); };
|
|
174
|
+
},
|
|
175
|
+
start() { document.addEventListener('keydown', onKey); drawTree(); drawRail(); refresh(); timer = setInterval(refresh, 15000); if (source().subscribe) S.unsub = source().subscribe(refresh); },
|
|
176
|
+
stop() { if (timer) clearInterval(timer); if (S.unsub) S.unsub(); document.removeEventListener('keydown', onKey); },
|
|
177
|
+
refresh,
|
|
178
|
+
};
|
|
179
|
+
return api;
|
|
180
|
+
}
|