create-pmos 0.117.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.
@@ -19,11 +19,12 @@
19
19
  */
20
20
 
21
21
  import { el, setChildren } from './dom.js';
22
- import { button, needsDot, needsCount } from './primitives.js';
22
+ import { button, iconButton, menu, needsDot, needsCount } from './primitives.js';
23
23
  import { COPY } from './frame-copy.js';
24
24
  import * as dock from './note-dock.js';
25
- import { iconButton } from './primitives.js';
26
25
  import { el as h } from './dom.js';
26
+ import { upcomingToday, upcomingWithoutNote } from './notes-links.js';
27
+ import { contextOf, peopleOf } from './notes-context.js';
27
28
 
28
29
  /** The destinations the bar names, in order. Settings sits at the end of the bar, after Sync. Docs joined the bar at the
29
30
  * PM's word (2026-10-06, docs-three-way-sync: "I thought Docs is one of the main menu"). People joined after Docs when the
@@ -57,8 +58,9 @@ export function createTopbar({ go, openPalette, openView, repoMenu, sync } = {})
57
58
  const dot = needsDot();
58
59
  const links = {};
59
60
  for (const id of [...BAR_DESTS, 'settings']) {
60
- links[id] = button({
61
- label: COPY.nav[id], variant: 'ghost', icon: NAV_ICON[id],
61
+ // icon only (meeting-notes Run 13): the place's name is its tooltip and its aria-label
62
+ links[id] = iconButton({
63
+ icon: NAV_ICON[id], ariaLabel: COPY.nav[id],
62
64
  attrs: { class: 'fr-link', 'data-fr-dest': id, 'aria-current': 'false' },
63
65
  onClick: () => go && go(id),
64
66
  });
@@ -80,42 +82,80 @@ export function createTopbar({ go, openPalette, openView, repoMenu, sync } = {})
80
82
  }
81
83
  const nav = el('nav', { class: 'fr-nav', 'aria-label': COPY.nav.label }, BAR_DESTS.map((id) => links[id]));
82
84
 
83
- const search = button({
84
- label: COPY.search, variant: 'outline', icon: 'search',
85
+ const search = iconButton({
86
+ icon: 'search', ariaLabel: COPY.search + ' (' + COPY.searchKey + ')',
85
87
  attrs: { class: 'fr-search', 'data-fr': 'search', 'aria-keyshortcuts': 'Meta+K Control+K' },
86
88
  onClick: () => openPalette && openPalette(),
87
89
  });
88
- search.appendChild(el('kbd', { class: 'fr-kbd' }, COPY.searchKey));
89
90
 
90
- /* The Record control (meeting-notes Run 12, mockup board 2), right of the bar before Settings. Idle: Record. Recording: the
91
- * red dot and the clock (a press opens the note), the highlight and Pause. Paused: the clock and Resume. Stop is in the note. */
91
+ /* The Record control (meeting-notes Run 13, prototype/meeting-notes/record-control-mockup.html). Two groups, always in the same
92
+ * place. Left, only while a meeting is recording or paused: the chip that names it, then Highlight, Pause and Stop (recording)
93
+ * or Resume and Stop (paused). Nothing to resume: no left group. Right, always: New (an untitled note) and an arrow that lists
94
+ * today's meetings that have not started; both are unavailable while a meeting is recording. The highlight's pop-up hangs
95
+ * under the control. */
92
96
  const recSlot = el('div', { class: 'fr-rec', 'data-fr': 'record' });
93
97
  let recKey = '';
94
98
  let recTick = null;
