@gotcos/glasses-server 6.99.12 → 6.99.20

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 (42) hide show
  1. package/CHANGELOG.md +124 -0
  2. package/package.json +1 -1
  3. package/server/index.ts +44 -2
  4. package/server/lib/ask-work-plan.ts +116 -0
  5. package/server/lib/batch-transcript-quality.ts +2 -0
  6. package/server/lib/cos-thread.ts +582 -0
  7. package/server/lib/drop-attachments.ts +55 -0
  8. package/server/lib/drop-service.ts +104 -13
  9. package/server/lib/drop-settings.ts +13 -2
  10. package/server/lib/drop-store.ts +38 -2
  11. package/server/lib/media-store.ts +80 -0
  12. package/server/lib/meeting-ask-runtime.ts +1 -1
  13. package/server/lib/meeting-batch-transcribe.ts +54 -2
  14. package/server/lib/permission-broker.ts +6 -4
  15. package/server/lib/phone-recording.ts +1 -1
  16. package/server/lib/ping-sources.ts +6 -2
  17. package/server/lib/prep-runtime.ts +915 -0
  18. package/server/lib/prep-store.ts +226 -0
  19. package/server/lib/stt-resolver.ts +26 -2
  20. package/server/lib/transcript-cleanup.ts +11 -2
  21. package/server/lib/work-orchestrator/runtime.ts +8 -2
  22. package/server/routes/ask-work-plan.ts +114 -0
  23. package/server/routes/cos-thread.ts +79 -0
  24. package/server/routes/drops.ts +4 -2
  25. package/server/routes/health.ts +8 -0
  26. package/server/routes/permission-broker.ts +2 -2
  27. package/server/routes/prep.ts +81 -0
  28. package/server/routes/transcribe-stream.ts +9 -1
  29. package/server/routes/welcome-context.ts +29 -1
  30. package/server/routes/work-orchestrator.ts +57 -0
  31. package/server/scripts/web-live-cls/cdp.mjs +57 -0
  32. package/server/scripts/web-live-cls/measure.mjs +80 -0
  33. package/server/scripts/web-live-cls/mock.mjs +97 -0
  34. package/server/scripts/web-live-cls/render.mjs +26 -0
  35. package/server/web-control/control.css +13 -0
  36. package/server/web-control/panes/cos-chat.css +39 -0
  37. package/server/web-control/panes/cos-chat.js +799 -41
  38. package/server/web-control/panes/meetings.css +91 -4
  39. package/server/web-control/panes/meetings.js +585 -58
  40. package/server/web-control/panes/messages.js +25 -18
  41. package/shared/meeting-ask.ts +6 -2
  42. package/shared/prep.ts +317 -0
@@ -5,7 +5,8 @@
5
5
  Data: GET /api/meetings?limit=200&domain=all&month=, /api/meetings/search, /api/meetings/detail,
6
6
  /api/meetings/live-chunks, /api/meeting/sessions/:id/status, /api/meeting/orphans, /api/meeting-ask/*,
7
7
  /api/live-cues/history, /api/context/status. Writes only on a tap: Meeting Ask, Stop and save, End and save,
8
- Recover, Save, Save as follow-up.
8
+ Recover, Save, Save as follow-up, and (6.99.14) Add file, paste or drop in the live view: /api/media or
9
+ /api/media/file, then POST /api/meeting-ask/files/media.
9
10
  Routes: #/meetings, #/meetings/<domain>/<month>/<filename>, #/meetings/live/<sessionId>. */
10
11
  (function () {
11
12
  'use strict';
@@ -721,6 +722,7 @@
721
722
  var o = opts || {};
722
723
  var headers = { 'X-COS-Web': '1' };
723
724
  if (o.body !== undefined) headers['Content-Type'] = 'application/json';
725
+ if (o.headers) Object.keys(o.headers).forEach(function (k) { headers[k] = o.headers[k]; });
724
726
  // A read gives up at 20 s, as W.api's do, so a stalled Mac never freezes a poll that waits on it.
725
727
  var method = o.method || 'GET', ctrl = method === 'GET' && typeof AbortController === 'function' ? new AbortController() : null;
726
728
  var timer = ctrl ? setTimeout(function () { ctrl.abort(); }, 20000) : null;
@@ -812,6 +814,7 @@
812
814
  if (r.startTime != null) f.start = r.startTime;
813
815
  if (r.lastActivityAt != null) f.last = Math.max(f.last || 0, r.lastActivityAt);
814
816
  if (typeof r.titleSteer === 'string') f.titleSteer = cleanTitle(r.titleSteer) || null;
817
+ setLevels(f, r.levels);
815
818
  liveChanged();
816
819
  }, function () {});
817
820
  }
@@ -1168,7 +1171,7 @@
1168
1171
  if (S.detailRow) W.setCrumb(S.detailRow.title || 'Meeting');
1169
1172
  paintDetail();
1170
1173
  if (!S.inbox) loadInbox();
1171
- if (S.detail) { W.setCrumb(detailRow().title || 'Meeting'); detailSessions().forEach(function (sid) { panelOpen(sid); }); return; }
1174
+ if (S.detail) { W.setCrumb(detailRow().title || 'Meeting'); detailSessions().forEach(function (sid) { panelOpen(sid); }); loadDetailPrep(); return; }
1172
1175
  var id = ++S.detailSeq;
1173
1176
  S.detailLoading = true;
1174
1177
  api('/api/meetings/detail?domain=' + encodeURIComponent(domain) + '&month=' + encodeURIComponent(month) + '&filename=' + encodeURIComponent(filename)).then(function (d) {
@@ -1178,6 +1181,7 @@
1178
1181
  W.setCrumb(detailRow().title || 'Meeting');
1179
1182
  paintDetail();
1180
1183
  detailSessions().forEach(function (sid) { panelOpen(sid); });
1184
+ loadDetailPrep();
1181
1185
  // A deep link has no list row; Control always opens one (its session, voices and captures). Read that day's list.
1182
1186
  if (!S.detailRow && /^\d{4}-\d{2}-\d{2}$/.test(S.detail.date || '')) {
1183
1187
  api('/api/meetings?limit=200&domain=all&day=' + S.detail.date).then(function (l) {
@@ -1187,6 +1191,7 @@
1187
1191
  S.detailRow = hit;
1188
1192
  paintDetail();
1189
1193
  detailSessions().forEach(function (sid) { if (before.indexOf(sid) < 0) panelOpen(sid); });
1194
+ loadDetailPrep();
1190
1195
  }, function () {});
1191
1196
  }
1192
1197
  }, function (err) {
@@ -1246,10 +1251,79 @@
1246
1251
  A(sid).ctx = { saved: true, meeting: row, captureLabel: 'Original recording ' + (i + 1) + '. Questions use its saved cutoff; each recording keeps its own history.' };
1247
1252
  return '<div class="mt-side-panel" id="mt-ask-' + safeId(sid) + '" data-sid="' + esc(sid) + '">' + sidePanelHtml(sid) + '</div>';
1248
1253
  }).join('') +
1254
+ '<div id="mt-dprep">' + detailPrepHtml() + '</div>' +
1249
1255
  '<article class="mt-doc">' + documentHtml(d) + '</article></div>';
1250
1256
  }
1251
1257
  pane.innerHTML = '<div class="cx mt mt-detail" data-tint="meetings">' + head + body + '</div>';
1252
1258
  }
