create-pmos 0.116.0 → 0.118.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.
Files changed (33) hide show
  1. package/package.json +1 -1
  2. package/payload/KIT-VERSION +1 -1
  3. package/payload/planning/evals/eval-calibration.md +1 -1
  4. package/payload/planning/okrs/health-budgets.md +1 -1
  5. package/payload/state/discoveries.md +1 -1
  6. package/workspace/guide/notes.md +43 -3
  7. package/workspace/guide/today.md +22 -13
  8. package/workspace/lib/brief.js +2 -2
  9. package/workspace/lib/day-marks.js +29 -9
  10. package/workspace/lib/goals.js +68 -10
  11. package/workspace/lib/notes.js +139 -17
  12. package/workspace/lib/routes-notes.js +68 -11
  13. package/workspace/package-lock.json +2 -2
  14. package/workspace/package.json +1 -1
  15. package/workspace/public/pm/components.css +289 -91
  16. package/workspace/public/pm/dest-notes.js +136 -29
  17. package/workspace/public/pm/note-dock.js +204 -164
  18. package/workspace/public/pm/notes-context.js +60 -0
  19. package/workspace/public/pm/notes-links.js +32 -6
  20. package/workspace/public/pm/primitives.js +1 -1
  21. package/workspace/public/pm/today-agenda.js +27 -53
  22. package/workspace/public/pm/today-copy.js +34 -15
  23. package/workspace/public/pm/today-day.js +98 -76
  24. package/workspace/public/pm/today-goals.js +159 -15
  25. package/workspace/public/pm/today-jobs.js +84 -0
  26. package/workspace/public/pm/today-mentions.js +50 -0
  27. package/workspace/public/pm/topbar.js +71 -32
  28. package/workspace/public/pm/view-notes.js +142 -73
  29. package/workspace/server.js +10 -8
  30. package/workspace/test-files.json +4 -0
  31. package/workspace/test-lists/today-goals-jobs-ui.json +5 -0
  32. package/workspace/test-lists/today-goals-jobs.json +5 -0
  33. package/workspace/test-lists/today-mentions-cards.json +5 -0
@@ -8,9 +8,12 @@
8
8
  * Layout, as the mockup: the page title and a search field, then two cards side by side.
9
9
  * left BY INITIATIVE · n notes. One group per initiative, then "No initiative"; each row is the meeting time or day,
10
10
  * the title, and an amber dot when a statement in the note differs from a file in the repository.
11
- * right the note: one meta line (Meeting note · day time · length · initiative), the title with Record / Resume / Stop
12
- * and the ⋯ menu, then only what is there: Conflicts with other files, Highlights, the Overview, Decisions,
13
- * Next steps (owner · due, and Make task), Open questions, and the discussion and transcript folded at the end.
11
+ * right THE FULL NOTE (meeting-notes Run 13: the pop-up note is gone). One meta line (Meeting note · day time · length ·
12
+ * initiative) with an icon that shows the exact times and dates, the title with its icons (Resume and Stop, or Stop
13
+ * while recording; Copy as Markdown; ⋯), the Meeting context card (people, agenda, what it is for, details), the live
14
+ * block while the meeting is recording or paused (status, Your notes, the live transcript), then only what is there:
15
+ * Conflicts with other files, Highlights, the Overview, Decisions, Next steps (owner · due, and Make task), Open
16
+ * questions, and the discussion and transcript folded at the end. Every button is an icon with a tooltip.
14
17
  *
15
18
  * WHERE EACH PART COMES FROM. After Stop and the final pass, the agent's final notes (note.final_notes: grounded in quotes);
16
19
  * before that, what was caught live (view-notes.js caughtItems: the agent's minute bullets, else the labelled lines) and
@@ -20,9 +23,11 @@
20
23
  * to the note's initiative as a plan item in its first stage (POST /api/plan-items, the Breakdown's own writer), with Undo.
21
24
  */
22
25
 
23
- import { el } from './dom.js';
26
+ import { el, patchChildren } from './dom.js';
24
27
  import { button, iconButton, field, row, menu, toast, empty, ensureComponentsCss } from './primitives.js';
25
- import { ensureSprite } from './icons.js';
28
+ import { onNotes } from './notes-links.js';
29
+ import { agendaHeard, peopleOf } from './notes-context.js';
30
+ import { ensureSprite, icon } from './icons.js';
26
31
  import { pmGet, pmPost } from './http.js';
