create-pmos 0.167.0 → 0.169.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/c3-driver-fixture.js +61 -0
- package/workspace/c3-driver-test.js +164 -0
- package/workspace/extension/README.md +33 -0
- package/workspace/extension/address.js +20 -0
- package/workspace/extension/content.js +76 -0
- package/workspace/extension/icons/icon-128.png +0 -0
- package/workspace/extension/icons/icon-16.png +0 -0
- package/workspace/extension/icons/icon-32.png +0 -0
- package/workspace/extension/icons/icon-48.png +0 -0
- package/workspace/extension/manifest.json +15 -0
- package/workspace/extension/options.css +33 -0
- package/workspace/extension/options.html +26 -0
- package/workspace/extension/options.js +24 -0
- package/workspace/extension/panel.css +21 -0
- package/workspace/extension/panel.html +14 -0
- package/workspace/extension/panel.js +67 -0
- package/workspace/guide/console-calendar.md +14 -0
- package/workspace/guide/index.md +1 -0
- package/workspace/guide/initiative-driver.md +87 -0
- package/workspace/lib/driver-cli.js +207 -0
- package/workspace/lib/driver-decide.js +118 -0
- package/workspace/lib/driver-facts.js +113 -0
- package/workspace/lib/driver-needs.js +107 -0
- package/workspace/lib/driver-run.js +187 -0
- package/workspace/lib/driver.js +408 -0
- package/workspace/lib/extension-cli.js +39 -0
- package/workspace/lib/headless.js +13 -2
- package/workspace/lib/meeting-focus.js +48 -1
- package/workspace/lib/needs-providers.js +1 -0
- package/workspace/lib/pmos-verbs.js +6 -0
- package/workspace/lib/route-modules.js +1 -0
- package/workspace/lib/routes-boards.js +11 -1
- package/workspace/lib/routes-driver.js +167 -0
- package/workspace/lib/routes-meeting-focus.js +17 -1
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/c3-brief-needs.js +19 -3
- package/workspace/public/pm/c3-demo-focus.js +12 -2
- package/workspace/public/pm/c3-demo.js +1 -1
- package/workspace/public/pm/c3-driver-work.js +110 -0
- package/workspace/public/pm/c3-driver.css +18 -0
- package/workspace/public/pm/c3-driver.js +298 -0
- package/workspace/public/pm/c3-focus.css +31 -0
- package/workspace/public/pm/c3-focus.js +122 -21
- package/workspace/public/pm/c3-ii-tabs.js +3 -2
- package/workspace/public/pm/c3-ini-ui.js +4 -2
- package/workspace/public/pm/c3-initiatives.js +14 -0
- package/workspace/public/pm/c3-shell.js +2 -2
- package/workspace/public/pm/pm-work-view.js +12 -2
- package/workspace/server.js +21 -1
- package/workspace/skills/initiative-drive.skill +50 -0
- package/workspace/skills/work-unblock.skill +42 -0
- package/workspace/test-files.json +6 -0
- package/workspace/test-lists/c3-meeting-focus.json +13 -4
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/* pm/c3-driver.js — the Initiative Driver on console3 (initiative initiative-driver, PRD section 5; mockup mockup-c3-driver, 39-driver.js).
|
|
2
|
+
* The agent works on every initiative and Work task on its own and files each result as a PROPOSAL. Nothing it writes changes a record
|
|
3
|
+
* or leaves PMOS: the PM keeps, sends back with a note, or drops. This file draws the three places the PM sees it:
|
|
4
|
+
*
|
|
5
|
+
* driverStrip(ctx, row) the Agent strip on an initiative's Overview: status, last action, blocker, one row per open proposal
|
|
6
|
+
* agentSetting(state, { onPick }) the Agent setting (Off, Shadow, Approve) in the Board settings sheet; loadAgentMode(api, board) reads it
|
|
7
|
+
* workNote(o) · openDraftSheet(o) the one agent line under a Work task and the sheet behind its Review (c3-driver-work.js draws them)
|
|
8
|
+
*
|
|
9
|
+
* Reads: GET /api/driver/strip, /api/driver/mode, /api/driver/open. Presses: POST /api/driver/keep, return, drop, pause, resume, mode,
|
|
10
|
+
* through http.js (the nonce and the page Origin). The page sends no `by`: the server takes the PM from the call path. After each press
|
|
11
|
+
* the strip is read again. Keep, send back and drop have no Undo: each adds a line to the ledger, and nothing is removed.
|
|
12
|
+
* A proposal that was just decided stays in its strip, marked, until the page is left. Plain words for every empty and error state.
|
|
13
|
+
*/
|
|
14
|
+
import { el } from './dom.js';
|
|
15
|
+
import { icon, pbtn, lk, chip, tag, st, note, lbl, chgl, list, nrow, sect, put, btn, plural } from './c3-ini-ui.js';
|
|
16
|
+
import { toast } from './c3-toast.js';
|
|
17
|
+
import { pmGet } from './http.js';
|
|
18
|
+
|
|
19
|
+
/** Link c3-driver.css once, from this module's own folder (the way c3-initiatives.js links its stylesheet). */
|
|
20
|
+
export function ensureDriverCss() {
|
|
21
|
+
if (typeof document === 'undefined' || document.querySelector('link[data-c3-drv]')) return;
|
|
22
|
+
const l = document.createElement('link');
|
|
23
|
+
l.rel = 'stylesheet'; l.href = new URL('./c3-driver.css', import.meta.url).href; l.setAttribute('data-c3-drv', '');
|
|
24
|
+
document.head.appendChild(l);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/* ── words ── */
|
|
28
|
+
export const WHAT = {
|
|
29
|
+
problem: ['Problem statement', 'doc'], evidence: ['Evidence', 'doc'], sizing: ['Opportunity sizing', 'target'], assumption: ['Riskiest assumption and spike brief', 'bolt'],
|
|
30
|
+
stakeholders: ['Stakeholders', 'user'], move: ['Move', 'next'], note: ['Note', 'notes'], 'next-step': ['Next step', 'check'], 'follow-up': ['Follow-up', 'mail'],
|
|
31
|
+
};
|
|
32
|
+
export const MODES = [
|
|
33
|
+
['off', 'Off', 'The agent does nothing on this board.'],
|
|
34
|
+
['shadow', 'Shadow', 'The agent drafts but raises nothing. You see its drafts only on the initiative page.'],
|
|
35
|
+
['approve', 'Approve', 'The agent drafts and asks you. Each draft waits for Keep, Send back or Drop.'],
|
|
36
|
+
];
|
|
37
|
+
const errText = (r, what) => what + ': ' + ((r && r.error) || 'the workspace did not answer') + '.';
|
|
38
|
+
const isUrl = (s) => /^https?:\/\/\S+$/i.test(String(s || ''));
|
|
39
|
+
const MON = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
40
|
+
const WD = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
41
|
+
const p2 = (n) => String(n).padStart(2, '0');
|
|
42
|
+
const clock = (d) => p2(d.getHours()) + ':' + p2(d.getMinutes());
|
|
43
|
+
/** '11:05' for today, 'Fri 9 Oct, 16:05' for any other day. */
|
|
44
|
+
export function whenLabel(iso, now = new Date()) {
|
|
45
|
+
const d = new Date(iso);
|
|
46
|
+
if (!Number.isFinite(d.getTime())) return '';
|
|
47
|
+
return d.toDateString() === now.toDateString() ? clock(d) : WD[d.getDay()] + ' ' + d.getDate() + ' ' + MON[d.getMonth()] + ', ' + clock(d);
|
|
48
|
+
}
|
|
49
|
+
/** Whole days a time is old. */
|
|
50
|
+
export const daysOld = (iso, now = Date.now()) => { const t = Date.parse(iso); return Number.isFinite(t) ? Math.floor((now - t) / 864e5) : 0; };
|
|
51
|
+
/** 'Thu 15 Oct' for a YYYY-MM-DD. */
|
|
52
|
+
export function dueLabel(ymd) {
|
|
53
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(ymd || ''));
|
|
54
|
+
if (!m) return '';
|
|
55
|
+
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
|
|
56
|
+
return WD[d.getDay()] + ' ' + d.getDate() + ' ' + MON[d.getMonth()];
|
|
57
|
+
}
|
|
58
|
+
const textOf = (p) => (p && p.data && typeof p.data.text === 'string' ? p.data.text : '');
|
|
59
|
+
export const titleOf = (p) => (p.what === 'move' && p.data && p.data.to ? 'Move to ' + p.data.to : (WHAT[p.what] || [p.what])[0]);
|
|
60
|
+
|
|
61
|
+
/** The text a proposal shows when it opens: the draft, then the agent's one-line reason. */
|
|
62
|
+
export function bodyOf(p) {
|
|
63
|
+
const out = [];
|
|
64
|
+
const t = textOf(p);
|
|
65
|
+
if (p.what === 'move') out.push('Move this initiative to ' + ((p.data && p.data.to) || 'the next stage') + '.');
|
|
66
|
+
else if (t) out.push(t);
|
|
67
|
+
else if (p.data && Array.isArray(p.data.items)) out.push(plural(p.data.items.length, 'item') + ' with a pointer each.');
|
|
68
|
+
else if (p.data && typeof p.data === 'object' && Object.keys(p.data).length) out.push(Object.entries(p.data).map(([k, v]) => k + ': ' + (typeof v === 'string' ? v : JSON.stringify(v))).join('\n'));
|
|
69
|
+
if (p.what === 'next-step' && p.data && p.data.due) out.push('Due: ' + dueLabel(p.data.due) + '.');
|
|
70
|
+
if (p.why) out.push('Why: ' + p.why);
|
|
71
|
+
if (p.what === 'move') out.push('Keep moves the stage. The agent only proposes.');
|
|
72
|
+
return out.join('\n');
|
|
73
|
+
}
|
|
74
|
+
/** The sources of a proposal: its own list, then the items of an evidence draft that carry a pointer. */
|
|
75
|
+
export function sourcesOf(p) {
|
|
76
|
+
const out = (p.sources || []).map((s) => ({ label: s.label, ref: s.ref, text: '' }));
|
|
77
|
+
if (p.data && Array.isArray(p.data.items)) for (const i of p.data.items) if (i && i.ref) out.push({ label: 'Evidence', ref: String(i.ref), text: String(i.text || '') });
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
const srcIcon = (label) => (/jira/i.test(label) ? 'jira' : /slack|mail|email|thread/i.test(label) ? 'mail' : 'doc');
|
|
81
|
+
function srcRow(s) {
|
|
82
|
+
const ref = isUrl(s.ref) ? el('a', { class: 'mono drv-ref', href: s.ref, target: '_blank', rel: 'noopener noreferrer' }, s.ref) : el('span', { class: 'mono drv-ref' }, s.ref);
|
|
83
|
+
return el('li', { 'data-drv-src': '' }, icon(srcIcon(s.label), 'sm'), el('span', { class: 'drv-src' }, el('b', null, s.label), ref, s.text ? el('span', null, s.text) : null));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/* ── what the PM decided this visit: kept and sent-back rows stay in the strip, marked ── */
|
|
87
|
+
const DECIDED = new Map(); // target key -> Map(proposal id -> { st: 'kept' | 'back', note, p })
|
|
88
|
+
const decidedOf = (key) => { if (!DECIDED.has(key)) DECIDED.set(key, new Map()); return DECIDED.get(key); };
|
|
89
|
+
|
|
90
|
+
/** One press on the driver. Returns the server's answer; an error is a toast and null. */
|
|
91
|
+
async function press(ctx, route, body, what) {
|
|
92
|
+
const r = await ctx.api.post('/api/driver/' + route, body);
|
|
93
|
+
if (!r || !r.ok) { toast({ text: errText(r, what + ' did not work'), tone: 'error', timeout: 6000 }); return null; }
|
|
94
|
+
return r;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The sheet that asks for a one-line note and sends a proposal back with it. */
|
|
98
|
+
export function openSendBack(ctx, p, onSent) {
|
|
99
|
+
const input = el('input', { type: 'text', 'data-drv': 'note', placeholder: 'Tell the agent what is wrong or missing', 'aria-label': 'Note to the agent', maxlength: '500', autocomplete: 'off' });
|
|
100
|
+
const err = el('div', { class: 'fold w', role: 'alert', hidden: true, 'data-drv': 'note-error' });
|
|
101
|
+
let busy = false;
|
|
102
|
+
const send = async () => {
|
|
103
|
+
const n = input.value.trim();
|
|
104
|
+
if (!n) { err.hidden = false; put(err, icon('alert', 'sm'), 'Write one line for the agent. A note starts its next run.'); input.focus(); return; }
|
|
105
|
+
if (busy) return; busy = true;
|
|
106
|
+
const r = await press(ctx, 'return', { proposal: p.id, note: n }, 'Send back');
|
|
107
|
+
busy = false;
|
|
108
|
+
if (!r) return;
|
|
109
|
+
sh.close();
|
|
110
|
+
toast('Sent back with your note. The agent redrafts it.');
|
|
111
|
+
onSent(n);
|
|
112
|
+
};
|
|
113
|
+
input.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.isComposing) { e.preventDefault(); send(); } });
|
|
114
|
+
const sh = ctx.openSheet({ title: 'Send back: ' + titleOf(p), icon: 'reply', body: el('div', { class: 'drv-sheet' },
|
|
115
|
+
el('p', { class: 'drv-pre' }, bodyOf(p)), el('div', { class: 'form' }, input), err,
|
|
116
|
+
el('div', { class: 'iacts chips2' }, pbtn('primary', 'Send back with note', 'reply', { 'data-drv': 'send-back', onclick: send }), pbtn('ghost', 'Cancel', null, { onclick: () => sh.close() }))) });
|
|
117
|
+
setTimeout(() => input.focus(), 30);
|
|
118
|
+
return sh;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/* ── the Agent strip ── */
|
|
122
|
+
/** Plain words for the state of the agent on this initiative, from GET /api/driver/strip. */
|
|
123
|
+
export function statusOf(d, now = Date.now()) {
|
|
124
|
+
const open = d.proposals || [];
|
|
125
|
+
const used = (d.budget && d.budget.used) || 0; const limit = (d.budget && d.budget.limit) || 3;
|
|
126
|
+
const oldest = open.map((p) => daysOld(p.at, now)).reduce((a, b) => Math.max(a, b), 0);
|
|
127
|
+
let last = 'No run yet.';
|
|
128
|
+
if (d.last) last = d.last.ok ? 'Last: ' + (d.last.proposals ? 'filed ' + plural(d.last.proposals, 'proposal') : 'looked and found nothing new') + ' · ' + whenLabel(d.last.at, new Date(now))
|
|
129
|
+
: 'Last run did not finish' + (d.last.reason ? ': ' + d.last.reason : '') + ' · ' + whenLabel(d.last.at, new Date(now));
|
|
130
|
+
if (d.paused) return { ic: 'pause', b: 'Agent is paused', sub: 'It files nothing until you resume.', last };
|
|
131
|
+
if (d.running) return { ic: 'spark', b: 'Agent is working · started ' + whenLabel(d.running.since, new Date(now)), sub: 'It works inside the limits you set and files each result as a proposal.', last };
|
|
132
|
+
if (open.length) return { ic: 'wait', b: 'Agent is idle · waiting on you', sub: oldest >= 1 ? 'The oldest proposal is ' + plural(oldest, 'day') + ' old.' : plural(open.length, 'proposal') + ' wait for your decision.', last };
|
|
133
|
+
if (used >= limit) return { ic: 'clock', b: 'Agent is idle · used its runs for today', sub: 'It runs up to ' + limit + ' times a day on each initiative. It starts again tomorrow.', last };
|
|
134
|
+
return { ic: 'spark', b: 'Agent is idle', sub: 'It starts again when something changes: a stage move, a new initiative, or a note from you.', last };
|
|
135
|
+
}
|
|
136
|
+
/** The line that says what stops the agent, or ''. A blocker the PM owns names the PM. */
|
|
137
|
+
export function blockerLine(d) {
|
|
138
|
+
if (!d.blocker || !d.blocker.text) return '';
|
|
139
|
+
if (d.blocker.owner === 'pm') return 'Stopped by: you. ' + d.blocker.text + '. The agent starts no run until you decide.';
|
|
140
|
+
return 'Stopped by: ' + String(d.blocker.text).replace(/\.$/, '') + '.';
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The Agent section of an initiative's Overview. Draws at once with "Reading the agent" and fills itself from the server.
|
|
145
|
+
* ctx: api, row (the initiative), board, refresh, openSheet, openBoardSettings.
|
|
146
|
+
*/
|
|
147
|
+
export function driverStrip(ctx, row) {
|
|
148
|
+
ensureDriverCss();
|
|
149
|
+
const wrap = el('div', { class: 'drv', 'data-drv': 'strip' });
|
|
150
|
+
const key = (ctx.ws || '') + ':initiative:' + row.id;
|
|
151
|
+
const S = { data: null, err: '', open: new Set(), busy: false };
|
|
152
|
+
|
|
153
|
+
async function load() {
|
|
154
|
+
const r = await ctx.api.get('/api/driver/strip', { target_kind: 'initiative', id: row.id, board: ctx.board && ctx.board.id });
|
|
155
|
+
if (!r || !r.ok) { S.data = null; S.err = errText(r, 'The agent strip could not be read'); } else { S.data = r; S.err = ''; }
|
|
156
|
+
draw();
|
|
157
|
+
}
|
|
158
|
+
const reread = load;
|
|
159
|
+
|
|
160
|
+
async function decide(route, p, extra) {
|
|
161
|
+
const r = await press(ctx, route, { proposal: p.id, ...(extra || {}) }, route === 'keep' ? 'Keep' : 'Drop');
|
|
162
|
+
return r;
|
|
163
|
+
}
|
|
164
|
+
async function keep(p) {
|
|
165
|
+
const r = await decide('keep', p);
|
|
166
|
+
if (!r) return;
|
|
167
|
+
decidedOf(key).set(p.id, { st: 'kept', p });
|
|
168
|
+
const w = (r.warnings || []).filter(Boolean);
|
|
169
|
+
toast(p.what === 'move' ? 'Kept. The stage moved to ' + ((p.data && p.data.to) || 'the next stage') + '.' + (w.length ? ' ' + w.join(' ') : '') : 'Kept: ' + titleOf(p));
|
|
170
|
+
if (['move', 'problem', 'next-step'].includes(r.wrote)) ctx.refresh(); // the stage chips and the record cards read the new record
|
|
171
|
+
else await reread();
|
|
172
|
+
}
|
|
173
|
+
async function drop(p) {
|
|
174
|
+
const r = await decide('drop', p);
|
|
175
|
+
if (!r) return;
|
|
176
|
+
S.open.delete(p.id);
|
|
177
|
+
toast('Dropped: ' + titleOf(p));
|
|
178
|
+
await reread();
|
|
179
|
+
}
|
|
180
|
+
const sendBack = (p) => openSendBack(ctx, p, (n) => { decidedOf(key).set(p.id, { st: 'back', note: n, p }); reread(); });
|
|
181
|
+
async function pause(on) {
|
|
182
|
+
const r = await press(ctx, on ? 'pause' : 'resume', { target_kind: 'initiative', id: row.id }, on ? 'Pause' : 'Resume');
|
|
183
|
+
if (!r) return;
|
|
184
|
+
toast(on ? 'Agent paused. It files nothing until you resume.' : 'Agent resumed.');
|
|
185
|
+
await reread();
|
|
186
|
+
}
|
|
187
|
+
function openRuns() {
|
|
188
|
+
const d = S.data; const runs = (d && d.today) || [];
|
|
189
|
+
const used = d && d.budget ? d.budget.used : runs.length; const limit = d && d.budget ? d.budget.limit : 3;
|
|
190
|
+
ctx.openSheet({ title: 'Agent runs today', icon: 'clock', body: el('div', { class: 'drv-sheet', 'data-drv': 'runs-sheet' },
|
|
191
|
+
note(row.title + ' · ' + used + ' of ' + limit + ' runs used today'),
|
|
192
|
+
runs.length ? chgl(runs.map((r) => el('li', { 'data-drv-run': r.run }, icon('clock', 'sm'),
|
|
193
|
+
el('span', null, el('b', null, whenLabel(r.start)), ' ', r.end ? (r.ok ? 'Run ended' : 'Run did not finish') : 'Running now'),
|
|
194
|
+
tag(r.end ? (r.ok ? (r.proposals ? 'Filed ' + plural(r.proposals, 'proposal') : 'Nothing new') : (r.reason || 'Failed')) : 'Running now'))))
|
|
195
|
+
: el('div', { class: 'fold' }, icon('info', 'sm'), 'The agent has not run on this initiative today.'),
|
|
196
|
+
el('div', { class: 'fold' }, icon('info', 'sm'), 'The agent runs up to ' + limit + ' times a day on each initiative. It stops at the limit and starts again tomorrow. A run that fails still counts.'),
|
|
197
|
+
el('div', { class: 'iacts chips2' }, pbtn('outline', 'Board settings', 'settings', { onclick: () => { closeAnd(() => ctx.openBoardSettings()); } }))) });
|
|
198
|
+
}
|
|
199
|
+
const closeAnd = (fn) => { import('./c3-sheet.js').then((m) => { m.closeSheet(); fn(); }); };
|
|
200
|
+
|
|
201
|
+
function propRow(p, state) {
|
|
202
|
+
const kept = state && state.st === 'kept'; const back = state && state.st === 'back';
|
|
203
|
+
const old = !state && daysOld(p.at) >= 1;
|
|
204
|
+
const srcs = sourcesOf(p);
|
|
205
|
+
const chips = [
|
|
206
|
+
tag(p.what === 'move' ? 'stage move' : 'draft'),
|
|
207
|
+
p.at ? chip(icon('clock', 'sm'), whenLabel(p.at)) : null,
|
|
208
|
+
srcs.length ? chip(icon('out', 'sm'), plural(srcs.length, 'source')) : null,
|
|
209
|
+
old ? el('span', { class: 'tag warn' }, 'waiting ' + plural(daysOld(p.at), 'day')) : null,
|
|
210
|
+
kept ? st('ok', 'check', 'Kept by you') : null,
|
|
211
|
+
back ? tag('Sent back · agent is redrafting') : null,
|
|
212
|
+
];
|
|
213
|
+
const xp = el('div', { class: 'ip' },
|
|
214
|
+
el('p', { class: 'drv-pre' }, bodyOf(p)),
|
|
215
|
+
back && state.note ? el('div', { class: 'fold' }, icon('reply', 'sm'), ' Your note: ' + state.note) : null,
|
|
216
|
+
srcs.length ? [el('span', { class: 'dim ini-note' }, 'Sources'), chgl(srcs.map(srcRow))] : el('span', { class: 'dim ini-note' }, 'No sources. ' + (p.what === 'move' ? 'This is a stage proposal.' : 'The agent named none.')));
|
|
217
|
+
const acts = state ? [] : S.data.mode === 'shadow' ? [el('span', { class: 'tag', title: 'Shadow mode: the agent drafts and raises nothing.' }, 'Shadow draft')] : [
|
|
218
|
+
pbtn('outline', 'Keep', 'check', { 'data-drv-act': 'keep', onclick: () => keep(p) }),
|
|
219
|
+
pbtn('ghost', 'Send back', null, { 'data-drv-act': 'back', onclick: () => sendBack(p) }),
|
|
220
|
+
pbtn('ghost', 'Drop', null, { 'data-drv-act': 'drop', onclick: () => drop(p) })];
|
|
221
|
+
const r = nrow({ ic: (WHAT[p.what] || ['', 'doc'])[1], hot: !state, title: titleOf(p), titleAttrs: { 'data-drv-title': p.id }, chips, xp, right: acts, attrs: { 'data-drv-row': p.id, 'data-drv-state': state ? state.st : 'open' }, key: 'drv:' + p.id, open: S.open.has(p.id), onToggle: (on) => { if (on) S.open.add(p.id); else S.open.delete(p.id); } });
|
|
222
|
+
return r;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function draw() {
|
|
226
|
+
const d = S.data;
|
|
227
|
+
if (S.err || !d) { put(wrap, sect({ ic: 'spark', title: 'Agent', attrs: { 'aria-label': 'Agent' } },
|
|
228
|
+
S.err ? el('div', { class: 'fold w', role: 'alert', 'data-drv': 'error' }, icon('alert', 'sm'), S.err, ' ', pbtn('outline', 'Try again', null, { onclick: load })) : note('Reading the agent'))); return; }
|
|
229
|
+
if (!d.ledger) { put(wrap, sect({ ic: 'spark', title: 'Agent', attrs: { 'aria-label': 'Agent' } }, el('div', { class: 'fold', 'data-drv': 'no-ledger' }, icon('info', 'sm'), 'This repository has no state folder (pmos/state, state or .pmos), so the agent has nowhere to keep its drafts.'))); return; }
|
|
230
|
+
const used = d.budget.used + ' of ' + d.budget.limit + ' runs used today';
|
|
231
|
+
const right = el('div', { class: 'chips2' },
|
|
232
|
+
d.mode === 'approve' ? (d.paused ? pbtn('outline', 'Resume', 'next', { 'data-drv-act': 'resume', onclick: () => pause(false) }) : pbtn('outline', 'Pause', 'pause', { 'data-drv-act': 'pause', onclick: () => pause(true) })) : null,
|
|
233
|
+
lk({ 'data-drv': 'runs', onclick: openRuns }, icon('clock', 'sm'), used));
|
|
234
|
+
let body;
|
|
235
|
+
if (d.mode === 'off') {
|
|
236
|
+
body = [el('div', { class: 'lh', 'data-drv': 'off' }, el('span', { class: 'ki' }, icon('pause')),
|
|
237
|
+
el('div', { class: 'ini-lhm' }, el('b', null, 'Agent is off for ' + ((ctx.board && ctx.board.name) || 'this board')), el('div', { class: 'dim' }, 'Nothing is drafted on this board. Turn it on in the board settings.')),
|
|
238
|
+
el('div', { class: 'chips2' }, pbtn('outline', 'Board settings', 'settings', { onclick: () => ctx.openBoardSettings() })))];
|
|
239
|
+
} else {
|
|
240
|
+
const s = statusOf(d); const blk = blockerLine(d);
|
|
241
|
+
const decided = decidedOf(key);
|
|
242
|
+
const ids = new Set((d.proposals || []).map((p) => p.id));
|
|
243
|
+
const rows = (d.proposals || []).map((p) => propRow(p, decided.get(p.id) || null));
|
|
244
|
+
for (const [id, x] of decided) if (!ids.has(id) && x.st !== 'dropped') rows.push(propRow(x.p, x));
|
|
245
|
+
const oldest = [...(d.proposals || [])].sort((a, b) => Date.parse(a.at) - Date.parse(b.at))[0];
|
|
246
|
+
body = [
|
|
247
|
+
el('div', { class: 'lh', 'data-drv': 'status' }, el('span', { class: 'ki' }, icon(s.ic)), el('div', { class: 'ini-lhm' }, el('b', null, s.b), el('div', { class: 'dim' }, s.sub), el('div', { class: 'dim', 'data-drv': 'last' }, s.last))),
|
|
248
|
+
d.mode === 'shadow' ? el('div', { class: 'fold', 'data-drv': 'shadow' }, icon('info', 'sm'), ' Shadow mode. The agent drafts but raises nothing: no Needs you rows, no notes in Work. Switch to Approve in the board settings to act on these.') : null,
|
|
249
|
+
blk ? el('div', { class: 'fold w', role: 'alert', 'data-drv': 'blocker' }, icon('alert', 'sm'), el('span', null, blk, d.blocker.owner === 'pm' && oldest && d.mode === 'approve' ? [' ', pbtn('outline', 'Review ' + titleOf(oldest), null, { 'data-drv-act': 'focus', onclick: () => { S.open.add(oldest.id); draw(); const t = wrap.querySelector('[data-drv-row="' + oldest.id + '"]'); if (t && t.scrollIntoView) t.scrollIntoView({ block: 'center' }); } })] : null)) : null,
|
|
250
|
+
rows.length ? list(rows) : el('div', { class: 'fold', 'data-drv': 'empty' }, icon('check', 'sm'), 'No open proposals. The agent files the next one when it has something.')];
|
|
251
|
+
}
|
|
252
|
+
put(wrap, sect({ ic: 'spark', title: 'Agent', count: d.mode === 'off' ? 0 : (d.proposals || []).length, right, attrs: { 'aria-label': 'Agent' } }, ...body));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
draw();
|
|
256
|
+
load();
|
|
257
|
+
return wrap;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/* ── the Agent setting in Board settings ── */
|
|
261
|
+
/** Read a board's mode. { ok, mode } or { ok: false, error }. */
|
|
262
|
+
export async function loadAgentMode(api, board) {
|
|
263
|
+
const r = await api.get('/api/driver/mode', { board });
|
|
264
|
+
return r && r.ok ? { ok: true, mode: r.mode } : { ok: false, error: errText(r, 'The agent setting could not be read') };
|
|
265
|
+
}
|
|
266
|
+
/** state: { mode, err, busy }. onPick(mode) saves at once (POST /api/driver/mode); the caller redraws. */
|
|
267
|
+
export function agentSetting(state, { onPick } = {}) {
|
|
268
|
+
ensureDriverCss();
|
|
269
|
+
const cur = MODES.find((m) => m[0] === state.mode);
|
|
270
|
+
return el('div', { class: 'drv-set ini-field', 'data-drv': 'setting' }, lbl('AGENT'),
|
|
271
|
+
state.mode == null && !state.err ? note('Reading the agent setting') : null,
|
|
272
|
+
state.err ? el('div', { class: 'fold w', role: 'alert', 'data-drv': 'setting-error' }, icon('alert', 'sm'), state.err) : null,
|
|
273
|
+
state.mode != null ? [
|
|
274
|
+
el('div', { class: 'seg ini-seg', role: 'group', 'aria-label': 'Agent mode' }, ...MODES.map((m) => btn({ 'aria-pressed': String(state.mode === m[0]), 'data-drv-mode': m[0], disabled: !!state.busy, onclick: () => onPick(m[0]) }, m[1]))),
|
|
275
|
+
el('div', { class: 'dim ini-note', 'data-drv': 'mode-line' }, el('b', null, cur[1] + '.'), ' ' + cur[2]),
|
|
276
|
+
chgl(MODES.filter((m) => m !== cur).map((m) => el('li', null, icon('next', 'sm'), el('span', null, el('b', null, m[1] + ':'), ' ' + m[2]), el('span')))),
|
|
277
|
+
el('div', { class: 'dim ini-note' }, 'The agent files every result as a proposal. It cannot send, merge or change a record by itself. A mode is saved when you press it.')] : null);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/* ── Needs you: the agent's rows (Briefing) ── */
|
|
281
|
+
/** The driver rows of GET /api/needs: "Agent has 3 drafts on <title>" and "<title> is waiting on you". [] when the read fails. */
|
|
282
|
+
export async function loadDriverNeeds() {
|
|
283
|
+
const r = await pmGet('/api/needs');
|
|
284
|
+
return r && Array.isArray(r.needs) ? r.needs.filter((n) => n && (n.kind === 'driver-drafts' || n.kind === 'driver-blocked')) : [];
|
|
285
|
+
}
|
|
286
|
+
/** One li.nrow for a driver row (the mockup's two rows). `go(row)` opens the initiative or the Work page. */
|
|
287
|
+
export function needsRow(n, go) {
|
|
288
|
+
ensureDriverCss();
|
|
289
|
+
const blocked = n.kind === 'driver-blocked';
|
|
290
|
+
const work = !n.initiative_id;
|
|
291
|
+
const chips = blocked ? [el('span', { class: 'tag warn' }, 'waiting on you'), chip(icon(work ? 'check' : 'init', 'sm'), work ? 'Work' : n.initiative_id)]
|
|
292
|
+
: [tag('agent'), chip(icon(work ? 'check' : 'init', 'sm'), work ? 'Work' : n.initiative_id)];
|
|
293
|
+
chips[0].className = blocked ? 'tag warn' : 'tag acc';
|
|
294
|
+
chips.push(el('span', { class: 'dim' }, blocked ? 'The agent will not go on until you decide' : 'Keep, send back or drop each one'));
|
|
295
|
+
const li = nrow({ ic: blocked ? 'next' : 'spark', hot: true, title: n.question, onOpen: () => go(n), openLabel: n.question, chips,
|
|
296
|
+
attrs: { 'data-needs-row': n.run_id, 'data-kind': n.kind, 'data-drv-need': n.kind, 'data-ref-row': n.initiative_id || n.run_id } });
|
|
297
|
+
return li;
|
|
298
|
+
}
|
|
@@ -167,3 +167,34 @@
|
|
|
167
167
|
.c3focus .fc-cx > span { display: grid; gap: 1px; overflow: visible; text-align: left; }
|
|
168
168
|
.c3focus .fc-cx > span > b { font-weight: 600; }
|
|
169
169
|
.c3.fc-open { container-type: normal; }
|
|
170
|
+
|
|
171
|
+
/* compact mode (?pop=1, initiative c3-meet-extension): the panel the Chrome extension frames over a Google Meet call. A head, What is being said,
|
|
172
|
+
* the conversation and a foot. Folded to a pill, only the head shows. The look is the mockup's "Over Google Meet" panel. Tokens only. */
|
|
173
|
+
.c3focus.fc-pop { overflow: hidden; }
|
|
174
|
+
.c3focus.fc-pop .fc-page { display: flex; flex-direction: column; gap: 8px; height: 100%; max-width: none; margin: 0; padding: 10px; }
|
|
175
|
+
.c3focus.fc-pop .fc-ph { display: flex; align-items: center; gap: 8px; min-height: 24px; font-size: var(--fs-2); flex: 0 0 auto; }
|
|
176
|
+
.c3focus.fc-pop .fc-ph b { font-size: var(--fs-1); color: var(--acc); }
|
|
177
|
+
.c3focus.fc-pop .fc-ph-topic { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; }
|
|
178
|
+
.c3focus.fc-pop .fc-ph .mono.dim { font-variant-numeric: tabular-nums; }
|
|
179
|
+
.c3focus.fc-pop .fc-popbody { display: flex; flex-direction: column; gap: 8px; flex: 1 1 auto; min-height: 0; overflow: auto; }
|
|
180
|
+
.c3focus.fc-pop .fc-ph .ib { flex: 0 0 auto; width: 28px; height: 28px; min-width: 0; min-height: 0; padding: 0; }
|
|
181
|
+
.c3focus.fc-pop .fc-bandwrap { flex: 0 0 auto; }
|
|
182
|
+
.c3focus.fc-pop .fc-gist { padding: 8px; border-radius: 8px; }
|
|
183
|
+
.c3focus.fc-pop .fc-gist p { font-size: var(--fs-2); }
|
|
184
|
+
.c3focus.fc-pop .fc-gl { display: block; margin: 0 0 2px; }
|
|
185
|
+
.c3focus.fc-pop .fc-cx { padding: 2px 8px; }
|
|
186
|
+
.c3focus.fc-pop .fc-cx > span { display: block; }
|
|
187
|
+
.c3focus.fc-pop .fc-cx > span > span { display: none; }
|
|
188
|
+
.c3focus.fc-pop .fc-cv { display: flex; flex-direction: column; flex: 1 0 auto; min-height: 200px; max-height: none; border: 0; background: none; border-radius: 0; }
|
|
189
|
+
.c3focus.fc-pop .fc-tk { padding: 4px 2px 6px; }
|
|
190
|
+
.c3focus.fc-pop .fc-s2 { flex: 1 1 auto; padding: 4px 2px; }
|
|
191
|
+
.c3focus.fc-pop .fc-in { padding: 6px 2px 0; }
|
|
192
|
+
.c3focus.fc-pop .fc-foot { display: flex; justify-content: space-between; align-items: center; gap: 8px; flex: 0 0 auto; position: sticky; bottom: 0; padding-top: 4px; background: var(--background); font-size: var(--fs-1); }
|
|
193
|
+
.c3focus.fc-pop .fc-popmsg { display: grid; gap: 6px; margin: 0; padding: 8px; border: 1px solid var(--border); border-radius: 8px; background: var(--card); font-size: var(--fs-2); }
|
|
194
|
+
.c3focus.fc-pop .fc-popmsg p { margin: 0; line-height: 1.5; }
|
|
195
|
+
.c3focus.fc-pop .fc-choices { display: grid; gap: 6px; }
|
|
196
|
+
.c3focus.fc-pop .fc-choices button { justify-content: flex-start; text-align: left; white-space: normal; height: auto; }
|
|
197
|
+
.c3focus.fc-pop .fc-choices .mono { margin-right: 4px; }
|
|
198
|
+
.c3focus.fc-pop:not([data-fc-state="ok"]) .fc-cv, .c3focus.fc-pop:not([data-fc-state="ok"]) .fc-foot { display: none; }
|
|
199
|
+
.c3focus.fc-pop.fc-pill .fc-popbody { display: none; }
|
|
200
|
+
.c3focus.fc-pop.fc-pill .fc-page { padding: 6px 10px; justify-content: center; }
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/* pm/c3-focus.js — the Meeting focus page of console3 (initiative c3-meeting-focus; the mockup the PM approved on 2026-10-12, version 7).
|
|
2
|
-
* openFocus({ event, date }) closeFocus()
|
|
2
|
+
* openFocus({ event, date, pop, meet, mtitle }) closeFocus()
|
|
3
3
|
* One calm full-screen page over console3, inside #c3-root: it covers the header, the tabs and the dock (they stay in the DOM, set inert) and Leave
|
|
4
4
|
* focus gives them back. Recording is not touched by leaving. It reads GET /api/meeting-focus every 3 seconds while it is open:
|
|
5
5
|
* header title, time, people, initiative; the clock (starts in, or elapsed and left); REC with Start recording or End meeting (the note dock);
|
|
@@ -9,7 +9,12 @@
|
|
|
9
9
|
* stage the meeting's material as a card with Open (and Present for Slides); a Google Slides or Docs preview loads only on a press; before the start, what is prepared
|
|
10
10
|
* conversation the room's last two lines as a faint ticker; PMOS notes below it (only the newest is open); the ones PMOS holds; Ask PMOS quietly
|
|
11
11
|
* Nothing is invented: with the companion off, or before it has answered, the panel shows the ticker and a quiet line. Nothing is sent anywhere.
|
|
12
|
-
* In demo mode (?demo=1) c3-demo-focus.js answers the reads with the mockup's Partner API review.
|
|
12
|
+
* In demo mode (?demo=1) c3-demo-focus.js answers the reads with the mockup's Partner API review.
|
|
13
|
+
* Compact mode (?pop=1, initiative c3-meet-extension): the page the Chrome extension frames over a Google Meet call. Only a head (PMOS, the topic or "Starts in",
|
|
14
|
+
* the time left, a fold button), What's being said, the conversation and a foot (Open focus in PMOS, only you see this). The stage, the bar of the talk, the now line and
|
|
15
|
+
* the captured items are not drawn, and there is no Leave focus. ?focus=now first asks GET /api/meeting-focus/now (meet = the Meet code, mtitle = the tab's title) which
|
|
16
|
+
* meeting the call is; when none fits, the page says so and lists today's meetings to pick from. Fold shows the head only and tells the frame's parent with one
|
|
17
|
+
* message, { pmosFocus: 'fold' | 'open' }, that carries no data. */
|
|
13
18
|
import { el } from './dom.js';
|
|
14
19
|
import { pmGet, pmPost } from './http.js';
|
|
15
20
|
import { ensureC3Css } from './pm-work-view.js';
|
|
@@ -75,42 +80,78 @@ export function closeFocus() {
|
|
|
75
80
|
if (f.opener && document.contains(f.opener) && typeof f.opener.focus === 'function') { try { f.opener.focus({ preventScroll: true }); } catch { /* not focusable any more */ } }
|
|
76
81
|
}
|
|
77
82
|
|
|
78
|
-
export function openFocus({ event, date } = {}) {
|
|
83
|
+
export function openFocus({ event, date, pop, meet, mtitle } = {}) {
|
|
79
84
|
closeFocus();
|
|
80
85
|
const ev = event || {};
|
|
81
|
-
const
|
|
86
|
+
const want = String(ev.id || '');
|
|
87
|
+
const resolve = !!pop && want === 'now'; // the extension names no meeting: which one this call is comes first
|
|
88
|
+
const id = resolve ? '' : want;
|
|
82
89
|
const day = String(date || String(ev.at || ev.start || '').slice(0, 10) || '');
|
|
83
90
|
ensureCss();
|
|
84
91
|
const host = document.getElementById('c3-root') || document.body;
|
|
85
|
-
const root = el('div', { class: 'c3focus', role: 'region', 'aria-label': 'Meeting focus', 'data-c3focus': '' });
|
|
92
|
+
const root = el('div', { class: 'c3focus' + (pop ? ' fc-pop' : ''), role: 'region', 'aria-label': pop ? 'PMOS meeting helper' : 'Meeting focus', 'data-c3focus': '', ...(pop ? { 'data-fc-pop': '' } : {}) });
|
|
86
93
|
const r = {};
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
r.
|
|
90
|
-
r.
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
94
|
+
const convo = el('section', { class: 'fc-cv', 'aria-label': 'The conversation, with PMOS' },
|
|
95
|
+
pop ? null : el('div', { class: 'fc-cv-h' }, el('b', null, 'Conversation'), el('span', { class: 'dim' }, 'PMOS replies only to you')),
|
|
96
|
+
r.tick = el('div', { class: 'fc-tk', 'aria-label': 'Last lines in the room', 'data-fc': 'ticker' }),
|
|
97
|
+
r.stream = el('ol', { class: 'fc-s2', role: 'list', 'data-fc': 'stream' }),
|
|
98
|
+
r.form = askForm());
|
|
99
|
+
const page = pop
|
|
100
|
+
? el('div', { class: 'fc-page' },
|
|
101
|
+
r.top = el('header', { class: 'fc-ph', 'data-fc': 'pop-head' }),
|
|
102
|
+
el('div', { class: 'fc-popbody', 'data-fc': 'pop-body' },
|
|
103
|
+
r.band = el('div', { class: 'fc-bandwrap' }), convo,
|
|
104
|
+
r.foot = el('div', { class: 'fc-foot', 'data-fc': 'pop-foot' },
|
|
105
|
+
lk({ 'data-fc': 'pop-open', title: 'Opens the full focus page in a new tab' }, icon('out', 'sm'), 'Open focus in PMOS'), el('span', { class: 'dim mono' }, 'only you see this'))))
|
|
106
|
+
: el('div', { class: 'fc-page' },
|
|
107
|
+
r.top = el('header', { class: 'card fc-top', 'aria-label': 'Meeting' }),
|
|
108
|
+
r.now = el('div', { class: 'fc-nowl', 'data-fc': 'now-line' }),
|
|
109
|
+
r.band = el('div', { class: 'fc-bandwrap' }),
|
|
110
|
+
el('div', { class: 'fc-main' },
|
|
111
|
+
el('div', { class: 'fc-s' }, r.stage = el('div', { class: 'fc-stagewrap' }), r.cap = el('div', { class: 'fc-capwrap' })),
|
|
112
|
+
el('aside', { class: 'fc-w', 'aria-label': 'Conversation' }, convo)));
|
|
99
113
|
root.append(page);
|
|
100
|
-
F = { id, date: day, ev0: ev, root, r, sig: {}, data: null, offset: 0, ui: { open: new Set(), folded: new Set(), pulled: [], stage: 0, picker: false, newTopic: false, addTopic: false, heldOpen: false, pending: new Map(), failed: new Map() },
|
|
114
|
+
F = { id, date: day, ev0: ev, root, r, sig: {}, data: null, offset: 0, ui: { open: new Set(), folded: new Set(), pulled: [], stage: 0, picker: false, newTopic: false, addTopic: false, heldOpen: false, pending: new Map(), failed: new Map(), pill: false },
|
|
115
|
+
pop: !!pop, meet: String(meet || ''), mtitle: String(mtitle || ''), choices: null,
|
|
101
116
|
timers: {}, askTimers: new Map(), sheet: null, sheetOpen: false, status: 'reading', error: '',
|
|
102
117
|
opener: document.activeElement && document.activeElement !== document.body ? document.activeElement : null, seenNotes: 0 };
|
|
103
118
|
// a fixed box inside an element with container-type is placed against that element, not the screen: lift it while the page is open
|
|
104
119
|
host.classList.add('fc-open');
|
|
105
120
|
host.append(root);
|
|
106
121
|
const grid = host.querySelector && host.querySelector('.c3grid'); if (grid) grid.inert = true;
|
|
107
|
-
try { const u = new URL(location.href); u.searchParams.set('focus', id); if (day) u.searchParams.set('fdate', day); history.replaceState(history.state, '', u); } catch { /* no history */ }
|
|
122
|
+
if (!pop) { try { const u = new URL(location.href); u.searchParams.set('focus', id); if (day) u.searchParams.set('fdate', day); history.replaceState(history.state, '', u); } catch { /* no history */ } } // the compact page keeps its address: a reload asks again which meeting it is
|
|
108
123
|
draw();
|
|
109
124
|
F.timers.clock = setInterval(tick, 1000);
|
|
110
|
-
load();
|
|
125
|
+
if (resolve) resolveNow(); else load();
|
|
111
126
|
const lv = root.querySelector('[data-fc="leave"]'); if (lv) lv.focus({ preventScroll: true });
|
|
112
127
|
}
|
|
113
128
|
|
|
129
|
+
/* ── which meeting is this call (compact mode) ──────────────────────────────────────────────────────────────────── */
|
|
130
|
+
|
|
131
|
+
const NOW_RETRY_MS = 30000;
|
|
132
|
+
async function resolveNow() {
|
|
133
|
+
const f = F; if (!f) return;
|
|
134
|
+
clearTimeout(f.timers.poll);
|
|
135
|
+
const p = new URLSearchParams();
|
|
136
|
+
if (f.meet) p.set('meet', f.meet);
|
|
137
|
+
if (f.mtitle) p.set('title', f.mtitle);
|
|
138
|
+
const j = await pmGet('/api/meeting-focus/now?' + p.toString());
|
|
139
|
+
if (F !== f) return;
|
|
140
|
+
if (j && j.ok && j.event && j.event.id) { chooseMeeting(j.event, j.date); return; }
|
|
141
|
+
if (j && Array.isArray(j.choices)) { f.status = 'nomeeting'; f.choices = j.choices; f.error = ''; }
|
|
142
|
+
else { f.status = 'error'; f.error = (j && j.error) || 'PMOS could not look for the meeting.'; f.choices = null; }
|
|
143
|
+
draw();
|
|
144
|
+
f.timers.poll = setTimeout(resolveNow, NOW_RETRY_MS); // a meeting that starts soon is found without a press
|
|
145
|
+
}
|
|
146
|
+
/** The meeting this page shows from now on: read it, and keep reading it. */
|
|
147
|
+
function chooseMeeting(ev, date) {
|
|
148
|
+
const f = F; if (!f) return;
|
|
149
|
+
clearTimeout(f.timers.poll);
|
|
150
|
+
f.id = String(ev.id); f.ev0 = ev; f.date = String(date || String(ev.at || '').slice(0, 10)); f.choices = null; f.status = 'reading'; f.error = ''; f.data = null;
|
|
151
|
+
draw();
|
|
152
|
+
load();
|
|
153
|
+
}
|
|
154
|
+
|
|
114
155
|
/* ── reading ──────────────────────────────────────────────────────────────────────────────────────────────────── */
|
|
115
156
|
|
|
116
157
|
async function load() {
|
|
@@ -154,6 +195,8 @@ const recState = (d) => (d.note ? d.note.state : 'none');
|
|
|
154
195
|
function draw() {
|
|
155
196
|
if (!F) return;
|
|
156
197
|
const d = F.data;
|
|
198
|
+
F.root.dataset.fcState = d ? 'ok' : F.status;
|
|
199
|
+
if (F.pop) { drawPop(d); return; }
|
|
157
200
|
if (!d) {
|
|
158
201
|
const title = F.ev0.title || 'Meeting';
|
|
159
202
|
region('top', ['wait', F.status, F.error], () => [el('div', { class: 'fc-id' }, leaveBtn(), el('div', { class: 'fc-tt' }, el('h1', null, title),
|
|
@@ -166,6 +209,60 @@ function draw() {
|
|
|
166
209
|
tick();
|
|
167
210
|
}
|
|
168
211
|
|
|
212
|
+
/* ── compact mode: a head, what is being said, the conversation and a foot ──────────────────────────────────────── */
|
|
213
|
+
|
|
214
|
+
function drawPop(d) {
|
|
215
|
+
const ph = d ? phaseOf() : 'before';
|
|
216
|
+
drawPopHead(d, ph);
|
|
217
|
+
if (!d) {
|
|
218
|
+
region('band', ['msg', F.status, F.error, F.choices], popMessage);
|
|
219
|
+
for (const k of ['tick', 'stream']) region(k, 'wait', () => []);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
drawBand(d); drawConversation(d);
|
|
223
|
+
tick();
|
|
224
|
+
}
|
|
225
|
+
function drawPopHead(d, ph) {
|
|
226
|
+
const e = d ? d.event : F.ev0 || {}; const cur = d ? currentOf(d) : null;
|
|
227
|
+
const sig = [F.status, e.title, e.at, e.end, ph, cur && cur.text, F.ui.pill];
|
|
228
|
+
region('top', sig, () => {
|
|
229
|
+
const s = startMs(); const f = endMs();
|
|
230
|
+
const label = !d ? (F.status === 'nomeeting' ? 'No meeting right now' : F.status === 'reading' ? 'Reading the meeting' : 'No meeting to show')
|
|
231
|
+
: ph === 'before' ? ['Starts in ', el('span', { class: 'mono', 'data-fc': 'big' }, clockText((s - nowMs()) / 1000))]
|
|
232
|
+
: ph === 'after' ? (e.title || 'Meeting') + ' has ended' : cur ? cur.text : e.title;
|
|
233
|
+
const left = d && ph === 'during' && Number.isFinite(f) ? el('span', { class: 'mono dim', 'data-fc': 'left', title: 'Time left' }, clockText((f - nowMs()) / 1000)) : null;
|
|
234
|
+
const lbl = F.ui.pill ? 'Open' : 'Fold to a pill';
|
|
235
|
+
return [el('b', { class: 'mono' }, 'PMOS'), el('span', { class: 'fc-ph-topic', 'data-fc': 'pop-topic', title: typeof label === 'string' ? label : '' }, label), left,
|
|
236
|
+
ib(lbl, F.ui.pill ? 'next' : 'fold', { 'data-fc': 'pill', 'aria-pressed': String(F.ui.pill) })];
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
function popMessage() {
|
|
240
|
+
if (F.status === 'nomeeting') {
|
|
241
|
+
const cs = F.choices || [];
|
|
242
|
+
return [el('div', { class: 'fc-popmsg', role: 'status', 'data-fc': 'pop-msg' },
|
|
243
|
+
el('p', null, el('b', null, 'No meeting right now.'), ' PMOS could not tell which meeting this call is.'),
|
|
244
|
+
cs.length ? el('p', { class: 'dim mono fc-gl' }, 'Today\'s meetings. Pick this call\'s.') : el('p', { class: 'dim' }, 'Nothing else is on today\'s calendar.'),
|
|
245
|
+
cs.length ? el('div', { class: 'fc-choices', role: 'list', 'data-fc': 'choices' }, ...cs.map((c, i) => pbtn('outline', [el('span', { class: 'mono' }, hhmm(Date.parse(c.at))), ' ' + c.title], null, { 'data-fc': 'choice', 'data-i': String(i), role: 'listitem' }))) : null)];
|
|
246
|
+
}
|
|
247
|
+
if (F.status === 'reading') return [el('p', { class: 'dim mono fc-popmsg', role: 'status', 'data-fc': 'pop-msg' }, 'Reading the meeting.')];
|
|
248
|
+
return [el('p', { class: 'dim mono fc-popmsg', role: 'status', 'data-fc': 'pop-msg' }, F.status === 'missing' ? 'PMOS has no record of this meeting on the calendar, so there is nothing to show.' : F.error || 'The meeting could not be read.')];
|
|
249
|
+
}
|
|
250
|
+
/** The full focus page, in a new tab: the compact page's way to everything it leaves out. */
|
|
251
|
+
function fullUrl() {
|
|
252
|
+
const u = new URL('/c3.html', location.origin);
|
|
253
|
+
u.searchParams.set('focus', F.data ? F.data.event.id : F.id);
|
|
254
|
+
if (F.date) u.searchParams.set('fdate', F.date);
|
|
255
|
+
if (wsParam()) u.searchParams.set('ws', wsParam());
|
|
256
|
+
if (isDemo()) u.searchParams.set('demo', '1');
|
|
257
|
+
return u.href;
|
|
258
|
+
}
|
|
259
|
+
function openFull() { try { window.open(fullUrl(), '_blank', 'noopener'); } catch { /* a blocked pop-up */ } }
|
|
260
|
+
function setPill(on) {
|
|
261
|
+
F.ui.pill = on; F.root.classList.toggle('fc-pill', on);
|
|
262
|
+
draw();
|
|
263
|
+
try { if (window.parent && window.parent !== window) window.parent.postMessage({ pmosFocus: on ? 'fold' : 'open' }, '*'); } catch { /* no parent */ } // one word, no data: the extension resizes its frame
|
|
264
|
+
}
|
|
265
|
+
|
|
169
266
|
const leaveBtn = () => pbtn('ghost', 'Leave focus', 'prev', { 'data-fc': 'leave', title: 'Back to where you were. The meeting keeps recording.' });
|
|
170
267
|
|
|
171
268
|
function leftText(d, ph) {
|
|
@@ -339,7 +436,7 @@ function askBubble(a, err) {
|
|
|
339
436
|
}
|
|
340
437
|
|
|
341
438
|
function drawConversation(d) {
|
|
342
|
-
const tail = (d.tail || []).slice(-2);
|
|
439
|
+
const tail = (d.tail || []).slice(F.pop ? -1 : -2);
|
|
343
440
|
region('tick', tail, () => tail.map((x) => el('p', null, el('span', { class: 'mono' }, x.who), ' ' + x.text)));
|
|
344
441
|
const notes = d.notes || [];
|
|
345
442
|
const pulled = F.ui.pulled.map((id) => notes.find((n) => n.id === id)).filter(Boolean);
|
|
@@ -505,6 +602,7 @@ const goTab = (id) => { const b = document.querySelector('[data-c3-tab="' + id +
|
|
|
505
602
|
function openRef(ref) {
|
|
506
603
|
if (!ref) return;
|
|
507
604
|
const t = ref.type;
|
|
605
|
+
if (F && F.pop) { openFull(); return; } // the compact page is a small frame: what a note names opens in the full page
|
|
508
606
|
closeFocus();
|
|
509
607
|
if (t === 'initiative') { import('./c3-init-extras.js').then((m) => { if (!m.goInitiative(ref.id)) goTab('init'); }).catch(() => goTab('init')); return; }
|
|
510
608
|
if (t === 'note') { window.dispatchEvent(new CustomEvent('c3-open-note', { detail: { id: ref.id } })); return; }
|
|
@@ -549,6 +647,9 @@ function onClick(ev) {
|
|
|
549
647
|
const t = ev.target.closest('[data-fc]'); if (!t || !F || !F.root.contains(t)) return;
|
|
550
648
|
const k = t.dataset.fc; const d = F.data;
|
|
551
649
|
if (k === 'leave') { closeFocus(); return; }
|
|
650
|
+
if (k === 'pill') { setPill(!F.ui.pill); return; }
|
|
651
|
+
if (k === 'pop-open') { openFull(); return; }
|
|
652
|
+
if (k === 'choice') { const c = (F.choices || [])[Number(t.dataset.i)]; if (c) chooseMeeting(c, String(c.at || '').slice(0, 10)); return; }
|
|
552
653
|
if (!d) return;
|
|
553
654
|
if (k === 'topics') { showSheet('topics', 'Topics', 'todo', topicsBuild); return; }
|
|
554
655
|
if (k === 'captured') { showSheet('captured', 'Captured so far', 'check', capturedBuild); return; }
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/* pm/c3-ii-tabs.js — the six tabs of an initiative page (mockup v5, 34-initiatives.js iniOverview, iniBreakdown, iniDocs, iniTickets,
|
|
2
2
|
* iniTimeline, iniHistory). registerInitiativeTab(id, mount(host, ctx)); ctx.row, ctx.history (newest first), ctx.tools, ctx.toolsByCat
|
|
3
3
|
* and ctx.tool come from the page (c3-initiatives.js).
|
|
4
|
-
* Overview Left: a Summary, then one card per tool of the current stage: the stage records (c3-ini-records.js: problem, RICE, launch
|
|
4
|
+
* Overview On top, the Agent strip (c3-driver.js: GET /api/driver/strip; not in demo mode). Left: a Summary, then one card per tool of the current stage: the stage records (c3-ini-records.js: problem, RICE, launch
|
|
5
5
|
* checklist, burndown, kill conditions, learnings) and the server's stage tools (ideas, PRD, breakdown, tickets, releases,
|
|
6
6
|
* measurements, …) drawn from what PMOS holds. ctx.tool (a tool pressed in the strip or picked in More tools) is drawn
|
|
7
7
|
* first and scrolled to. Right: Details (portfolio, board, workflow with Edit, stage, owner, anchor, horizon, lane), Progress,
|
|
@@ -19,6 +19,7 @@ import { wfOf, catOf, kindOf, tplName, toolLabel, toolIcon, loadItems, dropItems
|
|
|
19
19
|
import { recordCards, recordToolsFor, RECORD_META } from './c3-ini-records.js';
|
|
20
20
|
import { loadDocs, renderDocs } from './c3-ib-docs.js';
|
|
21
21
|
import { timeline, tlInit, tlRange, tlStill } from './c3-ini-timeline.js';
|
|
22
|
+
import { driverStrip } from './c3-driver.js';
|
|
22
23
|
|
|
23
24
|
const errLine = (text) => el('div', { class: 'fold w', role: 'alert', 'data-ini': 'error' }, icon('alert', 'sm'), text);
|
|
24
25
|
const toneOf = (s) => (s === 'late' ? 'bad' : s === 'at risk' ? 'warn' : 'ok');
|
|
@@ -138,7 +139,7 @@ tab('overview', async (ctx, isDead, host) => {
|
|
|
138
139
|
rels.length ? card({ ic: 'out', title: 'Releases', sub: 'live in the Calendar' }, chgl(rels.map((x) => el('li', null, icon('out', 'sm'), el('span', null, (x.name || x.id) + ' · ' + day(String(x.release_at || x.deploy_at || '').slice(0, 10))), tag('Calendar'))))) : null,
|
|
139
140
|
];
|
|
140
141
|
if (picked) requestAnimationFrame(() => { const n = host.querySelector('[data-ini-tool-card="' + CSS.escape(picked) + '"]'); if (n && !isDead()) { if (n.scrollIntoView) n.scrollIntoView({ block: 'nearest' }); n.classList.add('ini-flash'); setTimeout(() => n.classList.remove('ini-flash'), 1400); } });
|
|
141
|
-
return cols(left, right);
|
|
142
|
+
return [ctx.demo ? null : driverStrip(ctx, r), cols(left, right)];
|
|
142
143
|
});
|
|
143
144
|
|
|
144
145
|
/* ── Breakdown ── */
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* Rules the builders keep (the conformance gate, R28): every control is a <button> from primitives.js button() (btn() below adds the
|
|
8
8
|
* console3 class it is drawn as, and `ini-b`, the reset that takes the primitive's own height and nowrap off); a colour, a width or a
|
|
9
9
|
* position is a class or a `--in-…` custom property set with geo(), never an inline style; icons come from the console3 sprite
|
|
10
|
-
* (c3ic) or, for the
|
|
10
|
+
* (c3ic) or, for the four it lacks (gear, grip, pause, the Jira mark), from svgIcon(). Nothing here reads or writes the server.
|
|
11
11
|
*
|
|
12
12
|
* btn(attrs, ...kids) a button; attrs.class is the console3 class (pill, chip2 lk, ib sm, tt, ...), attrs.onclick its press
|
|
13
13
|
* pbtn(variant, label, icon, attrs) a primitive button drawn as the primitive (primary, outline, ghost), small
|
|
@@ -41,12 +41,14 @@ export function svgIcon(paths, cls) {
|
|
|
41
41
|
}
|
|
42
42
|
export const GEAR = ['M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z', 'M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0z'];
|
|
43
43
|
export const GRIP = ['M9 5h.01M9 12h.01M9 19h.01M15 5h.01M15 12h.01M15 19h.01'];
|
|
44
|
+
export const PAUSE = ['M8 5v14M16 5v14'];
|
|
44
45
|
export const JIRA = ['M12 3l9 9-9 9-9-9z', 'M12 8l4 4-4 4-4-4z'];
|
|
45
46
|
/** An icon by name: the sprite's, or one of the three drawn here. */
|
|
46
47
|
export function icon(name, cls) {
|
|
47
48
|
if (name === 'settings') return svgIcon(GEAR, cls);
|
|
48
49
|
if (name === 'grip') return svgIcon(GRIP, cls);
|
|
49
50
|
if (name === 'jira') return svgIcon(JIRA, cls);
|
|
51
|
+
if (name === 'pause') return svgIcon(PAUSE, cls);
|
|
50
52
|
return c3ic(name, cls);
|
|
51
53
|
}
|
|
52
54
|
|
|
@@ -170,7 +172,7 @@ export function nrow(o) {
|
|
|
170
172
|
el('span', { class: 'ki' + (o.hot ? ' hot' : '') + (o.tone ? ' ini-k-' + o.tone : '') }, icon(o.ic || 'doc')),
|
|
171
173
|
el('div', { class: 'nb' }, t, o.sub ? note(o.sub) : null, o.chips && o.chips.length ? el('div', { class: 'chips' }, ...o.chips.filter(Boolean)) : null, o.after || null, xp),
|
|
172
174
|
o.right && o.right.filter(Boolean).length ? el('div', { class: 'acts' }, ...o.right.filter(Boolean)) : el('span'));
|
|
173
|
-
if (xp) attachExpand(li, xp, { key: o.key });
|
|
175
|
+
if (xp) attachExpand(li, xp, { key: o.key, initial: !!o.open, onToggle: o.onToggle });
|
|
174
176
|
return li;
|
|
175
177
|
}
|
|
176
178
|
export function crumbs(parts, right) {
|