1259
+ // ── Prep in a saved meeting (6.99.19) ─────────────────
1260
+ // GET /api/prep/by-session/:sessionId for each of the meeting's recordings: what the prep focused on and its files,
1261
+ // each opening from the Prep store. Nothing shows when there is no prep, or on a server without Prep.
1262
+ function detailPrepKey() { return S.detailKey + '|' + detailSessions().join(','); }
1263
+ function loadDetailPrep() {
1264
+ var key = detailPrepKey(), sessions = detailSessions(), cc = W.cosChat;
1265
+ if (!sessions.length || !cc || typeof cc.ensureCaps !== 'function') return;
1266
+ if (S.dprep && S.dprep.key === key) { paintDetailPrep(); return; }
1267
+ S.dprep = { key: key, drafts: [] };
1268
+ cc.ensureCaps().then(function () {
1269
+ if (!prepCapsNow()) return;
1270
+ return Promise.all(sessions.map(function (sid) { return answer('/api/prep/by-session/' + encodeURIComponent(sid)); })).then(function (list) {
1271
+ list.forEach(function (r) { if (r.status === 200) notePrepInstance(r.body); });
1272
+ if (!S.dprep || S.dprep.key !== key) return;
1273
+ var seen = {}, out = [];
1274
+ list.forEach(function (r) {
1275
+ // Amendment 21: {serverInstanceId, snapshots}, one per binding to this meeting, newest first, each frozen at bind
1276
+ // time (title, startsAt, steer, files), never the draft as it is now. A binding taken back off (removedAt) is
1277
+ // not shown. Open links name the draft and the file.
1278
+ if (r.status !== 200 || !Array.isArray(r.body.snapshots)) return;
1279
+ // Several binds of one draft are kept, newest first; `current` marks the one the meeting's copies hold now.
1280
+ // Only that one is shown when the server marks it. A 'removed' bind (or removedAt) is never shown.
1281
+ var rows = r.body.snapshots.filter(function (sn) { return sn && typeof sn.draftId === 'string' && sn.state !== 'removed' && !sn.removedAt; });
1282
+ var hasCurrent = {};
1283
+ r.body.snapshots.forEach(function (sn) { if (sn && sn.current === true) hasCurrent[sn.draftId] = 1; });
1284
+ rows.forEach(function (sn) {
1285
+ if (hasCurrent[sn.draftId] && sn.current !== true) return;
1286
+ var k = sn.draftId + '@' + sn.draftRevision;
1287
+ if (seen[k]) return;
1288
+ seen[k] = 1;
1289
+ out.push({ id: sn.draftId, title: sn.title, startsAt: sn.startsAt, steer: typeof sn.steer === 'string' ? sn.steer : '', files: Array.isArray(sn.files) ? sn.files : [],
1290
+ applying: sn.state === 'applying', held: sn.held === true });
1291
+ });
1292
+ });
1293
+ S.dprep.drafts = out;
1294
+ paintDetailPrep();
1295
+ });
1296
+ });
1297
+ }
1298
+ function prepItemHtml(d) {
1299
+ var files = (d.files || []).filter(function (x) { return x && x.status !== 'removed' && String(x.name || '').trim(); });
1300
+ var steer = String(d.steer || '').trim();
1301
+ if (!steer && !files.length && !d.held && !d.applying) return '';
1302
+ var at = d.startsAt ? Date.parse(d.startsAt) : NaN;
1303
+ var when = isFinite(at) ? new Date(at).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + ', ' + clockOf(new Date(at)) : '';
1304
+ return '<div class="mt-prep-item"><p class="mt-q">' + esc((cleanTitle(d.title) || 'Prep') + (when ? ' · ' + when : '')) + '</p>' +
1305
+ // Amendment 11: a bind is written 'applying' before the meeting's files change. Said as it is, never as adopted.
1306
+ (d.applying ? '<p class="mt-amber-line mt-prep-state">Still being added to this meeting. These notes and files are not in its files yet.</p>' : '') +
1307
+ // A held note (it looked like a password or key) was not adopted; its words are not shown.
1308
+ (d.held ? '<p class="mt-muted mt-prep-held">Notes held: they look like a password or key.</p>' : '') +
1309
+ (steer ? '<div class="mt-prep-steer">' + steer.split(/\n{2,}/).map(function (para) { return '<p>' + esc(para) + '</p>'; }).join('') + '</div>' : '') +
1310
+ (files.length ? '<ul class="mt-files-list mt-prep-files">' + files.map(function (x) {
1311
+ var meta = x.status === 'ready' ? (x.notice || '') : (x.notice || FILE_STATE[x.status] || '');
1312
+ return '<li class="mt-up"><span class="mt-files-name">' + esc(x.name) + '</span>' + (meta ? '<span class="mt-up-meta mt-muted">' + esc(meta) + '</span>' : '') +
1313
+ '<span class="mt-up-acts"><a class="mt-link" href="/api/prep/drafts/' + encodeURIComponent(d.id) + '/files/' + encodeURIComponent(x.id) + '" target="_blank" rel="noopener" aria-label="' + esc('Open ' + x.name) + '">Open</a></span></li>';
1314
+ }).join('') + '</ul>' : '') + '</div>';
1315
+ }
1316
+ function detailPrepHtml() {
1317
+ var dp = S.dprep;
1318
+ if (!dp || !S.detail || dp.key !== detailPrepKey()) return '';
1319
+ var items = dp.drafts.map(prepItemHtml).filter(Boolean);
1320
+ return items.length ? '<section class="mt-panel mt-prep" aria-label="Prep"><h3 class="mt-ask-title">Prep</h3>' + items.join('') + '</section>' : '';
1321
+ }
1322
+ function paintDetailPrep() {
1323
+ var el = $('mt-dprep'); if (!el || S.view !== 'detail') return;
1324
+ var html = detailPrepHtml();
1325
+ if (el.innerHTML !== html) el.innerHTML = html;
1326
+ }
1253
1327
  function paintBar() {
1254
1328
  if (S.view !== 'detail' || !S.detail) return;
1255
1329
  var bar = pane.querySelector('.mt-bar'); if (!bar) return;
@@ -1334,8 +1408,8 @@
1334
1408
  exchanges.reduce(function (all, e) { return all.concat(e.files || []); }, []).concat(current).forEach(function (f) { var k = f.id + '@' + f.revision; if (!seen[k]) { seen[k] = 1; out.push(f); } });
1335
1409
  return out;
1336
1410
  }
