create-pmos 0.117.0 → 0.118.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +1 -1
- 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 +250 -80
- 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/topbar.js +71 -32
- package/workspace/public/pm/view-notes.js +142 -73
- package/workspace/test-files.json +4 -0
|
@@ -19,11 +19,12 @@
|
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
import { el, setChildren } from './dom.js';
|
|
22
|
-
import { button, needsDot, needsCount } from './primitives.js';
|
|
22
|
+
import { button, iconButton, menu, needsDot, needsCount } from './primitives.js';
|
|
23
23
|
import { COPY } from './frame-copy.js';
|
|
24
24
|
import * as dock from './note-dock.js';
|
|
25
|
-
import { iconButton } from './primitives.js';
|
|
26
25
|
import { el as h } from './dom.js';
|
|
26
|
+
import { upcomingToday, upcomingWithoutNote } from './notes-links.js';
|
|
27
|
+
import { contextOf, peopleOf } from './notes-context.js';
|
|
27
28
|
|
|
28
29
|
/** The destinations the bar names, in order. Settings sits at the end of the bar, after Sync. Docs joined the bar at the
|
|
29
30
|
* PM's word (2026-10-06, docs-three-way-sync: "I thought Docs is one of the main menu"). People joined after Docs when the
|
|
@@ -57,8 +58,9 @@ export function createTopbar({ go, openPalette, openView, repoMenu, sync } = {})
|
|
|
57
58
|
const dot = needsDot();
|
|
58
59
|
const links = {};
|
|
59
60
|
for (const id of [...BAR_DESTS, 'settings']) {
|
|
60
|
-
|
|
61
|
-
|
|
61
|
+
// icon only (meeting-notes Run 13): the place's name is its tooltip and its aria-label
|
|
62
|
+
links[id] = iconButton({
|
|
63
|
+
icon: NAV_ICON[id], ariaLabel: COPY.nav[id],
|
|
62
64
|
attrs: { class: 'fr-link', 'data-fr-dest': id, 'aria-current': 'false' },
|
|
63
65
|
onClick: () => go && go(id),
|
|
64
66
|
});
|
|
@@ -80,42 +82,80 @@ export function createTopbar({ go, openPalette, openView, repoMenu, sync } = {})
|
|
|
80
82
|
}
|
|
81
83
|
const nav = el('nav', { class: 'fr-nav', 'aria-label': COPY.nav.label }, BAR_DESTS.map((id) => links[id]));
|
|
82
84
|
|
|
83
|
-
const search =
|
|
84
|
-
|
|
85
|
+
const search = iconButton({
|
|
86
|
+
icon: 'search', ariaLabel: COPY.search + ' (' + COPY.searchKey + ')',
|
|
85
87
|
attrs: { class: 'fr-search', 'data-fr': 'search', 'aria-keyshortcuts': 'Meta+K Control+K' },
|
|
86
88
|
onClick: () => openPalette && openPalette(),
|
|
87
89
|
});
|
|
88
|
-
search.appendChild(el('kbd', { class: 'fr-kbd' }, COPY.searchKey));
|
|
89
90
|
|
|
90
|
-
/* The Record control (meeting-notes Run
|
|
91
|
-
*
|
|
91
|
+
/* The Record control (meeting-notes Run 13, prototype/meeting-notes/record-control-mockup.html). Two groups, always in the same
|
|
92
|
+
* place. Left, only while a meeting is recording or paused: the chip that names it, then Highlight, Pause and Stop (recording)
|
|
93
|
+
* or Resume and Stop (paused). Nothing to resume: no left group. Right, always: New (an untitled note) and an arrow that lists
|
|
94
|
+
* today's meetings that have not started; both are unavailable while a meeting is recording. The highlight's pop-up hangs
|
|
95
|
+
* under the control. */
|
|
92
96
|
const recSlot = el('div', { class: 'fr-rec', 'data-fr': 'record' });
|
|
93
97
|
let recKey = '';
|
|
94
98
|
let recTick = null;
|
|
95
|
-
const
|
|
99
|
+
const left = (until) => Math.max(1, Math.ceil((until - Date.now()) / 60000));
|
|
100
|
+
const chipText = (st) => (st.mode === 'recording' ? (st.started ? fmtClock(Date.now() - st.started) : '') : st.until ? 'paused · ' + left(st.until) + ' min left' : 'paused');
|
|
101
|
+
const tip = (b, text) => { b.title = text; b.setAttribute('aria-label', text); return b; };
|
|
102
|
+
const off = (b, why) => { b.setAttribute('aria-disabled', 'true'); b.classList.add('fr-rec__off'); tip(b, why); return b; };
|
|
103
|
+
const chipOf = (st) => {
|
|
104
|
+
const clock = h('span', { class: 'fr-rec__clock', 'data-fr-rec-clock': '' }, chipText(st));
|
|
105
|
+
const dotEl = h('span', { class: 'fr-rec-dot' + (st.mode === 'paused' ? ' fr-rec-dot--paused' : ''), 'aria-hidden': 'true' });
|
|
106
|
+
const b = button({ label: h('span', { class: 'fr-rec__txt' }, dotEl, h('b', { class: 'fr-rec__title' }, st.title), clock), variant: 'ghost',
|
|
107
|
+
attrs: { class: 'fr-rec__seg fr-rec__chip', 'data-fr-rec': st.mode, 'aria-label': (st.mode === 'recording' ? 'Recording ' : 'Paused: ') + st.title + '. Open the note.' }, onClick: () => dock.openDock(st.id) });
|
|
108
|
+
return b;
|
|
109
|
+
};
|
|
96
110
|
function drawRecord() {
|
|
97
111
|
const st = dock.recordState();
|
|
98
|
-
const
|
|
99
|
-
const key = st.mode
|
|
112
|
+
const hl = st.mode === 'recording' ? dock.highlightBusy() : null;
|
|
113
|
+
const key = [st.mode, st.id, st.title, hl ? hl.state + hl.open : ''].join(':');
|
|
100
114
|
if (key === recKey) return;
|
|
101
115
|
recKey = key;
|
|
102
116
|
clearInterval(recTick); recTick = null;
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
117
|
+
const kids = [];
|
|
118
|
+
const recording = st.mode === 'recording';
|
|
119
|
+
if (st.mode !== 'idle') {
|
|
120
|
+
const chip = chipOf(st);
|
|
121
|
+
const group = [chip];
|
|
122
|
+
if (recording) {
|
|
123
|
+
const hlb = iconButton({ icon: 'highlighter', ariaLabel: hl ? 'Highlight in progress: show it' : 'Highlight the last 30 seconds and what is said next',
|
|
124
|
+
attrs: { class: 'fr-rec__seg' + (hl ? ' fr-rec__seg--busy' : ''), 'data-fr-rec-highlight': hl ? hl.state : '' }, onClick: () => dock.highlightNow() });
|
|
125
|
+
group.push(hlb, iconButton({ icon: 'pause', ariaLabel: 'Pause (you can resume within 1 hour)', attrs: { class: 'fr-rec__seg', 'data-fr-rec-pause': '' }, onClick: () => dock.pauseNow() }));
|
|
126
|
+
} else {
|
|
127
|
+
group.push(iconButton({ icon: 'play', ariaLabel: 'Resume ' + st.title, attrs: { class: 'fr-rec__seg fr-rec__resume', 'data-fr-rec-resume': '' }, onClick: () => dock.resumeNow() }));
|
|
128
|
+
}
|
|
129
|
+
group.push(iconButton({ icon: 'square', ariaLabel: 'Stop for good (cannot be resumed)', attrs: { class: 'fr-rec__seg fr-rec__stop', 'data-fr-rec-stop': '' }, onClick: () => dock.stopNow() }));
|
|
130
|
+
kids.push(h('div', { class: 'fr-rec__group', role: 'group', 'aria-label': (recording ? 'Recording ' : 'Paused: ') + st.title }, group));
|
|
131
|
+
const clockNode = chip.querySelector('[data-fr-rec-clock]');
|
|
132
|
+
recTick = setInterval(() => { const s2 = dock.recordState(); if (clockNode) clockNode.textContent = chipText(s2); }, 1000);
|
|
110
133
|
}
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
const
|
|
118
|
-
|
|
134
|
+
const why = 'Pause or stop the meeting being recorded to start a new one';
|
|
135
|
+
const mic = iconButton({ icon: 'mic', ariaLabel: 'Record an untitled note', attrs: { class: 'fr-rec__seg fr-rec__new', 'data-fr-rec-new': '' }, onClick: (e) => { if (recording) return; dock.recordNew(e && e.currentTarget); } });
|
|
136
|
+
const arrow = iconButton({ icon: 'chevron-down', ariaLabel: 'Record for an upcoming meeting', size: 'sm', attrs: { class: 'fr-rec__seg fr-rec__upcoming', 'data-fr-rec-upcoming': '' },
|
|
137
|
+
onClick: (e) => { if (!recording) openUpcoming(e && e.currentTarget); } });
|
|
138
|
+
if (recording) { off(mic, why); off(arrow, why); }
|
|
139
|
+
kids.push(h('div', { class: 'fr-rec__group fr-rec__group--new', role: 'group', 'aria-label': 'New recording' }, mic, arrow));
|
|
140
|
+
const panel = recording ? dock.highlightPanel() : null;
|
|
141
|
+
if (panel) kids.push(panel);
|
|
142
|
+
setChildren(recSlot, kids);
|
|
143
|
+
}
|
|
144
|
+
/** The arrow beside New: Untitled note, then today's meetings that have not started and have no note, the next one marked. */
|
|
145
|
+
async function openUpcoming(anchor) {
|
|
146
|
+
const evs = upcomingWithoutNote(await upcomingToday());
|
|
147
|
+
const fmt = (e, i) => {
|
|
148
|
+
const c = contextOf(e) || {};
|
|
149
|
+
const mins = Math.max(1, Math.round((Date.parse(e.start) - Date.now()) / 60000));
|
|
150
|
+
const inText = mins < 60 ? 'in ' + mins + ' min' : 'in ' + Math.floor(mins / 60) + ' h' + (mins % 60 ? ' ' + (mins % 60) + ' min' : '');
|
|
151
|
+
const who = peopleOf(c); const extra = c.attendees ? ' (' + c.attendees + ' attendees)' : '';
|
|
152
|
+
const bits = [i === 0 ? 'Next' : null, inText, who.length ? who.slice(0, 2).join(', ') + (who.length > 2 ? ' +' + (who.length - 2) : '') : c.attendees ? c.attendees + ' attendees' : null, c.agenda && c.agenda.length ? c.agenda.length + ' agenda item' + (c.agenda.length === 1 ? '' : 's') : null].filter(Boolean);
|
|
153
|
+
void extra;
|
|
154
|
+
return { label: e.title, hint: e.time || '', detail: bits.join(' · '), attrs: { 'data-fr-rec-event': e.id || '' }, onSelect: () => dock.recordEvent(e) };
|
|
155
|
+
};
|
|
156
|
+
const items = [{ label: 'Untitled note', icon: 'plus', detail: 'not linked to a meeting', attrs: { 'data-fr-rec-untitled': '' }, onSelect: () => dock.recordNew() }, { sep: true },
|
|
157
|
+
...(evs.length ? evs.map(fmt) : [{ label: 'No more meetings today', attrs: { 'data-fr-rec-none': '' }, onSelect: () => {} }])];
|
|
158
|
+
menu({ anchor, items, ariaLabel: 'Record a new meeting' });
|
|
119
159
|
}
|
|
120
160
|
|
|
121
161
|
let syncBtn = null;
|
|
@@ -130,11 +170,10 @@ export function createTopbar({ go, openPalette, openView, repoMenu, sync } = {})
|
|
|
130
170
|
return b;
|
|
131
171
|
}
|
|
132
172
|
function syncButton(label, busy) {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
});
|
|
173
|
+
const b = iconButton({ icon: 'refresh-cw', ariaLabel: busy ? label : COPY.sync.title || label,
|
|
174
|
+
attrs: { class: 'fr-sync', 'data-fr': 'sync', 'data-fr-sync-label': label, 'aria-busy': busy ? 'true' : 'false' }, onClick: () => { if (!busy && sync) sync(); } });
|
|
175
|
+
if (busy) { b.setAttribute('aria-disabled', 'true'); b.classList.add('fr-sync--busy'); }
|
|
176
|
+
return b;
|
|
138
177
|
}
|
|
139
178
|
|
|
140
179
|
function update(m) {
|
|
@@ -28,10 +28,11 @@
|
|
|
28
28
|
* calendar) and which initiative it belongs to, and how that was set: from the calendar event, suggested from what was said,
|
|
29
29
|
* or chosen by the PM. Change opens a sheet for each.
|
|
30
30
|
*
|
|
31
|
-
*
|
|
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 || ''; }
|
|
@@ -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,
|