create-pmos 0.240.0 → 0.242.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.
Files changed (68) hide show
  1. package/package.json +1 -1
  2. package/payload/KIT-VERSION +1 -1
  3. package/payload/planning/evals/eval-calibration.md +1 -1
  4. package/payload/planning/okrs/health-budgets.md +1 -1
  5. package/payload/state/discoveries.md +1 -1
  6. package/workspace/c3-report-structure-real-test.js +211 -0
  7. package/workspace/c3-report-structure-test.js +382 -0
  8. package/workspace/guide/console-calendar.md +14 -0
  9. package/workspace/guide/index.md +2 -0
  10. package/workspace/guide/notes.md +22 -1
  11. package/workspace/guide/report-design.md +4 -0
  12. package/workspace/guide/report-structure.md +91 -0
  13. package/workspace/guide/reports-dashboards.md +1 -1
  14. package/workspace/guide/reports-set-up.md +2 -0
  15. package/workspace/lib/charts-store.js +124 -0
  16. package/workspace/lib/headless.js +17 -3
  17. package/workspace/lib/meeting-material.js +151 -0
  18. package/workspace/lib/meeting-replay.js +170 -0
  19. package/workspace/lib/needs-providers.js +1 -0
  20. package/workspace/lib/notes-screen.js +128 -8
  21. package/workspace/lib/report-structure-needs.js +64 -0
  22. package/workspace/lib/report-structure.js +515 -0
  23. package/workspace/lib/route-modules.js +3 -0
  24. package/workspace/lib/routes-meeting-focus.js +5 -3
  25. package/workspace/lib/routes-meeting-material.js +143 -0
  26. package/workspace/lib/routes-meeting-replay.js +75 -0
  27. package/workspace/lib/routes-notes.js +37 -4
  28. package/workspace/lib/routes-report-setups.js +11 -6
  29. package/workspace/lib/routes-report-structure.js +619 -0
  30. package/workspace/package-lock.json +2 -2
  31. package/workspace/package.json +1 -1
  32. package/workspace/public/pm/c3-calendar.js +15 -3
  33. package/workspace/public/pm/c3-demo-material.js +58 -0
  34. package/workspace/public/pm/c3-demo-rd.js +4 -0
  35. package/workspace/public/pm/c3-demo.js +5 -1
  36. package/workspace/public/pm/c3-design-studio.js +7 -3
  37. package/workspace/public/pm/c3-focus.js +83 -16
  38. package/workspace/public/pm/c3-listen.css +10 -0
  39. package/workspace/public/pm/c3-material.css +22 -0
  40. package/workspace/public/pm/c3-material.js +311 -0
  41. package/workspace/public/pm/c3-notes.js +12 -4
  42. package/workspace/public/pm/c3-replay.css +127 -0
  43. package/workspace/public/pm/c3-replay.js +413 -0
  44. package/workspace/public/pm/c3-report-setup.js +3 -0
  45. package/workspace/public/pm/c3-report-structure.css +40 -0
  46. package/workspace/public/pm/c3-report-structure.js +455 -0
  47. package/workspace/public/pm/c3-reports.css +2 -0
  48. package/workspace/public/pm/c3-reports.js +22 -3
  49. package/workspace/public/pm/c3-shell.js +2 -0
  50. package/workspace/public/pm/dest-notes.js +4 -2
  51. package/workspace/public/pm/listen-sheet.js +94 -0
  52. package/workspace/public/pm/listen-sources.js +82 -0
  53. package/workspace/public/pm/notes-aside.js +62 -0
  54. package/workspace/public/pm/notes-screen-capture.js +23 -10
  55. package/workspace/public/pm/notes-screen-view.js +18 -5
  56. package/workspace/public/pm/notes-shots.js +55 -0
  57. package/workspace/public/pm/shell-recording.js +10 -2
  58. package/workspace/public/pm/view-notes.js +48 -8
  59. package/workspace/server.js +38 -9
  60. package/workspace/skills/report-source-check.skill +45 -0
  61. package/workspace/skills/report-structure-dry.skill +51 -0
  62. package/workspace/skills/report-structure.skill +61 -0
  63. package/workspace/skills/report-template.skill +10 -0
  64. package/workspace/test-lists/meeting-material.json +6 -0
  65. package/workspace/test-lists/meeting-replay.json +6 -0
  66. package/workspace/test-lists/mm-capture.json +15 -0
  67. package/workspace/test-lists/report-structure-backend.json +5 -0
  68. package/workspace/test-lists/report-structure-ui.json +11 -0