1337
- function fullContext(title, transcript, exchanges, files, cues, cueNote) {
1338
- var sections = ['# ' + title, '## Transcript\n' + transcript];
1411
+ function fullContext(title, transcript, exchanges, files, cues, cueNote, plainTitle) {
1412
+ var sections = [(plainTitle ? '' : '# ') + title, '## Transcript\n' + transcript];
1339
1413
  exchanges.forEach(function (e, i) {
1340
1414
  var ns = 'Q' + (i + 1), ans = e.answer != null ? e.answer : 'No answer (' + e.status + ').';
1341
1415
  (e.sources || []).forEach(function (s) { ans = ans.split('[' + s.id + ']').join('[' + ns + ':' + s.id + ']'); });
@@ -1793,17 +1867,32 @@
1793
1867
  return html;
1794
1868
  }
1795
1869
  var FILE_STATE = { pending: 'Reading', unsupported: 'No readable text', failed: 'Could not be read', 'secret-held': 'Held: it appears to contain credentials' };
1796
- function filesRowHtml(sid) {
1870
+ // `live`: the live view while it records, where this page adds files itself (Add file, paste, drop). There the row is
1871
+ // always drawn, one line tall whatever it says, so files that arrive with the Ask history, an upload that starts and
1872
+ // one that finishes never move what is under it. Open, the files and the uploads are rows of the same height: a
1873
+ // finished upload becomes its file in place. { items, note } are the uploads and the last refusal.
1874
+ function filesRowHtml(sid, live) {
1797
1875
  var a = A(sid), shown = (a.files || []).filter(function (x) { return x && x.status !== 'removed' && String(x.name || '').trim(); });
1798
- if (!shown.length) return '';
1799
- var title = 'Files \u00b7 ' + shown.length;
1800
- var html = '<div class="mt-files"><button type="button" class="mt-files-head" data-mt="files-toggle" data-sid="' + esc(sid) + '" aria-expanded="' + !!a.filesOpen + '">' +
1801
- icon('clip') + '<span>' + esc(title) + '</span>' + icon('chev') + '</button>';
1802
- if (a.filesOpen) {
1876
+ var ups = live ? live.items : [], note = live ? live.note : '';
1877
+ if (!shown.length && !(live && live.on) && !ups.length && !note) return '';
1878
+ var busy = ups.filter(function (t) { return t.state !== 'failed'; }).length, failed = ups.length - busy;
1879
+ var title = shown.length ? 'Files \u00b7 ' + shown.length : (live && live.on ? 'No files yet. Add file, paste or drop one here.' : 'Files');
1880
+ var extra = busy ? ' \u00b7 adding ' + busy : '';
1881
+ var bad = failed ? (failed === 1 ? '1 did not upload' : failed + ' did not upload') : '';
1882
+ var openable = shown.length + ups.length > 0 || !!note;
1883
+ var html = '<div class="mt-files' + (live ? ' live' : '') + '">' + (openable
1884
+ ? '<button type="button" class="mt-files-head" data-mt="files-toggle" data-sid="' + esc(sid) + '" aria-expanded="' + !!a.filesOpen + '">'
1885
+ : '<div class="mt-files-head quiet">') +
1886
+ icon('clip') + '<span>' + esc(title + extra) + (bad ? '<span class="mt-amber"> \u00b7 ' + esc(bad) + '</span>' : '') + '</span>' + (openable ? icon('chev') + '</button>' : '</div>');
1887
+ if (a.filesOpen && openable) {
1803
1888
  html += '<ul class="mt-files-list">' + shown.map(function (x) {
1804
- var note = [x.status !== 'ready' ? (x.notice || FILE_STATE[x.status] || '') : '', x.origin === 'phone' ? 'Added from the phone' : ''].filter(Boolean).join(' \u00b7 ');
1805
- return '<li><span class="mt-files-name">' + esc(x.name) + '</span>' + (note ? '<span class="mt-muted">' + esc(note) + '</span>' : '') + '</li>';
1806
- }).join('') + '</ul><p class="mt-muted mt-files-note">Meeting Ask reads these files. Add or remove them in COS Control on your Mac.</p>';
1889
+ var line = [x.status !== 'ready' ? (x.notice || FILE_STATE[x.status] || '') : (x.notice || ''), x.origin === 'phone' ? 'Added from a phone or browser' : x.origin === 'prep' ? 'From prep' : ''].filter(Boolean).join(' \u00b7 ');
1890
+ return '<li><span class="mt-files-name">' + esc(x.name) + '</span>' + (line ? '<span class="mt-muted">' + esc(line) + '</span>' : '') + '</li>';
1891
+ }).join('') + ups.map(uploadRowHtml).join('') + '</ul>' +
1892
+ (note ? '<p class="mt-amber-line mt-files-note" role="status">' + esc(note) + '</p>' : '') +
1893
+ '<p class="mt-muted mt-files-note">' + (live && live.on
1894
+ ? 'Meeting Ask reads these files. Add more here; remove them in COS Control on your Mac.'
1895
+ : 'Meeting Ask reads these files. Add or remove them in COS Control on your Mac.') + '</p>';
1807
1896
  }
1808
1897
  return html + '</div>';
1809
1898
  }
@@ -1899,20 +1988,31 @@
1899
1988
  var hours = f.turns.some(function (t) { return t.elapsedMs >= 3600000; });
1900
1989
  return [header].concat(f.turns.map(function (t) { return '[' + stampMs(t.elapsedMs, hours) + '] ' + t.speaker + ': ' + t.text; })).join('\n');
1901
1990
  }
1991
+ // The full context's first line for a live meeting: "Live meeting: <its name>", never a bare "# Live meeting".
1992
+ function liveContextTitle(f) { return 'Live meeting: ' + liveTitle(f); }
1902
1993
  function openLive(sid) {
1903
1994
  S.view = 'live';
1904
- if (S.liveSid !== sid) { S.liveSid = sid; S.follow = true; S.confirm = ''; S.liveCopy = ''; S.liveReason = ''; }
1995
+ if (S.liveSid !== sid) { S.liveSid = sid; S.confirm = ''; S.liveCopy = ''; S.liveReason = ''; }
1996
+ // A new box starts at its newest line, following.
1997
+ S.follow = true;
1905
1998
  var f = feedFor(sid);
1906
1999
  A(sid).ctx = { live: true };
1907
2000
  W.setCrumb(liveTitle(f));
1908
- pane.innerHTML = '<div class="cx mt mt-livev" data-tint="meetings"><div id="mt-lhead"></div><div class="mt-lgrid"><div class="mt-lmain">' +
1909
- '<div id="mt-turns" class="mt-turns" aria-live="polite"></div><div id="mt-lfoot"></div></div>' +
2001
+ // 6.99.14: the transcript scrolls inside its own box, as Control's does, so a new line never moves the page: the
2002
+ // title, the recording bar, the files and the Ask panel stay where the reader left them. Everything that changes
2003
+ // height while it records (the stop line, the files, the confirm card) sits under the box, never above it.
2004
+ pane.innerHTML = '<div class="cx mt mt-livev" data-tint="meetings"><div id="mt-lhead"></div><div class="mt-lgrid"><div class="mt-lmain" id="mt-lmain">' +
2005
+ '<div class="mt-tbox"><div id="mt-turns" class="mt-turns" tabindex="0" role="log" aria-live="polite" aria-label="Live transcript"></div>' +
2006
+ '<button type="button" class="cx-btn quiet mt-jump" id="mt-jump" data-mt="jump" hidden>Jump to live</button></div>' +
2007
+ '<div id="mt-lbar" class="mt-lbarslot"></div><div id="mt-lfiles"></div><div id="mt-lprep"></div><div id="mt-lfoot"></div>' +
2008
+ '<input type="file" id="mt-lpick" class="mt-pick" multiple accept="' + esc(LIVE_ACCEPT) + '" tabindex="-1" aria-hidden="true"></div>' +
1910
2009
  '<aside class="mt-side-panel" id="mt-ask-' + safeId(sid) + '" data-sid="' + esc(sid) + '">' + sidePanelHtml(sid) + '</aside></div></div>';
1911
- S.headHtml = S.footHtml = null;
2010
+ S.headHtml = S.footHtml = S.barHtml = S.filesHtml = S.prepHtml = null;
1912
2011
  paintLiveHead(); paintTurns(true); paintLiveFoot();
1913
- clearInterval(S.liveTimer);
2012
+ clearInterval(S.liveTimer); clearInterval(S.clockTimer);
1914
2013
  livePoll();
1915
2014
  S.liveTimer = setInterval(livePoll, 1500);
2015
+ S.clockTimer = setInterval(tickClock, 1000);
1916
2016
  panelOpen(sid);
1917
2017
  }
1918
2018
  // Control's LiveMeeting.title: the typed name, else a calendar event that started within CALENDAR_MATCH_MS of the
@@ -1951,7 +2051,9 @@
1951
2051
  var t = Date.now(), files = a.body.sessions || [];
1952
2052
  S.files = files;
1953
2053
  var file = files.filter(function (s) { return s.sessionId === f.sid; })[0];
1954
- var changed = applyRead(f, a.body.read, t);
2054
+ var r = a.body.read;
2055
+ if (r && r.sessionId === f.sid && !r.unchanged) setLevels(f, r.levels);
2056
+ var changed = applyRead(f, r, t);
1955
2057
  if (file) { var m = num(file.mtimeMs); if (!f.last || m > f.last) f.last = m; }
1956
2058
  else if (!f.endedAt) changed = markEnded(f, t) || changed;
1957
2059
  if (!f.endedAt && f.last && t - f.last >= QUIET_MS && t - f.statusAt >= STATUS_QUIET_MS) askStatus(f, t);
@@ -1992,22 +2094,422 @@
1992
2094
  : (ended ? '' : (isMac(sid) ? 'Recording on this Mac' : 'Recording on the glasses') + (f.titleSteer && f.start ? ' · started ' + clockOf(new Date(f.start)) : ''));
1993
2095
  var quiet = ended ? null : quietLabel(f.last, now);
1994
2096
  var sub = f.titleSteer && f.start && !isPhone(sid) ? (durationLabel(f.start, now, f.endedAt) || 'Starting') : liveSubtitle(f.start, now, f.endedAt);
1995
- var controls = '';
1996
- if (!ended && isGlasses(sid)) controls = '<button type="button" class="cx-btn quiet" data-mt="stop-ask" data-sid="' + esc(sid) + '"' + (stopping(sid) ? ' disabled' : '') + '>Stop and save</button>';
1997
- else if (!ended && isPhone(sid)) controls = '<span class="mt-side-note">Stop on iPhone</span>';
1998
- var line = stopLineFor(sid);
2097
+ // Every line here is one line tall whatever it says (the CSS keeps each to one line), so the header never changes
2098
+ // height while it records and the transcript under it never moves.
1999
2099
  var html = '<div class="mt-dhead mt-lhead"><div class="mt-ltitle"><span class="mt-livedot big' + (ended ? '' : ' on') + '"></span><h2 class="mt-dtitle">' + esc(liveTitle(f)) + '</h2></div>' +
2000
2100
  (status ? '<p class="mt-lstatus">' + esc(status) + '</p>' : '') +
2001
2101
  (isPhone(sid) ? '<p class="mt-muted">iPhone microphone recordings are anonymous. Names and voice training are unavailable.</p>' : '') +
2002
2102
  '<p class="mt-muted">' + esc(LIVE_HEADER) + '</p>' +
2003
2103
  (isMac(sid) ? '<p class="mt-muted">Mac playback · microphone off · voice numbers do not identify people</p>' : '') +
2004
- '<p class="mt-lsub">' + esc(sub) + (quiet ? '<span class="mt-amber"> · ' + esc(quiet) + '</span>' : '') + '</p>' +
2005
- liveHandoffLine(f) + filesRowHtml(sid) +
2006
- (line ? '<p class="mt-stopline' + (S.stop && S.stop.nothing ? ' bad' : '') + '">' + esc(line) + '</p>' : '') +
2007
- (controls ? '<div class="mt-bar">' + controls + '</div>' : '') +
2008
- (S.confirm === 'stop:' + sid ? confirmCard(STOP_TITLE, STOP_LINE, 'stop-yes', 'Stop and save', 'Cancel', sid) : '') + '</div>';
2104
+ '<p class="mt-lsub">' + esc(sub) + (quiet ? '<span class="mt-amber"> · ' + esc(quiet) + '</span>' : '') + '</p></div>';
2009
2105
  if (html !== S.headHtml) { S.headHtml = html; el.innerHTML = html; }
2010
2106
  if (W.crumb() !== liveTitle(f)) W.setCrumb(liveTitle(f));
2107
+ paintLiveBar(); paintLiveFiles();
2108
+ suggestPrep(f); paintLivePrep();
2109
+ }
2110
+
2111
+ // ── the recording bar (LiveRecordingBar, 0.5.339) ──────
2112
+ // The red dot, the elapsed time on its own one-second clock, the level strip, Add file and Stop and save, in one row
2113
+ // of fixed height under the transcript. Under it: the stop line and the confirm card. Once it ends: the handoff line.
2114
+ // The row is rebuilt only when what it offers changes; the clock and the strip draw in place.
2115
+ function elapsedLabel(ms) {
2116
+ var t = Math.max(0, Math.floor((isFinite(ms) ? ms : 0) / 1000)), h = Math.floor(t / 3600), m = Math.floor((t % 3600) / 60), s = t % 60;
2117
+ return h > 0 ? h + ':' + pad2(m) + ':' + pad2(s) : m + ':' + pad2(s);
2118
+ }
2119
+ function clockText(f, now) { return f && f.start ? elapsedLabel(now - f.start) : 'Starting'; }
2120
+ // The Meeting Ask files route takes the ids Meeting Ask does (server/lib/ask-session-id.ts ASK_SESSION).
2121
+ var ASK_SESSION = /^(?:meeting_[A-Za-z0-9_-]{1,89}|(?:mac_|phone_(?:[0-9]{13}_)?)[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/;
2122
+ function canAddFiles(f) { return !!f && !f.endedAt && ASK_SESSION.test(f.sid); }
2123
+ function liveStopHtml(sid) {
2124
+ if (isGlasses(sid)) return '<button type="button" class="cx-btn quiet mt-stopbtn" data-mt="stop-ask" data-sid="' + esc(sid) + '"' + (stopping(sid) ? ' disabled' : '') + '>Stop and save</button>';
2125
+ if (isPhone(sid)) return '<span class="mt-side-note">Stop on iPhone</span>';
2126
+ return '<span class="mt-side-note">Stop on your Mac</span>';
2127
+ }
2128
+ function liveBarHtml(f) {
2129
+ var sid = f.sid;
2130
+ if (f.endedAt) return liveHandoffLine(f);
2131
+ var line = stopLineFor(sid);
2132
+ return '<div class="mt-lbar" role="group" aria-label="Recording">' +
2133
+ '<span class="mt-livedot on" aria-hidden="true"></span>' +
2134
+ '<span class="mt-ltime' + (f.start ? '' : ' starting') + '" id="mt-ltime" role="timer" aria-label="Recording time">' + esc(clockText(f, Date.now())) + '</span>' +
2135
+ '<span class="mt-meterwrap"><canvas class="mt-meter" id="mt-meter" role="img" aria-label="' + esc(LEVEL_LABEL) + '"></canvas>' +
2136
+ '<span class="mt-glyph" aria-hidden="true"><i></i><i></i><i></i></span></span>' +
2137
+ (canAddFiles(f) ? '<button type="button" class="cx-btn quiet mt-addfile" data-mt="add-file" aria-label="Add file" title="Add a screenshot or files to this meeting">' + icon('clip') + '<span class="mt-addlabel">Add file</span></button>' : '') +
2138
+ liveStopHtml(sid) + '</div>' +
2139
+ (line ? '<p class="mt-stopline' + (S.stop && S.stop.nothing ? ' bad' : '') + '">' + esc(line) + '</p>' : '') +
2140
+ (S.confirm === 'stop:' + sid ? confirmCard(STOP_TITLE, STOP_LINE, 'stop-yes', 'Stop and save', 'Cancel', sid) : '');
2141
+ }
2142
+ function paintLiveBar() {
2143
+ var el = $('mt-lbar'); if (!el || S.view !== 'live') return;
2144
+ var f = S.feeds[S.liveSid]; if (!f) return;
2145
+ // The clock's own text is left out of the comparison, so a second ticking never rebuilds the row (or the strip).
2146
+ var html = liveBarHtml(f), key = html.replace(/(id="mt-ltime"[^>]*>)[^<]*/, '$1');
2147
+ if (key !== S.barHtml) { S.barHtml = key; el.innerHTML = html; drawMeter(); }
2148
+ }
2149
+ function tickClock() {
2150
+ if (S.view !== 'live' || document.hidden) return;
2151
+ var f = S.feeds[S.liveSid], el = $('mt-ltime');
2152
+ if (!f || !el) return;
2153
+ var text = clockText(f, Date.now());
2154
+ if (el.textContent !== text) el.textContent = text;
2155
+ }
2156
+
2157
+ // ── the level strip (LiveChunkLevelStripView) ─────────
2158
+ // One bar per chunk from the live read's `levels` (server 6.87.0: the quiet gate's RMS and Silero speech seconds,
2159
+ // kept on the active-session record), newest at the right, a dot where the chunk held almost no speech, the oldest
2160
+ // clipped first when narrow. It trails the room by a chunk. Reduce Motion: one still bar for the newest chunk.
2161
+ var LEVEL_STEP = 5, SPEECH_FLOOR = 0.5, LEVEL_FLOOR = 0.27;
2162
+ var LEVEL_LABEL = 'Audio level, a few seconds behind';
2163
+ function levelSilent(l) { return l.speechSec != null ? l.speechSec < SPEECH_FLOOR : l.level <= LEVEL_FLOOR; }
2164
+ function cleanLevels(v) {
2165
+ if (!Array.isArray(v)) return null;
2166
+ var seen = {}, out = [];
2167
+ v.forEach(function (l) {
2168
+ if (!l || typeof l !== 'object' || !Number.isInteger(l.i) || l.i < 0 || seen[l.i]) return;
2169
+ if (typeof l.level !== 'number' || !(l.level >= 0 && l.level <= 1)) return;
2170
+ if (l.speechSec != null && !(typeof l.speechSec === 'number' && l.speechSec >= 0)) return;
2171
+ seen[l.i] = 1; out.push({ i: l.i, level: l.level, speechSec: l.speechSec == null ? null : l.speechSec });
2172
+ });
2173
+ return out.sort(function (a, b) { return a.i - b.i; }).slice(-120);
2174
+ }
2175
+ function visibleLevels(levels, width) { var fit = Math.floor(width / LEVEL_STEP); return fit < 1 ? [] : levels.slice(-fit); }
2176
+ function reduceMotion() { try { return !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches); } catch (_e) { return false; } }
2177
+ function drawMeter() {
2178
+ var c = $('mt-meter'); if (!c || !c.getContext) return;
2179
+ var f = S.feeds[S.liveSid], levels = (f && f.levels) || [];
2180
+ var wrap = c.parentNode; if (wrap && wrap.classList) wrap.classList.toggle('mt-nolevels', !levels.length);
2181
+ var last = levels[levels.length - 1];
2182
+ c.setAttribute('aria-label', LEVEL_LABEL + ': ' + (!last ? 'waiting for the first chunk' : levelSilent(last) ? 'quiet' : 'speech'));
2183
+ var w = c.clientWidth, h = c.clientHeight; if (!w || !h) return;
2184
+ var dpr = window.devicePixelRatio || 1;
2185
+ if (c.width !== Math.round(w * dpr) || c.height !== Math.round(h * dpr)) { c.width = Math.round(w * dpr); c.height = Math.round(h * dpr); }
2186
+ var g = c.getContext('2d'); if (!g) return;
2187
+ g.setTransform(dpr, 0, 0, dpr, 0, 0); g.clearRect(0, 0, w, h);
2188
+ if (!levels.length) return;
2189
+ var css = getComputedStyle(c), bar = css.getPropertyValue('--mt-bar').trim() || '#c2410c', dot = css.getPropertyValue('--mt-dot').trim() || '#8a8a8a';
2190
+ var mid = h / 2;
2191
+ if (reduceMotion()) {
2192
+ var lv = levelSilent(last) ? 0 : last.level;
2193
+ g.fillStyle = dot; g.globalAlpha = 0.35; g.fillRect(0, mid - 2, w, 4); g.globalAlpha = 1;
2194
+ g.fillStyle = bar; g.fillRect(0, mid - 2, Math.max(2, w * lv), 4);
2195
+ return;
2196
+ }
2197
+ var shown = visibleLevels(levels, w);
2198
+ for (var back = 0; back < shown.length; back++) {
2199
+ var l = shown[shown.length - 1 - back], x = w - (back + 0.5) * LEVEL_STEP;
2200
+ // The oldest fade out at the left edge, so clipped history reads as older, not cut.
2201
+ g.globalAlpha = Math.min(1, Math.max(0.15, x / 28));
2202
+ if (levelSilent(l)) { g.fillStyle = dot; g.beginPath(); g.arc(x, mid, 1, 0, Math.PI * 2); g.fill(); }
2203
+ else { var bh = Math.max(3, l.level * h); g.fillStyle = bar; g.fillRect(x - 1.5, mid - bh / 2, 3, bh); }
2204
+ }
2205
+ g.globalAlpha = 1;
2206
+ }
2207
+ function setLevels(f, raw) {
2208
+ var next = cleanLevels(raw); if (!next) return;
2209
+ var sig = next.map(function (l) { return l.i + ':' + l.level + ':' + l.speechSec; }).join('|');
2210
+ if (sig === f.levelSig) return;
2211
+ f.levelSig = sig; f.levels = next;
2212
+ if (S.view === 'live' && S.liveSid === f.sid) drawMeter();
2213
+ }
2214
+
2215
+ // ── files during a live meeting (LiveMeetingFiles) ─────
2216
+ // Add file, paste and drop: each file goes to the media store (W.cosChat.uploadMedia, the composer's own upload) and
2217
+ // then joins the meeting's files through POST /api/meeting-ask/files/media, the route the phone uses, so Meeting Ask
2218
+ // reads it and Copy full context carries it. A file never becomes text: a paste or a drop that holds a file is taken
2219
+ // as the file, and one the browser will not hand over is refused out loud instead of landing as its path.
2220
+ var LIVE_ACCEPT = 'image/*,.heic,.heif,application/pdf,.pdf,text/plain,.txt,text/markdown,.md,text/csv,.csv,application/json,.json,.xlsx';
2221
+ var MAX_LIVE_UPLOADS = 10;
2222
+ function LU() { return S.lup || (S.lup = { sid: '', items: [], note: '' }); }
2223
+ function liveUploads(sid) { var u = LU(); if (u.sid !== sid) { u.items.forEach(abortUpload); S.lup = { sid: sid, items: [], note: '' }; } return S.lup; }
2224
+ function abortUpload(t) { if (t.xhr) { try { t.xhr.abort(); } catch (_e) {} } }
2225
+ /** The files a paste or a drop carries: its File list, else the items of kind file. */
2226
+ function filesOf(dt) {
2227
+ if (!dt) return [];
2228
+ var list = Array.prototype.slice.call(dt.files || []);
2229
+ if (!list.length && dt.items) Array.prototype.forEach.call(dt.items, function (it) { if (it && it.kind === 'file' && it.getAsFile) { var x = it.getAsFile(); if (x) list.push(x); } });
2230
+ return list;
2231
+ }
2232
+ /** True when a paste or a drop is a file (or a file's link) rather than words: it must never be inserted as text. */
2233
+ function carriesFile(dt) {
2234
+ if (!dt) return false;
2235
+ var types = Array.prototype.slice.call(dt.types || []);
2236
+ if (types.indexOf('Files') >= 0 || filesOf(dt).length) return true;
2237
+ var uri = '';
2238
+ try { uri = types.indexOf('text/uri-list') >= 0 ? String(dt.getData('text/uri-list') || '') : ''; } catch (_e) { uri = ''; }
2239
+ return /^\s*file:/i.test(uri);
2240
+ }
2241
+ function liveFileKind(file) {
2242
+ var cc = W.cosChat;
2243
+ if (!cc || !cc.classify || !cc.uploadMedia) return { refuse: 'This page could not load its upload code. Reload the page, then try again.' };
2244
+ var c = cc.classify(file);
2245
+ if (c.refuse) return c;
2246
+ if (c.kind === 'video') return { refuse: (file.name || 'That video') + ' is a video. Meeting Ask reads documents and the text in images, so attach a PDF, a text file or a screenshot.' };
2247
+ return c;
2248
+ }
2249
+ function addLiveFiles(list) {
2250
+ var f = S.feeds[S.liveSid];
2251
+ if (S.view !== 'live' || !f) return;
2252
+ var u = liveUploads(f.sid), files = Array.prototype.slice.call(list || []), refusals = [];
2253
+ if (!canAddFiles(f)) { liveNote(f.endedAt ? 'This recording has ended. Files can be added while it records.' : 'Meeting Ask does not take files for this recording.'); return; }
2254
+ files.forEach(function (file) {
2255
+ if (u.items.filter(function (t) { return t.state !== 'failed'; }).length >= MAX_LIVE_UPLOADS) { refusals.push('Up to 10 files at a time. ' + (file.name || 'A file') + ' was not added.'); return; }
2256
+ var c = liveFileKind(file);
2257
+ if (c.refuse) { refusals.push(c.refuse); return; }
2258
+ var t = { id: W.cosChat.uuid(), file: file, name: file.name || (c.kind === 'photo' ? 'Screenshot' : 'File'), size: file.size, kind: c.kind, mime: c.mime || '', state: 'reading', progress: 0, mediaId: '', error: '' };
2259
+ u.items.push(t);
2260
+ sendLiveFile(f.sid, t, false);
2261
+ });
2262
+ u.note = refusals.join(' ');
2263
+ // Opened by the person's own add, in the same task as the paste or drop, never later when an upload lands.
2264
+ A(f.sid).filesOpen = true;
2265
+ paintLiveFiles();
2266
+ }
2267
+ var ATTACH_COPY = {
2268
+ file_context_limit: 'This meeting holds as many files as Meeting Ask reads. Remove one in COS Control on your Mac, then retry.',
2269
+ file_context_storage_limit: 'COS is out of room for meeting files. Remove some in COS Control on your Mac, then retry.',
2270
+ file_revision_conflict: 'The meeting’s files changed while this one was read. Retry.',
2271
+ media_not_found: 'The upload expired before it was added. Retry.', media_expired: 'The upload expired before it was added. Retry.',
2272
+ media_conflict: 'The upload is still being read. Retry in a moment.', media_unavailable: 'COS could not read the upload. Retry.',
2273
+ maintenance: 'COS is updating. Retry in a minute.', invalid_session_id: 'Meeting Ask does not take files for this recording.',
2274
+ invalid_media: 'COS did not accept that file. Retry, or attach it from COS Control on your Mac.'
2275
+ };
2276
+ // The media store has it; now the meeting. A changed server (it restarted) is read again once before giving up.
2277
+ function attachLiveFile(sid, t, again) {
2278
+ t.state = 'attaching'; paintLiveFiles();
2279
+ return loadConfig(again).then(function (conf) {
2280
+ if (!conf) { var e = new Error(S.conf.notice || 'Meeting assistance is not available on this server.'); e.code = 'config'; throw e; }
2281
+ return answer('/api/meeting-ask/files/media', { method: 'POST', body: { sessionId: sid, serverInstanceId: conf.serverInstanceId, media: [{ id: t.mediaId, name: t.name.slice(0, 240) }] } });
2282
+ }).then(function (a) {
2283
+ var b = a.body || {}, code = typeof b.error === 'string' ? b.error : (b.error && b.error.code) || b.reason || '';
2284
+ if (a.status === 200 && Array.isArray(b.files)) return b.files;
2285
+ if (a.status === 409 && code === 'server_instance_mismatch' && !again) return attachLiveFile(sid, t, true);
2286
+ var err = new Error(a.status === 0 ? 'COS on your Mac did not answer. Retry when it is awake.' : a.status === 401 ? SIGNED_OUT + ' The file is kept here; sign in, then retry.' : ATTACH_COPY[code] || 'COS could not add the file to this meeting (HTTP ' + a.status + '). Retry.');
2287
+ err.code = 'attach'; throw err;
2288
+ });
2289
+ }
2290
+ function sendLiveFile(sid, t, retry) {
2291
+ var u = liveUploads(sid);
2292
+ t.state = t.mediaId ? 'attaching' : 'reading'; t.progress = 0; t.error = '';
2293
+ var step = t.mediaId ? Promise.resolve({ id: t.mediaId }) : W.cosChat.uploadMedia(t, {
2294
+ uploading: function () { t.state = 'uploading'; paintLiveFiles(); },
2295
+ progress: function (p) { t.progress = p; paintUploadProgress(t); }
2296
+ });
2297
+ if (retry) paintLiveFiles();
2298
+ step.then(function (ref) {
2299
+ if (u.items.indexOf(t) < 0) return null;
2300
+ if (!ref || !ref.id) { var e = new Error('COS did not keep the file. Retry.'); e.code = 'attach'; throw e; }
2301
+ t.mediaId = ref.id;
2302
+ return attachLiveFile(sid, t, false);
2303
+ }).then(function (files) {
2304
+ if (!files || u.items.indexOf(t) < 0) return;
2305
+ u.items = u.items.filter(function (x) { return x !== t; });
2306
+ var a = A(sid); a.files = files;
2307
+ repaintAsk(sid); paintLiveFiles();
2308
+ }).catch(function (err) {
2309
+ if (u.items.indexOf(t) < 0 || (err && err.code === 'aborted')) return;
2310
+ t.state = 'failed';
2311
+ t.error = err && (err.code === 'attach' || err.code === 'config') ? err.message
2312
+ : err && err.status === 401 ? SIGNED_OUT + ' The file is kept here; sign in, then retry.'
2313
+ : W.cosChat.uploadErrorText(err);
2314
+ paintLiveFiles();
2315
+ });
2316
+ }
2317
+ function uploadMeta(t) {
2318
+ if (t.state === 'reading') return t.kind === 'photo' ? 'Preparing the image' : 'Preparing';
2319
+ if (t.state === 'uploading') return 'Uploading ' + Math.round(t.progress * 100) + '%';
2320
+ if (t.state === 'attaching') return t.kind === 'photo' ? 'Reading the text in the image' : 'Adding to the meeting';
2321
+ return t.error || 'Did not upload';
2322
+ }
2323
+ function uploadRowHtml(t) {
2324
+ return '<li class="mt-up ' + t.state + '" data-up="' + esc(t.id) + '"><span class="mt-files-name">' + esc(t.name) + '</span>' +
2325
+ '<span class="mt-up-meta ' + (t.state === 'failed' ? 'mt-amber' : 'mt-muted') + '" data-up-meta>' + esc(uploadMeta(t)) + '</span>' +
2326
+ '<span class="mt-up-acts">' + (t.state === 'failed' ? '<button type="button" class="mt-link" data-mt="up-retry" data-up="' + esc(t.id) + '">Retry</button>' : '') +
2327
+ '<button type="button" class="mt-link" data-mt="up-remove" data-up="' + esc(t.id) + '" aria-label="Remove ' + esc(t.name) + '">' + (t.state === 'failed' ? 'Remove' : 'Cancel') + '</button></span>' +
2328
+ (t.state === 'uploading' || t.state === 'reading' ? '<span class="mt-up-bar" data-up-bar style="width:' + Math.round(t.progress * 100) + '%"></span>' : '') + '</li>';
2329
+ }
2330
+ // Progress moves the bar and its words only, so a tap on Cancel never misses.
2331
+ function paintUploadProgress(t) {
2332
+ var row = pane.querySelector('[data-up="' + t.id + '"]'); if (!row) return;
2333
+ var meta = row.querySelector('[data-up-meta]'); if (meta) meta.textContent = uploadMeta(t);
2334
+ var bar = row.querySelector('[data-up-bar]'); if (bar) bar.style.width = Math.round(t.progress * 100) + '%';
2335
+ }
2336
+ function paintLiveFiles() {
2337
+ var el = $('mt-lfiles'); if (!el || S.view !== 'live') return;
2338
+ var f = S.feeds[S.liveSid]; if (!f) return;
2339
+ var u = liveUploads(f.sid);
2340
+ var html = filesRowHtml(f.sid, { on: canAddFiles(f), items: u.items, note: u.note });
2341
+ if (html !== S.filesHtml) { S.filesHtml = html; el.innerHTML = html; }
2342
+ }
2343
+ function liveNote(text) { LU().note = text; if (S.liveSid) A(S.liveSid).filesOpen = true; paintLiveFiles(); }
2344
+ // ── Prep in the live view (6.99.19, PREP_CONTRACT_v1) ──
2345
+ // A prep made before the call (the chat's Prep me) can join this meeting. GET /api/prep/suggest lists up to five
2346
+ // drafts, closest to this recording's start first. One area under the files offers them: one prep as a line, several
2347
+ // as a short list, each with its own Add to this meeting. Only that tap binds (POST .../bind); its notes and files
2348
+ // then join the meeting's files, each marked From prep.
2349
+ // The area's height is reserved from the first paint whenever a suggestion is possible (Prep on, or not known yet,
2350
+ // and a recording that takes files), and every state (looking, none, one, several, adding, added, ended) fills the
2351
+ // same fixed box, so nothing under it moves when the answer arrives. The transcript box sits above it.
2352
+ function prepCapsNow() { var cc = W.cosChat; return cc && typeof cc.prepCaps === 'function' ? cc.prepCaps() : null; }
2353
+ function livePrep(sid) { if (!S.lprep || S.lprep.sid !== sid) S.lprep = { sid: sid, asked: false, state: 'idle', drafts: [], busy: '', done: null, note: '', changed: '' }; return S.lprep; }
2354
+ function notePrepInstance(b) { var cc = W.cosChat; if (b && cc && typeof cc.notePrepInstance === 'function') cc.notePrepInstance(b.serverInstanceId); }
2355
+ function prepBoundTo(d, sid) { return ((d && d.bindings) || []).some(function (b) { return b && b.sessionId === sid && !b.removedAt; }); }
2356
+ function prepReadyFiles(d) { return ((d && d.files) || []).filter(function (x) { return x && x.status === 'ready'; }); }
2357
+ function prepHasNotes(d) { return !!String((d && d.steer) || '').trim(); }
2358
+ // Whether this live view may offer a prep at all: the area is reserved exactly when this is true.
2359
+ function prepPossible(f) {
2360
+ var cc = W.cosChat;
2361
+ if (!f || !canAddFiles(f) && !(S.lprep && S.lprep.sid === f.sid && (S.lprep.done || S.lprep.state !== 'idle'))) return false;
2362
+ if (!cc || typeof cc.known !== 'function') return false;
2363
+ if (!cc.known()) return true;
2364
+ var caps = prepCapsNow();
2365
+ return !!(caps && caps.suggest && caps.bind);
2366
+ }
2367
+ function suggestPrep(f) {
2368
+ if (!f || f.endedAt || !f.start || !canAddFiles(f)) return;
2369
+ var p = livePrep(f.sid), cc = W.cosChat;
2370
+ if (p.asked || !cc || typeof cc.ensureCaps !== 'function') return;
2371
+ p.asked = true; p.state = 'checking';
2372
+ cc.ensureCaps().then(function () {
2373
+ var caps = prepCapsNow();
2374
+ if (!caps || !caps.suggest || !caps.bind) { p.state = 'off'; paintLivePrep(); return; }
2375
+ return answer('/api/prep/suggest?sessionId=' + encodeURIComponent(f.sid) + '&startedAt=' + Math.round(f.start)).then(function (r) {
2376
+ if (S.lprep !== p) return;
2377
+ notePrepInstance(r.body);
2378
+ p.drafts = r.status === 200 && Array.isArray(r.body.drafts) ? r.body.drafts.filter(function (d) {
2379
+ return d && typeof d.id === 'string' && !prepBoundTo(d, f.sid) && (prepHasNotes(d) || prepReadyFiles(d).length > 0);
2380
+ }).slice(0, 5) : [];
2381
+ p.state = r.status === 200 ? 'ready' : 'failed';
2382
+ paintLivePrep();
2383
+ });
2384
+ });
2385
+ }
2386
+ function prepWhat(d) {
2387
+ var n = prepReadyFiles(d).length, files = n + (n === 1 ? ' file' : ' files');
2388
+ return prepHasNotes(d) ? (n ? 'its notes and ' + files : 'its notes') : 'its ' + files;
2389
+ }
2390
+ function prepAddBtn(p, d) {
2391
+ return '<button type="button" class="cx-btn quiet" data-mt="prep-bind" data-id="' + esc(d.id) + '"' + (p.busy ? ' disabled' : '') + '>' + (p.busy === d.id ? 'Adding…' : 'Add to this meeting') + '</button>';
2392
+ }
2393
+ function prepQuiet(text, warn) { return '<div class="mt-prepline" role="status"><span class="mt-prepline-text' + (warn ? ' mt-amber' : '') + '">' + esc(text) + '</span></div>'; }
2394
+ function livePrepHtml() {
2395
+ var f = S.feeds[S.liveSid];
2396
+ if (!prepPossible(f)) return '';
2397
+ var p = livePrep(f.sid), html;
2398
+ if (p.done) html = prepQuiet('Added ' + (cleanTitle(p.done.title) ? 'the prep for ' + cleanTitle(p.done.title) : 'the prep') + ' to this meeting. Its files say From prep.');
2399
+ else if (f.endedAt) html = prepQuiet(p.drafts.length ? 'The recording ended, so prep can no longer be added here.' : 'No prep was added to this meeting.');
2400
+ else if (p.state === 'failed') html = prepQuiet('Prep for this meeting could not be checked. It is checked again when you reopen this view.', true);
2401
+ else if (p.state !== 'ready') html = prepQuiet('Looking for a prep for this meeting…');
2402
+ else if (!p.drafts.length) html = prepQuiet('No prep for this meeting. Make one from a Prep me card in COS chat.');
2403
+ else if (p.drafts.length === 1) {
2404
+ var d = p.drafts[0], title = cleanTitle(d.title) || 'this meeting';
2405
+ var text = p.note || 'Prep for ' + title + (p.changed === d.id ? ' changed since it was offered: add ' : ' is ready: add ') + prepWhat(d) + ' to this meeting';
2406
+ html = '<div class="mt-prepline"><span class="mt-prepline-text' + (p.note ? ' mt-amber' : '') + '" title="' + esc(text) + '">' + esc(text) + '</span>' + prepAddBtn(p, d) + '</div>';
2407
+ } else {
2408
+ // Several: the person picks. Each row says what it adds; the list scrolls inside the same box.
2409
+ html = '<div class="mt-prepline list"><ul class="mt-preplist" aria-label="' + esc(p.drafts.length + ' preps are ready for this meeting') + '">' +
2410
+ (p.note ? '<li class="mt-prepnote"><span class="mt-prepline-text mt-amber">' + esc(p.note) + '</span></li>' : '') +
2411
+ p.drafts.map(function (d) {
2412
+ var label = (cleanTitle(d.title) || 'Prep') + (p.changed === d.id ? ' (changed)' : '') + ': ' + prepWhat(d).replace(/^its /, '');
2413
+ return '<li><span class="mt-prepline-text" title="' + esc(label) + '">' + esc(label) + '</span>' + prepAddBtn(p, d) + '</li>';
2414
+ }).join('') + '</ul></div>';
2415
+ }
2416
+ return '<div class="mt-prepslot">' + html + '</div>';
2417
+ }
2418
+ function paintLivePrep() {
2419
+ var el = $('mt-lprep'); if (!el || S.view !== 'live') return;
2420
+ var html = livePrepHtml();
2421
+ if (html !== S.prepHtml) { S.prepHtml = html; el.innerHTML = html; }
2422
+ }
2423
+ var PREP_SERVER_CHANGED = 'A different COS server answered than the one that offered this prep. It was read again; tap Add to this meeting again.';
2424
+ var PREP_BIND_COPY = {
2425
+ maintenance: 'COS is updating. Try again in a minute.', invalid_session_id: 'This recording does not take prep files.',
2426
+ file_context_limit: 'This meeting holds as many files as Meeting Ask reads. Remove one in COS Control on your Mac, then try again.',
2427
+ file_context_storage_limit: 'COS is out of room for meeting files. Remove some in COS Control on your Mac, then try again.',
2428
+ not_found: 'That prep is no longer on your Mac.', unknown_session: 'COS does not know this recording yet. Try again in a moment.',
2429
+ request_reused: 'COS saw a different request with the same id. Reload the page, then try again.'
2430
+ };
2431
+ function kb(n) { return Math.max(1, Math.round(num(n) / 1024)).toLocaleString() + ' KB'; }
2432
+ // v1.1: the bind names the revision the offer described ({revision, sessionId, serverInstanceId, surface}), so a newer
2433
+ // version nobody has seen is never attached. The server id is the newest one read: capabilities.prep or the most
2434
+ // recent Prep answer (W.cosChat.prepInstance), else Meeting Ask's, which is the same id.
2435
+ function bindOnce(sid, d) {
2436
+ var cc = W.cosChat, known = cc && typeof cc.prepInstance === 'function' ? cc.prepInstance() : '';
2437
+ return (known ? Promise.resolve(known) : loadConfig().then(function (conf) { return conf && conf.serverInstanceId; })).then(function (inst) {
2438
+ if (!inst) throw new Error(S.conf.notice || 'Meeting assistance is not available on this server.');
2439
+ // Amendment 19: the header is what authorizes the write; the body carries the same id.
2440
+ return answer('/api/prep/drafts/' + encodeURIComponent(d.id) + '/bind', { method: 'POST', headers: { 'X-COS-Expected-Server-Instance': inst }, body: { revision: d.revision, sessionId: sid, serverInstanceId: inst, surface: 'web' } });
2441
+ }).then(function (a) {
2442
+ var b = a.body || {}, code = typeof b.error === 'string' ? b.error : (b.error && b.error.code) || b.reason || '';
2443
+ if (a.status >= 200 && a.status < 300 || a.status === 409) notePrepInstance(b);
2444
+ if (a.status >= 200 && a.status < 300) return b;
2445
+ // A different server than the one that offered it: the prep is read again (amendment 19) and the line says so.
2446
+ // Adding it takes another tap, against the server that answers now.
2447
+ if (a.status === 409 && code === 'server_instance_mismatch') {
2448
+ return answer('/api/prep/drafts/' + encodeURIComponent(d.id)).then(function (g) {
2449
+ if (g.status === 200) notePrepInstance(g.body);
2450
+ var e = new Error(PREP_SERVER_CHANGED);
2451
+ e.draft = g.status === 200 && g.body.draft ? g.body.draft : d;
2452
+ e.changedServer = true;
2453
+ throw e;
2454
+ });
2455
+ }
2456
+ if (a.status === 428 && typeof console !== 'undefined') console.error('[prep] bind went without X-COS-Expected-Server-Instance');
2457
+ var err = new Error(a.status === 0 ? 'COS on your Mac did not answer. Try again when it is awake.' : a.status === 401 ? SIGNED_OUT
2458
+ : a.status === 428 ? 'This page did not name the Mac’s COS server, so nothing was added. Reload the page, then try again.'
2459
+ : code === 'file_context_limit' && b.needBytes != null ? 'This meeting has too much file text for this prep: it needs ' + kb(b.needBytes) + ' and the meeting has room for ' + kb(b.haveBytes) + '. Remove a large file from the meeting in COS Control on your Mac, then try again.'
2460
+ : PREP_BIND_COPY[code] || (a.status === 404 ? PREP_BIND_COPY.not_found : 'COS could not add the prep (HTTP ' + a.status + '). Try again.'));
2461
+ // Changed since it was offered: the area now describes the newer version, and adding it takes another tap.
2462
+ if (a.status === 409 && code === 'revision_conflict' && b.draft) err.draft = b.draft;
2463
+ throw err;
2464
+ });
2465
+ }
2466
+ // Only from a tap on Add to this meeting: nothing here binds on its own.
2467
+ function bindPrep(id) {
2468
+ var f = S.feeds[S.liveSid], p = S.lprep;
2469
+ if (!f || !p || p.sid !== f.sid || p.busy || p.done || f.endedAt) return;
2470
+ var d = p.drafts.filter(function (x) { return x.id === id; })[0]; if (!d) return;
2471
+ p.busy = id; p.note = ''; paintLivePrep();
2472
+ bindOnce(f.sid, d).then(function (b) {
2473
+ p.busy = ''; p.done = (b && b.draft) || d;
2474
+ var a = A(f.sid);
2475
+ // The meeting's files as the bind answered them, when it answered Meeting Ask files; and read again either way.
2476
+ if (Array.isArray(b && b.files) && b.files.every(function (x) { return x && typeof x.id === 'string' && x.revision != null; })) a.files = b.files;
2477
+ a.filesOpen = true;
2478
+ paintLivePrep(); paintLiveFiles();
2479
+ (a.openP || Promise.resolve()).then(function () { return askOpen(f.sid, 'poll'); });
2480
+ }, function (err) {
2481
+ p.busy = '';
2482
+ if (err && err.draft && typeof err.draft.id === 'string') {
2483
+ p.drafts = p.drafts.map(function (x) { return x.id === err.draft.id ? err.draft : x; });
2484
+ if (err.changedServer) p.note = err.message; else { p.changed = err.draft.id; p.note = ''; }
2485
+ } else p.note = 'Not added. ' + ((err && err.message) || 'Try again.');
2486
+ paintLivePrep();
2487
+ });
2488
+ }
2489
+ function findUpload(id) { var u = LU(); for (var i = 0; i < u.items.length; i++) if (u.items[i].id === id) return u.items[i]; return null; }
2490
+ function onLivePaste(e) {
2491
+ if (S.view !== 'live') return;
2492
+ var dt = e.clipboardData;
2493
+ if (!carriesFile(dt)) return;
2494
+ // Taken as the file, never as its name or path in a text box.
2495
+ e.preventDefault();
2496
+ var files = filesOf(dt);
2497
+ if (files.length) addLiveFiles(files);
2498
+ else liveNote('This browser did not hand over the file. Use Add file to choose it.');
2499
+ }
2500
+ function onLiveDrag(e) {
2501
+ if (S.view !== 'live') return;
2502
+ var dt = e.dataTransfer;
2503
+ if (!carriesFile(dt)) return;
2504
+ // Every file drag is ours: the browser must not open the file, or drop its path into the Ask box.
2505
+ e.preventDefault();
2506
+ var main = $('mt-lmain');
2507
+ if (e.type === 'dragenter' || e.type === 'dragover') { if (dt) dt.dropEffect = 'copy'; if (main) main.classList.add('dropping'); S.dragAt = Date.now(); return; }
2508
+ if (e.type === 'dragleave') { if (main && (!e.relatedTarget || !pane.contains(e.relatedTarget))) main.classList.remove('dropping'); return; }
2509
+ if (main) main.classList.remove('dropping');
2510
+ var files = filesOf(dt);
2511
+ if (files.length) addLiveFiles(files);
2512
+ else liveNote('This browser did not hand over the file. Use Add file to choose it.');
2011
2513
  }
2012
2514
  function paintTurns(force) {
2013
2515
  var el = $('mt-turns'); if (!el) return;
@@ -2017,7 +2519,8 @@
2017
2519
  S.turnsDirty = false;
2018
2520
  var f = S.feeds[S.liveSid]; if (!f) return;
2019
2521
  var hours = f.turns.some(function (t) { return t.elapsedMs >= 3600000; });
2020
- // Reading (not following): hold what is on screen still while turns above it are refined or new ones arrive.
2522
+ // Reading (not following): hold the first line on screen still while turns above it are refined. A new turn lands
2523
+ // below the reader, inside the box, so it moves nothing and needs no scroll at all.
2021
2524
  var anchor = S.follow ? null : turnsAnchor(el);
2022
2525
  if (!f.turns.length) {
2023
2526
  el.innerHTML = '<p class="mt-lempty">' + esc(f.endedAt ? 'No words were transcribed before the recording ended.' : 'Waiting for the first words. Text appears a few seconds after it is spoken.') + '</p>';
@@ -2038,45 +2541,52 @@
2038
2541
  prev = node;
2039
2542
  });
2040
2543
  }
2041
- if (S.follow) jumpToLive(); else { holdAnchor(anchor); paintLiveFoot(); }
2544
+ if (S.follow) pinBottom(el); else holdAnchor(el, anchor);
2545
+ paintJump();
2042
2546
  repaintAskEnabled(f.sid);
2043
2547
  }