95
- const recClock = () => { const st = dock.recordState(); return st.started ? fmtClock(Date.now() - st.started) : ''; };
99
+ const left = (until) => Math.max(1, Math.ceil((until - Date.now()) / 60000));
100
+ const chipText = (st) => (st.mode === 'recording' ? (st.started ? fmtClock(Date.now() - st.started) : '') : st.until ? 'paused · ' + left(st.until) + ' min left' : 'paused');
101
+ const tip = (b, text) => { b.title = text; b.setAttribute('aria-label', text); return b; };
102
+ const off = (b, why) => { b.setAttribute('aria-disabled', 'true'); b.classList.add('fr-rec__off'); tip(b, why); return b; };
103
+ const chipOf = (st) => {
104
+ const clock = h('span', { class: 'fr-rec__clock', 'data-fr-rec-clock': '' }, chipText(st));
105
+ const dotEl = h('span', { class: 'fr-rec-dot' + (st.mode === 'paused' ? ' fr-rec-dot--paused' : ''), 'aria-hidden': 'true' });
106
+ const b = button({ label: h('span', { class: 'fr-rec__txt' }, dotEl, h('b', { class: 'fr-rec__title' }, st.title), clock), variant: 'ghost',
107
+ attrs: { class: 'fr-rec__seg fr-rec__chip', 'data-fr-rec': st.mode, 'aria-label': (st.mode === 'recording' ? 'Recording ' : 'Paused: ') + st.title + '. Open the note.' }, onClick: () => dock.openDock(st.id) });
108
+ return b;
109
+ };
96
110
  function drawRecord() {
97
111
  const st = dock.recordState();
98
- const busy = st.mode === 'recording' && dockHighlight();
99
- const key = st.mode + ':' + st.id + ':' + (busy ? busy.state + busy.open : '');
112
+ const hl = st.mode === 'recording' ? dock.highlightBusy() : null;
113
+ const key = [st.mode, st.id, st.title, hl ? hl.state + hl.open : ''].join(':');
100
114
  if (key === recKey) return;
101
115
  recKey = key;
102
116
  clearInterval(recTick); recTick = null;
103
- if (st.mode === 'idle') {
104
- setChildren(recSlot, [button({ label: 'Record', variant: 'outline', icon: 'mic', attrs: { class: 'fr-rec__start', 'data-fr-rec': 'idle', title: 'Start a note and record the meeting' }, onClick: (e) => dock.recordNew(e && e.currentTarget) })]);
105
- return;
106
- }
107
- if (st.mode === 'paused') {
108
- setChildren(recSlot, [button({ label: 'Resume', variant: 'outline', icon: 'mic', attrs: { class: 'fr-rec__start', 'data-fr-rec': 'paused', title: 'Resume recording ' + (st.title || 'the note') }, onClick: () => dock.resumeNow() })]);
109
- return;
117
+ const kids = [];
118
+ const recording = st.mode === 'recording';
119
+ if (st.mode !== 'idle') {
120
+ const chip = chipOf(st);
121
+ const group = [chip];
122
+ if (recording) {
123
+ const hlb = iconButton({ icon: 'highlighter', ariaLabel: hl ? 'Highlight in progress: show it' : 'Highlight the last 30 seconds and what is said next',
124
+ attrs: { class: 'fr-rec__seg' + (hl ? ' fr-rec__seg--busy' : ''), 'data-fr-rec-highlight': hl ? hl.state : '' }, onClick: () => dock.highlightNow() });
125
+ group.push(hlb, iconButton({ icon: 'pause', ariaLabel: 'Pause (you can resume within 1 hour)', attrs: { class: 'fr-rec__seg', 'data-fr-rec-pause': '' }, onClick: () => dock.pauseNow() }));
126
+ } else {
127
+ group.push(iconButton({ icon: 'play', ariaLabel: 'Resume ' + st.title, attrs: { class: 'fr-rec__seg fr-rec__resume', 'data-fr-rec-resume': '' }, onClick: () => dock.resumeNow() }));
128
+ }
129
+ group.push(iconButton({ icon: 'square', ariaLabel: 'Stop for good (cannot be resumed)', attrs: { class: 'fr-rec__seg fr-rec__stop', 'data-fr-rec-stop': '' }, onClick: () => dock.stopNow() }));
130
+ kids.push(h('div', { class: 'fr-rec__group', role: 'group', 'aria-label': (recording ? 'Recording ' : 'Paused: ') + st.title }, group));
131
+ const clockNode = chip.querySelector('[data-fr-rec-clock]');
132
+ recTick = setInterval(() => { const s2 = dock.recordState(); if (clockNode) clockNode.textContent = chipText(s2); }, 1000);
110
133
  }
111
- const clock = h('span', { class: 'fr-rec__clock', 'data-fr-rec-clock': '' }, recClock());
112
- recTick = setInterval(() => { clock.textContent = recClock(); }, 1000);
113
- const open = button({ label: h('span', { class: 'fr-rec__txt' }, h('span', { class: 'fr-rec-dot', 'aria-hidden': 'true' }), clock), variant: 'ghost',
114
- attrs: { class: 'fr-rec__seg fr-rec__open', 'data-fr-rec': 'recording', 'aria-label': 'Recording ' + (st.title || 'a note') + '. Open the note.' }, onClick: () => dock.openDock(st.id) });
115
- const hl = iconButton({ icon: 'highlighter', ariaLabel: busy ? 'Highlight in progress: show it' : 'Highlight the last 30 seconds and what is said next',
116
- attrs: { class: 'fr-rec__seg' + (busy ? ' fr-rec__seg--busy' : ''), 'data-fr-rec-highlight': busy ? busy.state : '' }, onClick: () => dock.highlightNow() });
117
- const pause = iconButton({ icon: 'pause', ariaLabel: 'Pause recording', attrs: { class: 'fr-rec__seg', 'data-fr-rec-pause': '' }, onClick: () => dock.pauseNow() });
118
- setChildren(recSlot, [h('div', { class: 'fr-rec__group', role: 'group', 'aria-label': 'Recording' }, open, hl, pause)]);
134
+ const why = 'Pause or stop the meeting being recorded to start a new one';
135
+ const mic = iconButton({ icon: 'mic', ariaLabel: 'Record an untitled note', attrs: { class: 'fr-rec__seg fr-rec__new', 'data-fr-rec-new': '' }, onClick: (e) => { if (recording) return; dock.recordNew(e && e.currentTarget); } });
136
+ const arrow = iconButton({ icon: 'chevron-down', ariaLabel: 'Record for an upcoming meeting', size: 'sm', attrs: { class: 'fr-rec__seg fr-rec__upcoming', 'data-fr-rec-upcoming': '' },
137
+ onClick: (e) => { if (!recording) openUpcoming(e && e.currentTarget); } });
138
+ if (recording) { off(mic, why); off(arrow, why); }
139
+ kids.push(h('div', { class: 'fr-rec__group fr-rec__group--new', role: 'group', 'aria-label': 'New recording' }, mic, arrow));
140
+ const panel = recording ? dock.highlightPanel() : null;
141
+ if (panel) kids.push(panel);
142
+ setChildren(recSlot, kids);
143
+ }
144
+ /** The arrow beside New: Untitled note, then today's meetings that have not started and have no note, the next one marked. */
145
+ async function openUpcoming(anchor) {
146
+ const evs = upcomingWithoutNote(await upcomingToday());
147
+ const fmt = (e, i) => {
148
+ const c = contextOf(e) || {};
149
+ const mins = Math.max(1, Math.round((Date.parse(e.start) - Date.now()) / 60000));
150
+ const inText = mins < 60 ? 'in ' + mins + ' min' : 'in ' + Math.floor(mins / 60) + ' h' + (mins % 60 ? ' ' + (mins % 60) + ' min' : '');
151
+ const who = peopleOf(c); const extra = c.attendees ? ' (' + c.attendees + ' attendees)' : '';
152
+ const bits = [i === 0 ? 'Next' : null, inText, who.length ? who.slice(0, 2).join(', ') + (who.length > 2 ? ' +' + (who.length - 2) : '') : c.attendees ? c.attendees + ' attendees' : null, c.agenda && c.agenda.length ? c.agenda.length + ' agenda item' + (c.agenda.length === 1 ? '' : 's') : null].filter(Boolean);
153
+ void extra;
154
+ return { label: e.title, hint: e.time || '', detail: bits.join(' · '), attrs: { 'data-fr-rec-event': e.id || '' }, onSelect: () => dock.recordEvent(e) };
155
+ };
156
+ const items = [{ label: 'Untitled note', icon: 'plus', detail: 'not linked to a meeting', attrs: { 'data-fr-rec-untitled': '' }, onSelect: () => dock.recordNew() }, { sep: true },
157
+ ...(evs.length ? evs.map(fmt) : [{ label: 'No more meetings today', attrs: { 'data-fr-rec-none': '' }, onSelect: () => {} }])];
158
+ menu({ anchor, items, ariaLabel: 'Record a new meeting' });
119
159
  }
120
160
 
121
161
  let syncBtn = null;
@@ -130,11 +170,10 @@ export function createTopbar({ go, openPalette, openView, repoMenu, sync } = {})
130
170
  return b;
131
171
  }
