create-pmos 0.75.0 → 0.76.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.
@@ -44,14 +44,14 @@ import { askConfirm, askText } from './dialogs.js';
44
44
  import { pmGet, pmPost } from './http.js';
45
45
  import { mdRender } from './markdown.js';
46
46
  import { icon } from './icons.js';
47
- import { button, iconButton, field, row, sheet, empty, ensureComponentsCss } from './primitives.js';
47
+ import { button, iconButton, field, row, sheet, empty, menu, toast, ensureComponentsCss } from './primitives.js';
48
48
 
49
49
  const SAVE_MS = 700; // a pause this long in typing saves the note
50
50
  const POLL_MS = 1500; // while listening, and until the last line is labelled, the note is read this often
51
51
  const AGENT_POLL_MS = 3000;
52
52
  const CHUNK_SAMPLES = 16000; // one second at 16 kHz
53
53
  const RATE = 16000;
54
- const SECTIONS = [['decision', 'Decisions'], ['action', 'Actions'], ['question', 'Questions']];
54
+ const SECTIONS = [['decision', 'Decisions'], ['action', 'Next steps'], ['question', 'Open questions']];
55
55
 
56
56
  /* ── module state: survives the redraw every setState causes ─────────────────────────────── */
57
57
  let list = null; // { state, notes, error }
@@ -69,6 +69,7 @@ let busy = '';
69
69
  let player = null; // the replay: { id, bytes, el, playing }, kept across redraws so playback goes on
70
70
  let people = null; // { ws, names } — the repository's people, offered in the line editor
71
71
  let pendingOpen = null; // a note another page asked to open (openNote), selected at the next draw
72
+ let transcriptOpen = { inline: false, full: true }; // the transcript is folded in the pop-up and open on the full screen
72
73
  let inlineSwitch = null; // the note the pop-up is switching to (renderInline), so a redraw does not start it twice
73
74
  let popped = null; // the note the pop-up shows (pm/note-dock.js), which the Notes tab then does not draw
74
75
  const LABELS = [['decision', 'Decision'], ['action', 'Action'], ['question', 'Question'], ['fact', 'Fact'], ['topic', 'Topic'], ['statement', 'Statement'], ['small talk', 'Small talk']];
@@ -212,7 +213,7 @@ async function loadList(ctx) {
212
213
  if (!currentId && list.notes.length) currentId = list.notes[0].id;
213
214
  ctx.setState({});
214
215
  }