2044
2548
  function turnInner(t, hours) {
2045
2549
  return '<div class="mt-lwho"><span class="mt-lname">' + esc(t.speaker) + '</span><span class="mt-lstamp">' + esc(stampMs(t.elapsedMs, hours)) + '</span></div>' +
2046
2550
  t.text.split(/\n{2,}/).map(function (p) { return '<p>' + esc(p) + '</p>'; }).join('');
2047
2551
  }
2048
- // iOS Safari has no scroll anchoring. The anchor is the first turn still on screen; when every turn is above the
2049
- // screen (reading Ask under the transcript) it is the transcript's bottom edge, so the panel below holds still.
2050
- function turnsAnchor(el) {
2051
- var kids = el.children;
2552
+ // iOS Safari has no scroll anchoring, and Chrome's would fight this one (the box sets overflow-anchor: none), so the
2553
+ // anchor is ours on every browser: the first turn still showing in the box, and its distance from the box's top.
2554
+ function turnsAnchor(box) {
2555
+ var kids = box.children, top = box.scrollTop;
2052
2556
  for (var i = 0; i < kids.length; i++) {
2053
- var r = kids[i].getBoundingClientRect();
2054
- if (r.bottom > 0) return r.top >= window.innerHeight ? null : { node: kids[i], top: r.top };
2557
+ var n = kids[i];
2558
+ if (n.offsetTop + n.offsetHeight > top) return { node: n, off: n.offsetTop - top };
2055
2559
  }
2056
- return kids.length ? { node: el, edge: true, top: el.getBoundingClientRect().bottom } : null;
2560
+ return null;
2057
2561
  }
2058
- function holdAnchor(a) {
2562
+ // Moves the box only when a turn above the reader changed height; a write to scrollTop during an iOS fling stops it.
2563
+ function holdAnchor(box, a) {
2059
2564
  if (!a || !a.node.isConnected) return;
2060
- var now = a.edge ? a.node.getBoundingClientRect().bottom : a.node.getBoundingClientRect().top;
2061
- var dy = now - a.top;
2062
- if (Math.abs(dy) >= 1) { S.autoScroll = Date.now(); window.scrollBy(0, dy); }
2565
+ var dy = (a.node.offsetTop - box.scrollTop) - a.off;
2566
+ if (Math.abs(dy) >= 1) box.scrollTop += dy;
2063
2567
  }
2568
+ function atBottom(box) { return box.scrollHeight - box.scrollTop - box.clientHeight <= 40; }
2569
+ function pinBottom(box) { var end = box.scrollHeight - box.clientHeight; if (end - box.scrollTop > 0.5) box.scrollTop = end; }
2064
2570
  // A question needs words: the panel's buttons follow the transcript's first turn.
2065
2571
  function repaintAskEnabled(sid) {
2066
2572
  var a = A(sid), en = askEnabled(sid);
2067
2573
  if (a.lastEnabled !== en) { a.lastEnabled = en; repaintAsk(sid); }
2068
2574
  }
2069
2575
  function jumpToLive() {
2070
- var el = $('mt-turns'), last = el && el.lastElementChild;
2071
- if (last) { S.autoScroll = Date.now(); last.scrollIntoView({ block: 'end' }); }
2072
- S.follow = true; paintLiveFoot();
2576
+ var el = $('mt-turns');
2577
+ if (el) pinBottom(el);
2578
+ S.follow = true; paintJump();
2579
+ }
2580
+ // Jump to live floats over the box's bottom edge: showing it never moves a line.
2581
+ function paintJump() {
2582
+ var b = $('mt-jump'), f = S.feeds[S.liveSid];
2583
+ if (b) b.hidden = S.follow || !(f && f.turns.length);
2073
2584
  }
2074
2585
  function paintLiveFoot() {
2075
2586
  var el = $('mt-lfoot'); if (!el || S.view !== 'live') return;
2076
2587
  var f = S.feeds[S.liveSid], has = !!(f && f.turns.length);
2077
2588
  var html = '<div class="mt-bar"><button type="button" class="cx-btn primary" data-mt="live-copy"' + (has ? '' : ' disabled') + '>Copy transcript</button>' +
2078
- '<button type="button" class="cx-btn quiet" data-mt="live-context">Copy full context</button>' +
2079
- (!S.follow && has ? '<button type="button" class="cx-btn quiet" data-mt="jump">Jump to live</button>' : '') + '</div>' +
2589
+ '<button type="button" class="cx-btn quiet" data-mt="live-context">Copy full context</button></div>' +
2080
2590
  (S.liveCopy ? '<p class="mt-copynote">' + esc(S.liveCopy) + '</p>' : S.liveReason ? '<p class="mt-copynote mt-amber">' + esc('Live transcript paused · ' + S.liveReason.replace(/_/g, ' ')) + '</p>' : '');
2081
2591
  if (html !== S.footHtml) { S.footHtml = html; el.innerHTML = html; }
2082
2592
  }
@@ -2088,7 +2598,9 @@
2088
2598
  if (contextReady([f.sid])) {
2089
2599
  var a = A(f.sid);
2090
2600
  var cueNote = a.cues ? null : (a.cuesState && a.cuesState !== 'older' ? 'Context coverage: cues could not be read from the server.' : null);
2091
- var text = fullContext('Live meeting', f.turns.length ? liveCopyText(f) : '', a.history, evidenceFiles(a.history, a.files || []), a.cues || [], cueNote) + (a.error ? '\n\nContext coverage: ' + a.error : '');
2601
+ // The first line names the meeting in plain words ("Live meeting: <title>"), so a session started from this paste
2602
+ // is titled by it; the sections under it are what they were.
2603
+ var text = fullContext(liveContextTitle(f), f.turns.length ? liveCopyText(f) : '', a.history, evidenceFiles(a.history, a.files || []), a.cues || [], cueNote, true) + (a.error ? '\n\nContext coverage: ' + a.error : '');
2092
2604
  W.copyText(text).then(function () { done('Copied full meeting context'); }, function (err) { done(err.message); });
2093
2605
  return;
2094
2606
  }
@@ -2098,14 +2610,12 @@
2098
2610
  return askOpen(f.sid, true).then(function () { return loadCues(f.sid); }).then(function () { done('Ready. Tap Copy full context again.'); });
2099
2611
  });