27
32
  import { registerCommand, openDestination } from './registry.js';
28
33
  import { askConfirm } from './dialogs.js';
@@ -43,10 +48,14 @@ export const COPY = Object.freeze({
43
48
  reading: 'Reading…',
44
49
  readError: 'The notes could not be read.',
45
50
  meetingNote: 'Meeting note',
46
- record: 'Record', resume: 'Resume', stop: 'Stop',
51
+ record: 'Record this note', resume: 'Resume', stop: 'Stop for good (cannot be resumed)', copy: 'Copy as Markdown (with the meeting context)', timeInfo: 'Exact times and dates',
47
52
  conflicts: 'Conflicts with other files',
48
53
  conflictWith: 'conflicts with',
49
54
  openFile: 'Open file',
55
+ linkMeeting: 'Link to a meeting', linkInitiative: 'Link to an initiative',
56
+ context: 'Meeting context', people: 'People', agenda: 'Agenda', about: 'What it is for', details: 'Details',
57
+ noMeeting: 'Not linked to a meeting. Link it to add the meeting’s people, agenda and details to the note.',
58
+ noContext: 'The calendar gave no more about this meeting.',
50
59
  highlights: 'Highlights',
51
60
  overview: 'Overview',
52
61
  noOverview: 'No overview yet. It is written when you stop recording.',
@@ -58,7 +67,6 @@ export const COPY = Object.freeze({
58
67
  undo: 'Undo',
59
68
  discussion: 'Discussion',
60
69
  transcript: (n) => `${n} transcript ${n === 1 ? 'line' : 'lines'}`,
61
- openFull: 'Open the full note',
62
70
  del: 'Delete note',
63
71
  delAsk: (t) => `Delete “${t}”?`,
64
72
  delBody: 'The note, its transcript and its audio are deleted from this machine.',
@@ -174,9 +182,10 @@ export function mountDestination(host, ctx) {
174
182
  inst.head = el('div', { class: 'rp-head nt-pg-head' }, el('h1', { class: 'rp-head__title' }, COPY.title), find);
175
183
  inst.root.replaceChildren(inst.head, el('div', { class: 'nt-pg-grid' }, inst.left, inst.right));
176
184
  }
185
+ if (!inst.unsub) inst.unsub = onNotes(() => { if (insts.get(host) === inst) refresh(inst); });
177
186
  inst.repo = (ctx && ctx.tab && ctx.tab.repo) || (ctx && ctx.state && ctx.state.product) || null;
178
187
  const slug = (ctx && ctx.tab && ctx.tab.slug) || null;
179
- if (slug !== inst.slug) { inst.slug = slug; if (slug) { inst.open = slug; inst.note = null; } }
188
+ if (slug !== inst.slug) { inst.slug = slug; if (slug) { inst.open = slug; inst.note = null; inst.ctxOpen = null; inst.info = false; loadOpen(inst); } }
180
189
  if (!inst.rows || Date.now() - inst.readAt > REREAD_MS) refresh(inst);
181
190
  drawLeft(inst);
182
191
  drawRight(inst);
@@ -186,6 +195,7 @@ export function disposeDestination(host) {
186
195
  const inst = insts.get(host);
187
196
  if (!inst) return;
188
197
  inst.seq++;
198
+ if (inst.unsub) inst.unsub();
189
199
  inst.root.remove();
190
200
  insts.delete(host);
191
201
  }
@@ -211,7 +221,7 @@ async function loadOpen(inst) {
211
221
  }
212
222
  function choose(inst, id) {
213
223
  if (inst.open === id) return;
214
- inst.open = id; inst.note = null;
224
+ inst.open = id; inst.note = null; inst.ctxOpen = null; inst.info = false;
215
225
  drawLeft(inst, true); drawRight(inst);
216
226
  loadOpen(inst);
217
227
  }
@@ -238,7 +248,7 @@ function drawLeft(inst, force) {
238
248
  trail: r.conflicts ? [el('span', { class: 'nt-pg-warn-dot', 'data-ntm-conflict-dot': '', title: r.conflicts + (r.conflicts === 1 ? ' conflict' : ' conflicts') })] : null,
239
249
  onOpen: () => choose(inst, r.id), openLabel: 'Open ' + (r.title || 'Untitled note'),
240
250
  openAttrs: { 'aria-current': r.id === inst.open ? 'true' : 'false' },
241
- attrs: { class: 'nt-pg-row', 'data-ntm-row': r.id } })))));
251
+ attrs: { class: 'nt-pg-row', 'data-ntm-row': r.id, title: 'Started ' + fullAt((r.sessions && r.sessions[0] && r.sessions[0].from) || startOf(r)) } })))));
242
252
  }