132
172
  function syncButton(label, busy) {
133
- return button({
134
- label, variant: 'outline', size: 'sm', disabled: busy,
135
- attrs: { class: 'fr-sync', 'data-fr': 'sync', title: COPY.sync.title },
136
- onClick: () => sync && sync(),
137
- });
173
+ const b = iconButton({ icon: 'refresh-cw', ariaLabel: busy ? label : COPY.sync.title || label,
174
+ attrs: { class: 'fr-sync', 'data-fr': 'sync', 'data-fr-sync-label': label, 'aria-busy': busy ? 'true' : 'false' }, onClick: () => { if (!busy && sync) sync(); } });
175
+ if (busy) { b.setAttribute('aria-disabled', 'true'); b.classList.add('fr-sync--busy'); }
176
+ return b;
138
177
  }
139
178
 
140
179
  function update(m) {
@@ -28,10 +28,11 @@
28
28
  * calendar) and which initiative it belongs to, and how that was set: from the calendar event, suggested from what was said,
29
29
  * or chosen by the PM. Change opens a sheet for each.
30
30
  *
31
- * MINIMIZED AT THE BOTTOM RIGHT (Run 6). Take notes on a calendar event starts listening at once (listenNow: no chooser) and
32
- * keeps the note as a pill at the bottom right (pm/note-dock.js). The pill opens the note as a pop-up, which renderInline
33
- * draws in one column. While the pop-up shows a note, the Notes tab says so and does not draw it a second time.
34
- * Open in Notes shows it on the full screen.
31
+ * ONE FULL NOTE (Run 13). Take notes, Record and Resume start listening at once (listenNow: no chooser), and the Record
32
+ * control in the top bar (pm/topbar.js, pm/note-dock.js) holds the meeting. The note itself is the right column of the Notes
33
+ * place (pm/dest-notes.js); while the meeting is recording or paused that column draws renderLive: the status, Your notes
34
+ * and the live transcript. There is no pop-up note. PAUSE keeps the meeting open and resumable for an hour (POST
35
+ * /api/notes/pause); STOP ends it for good and writes the final notes (POST /api/notes/stop).
35
36
  *
36
37
  * Reads and writes (lib/routes-notes.js). Notes live in the per-user runtime directory, never in a repository:
37
38
  * GET /api/notes · /api/notes/one?id=… · /api/notes/engine?ws=… · /api/notes/recording?id=… · /api/people?ws=…
@@ -40,12 +41,13 @@
40
41
  * · summarize · summarize-agent · delete · line · recording-delete · link
41
42
  */
42
43
  import { el, kept } from './dom.js';
43
- import { askConfirm, askText } from './dialogs.js';
44
+ import { askConfirm, askText, askChoice } from './dialogs.js';
44
45
  import { pmGet, pmPost } from './http.js';
45
46
  import { mdRender } from './markdown.js';
46
47
  import { icon } from './icons.js';
47
48
  import { button, iconButton, field, row, sheet, empty, menu, toast, ensureComponentsCss } from './primitives.js';
48
49
  import { toInt16, makeResampler, openCapture } from './audio-capture.js';
50
+ import { agendaHeard, peopleOf as contextPeople } from './notes-context.js';
49
51
 
50
52
  const SAVE_MS = 700; // a pause this long in typing saves the note
51
53
  const POLL_MS = 1500; // while listening, and until the last line is labelled, the note is read this often
@@ -68,7 +70,7 @@ let speech = null; // the browser recogniser: { mode: 'dictate'|'reco
68
70
  let audio = null; // local Whisper capture: { id, state, sources, ctx, streams, started, clock, msg }
69
71
  let engine = null; // { ws, whisper, jev, at }
70
72
  let showList = false;
71
- let inlineDraw = false; // the pop-up is drawing: the fields and the player it shows are left in place (pm/note-dock.js)
73
+ let inlineDraw = false; // the live block is drawing: the fields it shows are left in place (renderLive)
72
74
  /** A node this view keeps across redraws (an editor field, the player). The pop-up patches itself (dom.js patchChildren), so
73
75
  * there the node is a placeholder and stays where it is: a field that is typed in is never moved, and keeps its focus. */
74
76
  const own = (node) => (inlineDraw ? kept(node) : node);
@@ -77,9 +79,9 @@ let busy = '';
77
79
  let player = null; // the replay: { id, bytes, el, playing }, kept across redraws so playback goes on
78
80
  let people = null; // { ws, names } — the repository's people, offered in the line editor
79
81
  let pendingOpen = null; // a note another page asked to open (openNote), selected at the next draw
80
- let transcriptOpen = { inline: false, full: true }; // the transcript is folded in the pop-up and open on the full screen
81
- let inlineSwitch = null; // the note the pop-up is switching to (renderInline), so a redraw does not start it twice
82
- let popped = null; // the note the pop-up shows (pm/note-dock.js), which the Notes tab then does not draw
82
+ let transcriptOpen = { inline: false, full: true }; // the transcript is open in the live block and on the full screen
83
+ let starting = false; // a listen is starting (listenNow): the live block must not switch notes under it
84
+ let inlineSwitch = null; // the note the live block is switching to (renderLive), so a redraw does not start it twice
83
85
  let liveTranscript = false; // after the final pass, the PM can look at the live transcript instead (Run 9)
84
86
  let wisprTimer = null;
85
87
  let highlight = null; // { id, state, listenDurationMs, listenedMs, text, label, conflicts } while highlighting
@@ -180,10 +182,38 @@ export function lineDiff(a, b) {
180
182
  while (j < y.length) out.push({ op: 'add', text: y[j++] });
181
183
  return out;
182
184
  }