2100
2612
  }
2101
- function onScroll() {
2102
- if (S.view !== 'live' || Date.now() - (S.autoScroll || 0) < 600) return;
2103
- var el = $('mt-turns'), last = el && el.lastElementChild;
2104
- if (!last) return;
2105
- // Following means the end of the transcript is on screen. Scrolled past it (reading Ask below) is not following:
2106
- // a new chunk must not pull the reader back up.
2107
- var b = last.getBoundingClientRect().bottom, atEnd = b <= window.innerHeight + 40 && b >= 0;
2108
- if (atEnd !== S.follow) { S.follow = atEnd; paintLiveFoot(); if (atEnd && S.turnsDirty) paintTurns(false); }
2613
+ // Following means the end of the transcript shows in its box. Scrolled up is reading: a new line must not pull the
2614
+ // reader down. The page's own scroll is never touched.
2615
+ function onBoxScroll(e) {
2616
+ if (S.view !== 'live' || !e.target || e.target.id !== 'mt-turns') return;
2617
+ var end = atBottom(e.target);
2618
+ if (end !== S.follow) { S.follow = end; paintJump(); if (end && S.turnsDirty) paintTurns(false); }
2109
2619
  }
2110
2620
 
2111
2621
  // ── list shell ────────────────────────────────────────
@@ -2116,7 +2626,7 @@
2116
2626
  '<div id="mt-cal"></div><div id="mt-toolbar"></div><div id="mt-list"></div></div>';
2117
2627
  paintHero(); paintOrphans(); paintLive(); paintCal(); paintToolbar(); paintList();
2118
2628
  }