243
253
  inst.left.replaceChildren(head, body);
244
254
  }
@@ -248,42 +258,134 @@ function drawLeft(inst, force) {
248
258
  function drawRight(inst, force) {
249
259
  const n = inst.note;
250
260
  const live = dock.recordState();
251
- const sig = JSON.stringify([inst.open, n && [n.updated_at, n.title, n.final_notes && n.final_notes.at, (n.transcript || []).length, (n.highlights || []).length, n.agent && n.agent.markdown, iniName(n.initiative)], live.mode, live.id, !!inst.rows]);
252
- if (!force && sig === inst.rightSig) return;
253
- // a field being typed in is not redrawn under the cursor; the next read after it is left draws it
254
- if (inst.right.contains(document.activeElement) && document.activeElement.matches('input, [contenteditable="true"]')) return;
261
+ const held = !!n && live.mode !== 'idle' && live.id === n.id;
262
+ const sig = JSON.stringify([inst.open, n && [n.updated_at, n.title, n.final_notes && n.final_notes.at, (n.transcript || []).length, (n.highlights || []).length, n.agent && n.agent.markdown, iniName(n.initiative), n.state, n.resume_until, n.ended_at, (n.sessions || []).length, n.meeting && n.meeting.context && n.meeting.context.taken_at], live.mode, live.id, !!inst.rows, inst.ctxOpen, inst.info]);
263
+ // a held note draws again on every render: the clock, the lines and the paused minutes move. Nothing else draws without a change.
264
+ if (!force && !held && sig === inst.rightSig) return;
265
+ // a field being typed in is not redrawn under the cursor, except in the live block, whose fields are kept across redraws
266
+ const active = typeof document !== 'undefined' ? document.activeElement : null;
267
+ if (active && inst.right.contains(active) && active.matches('input, textarea, [contenteditable="true"]') && !active.closest('[data-nt-part="live"]')) return;
255
268
  inst.rightSig = sig;
256
- if (!inst.open) { inst.right.replaceChildren(empty({ text: inst.rows && inst.rows.length ? COPY.pick : COPY.none })); return; }
257
- if (!n) { inst.right.replaceChildren(el('p', { class: 'nt-hint' }, COPY.reading)); return; }
258
- inst.right.replaceChildren(...noteParts(inst, n, live));
269
+ const fresh = el('div');
270
+ if (!inst.open) fresh.appendChild(empty({ text: inst.rows && inst.rows.length ? COPY.pick : COPY.none }));
271
+ else if (!n) fresh.appendChild(el('p', { class: 'nt-hint' }, COPY.reading));
272
+ else for (const part of noteParts(inst, n, live)) fresh.appendChild(part);
273
+ // a live transcript read to its end follows the new lines; one the PM scrolled up in stays where they left it
274
+ const was = inst.right.querySelector('[data-nt-transcript]');
275
+ const atEnd = !!was && was.scrollTop + was.clientHeight >= was.scrollHeight - 8;
276
+ patchChildren(inst.right, fresh);
277
+ const box = inst.right.querySelector('[data-nt-transcript]');
278
+ if (atEnd && box && box.isConnected) box.scrollTop = box.scrollHeight;
279
+ }
280
+
281
+ const pad2 = (v) => String(v).padStart(2, '0');
282
+ const fullAt = (t) => { const d = new Date(t); return Number.isFinite(d.getTime()) ? d.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric' }) + ', ' + pad2(d.getHours()) + ':' + pad2(d.getMinutes()) + ':' + pad2(d.getSeconds()) : ''; };
283
+ const hmAt = (t) => { const d = new Date(t); return Number.isFinite(d.getTime()) ? pad2(d.getHours()) + ':' + pad2(d.getMinutes()) : ''; };
284
+ const TZ = (() => { try { return Intl.DateTimeFormat().resolvedOptions().timeZone; } catch { return ''; } })();
285
+
286
+ /** The exact times and dates of a note (R56): the calendar event, each recording stretch, the total, the status, the resume deadline. */
287
+ function infoPanel(n) {
288
+ const rows = [];
289
+ const m = n.meeting && n.meeting.kind === 'event' ? n.meeting : null;
290
+ if (m && m.start) rows.push(['Calendar event', fullAt(m.start).replace(/:\d\d$/, '') + (m.end ? '–' + hmAt(m.end) : '')]);
291
+ (n.sessions || []).forEach((x, i) => rows.push(['Recording ' + (i + 1), fullAt(x.from) + (x.to ? ' → ' + fullAt(x.to) : ' → now')]));
292
+ const total = (n.sessions || []).reduce((a, x) => a + Math.max(0, (x.to ? Date.parse(x.to) : Date.now()) - Date.parse(x.from)), 0);
293
+ if (total) rows.push(['Total recorded', V.fmtTime(total)]);
294
+ rows.push(['Status', ({ recording: 'Recording', paused: 'Paused', ended: 'Ended', idle: 'Not recorded' })[n.state] || '']);
295
+ if (n.state === 'paused' && n.resume_until) rows.push(['Can be resumed', 'yes, until ' + fullAt(n.resume_until)]);
296
+ else if (n.state === 'ended') rows.push(['Can be resumed', 'no: ' + (n.ended_by === 'stop' ? 'stopped for good' : 'paused over an hour, so it ended') + (n.ended_at ? ' at ' + fullAt(n.ended_at) : '')]);
297
+ if (TZ) rows.push(['Time zone', TZ]);
298
+ return el('dl', { class: 'nt-pg-info', 'data-ntm': 'info' }, rows.map(([k, v]) => [el('dt', null, k), el('dd', null, v)]));
299
+ }
300
+
301
+ /** The Meeting context card (R59): what the note kept of its meeting. Only what the calendar and the brief gave; nothing is made up. */
302
+ function contextCard(inst, n, held) {
303
+ const m = n.meeting && n.meeting.kind === 'event' ? n.meeting : null;
304
+ if (!m) {
305
+ return el('section', { class: 'nt-pg-ctx', 'data-ntm': 'context', 'data-ntm-context': 'none' },
306
+ el('div', { class: 'nt-pg-ctx__h' }, el('h3', { class: 'nt-pg-sec__h' }, COPY.context), el('span', { class: 'nt-pg-ctx__sum' }, COPY.noMeeting),
307
+ iconButton({ icon: 'link', ariaLabel: COPY.linkMeeting, onClick: () => V.linkMeeting({ state: inst.ctx.state, setState: inst.ctx.setState }, n.id), attrs: { 'data-ntm-link-meeting': '' } })));
308
+ }
309
+ const c = m.context || null;
310
+ const lines = (n.final && n.final.segments && n.final.segments.length ? n.final.segments : (n.transcript || []).filter((x) => !x.dropped)).map((x) => ({ t: x.t, text: x.text }));
311
+ const heard = c ? agendaHeard(c, lines) : [];
312
+ const names = peopleOf(c);
313
+ const open = inst.ctxOpen == null ? (held || !(n.transcript || []).length) : inst.ctxOpen;
314
+ const sum = [names.length ? names.length + (names.length === 1 ? ' person' : ' people') : c && c.attendees ? c.attendees + ' attendees' : null,
315
+ c && c.agenda && c.agenda.length ? heard.filter((x) => x !== null).length + ' of ' + c.agenda.length + ' agenda items heard' : null].filter(Boolean).join(' · ') || m.title;
316
+ const head = el('div', { class: 'nt-pg-ctx__h' }, el('h3', { class: 'nt-pg-sec__h' }, COPY.context), el('span', { class: 'nt-pg-ctx__sum' }, sum),
317
+ iconButton({ icon: 'chevron-down', ariaLabel: open ? 'Hide the meeting context' : 'Show the meeting context', expanded: open,
318
+ onClick: () => { inst.ctxOpen = !open; drawRight(inst, true); }, attrs: { class: 'nt-pg-ctx__toggle' + (open ? ' nt-pg-ctx__toggle--open' : ''), 'data-ntm-context-toggle': '' } }));
319
+ if (!open) return el('section', { class: 'nt-pg-ctx', 'data-ntm': 'context', 'data-ntm-context': 'folded' }, head);
320
+ const cols = [];
321
+ if (names.length) cols.push(el('div', { class: 'nt-pg-ctx__col', 'data-ntm-ctx': 'people' }, el('h4', null, COPY.people),
322
+ el('ul', null, names.map((x) => el('li', null, el('span', { class: 'nt-pg-av', 'aria-hidden': 'true' }, x.split(/\s+/).map((w) => w[0]).slice(0, 2).join('').toUpperCase()), el('span', null, x))))));
323
+ else if (c && c.attendees) cols.push(el('div', { class: 'nt-pg-ctx__col', 'data-ntm-ctx': 'people' }, el('h4', null, COPY.people), el('p', null, c.attendees + ' attendees. Names are not kept while calendar names are off.')));
324
+ if (c && c.agenda && c.agenda.length) cols.push(el('div', { class: 'nt-pg-ctx__col', 'data-ntm-ctx': 'agenda' }, el('h4', null, COPY.agenda),
325
+ el('ul', null, c.agenda.map((a, i) => el('li', { class: heard[i] !== null ? 'nt-pg-ag nt-pg-ag--heard' : 'nt-pg-ag', 'data-ntm-agenda': heard[i] !== null ? 'heard' : 'open' },
326
+ el('span', { class: 'nt-pg-ag__mk', 'aria-hidden': 'true' }, heard[i] !== null ? icon('check') : null), el('span', null, a),
327
+ heard[i] !== null ? el('span', { class: 'nt-pg-li__at' }, V.fmtTime(heard[i])) : null)))));
328
+ const about = [];
329
+ if (c && c.purpose) about.push(el('p', null, c.purpose));
330
+ if (c && c.prep) about.push(el('p', { class: 'nt-pg-dim' }, 'To be ready: ' + c.prep));
331
+ const tags = [];
332
+ if (n.initiative) tags.push(el('span', { class: 'nt-pg-chip' }, 'Initiative · ' + iniName(n.initiative)));
333
+ for (const r of (c && c.refs) || []) tags.push(r.link ? el('a', { class: 'nt-pg-chip', href: r.link, target: '_blank', rel: 'noopener noreferrer' }, r.label) : el('span', { class: 'nt-pg-chip' }, r.label));
334
+ if (about.length || tags.length) cols.push(el('div', { class: 'nt-pg-ctx__col', 'data-ntm-ctx': 'about' }, el('h4', null, COPY.about), ...about, tags.length ? el('div', { class: 'nt-pg-chips' }, tags) : null));
335
+ const det = [['When', fullAt(m.start).replace(/:\d\d$/, '') + (m.end ? '–' + hmAt(m.end) : '')]];
336
+ if (c && c.series) det.push(['Series', c.series]);
337
+ if (c && c.role) det.push(['Your role', c.role]);
338
+ if (c && c.external) det.push(['Outside', 'someone from outside the company attends']);
339
+ const links = ((c && c.links) || []).map((l) => el('a', { class: 'nt-pg-chip', href: l.url, target: '_blank', rel: 'noopener noreferrer' }, l.label));
340
+ cols.push(el('div', { class: 'nt-pg-ctx__col', 'data-ntm-ctx': 'details' }, el('h4', null, COPY.details),
341
+ el('dl', { class: 'nt-pg-dl' }, det.map(([k, v]) => [el('dt', null, k), el('dd', null, v)])), links.length ? el('div', { class: 'nt-pg-chips' }, links) : null));
342
+ return el('section', { class: 'nt-pg-ctx nt-pg-ctx--open', 'data-ntm': 'context', 'data-ntm-context': 'open' }, head,
343
+ el('div', { class: 'nt-pg-ctx__b' }, cols, c ? null : el('p', { class: 'nt-pg-dim nt-pg-ctx__note' }, COPY.noContext)));
259
344
  }
260
345
 
261
346
  function noteParts(inst, n, live) {
262
347
  const p = partsOf(n);
348
+ const held = live.mode !== 'idle' && live.id === n.id;
349
+ const recording = held && live.mode === 'recording';
263
350
  const meta = [COPY.meetingNote, whenLong(startOf(n)), minutes(lengthOf(n)), iniName(n.initiative)].filter(Boolean).join(' · ');
264
351
  const title = field({ label: 'Title', kind: 'title', hideLabel: true, value: n.title || '', placeholder: 'Untitled note',
265
352
  attrs: { 'data-ntm': 'title', class: 'nt-pg-title' },
266
- onCommit: async (v) => { const r = await pmPost('/api/notes/save', { id: n.id, title: v }); if (r && r.ok) { n.title = v; refresh(inst); } else toast({ text: COPY.notSaved, tone: 'destructive' }); } });
267
- const isLive = live.mode === 'recording' && live.id === n.id;
353
+ onCommit: async (v) => { const r = await pmPost('/api/notes/save', { id: n.id, title: v }); if (r && r.ok) { n.title = v; V.noteTitled(n.id, v); refresh(inst); } else toast({ text: COPY.notSaved, tone: 'destructive' }); } });
354
+ // the icons (R54): recording shows Stop only (Highlight and Pause are in the top bar); paused shows Resume and Stop; a stopped
355
+ // meeting shows neither; a note never recorded shows Record
268
356
  const acts = [];
269
357
  if (!n.private) {
270
- acts.push(isLive
271
- ? button({ label: COPY.stop, variant: 'outline', size: 'sm', onClick: () => dock.finish(), attrs: { 'data-ntm': 'stop' } })
272
- : button({ label: (n.transcript || []).length ? COPY.resume : COPY.record, variant: 'primary', size: 'sm', icon: 'mic',
273
- onClick: () => dock.recordNote(n.id, n.title), attrs: { 'data-ntm': (n.transcript || []).length ? 'resume' : 'record' } }));
358
+ if (held && !recording) acts.push(iconButton({ icon: 'play', ariaLabel: 'Resume ' + (n.title || 'the note'), onClick: () => dock.resumeNow(), attrs: { class: 'nt-pg-resume', 'data-ntm': 'resume' } }));
359
+ if (held) acts.push(iconButton({ icon: 'square', ariaLabel: COPY.stop, onClick: () => dock.stopNow(), attrs: { class: 'nt-pg-stop', 'data-ntm': 'stop' } }));
360
+ else if (n.state === 'idle' || (n.state === undefined && !(n.transcript || []).length)) {
361
+ const busy = live.mode === 'recording';
362
+ const b = iconButton({ icon: 'mic', ariaLabel: busy ? 'Pause or stop the meeting being recorded first' : COPY.record, onClick: () => { if (!busy) dock.recordNote(n.id); }, attrs: { 'data-ntm': 'record' } });
363
+ if (busy) b.setAttribute('aria-disabled', 'true');
364
+ acts.push(b);
365
+ }
274
366
  }
367
+ acts.push(iconButton({ icon: 'file-text', ariaLabel: COPY.copy, onClick: () => copyMarkdown(n), attrs: { 'data-ntm': 'copy' } }));
275
368
  const more = iconButton({ icon: 'ellipsis', ariaLabel: 'More', attrs: { 'data-ntm': 'more' },
276
369
  onClick: () => menu({ anchor: more, ariaLabel: 'Note actions', items: [
277
- { label: COPY.openFull, onSelect: () => dock.openDock(n.id) },
278
- { label: COPY.del, onSelect: () => deleteNote(inst, n) },
370
+ { label: COPY.linkMeeting, icon: 'link', onSelect: () => V.linkMeeting({ state: inst.ctx.state, setState: inst.ctx.setState }, n.id), attrs: { 'data-ntm-more-meeting': '' } },
371
+ { label: COPY.linkInitiative, icon: 'target', onSelect: () => V.linkInitiative({ state: inst.ctx.state, setState: inst.ctx.setState }, n.id), attrs: { 'data-ntm-more-initiative': '' } },
372
+ { sep: true },
373
+ { label: COPY.del, danger: true, onSelect: () => deleteNote(inst, n) },
279
374
  ] }) });
280
375
  acts.push(more);
281
376
  const out = [
282
377
  el('div', { class: 'nt-pg-top' },
283
- el('span', { class: 'nt-pg-meta', 'data-ntm': 'meta' }, meta),
378
+ el('div', { class: 'nt-pg-metarow' }, el('span', { class: 'nt-pg-meta', 'data-ntm': 'meta' }, meta),
379
+ iconButton({ icon: 'clock', ariaLabel: COPY.timeInfo, expanded: !!inst.info, size: 'sm', onClick: () => { inst.info = !inst.info; drawRight(inst, true); }, attrs: { 'data-ntm': 'time-info' } })),
380
+ inst.info ? infoPanel(n) : null,
284
381
  el('div', { class: 'nt-pg-titlerow' }, el('div', { class: 'nt-pg-titlewrap' }, title), el('div', { class: 'nt-pg-acts' }, acts))),
285
382
  ];
286
383
  if (n.private) out.push(el('p', { class: 'nt-hint' }, 'This note is private: it is not recorded, and nothing from it goes to Jev or to any agent.'));
384
+ out.push(contextCard(inst, n, held));
385
+ if (held) {
386
+ const liveHost = el('div', { class: 'nt-pg-live', 'data-ntm': 'live' });
387
+ if (V.renderLive(liveHost, { state: inst.ctx.state, setState: inst.ctx.setState, FIXTURE: inst.ctx.FIXTURE }, n.id)) out.push(liveHost);
388
+ }
287
389
 
288
390
  const cf = conflictsOf(n);
289
391
  if (cf.length) {
@@ -292,7 +394,7 @@ function noteParts(inst, n, live) {
292
394
  cf.map((c) => el('div', { class: 'nt-pg-li', 'data-ntm-conflict': c.doc },
293
395
  el('span', { class: 'nt-pg-warn-dot' }),
294
396
  el('span', { class: 'nt-pg-li__text' }, '“' + c.text + '” ', el('span', { class: 'nt-pg-dim' }, COPY.conflictWith + ' '), el('span', { class: 'nt-pg-mono' }, c.doc + (c.line ? ':' + c.line : ''))),
295
- button({ label: COPY.openFile, variant: 'outline', size: 'sm', onClick: () => openFile(inst, c.doc), attrs: { 'data-ntm-open-file': c.doc } })))));
397
+ iconButton({ icon: 'file-text', ariaLabel: COPY.openFile + ': ' + c.doc, onClick: () => openFile(inst, c.doc), attrs: { 'data-ntm-open-file': c.doc } })))));
296
398
  }