185
+ /** The meeting's context as Markdown lines, first in the note (R59): when, where, people, the agenda with what was heard, what it is
186
+ * for and how to be ready, the links and related items. Empty when the note keeps no context. Pure. */
187
+ export function contextMarkdown(n) {
188
+ const m = n.meeting;
189
+ const c = m && m.context;
190
+ if (!m || m.kind !== 'event' || !c) return [];
191
+ const out = [];
192
+ const when = m.start ? dayOf(m.start) + ', ' + nowClock(m.start) + (m.end ? '–' + nowClock(m.end) : '') : '';
193
+ if (when) out.push('**When:** ' + when);
194
+ if (n.initiative) out.push('**Initiative:** ' + (n.initiative.title || n.initiative.id));
195
+ const who = contextPeople(c);
196
+ if (who.length) out.push('**People:** ' + who.join(', '));
197
+ else if (c.attendees !== null && c.attendees !== undefined) out.push('**People:** ' + c.attendees + (c.attendees === 1 ? ' attendee' : ' attendees'));
198
+ if (c.role) out.push('**Your role:** ' + c.role);
199
+ if (c.external) out.push('**Someone from outside the company attends.**');
200
+ if (c.series) out.push('**Series:** ' + c.series);
201
+ if (c.purpose) out.push('', '**What it is for:** ' + c.purpose);
202
+ if (c.prep) out.push('**How to be ready:** ' + c.prep);
203
+ if ((c.agenda || []).length) {
204
+ const heard = agendaHeard(c, (n.transcript || []).filter((x) => !x.dropped));
205
+ out.push('', '## Agenda', '', ...c.agenda.map((a, i) => '- [' + (heard[i] !== null ? 'x' : ' ') + '] ' + a + (heard[i] !== null ? ' (heard at ' + fmtTime(heard[i]) + ')' : '')));
206
+ }
207
+ if ((c.links || []).length) out.push('', ...c.links.map((l) => '- [' + l.label + '](' + l.url + ')'));
208
+ if ((c.refs || []).length) out.push('', '**Related:** ' + c.refs.map((r) => r.label).join('; '));
209
+ out.push('');
210
+ return out;
211
+ }
183
212
  /** The whole note as Markdown, for "Copy as Markdown". Pure, for the suite. */