2119
- function stopTimers() { clearInterval(S.listTimer); clearInterval(S.liveTimer); S.listTimer = null; S.liveTimer = null; }
2629
+ function stopTimers() { clearInterval(S.listTimer); clearInterval(S.liveTimer); clearInterval(S.clockTimer); S.listTimer = null; S.liveTimer = null; S.clockTimer = null; }
2120
2630
 
2121
2631
  // ── events ────────────────────────────────────────────
2122
2632
  function exchangeOf(sid, req) { return A(sid).history.filter(function (x) { return x.requestId === req; })[0]; }
@@ -2165,7 +2675,10 @@
2165
2675
  return;
2166
2676
  }
2167
2677
  case 'ask-refresh': loadConfig(true).then(function () { panelOpen(sid); }); return;
2168
- case 'files-toggle': a = A(sid); a.filesOpen = !a.filesOpen; if (S.view === 'live' && S.liveSid === sid) paintLiveHead(); else repaintAsk(sid); return;
2678
+ case 'files-toggle': a = A(sid); a.filesOpen = !a.filesOpen; if (S.view === 'live' && S.liveSid === sid) paintLiveFiles(); else repaintAsk(sid); return;
2679
+ case 'add-file': { var pick = $('mt-lpick'); if (pick) pick.click(); return; }
2680
+ case 'up-retry': { var ru = findUpload(t.getAttribute('data-up')); if (ru && ru.state === 'failed' && S.liveSid) sendLiveFile(S.liveSid, ru, true); return; }
2681
+ case 'up-remove': { var xu = findUpload(t.getAttribute('data-up')); if (xu) { abortUpload(xu); LU().items = LU().items.filter(function (q) { return q !== xu; }); paintLiveFiles(); } return; }
2169
2682
  case 'ask-act': submit(sid, t.getAttribute('data-action'), t.getAttribute('data-q')); return;
