create-pmos 0.116.0 → 0.118.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +43 -3
- package/workspace/guide/today.md +22 -13
- package/workspace/lib/brief.js +2 -2
- package/workspace/lib/day-marks.js +29 -9
- package/workspace/lib/goals.js +68 -10
- package/workspace/lib/notes.js +139 -17
- package/workspace/lib/routes-notes.js +68 -11
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/components.css +289 -91
- package/workspace/public/pm/dest-notes.js +136 -29
- package/workspace/public/pm/note-dock.js +204 -164
- package/workspace/public/pm/notes-context.js +60 -0
- package/workspace/public/pm/notes-links.js +32 -6
- package/workspace/public/pm/primitives.js +1 -1
- package/workspace/public/pm/today-agenda.js +27 -53
- package/workspace/public/pm/today-copy.js +34 -15
- package/workspace/public/pm/today-day.js +98 -76
- package/workspace/public/pm/today-goals.js +159 -15
- package/workspace/public/pm/today-jobs.js +84 -0
- package/workspace/public/pm/today-mentions.js +50 -0
- package/workspace/public/pm/topbar.js +71 -32
- package/workspace/public/pm/view-notes.js +142 -73
- package/workspace/server.js +10 -8
- package/workspace/test-files.json +4 -0
- package/workspace/test-lists/today-goals-jobs-ui.json +5 -0
- package/workspace/test-lists/today-goals-jobs.json +5 -0
- package/workspace/test-lists/today-mentions-cards.json +5 -0
|
@@ -28,10 +28,11 @@
|
|
|
28
28
|
* calendar) and which initiative it belongs to, and how that was set: from the calendar event, suggested from what was said,
|
|
29
29
|
* or chosen by the PM. Change opens a sheet for each.
|
|
30
30
|
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
31
|
+
* ONE FULL NOTE (Run 13). Take notes, Record and Resume start listening at once (listenNow: no chooser), and the Record
|
|
32
|
+
* control in the top bar (pm/topbar.js, pm/note-dock.js) holds the meeting. The note itself is the right column of the Notes
|
|
33
|
+
* place (pm/dest-notes.js); while the meeting is recording or paused that column draws renderLive: the status, Your notes
|
|
34
|
+
* and the live transcript. There is no pop-up note. PAUSE keeps the meeting open and resumable for an hour (POST
|
|
35
|
+
* /api/notes/pause); STOP ends it for good and writes the final notes (POST /api/notes/stop).
|
|
35
36
|
*
|
|
36
37
|
* Reads and writes (lib/routes-notes.js). Notes live in the per-user runtime directory, never in a repository:
|
|
37
38
|
* GET /api/notes · /api/notes/one?id=… · /api/notes/engine?ws=… · /api/notes/recording?id=… · /api/people?ws=…
|
|
@@ -40,12 +41,13 @@
|
|
|
40
41
|
* · summarize · summarize-agent · delete · line · recording-delete · link
|
|
41
42
|
*/
|
|
42
43
|
import { el, kept } from './dom.js';
|
|
43
|
-
import { askConfirm, askText } from './dialogs.js';
|
|
44
|
+
import { askConfirm, askText, askChoice } from './dialogs.js';
|
|
44
45
|
import { pmGet, pmPost } from './http.js';
|
|
45
46
|
import { mdRender } from './markdown.js';
|
|
46
47
|
import { icon } from './icons.js';
|
|
47
48
|
import { button, iconButton, field, row, sheet, empty, menu, toast, ensureComponentsCss } from './primitives.js';
|
|
48
49
|
import { toInt16, makeResampler, openCapture } from './audio-capture.js';
|
|
50
|
+
import { agendaHeard, peopleOf as contextPeople } from './notes-context.js';
|
|
49
51
|
|
|
50
52
|
const SAVE_MS = 700; // a pause this long in typing saves the note
|
|
51
53
|
const POLL_MS = 1500; // while listening, and until the last line is labelled, the note is read this often
|
|
@@ -68,7 +70,7 @@ let speech = null; // the browser recogniser: { mode: 'dictate'|'reco
|
|
|
68
70
|
let audio = null; // local Whisper capture: { id, state, sources, ctx, streams, started, clock, msg }
|
|
69
71
|
let engine = null; // { ws, whisper, jev, at }
|
|
70
72
|
let showList = false;
|
|
71
|
-
let inlineDraw = false; // the
|
|
73
|
+
let inlineDraw = false; // the live block is drawing: the fields it shows are left in place (renderLive)
|
|
72
74
|
/** A node this view keeps across redraws (an editor field, the player). The pop-up patches itself (dom.js patchChildren), so
|
|
73
75
|
* there the node is a placeholder and stays where it is: a field that is typed in is never moved, and keeps its focus. */
|
|
74
76
|
const own = (node) => (inlineDraw ? kept(node) : node);
|
|
@@ -77,9 +79,9 @@ let busy = '';
|
|
|
77
79
|
let player = null; // the replay: { id, bytes, el, playing }, kept across redraws so playback goes on
|
|
78
80
|
let people = null; // { ws, names } — the repository's people, offered in the line editor
|
|
79
81
|
let pendingOpen = null; // a note another page asked to open (openNote), selected at the next draw
|
|
80
|
-
let transcriptOpen = { inline: false, full: true }; // the transcript is
|
|
81
|
-
let
|
|
82
|
-
let
|
|
82
|
+
let transcriptOpen = { inline: false, full: true }; // the transcript is open in the live block and on the full screen
|
|
83
|
+
let starting = false; // a listen is starting (listenNow): the live block must not switch notes under it
|
|
84
|
+
let inlineSwitch = null; // the note the live block is switching to (renderLive), so a redraw does not start it twice
|
|
83
85
|
let liveTranscript = false; // after the final pass, the PM can look at the live transcript instead (Run 9)
|
|
84
86
|
let wisprTimer = null;
|
|
85
87
|
let highlight = null; // { id, state, listenDurationMs, listenedMs, text, label, conflicts } while highlighting
|
|
@@ -180,10 +182,38 @@ export function lineDiff(a, b) {
|
|
|
180
182
|
while (j < y.length) out.push({ op: 'add', text: y[j++] });
|
|
181
183
|
return out;
|
|
182
184
|
}
|
|
185
|
+
/** The meeting's context as Markdown lines, first in the note (R59): when, where, people, the agenda with what was heard, what it is
|
|
186
|
+
* for and how to be ready, the links and related items. Empty when the note keeps no context. Pure. */
|
|
187
|
+
export function contextMarkdown(n) {
|
|
188
|
+
const m = n.meeting;
|
|
189
|
+
const c = m && m.context;
|
|
190
|
+
if (!m || m.kind !== 'event' || !c) return [];
|
|
191
|
+
const out = [];
|
|
192
|
+
const when = m.start ? dayOf(m.start) + ', ' + nowClock(m.start) + (m.end ? '–' + nowClock(m.end) : '') : '';
|
|
193
|
+
if (when) out.push('**When:** ' + when);
|
|
194
|
+
if (n.initiative) out.push('**Initiative:** ' + (n.initiative.title || n.initiative.id));
|
|
195
|
+
const who = contextPeople(c);
|
|
196
|
+
if (who.length) out.push('**People:** ' + who.join(', '));
|
|
197
|
+
else if (c.attendees !== null && c.attendees !== undefined) out.push('**People:** ' + c.attendees + (c.attendees === 1 ? ' attendee' : ' attendees'));
|
|
198
|
+
if (c.role) out.push('**Your role:** ' + c.role);
|
|
199
|
+
if (c.external) out.push('**Someone from outside the company attends.**');
|
|
200
|
+
if (c.series) out.push('**Series:** ' + c.series);
|
|
201
|
+
if (c.purpose) out.push('', '**What it is for:** ' + c.purpose);
|
|
202
|
+
if (c.prep) out.push('**How to be ready:** ' + c.prep);
|
|
203
|
+
if ((c.agenda || []).length) {
|
|
204
|
+
const heard = agendaHeard(c, (n.transcript || []).filter((x) => !x.dropped));
|
|
205
|
+
out.push('', '## Agenda', '', ...c.agenda.map((a, i) => '- [' + (heard[i] !== null ? 'x' : ' ') + '] ' + a + (heard[i] !== null ? ' (heard at ' + fmtTime(heard[i]) + ')' : '')));
|
|
206
|
+
}
|
|
207
|
+
if ((c.links || []).length) out.push('', ...c.links.map((l) => '- [' + l.label + '](' + l.url + ')'));
|
|
208
|
+
if ((c.refs || []).length) out.push('', '**Related:** ' + c.refs.map((r) => r.label).join('; '));
|
|
209
|
+
out.push('');
|
|
210
|
+
return out;
|
|
211
|
+
}
|
|
183
212
|
/** The whole note as Markdown, for "Copy as Markdown". Pure, for the suite. */
|
|
184
213
|
export function noteMarkdown(n) {
|
|
185
214
|
const out = ['# ' + (n.title || 'Untitled note'), ''];
|
|
186
215
|
if (n.created_at) out.push('_' + dayOf(n.created_at) + ', ' + nowClock(n.created_at) + '_', '');
|
|
216
|
+
out.push(...contextMarkdown(n));
|
|
187
217
|
if (n.agent && n.agent.markdown) out.push('## Summary', '', n.agent.markdown.trim(), '');
|
|
188
218
|
else if (n.summary && n.summary.sections && n.summary.sections.overview && n.summary.sections.overview.length) out.push('## Summary', '', ...n.summary.sections.overview.map((x) => '- ' + x.text), '');
|
|
189
219
|
if (String(n.body || '').trim()) out.push('## Notes', '', n.body.trim(), '');
|
|
@@ -320,7 +350,7 @@ async function deleteNote(ctx, n) {
|
|
|
320
350
|
if (r && r.ok) {
|
|
321
351
|
cache.delete(n.id);
|
|
322
352
|
if (list) list.notes = (list.notes || []).filter((x) => x.id !== n.id);
|
|
323
|
-
if (here) { currentId = list && list.notes && list.notes.length ? list.notes[0].id : null; editor = null;
|
|
353
|
+
if (here) { currentId = list && list.notes && list.notes.length ? list.notes[0].id : null; editor = null; }
|
|
324
354
|
import('./note-dock.js').then((d) => d.forget(n.id)).catch(() => {});
|
|
325
355
|
import('./notes-links.js').then((l) => l.refreshNotes && l.refreshNotes()).catch(() => {});
|
|
326
356
|
toast({ text: '"' + (n.title || 'Untitled note') + '" was deleted.' });
|
|
@@ -401,8 +431,18 @@ async function finalSetup(ctx) {
|
|
|
401
431
|
: { tone: 'bad', text: 'No terminal opened' + (r && r.reason ? ': ' + r.reason : '') + '. Run this in a terminal: ' + ((r && r.command) || '') };
|
|
402
432
|
ctx.setState({});
|
|
403
433
|
}
|
|
434
|
+
/** Name a speaker. When the note keeps its meeting's people (R59), the choice starts with them; "Someone else" asks for a name. */
|
|
435
|
+
async function askSpeaker(n, current, placeholder) {
|
|
436
|
+
const names = contextPeople(n.meeting && n.meeting.context);
|
|
437
|
+
const ask = () => askText({ title: 'Name this speaker', label: 'Name', value: current, placeholder, confirmLabel: 'Save' });
|
|
438
|
+
if (!names.length) return ask();
|
|
439
|
+
const other = '__other';
|
|
440
|
+
const pick = await askChoice({ title: 'Who is speaking?', label: 'Speaker', options: [...names.map((x) => ({ value: x, label: x })), { value: other, label: 'Someone else …' }], value: names.includes(current) ? current : names[0], confirmLabel: 'Save' });
|
|
441
|
+
if (pick == null) return null;
|
|
442
|
+
return pick === other ? ask() : pick;
|
|
443
|
+
}
|
|
404
444
|
async function renameFinalSpeaker(ctx, n, key, now) {
|
|
405
|
-
const name = await
|
|
445
|
+
const name = await askSpeaker(n, now, now);
|
|
406
446
|
if (name == null) return;
|
|
407
447
|
const r = await pmPost('/api/notes/speaker', { id: n.id, key, name });
|
|
408
448
|
// the note may have been read again since this button was drawn, so read it once more rather than change a stale copy
|
|
@@ -428,7 +468,7 @@ async function unpick(ctx, n, x) {
|
|
|
428
468
|
ctx.setState({});
|
|
429
469
|
}
|
|
430
470
|
async function renameSpeaker(ctx, n, source) {
|
|
431
|
-
const name = await
|
|
471
|
+
const name = await askSpeaker(n, n.speakers && n.speakers[source] ? n.speakers[source] : '', SPEAKER[source]);
|
|
432
472
|
if (name == null) return;
|
|
433
473
|
const r = await pmPost('/api/notes/speaker', { id: n.id, source, name });
|
|
434
474
|
if (r && r.ok) n.speakers = r.speakers; else banner = { tone: 'bad', text: (r && r.error) || 'not renamed' };
|
|
@@ -629,6 +669,7 @@ async function startAudio(ctx, n, want) {
|
|
|
629
669
|
a.clock = setInterval(() => tickClocks(a.started), 1000);
|
|
630
670
|
ctx.setState({});
|
|
631
671
|
poll(ctx, n.id);
|
|
672
|
+
import('./notes-links.js').then((m) => m.refreshNotes()).catch(() => {});
|
|
632
673
|
}
|
|
633
674
|
function sendChunk(ctx, a, kind, final) {
|
|
634
675
|
const b = a.buf[kind] || []; a.buf[kind] = [];
|
|
@@ -655,7 +696,7 @@ function sendChunk(ctx, a, kind, final) {
|
|
|
655
696
|
}).catch(() => {});
|
|
656
697
|
return a.queue[kind];
|
|
657
698
|
}
|
|
658
|
-
async function stopAudio(ctx, { discard = false } = {}) {
|
|
699
|
+
async function stopAudio(ctx, { discard = false, resumable = true } = {}) {
|
|
659
700
|
const a = audio;
|
|
660
701
|
if (!a) return;
|
|
661
702
|
audio = null;
|
|
@@ -669,8 +710,10 @@ async function stopAudio(ctx, { discard = false } = {}) {
|
|
|
669
710
|
const tail24 = a.rs24[k] ? a.rs24[k].flush() : []; const b24 = a.buf24[k]; for (let i = 0; i < tail24.length; i++) b24.push(tail24[i]);
|
|
670
711
|
}
|
|
671
712
|
await Promise.all(a.sources.map((k) => sendChunk(ctx, a, k, true)));
|
|
672
|
-
await pmPost('/api/notes/pause', { id: a.id });
|
|
713
|
+
await pmPost(resumable ? '/api/notes/pause' : '/api/notes/stop', { id: a.id });
|
|
673
714
|
ctx.setState({});
|
|
715
|
+
// a pause keeps the meeting open: the final notes wait for Stop, or for the hour to end
|
|
716
|
+
if (resumable) { poll(ctx, a.id); return; }
|
|
674
717
|
const n = cache.get(a.id);
|
|
675
718
|
if (!n || n.private) return;
|
|
676
719
|
const t0 = Date.now();
|
|
@@ -763,7 +806,7 @@ async function startSpeech(ctx, n, mode, install = false) {
|
|
|
763
806
|
rec.onend = () => { if (speech === s && s.state === 'on') { try { rec.start(); } catch { /* it is starting */ } } };
|
|
764
807
|
s.rec = rec;
|
|
765
808
|
try { rec.start(); s.state = 'on'; } catch (e) { s.state = 'error'; s.msg = 'Listening did not start: ' + ((e && e.message) || e); }
|
|
766
|
-
if (mode === 'record') { s.clock = setInterval(() => tickClocks(s.started), 1000); poll(ctx, n.id); }
|
|
809
|
+
if (mode === 'record') { s.clock = setInterval(() => tickClocks(s.started), 1000); poll(ctx, n.id); import('./notes-links.js').then((m) => m.refreshNotes()).catch(() => {}); }
|
|
767
810
|
ctx.setState({});
|
|
768
811
|
}
|
|
769
812
|
|
|
@@ -841,7 +884,7 @@ async function removeHighlight(ctx, h) {
|
|
|
841
884
|
export const highlightBusy = () => (highlight ? { state: highlight.state, open: hlOpen } : null);
|
|
842
885
|
export function openHighlight(ctx) { if (highlight) { hlOpen = true; ctx.setState({}); } }
|
|
843
886
|
|
|
844
|
-
/** The highlight's pop-up (pm/
|
|
887
|
+
/** The highlight's pop-up (pm/topbar.js draws it under the Record control), or null when there is none or it is closed. */
|
|
845
888
|
export function highlightPanel(ctx) {
|
|
846
889
|
const h = highlight;
|
|
847
890
|
if (!h || !hlOpen) return null;
|
|
@@ -877,36 +920,57 @@ export function highlightPanel(ctx) {
|
|
|
877
920
|
if (h.state === 'failed') body.push(el('p', { class: 'nt-hint nt-hint--bad', 'data-nt-hl-failed': '' }, 'No highlight: ' + h.error + '.'));
|
|
878
921
|
const acts = [];
|
|
879
922
|
if (h.state === 'listening') acts.push(button({ label: 'Stop listening now', variant: 'outline', size: 'sm', onClick: () => { h.pressed = Date.now() - hlAfterMs(); hlTick(ctx); }, attrs: { 'data-nt-hl-stop': '' } }));
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
923
|
+
// icons only (Run 13): keep, discard, close; each carries its name as a tooltip and an aria-label
|
|
924
|
+
if (h.state === 'done') acts.push(iconButton({ icon: 'check', ariaLabel: 'Keep the highlight in the note', onClick: () => closeHighlight(ctx), attrs: { 'data-nt-hl-done': '' } }),
|
|
925
|
+
iconButton({ icon: 'ban', ariaLabel: 'Discard the highlight', onClick: () => removeHighlight(ctx, h), attrs: { 'data-nt-hl-remove': '' } }));
|
|
926
|
+
else acts.push(iconButton({ icon: 'x', ariaLabel: 'Close (the highlight keeps working)', onClick: () => closeHighlight(ctx), attrs: { 'data-nt-hl-close': '' } }));
|
|
883
927
|
body.push(el('div', { class: 'nt-hl-acts' }, acts));
|
|
884
928
|
return el('section', { class: 'nt-hl', role: 'dialog', 'aria-label': 'Highlight', 'data-nt-part': 'highlight-popup', 'data-nt-hl': h.state }, body);
|
|
885
929
|
}
|
|
886
930
|
|
|
887
|
-
async function stopRecording(ctx) {
|
|
931
|
+
async function stopRecording(ctx, { resumable = true } = {}) {
|
|
888
932
|
const s = speech;
|
|
889
933
|
if (s && s.mode === 'record') {
|
|
890
934
|
clearInterval(s.clock); stopSpeech();
|
|
891
935
|
await s.queue;
|
|
892
|
-
await pmPost('/api/notes/pause', { id: s.id });
|
|
936
|
+
await pmPost(resumable ? '/api/notes/pause' : '/api/notes/stop', { id: s.id });
|
|
893
937
|
const n = cache.get(s.id);
|
|
894
|
-
if (n && !n.private && (n.transcript || []).length) await summarize(ctx, n, true);
|
|
938
|
+
if (!resumable && n && !n.private && (n.transcript || []).length) await summarize(ctx, n, true);
|
|
895
939
|
}
|
|
896
|
-
await stopAudio(ctx);
|
|
940
|
+
await stopAudio(ctx, { resumable });
|
|
897
941
|
ctx.setState({});
|
|
898
942
|
}
|
|
899
|
-
|
|
900
|
-
/** Stop in the note: listening stops and the note leaves the Record control at the top right (pm/note-dock.js). */
|
|
943
|
+
/** Stop in the note: ask the Record control to stop the meeting for good (it asks first). */
|
|
901
944
|
async function stopAndFinish(ctx) {
|
|
902
945
|
const d = await import('./note-dock.js');
|
|
903
|
-
|
|
946
|
+
await d.stopNow();
|
|
904
947
|
}
|
|
905
948
|
/** Resume in the note: the Record control holds it again and it listens. */
|
|
906
949
|
async function resumeOn(n) {
|
|
907
950
|
const d = await import('./note-dock.js');
|
|
908
951
|
await d.recordNote(n.id, n.title);
|
|
909
952
|
}
|
|
953
|
+
/** PAUSE: listening stops and the meeting stays open for an hour; it can be resumed. No final notes yet. */
|
|
954
|
+
export const pauseListening = (ctx) => stopRecording(ctx, { resumable: true });
|
|
955
|
+
/** STOP: the meeting ends for good. Listening stops, the server ends the meeting, and the final notes are written. */
|
|
956
|
+
export async function stopMeeting(ctx, id) {
|
|
957
|
+
if (listeningId() === id) { await stopRecording(ctx, { resumable: false }); return; }
|
|
958
|
+
const r = await pmPost('/api/notes/stop', { id });
|
|
959
|
+
if (!(r && r.ok)) { banner = { tone: 'bad', text: (r && r.error) || 'the meeting was not stopped' }; ctx.setState({}); return; }
|
|
960
|
+
await loadNote(ctx, id);
|
|
961
|
+
const n = cache.get(id);
|
|
962
|
+
if (n && !n.private && (n.transcript || []).length) await summarize(ctx, n, true);
|
|
963
|
+
poll(ctx, id);
|
|
964
|
+
ctx.setState({});
|
|
965
|
+
}
|
|
966
|
+
/** Delete a recording that was started by mistake: stop listening without keeping the audio, then delete the note. */
|
|
967
|
+
export async function discardRecording(ctx, id) {
|
|
968
|
+
if (listeningId() === id) await stopAll(ctx, { discard: true });
|
|
969
|
+
const r = await pmPost('/api/notes/delete', { id });
|
|
970
|
+
if (r && r.ok) { cache.delete(id); if (currentId === id) { currentId = null; editor = null; dirty = false; clearTimeout(saveTimer); } }
|
|
971
|
+
ctx.setState({});
|
|
972
|
+
return !!(r && r.ok);
|
|
973
|
+
}
|
|
910
974
|
|
|
911
975
|
/* ── the two sheets: what to listen to, and settings ─────────────────────────────────────────── */
|
|
912
976
|
async function chooseSources(ctx, n) {
|
|
@@ -1215,8 +1279,7 @@ function moreMenu(ctx, n, opts, anchor) {
|
|
|
1215
1279
|
const has = (n.transcript || []).length > 0;
|
|
1216
1280
|
const items = [
|
|
1217
1281
|
has ? { label: 'Copy as Markdown', onSelect: () => copyMarkdown(ctx, n), attrs: { 'data-nt-copy': '' } } : null,
|
|
1218
|
-
|
|
1219
|
-
: { label: 'New meeting', onSelect: () => newNote(ctx), attrs: { 'data-nt-new': '' } },
|
|
1282
|
+
{ label: 'New meeting', onSelect: () => newNote(ctx), attrs: { 'data-nt-new': '' } },
|
|
1220
1283
|
{ sep: true },
|
|
1221
1284
|
{ label: 'Change meeting', onSelect: () => meetingSheet(ctx, n), attrs: { 'data-nt-meeting': '' } },
|
|
1222
1285
|
{ label: n.initiative ? 'Change initiative' : 'Link an initiative', onSelect: () => initiativeSheet(ctx, n), attrs: { 'data-nt-ini': '' } },
|
|
@@ -1249,8 +1312,7 @@ function header(ctx, n, ed, opts = {}) {
|
|
|
1249
1312
|
? button({ label: 'Resume', variant: 'primary', size: 'sm', onClick: () => resumeOn(n), attrs: { 'data-nt-listen': 'again' } })
|
|
1250
1313
|
: button({ label: 'Listen', variant: 'primary', size: 'sm', onClick: () => chooseSources(ctx, n), attrs: { 'data-nt-listen': '' } }));
|
|
1251
1314
|
}
|
|
1252
|
-
|
|
1253
|
-
if (!opts.inline) acts.push(button({ label: 'All notes', variant: 'ghost', size: 'sm', onClick: () => { showList = !showList; ctx.setState({}); if (showList) loadList(ctx); }, attrs: { 'data-nt-all': '', 'aria-expanded': showList ? 'true' : 'false' } }));
|
|
1315
|
+
acts.push(button({ label: 'All notes', variant: 'ghost', size: 'sm', onClick: () => { showList = !showList; ctx.setState({}); if (showList) loadList(ctx); }, attrs: { 'data-nt-all': '', 'aria-expanded': showList ? 'true' : 'false' } }));
|
|
1254
1316
|
const more = iconButton({ icon: 'ellipsis', ariaLabel: 'More', onClick: (e) => moreMenu(ctx, n, opts, (e && e.currentTarget) || more), attrs: { 'data-nt-more': '' } });
|
|
1255
1317
|
acts.push(more);
|
|
1256
1318
|
return el('header', { class: 'nt-header', 'data-nt-part': 'header' },
|
|
@@ -1601,12 +1663,7 @@ export function renderBoard(inner, ctx) {
|
|
|
1601
1663
|
if (banner) wrap.appendChild(el('div', { class: 'nt-banner' + (banner.tone === 'bad' ? ' nt-banner--bad' : ''), 'data-nt-banner': banner.tone, role: banner.tone === 'bad' ? 'alert' : 'status' }, banner.text));
|
|
1602
1664
|
const n = currentId ? cache.get(currentId) : null;
|
|
1603
1665
|
if (currentId && !n) { loadNote(ctx, currentId); wrap.appendChild(el('p', { class: 'nt-hint' }, 'reading the note …')); }
|
|
1604
|
-
else if (n
|
|
1605
|
-
wrap.appendChild(el('div', { class: 'nt-start', 'data-nt-popped': '' },
|
|
1606
|
-
el('h2', { class: 'nt-h' }, n.title || 'Untitled note'),
|
|
1607
|
-
el('p', { class: 'nt-hint' }, 'This note is open in the pop-up at the bottom right.'),
|
|
1608
|
-
button({ label: 'Show it here', variant: 'primary', onClick: () => openFull(ctx, n.id), attrs: { 'data-nt-unpop': '' } })));
|
|
1609
|
-
} else if (!n) {
|
|
1666
|
+
else if (!n) {
|
|
1610
1667
|
wrap.appendChild(el('div', { class: 'nt-start' },
|
|
1611
1668
|
el('h2', { class: 'nt-h' }, 'Notes'),
|
|
1612
1669
|
el('p', { class: 'nt-hint', 'data-nt-about': '' }, 'Take notes in a meeting. PMOS listens on this machine, catches the decisions, actions and questions, and checks them against your repository. No audio leaves this machine, and notes are kept outside every repository.'),
|
|
@@ -1637,43 +1694,38 @@ function keepFocus() {
|
|
|
1637
1694
|
const caret = [editor[focus].selectionStart, editor[focus].selectionEnd];
|
|
1638
1695
|
return () => queueMicrotask(() => { try { const t = editor[focus]; if (t.isConnected) { t.focus(); t.setSelectionRange(caret[0], caret[1]); } } catch { /* the field is gone */ } });
|
|
1639
1696
|
}
|
|
1640
|
-
async function openFull(ctx, id) {
|
|
1641
|
-
await flushSave(ctx);
|
|
1642
|
-
popped = null;
|
|
1643
|
-
const [c, d] = await Promise.all([import('./panel-cockpit.js'), import('./note-dock.js')]);
|
|
1644
|
-
d.minimize();
|
|
1645
|
-
pendingOpen = id;
|
|
1646
|
-
c.openView('notes');
|
|
1647
|
-
}
|
|
1648
|
-
|
|
1649
|
-
/** The note the pop-up shows (pm/note-dock.js), or null. */
|
|
1650
|
-
export function setPopped(id) { popped = id || null; }
|
|
1651
1697
|
/** The note's title as last read or typed, or ''. */
|
|
1652
1698
|
export const titleOf = (id) => (editor && editor.id === id && editor.title.value.trim()) || ((cache.get(id) || {}).title) || '';
|
|
1653
1699
|
/** When the note that is listening started, or null. */
|
|
1654
1700
|
export const liveStarted = () => (audio && audio.state === 'on' ? audio.started : speech && speech.mode === 'record' && speech.state === 'on' ? speech.started : null);
|
|
1655
1701
|
/** Stop the note that is listening, as its Stop does. */
|
|
1656
|
-
export const stopListening = (ctx) => stopRecording(ctx);
|
|
1702
|
+
export const stopListening = (ctx) => stopRecording(ctx, { resumable: true });
|
|
1657
1703
|
/** Start listening on this note at once, with no chooser (Run 6, Take notes): the microphone and the meeting when Whisper is set
|
|
1658
1704
|
* up, else the microphone through the browser's on-device recognition. False when the note cannot listen (private, gone). */
|
|
1659
1705
|
export async function listenNow(ctx, id) {
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1706
|
+
starting = true;
|
|
1707
|
+
try {
|
|
1708
|
+
// a highlight belongs to the meeting it was pressed in: it does not carry over to the next one
|
|
1709
|
+
if (highlight && highlight.id !== id) { highlight = null; hlOpen = false; clearInterval(highlightTimer); highlightTimer = null; }
|
|
1710
|
+
if (id !== currentId) await selectNote(ctx, id);
|
|
1711
|
+
let n = cache.get(id);
|
|
1712
|
+
if (!n) { await loadNote(ctx, id); n = cache.get(id); }
|
|
1713
|
+
if (!n || n.private) return false;
|
|
1714
|
+
const eng = engine && engine.ws === wsOf(ctx) && engine.whisper ? engine : await loadEngine(ctx);
|
|
1715
|
+
if (eng.whisper && eng.whisper.ready) await startAudio(ctx, n, ['mic', 'meeting']); else await startSpeech(ctx, n, 'record');
|
|
1716
|
+
return listeningId() === id;
|
|
1717
|
+
} finally { starting = false; ctx.setState({}); }
|
|
1667
1718
|
}
|
|
1668
1719
|
|
|
1669
1720
|
/** The note that is listening now, or null. */
|
|
1670
1721
|
export const listeningId = () => (audio && audio.id) || (speech && speech.mode === 'record' && (speech.state === 'on' || speech.state === 'checking') ? speech.id : null);
|
|
1671
1722
|
|
|
1672
|
-
/**
|
|
1673
|
-
*
|
|
1674
|
-
* listening it draws nothing and returns
|
|
1675
|
-
|
|
1676
|
-
|
|
1723
|
+
/** The live part of the note, drawn in the Notes place's right column while the meeting is recording or paused (meeting-notes
|
|
1724
|
+
* Run 13): the status, Your notes (the field to type in) and the live transcript. It is the same editor and listening as the
|
|
1725
|
+
* rest of this view. It never stops a meeting that is being heard: when another note is listening it draws nothing and returns
|
|
1726
|
+
* false. The fields it shows are kept across redraws (kept() through own()), so typing and scrolling survive new lines. */
|
|
1727
|
+
export function renderLive(host, ctx, id) {
|
|
1728
|
+
if (ctx.FIXTURE || starting) return false;
|
|
1677
1729
|
const other = listeningId();
|
|
1678
1730
|
if (other && other !== id) return false;
|
|
1679
1731
|
ensureComponentsCss();
|
|
@@ -1683,20 +1735,37 @@ export function renderInline(host, ctx, id, opts = {}) {
|
|
|
1683
1735
|
if (!n) { loadNote(ctx, id); host.appendChild(el('p', { class: 'nt-hint' }, 'reading the note …')); return true; }
|
|
1684
1736
|
const keep = keepFocus();
|
|
1685
1737
|
const ed = editorFor(ctx, n);
|
|
1738
|
+
// the title is typed in the Notes place, not here: this editor takes it from the note, so a save here never puts back an old one
|
|
1739
|
+
if (!dirty && ed.title.value !== (n.title || '')) ed.title.value = n.title || '';
|
|
1686
1740
|
inlineDraw = true;
|
|
1687
|
-
try {
|
|
1741
|
+
try { host.appendChild(liveBlock(ctx, n, ed)); } finally { inlineDraw = false; }
|
|
1688
1742
|
keep();
|
|
1689
1743
|
return true;
|
|
1690
1744
|
}
|
|
1691
|
-
function
|
|
1692
|
-
const
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
if (
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
}
|
|
1745
|
+
function liveBlock(ctx, n, ed) {
|
|
1746
|
+
const live = listening(n);
|
|
1747
|
+
const parts = [];
|
|
1748
|
+
if (banner) parts.push(el('div', { class: 'nt-banner' + (banner.tone === 'bad' ? ' nt-banner--bad' : ''), 'data-nt-banner': banner.tone, role: banner.tone === 'bad' ? 'alert' : 'status' }, banner.text));
|
|
1749
|
+
if (live) {
|
|
1750
|
+
parts.push(el('div', { class: 'nt-live', 'data-nt-listening': live.kind, role: 'status' },
|
|
1751
|
+
el('span', { class: 'nt-live-dot', 'aria-hidden': 'true' }), el('b', null, 'Taking notes'),
|
|
1752
|
+
el('span', { 'data-nt-clock': '' }, fmtTime(Date.now() - live.started)),
|
|
1753
|
+
el('span', { 'data-nt-sources': '' }, ' · ' + (live.state === 'starting' || live.state === 'checking' ? 'starting' : ['mic', 'meeting'].filter((s) => live.sources.includes(s)).join(' + '))),
|
|
1754
|
+
el('span', { class: 'nt-hint' }, 'Highlight and Pause are in the top bar.'),
|
|
1755
|
+
el('span', { class: 'nt-sr' }, ' Notes are being taken.')));
|
|
1756
|
+
} else {
|
|
1757
|
+
const left = n.resume_until ? Math.max(1, Math.ceil((Date.parse(n.resume_until) - Date.now()) / 60000)) : null;
|
|
1758
|
+
parts.push(el('div', { class: 'nt-live nt-live--paused', 'data-nt-paused': '', role: 'status' }, el('b', null, 'Paused'),
|
|
1759
|
+
el('span', { class: 'nt-hint' }, (left ? left + (left === 1 ? ' minute' : ' minutes') + ' left to resume. ' : '') + 'Stop ends the meeting for good.')));
|
|
1760
|
+
}
|
|
1761
|
+
if (n.private) parts.push(el('p', { class: 'nt-hint', 'data-nt-private': '' }, 'This note is private: it is not recorded, and nothing from it goes to Jev or to any agent.'));
|
|
1762
|
+
parts.push(myNotes(ctx, n, ed), transcriptColumn(ctx, n, {}));
|
|
1763
|
+
return el('section', { class: 'nt-liveblock', 'data-nt-part': 'live', 'data-nt-id': n.id }, parts);
|
|
1764
|
+
}
|
|
1765
|
+
/** Link the note to a calendar meeting, or to an initiative, from the Notes place's ⋯ menu: the same sheets as the Notes tab. */
|
|
1766
|
+
export async function linkMeeting(ctx, id) { await loadNote(ctx, id); const n = cache.get(id); if (n) await meetingSheet(ctx, n); }
|
|
1767
|
+
export async function linkInitiative(ctx, id) { await loadNote(ctx, id); const n = cache.get(id); if (n) await initiativeSheet(ctx, n); }
|
|
1768
|
+
/** The text of the last failure shown on this view (for example "this meeting was stopped"), and forget it. */
|
|
1769
|
+
export function takeBanner() { const t = banner && banner.tone === 'bad' ? banner.text : null; if (t) banner = null; return t; }
|
|
1770
|
+
/** The title was changed elsewhere (the Notes place): keep this view's copy in step. */
|
|
1771
|
+
export function noteTitled(id, title) { const c = cache.get(id); if (c) c.title = title; if (editor && editor.id === id && !dirty) editor.title.value = title || ''; }
|
package/workspace/server.js
CHANGED
|
@@ -3889,7 +3889,7 @@ function taskWsId(run, t) {
|
|
|
3889
3889
|
const brief = briefOfDay(run.date);
|
|
3890
3890
|
if (!brief || !t.item_id) return null;
|
|
3891
3891
|
const st = proSettings();
|
|
3892
|
-
const r = goalsLib.itemRepos({ day: readGoalsDay(run.date), brief, placeRepos: st.placeRepos || {}, iniRepos: iniRepos() })[t.item_id];
|
|
3892
|
+
const r = goalsLib.itemRepos({ day: readGoalsDay(run.date), brief, placeRepos: st.placeRepos || {}, iniRepos: iniRepos(), links: dayMarksLib.links(readDayMarks(run.date)) })[t.item_id];
|
|
3893
3893
|
const w = r && r !== run.ws ? getWs(r) : null;
|
|
3894
3894
|
return w && !daySkillPrompt(run, 'day-task', w.id).error ? w.id : null;
|
|
3895
3895
|
} catch { return null; }
|
|
@@ -4035,9 +4035,10 @@ function writeDayMarks(rec) {
|
|
|
4035
4035
|
fs.writeFileSync(tmp, JSON.stringify(rec) + '\n'); fs.renameSync(tmp, f); return true;
|
|
4036
4036
|
} catch (e) { warnWriteFail('day marks', e); return false; }
|
|
4037
4037
|
}
|
|
4038
|
-
/** The ids a mark may name: today's brief items ('item')
|
|
4039
|
-
function dayMarkIds(brief) {
|
|
4038
|
+
/** The ids a mark may name: today's brief items ('item'), calendar events ('event') and the run's tasks ('task', for a link). */
|
|
4039
|
+
function dayMarkIds(brief, run) {
|
|
4040
4040
|
const known = new Map();
|
|
4041
|
+
for (const t of (run && run.tasks) || []) if (t && /^t-[0-9a-f]{12}$/.test(String(t.id))) known.set(t.id, 'task');
|
|
4041
4042
|
if (!brief) return known;
|
|
4042
4043
|
for (const x of [...(brief.priorities || []), ...(brief.worth_asking || [])]) known.set(x.id, 'item');
|
|
4043
4044
|
for (const e of brief.calendar || []) known.set(e.id, 'event');
|
|
@@ -7850,7 +7851,7 @@ async function handleRequest(req, res) {
|
|
|
7850
7851
|
goalsCardsMemo.set(w.id, { at: now, cards, error: cardsError });
|
|
7851
7852
|
}
|
|
7852
7853
|
}
|
|
7853
|
-
const plan = goalsLib.planDay({ day, brief, cards, now });
|
|
7854
|
+
const plan = goalsLib.planDay({ day, brief, cards, now, links: dayMarksLib.links(readDayMarks(date)) });
|
|
7854
7855
|
const sched = readSchedules().find((x) => x.skill === 'morning-brief' && (!w || x.ws === w.id)) || null;
|
|
7855
7856
|
return json(res, { ok: true, ws: w ? w.id : null, today: date, plan, cards_error: cardsError, max_goals: goalsLib.MAX_GOALS,
|
|
7856
7857
|
schedule: sched ? { id: sched.id, time: sched.time, lastRun: sched.lastRun, lastStatus: sched.lastStatus } : null,
|
|
@@ -7861,7 +7862,7 @@ async function handleRequest(req, res) {
|
|
|
7861
7862
|
const now = Date.now();
|
|
7862
7863
|
const date = briefLib.localDate(now);
|
|
7863
7864
|
const r = goalsLib.edit(readGoalsDay(date), body, { now });
|
|
7864
|
-
if (!r.ok) return json(res, { ok: false, error: r.error }, /no goal with that id/.test(r.error) ? 404 : 400);
|
|
7865
|
+
if (!r.ok) return json(res, { ok: false, error: r.error }, /no goal with that id|no job with that id/.test(r.error) ? 404 : 400);
|
|
7865
7866
|
try { fs.mkdirSync(GOALS_DIR(), { recursive: true }); fs.writeFileSync(path.join(GOALS_DIR(), date + '.json'), JSON.stringify(r.day) + '\n'); }
|
|
7866
7867
|
catch (e) { warnWriteFail('goals', e); return json(res, { ok: false, error: 'could not save — nothing changed' }, 500); }
|
|
7867
7868
|
return json(res, { ok: true, day: r.day, goal: r.goal });
|
|
@@ -7943,7 +7944,7 @@ async function handleRequest(req, res) {
|
|
|
7943
7944
|
goalsCardsMemo.set(w.id, { at: now, cards, error: cardsError });
|
|
7944
7945
|
}
|
|
7945
7946
|
}
|
|
7946
|
-
const plan = goalsLib.planDay({ day: readGoalsDay(date), brief, cards, now });
|
|
7947
|
+
const plan = goalsLib.planDay({ day: readGoalsDay(date), brief, cards, now, links: dayMarksLib.links(readDayMarks(date)) });
|
|
7947
7948
|
const due = dayRunLib.checkDue(run, { settings: st, now, events: brief ? brief.calendar || [] : [] });
|
|
7948
7949
|
const items = brief ? [...(brief.priorities || []), ...(brief.worth_asking || [])] : [];
|
|
7949
7950
|
const sources = proactiveLib.BRIEF_SOURCES.map((k) => ({ k, on: st.briefSources[k] === true, state: brief ? brief.sources[k] || null : null, got: items.filter((x) => x.source === k).length }));
|
|
@@ -7959,7 +7960,7 @@ async function handleRequest(req, res) {
|
|
|
7959
7960
|
settings: { briefSources: st.briefSources, scopes: st.scopes, placeRepos: st.placeRepos, dayCheck: st.dayCheck, websites: st.websites }, sources,
|
|
7960
7961
|
// one day for every repository: the repositories it can be narrowed to, and the repository of each tagged item
|
|
7961
7962
|
repos: [...workspaces.values()].map((x) => ({ id: x.id, name: x.name || path.basename(x.root) })),
|
|
7962
|
-
item_repos: goalsLib.itemRepos({ day: readGoalsDay(date), brief, placeRepos: st.placeRepos || {}, iniRepos: iniRepos(now) }),
|
|
7963
|
+
item_repos: goalsLib.itemRepos({ day: readGoalsDay(date), brief, placeRepos: st.placeRepos || {}, iniRepos: iniRepos(now), links: dayMarksLib.links(readDayMarks(date)) }),
|
|
7963
7964
|
goal_repos: goalsLib.goalRepos({ day: readGoalsDay(date), iniRepos: iniRepos(now) }),
|
|
7964
7965
|
check: { every: st.dayCheck.every, last: run ? run.checks.last : null, next: due.next, running: !!(run && run.checks.session_id), log: run ? run.checks.log : [] } });
|
|
7965
7966
|
}
|
|
@@ -7987,7 +7988,8 @@ async function handleRequest(req, res) {
|
|
|
7987
7988
|
const stored = briefOfDay(date);
|
|
7988
7989
|
const brief = stored && stored.date === date ? stored : null;
|
|
7989
7990
|
if (!brief) return json(res, { ok: false, error: 'no brief today — press Prepare my day first' }, 409);
|
|
7990
|
-
const
|
|
7991
|
+
const openGoals = new Set(readGoalsDay(date).goals.filter((g) => g.status === 'open').map((g) => g.id));
|
|
7992
|
+
const r = dayMarksLib.edit(readDayMarks(date), body, { now, known: dayMarkIds(brief, readDayRun(date)), goals: openGoals });
|
|
7991
7993
|
if (!r.ok) return json(res, { ok: false, error: r.error }, /no item or meeting with that id/.test(r.error) ? 404 : 400);
|
|
7992
7994
|
if (!writeDayMarks(r.rec)) return json(res, { ok: false, error: 'could not save — nothing changed' }, 500);
|
|
7993
7995
|
return json(res, { ok: true, mark: r.mark, prev: r.prev, marks: dayMarksLib.effective(r.rec, now) });
|
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
"notes-highlight.test.js",
|
|
45
45
|
"notes-page.test.js",
|
|
46
46
|
"notes-conflicts.test.js",
|
|
47
|
+
"notes-resume.test.js",
|
|
47
48
|
"proactive-prepare.test.js",
|
|
48
49
|
"learn.test.js",
|
|
49
50
|
"session-flag.test.js",
|
|
@@ -107,6 +108,7 @@
|
|
|
107
108
|
"pm-r29-newsession-alignment-test.js",
|
|
108
109
|
"pm-live-notes-test.js",
|
|
109
110
|
"pm-notes-menu-test.js",
|
|
111
|
+
"pm-record-control-test.js",
|
|
110
112
|
"pm-r30-test.js",
|
|
111
113
|
"pm-breakdown-test.js",
|
|
112
114
|
"pm-breakdown-phone-test.js",
|
|
@@ -152,6 +154,7 @@
|
|
|
152
154
|
"notes-highlight.test.js": 1,
|
|
153
155
|
"notes-page.test.js": 1,
|
|
154
156
|
"notes-conflicts.test.js": 1,
|
|
157
|
+
"notes-resume.test.js": 8,
|
|
155
158
|
"proactive-prepare.test.js": 9,
|
|
156
159
|
"learn.test.js": 3,
|
|
157
160
|
"session-flag.test.js": 1,
|
|
@@ -209,6 +212,7 @@
|
|
|
209
212
|
"pm-r29-newsession-alignment-test.js": 8,
|
|
210
213
|
"pm-live-notes-test.js": 24,
|
|
211
214
|
"pm-notes-menu-test.js": 10,
|
|
215
|
+
"pm-record-control-test.js": 12,
|
|
212
216
|
"pm-r30-test.js": 4,
|
|
213
217
|
"ui-test.js": 30,
|
|
214
218
|
"plan-items.test.js": 1,
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "Initiative today-goals-jobs, the client half: goals own jobs, who acts on each job, the link control and the meeting's goal block, in a browser against a real server.",
|
|
3
|
+
"panels": ["pm-today-goals-jobs-test.js"],
|
|
4
|
+
"seconds": { "pm-today-goals-jobs-test.js": 40 }
|
|
5
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "Initiative today-goals-jobs, slice C: a Slack, Confluence or Figma mention draws as the card of its service, from the brief item's own fields, with no way to post to, resolve in or react in the service.",
|
|
3
|
+
"panels": ["pm-today-mentions-cards-test.js"],
|
|
4
|
+
"seconds": { "pm-today-mentions-cards-test.js": 25 }
|
|
5
|
+
}
|