215
- const sig = (n) => JSON.stringify([n.transcript, n.summary && n.summary.at, n.agent && n.agent.markdown, n.private, n.speakers, n.title, (n.versions || []).length, n.__recording && n.__recording.bytes, n.meeting, n.initiative]);
216
+ const sig = (n) => JSON.stringify([n.transcript, n.bullets, n.bullets_state && n.bullets_state.agent, n.summary && n.summary.at, n.agent && n.agent.markdown, n.private, n.speakers, n.title, (n.versions || []).length, n.__recording && n.__recording.bytes, n.meeting, n.initiative]);
216
217
  async function loadNote(ctx, id) {
217
218
  const r = await pmGet('/api/notes/one?id=' + encodeURIComponent(id));
218
219
  if (r && r.ok) {
@@ -221,6 +222,7 @@ async function loadNote(ctx, id) {
221
222
  if (editor && editor.id === id && dirty) { r.note.title = editor.title.value; r.note.body = editor.body.value; }
222
223
  r.note.__audio = r.audio || null;
223
224
  r.note.__recording = r.recording || null;
225
+ r.note.bullets_state = r.bullets_state || null;
224
226
  cache.set(id, r.note);
225
227
  if (!had || sig(had) !== sig(r.note)) ctx.setState({});
226
228
  } else if (r && r.status === 404) { cache.delete(id); if (currentId === id) currentId = null; loadList(ctx); }
@@ -306,12 +308,22 @@ async function selectNote(ctx, id) {
306
308
  loadNote(ctx, id);
307
309
  }
308
310
  async function deleteNote(ctx, n) {
309
- const sure = await askConfirm({ title: 'Delete this note?', body: '"' + (n.title || 'Untitled note') + '" and its transcript and summaries are deleted from this machine. This cannot be undone.', confirmLabel: 'Delete note', danger: true });
311
+ const sure = await askConfirm({ title: 'Delete "' + (n.title || 'Untitled note') + '"?', body: 'The note, its transcript, its summaries and its kept audio are deleted from this machine. Its summary goes from the calendar and the initiative. This cannot be undone.', confirmLabel: 'Delete note', cancelLabel: 'Keep it', danger: true });
310
312
  if (!sure) return;
311
- await stopAll(ctx, { discard: true }); clearTimeout(saveTimer); dirty = false; dropPlayer();
313
+ const here = n.id === currentId;
314
+ // only the note that is deleted stops listening; another note that listens goes on
315
+ if (listeningId() === n.id) await stopAll(ctx, { discard: true });
316
+ if (here) { clearTimeout(saveTimer); dirty = false; dropPlayer(); }
312
317
  const r = await pmPost('/api/notes/delete', { id: n.id });
313
- if (r && r.ok) { cache.delete(n.id); currentId = null; editor = null; if (list) list.notes = (list.notes || []).filter((x) => x.id !== n.id); if (list && list.notes.length) currentId = list.notes[0].id; banner = { tone: 'ok', text: 'The note was deleted.' }; }
314
- else banner = { tone: 'bad', text: (r && r.error) || 'the note was not deleted' };
318
+ if (r && r.ok) {
319
+ cache.delete(n.id);
320
+ if (list) list.notes = (list.notes || []).filter((x) => x.id !== n.id);
321
+ if (here) { currentId = list && list.notes && list.notes.length ? list.notes[0].id : null; editor = null; if (popped === n.id) popped = null; }
322
+ import('./note-dock.js').then((d) => d.forget(n.id)).catch(() => {});
323
+ import('./notes-links.js').then((l) => l.refreshNotes && l.refreshNotes()).catch(() => {});
324
+ toast({ text: '"' + (n.title || 'Untitled note') + '" was deleted.' });
325
+ banner = null;
326
+ } else banner = { tone: 'bad', text: (r && r.error) || 'the note was not deleted' };
315
327
  ctx.setState({});
316
328
  }
317
329
  async function setPrivate(ctx, n, priv) {
@@ -326,9 +338,13 @@ async function summarize(ctx, n, quiet = false) {
326
338
  await flushSave(ctx);
327
339
  busy = 'summary'; ctx.setState({});
328
340
  const ws = n.ws || wsOf(ctx);
329
- const r = await pmPost('/api/notes/summarize', { id: n.id, ...(ws ? { ws } : {}) });
341
+ // after Stop the agent writes the overview when it can (Run 7); the local summary is stored first as the fallback
342
+ const r = await pmPost('/api/notes/summarize', { id: n.id, ...(ws ? { ws } : {}), ...(quiet && !n.private ? { prefer: 'agent' } : {}) });
330
343
  busy = '';
331
- if (r && r.ok) { n.summary = r.summary; if (r.initiative !== undefined) { n.initiative = r.initiative; const c = cache.get(n.id); if (c) c.initiative = r.initiative; } banner = null; } else if (!quiet) banner = { tone: 'bad', text: (r && r.error) || 'no summary was made' };
344
+ if (r && r.ok) {
345
+ n.summary = r.summary; if (r.initiative !== undefined) { n.initiative = r.initiative; const c = cache.get(n.id); if (c) c.initiative = r.initiative; } banner = null;
346
+ if (r.agent && r.agent.started) { n.agent = { requested_at: new Date().toISOString(), started: true, reason: '', session: r.agent.session, withheld: r.agent.withheld, markdown: null, out: r.agent.out }; pollAgent(ctx, n.id); }
347
+ } else if (!quiet) banner = { tone: 'bad', text: (r && r.error) || 'no summary was made' };
332
348
  ctx.setState({});
333
349
  }
334
350
  async function summarizeAgent(ctx, n) {
@@ -362,6 +378,13 @@ async function copyMarkdown(ctx, n) {
362
378
  catch { banner = { tone: 'bad', text: 'The clipboard was not available. Select the text and copy it instead.' }; }
363
379
  ctx.setState({});
364
380
  }
381
+ /** Take a key point out of the note's sections (POST /api/notes/bullet, Run 7). */
382
+ async function dropBullet(ctx, n, bullet) {
383
+ const r = await pmPost('/api/notes/bullet', { id: n.id, bullet, drop: true });
384
+ if (r && r.ok) { const b = (n.bullets || []).find((x) => x.id === bullet); if (b) b.dropped = true; if (r.summary) n.summary = r.summary; }
385
+ else banner = { tone: 'bad', text: (r && r.error) || 'the point was not removed' };
386
+ ctx.setState({});
387
+ }
365
388
  async function unpick(ctx, n, x) {
366
389
  const r = await pmPost('/api/notes/label', { id: n.id, index: x.index, cat: x.cat, drop: true });
367
390
  if (r && r.ok) { n.transcript[x.index] = r.segment; } else banner = { tone: 'bad', text: (r && r.error) || 'not changed' };
@@ -526,8 +549,8 @@ async function startAudio(ctx, n, want) {
526
549
  const r = await pmPost('/api/notes/record', wsOf(ctx) ? { id: n.id, ws: wsOf(ctx) } : { id: n.id });
527
550
  if (!r || !r.ok) { for (const [, s] of opened) for (const t of s.getTracks()) t.stop(); audio = null; banner = { tone: 'bad', text: (r && r.error) || 'listening did not start' }; ctx.setState({}); return; }
528
551
  n.recorded_from = r.recorded_from;
529
- // the clock counts from the note's first Listen, as the line times do
530
- a.started = Date.parse(r.recorded_from) || a.started;
552
+ // the clock counts the meeting time, as the line times do: Listen again carries on from where it stopped
553
+ a.started = Date.parse(r.clock_from || r.recorded_from) || a.started;
531
554
  let ac;
532
555
  try { ac = new AudioContext({ sampleRate: RATE }); } catch { ac = new AudioContext(); }
533
556
  a.ctx = ac;
@@ -546,7 +569,7 @@ async function startAudio(ctx, n, want) {
546
569
  for (const t of stream.getTracks()) t.addEventListener('ended', () => { if (audio !== a) return; a.ended++; if (a.ended >= a.streams.length) stopAudio(ctx); });
547
570
  }
548
571
  a.state = 'on';
549
- a.clock = setInterval(() => { const c = typeof document !== 'undefined' && document.querySelector('[data-nt-clock]'); if (c) c.textContent = fmtTime(Date.now() - a.started); }, 1000);
572
+ a.clock = setInterval(() => tickClocks(a.started), 1000);
550
573
  ctx.setState({});
551
574
  poll(ctx, n.id);
552
575
  }
@@ -569,6 +592,7 @@ async function stopAudio(ctx, { discard = false } = {}) {
569
592
  if (discard) { ctx.setState({}); return; }
570
593
  // the rest of what was said, then the summary once the last line is transcribed
571
594
  await Promise.all(a.sources.map((k) => sendChunk(ctx, a, k, true)));
595
+ await pmPost('/api/notes/pause', { id: a.id });
572
596
  ctx.setState({});
573
597
  const n = cache.get(a.id);
574
598
  if (!n || n.private) return;
@@ -603,13 +627,15 @@ async function onFinal(ctx, s, text) {
603
627
  // dictation: the server cleans the phrase and resolves the spoken commands; the page applies them at the cursor
604
628
  const r = await pmPost('/api/notes/dictate', { text });
605
629
  if (!r || !r.ok || !editor || editor.id !== s.id) return;
606
- const ta = editor.body;
630
+ const ta = editor.add;
607
631
  const focused = document.activeElement === ta;
608
632
  const sel = focused ? [ta.selectionStart, ta.selectionEnd] : (editor.sel || [ta.value.length, ta.value.length]);
609
633
  const out = applyOps(ta.value, sel, r.ops, s.last);
610
- ta.value = out.value; editor.sel = out.sel; s.last = out.last; fitBody();
634
+ // a spoken "new line" adds what is before it as a note line; the rest stays in the field
635
+ const parts = out.value.split('\n');
636
+ if (parts.length > 1) { addLines(ctx, parts.slice(0, -1)); ta.value = parts[parts.length - 1]; editor.sel = [ta.value.length, ta.value.length]; s.last = null; return; }
637
+ ta.value = out.value; editor.sel = out.sel; s.last = out.last;
611
638
  if (focused) { try { ta.setSelectionRange(out.sel[0], out.sel[1]); } catch { /* detached */ } }
612
- scheduleSave(ctx);
613
639
  }
614
640
  async function startSpeech(ctx, n, mode, install = false) {
615
641
  await stopAll(ctx);
@@ -634,7 +660,7 @@ async function startSpeech(ctx, n, mode, install = false) {
634
660
  const r = await pmPost('/api/notes/record', wsOf(ctx) ? { id: n.id, ws: wsOf(ctx) } : { id: n.id });
635
661
  if (!r || !r.ok) { s.state = 'error'; s.msg = (r && r.error) || 'listening did not start'; ctx.setState({}); return; }
636
662
  n.recorded_from = r.recorded_from;
637
- s.started = Date.parse(r.recorded_from) || s.started;
663
+ s.started = Date.parse(r.clock_from || r.recorded_from) || s.started;
638
664
  }
639
665
  if (speech !== s) return;
640
666
  const rec = new C();
@@ -660,7 +686,7 @@ async function startSpeech(ctx, n, mode, install = false) {
660
686
  rec.onend = () => { if (speech === s && s.state === 'on') { try { rec.start(); } catch { /* it is starting */ } } };
661
687
  s.rec = rec;
662
688
  try { rec.start(); s.state = 'on'; } catch (e) { s.state = 'error'; s.msg = 'Listening did not start: ' + ((e && e.message) || e); }
663
- if (mode === 'record') { s.clock = setInterval(() => { const c = typeof document !== 'undefined' && document.querySelector('[data-nt-clock]'); if (c) c.textContent = fmtTime(Date.now() - s.started); }, 1000); poll(ctx, n.id); }
689
+ if (mode === 'record') { s.clock = setInterval(() => tickClocks(s.started), 1000); poll(ctx, n.id); }
664
690
  ctx.setState({});
665
691
  }
666
692
  async function stopRecording(ctx) {
@@ -668,6 +694,7 @@ async function stopRecording(ctx) {
668
694
  if (s && s.mode === 'record') {
669
695
  clearInterval(s.clock); stopSpeech();
670
696
  await s.queue;
697
+ await pmPost('/api/notes/pause', { id: s.id });
671
698
  const n = cache.get(s.id);
672
699
  if (n && !n.private && (n.transcript || []).length) await summarize(ctx, n, true);
673
700
  }
@@ -756,26 +783,55 @@ function openSettings(ctx, n) {
756
783
  }
757
784
 
758
785
  /* ── drawing ──────────────────────────────────────────────────────────────────────────────────── */
759
- function fitBody() {
760
- if (!editor || !editor.body) return;
761
- const t = editor.body;
762
- t.style.setProperty('--nt-h', 'auto');
763
- t.style.setProperty('--nt-h', Math.max(96, t.scrollHeight + 2) + 'px');
786
+ /** Every clock on the page, and the time beside the note field, from the meeting clock's start. */
787
+ function tickClocks(started) {
788
+ if (typeof document === 'undefined') return;
789
+ const t = fmtTime(Date.now() - started);
790
+ for (const c of document.querySelectorAll('[data-nt-clock], [data-nt-clock-now]')) c.textContent = t;
764
791
  }
792
+ /** The meeting time now, while this note listens; else null. */
793
+ const liveMs = (n) => { const l = n ? listening(n) : null; return l && l.started ? Date.now() - l.started : null; };
765
794
  function editorFor(ctx, n) {
766
795
  if (editor && editor.id === n.id) return editor;
767
796
  const tf = field({ label: 'Title', kind: 'title', hideLabel: true, value: n.title || '', placeholder: 'Untitled meeting', attrs: { 'data-nt-title': '', class: 'nt-titlein' } });
768
- const bf = field({ label: 'Your notes', kind: 'textarea', hideLabel: true, value: n.body || '', placeholder: 'Type your own notes. They steer the summary.',
769
- attrs: { 'data-nt-body': '', class: 'nt-text', rows: 3 } });
770
- const title = tf.control; const body = bf.control;
771
- editor = { id: n.id, title, body, titleWrap: tf, bodyWrap: bf, sel: null };
772
- const remember = () => { editor.sel = [body.selectionStart, body.selectionEnd]; };
797
+ // Your notes are lines (Run 7): each line typed and committed with Enter is kept with the meeting time it was added at
798
+ const af = field({ label: 'Add a note', kind: 'text', hideLabel: true, value: '', placeholder: 'Add a note and press Enter',
799
+ attrs: { 'data-nt-body': '', class: 'nt-addin' } });
800
+ // Enter adds the line. A blur does not: a redraw moves the field, and half a line must not become a note line.
801
+ af.control.addEventListener('keydown', (e) => {
802
+ if (e.key !== 'Enter' || e.isComposing || !String(af.control.value).trim()) return;
803
+ addLines(ctx, [af.control.value]); af.control.resetCommitted('');
804
+ });
805
+ const title = tf.control; const add = af.control;
806
+ editor = { id: n.id, title, add, titleWrap: tf, addWrap: af, text: n.body || '', sel: null };
807
+ // the notes text, as the saves, History and Copy read it
808
+ editor.body = { get value() { return editor.text; }, set value(v) { editor.text = String(v == null ? '' : v); } };
809
+ const remember = () => { editor.sel = [add.selectionStart, add.selectionEnd]; };
773
810
  title.addEventListener('input', () => scheduleSave(ctx));
774
- body.addEventListener('input', () => { remember(); fitBody(); if (speech) speech.last = null; scheduleSave(ctx); });
775
- for (const ev of ['keyup', 'mouseup', 'select', 'blur']) body.addEventListener(ev, remember);
811
+ add.addEventListener('input', () => { remember(); if (speech) speech.last = null; });
812
+ for (const ev of ['keyup', 'mouseup', 'select', 'blur']) add.addEventListener(ev, remember);
776
813
  title.addEventListener('blur', () => flushSave(ctx));
777
814
  return editor;
778
815
  }
816
+ /** Add note lines at the end of Your notes and save at once, so each gets the meeting time it was added at. */
817
+ function addLines(ctx, lines) {
818
+ if (!editor) return;
819
+ const add = lines.map((l) => String(l).trim()).filter(Boolean);
820
+ if (!add.length) return;
821
+ const n = cache.get(editor.id);
822
+ const ms = liveMs(n);
823
+ editor.text = [String(editor.text || '').replace(/\s+$/, ''), ...add].filter(Boolean).join('\n');
824
+ if (n) { n.body = editor.text; if (ms !== null) { n.line_times = { ...(n.line_times || {}) }; for (const l of add) if (n.line_times[l] === undefined) n.line_times[l] = Math.round(ms); } }
825
+ dirty = true; flushSave(ctx); ctx.setState({});
826
+ }
827
+ function removeLine(ctx, k) {
828
+ if (!editor) return;
829
+ const lines = String(editor.text || '').split(/\r?\n/);
830
+ lines.splice(k, 1);
831
+ editor.text = lines.join('\n');
832
+ const n = cache.get(editor.id); if (n) n.body = editor.text;
833
+ dirty = true; flushSave(ctx); ctx.setState({});
834
+ }
779
835
  /* ── the meeting and the initiative of a note (Run 4) ───────────────────────────────────────────── */
780
836
  const BY_WORDS = { event: 'from the calendar event', local: 'suggested from what was said', pm: 'chosen by you' };
781
837
  const ymOfIso = (iso) => { const d = new Date(iso); return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0'); };
@@ -862,49 +918,86 @@ async function openInitiativeOf(ctx, ini) {
862
918
  const reg = await import('./registry.js');
863
919
  reg.openInitiative(ini.repo || (ctx.state && ctx.state.product) || ctx.repo || null, ini.id);
864
920
  }
865
- function linksLine(ctx, n) {
866
- const m = n.meeting; const ini = n.initiative;
867
- const meet = m && m.kind === 'event'
868
- ? [el('b', null, m.title), el('span', { class: 'nt-hint' }, dayOf(m.start) + ', ' + nowClock(m.start) + (m.end ? '–' + nowClock(m.end) : ''))]
869
- : m && m.kind === 'adhoc' ? [el('b', null, 'Not on the calendar'), el('span', { class: 'nt-hint' }, dayOf(n.created_at) + ', ' + nowClock(n.created_at))]
870
- : [el('b', null, 'None')];
871
- return el('div', { class: 'nt-links', 'data-nt-part': 'links' },
872
- el('div', { class: 'nt-link', 'data-nt-link': 'meeting' }, el('span', { class: 'nt-link__k' }, 'Meeting'), ...meet,
873
- button({ label: 'Change', variant: 'ghost', size: 'sm', onClick: () => meetingSheet(ctx, n), attrs: { 'data-nt-meeting': '' } })),
874
- el('div', { class: 'nt-link', 'data-nt-link': 'initiative', 'data-nt-ini-by': ini ? ini.by : '' }, el('span', { class: 'nt-link__k' }, 'Initiative'),
875
- ini ? [button({ label: ini.title || ini.id, variant: 'ghost', size: 'sm', onClick: () => openInitiativeOf(ctx, ini), attrs: { 'data-nt-ini-open': ini.id } }), el('span', { class: 'nt-hint' }, BY_WORDS[ini.by] || '')] : el('b', null, 'None'),
876
- button({ label: 'Change', variant: 'ghost', size: 'sm', onClick: () => initiativeSheet(ctx, n), attrs: { 'data-nt-ini': '' } })));
877
- }
878
921
  function listening(n) {
879
922
  if (audio && audio.id === n.id) return { kind: 'local', state: audio.state, sources: audio.sources, started: audio.started };
880
923
  if (speech && speech.id === n.id && speech.mode === 'record' && (speech.state === 'on' || speech.state === 'checking')) return { kind: 'browser', state: speech.state, sources: ['mic'], started: speech.started };
881
924
  return null;
882
925
  }
883
- function header(ctx, n, ed, opts = {}) {
884
- const live = listening(n);
885
- const when = el('span', { class: 'nt-when' }, dayOf(n.created_at) + ', ' + nowClock(n.created_at));
926
+ /** The day, the time, the meeting and the initiative, on one line under the title. The ⋯ menu changes them. */
927
+ function whenLine(ctx, n) {
928
+ const m = n.meeting; const ini = n.initiative;
929
+ const at = m && m.kind === 'event' ? m.start : n.created_at;
930
+ const meet = m && m.kind === 'event' ? m.title + ', ' + nowClock(m.start) + (m.end ? '–' + nowClock(m.end) : '') : m && m.kind === 'adhoc' ? 'Not on the calendar' : 'No meeting';
886
931
  const nv = (n.versions || []).length;
887
- const left = el('div', { class: 'nt-headl' }, ed.titleWrap, when, el('span', { class: 'nt-saved', 'data-nt-saved': '' }, saveWords()),
932
+ return el('div', { class: 'nt-when', 'data-nt-part': 'links' },
933
+ el('span', null, dayOf(at) + ', ' + nowClock(n.created_at)),
934
+ el('span', { class: 'nt-link', 'data-nt-link': 'meeting', title: 'Change it with More' }, el('span', { class: 'nt-sr' }, 'Meeting: '), meet),
935
+ el('span', { class: 'nt-link', 'data-nt-link': 'initiative', 'data-nt-ini-by': ini ? ini.by : '', title: 'Change it with More' }, el('span', { class: 'nt-sr' }, 'Initiative: '),
936
+ ini ? ini.title || ini.id : 'No initiative', ini && BY_WORDS[ini.by] ? el('span', { class: 'nt-sr' }, ' (' + BY_WORDS[ini.by] + ')') : null),
937
+ el('span', { class: 'nt-saved', 'data-nt-saved': '' }, saveWords()),
888
938
  nv ? button({ label: 'Edited', icon: 'history', variant: 'ghost', size: 'sm', onClick: () => openHistory(ctx, n), attrs: { 'data-nt-history': String(nv), title: nv + ' earlier version' + (nv === 1 ? '' : 's') + ' kept' } }) : null);
939
+ }
940
+ /** Who was in the meeting, as the note knows them: the speakers named in the transcript and the people tagged. */
941
+ export function peopleOf(n) {
942
+ const out = [];
943
+ const add = (p) => { const s = String(p || '').trim(); if (s && !/^(you|meeting|speaker)$/i.test(s) && !out.some((x) => x.toLowerCase() === s.toLowerCase())) out.push(s); };
944
+ for (const v of Object.values(n.speakers || {})) add(v);
945
+ for (const x of n.transcript || []) for (const p of x.people || []) add(p);
946
+ for (const b of n.bullets || []) if (b && b.owner) add(b.owner);
947
+ return out.slice(0, 12);
948
+ }
949
+ const initials = (name) => name.split(/\s+/).filter(Boolean).slice(0, 2).map((w) => w[0].toUpperCase()).join('');
950
+ function peopleRow(n) {
951
+ const ps = peopleOf(n);
952
+ if (!ps.length) return null;
953
+ return el('div', { class: 'nt-people', 'data-nt-people': String(ps.length) },
954
+ el('span', { class: 'nt-avs', 'aria-hidden': 'true' }, ps.slice(0, 5).map((p) => el('span', { class: 'nt-av' }, initials(p)))),
955
+ el('span', null, ps.join(', ')));
956
+ }
957
+ /** The ⋯ menu: everything that is not taking the note. */
958
+ function moreMenu(ctx, n, opts, anchor) {
959
+ const has = (n.transcript || []).length > 0;
960
+ const items = [
961
+ has ? { label: 'Copy as Markdown', onSelect: () => copyMarkdown(ctx, n), attrs: { 'data-nt-copy': '' } } : null,
962
+ opts.inline ? { label: 'Open in Notes', onSelect: () => openFull(ctx, n.id), attrs: { 'data-nt-full': '' } }
963
+ : { label: 'New meeting', onSelect: () => newNote(ctx), attrs: { 'data-nt-new': '' } },
964
+ { sep: true },
965
+ { label: 'Change meeting', onSelect: () => meetingSheet(ctx, n), attrs: { 'data-nt-meeting': '' } },
966
+ { label: n.initiative ? 'Change initiative' : 'Link an initiative', onSelect: () => initiativeSheet(ctx, n), attrs: { 'data-nt-ini': '' } },
967
+ n.initiative ? { label: 'Open the initiative', onSelect: () => openInitiativeOf(ctx, n.initiative), attrs: { 'data-nt-ini-open': n.initiative.id } } : null,
968
+ has && !n.private ? { label: 'Summary by agent', onSelect: () => summarizeAgent(ctx, n), attrs: { 'data-nt-menu-agent': '' } } : null,
969
+ { label: n.private ? 'Mark not private' : 'Mark private', onSelect: () => setPrivate(ctx, n, !n.private), attrs: { 'data-nt-menu-private': '' } },
970
+ { label: 'Settings', icon: 'settings', onSelect: () => openSettings(ctx, n), attrs: { 'data-nt-settings': '' } },
971
+ { sep: true },
972
+ { label: 'Delete note', danger: true, onSelect: () => deleteNote(ctx, n), attrs: { 'data-nt-menu-delete': '' } },
973
+ ].filter(Boolean);
974
+ menu({ anchor, items, ariaLabel: 'More for this note' });
975
+ }
976
+ function header(ctx, n, ed, opts = {}) {
977
+ const live = listening(n);
889
978
  const acts = [];
979
+ let status;
890
980
  if (live) {
891
- acts.push(el('span', { class: 'nt-live', 'data-nt-listening': live.kind, role: 'status' },
892
- el('span', { class: 'nt-live-dot', 'aria-hidden': 'true' }),
981
+ status = el('span', { class: 'nt-live', 'data-nt-listening': live.kind, role: 'status' },
982
+ el('span', { class: 'nt-live-dot', 'aria-hidden': 'true' }), el('b', null, 'Taking notes'),
893
983
  el('span', { 'data-nt-clock': '' }, fmtTime(Date.now() - live.started)),
894
984
  el('span', { 'data-nt-sources': '' }, ' · ' + (live.state === 'starting' || live.state === 'checking' ? 'starting' : ['mic', 'meeting'].filter((s) => live.sources.includes(s)).join(' + '))),
895
- el('span', { class: 'nt-sr' }, ' Notes are being taken.')));
896
- acts.push(button({ label: 'Stop', variant: 'danger', onClick: () => stopRecording(ctx), attrs: { 'data-nt-stop': '' } }));
897
- } else if ((n.transcript || []).length) {
898
- acts.push(button({ label: 'Copy as Markdown', variant: 'ghost', onClick: () => copyMarkdown(ctx, n), attrs: { 'data-nt-copy': '' } }));
899
- if (!n.private) acts.push(button({ label: 'Listen again', variant: 'ghost', onClick: () => chooseSources(ctx, n), attrs: { 'data-nt-listen': 'again' } }));
900
- if (!opts.inline) acts.push(button({ label: busy === 'new' ? 'Creating …' : 'New meeting', disabled: busy === 'new', onClick: () => newNote(ctx), attrs: { 'data-nt-new': '' } }));
901
- } else if (!n.private) {
902
- acts.push(button({ label: 'Listen', variant: 'primary', onClick: () => chooseSources(ctx, n), attrs: { 'data-nt-listen': '' } }));
985
+ el('span', { class: 'nt-sr' }, ' Notes are being taken.'));
986
+ acts.push(button({ label: 'Stop', variant: 'outline', size: 'sm', onClick: () => stopRecording(ctx), attrs: { 'data-nt-stop': '' } }));
987
+ } else {
988
+ const len = (n.transcript || []).length ? fmtTime(Math.max(...(n.transcript || []).map((x) => x.t || 0))) : '';
989
+ status = el('span', { class: 'nt-live' }, el('b', null, 'Meeting note'), len ? el('span', null, len) : null);
990
+ if (!n.private) acts.push((n.transcript || []).length
991
+ ? button({ label: 'Listen again', variant: 'ghost', size: 'sm', onClick: () => chooseSources(ctx, n), attrs: { 'data-nt-listen': 'again' } })
992
+ : button({ label: 'Listen', variant: 'primary', size: 'sm', onClick: () => chooseSources(ctx, n), attrs: { 'data-nt-listen': '' } }));
903
993
  }
904
- if (opts.inline) acts.push(button({ label: 'Open in Notes', variant: 'ghost', onClick: () => openFull(ctx, n.id), attrs: { 'data-nt-full': '' } }));
905
- else acts.push(button({ label: 'All notes', variant: 'ghost', onClick: () => { showList = !showList; ctx.setState({}); if (showList) loadList(ctx); }, attrs: { 'data-nt-all': '', 'aria-expanded': showList ? 'true' : 'false' } }));
906
- acts.push(iconButton({ icon: 'settings', ariaLabel: 'Settings', onClick: () => openSettings(ctx, n), attrs: { 'data-nt-settings': '' } }));
907
- return el('header', { class: 'nt-header', 'data-nt-part': 'header' }, el('div', { class: 'nt-headwrap' }, left, linksLine(ctx, n)), el('div', { class: 'nt-acts' }, acts));
994
+ if (opts.onMinimize) acts.push(button({ label: 'Minimize', variant: 'ghost', size: 'sm', onClick: opts.onMinimize, attrs: { 'data-nt-minimize': '' } }));
995
+ 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' } }));
996
+ const more = iconButton({ icon: 'ellipsis', ariaLabel: 'More', onClick: () => moreMenu(ctx, n, opts, more), attrs: { 'data-nt-more': '' } });
997
+ acts.push(more);
998
+ return el('header', { class: 'nt-header', 'data-nt-part': 'header' },
999
+ el('div', { class: 'nt-bar' }, status, el('div', { class: 'nt-acts' }, acts)),
1000
+ el('div', { class: 'nt-headl' }, ed.titleWrap, whenLine(ctx, n), peopleRow(n)));
908
1001
  }
909
1002
  function listPane(ctx) {
910
1003
  const rows = (list && list.notes) || [];
@@ -913,6 +1006,7 @@ function listPane(ctx) {
913
1006
  rows.length ? el('div', { class: 'nt-rows' }, rows.map((r) => row({ title: r.title || 'Untitled note',
914
1007
  meta: [dayOf(r.updated_at) + ' ' + nowClock(r.updated_at), r.segments ? 'listened' : null, r.private ? 'private' : null],
915
1008
  onOpen: () => selectNote(ctx, r.id), openLabel: 'Open ' + (r.title || 'Untitled note'),
1009
+ trail: [button({ label: 'Delete', variant: 'ghost', size: 'sm', onClick: (e) => { if (e) e.stopPropagation(); deleteNote(ctx, { id: r.id, title: r.title }); }, attrs: { 'data-nt-row-delete': r.id, 'aria-label': 'Delete ' + (r.title || 'Untitled note'), class: 'nt-del' } })],
916
1010
  attrs: { 'data-nt-row': r.id, 'aria-current': r.id === currentId ? 'true' : 'false', class: r.id === currentId ? 'nt-row--current' : '' } })))
917
1011
  : empty({ text: list && list.state === 'loading' ? 'reading …' : 'No notes yet.' }));
918
1012
  }
@@ -929,84 +1023,135 @@ function chips(x) {
929
1023
  ...(x.people || []).map((p) => el('span', { class: 'nt-tag nt-tag--person', 'data-nt-person': p }, '@' + p))];
930
1024
  }
931
1025
  const editedMark = (x) => (x.orig_text !== undefined ? el('span', { class: 'nt-edited', 'data-nt-edited': '', title: 'Heard as: ' + x.orig_text }, 'edited') : null);
932
- function sectionOf(ctx, n, key, title, items) {
933
- if (!items.length) return null;
1026
+ /** One item of a section: its meeting time first, then the words, its repository check, and the press that takes it out. */
1027
+ function itemRow(ctx, n, key, title, x) {
1028
+ const bullet = x.bullet !== undefined;
1029
+ return el('li', { class: 'nt-item', 'data-nt-item': bullet ? null : String(x.index), 'data-nt-bullet': bullet ? String(x.bullet) : null },
1030
+ el('span', { class: 'nt-at' }, typeof x.t === 'number' ? fmtTime(x.t) : ''),
1031
+ el('span', { class: 'nt-item__text' },
1032
+ x.text, x.open ? el('span', { class: 'nt-who', 'data-nt-open': '' }, ' … continuing') : null, x.owner ? el('span', { class: 'nt-who' }, ' · ' + x.owner) : null,
1033
+ ' ', checkMark(x), ' ', editedMark(x), chips(x)),
1034
+ bullet ? iconButton({ icon: 'x', ariaLabel: 'Remove from ' + title, onClick: () => dropBullet(ctx, n, x.bullet), size: 'sm', attrs: { 'data-nt-unbullet': String(x.bullet) } })
1035
+ : key === 'fact' || key === 'tagged' || key === 'point' ? null
1036
+ : iconButton({ icon: 'x', ariaLabel: 'Remove from ' + title, onClick: () => unpick(ctx, n, x), size: 'sm', attrs: { 'data-nt-unpick': String(x.index) } }));
1037
+ }
1038
+ function sectionOf(ctx, n, key, title, items, extra) {
1039
+ if (!items.length && !extra) return null;
934
1040
  return el('section', { class: 'nt-sec', 'data-nt-sec': key },
935
- el('h3', { class: 'nt-sec__h' }, title),
936
- el('ul', { class: 'nt-items' }, items.map((x) => el('li', { class: 'nt-item', 'data-nt-item': String(x.index) },
937
- el('span', { class: 'nt-item__text' },
938
- key === 'action' && x.source && x.source !== 'speech' ? el('span', { class: 'nt-who' }, speakerOf(n, x.source) + ': ') : null,
939
- x.text, ' ', checkMark(x), ' ', editedMark(x), chips(x)),
940
- el('span', { class: 'nt-at' }, fmtTime(x.t)),
941
- key === 'fact' || key === 'tagged' ? null : iconButton({ icon: 'x', ariaLabel: 'Remove from ' + title, onClick: () => unpick(ctx, n, x), size: 'sm', attrs: { 'data-nt-unpick': String(x.index) } })))));
1041
+ el('h3', { class: 'nt-sec__h' }, title, extra || null),
1042
+ items.length ? el('ul', { class: 'nt-items' }, items.map((x) => itemRow(ctx, n, key, title, x))) : null);
1043
+ }
1044
+ /** What was caught, by section: from the agent's minute bullets when the note has them (Run 7), else from the lines. Pure. */
1045
+ export function caughtItems(n) {
1046
+ const bs = (n.bullets || []).filter((b) => b && b.text && !b.dropped);
1047
+ if (!bs.length) return { ...caught(n), point: [] };
1048
+ const out = { decision: [], action: [], question: [], fact: [], point: [] };
1049
+ bs.forEach((b, i) => {
1050
+ const x = { ...b, bullet: b.id !== undefined ? b.id : i };
1051
+ if (b.open) { out.point.push(x); return; } // cut at the minute: no category until the next minute ends it
1052
+ if (b.cat === 'small talk') return;
1053
+ if (out[b.cat] && b.cat !== 'point') out[b.cat].push(x); else out.point.push(x);
1054
+ });
1055
+ return out;
942
1056
  }
943
1057
  function summarySection(ctx, n) {
944
1058
  const a = n.agent;
945
1059
  if (a && a.markdown) {
946
1060
  let blocks = [];
947
1061
  try { blocks = mdRender(a.markdown).blocks || []; } catch { blocks = [el('div', { class: 'nt-pre' }, a.markdown)]; }
948
- return el('section', { class: 'nt-sec', 'data-nt-summary': 'agent' }, el('h3', { class: 'nt-sec__h' }, 'Summary'), el('div', { class: 'nt-md' }, blocks), el('p', { class: 'nt-hint' }, 'Written by your agent session.'));
1062
+ return el('section', { class: 'nt-sec', 'data-nt-summary': 'agent' }, el('h3', { class: 'nt-sec__h' }, 'Overview', el('span', { class: 'nt-src' }, ' · by your agent session')), el('div', { class: 'nt-md' }, blocks));
949
1063
  }
950
1064
  const ov = n.summary && n.summary.sections && n.summary.sections.overview;
951
1065
  const out = [];
952
- if (a && a.started && !a.markdown) out.push(el('p', { class: 'nt-hint', 'data-nt-agent-wait': '' }, 'Waiting for your agent session to write the summary.'));
1066
+ if (a && a.started && !a.markdown) out.push(el('p', { class: 'nt-hint', 'data-nt-agent-wait': '' }, 'Your agent session is writing the overview.'));
953
1067
  else if (a && a.started === false && a.reason) out.push(el('p', { class: 'nt-hint', 'data-nt-agent-failed': '' }, 'No agent session started: ' + a.reason + '.'));
954
- if (!ov || !ov.length) return out.length ? el('section', { class: 'nt-sec' }, out) : null;
955
- return el('section', { class: 'nt-sec', 'data-nt-summary': 'local' }, el('h3', { class: 'nt-sec__h' }, 'Summary'),
1068
+ if (!ov || !ov.length) return out.length ? el('section', { class: 'nt-sec' }, el('h3', { class: 'nt-sec__h' }, 'Overview'), out) : null;
1069
+ return el('section', { class: 'nt-sec', 'data-nt-summary': 'local' }, el('h3', { class: 'nt-sec__h' }, 'Overview', el('span', { class: 'nt-src' }, ' · picked on this machine')),
956
1070
  el('ul', { class: 'nt-items' }, ov.map((x) => el('li', { class: 'nt-item nt-item--plain', 'data-nt-ov': x.index === undefined ? null : String(x.index) }, el('span', { class: 'nt-item__text' }, x.text),
957
- x.index === undefined ? null : iconButton({ icon: 'x', ariaLabel: 'Take out of the summary', onClick: () => unsummarize(ctx, n, x.index), size: 'sm', attrs: { 'data-nt-unsum': String(x.index) } })))),
958
- el('p', { class: 'nt-hint' }, 'Picked from what was said, on this machine.'), ...out);
1071
+ x.index === undefined ? null : iconButton({ icon: 'x', ariaLabel: 'Take out of the overview', onClick: () => unsummarize(ctx, n, x.index), size: 'sm', attrs: { 'data-nt-unsum': String(x.index) } })))),
1072
+ ...out);
959
1073
  }
960
- function noteColumn(ctx, n, ed) {
1074
+ /** Your notes: one line each, with the meeting time it was added at, and the field that adds the next line. */
1075
+ function myNotes(ctx, n, ed) {
961
1076
  const live = listening(n);
962
- const c = caught(n);
1077
+ const times = n.line_times || {};
963
1078
  const replaying = player && player.id === n.id && player.playing;
964
- const notes = replaying
965
- ? el('div', { class: 'nt-notelines', 'data-nt-notelines': '' }, String(ed.body.value || '').split(/\r?\n/).map((l, k) => el('div', { class: 'nt-noteline', 'data-nt-noteline': String(k) }, l || ' ')),
966
- el('p', { class: 'nt-hint' }, 'Your notes, as the meeting plays. Pause to edit them.'))
967
- : ed.bodyWrap;
968
- const parts = [summarySection(ctx, n), notes];
1079
+ const items = [];
1080
+ String(ed.text || '').split(/\r?\n/).forEach((l, k) => {
1081
+ const s = l.trim();
1082
+ if (!s) return;
1083
+ items.push(el('li', { class: 'nt-item', 'data-nt-noteline': String(k) },
1084
+ el('span', { class: 'nt-at' }, typeof times[s] === 'number' ? fmtTime(times[s]) : ''),
1085
+ el('span', { class: 'nt-item__text' }, s),
1086
+ replaying ? null : iconButton({ icon: 'x', ariaLabel: 'Remove this note', onClick: () => removeLine(ctx, k), size: 'sm', attrs: { 'data-nt-unline': String(k) } })));
1087
+ });
1088
+ const t = liveMs(n);
1089
+ const parts = [el('h3', { class: 'nt-sec__h' }, 'Your notes')];
1090
+ if (items.length) parts.push(el('ul', { class: 'nt-items', 'data-nt-notelines': '' }, items));
1091
+ if (!replaying) parts.push(el('div', { class: 'nt-addrow' }, el('span', { class: 'nt-at', 'data-nt-clock-now': '' }, t !== null ? fmtTime(t) : ''), ed.addWrap));
1092
+ else parts.push(el('p', { class: 'nt-hint' }, 'Your notes, as the meeting plays. Pause to add more.'));
969
1093
  const sp = speech && speech.id === n.id ? speech : null;
970
1094
  const bar = [];
971
- if (sp && sp.mode === 'dictate' && sp.state === 'on') bar.push(el('span', { class: 'nt-hint', 'data-nt-listening': 'dictate', role: 'status' }, 'Dictating into your notes.'), button({ label: 'Stop dictating', variant: 'ghost', onClick: () => { stopSpeech(); ctx.setState({}); }, attrs: { 'data-nt-stop-dictate': '' } }));
972
- else if (!live) bar.push(button({ label: sp && sp.mode === 'dictate' && (sp.state === 'checking' || sp.state === 'installing') ? 'Starting …' : 'Dictate', variant: 'ghost', onClick: () => startSpeech(ctx, n, 'dictate'), attrs: { 'data-nt-dictate': '' } }));
1095
+ if (sp && sp.mode === 'dictate' && sp.state === 'on') bar.push(el('span', { class: 'nt-hint', 'data-nt-listening': 'dictate', role: 'status' }, 'Dictating into your notes. Say "new line" to add the line.'), button({ label: 'Stop dictating', variant: 'ghost', size: 'sm', onClick: () => { stopSpeech(); ctx.setState({}); }, attrs: { 'data-nt-stop-dictate': '' } }));
1096
+ else if (!live && !replaying) bar.push(button({ label: sp && sp.mode === 'dictate' && (sp.state === 'checking' || sp.state === 'installing') ? 'Starting …' : 'Dictate', variant: 'ghost', size: 'sm', onClick: () => startSpeech(ctx, n, 'dictate'), attrs: { 'data-nt-dictate': '' } }));
973
1097
  if (sp && sp.state === 'needs-install') bar.push(button({ label: 'Download for ' + sp.lang, onClick: () => startSpeech(ctx, n, sp.mode, true), attrs: { 'data-nt-install': '' } }));
974
- parts.push(el('div', { class: 'nt-row' }, bar));
1098
+ if (bar.length) parts.push(el('div', { class: 'nt-row' }, bar));
975
1099
  if (sp && sp.msg && sp.state !== 'on') parts.push(el('p', { class: 'nt-hint', 'data-nt-listen-msg': sp.state }, sp.msg));
1100
+ return el('section', { class: 'nt-sec', 'data-nt-sec': 'mine' }, parts);
1101
+ }
1102
+ /** The note, top to bottom as in the Run 7 mockup: while listening, Your notes first; after Stop, the Overview first. */
1103
+ function noteColumn(ctx, n, ed) {
1104
+ const live = listening(n);
1105
+ const c = caughtItems(n);
1106
+ const bs = n.bullets_state;
1107
+ const parts = [];
976
1108
  if (n.__audio && n.__audio.error && live) parts.push(el('p', { class: 'nt-hint nt-hint--bad', 'data-nt-audio-error': '' }, n.__audio.error));
977
- const secs = SECTIONS.map(([k, t]) => sectionOf(ctx, n, k, t, c[k])).filter(Boolean);
978
- if (!live && (n.transcript || []).length) { const f = sectionOf(ctx, n, 'fact', 'Facts mentioned', c.fact); if (f) secs.push(f); }
979
- const tg = sectionOf(ctx, n, 'tagged', 'Tagged', tagged(n)); if (tg) secs.push(tg);
980
- if (secs.length) parts.push(...secs);
981
- else parts.push(el('p', { class: 'nt-hint', 'data-nt-caught-hint': '' }, live ? 'Listening …' : (n.transcript || []).length ? 'Nothing was caught as a decision, action or question.'
1109
+ if (live) parts.push(myNotes(ctx, n, ed)); else parts.push(summarySection(ctx, n));
1110
+ const each = bs && bs.agent === 'on' ? el('span', { class: 'nt-src', 'data-nt-each-minute': '' }, ' · each minute') : null;
1111
+ const secs = [sectionOf(ctx, n, 'point', 'Key points', c.point, c.point.length || each ? each : null)];
1112
+ for (const [k, t] of SECTIONS) secs.push(sectionOf(ctx, n, k, t, c[k]));
1113
+ if (!live && (n.transcript || []).length) secs.push(sectionOf(ctx, n, 'fact', 'Facts mentioned', c.fact));
1114
+ secs.push(sectionOf(ctx, n, 'tagged', 'Tagged', tagged(n)));
1115
+ const shown = secs.filter(Boolean);
1116
+ if (shown.some((x) => x.querySelector('li'))) parts.push(...shown);
1117
+ else parts.push(el('p', { class: 'nt-hint', 'data-nt-caught-hint': '' }, live ? 'Listening … Key points, decisions, next steps and questions appear here as they are said.' : (n.transcript || []).length ? 'Nothing was caught as a decision, next step or question.'
982
1118
  : 'Decisions, actions and questions appear here as they are said, each checked against this repository.'));
1119
+ if (bs && bs.agent === 'failed' && bs.reason) parts.push(el('p', { class: 'nt-hint', 'data-nt-bullets-failed': '' }, 'Key points are not written each minute: ' + bs.reason + '. Lines are labelled on this machine.'));
1120
+ if (!live) parts.push(myNotes(ctx, n, ed));
983
1121
  return el('section', { class: 'nt-notecol', 'data-nt-part': 'notes' }, parts.filter(Boolean));
984
1122
  }
985
- function transcriptColumn(ctx, n) {
1123
+ function transcriptColumn(ctx, n, opts = {}) {
986
1124
  const tr = n.transcript || [];
987
1125
  const live = listening(n);
988
1126
  const lines = tr.map((x, i) => {
1127
+ if (x.source === 'typed' && x.dropped) return null; // a note line the PM removed
989
1128
  const lab = x.cat && x.cat !== 'small talk' ? el('span', { class: 'nt-lab', 'data-nt-cat': x.cat }, x.cat) : null;
990
- const who = speakerOf(n, x.source);
1129
+ const who = x.source === 'typed' ? 'You, typed' : speakerOf(n, x.source);
991
1130
  const spk = x.source === 'meeting'
992
1131
  ? button({ label: who, variant: 'ghost', size: 'sm', onClick: () => renameSpeaker(ctx, n, 'meeting'), attrs: { 'data-nt-spk': 'meeting', title: 'Name this speaker' } })
993
- : el('span', { class: 'nt-spk', 'data-nt-spk': x.source || 'speech' }, who);
1132
+ : el('span', { class: 'nt-spk' + (x.source === 'typed' ? ' nt-spk--typed' : ''), 'data-nt-spk': x.source || 'speech' }, who);
994
1133
  const at = n.__recording && !live && typeof x.t === 'number'
995
1134
  ? button({ label: fmtTime(x.t), variant: 'ghost', size: 'sm', onClick: () => seek(ctx, n, x.t), attrs: { 'data-nt-seek': String(i), title: 'Play from here' } })
996
1135
  : el('span', { class: 'nt-at' }, fmtTime(x.t));
997
1136
  return el('div', { class: 'nt-ln' + (['decision', 'action', 'question', 'fact'].includes(x.cat) ? ' nt-ln--picked' : ''), 'data-nt-seg': String(i) },
998
- el('div', { class: 'nt-ln__meta' }, spk, at, lab, editedMark(x),
1137
+ el('div', { class: 'nt-ln__meta' }, at, spk, lab, editedMark(x),
999
1138
  iconButton({ icon: 'ellipsis', ariaLabel: 'Correct this line', onClick: () => editLine(ctx, n, i), size: 'sm', attrs: { 'data-nt-edit': String(i) } })),
1000
1139
  el('div', null, x.text), x.tags || x.people ? el('div', { class: 'nt-tags' }, chips(x)) : null);
1001
1140
  });
1002
1141
  const sp = speech && speech.id === n.id && speech.mode === 'record' && speech.state === 'on' ? speech : null;
1003
1142
  if (sp) lines.push(el('div', { class: 'nt-ln nt-ln--interim', 'data-nt-interim': '', 'aria-live': 'polite' }, sp.interim || ''));
1004
1143
  const pending = n.__audio && n.__audio.pending;
1144
+ for (let k = lines.length - 1; k >= 0; k--) if (!lines[k]) lines.splice(k, 1);
1005
1145
  if (!lines.length) lines.push(el('p', { class: 'nt-hint' }, live ? 'Waiting for speech …' : 'The transcript runs here.'));
1006
1146
  else if (live && pending) lines.push(el('p', { class: 'nt-hint', 'data-nt-pending': '' }, 'Transcribing …'));
1007
1147
  const box = el('div', { class: 'nt-lines', 'data-nt-transcript': '' }, lines);
1008
- if (live) requestAnimationFrame(() => { box.scrollTop = box.scrollHeight; });
1009
- return el('aside', { class: 'nt-trans', 'data-nt-part': 'transcript' }, el('h3', { class: 'nt-sec__h' }, 'Transcript'), box);
1148
+ const shownN = tr.filter((x) => !(x.source === 'typed' && x.dropped)).length;
1149
+ const where = opts.inline ? 'inline' : 'full';
1150
+ const d = el('details', { class: 'nt-trans', 'data-nt-part': 'transcript', open: transcriptOpen[where] },
1151
+ el('summary', { class: 'nt-sec__h nt-fold' }, 'Transcript', el('span', { class: 'nt-src' }, ' · ' + shownN + (shownN === 1 ? ' line' : ' lines') + (live ? ' so far' : ''))), box);
1152
+ d.addEventListener('toggle', () => { transcriptOpen[where] = d.open; if (d.open) box.scrollTop = box.scrollHeight; });
1153
+ if (live && transcriptOpen[where]) requestAnimationFrame(() => { box.scrollTop = box.scrollHeight; });
1154
+ return d;
1010
1155
  }
1011
1156
 
1012
1157
  export function renderBoard(inner, ctx) {
@@ -1046,7 +1191,6 @@ export function renderBoard(inner, ctx) {
1046
1191
  wrap.appendChild(el('div', { class: 'nt-body2' }, noteColumn(ctx, n, ed), transcriptColumn(ctx, n)));
1047
1192
  if (pl) queueMicrotask(paintNow);
1048
1193
  if (n.agent && n.agent.started && !n.agent.markdown && !agentTimer) pollAgent(ctx, n.id);
1049
- requestAnimationFrame(fitBody);
1050
1194
  }
1051
1195
  inner.appendChild(wrap);
1052
1196
  keep();
@@ -1056,7 +1200,7 @@ export function renderBoard(inner, ctx) {
1056
1200
  * them; focus comes back in a microtask, after the frame is attached and before the next key event, so no keystroke lands
1057
1201
  * while the field is detached. */
1058
1202
  function keepFocus() {
1059
- const focus = typeof document !== 'undefined' && editor && (document.activeElement === editor.body ? 'body' : document.activeElement === editor.title ? 'title' : null);
1203
+ const focus = typeof document !== 'undefined' && editor && (document.activeElement === editor.add ? 'add' : document.activeElement === editor.title ? 'title' : null);
1060
1204
  if (!focus) return () => {};
1061
1205
  const caret = [editor[focus].selectionStart, editor[focus].selectionEnd];
1062
1206
  return () => queueMicrotask(() => { try { const t = editor[focus]; if (t.isConnected) { t.focus(); t.setSelectionRange(caret[0], caret[1]); } } catch { /* the field is gone */ } });
@@ -1096,7 +1240,7 @@ export const listeningId = () => (audio && audio.id) || (speech && speech.mode =
1096
1240
  /** 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,
1097
1241
  * in one column, with Open in Notes for the full screen. It never stops a meeting that is being heard: when another note is
1098
1242
  * listening it draws nothing and returns false. */
1099
- export function renderInline(host, ctx, id) {
1243
+ export function renderInline(host, ctx, id, opts = {}) {
1100
1244
  if (ctx.FIXTURE) return false;
1101
1245
  const other = listeningId();
1102
1246
  if (other && other !== id) return false;
@@ -1109,14 +1253,13 @@ export function renderInline(host, ctx, id) {
1109
1253
  const ed = editorFor(ctx, n);
1110
1254
  const wrap = el('div', { class: 'nt-wrap nt-wrap--inline', 'data-nt-part': 'inline', 'data-nt-id': n.id });
1111
1255
  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));
1112
- wrap.appendChild(header(ctx, n, ed, { inline: true }));
1256
+ wrap.appendChild(header(ctx, n, ed, { inline: true, onMinimize: opts.onMinimize }));
1113
1257
  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.'));
1114
1258
  const pl = !listening(n) ? playerFor(ctx, n) : null;
1115
1259
  if (pl) wrap.appendChild(el('div', { class: 'nt-replay', 'data-nt-part': 'replay' }, el('span', { class: 'nt-sec__h' }, 'Replay'), pl.el));
1116
- wrap.appendChild(el('div', { class: 'nt-body2' }, noteColumn(ctx, n, ed), transcriptColumn(ctx, n)));
1260
+ wrap.appendChild(el('div', { class: 'nt-body2' }, noteColumn(ctx, n, ed), transcriptColumn(ctx, n, { inline: true })));
1117
1261
  if (pl) queueMicrotask(paintNow);
1118
1262
  if (n.agent && n.agent.started && !n.agent.markdown && !agentTimer) pollAgent(ctx, n.id);
1119
- requestAnimationFrame(fitBody);
1120
1263
  host.appendChild(wrap);
1121
1264
  keep();
1122
1265
  return true;