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.
- package/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/guide/notes.md +15 -10
- package/workspace/lib/notes-whisper.js +94 -22
- package/workspace/lib/notes.js +559 -59
- package/workspace/lib/routes-notes.js +276 -46
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +1 -1
- package/workspace/public/pm/components.css +142 -0
- package/workspace/public/pm/note-dock.js +12 -6
- package/workspace/public/pm/view-notes.js +245 -102
- package/workspace/server.js +12 -1
|
@@ -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', '
|
|
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
|
|
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
|
-
|
|
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) {
|
|
314
|
-
|
|
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
|
-
|
|
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) {
|
|
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
|
|
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(() =>
|
|
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.
|
|
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
|
-
|
|
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(() =>
|
|
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
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
t.
|
|
763
|
-
|
|
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
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
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
|
-
|
|
775
|
-
for (const ev of ['keyup', 'mouseup', 'select', 'blur'])
|
|
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
|
-
|
|
884
|
-
|
|
885
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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: '
|
|
897
|
-
} else
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
if (!
|
|
901
|
-
|
|
902
|
-
|
|
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.
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
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
|
-
|
|
933
|
-
|
|
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) =>
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
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' }, '
|
|
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': '' }, '
|
|
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' }, '
|
|
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
|
|
958
|
-
|
|
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
|
-
|
|
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
|
|
1077
|
+
const times = n.line_times || {};
|
|
963
1078
|
const replaying = player && player.id === n.id && player.playing;
|
|
964
|
-
const
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
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
|
-
|
|
978
|
-
|
|
979
|
-
const
|
|
980
|
-
|
|
981
|
-
|
|
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' },
|
|
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
|
-
|
|
1009
|
-
|
|
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.
|
|
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;
|