2170
2683
  case 'ask-retry': ex = exchangeOf(sid, req); if (ex) submit(sid, ex.action, ex.question, ex.requestId); return;
2171
2684
  case 'ask-stop': cancelAnswer(sid, req); return;
@@ -2185,6 +2698,7 @@
2185
2698
  case 'live-copy': liveCopy('transcript'); return;
2186
2699
  case 'live-context': liveCopy('context'); return;
2187
2700
  case 'jump': jumpToLive(); return;
2701
+ case 'prep-bind': bindPrep(t.getAttribute('data-id')); return;
2188
2702
  }
2189
2703
  });
2190
2704
  pane.addEventListener('input', function (e) {
@@ -2216,6 +2730,7 @@
2216
2730
  if (sid && A(sid).panelDirty) setTimeout(function () { repaintAsk(sid); }, 0);
2217
2731
  });
2218
2732
  pane.addEventListener('change', function (e) {
2733
+ if (e.target.id === 'mt-lpick') { addLiveFiles(e.target.files); e.target.value = ''; return; }
2219
2734
  if (e.target.id === 'mt-recency') { S.recency = e.target.value; paintBrowse(); }
2220
2735
  else if (e.target.id === 'mt-dom') { S.domain = e.target.value; if (queryActive()) runSearch(); else paintBrowse(); }
2221
2736
  else if (e.target.hasAttribute && e.target.hasAttribute('data-mt-model')) setModel(e.target.value);
@@ -2233,7 +2748,13 @@
2233
2748
  var sid = t.getAttribute('data-sid'); submit(sid, 'ask', A(sid).draft);
2234
2749
  }
2235
2750
  });