184
213
  export function noteMarkdown(n) {
185
214
  const out = ['# ' + (n.title || 'Untitled note'), ''];
186
215
  if (n.created_at) out.push('_' + dayOf(n.created_at) + ', ' + nowClock(n.created_at) + '_', '');
216
+ out.push(...contextMarkdown(n));
187
217
  if (n.agent && n.agent.markdown) out.push('## Summary', '', n.agent.markdown.trim(), '');
188
218
  else if (n.summary && n.summary.sections && n.summary.sections.overview && n.summary.sections.overview.length) out.push('## Summary', '', ...n.summary.sections.overview.map((x) => '- ' + x.text), '');
189
219
  if (String(n.body || '').trim()) out.push('## Notes', '', n.body.trim(), '');
@@ -320,7 +350,7 @@ async function deleteNote(ctx, n) {
320
350
  if (r && r.ok) {
321
351
  cache.delete(n.id);
322
352
  if (list) list.notes = (list.notes || []).filter((x) => x.id !== n.id);
323
- if (here) { currentId = list && list.notes && list.notes.length ? list.notes[0].id : null; editor = null; if (popped === n.id) popped = null; }
353
+ if (here) { currentId = list && list.notes && list.notes.length ? list.notes[0].id : null; editor = null; }
324
354
  import('./note-dock.js').then((d) => d.forget(n.id)).catch(() => {});
325
355
  import('./notes-links.js').then((l) => l.refreshNotes && l.refreshNotes()).catch(() => {});
326
356
  toast({ text: '"' + (n.title || 'Untitled note') + '" was deleted.' });
@@ -401,8 +431,18 @@ async function finalSetup(ctx) {
401
431
  : { tone: 'bad', text: 'No terminal opened' + (r && r.reason ? ': ' + r.reason : '') + '. Run this in a terminal: ' + ((r && r.command) || '') };
402
432
  ctx.setState({});
403
433
  }
434
+ /** Name a speaker. When the note keeps its meeting's people (R59), the choice starts with them; "Someone else" asks for a name. */
435
+ async function askSpeaker(n, current, placeholder) {
436
+ const names = contextPeople(n.meeting && n.meeting.context);
437
+ const ask = () => askText({ title: 'Name this speaker', label: 'Name', value: current, placeholder, confirmLabel: 'Save' });
438
+ if (!names.length) return ask();
439
+ const other = '__other';
440
+ const pick = await askChoice({ title: 'Who is speaking?', label: 'Speaker', options: [...names.map((x) => ({ value: x, label: x })), { value: other, label: 'Someone else …' }], value: names.includes(current) ? current : names[0], confirmLabel: 'Save' });
441
+ if (pick == null) return null;
442
+ return pick === other ? ask() : pick;
443
+ }
404
444
  async function renameFinalSpeaker(ctx, n, key, now) {
405
- const name = await askText({ title: 'Name this speaker', label: 'Name', value: now, placeholder: now, confirmLabel: 'Save' });
445
+ const name = await askSpeaker(n, now, now);
406
446
  if (name == null) return;
407
447
  const r = await pmPost('/api/notes/speaker', { id: n.id, key, name });
408
448
  // the note may have been read again since this button was drawn, so read it once more rather than change a stale copy
@@ -428,7 +468,7 @@ async function unpick(ctx, n, x) {
428
468
  ctx.setState({});
429
469
  }
430
470
  async function renameSpeaker(ctx, n, source) {
431
- const name = await askText({ title: 'Name this speaker', label: 'Name', value: n.speakers && n.speakers[source] ? n.speakers[source] : '', placeholder: SPEAKER[source], confirmLabel: 'Save' });
471
+ const name = await askSpeaker(n, n.speakers && n.speakers[source] ? n.speakers[source] : '', SPEAKER[source]);
432
472
  if (name == null) return;
433
473
  const r = await pmPost('/api/notes/speaker', { id: n.id, source, name });
434
474
  if (r && r.ok) n.speakers = r.speakers; else banner = { tone: 'bad', text: (r && r.error) || 'not renamed' };
@@ -629,6 +669,7 @@ async function startAudio(ctx, n, want) {
629
669
  a.clock = setInterval(() => tickClocks(a.started), 1000);
630
670
  ctx.setState({});
631
671
  poll(ctx, n.id);
672
+ import('./notes-links.js').then((m) => m.refreshNotes()).catch(() => {});
632
673
  }
633
674
  function sendChunk(ctx, a, kind, final) {
634
675
  const b = a.buf[kind] || []; a.buf[kind] = [];
@@ -655,7 +696,7 @@ function sendChunk(ctx, a, kind, final) {
655
696
  }).catch(() => {});
656
697
  return a.queue[kind];
657
698
  }
658
- async function stopAudio(ctx, { discard = false } = {}) {
699
+ async function stopAudio(ctx, { discard = false, resumable = true } = {}) {
659
700
  const a = audio;
660
701
  if (!a) return;
661
702
  audio = null;
@@ -669,8 +710,10 @@ async function stopAudio(ctx, { discard = false } = {}) {
669
710
  const tail24 = a.rs24[k] ? a.rs24[k].flush() : []; const b24 = a.buf24[k]; for (let i = 0; i < tail24.length; i++) b24.push(tail24[i]);
670
711
  }
671
712
  await Promise.all(a.sources.map((k) => sendChunk(ctx, a, k, true)));
672
- await pmPost('/api/notes/pause', { id: a.id });
713
+ await pmPost(resumable ? '/api/notes/pause' : '/api/notes/stop', { id: a.id });
673
714
  ctx.setState({});
715
+ // a pause keeps the meeting open: the final notes wait for Stop, or for the hour to end
716
+ if (resumable) { poll(ctx, a.id); return; }
674
717
  const n = cache.get(a.id);
675
718
  if (!n || n.private) return;
676
719
  const t0 = Date.now();
@@ -763,7 +806,7 @@ async function startSpeech(ctx, n, mode, install = false) {
763
806
  rec.onend = () => { if (speech === s && s.state === 'on') { try { rec.start(); } catch { /* it is starting */ } } };
764
807
  s.rec = rec;
765
808
  try { rec.start(); s.state = 'on'; } catch (e) { s.state = 'error'; s.msg = 'Listening did not start: ' + ((e && e.message) || e); }
766
- if (mode === 'record') { s.clock = setInterval(() => tickClocks(s.started), 1000); poll(ctx, n.id); }
809
+ if (mode === 'record') { s.clock = setInterval(() => tickClocks(s.started), 1000); poll(ctx, n.id); import('./notes-links.js').then((m) => m.refreshNotes()).catch(() => {}); }
767
810
  ctx.setState({});
768
811
  }
769
812
 
@@ -841,7 +884,7 @@ async function removeHighlight(ctx, h) {
841
884
  export const highlightBusy = () => (highlight ? { state: highlight.state, open: hlOpen } : null);
842
885
  export function openHighlight(ctx) { if (highlight) { hlOpen = true; ctx.setState({}); } }
843
886
 
844
- /** The highlight's pop-up (pm/note-dock.js draws it on every place), or null when there is none or it is closed. */
887
+ /** The highlight's pop-up (pm/topbar.js draws it under the Record control), or null when there is none or it is closed. */
845
888
  export function highlightPanel(ctx) {
846
889
  const h = highlight;
847
890
  if (!h || !hlOpen) return null;
@@ -877,36 +920,57 @@ export function highlightPanel(ctx) {
877
920
  if (h.state === 'failed') body.push(el('p', { class: 'nt-hint nt-hint--bad', 'data-nt-hl-failed': '' }, 'No highlight: ' + h.error + '.'));
878
921
  const acts = [];
879
922
  if (h.state === 'listening') acts.push(button({ label: 'Stop listening now', variant: 'outline', size: 'sm', onClick: () => { h.pressed = Date.now() - hlAfterMs(); hlTick(ctx); }, attrs: { 'data-nt-hl-stop': '' } }));
880
- if (h.state === 'done') acts.push(button({ label: 'Done', variant: 'primary', size: 'sm', onClick: () => closeHighlight(ctx), attrs: { 'data-nt-hl-done': '' } }),
881
- button({ label: 'Remove', variant: 'ghost', size: 'sm', onClick: () => removeHighlight(ctx, h), attrs: { 'data-nt-hl-remove': '' } }));
882
- else acts.push(button({ label: 'Close', variant: 'ghost', size: 'sm', onClick: () => closeHighlight(ctx), attrs: { 'data-nt-hl-close': '' } }));
923
+ // icons only (Run 13): keep, discard, close; each carries its name as a tooltip and an aria-label
924
+ if (h.state === 'done') acts.push(iconButton({ icon: 'check', ariaLabel: 'Keep the highlight in the note', onClick: () => closeHighlight(ctx), attrs: { 'data-nt-hl-done': '' } }),
925
+ iconButton({ icon: 'ban', ariaLabel: 'Discard the highlight', onClick: () => removeHighlight(ctx, h), attrs: { 'data-nt-hl-remove': '' } }));
926
+ else acts.push(iconButton({ icon: 'x', ariaLabel: 'Close (the highlight keeps working)', onClick: () => closeHighlight(ctx), attrs: { 'data-nt-hl-close': '' } }));
883
927
  body.push(el('div', { class: 'nt-hl-acts' }, acts));
884
928
  return el('section', { class: 'nt-hl', role: 'dialog', 'aria-label': 'Highlight', 'data-nt-part': 'highlight-popup', 'data-nt-hl': h.state }, body);
885
929
  }
886
930
 
887
- async function stopRecording(ctx) {
931
+ async function stopRecording(ctx, { resumable = true } = {}) {
888
932
  const s = speech;
889
933
  if (s && s.mode === 'record') {
890
934
  clearInterval(s.clock); stopSpeech();
891
935
  await s.queue;
892
- await pmPost('/api/notes/pause', { id: s.id });
936
+ await pmPost(resumable ? '/api/notes/pause' : '/api/notes/stop', { id: s.id });
893
937
  const n = cache.get(s.id);
894
- if (n && !n.private && (n.transcript || []).length) await summarize(ctx, n, true);
938
+ if (!resumable && n && !n.private && (n.transcript || []).length) await summarize(ctx, n, true);
895
939
  }
896
- await stopAudio(ctx);
940
+ await stopAudio(ctx, { resumable });
897
941
  ctx.setState({});
898
942
  }
899
-
900
- /** Stop in the note: listening stops and the note leaves the Record control at the top right (pm/note-dock.js). */
943
+ /** Stop in the note: ask the Record control to stop the meeting for good (it asks first). */
901
944
  async function stopAndFinish(ctx) {
902
945
  const d = await import('./note-dock.js');
903
- if (d.recordState().mode !== 'idle') await d.finish(); else await stopRecording(ctx);
946
+ await d.stopNow();
904
947
  }
905
948
  /** Resume in the note: the Record control holds it again and it listens. */
906
949
  async function resumeOn(n) {
907
950
  const d = await import('./note-dock.js');
908
951
  await d.recordNote(n.id, n.title);
909
952
  }
953
+ /** PAUSE: listening stops and the meeting stays open for an hour; it can be resumed. No final notes yet. */
954
+ export const pauseListening = (ctx) => stopRecording(ctx, { resumable: true });
955
+ /** STOP: the meeting ends for good. Listening stops, the server ends the meeting, and the final notes are written. */
956
+ export async function stopMeeting(ctx, id) {
957
+ if (listeningId() === id) { await stopRecording(ctx, { resumable: false }); return; }
958
+ const r = await pmPost('/api/notes/stop', { id });
959
+ if (!(r && r.ok)) { banner = { tone: 'bad', text: (r && r.error) || 'the meeting was not stopped' }; ctx.setState({}); return; }
960
+ await loadNote(ctx, id);
961
+ const n = cache.get(id);
962
+ if (n && !n.private && (n.transcript || []).length) await summarize(ctx, n, true);
963
+ poll(ctx, id);
964
+ ctx.setState({});
965
+ }
966
+ /** Delete a recording that was started by mistake: stop listening without keeping the audio, then delete the note. */
967
+ export async function discardRecording(ctx, id) {
968
+ if (listeningId() === id) await stopAll(ctx, { discard: true });
969
+ const r = await pmPost('/api/notes/delete', { id });
970
+ if (r && r.ok) { cache.delete(id); if (currentId === id) { currentId = null; editor = null; dirty = false; clearTimeout(saveTimer); } }
971
+ ctx.setState({});
972
+ return !!(r && r.ok);
973
+ }
910
974
 
911
975
  /* ── the two sheets: what to listen to, and settings ─────────────────────────────────────────── */
912
976
  async function chooseSources(ctx, n) {
@@ -1215,8 +1279,7 @@ function moreMenu(ctx, n, opts, anchor) {
1215
1279
  const has = (n.transcript || []).length > 0;
1216
1280
  const items = [
1217
1281
  has ? { label: 'Copy as Markdown', onSelect: () => copyMarkdown(ctx, n), attrs: { 'data-nt-copy': '' } } : null,
1218
- opts.inline ? { label: 'Open in Notes', onSelect: () => openFull(ctx, n.id), attrs: { 'data-nt-full': '' } }
1219
- : { label: 'New meeting', onSelect: () => newNote(ctx), attrs: { 'data-nt-new': '' } },
1282
+ { label: 'New meeting', onSelect: () => newNote(ctx), attrs: { 'data-nt-new': '' } },
1220
1283
  { sep: true },
1221
1284
  { label: 'Change meeting', onSelect: () => meetingSheet(ctx, n), attrs: { 'data-nt-meeting': '' } },
1222
1285
  { label: n.initiative ? 'Change initiative' : 'Link an initiative', onSelect: () => initiativeSheet(ctx, n), attrs: { 'data-nt-ini': '' } },
@@ -1249,8 +1312,7 @@ function header(ctx, n, ed, opts = {}) {
1249
1312
  ? button({ label: 'Resume', variant: 'primary', size: 'sm', onClick: () => resumeOn(n), attrs: { 'data-nt-listen': 'again' } })
1250
1313
  : button({ label: 'Listen', variant: 'primary', size: 'sm', onClick: () => chooseSources(ctx, n), attrs: { 'data-nt-listen': '' } }));
1251
1314
  }
1252
- if (opts.onMinimize) acts.push(button({ label: 'Minimize', variant: 'ghost', size: 'sm', onClick: opts.onMinimize, attrs: { 'data-nt-minimize': '' } }));
1253
- if (!opts.inline) acts.push(button({ label: 'All notes', variant: 'ghost', size: 'sm', onClick: () => { showList = !showList; ctx.setState({}); if (showList) loadList(ctx); }, attrs: { 'data-nt-all': '', 'aria-expanded': showList ? 'true' : 'false' } }));
1315
+ acts.push(button({ label: 'All notes', variant: 'ghost', size: 'sm', onClick: () => { showList = !showList; ctx.setState({}); if (showList) loadList(ctx); }, attrs: { 'data-nt-all': '', 'aria-expanded': showList ? 'true' : 'false' } }));
1254
1316
  const more = iconButton({ icon: 'ellipsis', ariaLabel: 'More', onClick: (e) => moreMenu(ctx, n, opts, (e && e.currentTarget) || more), attrs: { 'data-nt-more': '' } });
1255
1317
  acts.push(more);
1256
1318
  return el('header', { class: 'nt-header', 'data-nt-part': 'header' },
@@ -1601,12 +1663,7 @@ export function renderBoard(inner, ctx) {
1601
1663
  if (banner) wrap.appendChild(el('div', { class: 'nt-banner' + (banner.tone === 'bad' ? ' nt-banner--bad' : ''), 'data-nt-banner': banner.tone, role: banner.tone === 'bad' ? 'alert' : 'status' }, banner.text));
1602
1664
  const n = currentId ? cache.get(currentId) : null;
1603
1665
  if (currentId && !n) { loadNote(ctx, currentId); wrap.appendChild(el('p', { class: 'nt-hint' }, 'reading the note …')); }
1604
- else if (n && popped === n.id) {
1605
- wrap.appendChild(el('div', { class: 'nt-start', 'data-nt-popped': '' },
1606
- el('h2', { class: 'nt-h' }, n.title || 'Untitled note'),
1607
- el('p', { class: 'nt-hint' }, 'This note is open in the pop-up at the bottom right.'),
1608
- button({ label: 'Show it here', variant: 'primary', onClick: () => openFull(ctx, n.id), attrs: { 'data-nt-unpop': '' } })));
1609
- } else if (!n) {
1666
+ else if (!n) {
1610
1667
  wrap.appendChild(el('div', { class: 'nt-start' },
1611
1668
  el('h2', { class: 'nt-h' }, 'Notes'),
1612
1669
  el('p', { class: 'nt-hint', 'data-nt-about': '' }, 'Take notes in a meeting. PMOS listens on this machine, catches the decisions, actions and questions, and checks them against your repository. No audio leaves this machine, and notes are kept outside every repository.'),
@@ -1637,43 +1694,38 @@ function keepFocus() {
1637
1694
  const caret = [editor[focus].selectionStart, editor[focus].selectionEnd];
1638
1695
  return () => queueMicrotask(() => { try { const t = editor[focus]; if (t.isConnected) { t.focus(); t.setSelectionRange(caret[0], caret[1]); } } catch { /* the field is gone */ } });
1639
1696
  }
1640
- async function openFull(ctx, id) {
1641
- await flushSave(ctx);
1642
- popped = null;
1643
- const [c, d] = await Promise.all([import('./panel-cockpit.js'), import('./note-dock.js')]);
1644
- d.minimize();
1645
- pendingOpen = id;
1646
- c.openView('notes');
1647
- }
1648
-
1649
- /** The note the pop-up shows (pm/note-dock.js), or null. */
1650
- export function setPopped(id) { popped = id || null; }
1651
1697
  /** The note's title as last read or typed, or ''. */
1652
1698
  export const titleOf = (id) => (editor && editor.id === id && editor.title.value.trim()) || ((cache.get(id) || {}).title) || '';
1653
1699
  /** When the note that is listening started, or null. */
1654
1700
  export const liveStarted = () => (audio && audio.state === 'on' ? audio.started : speech && speech.mode === 'record' && speech.state === 'on' ? speech.started : null);
1655
1701
  /** Stop the note that is listening, as its Stop does. */
1656
- export const stopListening = (ctx) => stopRecording(ctx);
1702
+ export const stopListening = (ctx) => stopRecording(ctx, { resumable: true });
1657
1703
  /** Start listening on this note at once, with no chooser (Run 6, Take notes): the microphone and the meeting when Whisper is set
1658
1704
  * up, else the microphone through the browser's on-device recognition. False when the note cannot listen (private, gone). */
1659
1705
  export async function listenNow(ctx, id) {
1660
- if (id !== currentId) await selectNote(ctx, id);
1661
- let n = cache.get(id);
1662
- if (!n) { await loadNote(ctx, id); n = cache.get(id); }
1663
- if (!n || n.private) return false;
1664
- const eng = engine && engine.ws === wsOf(ctx) && engine.whisper ? engine : await loadEngine(ctx);
1665
- if (eng.whisper && eng.whisper.ready) await startAudio(ctx, n, ['mic', 'meeting']); else await startSpeech(ctx, n, 'record');
1666
- return listeningId() === id;
1706
+ starting = true;
1707
+ try {
1708
+ // a highlight belongs to the meeting it was pressed in: it does not carry over to the next one
1709
+ if (highlight && highlight.id !== id) { highlight = null; hlOpen = false; clearInterval(highlightTimer); highlightTimer = null; }
1710
+ if (id !== currentId) await selectNote(ctx, id);
1711
+ let n = cache.get(id);
1712
+ if (!n) { await loadNote(ctx, id); n = cache.get(id); }
1713
+ if (!n || n.private) return false;
1714
+ const eng = engine && engine.ws === wsOf(ctx) && engine.whisper ? engine : await loadEngine(ctx);
1715
+ if (eng.whisper && eng.whisper.ready) await startAudio(ctx, n, ['mic', 'meeting']); else await startSpeech(ctx, n, 'record');
1716
+ return listeningId() === id;
1717
+ } finally { starting = false; ctx.setState({}); }
1667
1718
  }
1668
1719
 
1669
1720
  /** The note that is listening now, or null. */
1670
1721
  export const listeningId = () => (audio && audio.id) || (speech && speech.mode === 'record' && (speech.state === 'on' || speech.state === 'checking') ? speech.id : null);
1671
1722
 
1672
- /** One note drawn in the pop-up (meeting-notes Run 6, pm/note-dock.js): the same note, editor and listening as the Notes tab,
1673
- * in one column, with Open in Notes for the full screen. It never stops a meeting that is being heard: when another note is
1674
- * listening it draws nothing and returns false. */
1675
- export function renderInline(host, ctx, id, opts = {}) {
1676
- if (ctx.FIXTURE) return false;
1723
+ /** The live part of the note, drawn in the Notes place's right column while the meeting is recording or paused (meeting-notes
1724
+ * Run 13): the status, Your notes (the field to type in) and the live transcript. It is the same editor and listening as the
1725
+ * rest of this view. It never stops a meeting that is being heard: when another note is listening it draws nothing and returns
1726
+ * false. The fields it shows are kept across redraws (kept() through own()), so typing and scrolling survive new lines. */
1727
+ export function renderLive(host, ctx, id) {
1728
+ if (ctx.FIXTURE || starting) return false;
1677
1729
  const other = listeningId();
1678
1730
  if (other && other !== id) return false;
1679
1731
  ensureComponentsCss();
@@ -1683,20 +1735,37 @@ export function renderInline(host, ctx, id, opts = {}) {
1683
1735
  if (!n) { loadNote(ctx, id); host.appendChild(el('p', { class: 'nt-hint' }, 'reading the note …')); return true; }
1684
1736
  const keep = keepFocus();
1685
1737
  const ed = editorFor(ctx, n);
1738
+ // the title is typed in the Notes place, not here: this editor takes it from the note, so a save here never puts back an old one
1739
+ if (!dirty && ed.title.value !== (n.title || '')) ed.title.value = n.title || '';
1686
1740
  inlineDraw = true;
1687
- try { drawInline(ctx, n, ed, host, opts); } finally { inlineDraw = false; }
1741
+ try { host.appendChild(liveBlock(ctx, n, ed)); } finally { inlineDraw = false; }
1688
1742
  keep();
1689
1743
  return true;
1690
1744
  }
1691
- function drawInline(ctx, n, ed, host, opts) {
1692
- const wrap = el('div', { class: 'nt-wrap nt-wrap--inline', 'data-nt-part': 'inline', 'data-nt-id': n.id });
1693
- if (banner) wrap.appendChild(el('div', { class: 'nt-banner' + (banner.tone === 'bad' ? ' nt-banner--bad' : ''), 'data-nt-banner': banner.tone, role: banner.tone === 'bad' ? 'alert' : 'status' }, banner.text));
1694
- wrap.appendChild(header(ctx, n, ed, { inline: true, onMinimize: opts.onMinimize }));
1695
- if (n.private) wrap.appendChild(el('p', { class: 'nt-hint', 'data-nt-private': '' }, 'This note is private: it is not recorded, and nothing from it goes to Jev or to any agent.'));
1696
- const pl = !listening(n) ? playerFor(ctx, n) : null;
1697
- if (pl) wrap.appendChild(el('div', { class: 'nt-replay', 'data-nt-part': 'replay' }, el('span', { class: 'nt-sec__h' }, 'Replay'), own(pl.el)));
1698
- wrap.appendChild(el('div', { class: 'nt-body2' }, noteColumn(ctx, n, ed), transcriptColumn(ctx, n, { inline: true })));
1699
- if (pl) queueMicrotask(paintNow);
1700
- if (n.agent && n.agent.started && !n.agent.markdown && !agentTimer) pollAgent(ctx, n.id);
1701
- host.appendChild(wrap);
1702
- }
1745
+ function liveBlock(ctx, n, ed) {
1746
+ const live = listening(n);
1747
+ const parts = [];
1748
+ if (banner) parts.push(el('div', { class: 'nt-banner' + (banner.tone === 'bad' ? ' nt-banner--bad' : ''), 'data-nt-banner': banner.tone, role: banner.tone === 'bad' ? 'alert' : 'status' }, banner.text));
1749
+ if (live) {
1750
+ parts.push(el('div', { class: 'nt-live', 'data-nt-listening': live.kind, role: 'status' },
1751
+ el('span', { class: 'nt-live-dot', 'aria-hidden': 'true' }), el('b', null, 'Taking notes'),
1752
+ el('span', { 'data-nt-clock': '' }, fmtTime(Date.now() - live.started)),
1753
+ el('span', { 'data-nt-sources': '' }, ' · ' + (live.state === 'starting' || live.state === 'checking' ? 'starting' : ['mic', 'meeting'].filter((s) => live.sources.includes(s)).join(' + '))),
1754
+ el('span', { class: 'nt-hint' }, 'Highlight and Pause are in the top bar.'),
1755
+ el('span', { class: 'nt-sr' }, ' Notes are being taken.')));
1756
+ } else {
1757
+ const left = n.resume_until ? Math.max(1, Math.ceil((Date.parse(n.resume_until) - Date.now()) / 60000)) : null;
1758
+ parts.push(el('div', { class: 'nt-live nt-live--paused', 'data-nt-paused': '', role: 'status' }, el('b', null, 'Paused'),
1759
+ el('span', { class: 'nt-hint' }, (left ? left + (left === 1 ? ' minute' : ' minutes') + ' left to resume. ' : '') + 'Stop ends the meeting for good.')));
1760
+ }
1761
+ if (n.private) parts.push(el('p', { class: 'nt-hint', 'data-nt-private': '' }, 'This note is private: it is not recorded, and nothing from it goes to Jev or to any agent.'));
1762
+ parts.push(myNotes(ctx, n, ed), transcriptColumn(ctx, n, {}));
1763
+ return el('section', { class: 'nt-liveblock', 'data-nt-part': 'live', 'data-nt-id': n.id }, parts);
1764
+ }
1765
+ /** Link the note to a calendar meeting, or to an initiative, from the Notes place's ⋯ menu: the same sheets as the Notes tab. */
1766
+ export async function linkMeeting(ctx, id) { await loadNote(ctx, id); const n = cache.get(id); if (n) await meetingSheet(ctx, n); }
1767
+ export async function linkInitiative(ctx, id) { await loadNote(ctx, id); const n = cache.get(id); if (n) await initiativeSheet(ctx, n); }
1768
+ /** The text of the last failure shown on this view (for example "this meeting was stopped"), and forget it. */
1769
+ export function takeBanner() { const t = banner && banner.tone === 'bad' ? banner.text : null; if (t) banner = null; return t; }
1770
+ /** The title was changed elsewhere (the Notes place): keep this view's copy in step. */
1771
+ export function noteTitled(id, title) { const c = cache.get(id); if (c) c.title = title; if (editor && editor.id === id && !dirty) editor.title.value = title || ''; }
@@ -44,6 +44,7 @@
44
44
  "notes-highlight.test.js",
45
45
  "notes-page.test.js",
46
46
  "notes-conflicts.test.js",
47
+ "notes-resume.test.js",
47
48
  "proactive-prepare.test.js",
48
49
  "learn.test.js",
49
50
  "session-flag.test.js",
@@ -107,6 +108,7 @@
107
108
  "pm-r29-newsession-alignment-test.js",
108
109
  "pm-live-notes-test.js",
109
110
  "pm-notes-menu-test.js",
111
+ "pm-record-control-test.js",
110
112
  "pm-r30-test.js",
111
113
  "pm-breakdown-test.js",
112
114
  "pm-breakdown-phone-test.js",
@@ -152,6 +154,7 @@
152
154
  "notes-highlight.test.js": 1,
153
155
  "notes-page.test.js": 1,
154
156
  "notes-conflicts.test.js": 1,
157
+ "notes-resume.test.js": 8,
155
158
  "proactive-prepare.test.js": 9,
156
159
  "learn.test.js": 3,
157
160
  "session-flag.test.js": 1,
@@ -209,6 +212,7 @@
209
212
  "pm-r29-newsession-alignment-test.js": 8,
210
213
  "pm-live-notes-test.js": 24,
211
214
  "pm-notes-menu-test.js": 10,
215
+ "pm-record-control-test.js": 12,
212
216
  "pm-r30-test.js": 4,
213
217
  "ui-test.js": 30,
214
218
  "plan-items.test.js": 1,