@@ -0,0 +1,94 @@
1
+ /* listen-sheet.js — the sheets Listen shows before it asks the browser to share (initiative meeting-material-replay, part 3).
2
+ *
3
+ * askListen({ shots }) → Promise<{ app, picture, shots } | null> "Where is the meeting?": Zoom app, Google Meet in Chrome or Another app,
4
+ * what PMOS listens to and sees, and Start listening or Sound only. null when the PM closes it.
5
+ * `shots: false` means Capture the shared screen is off in Settings: the sheet offers sound only.
6
+ * askWindow(getStream) → Promise<MediaStream | null> the second share: "Pick the meeting window". A browser lets a page share only
7
+ * right after a press, so the button here makes the call itself (getStream). null when skipped.
8
+ *
9
+ * The last choice is remembered in localStorage (listen-sources.js recall / remember), wrapped so a blocked store changes nothing.
10
+ * The words are the mockup's (46-capture.js). Nothing here sends anything: it only draws and answers.
11
+ */
12
+ import { el } from './dom.js';
13
+ import { button } from './primitives.js';
14
+ import { openSheet, closeSheet } from './c3-sheet.js';
15
+ import { icon, sect, pbtn, plural } from './c3-ini-ui.js';
16
+ import { APPS, planListen, rowsOf, recall, remember } from './listen-sources.js';
17
+ import { ensureListenCss } from './notes-shots.js';
18
+
19
+ const store = () => { try { return window.localStorage; } catch { return null; } };
20
+
21
+ const tag = (t, cls) => el('span', { class: 'tag' + (cls ? ' ' + cls : '') }, t);
22
+ function row(r, act) {
23
+ return el('li', { class: 'nrow', 'data-ls-row': r.icon },
24
+ el('span', { class: 'ki' + (r.ok ? '' : ' hot') }, icon(r.icon)),
25
+ el('div', { class: 'nb' }, el('span', { class: 'tt' }, r.title), el('div', { class: 'chips' }, r.tags.map((t, k) => (k === 0 && r.ok ? el('span', { class: 'tag lsn-ok' }, icon('check', 'sm'), t) : tag(t, !r.ok && k === 0 ? 'lsn-warn' : ''))))),
26
+ act ? el('div', { class: 'acts' }, act) : el('span'));
27
+ }
28
+
29
+ export function askListen({ shots = true } = {}) {
30
+ ensureListenCss();
31
+ return new Promise((resolve) => {
32
+ const c = recall(store());
33
+ if (!shots) c.shots = false;
34
+ let done = false;
35
+ const finish = (v) => { if (done) return; done = true; resolve(v); };
36
+ const body = el('div', { class: 'lsn', 'data-ls': 'listen' });
37
+ openSheet({ title: 'Listen to the meeting', icon: 'mic', wide: true, body, onClose: () => finish(null) });
38
+ const go = (withShots) => {
39
+ const choice = { app: c.app, picture: c.picture, shots: withShots };
40
+ remember(store(), { app: c.app, picture: c.picture, shots: true }); // the place, not the pictures: Sound only is for this meeting
41
+ finish(choice);
42
+ closeSheet();
43
+ };
44
+ const draw = () => {
45
+ const plan = planListen(c);
46
+ const meet = c.app === 'meet';
47
+ const seg = el('div', { class: 'seg', role: 'group', 'aria-label': 'Meeting app' }, APPS.map((a) => button({ label: a.label, variant: 'ghost', size: 'sm', onClick: () => { c.app = a.id; draw(); }, attrs: { 'aria-pressed': String(c.app === a.id), 'data-ls-app': a.id } })));
48
+ const rows = rowsOf(plan).map((r) => {
49
+ if (r.icon === 'eye' && !meet && c.shots) return row(r, pbtn('ghost', 'Change', null, { 'data-ls-pic': 'screen', onclick: () => { c.picture = 'screen'; draw(); } }));
50
+ if (r.icon === 'alert') return row(r, pbtn('outline', 'Use the meeting window', null, { 'data-ls-pic': 'window', onclick: () => { c.picture = 'window'; draw(); } }));
51
+ return row(r);
52
+ });
53
+ const n = plan.shares.length;
54
+ const help = !c.shots ? 'PMOS takes no pictures. It still hears the meeting.'
55
+ : meet ? 'With the PMOS extension in Chrome, Listen starts on the Meet tab in one click, with no sharing window.'
56
+ : c.picture === 'window' ? 'Pick the meeting window in the second sharing window. When you present, the call shows your slides, so the shots keep them too. Your other apps are never seen.'
57
+ : 'Shots of other apps are set aside after the meeting for you to keep or delete. They never reach the note, the replay or the agent.';
58
+ body.replaceChildren(
59
+ el('p', { class: 'dim cal3-note' }, 'The browser asks you to pick what to share' + (n > 1 ? ', once for each line below' : '') + '. Nothing leaves this machine.'),
60
+ sect({ ic: 'cal', title: 'Where is the meeting?', right: seg }),
61
+ sect({ ic: 'eye', title: 'What PMOS listens to and sees', right: el('span', { class: 'dim mono' }, plural(n, 'share')) },
62
+ el('ul', { class: 'list', role: 'list' }, ...rows), el('p', { class: 'dim lsn-help', 'data-ls-help': '' }, help)),
63
+ sect({ ic: 'clock', title: 'While it runs' },
64
+ el('ul', { class: 'list', role: 'list' },
65
+ row({ icon: 'stop', title: 'Pause shots', tags: ['stops the pictures, keeps the sound', 'on the REC chip and in Focus'], ok: false }),
66
+ row({ icon: 'lock', title: 'Private', tags: ['stops sound and pictures until you resume'], ok: false }))),
67
+ el('div', { class: 'iacts' },
68
+ pbtn('primary', 'Start listening', 'mic', { 'data-ls-go': 'start', onclick: () => go(c.shots) }),
69
+ shots ? pbtn('ghost', 'Sound only, no shots', null, { 'data-ls-go': 'sound', onclick: () => go(false) }) : null));
70
+ };
71
+ draw();
72
+ });
73
+ }
74
+
75
+ /** The second share. The button makes the browser call itself, because that call needs a press of its own. */
76
+ export function askWindow(getStream) {
77
+ ensureListenCss();
78
+ return new Promise((resolve) => {
79
+ let done = false;
80
+ const finish = (v) => { if (done) return; done = true; resolve(v); };
81
+ const msg = el('p', { class: 'dim lsn-help', role: 'status', 'data-ls-msg': '' }, 'The sound is on. Now pick the meeting\'s window, so PMOS can take shots of it only. Your other apps are never seen.');
82
+ const pick = pbtn('primary', 'Pick the meeting window', 'eye', { 'data-ls-pick': '', onclick: async () => {
83
+ pick.disabled = true;
84
+ let s = null;
85
+ try { s = await getStream(); } catch { s = null; }
86
+ if (!s) { msg.textContent = 'No window was picked. Pick it again, or listen to the sound only.'; pick.disabled = false; return; }
87
+ finish(s);
88
+ closeSheet();
89
+ } });
90
+ const body = el('div', { class: 'lsn', 'data-ls': 'window' }, msg,
91
+ el('div', { class: 'iacts' }, pick, pbtn('ghost', 'Sound only, no shots', null, { 'data-ls-skip': '', onclick: () => { finish(null); closeSheet(); } })));
92
+ openSheet({ title: 'Pick the meeting window', icon: 'eye', body, onClose: () => finish(null) });
93
+ });
94
+ }
@@ -0,0 +1,82 @@
1
+ /* listen-sources.js — where the meeting is, and which browser shares Listen asks for (initiative meeting-material-replay, part 3).
2
+ *
3
+ * APPS the three places a meeting can be: Zoom app, Google Meet in Chrome, Another app
4
+ * normalize(choice) → { app, picture, shots } a choice with every field in its range
5
+ * planListen(choice) → plan the shares to ask for and what each one is used for
6
+ * surfaceOf(track, planned) → 'window' | 'browser' | 'monitor' | null what the picture really is
7
+ * recall(storage) · remember(storage, choice) the last choice, kept on this machine
8
+ * rowsOf(plan) → [{ icon, title, tags, ok }] the lines the Listen sheet shows
9
+ *
10
+ * WHY SEPARATE SHARES. Only the whole screen carries the sound of Zoom, but its picture holds every app the PM switches to. So for Zoom
11
+ * or another app the SOUND comes from one share of the whole screen with system audio (its picture is never used) and the PICTURE from a
12
+ * second share of the meeting's window only, which stays captured while the PM works in other apps. The second share needs its own press,
13
+ * and it can be skipped (sound only). Google Meet is a tab: one share of that tab carries both. The PM can still choose the whole screen
14
+ * as the picture; then the safety net in lib/notes-screen.js sets aside the shots that look like another app.
15
+ *
16
+ * Pure: no DOM and no network, so the unit test (listen-sources.test.js) imports it from node.
17
+ */
18
+ export const APPS = [
19
+ { id: 'zoom', label: 'Zoom app' },
20
+ { id: 'meet', label: 'Google Meet in Chrome' },
21
+ { id: 'other', label: 'Another app' },
22
+ ];
23
+ export const KEY = 'pmos.listen.where';
24
+ const SURFACES = ['window', 'browser', 'monitor'];
25
+ const AUDIO = { echoCancellation: false, noiseSuppression: false, autoGainControl: false };
26
+
27
+ /** The three options of one choice, each in its range. The default is the Zoom app with the picture from its window. */
28
+ export function normalize(c) {
29
+ const o = c && typeof c === 'object' ? c : {};
30
+ return {
31
+ app: APPS.some((a) => a.id === o.app) ? o.app : 'zoom',
32
+ picture: o.picture === 'screen' ? 'screen' : 'window',
33
+ shots: o.shots !== false,
34
+ };
35
+ }
36
+
37
+ /** getDisplayMedia options for one share. Chrome ignores a hint it does not know. */
38
+ export function shareOptions(role, app) {
39
+ if (role === 'picture') return { video: { displaySurface: 'window' }, audio: false, monitorTypeSurfaces: 'exclude', surfaceSwitching: 'exclude', selfBrowserSurface: 'exclude' };
40
+ if (app === 'meet') return { video: { displaySurface: 'browser' }, audio: { ...AUDIO }, monitorTypeSurfaces: 'exclude', surfaceSwitching: 'include', selfBrowserSurface: 'exclude' };
41
+ return { video: { displaySurface: 'monitor' }, audio: { ...AUDIO }, systemAudio: 'include', monitorTypeSurfaces: 'include', surfaceSwitching: 'include', selfBrowserSurface: 'exclude' };
42
+ }
43
+
44
+ /** The shares Listen asks for. Each share says its role ('sound' | 'picture' | 'both'), whether its picture and its sound are used,
45
+ * and whether the PM may skip it. `surface` is what the pictures show: the meeting's window, the Meet tab, the whole screen, or null (no pictures). */
46
+ export function planListen(choice) {
47
+ const c = normalize(choice);
48
+ const share = (role, keepVideo, keepAudio, optional = false) => ({ role, options: shareOptions(role, c.app), keepVideo, keepAudio, optional });
49
+ if (c.app === 'meet') return { ...c, shares: [share('both', c.shots, true)], surface: c.shots ? 'browser' : null };
50
+ if (!c.shots) return { ...c, shares: [share('sound', false, true)], surface: null };
51
+ if (c.picture === 'screen') return { ...c, shares: [share('both', true, true)], surface: 'monitor' };
52
+ return { ...c, shares: [share('sound', false, true), share('picture', true, false, true)], surface: 'window' };
53
+ }
54
+
55
+ /** What the picture really is: the browser's own word for the share when it gives one, else what was asked for. Null when neither is known. */
56
+ export function surfaceOf(track, planned) {
57
+ let s = null;
58
+ try { s = track && typeof track.getSettings === 'function' ? track.getSettings().displaySurface : null; } catch { s = null; }
59
+ if (SURFACES.includes(s)) return s;
60
+ return SURFACES.includes(planned) ? planned : null;
61
+ }
62
+
63
+ /** The last choice, from a Storage (localStorage). Anything wrong with storage gives the default. */
64
+ export function recall(storage) {
65
+ try { const raw = storage && storage.getItem(KEY); return normalize(raw ? JSON.parse(raw) : null); } catch { return normalize(null); }
66
+ }
67
+ export function remember(storage, choice) {
68
+ try { if (!storage) return false; storage.setItem(KEY, JSON.stringify(normalize(choice))); return true; } catch { return false; }
69
+ }
70
+
71
+ /** The lines the sheet shows for a choice: what is heard and what is seen. */
72
+ export function rowsOf(plan) {
73
+ const p = plan && plan.shares ? plan : planListen(plan);
74
+ const win = p.app === 'zoom' ? 'Zoom Meeting' : 'meeting app\'s';
75
+ if (p.app === 'meet') {
76
+ return [{ icon: 'mic', title: p.shots ? 'Sound and picture: the Meet tab' : 'Sound: the Meet tab', tags: ['tab audio on', p.shots ? 'only this tab is seen' : 'no pictures'], ok: true }];
77
+ }
78
+ const sound = { icon: 'vol', title: 'Sound: Entire screen, with system audio', tags: ['hears every app, so ' + (p.app === 'zoom' ? 'Zoom' : 'your meeting app') + ' too', p.picture === 'screen' && p.shots ? 'its picture is kept' : 'its picture is not kept'], ok: true };
79
+ if (!p.shots) return [sound, { icon: 'eye', title: 'Picture: none', tags: ['sound only, no shots'], ok: true }];
80
+ if (p.picture === 'screen') return [sound, { icon: 'alert', title: 'Picture: Entire screen', tags: ['every app you switch to is on it', 'PMOS keeps only shots that look like the call'], ok: false }];
81
+ return [sound, { icon: 'eye', title: 'Picture: the ' + win + ' window', tags: ['only this window is seen', 'still captured while you are in other apps'], ok: true }];
82
+ }
@@ -0,0 +1,62 @@
1
+ /* notes-aside.js — the review of the shots a note set aside as another app (initiative meeting-material-replay, part 3).
2
+ *
3
+ * openAside(noteId) → Promise<sheet | null> "Set aside: shots of other apps": each shot with its time and the app it looked like, and
4
+ * Keep it, Delete and Delete all N. Opens nothing when the note has none.
5
+ * asideCount(noteId) → Promise<number> how many shots the note has set aside
6
+ *
7
+ * Also on `window.NotesAside` ({ open, count }), which notes-shots.js defines at once and loads this module when it is first used, so the
8
+ * Replay page and Calendar › Notes call `NotesAside.open(id)` without importing it. A change fires `pmos-aside` ({ detail: { id, aside } }).
9
+ *
10
+ * When the picture of a meeting was the whole screen, lib/notes-screen.js sets aside the shots whose text looks like another app. They are
11
+ * not in the note, the replay or anything the agent reads. Keep it puts a shot back; Delete removes the file; a shot is deleted by itself
12
+ * after 7 days.
13
+ */
14
+ import { el } from './dom.js';
15
+ import { pmGet, pmPost } from './http.js';
16
+ import { openSheet } from './c3-sheet.js';
17
+ import { icon, pbtn } from './c3-ini-ui.js';
18
+ import { toast } from './c3-toast.js';
19
+ import { ensureListenCss } from './notes-shots.js';
20
+ import { fmt } from './notes-screen-view.js';
21
+
22
+ const srcOf = (id, t) => '/api/notes/screen?id=' + encodeURIComponent(id) + '&t=' + Math.round(t) + '&aside=1';
23
+ const tell = (id, aside) => { try { window.dispatchEvent(new CustomEvent('pmos-aside', { detail: { id, aside } })); } catch { /* no window */ } };
24
+
25
+ export async function asideCount(id) {
26
+ const r = await pmGet('/api/notes/screen-aside?id=' + encodeURIComponent(id)).catch(() => null);
27
+ return r && r.ok ? (r.frames || []).length : 0;
28
+ }
29
+
30
+ export async function openAside(id) {
31
+ ensureListenCss();
32
+ const r = await pmGet('/api/notes/screen-aside?id=' + encodeURIComponent(id)).catch(() => null);
33
+ if (!r || !r.ok) { toast({ text: (r && r.error) || 'The set-aside shots could not be read.', tone: 'error' }); return null; }
34
+ let frames = r.frames || [];
35
+ const days = r.days || 7;
36
+ const body = el('div', { class: 'lsn', 'data-ls': 'aside' });
37
+ const sheet = openSheet({ title: 'Set aside: shots of other apps', icon: 'eye', wide: true, body });
38
+ const answer = async (action, t) => {
39
+ const out = await pmPost('/api/notes/screen-aside', { id, action, ...(t !== undefined ? { t } : {}) });
40
+ if (!out || !out.ok) { toast({ text: (out && out.error) || 'That did not work. Try again.', tone: 'error' }); return; }
41
+ const fresh = await pmGet('/api/notes/screen-aside?id=' + encodeURIComponent(id)).catch(() => null);
42
+ frames = fresh && fresh.ok ? fresh.frames || [] : frames.filter((f) => (action === 'delete-all' ? false : f.t !== t));
43
+ tell(id, frames.length);
44
+ if (action === 'delete-all') { sheet.close(); toast('Deleted. They are gone from this machine.'); return; }
45
+ toast(action === 'keep' ? 'Kept. It joins the replay at its time.' : 'Deleted from this machine.');
46
+ draw();
47
+ };
48
+ const draw = () => {
49
+ const rows = frames.map((f) => el('li', { class: 'nrow lsn-aside', 'data-aside': String(f.t) },
50
+ el('img', { class: 'lsn-thumb', src: srcOf(id, f.t), alt: '', width: 148, height: 84, loading: 'lazy' }),
51
+ el('div', { class: 'nb' }, el('span', { class: 'tt' }, (f.app || 'Another app') + (f.text ? ' · ' + f.text.slice(0, 60) : '')),
52
+ el('div', { class: 'chips' }, el('span', { class: 'tag mono' }, fmt(f.t)), el('span', { class: 'tag lsn-warn' }, 'looked like ' + (f.app || 'another app') + ', not the call'))),
53
+ el('div', { class: 'acts' }, pbtn('ghost', 'Keep it', null, { 'data-aside-keep': String(f.t), onclick: () => answer('keep', f.t) }), pbtn('outline', 'Delete', 'trash', { 'data-aside-delete': String(f.t), onclick: () => answer('delete', f.t) }))));
54
+ body.replaceChildren(
55
+ el('p', { class: 'dim cal3-note' }, 'This meeting was shared as the whole screen, so PMOS checked each shot. ' + (frames.length === 1 ? 'This one' : 'These ' + frames.length) + ' did not look like the call or anything shared in it. They are not in the replay, the note or what the agent reads. Next time, share the meeting window as the picture and nothing like this is taken.'),
56
+ frames.length ? el('ul', { class: 'list', role: 'list' }, ...rows) : el('div', { class: 'fold', 'data-aside-none': '' }, icon('check', 'sm'), 'Nothing is set aside.'),
57
+ frames.length ? el('div', { class: 'iacts' }, pbtn('primary', 'Delete all ' + frames.length, 'trash', { 'data-aside-all': '', onclick: () => answer('delete-all') })) : null,
58
+ el('p', { class: 'dim lsn-help' }, 'Set-aside shots are deleted on their own after ' + days + ' days. Kept ones join the replay at their time.'));
59
+ };
60
+ draw();
61
+ return sheet;
62
+ }
@@ -1,12 +1,15 @@
1
1
  /* notes-screen-capture.js — screenshots of the shared screen while a note listens (notes-screen-capture).
2
2
  *
3
- * startScreen({ track, id, cap, clock, post, … }) → { stop(), cue(t, why), hear(lines), count, lost } take shots of one shared screen
3
+ * startScreen({ track, id, cap, clock, post, surface?, … }) → { stop(), cue(t, why), hear(lines), setPaused(bool), paused, count, lost } take shots of one shared picture
4
4
  * cueOf(line) → why | null does a transcript line point at the screen, or decide something? Pure.
5
5
  * greyOf(source, w, h, canvas?) → Uint8Array a small grey copy of a frame, for the comparison
6
6
  * changed(prev, cur, { pixel?, share? }) → boolean true when enough of the picture changed. Pure.
7
7
  *
8
- * WHERE THE PICTURE COMES FROM. Listen already asks the browser to share the screen, to hear the meeting (view-notes.js
9
- * openSource). That share carries the picture too. The browser's own sharing window is the PM's permission, and the browser
8
+ * WHERE THE PICTURE COMES FROM. Listen asks the browser for the meeting (view-notes.js openSource, listen-sources.js): for Meet one tab
9
+ * share carries the sound and the picture; for Zoom or another app the picture is a second share of the meeting's window, and the
10
+ * whole screen is used for sound only. `surface` says what the picture shows; the server keeps it with each shot (lib/notes-screen.js).
11
+ * Pause shots (notes-shots.js) stops the pictures and keeps the sound.
12
+ * Before that, Listen asked for one share and its picture was the whole screen. The browser's own sharing window is the PM's permission, and the browser
10
13
  * shows that it is sharing for as long as the track is live. This module never asks for a screen of its own.
11
14
  *
12
15
  * WHEN IT KEEPS A SHOT. It follows the meeting, not a timer. Every `every` ms (2 s) the frame is drawn into a 64 × 36 grey copy.
@@ -25,6 +28,8 @@
25
28
  *
26
29
  * WHAT IT NEVER DOES. It sends a shot only to this workspace's own server (`post`), never anywhere else.
27
30
  */