2236
- window.addEventListener('scroll', onScroll, { passive: true });
2751
+ // scroll does not bubble: the transcript box's own scroll is caught on the way down.
2752
+ pane.addEventListener('scroll', onBoxScroll, true);
2753
+ pane.addEventListener('paste', onLivePaste);
2754
+ // The strip is drawn at the box's own pixel size and in the theme's colours: draw again when either changes.
2755
+ window.addEventListener('resize', function () { if (S.view === 'live') drawMeter(); });
2756
+ if (typeof MutationObserver === 'function') new MutationObserver(function () { if (S.view === 'live') drawMeter(); }).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
2757
+ ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(function (type) { pane.addEventListener(type, onLiveDrag); });
2237
2758
  document.addEventListener('visibilitychange', function () {
2238
2759
  if (document.hidden) return;
2239
2760
  if (S.out) return;
@@ -2242,6 +2763,12 @@
2242
2763
  });
2243
2764
  window.addEventListener('focus', refreshShownAsk);
2244
2765
 
2766
+ // Test hooks (server/web-control/live-meeting-view.test.ts drives the real pane through these and through its events).
2767
+ W.meetingsLive = {
2768
+ _state: S, _elapsedLabel: elapsedLabel, _cleanLevels: cleanLevels, _visibleLevels: visibleLevels, _levelSilent: levelSilent,
2769
+ _carriesFile: carriesFile, _filesOf: filesOf, _addLiveFiles: addLiveFiles, _drawMeter: drawMeter, _tickClock: tickClock,
2770
+ _livePoll: livePoll, _feed: function (sid) { return S.feeds[sid]; }, _uploads: function () { return LU(); }
2771
+ };
2245
2772
  W.registerPane('meetings', {
2246
2773
  load: function (args) {
2247
2774
  args = args || [];