297
399
 
298
400
  const hl = n.highlights || [];
@@ -325,12 +427,13 @@ function noteParts(inst, n, live) {
325
427
  out.push(section('decision', COPY.decisions, p.decisions, '—'));
326
428
  out.push(section('action', COPY.actions, p.actions, '○', (x) => [
327
429
  x.owner || x.due ? el('span', { class: 'nt-pg-li__who' }, [x.owner, x.due].filter(Boolean).join(' · ')) : null,
328
- button({ label: COPY.makeTask, variant: 'outline', size: 'sm', onClick: (e) => makeTaskFrom(n, x, { repo: inst.repo, btn: e && e.currentTarget }), attrs: { 'data-ntm-make-task': '' } }),
430
+ iconButton({ icon: 'list-checks', ariaLabel: COPY.makeTask, onClick: (e) => makeTaskFrom(n, x, { repo: inst.repo, btn: e && e.currentTarget }), attrs: { 'data-ntm-make-task': '' } }),
329
431
  ]));
330
432
  out.push(section('question', COPY.questions, p.questions, '?'));
331
433
 
332
434
  const tr = (n.final && n.final.segments && n.final.segments.length ? n.final.segments.map((g) => ({ t: g.t, text: g.text })) : (n.transcript || []).filter((x) => !x.dropped));
333
- if (p.topics.length || tr.length) {
435
+ // while the meeting is held the live block already shows the transcript, so the fold waits for the end
436
+ if (!held && (p.topics.length || tr.length)) {
334
437
  const bits = [p.topics.length ? p.topics.length + (p.topics.length === 1 ? ' topic' : ' topics') : null, tr.length ? COPY.transcript(tr.length) : null].filter(Boolean).join(' · ');
335
438
  out.push(el('details', { class: 'nt-pg-sec nt-pg-fold', 'data-ntm': 'discussion' },
336
439
  el('summary', { class: 'nt-pg-fold__sum' }, COPY.discussion + ' · ' + bits),
@@ -364,6 +467,10 @@ async function saveFinal(inst, n, sec, i, text) {
364
467
  return true;
365
468
  }
366
469
 
470
+ async function copyMarkdown(n) {
471
+ try { await navigator.clipboard.writeText(V.noteMarkdown(n)); toast({ text: 'The note is on the clipboard as Markdown, with its meeting context.', timeout: 2500 }); }
472
+ catch { toast({ text: 'The clipboard was not available.', tone: 'destructive' }); }
473
+ }
367
474
  function openFile(inst, doc) {
368
475
  if (!inst.repo) return;
369
476
  import('./panel-tabs.js').then((m) => m.openDoc(inst.repo, doc, 'notes')).catch(() => toast({ text: 'The file could not be opened.', tone: 'destructive' }));