31
+ import { bind, unbind } from './notes-shots.js';
32
+
28
33
  const SMALL_W = 64;
29
34
  const SMALL_H = 36;
30
35
  const MAX_W = 1600;
@@ -68,7 +73,7 @@ export function cueOf(line) {
68
73
  return null;
69
74
  }
70
75
 
71
- export function startScreen({ track, id, cap, clock, post, every = 2000, slowGap = 15000, ringMs = RING_MS }) {
76
+ export function startScreen({ track, id, cap, clock, post, every = 2000, slowGap = 15000, ringMs = RING_MS, surface = null }) {
72
77
  const video = document.createElement('video');
73
78
  video.muted = true; video.playsInline = true;
74
79
  video.srcObject = new MediaStream([track]);
@@ -79,7 +84,7 @@ export function startScreen({ track, id, cap, clock, post, every = 2000, slowGap
79
84
  video.play().catch(() => {});
80
85
  const small = document.createElement('canvas');
81
86
  const big = document.createElement('canvas');
82
- const s = { count: 0, lost: 0, stopped: false, last: null, lastAt: 0, prev: null, moving: 0, still: 0, run: 0, ring: [], kept: new Map(), cues: [], heard: new Set(), chain: Promise.resolve(), timer: null };
87
+ const s = { count: 0, lost: 0, stopped: false, paused: false, last: null, lastAt: 0, prev: null, moving: 0, still: 0, run: 0, ring: [], kept: new Map(), cues: [], heard: new Set(), chain: Promise.resolve(), timer: null };
83
88
 
84
89
  const snap = async (t, grey) => {
85
90
  const w = Math.min(MAX_W, video.videoWidth);
@@ -93,10 +98,10 @@ export function startScreen({ track, id, cap, clock, post, every = 2000, slowGap
93
98
  // a shot a line or a highlight points at that is already kept is posted again, so it carries that better reason
94
99
  const keep = (shot, why, cue = false) => {
95
100
  s.chain = s.chain.then(async () => {
96
- if (s.stopped || !shot) return;
101
+ if (s.stopped || s.paused || !shot) return;
97
102
  const again = cue && s.kept.has(shot.t) && !s.kept.get(shot.t).cue;
98
103
  if (!again && !changed(s.last, shot.grey)) return;
99
- const r = await post({ id, cap, t: shot.t, img: await b64(shot.blob), w: shot.w, h: shot.h, why });
104
+ const r = await post({ id, cap, t: shot.t, img: await b64(shot.blob), w: shot.w, h: shot.h, why, ...(surface ? { surface } : {}) });
100
105
  if (r && r.ok) {
101
106
  s.kept.set(shot.t, { cue });
102
107
  if (again) return;
@@ -114,7 +119,7 @@ export function startScreen({ track, id, cap, clock, post, every = 2000, slowGap
114
119
 
115
120
  let busy = false;
116
121
  const tick = async () => {
117
- if (s.stopped || busy || track.readyState !== 'live' || !video.videoWidth) return;
122
+ if (s.stopped || s.paused || busy || track.readyState !== 'live' || !video.videoWidth) return;
118
123
  busy = true;
119
124
  try {
120
125
  const now = clock();
@@ -143,6 +148,7 @@ export function startScreen({ track, id, cap, clock, post, every = 2000, slowGap
143
148
  function stop() {
144
149
  if (s.stopped) return;
145
150
  s.stopped = true;
151
+ unbind(api);
146
152
  clearInterval(s.timer);
147
153
  try { track.stop(); } catch { /* stopped */ }
148
154
  video.srcObject = null;
@@ -150,7 +156,12 @@ export function startScreen({ track, id, cap, clock, post, every = 2000, slowGap
150
156
  s.ring = [];
151
157
  }
152
158
  /** Keep the picture on screen at meeting time t, for a reason (the PM's Highlight, a cue). */
153
- function cue(t, why) { if (!s.stopped) s.cues.push({ t, why }); }
159
+ function cue(t, why) { if (!s.stopped && !s.paused) s.cues.push({ t, why }); }
160
+ /** Pause shots: no picture is taken or kept, and none from before the pause can be kept later. The sound is not touched. */
161
+ function setPaused(on) {
162
+ s.paused = !!on;
163
+ if (s.paused) { s.ring = []; s.cues = []; s.prev = null; s.moving = 0; s.still = 0; s.run = 0; }
164
+ }
154
165
  /** New transcript lines: each one that asks for the screen keeps the picture of its time, once. */
155
166
  function hear(lines) {
156
167
  for (const l of lines || []) {
@@ -161,5 +172,7 @@ export function startScreen({ track, id, cap, clock, post, every = 2000, slowGap
161
172
  if (why) cue(l.t || 0, why);
162
173
  }
163
174
  }
164
- return { stop, cue, hear, get count() { return s.count; }, get lost() { return s.lost; } };
175
+ const api = { stop, cue, hear, setPaused, get paused() { return s.paused; }, get count() { return s.count; }, get lost() { return s.lost; } };
176
+ bind(api, { surface });
177
+ return api;
165
178
  }
@@ -1,6 +1,7 @@
1
1
  /* notes-screen-view.js — the screenshots a note kept, beside its transcript (notes-screen-capture).
2
2
  *
3
- * screenStrip({ id, frames, seek?, replay? }) → <section> | null the Screen strip: one thumbnail per shot with its meeting time
3
+ * screenStrip({ id, frames, seek?, replay?, aside? }) → <section> | null the Screen strip: one thumbnail per shot with its meeting time;
4
+ * `aside` is how many shots were set aside as another app: "N set aside" opens their review (notes-aside.js)
4
5
  * shotFor(frames, from, to) → frame | null the shot that came up while a line was said, [from, to)
5
6
  * shotButton({ id, frames, frame, seek? }) → <button> a small thumbnail that opens one shot (beside a transcript line)
6
7
  * openShot({ id, frames, index, seek? }) the shot large: its time, why it was kept, its text, Previous, Next, Play from here
@@ -88,11 +89,23 @@ function follow(replay, id) {
88
89
  replay.addEventListener('seeked', mark);
89
90
  }
90
91
 
91
- export function screenStrip({ id, frames, seek, replay }) {
92
- if (!frames || !frames.length) return null;
92
+ /** "N set aside": a button that opens the review of the shots set aside as another app. It follows the count when the review changes it. */
93
+ function asideLink(id, count) {
94
+ const b = button({ label: '', variant: 'ghost', size: 'sm', attrs: { 'data-nt-aside': id, title: 'Shots that looked like another app, not the call. Keep or delete them.' },
95
+ onClick: () => { if (typeof window !== 'undefined' && window.NotesAside) window.NotesAside.open(id); } });
96
+ const set = (n) => { b.textContent = n + ' set aside'; b.hidden = n < 1; };
97
+ set(count);
98
+ const on = (e) => { if (!b.isConnected) { window.removeEventListener('pmos-aside', on); return; } if (e.detail && e.detail.id === id) set(e.detail.aside); };
99
+ window.addEventListener('pmos-aside', on);
100
+ return b;
101
+ }
102
+
103
+ export function screenStrip({ id, frames, seek, replay, aside = 0 }) {
104
+ const none = !frames || !frames.length;
105
+ if (none && !(aside > 0)) return null;
93
106
  follow(replay, id);
94
107
  return el('section', { class: 'nt-screen', 'data-nt-screen': id },
95
- el('h3', { class: 'nt-sec__h' }, 'Screen · ' + frames.length + (frames.length === 1 ? ' shot' : ' shots')),
96
- el('div', { class: 'nt-screen__strip', role: 'list' },
108
+ el('h3', { class: 'nt-sec__h' }, none ? 'Screen' : 'Screen · ' + frames.length + (frames.length === 1 ? ' shot' : ' shots'), aside > 0 ? [' ', asideLink(id, aside)] : null),
109
+ none ? null : el('div', { class: 'nt-screen__strip', role: 'list' },
97
110
  frames.map((f, k) => el('div', { role: 'listitem' }, thumb({ id, f, label: fmt(f.t), cls: 'nt-shot', onClick: () => openShot({ id, frames, index: k, seek }), attrs: { 'data-nt-shot': String(f.t) } })))));
98
111
  }
@@ -0,0 +1,55 @@
1
+ /* notes-shots.js — Pause shots: the one switch for the pictures of the meeting being recorded (initiative meeting-material-replay, part 3).
2
+ *
3
+ * active() → boolean a capture is taking shots now (false when Listen has no picture, or nothing listens)
4
+ * paused() → boolean the pictures are paused; the sound goes on
5
+ * setPaused(bool) → boolean pause or resume the pictures; returns the new state (false when no capture is active)
6
+ * surface() → 'window' | 'browser' | 'monitor' | null what the pictures show
7
+ * chipText() · chipTitle() the words for the Pause-shots chip in Focus and on the recording chip
8
+ * bind(handle, { surface }) · unbind(handle) notes-screen-capture.js calls these when a capture starts and stops
9
+ *
10
+ * Also on `window.NotesShots` ({ active, paused, setPaused, surface }) so any page can read it. A change fires `pmos-shots` on window.
11
+ * The state lives in memory only: a new capture starts with the pictures on.
12
+ */
13
+ let handle = null;
14
+ let isPaused = false;
15
+ let shown = null;
16
+
17
+ const emit = () => { try { window.dispatchEvent(new CustomEvent('pmos-shots')); } catch { /* no window */ } };
18
+
19
+ export function bind(h, { surface = null } = {}) { handle = h; isPaused = false; shown = surface; emit(); }
20
+ export function unbind(h) { if (handle !== h) return; handle = null; isPaused = false; shown = null; emit(); }
21
+ export const active = () => !!handle;
22
+ export const paused = () => !!handle && isPaused;
23
+ export const surface = () => (handle ? shown : null);
24
+ export function setPaused(on) {
25
+ if (!handle) return false;
26
+ isPaused = !!on;
27
+ try { handle.setPaused(isPaused); } catch { /* the capture ended */ }
28
+ emit();
29
+ return isPaused;
30
+ }
31
+
32
+ /** Link c3-listen.css once: the Listen sheet, the set-aside review and the chip. */
33
+ export function ensureListenCss() {
34
+ if (typeof document === 'undefined' || document.querySelector('link[data-c3-listen]')) return;
35
+ const l = document.createElement('link');
36
+ l.rel = 'stylesheet'; l.href = new URL('./c3-listen.css', import.meta.url).href; l.setAttribute('data-c3-listen', '');
37
+ document.head.appendChild(l);
38
+ }
39
+
40
+ const WHAT ={ window: 'meeting window', browser: 'Meet tab', monitor: 'whole screen' };
41
+ /** The chip's words: what the shots show, or that they are paused. */
42
+ export const chipText = () => (paused() ? 'Shots paused' : 'Shots: ' + (WHAT[shown] || 'on'));
43
+ export const chipTitle = () => (paused()
44
+ ? 'Shots are paused. The sound is still recorded. Press to take shots again.'
45
+ : 'Press to pause the shots, for example before you show something private. The sound is still recorded.');
46
+
47
+ if (typeof window !== 'undefined') window.NotesShots = { active, paused, setPaused, surface };
48
+
49
+ // The review of the shots a note set aside (notes-aside.js), loaded when first used, so a page calls NotesAside.open(id) without importing it.
50
+ if (typeof window !== 'undefined' && !window.NotesAside) {
51
+ window.NotesAside = {
52
+ open: (id) => import('./notes-aside.js').then((m) => m.openAside(id)),
53
+ count: (id) => import('./notes-aside.js').then((m) => m.asideCount(id)),
54
+ };
55
+ }
@@ -19,6 +19,7 @@ import { noteById } from './notes-links.js';
19
19
  import { recView, clockOf } from './shell-model.js';
20
20
  import { reducedMotion } from './shell-ticker.js';
21
21
  import { toast } from './c3-toast.js';
22
+ import * as Shots from './notes-shots.js';
22
23
 
23
24
  /* the red dot sits on the Notes tab (the app's frame) and, on /c3.html, on the Calendar tab, where the meeting is recorded (the mockup's
24
25
  * `#tabbtns [data-k=cal]` .tabdot, line 4395) */
@@ -142,7 +143,7 @@ export function disposeRecording(host) {
142
143
 
143
144
  const NS_SVG = 'http://www.w3.org/2000/svg';
144
145
  const glyph = (d) => { const s = document.createElementNS(NS_SVG, 'svg'); s.setAttribute('class', 'ic sm'); s.setAttribute('viewBox', '0 0 24 24'); s.setAttribute('aria-hidden', 'true'); const p = document.createElementNS(NS_SVG, 'path'); p.setAttribute('d', d); s.appendChild(p); return s; };
145
- const RGL = { highlighter: () => c3ic('flag', 'sm'), pause: () => glyph('M8 5v14M16 5v14'), play: () => c3ic('play', 'sm'), square: () => c3ic('stop', 'sm'), mic: () => c3ic('mic', 'sm'), focus: () => c3ic('target', 'sm'), down: () => glyph('M6 9l6 6 6-6') };
146
+ const RGL = { highlighter: () => c3ic('flag', 'sm'), pause: () => glyph('M8 5v14M16 5v14'), play: () => c3ic('play', 'sm'), square: () => c3ic('stop', 'sm'), eye: () => c3ic('eye', 'sm'), mic: () => c3ic('mic', 'sm'), focus: () => c3ic('target', 'sm'), down: () => glyph('M6 9l6 6 6-6') };
146
147
 
147
148
  /** The chip opens the note: on console3, Calendar › Notes on the During stage (calendar-views.js listens for c3-open-note);
148
149
  * elsewhere the main app, where the full note is. */
@@ -221,7 +222,7 @@ function mountReal(host) {
221
222
  if (st.mode !== 'idle' && eng.id !== st.id) { eng = { id: st.id, text: '' }; const want = st.id; engineLabel().then((t) => { if (eng.id === want && t) { eng.text = t; rec.key = ''; sync(); } }); }
222
223
  const hl = st.mode === 'recording' && dock.highlightBusy ? dock.highlightBusy() : null;
223
224
  const mt = st.mode !== 'idle' ? meetingOfNote(st.id) : null;
224
- const key = [st.mode, st.id, st.title, hl ? hl.state + hl.open : '', eng.text, mt ? 'f' + (mt.event_id || '') + mt.start : ''].join(':');
225
+ const key = [st.mode, st.id, st.title, hl ? hl.state + hl.open : '', Shots.active() ? Shots.chipText() : '', eng.text, mt ? 'f' + (mt.event_id || '') + mt.start : ''].join(':');
225
226
  if (key !== rec.key) {
226
227
  rec.key = key; rec.clock = null;
227
228
  const recording = st.mode === 'recording';
@@ -237,6 +238,13 @@ function mountReal(host) {
237
238
  if (recording) {
238
239
  group.push(btn(hl ? 'Highlight in progress: show it' : 'Highlight the last 30 seconds and what is said next', 'highlighter', () => dock.highlightNow(), { 'data-fr-rec-highlight': hl ? hl.state : '', 'data-shell-rec-mark': '' }),
239
240
  btn('Pause (you can resume within 1 hour)', 'pause', () => dock.pauseNow(), { 'data-fr-rec-pause': '', 'data-shell-rec-pause': '' }));
241
+ // Pause shots: stops the pictures and keeps the sound (notes-shots.js; the same switch is in Focus next to REC)
242
+ if (Shots.active()) {
243
+ const off = Shots.paused();
244
+ group.push(btn(off ? 'Shots are paused. The sound is still recorded. Press to take shots again.' : 'Pause shots. The sound is still recorded.', off ? 'play' : 'eye',
245
+ () => { const now = Shots.setPaused(!Shots.paused()); toast({ text: now ? 'Shots paused. The sound is still recorded.' : 'Shots back on.' }); rec.key = ''; sync(); },
246
+ { 'data-fr-rec-shots': off ? 'paused' : 'on', 'aria-pressed': String(off) }));
247
+ }
240
248
  } else group.push(btn('Resume ' + st.title, 'play', () => dock.resumeNow(), { 'data-fr-rec-resume': '', 'data-shell-rec-resume': '' }));
241
249
  group.push(btn('Open the focus page for this meeting', 'focus', () => (mt ? openFocusFor(mt) : openFocusNote(st.id, st.title)), { 'data-fr-rec-focus': '', 'data-shell-rec-focus': '' }));
242
250
  group.push(btn('Stop for good (cannot be resumed)', 'square', () => dock.stopNow(), { 'data-fr-rec-stop': '', 'data-shell-rec-stop': '' }));
@@ -49,6 +49,7 @@ import { button, iconButton, field, row, sheet, empty, menu, toast, ensureCompon
49
49
  import { toInt16, makeResampler, openCapture } from './audio-capture.js';
50
50
  import { startScreen } from './notes-screen-capture.js';
51
51
  import { screenStrip } from './notes-screen-view.js';
52
+ import { planListen, surfaceOf } from './listen-sources.js';
52
53
  import { agendaHeard, peopleOf as contextPeople } from './notes-context.js';
53
54
 
54
55
  const SAVE_MS = 700; // a pause this long in typing saves the note
@@ -262,6 +263,7 @@ async function loadNote(ctx, id) {
262
263
  r.note.__audio = r.audio || null;
263
264
  r.note.__recording = r.recording || null;
264
265
  r.note.__shots = r.screen ? r.screen.frames || [] : [];
266
+ r.note.__aside = r.screen ? r.screen.aside || 0 : 0;
265
267
  // a new line that points at the screen, or a decision or next step, keeps the picture on screen when it was said
266
268
  if (audio && audio.id === id && audio.screen) audio.screen.hear(r.note.transcript || []);
267
269
  r.note.bullets_state = r.bullets_state || null;
@@ -636,9 +638,40 @@ async function cleanNow(ctx, n) {
636
638
  }
637
639
 
638
640
  /* ── listening with local Whisper ─────────────────────────────────────────────────────────────── */
639
- async function openSource(kind, { screen = false } = {}) {
641
+ /** The meeting from the shares the Listen sheet planned (listen-sources.js): for Zoom or another app the whole screen gives the sound only and
642
+ * a second share of the meeting's window gives the picture (skippable); for Meet one tab share gives both. Its stream carries screenTrack and
643
+ * screenSurface (what the picture really is) when there are pictures. */
644
+ async function openPlanned(plan) {
645
+ const md = navigator.mediaDevices;
646
+ const first = plan.shares[0];
647
+ const s = await md.getDisplayMedia(first.options);
648
+ const tracks = s.getAudioTracks();
649
+ let video = first.keepVideo ? s.getVideoTracks()[0] || null : null;
650
+ for (const t of s.getVideoTracks()) if (t !== video) t.stop();
651
+ if (!tracks.length) {
652
+ if (video) video.stop();
653
+ throw Object.assign(new Error(plan.app === 'meet' ? 'The shared tab has no audio. Share it again, and tick "Share tab audio".' : 'The shared screen has no audio. Share it again, and tick "Also share system audio".'), { noAudio: true });
654
+ }
655
+ let surface = video ? surfaceOf(video, plan.surface) : null;
656
+ const second = plan.shares[1];
657
+ if (second && second.keepVideo) {
658
+ const { askWindow } = await import('./listen-sheet.js');
659
+ const w = await askWindow(() => md.getDisplayMedia(second.options));
660
+ if (w) { video = w.getVideoTracks()[0] || null; for (const t of w.getAudioTracks()) t.stop(); surface = video ? surfaceOf(video, plan.surface) : null; }
661
+ }
662
+ return Object.assign(new MediaStream(tracks), { screenTrack: video, screenSurface: surface });
663
+ }
664
+ /** Where is the meeting? The Listen sheet asks, once for each Listen. A browser under automation (the test suites) has no one to ask. */
665
+ async function chooseListen() {
666
+ if (typeof navigator !== 'undefined' && navigator.webdriver === true && window.__ntListenAsk !== true) return { plan: null };
667
+ const { askListen } = await import('./listen-sheet.js');
668
+ const c = await askListen({ shots: screenOn() });
669
+ return c ? { plan: planListen(c) } : { cancelled: true };
670
+ }
671
+ async function openSource(kind, { screen = false, plan = null } = {}) {
640
672
  const md = navigator.mediaDevices;
641
673
  if (kind === 'mic') return md.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, channelCount: 1 } });
674
+ if (plan) return openPlanned(plan);
642
675
  // The meeting is often in Zoom or another app, not in this tab: offer the whole screen first (its system audio carries every app),
643
676
  // keep every window and tab selectable, and leave PMOS's own tab out, because it has no sound. Chrome ignores a hint it does not know.
644
677
  const s = await md.getDisplayMedia({ video: { displaySurface: 'monitor' }, audio: { echoCancellation: false, noiseSuppression: false, autoGainControl: false }, systemAudio: 'include', monitorTypeSurfaces: 'include', surfaceSwitching: 'include', selfBrowserSurface: 'exclude' });
@@ -647,7 +680,7 @@ async function openSource(kind, { screen = false } = {}) {
647
680
  const video = screen ? s.getVideoTracks()[0] || null : null;
648
681
  for (const t of s.getVideoTracks()) if (t !== video) t.stop();
649
682
  if (!tracks.length) { if (video) video.stop(); throw Object.assign(new Error('The shared screen, window or tab has no audio. Share it again, and tick "Also share system audio" (entire screen) or "Share tab audio" (a tab).'), { noAudio: true }); }
650
- return Object.assign(new MediaStream(tracks), { screenTrack: video });
683
+ return Object.assign(new MediaStream(tracks), { screenTrack: video, screenSurface: video ? surfaceOf(video, null) : null });
651
684
  }
652
685
  /** The meeting source reaches PMOS as exact silence while the microphone is heard: the other people are not being recorded. Most often the
653
686
  * sharing window had the wrong tab (PMOS's own tab has no sound). Say so once, and take the message back if sound arrives. */
@@ -663,12 +696,12 @@ function checkSilence(ctx, a) {
663
696
  + 'If the meeting is talking, press Stop, press Listen again, choose Entire screen in the sharing window (or the meeting\'s tab) and tick "Share tab audio" or "Also share system audio".' };
664
697
  ctx.setState({});
665
698
  }
666
- async function startAudio(ctx, n, want) {
699
+ async function startAudio(ctx, n, want, plan = null) {
667
700
  const a = { id: n.id, state: 'starting', sources: [], streams: [], ctx: null, started: Date.now(), clock: null, msg: null, queue: {}, buf: {}, rs: {}, rs24: {}, buf24: {}, peak: {}, began: Date.now(), lost: 0, ended: 0, cap: null, seq: {}, off: {} };
668
701
  const opened = [];
669
702
  // the meeting first, straight from the press: the browser lets a page share a screen only right after a click
670
703
  for (const kind of want.slice().sort((x, y) => (x === 'meeting' ? -1 : y === 'meeting' ? 1 : 0))) {
671
- try { opened.push([kind, await openSource(kind, { screen: kind === 'meeting' && screenOn() })]); }
704
+ try { opened.push([kind, await openSource(kind, { screen: kind === 'meeting' && screenOn(), plan: kind === 'meeting' ? plan : null })]); }
672
705
  catch (e) { a.msg = (a.msg ? a.msg + ' ' : '') + (kind === 'mic' ? 'The microphone was not opened' : 'The meeting audio was not opened') + ': ' + ((e && e.noAudio) ? e.message : ((e && e.message) || 'permission was refused') + '.'); }
673
706
  }
674
707
  await stopAll(ctx); await flushSave(ctx);
@@ -708,7 +741,7 @@ async function startAudio(ctx, n, want) {
708
741
  const shared = opened.find(([kind, s]) => kind === 'meeting' && s.screenTrack);
709
742
  if (shared && a.cap) {
710
743
  const track = shared[1].screenTrack;
711
- a.screen = startScreen({ track, id: a.id, cap: a.cap, clock: () => Date.now() - a.started, post: (b) => pmPost('/api/notes/screen', b) });
744
+ a.screen = startScreen({ track, id: a.id, cap: a.cap, clock: () => Date.now() - a.started, post: (b) => pmPost('/api/notes/screen', b), surface: shared[1].screenSurface || null });
712
745
  // the PM stops sharing the picture only: the sound goes on, and the shots stop
713
746
  track.addEventListener('ended', () => { if (a.screen) a.screen.stop(); });
714
747
  } else if (shared) shared[1].screenTrack.stop();
@@ -1031,7 +1064,10 @@ async function chooseSources(ctx, n) {
1031
1064
  const local = !!(eng.whisper && eng.whisper.ready);
1032
1065
  // Default to both mic and meeting (skip the chooser dialog entirely per Wawan's streamlining request)
1033
1066
  const want = local ? ['mic', 'meeting'] : ['speech'];
1034
- if (local) startAudio(ctx, n, want); else startSpeech(ctx, n, 'record');
1067
+ if (!local) { startSpeech(ctx, n, 'record'); return; }
1068
+ const pick = await chooseListen();
1069
+ if (pick.cancelled) { banner = { tone: 'bad', text: 'Listening was not started.' }; ctx.setState({}); return; }
1070
+ startAudio(ctx, n, want, pick.plan);
1035
1071
  }
1036
1072
  async function setSwitch(ctx, patch) {
1037
1073
  const ws = wsOf(ctx);
@@ -1814,7 +1850,7 @@ export function renderBoard(inner, ctx) {
1814
1850
  if (pl) wrap.appendChild(el('div', { class: 'nt-replay', 'data-nt-part': 'replay' }, el('span', { class: 'nt-sec__h' }, 'Replay'), pl.el,
1815
1851
  el('span', { class: 'nt-hint' }, 'Kept on this machine. Press a time in the transcript to play from there.')));
1816
1852
  // the screenshots of the shared screen, on the replay's clock (notes-screen-capture)
1817
- if (!listening(n) && !n.private && (n.__shots || []).length) wrap.appendChild(screenStrip({ id: n.id, frames: n.__shots, seek: pl ? (ms) => seek(ctx, n, ms) : null, replay: pl ? pl.el : null }));
1853
+ if (!listening(n) && !n.private && ((n.__shots || []).length || n.__aside)) wrap.appendChild(screenStrip({ id: n.id, frames: n.__shots, aside: n.__aside || 0, seek: pl ? (ms) => seek(ctx, n, ms) : null, replay: pl ? pl.el : null }));
1818
1854
  wrap.appendChild(el('div', { class: 'nt-body2' }, noteColumn(ctx, n, ed), transcriptColumn(ctx, n)));
1819
1855
  if (pl) queueMicrotask(paintNow);
1820
1856
  if (((n.agent && n.agent.started && !n.agent.markdown) || finalPending(n)) && !agentTimer) pollAgent(ctx, n.id);
@@ -1850,7 +1886,11 @@ export async function listenNow(ctx, id) {
1850
1886
  if (!n) { await loadNote(ctx, id); n = cache.get(id); }
1851
1887
  if (!n || n.private) return false;
1852
1888
  const eng = engine && engine.ws === wsOf(ctx) && engine.whisper ? engine : await loadEngine(ctx);
1853
- if (eng.whisper && eng.whisper.ready) await startAudio(ctx, n, ['mic', 'meeting']); else await startSpeech(ctx, n, 'record');
1889
+ if (eng.whisper && eng.whisper.ready) {
1890
+ const pick = await chooseListen(); // where the meeting is: the sheet, then the sharing windows
1891
+ if (pick.cancelled) { banner = { tone: 'bad', text: 'Listening was not started.' }; return false; }
1892
+ await startAudio(ctx, n, ['mic', 'meeting'], pick.plan);
1893
+ } else await startSpeech(ctx, n, 'record');
1854
1894
  return listeningId() === id;
1855
1895
  } finally { starting = false; ctx.setState({}); }
1856
1896
  }