@gotcos/glasses-server 6.99.25 → 6.99.29

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 (44) hide show
  1. package/CHANGELOG.md +166 -0
  2. package/package.json +1 -1
  3. package/server/index.ts +46 -4
  4. package/server/lib/agent-session-output-images.ts +280 -0
  5. package/server/lib/agent-session-store.ts +3 -1
  6. package/server/lib/agent-session-turns.ts +18 -7
  7. package/server/lib/client-liveness.ts +38 -3
  8. package/server/lib/codex-subagent-activity.ts +36 -0
  9. package/server/lib/g2-turn-provenance.ts +679 -0
  10. package/server/lib/media-store.ts +41 -2
  11. package/server/lib/permission-broker.ts +94 -17
  12. package/server/lib/queue-turn-evidence.ts +19 -2
  13. package/server/lib/session-halt.ts +15 -1
  14. package/server/lib/session-stream-events.ts +4 -0
  15. package/server/lib/session-subagents.ts +155 -0
  16. package/server/lib/session-turn-attachments.ts +245 -0
  17. package/server/lib/thread-turn-queue-deliver.ts +17 -2
  18. package/server/lib/thread-turn-queue.ts +10 -0
  19. package/server/lib/today-brief.ts +731 -0
  20. package/server/lib/turn-file-store.ts +202 -0
  21. package/server/lib/turn-footer.ts +81 -0
  22. package/server/routes/agent-session-bindings.ts +164 -16
  23. package/server/routes/agent-sessions.ts +77 -8
  24. package/server/routes/client-instance.ts +12 -1
  25. package/server/routes/cursor-stop-followup.ts +30 -1
  26. package/server/routes/g2-turns.ts +46 -0
  27. package/server/routes/health.ts +9 -0
  28. package/server/routes/media.ts +32 -8
  29. package/server/routes/permission-broker.ts +47 -4
  30. package/server/routes/thread-turn-queue.ts +148 -8
  31. package/server/routes/today-brief.ts +148 -0
  32. package/server/web-control/app.css +2 -0
  33. package/server/web-control/app.js +738 -102
  34. package/server/web-control/index.html +5 -1
  35. package/server/web-control/panes/cos-chat.css +18 -0
  36. package/server/web-control/panes/cos-chat.js +239 -45
  37. package/server/web-control/panes/meetings.js +20 -9
  38. package/server/web-control/panes/questions.css +39 -1
  39. package/server/web-control/panes/questions.js +368 -51
  40. package/server/web-control/panes/reader-media.js +154 -0
  41. package/server/web-control/panes/sessions.css +2 -0
  42. package/server/web-control/panes/settings.js +34 -2
  43. package/server/web-control/panes/today-actions.css +62 -4
  44. package/server/web-control/panes/today-actions.js +384 -53
@@ -40,6 +40,7 @@
40
40
  <symbol id="i-photo" viewBox="0 0 24 24"><rect x="3.5" y="5" width="17" height="14" rx="2.5"/><circle cx="9" cy="10" r="1.6"/><path d="m4 17 5-4.5 3.5 3 3-2.5 4.5 4"/></symbol>
41
41
  <symbol id="i-video" viewBox="0 0 24 24"><rect x="3.5" y="6" width="12.5" height="12" rx="2.5"/><path d="m16 10.5 4.5-2.5v8L16 13.5"/></symbol>
42
42
  <symbol id="i-doc" viewBox="0 0 24 24"><path d="M7 3.5h6.5L18 8v12a.5.5 0 0 1-.5.5h-10.5A.5.5 0 0 1 6.5 20V4a.5.5 0 0 1 .5-.5z"/><path d="M13.5 3.5V8H18M9 12.5h6M9 15.5h6"/></symbol>
43
+ <symbol id="i-camera" viewBox="0 0 24 24"><path d="M4.5 8h3l1.6-2.2h5.8L16.5 8h3a1.5 1.5 0 0 1 1.5 1.5v8A1.5 1.5 0 0 1 19.5 19h-15A1.5 1.5 0 0 1 3 17.5v-8A1.5 1.5 0 0 1 4.5 8z"/><circle cx="12" cy="13.2" r="3.4"/></symbol>
43
44
  <symbol id="i-clip" viewBox="0 0 24 24"><path d="m15.5 7.5-6.8 6.8a1.8 1.8 0 0 0 2.5 2.5l7.3-7.3a3.5 3.5 0 0 0-5-5l-7.6 7.6a5.2 5.2 0 0 0 7.4 7.4l5.7-5.7"/></symbol>
44
45
  <symbol id="i-messages" viewBox="0 0 24 24"><path d="M4 7a2.5 2.5 0 0 1 2.5-2.5h11A2.5 2.5 0 0 1 20 7v7a2.5 2.5 0 0 1-2.5 2.5H10L5.5 20v-3.6A2.5 2.5 0 0 1 4 14z"/></symbol>
45
46
  <symbol id="i-usage" viewBox="0 0 24 24"><path d="M4 20h16M7 16v-4M12 16V7M17 16v-7"/></symbol>
@@ -253,8 +254,10 @@
253
254
  <div class="sheet-grip"></div>
254
255
  <form class="sheet-head" id="ask-form">
255
256
  <input id="ask-q" class="field" type="search" placeholder="Ask COS or find a session" autocomplete="off" enterkeyhint="go">
257
+ <span id="ask-clip" class="ask-clip"></span>
256
258
  <button type="button" class="link-btn" id="ask-close">Cancel</button>
257
259
  </form>
260
+ <div id="ask-files" class="cc-chips ask-files hidden" aria-live="polite"></div>
258
261
  <div id="ask-results"></div>
259
262
  </div>
260
263
  </div>
@@ -276,7 +279,8 @@
276
279
  "dictation": "/control/panes/dictation.js",
277
280
  "settings": "/control/panes/settings.js",
278
281
  "memories": "/control/panes/memories.js",
279
- "todayActions": "/control/panes/today-actions.js"
282
+ "todayActions": "/control/panes/today-actions.js",
283
+ "readerMedia": "/control/panes/reader-media.js"
280
284
  }</script>
281
285
  <!-- The Knowledge graph explorer, loaded by panes/memories.js when the graph first opens. -->
282
286
  <script type="application/json" id="memories-graph-assets">{
@@ -198,3 +198,21 @@
198
198
  .cc-prep.dragging .cc-prep-panel { outline: 2px dashed var(--accent); outline-offset: 4px; }
199
199
  .cc-prep { scroll-margin-top: calc(64px + var(--safe-top, 0px)); }
200
200
  @media (min-width: 860px) { .msg-right .cc-prep { padding: 10px 16px 0; max-height: 55%; overflow-y: auto; flex: none; } }
201
+
202
+ /* The Ask sheet's files (11/07, app.js askFiles): the attach kit's chips and clip beside the Ask box. */
203
+ .sheet-head .cc-attach { flex: none; height: 44px; border-radius: 12px; }
204
+ .ask-clip:empty { display: none; }
205
+ .ask-files { margin-top: 10px; }
206
+ .ask-files .ask-files-note { flex-basis: 100%; margin: 0 2px; font-size: 13px; line-height: 1.35; color: var(--sub); }
207
+ #ask-sheet .sheet.dragging { outline: 2px dashed var(--accent); outline-offset: -6px; }
208
+
209
+ /* 6.99.27: a session's reply box with files (app.js paintReaderFiles, panes/today-actions.js): the kit's chips on a row of
210
+ their own above the words, Take photo and the clip beside Fork and Send, and the limits in plain words under the chips. */
211
+ .att-composer { flex-wrap: wrap; }
212
+ .att-composer .att-files { flex: 1 1 100%; padding: 2px 0 4px; }
213
+ .att-composer .att-files.hidden { display: none; }
214
+ .att-clip, .ta-clip { display: inline-flex; gap: 6px; }
215
+ .att-clip:empty { display: none; }
216
+ .att-clip .cc-attach { height: 38px; width: 42px; border-radius: var(--radius-sm); }
217
+ .att-composer.dragging { outline: 2px dashed var(--accent); outline-offset: 4px; }
218
+ .cc-chips .att-limit { flex-basis: 100%; margin: 0 2px; font-size: 12.5px; line-height: 1.35; color: var(--sub); }
@@ -107,7 +107,12 @@
107
107
  steer: !!(C.caps && C.caps.steer),
108
108
  thread: !!(drop && drop.enabled === true && drop.thread && drop.thread.protocol === 1),
109
109
  // 6.99.19: Prep (capabilities.prep, protocol 1). Absent on an older server: "Prep me" stays a plain Ask.
110
- prep: prepCapsOf(caps.prep)
110
+ prep: prepCapsOf(caps.prep),
111
+ // 11/07: how many files a session turn takes (threadTurnAttachments). 0 (an older server): words only, and Today's
112
+ // reply box shows no clip, so a file can never be dropped from a turn in silence.
113
+ turnFiles: h && Number.isInteger(h.threadTurnAttachments) && h.threadTurnAttachments > 0 ? Math.min(h.threadTurnAttachments, MAX_ATTS) : 0,
114
+ // 6.99.27: any file for a session turn, up to this many bytes (threadTurnFileBytes). 0: the chat's list only.
115
+ turnFileBytes: h && Number.isInteger(h.threadTurnFileBytes) && h.threadTurnFileBytes > 0 ? Math.min(h.threadTurnFileBytes, MAX_DOC) : 0
111
116
  };
112
117
  if (next.prep && next.prep.serverInstanceId) PREP_INST = next.prep.serverInstanceId;
113
118
  var changed = !C.caps || C.caps.dropIt !== next.dropIt || C.caps.jobs !== next.jobs || C.caps.thread !== next.thread;
@@ -276,8 +281,7 @@
276
281
  '<div class="cc-chips hidden" data-cos-chips></div>' +
277
282
  '<textarea class="field cc-input" id="' + esc(k.inputId) + '" rows="1" maxlength="2000" placeholder="' + esc(o.placeholder) + '" aria-label="' + esc(o.label) + '"></textarea>' +
278
283
  '<div class="cc-buttons">' +
279
- '<label class="cx-btn cc-attach" title="Attach photos or files"><svg class="icon" aria-hidden="true"><use href="#i-clip"/></svg><span class="sr-only">Attach photos or files</span>' +
280
- '<input type="file" class="cc-file" multiple accept="' + ACCEPT + '" data-cos-file aria-label="Attach photos or files"></label>' +
284
+ cameraHtml('data-cos-file') + clipHtml('data-cos-file') +
281
285
  '<button type="button" class="cx-btn primary cc-ask" data-cos="ask">Ask</button>' +
282
286
  (drop ? '<button type="button" class="cx-btn cc-drop" data-cos="drop">Drop</button>' : '') + '</div>' +
283
287
  '<div class="cc-foot"><span class="cc-menus" data-cos-model-slot>' + (C.models ? modelMenuHtml() : '') + '</span>' +
@@ -549,11 +553,19 @@
549
553
  var ua = (window.navigator && window.navigator.userAgent) || '';
550
554
  return /safari/i.test(ua) && !/chrome|chromium|crios|fxios|edg|android|opr/i.test(ua);
551
555
  }
556
+ // QA 6.99.27 (User 3): every browser on an iPhone or iPad is WebKit and decodes HEIC, including the Home Screen web
557
+ // app, whose user agent has no "Safari" in it. iPadOS asks for the desktop site by default, so it reads as a Mac
558
+ // with a touch screen.
559
+ function appleMobile() {
560
+ var nav = window.navigator || {}, ua = nav.userAgent || '';
561
+ return /iPhone|iPad|iPod/.test(ua) || (/Macintosh/.test(ua) && Number(nav.maxTouchPoints) > 1);
562
+ }
563
+ function heicReadable() { return isSafari() || appleMobile(); }
552
564
  /** What a file is to COS: 'photo', 'doc' or 'video', with a refusal sentence when it cannot be attached. */
553
565
  function classify(file) {
554
566
  var type = String(file.type || '').toLowerCase(), ext = extOf(file.name);
555
567
  var heic = /^image\/hei[cf]/.test(type) || ext === 'heic' || ext === 'heif';
556
- if (heic && !isSafari()) return { refuse: file.name + ' is a HEIC photo, which only Safari can read here. Attach it from Safari, or save it as a JPEG first.' };
568
+ if (heic && !heicReadable()) return { refuse: file.name + ' is a HEIC photo, which only Safari can read here. Attach it from Safari, or save it as a JPEG first.' };
557
569
  if (/^image\//.test(type) || PHOTO_EXT[ext]) return { kind: 'photo' };
558
570
  if (/^video\//.test(type) || VIDEO_EXT[ext]) {
559
571
  if (!VIDEO_EXT[ext] && type !== 'video/mp4' && type !== 'video/quicktime') return { refuse: file.name + ' is a video COS cannot read. Use MP4 or MOV.' };
@@ -566,19 +578,80 @@
566
578
  }
567
579
  return { refuse: file.name + ' cannot be attached. COS takes photos, PDF, TXT, MD, CSV, JSON, XLSX, MP4 and MOV.' };
568
580
  }
569
- function addFiles(k, files) {
581
+ // ── the attach kit (11/07: "attach files and images from the dashboard view as well") ──
582
+ // The clip, the chips, the limits and the upload above, for any place that sends files with words: the chat's own
583
+ // composers, Today's reply and note boxes (panes/today-actions.js) and the Ask sheet (app.js). One copy, used by all.
584
+ // A box is { atts: [], signedOut: false, ui } (W.cosChat.attach.box): ui.changed(box) repaints after any change, ui.removed(box) runs after a
585
+ // Remove, ui.signedOut(box) says the browser was signed out mid-upload. Progress moves only the chip's own bar
586
+ // (paintProgress, by the chip's id). Nothing here sends: each caller sends the ready ids its own way, and only once
587
+ // pending() and failed() are both false.
588
+ // opts.any (a session turn): no accept list, so any file, and the label says the limits.
589
+ function clipHtml(fileAttr, opts) {
590
+ var any = !!(opts && opts.any);
591
+ var label = any ? 'Attach photos or files, up to 5, ' + mb(opts.any) + ' each' : 'Attach photos or files';
592
+ return '<label class="cx-btn cc-attach" title="' + esc(label) + '"><svg class="icon" aria-hidden="true"><use href="#i-clip"/></svg><span class="sr-only">' + esc(label) + '</span>' +
593
+ // No capture attribute: iOS then offers Photo Library, Take Photo and Choose File.
594
+ '<input type="file" class="cc-file" multiple' + (any ? '' : ' accept="' + ACCEPT + '"') + ' ' + fileAttr + ' aria-label="' + esc(label) + '"></label>';
595
+ }
596
+ // 6.99.27 (Miles: "take picture"): the camera itself, one tap. On a phone or tablet only: a desktop browser ignores
597
+ // `capture` and would open a second file picker labelled Take photo.
598
+ function cameraOn() { return !!(window.matchMedia && window.matchMedia('(pointer: coarse)').matches); }
599
+ function cameraHtml(fileAttr) {
600
+ if (!cameraOn()) return '';
601
+ return '<label class="cx-btn cc-attach cc-camera" title="Take photo"><svg class="icon" aria-hidden="true"><use href="#i-camera"/></svg><span class="sr-only">Take photo</span>' +
602
+ '<input type="file" class="cc-file" accept="image/*" capture="environment" ' + fileAttr + ' aria-label="Take photo"></label>';
603
+ }
604
+ // The limits in plain words, under the chips of a session turn.
605
+ function limitLine(box) {
606
+ return box && box.anyBytes ? 'Up to 5 files, ' + mb(box.anyBytes) + ' each (videos up to ' + mb(MAX_VIDEO) + '). The session gets where each one is on your Mac.' : 'Up to 5 photos or files.';
607
+ }
608
+ // A composer here has no ui of its own (it is made before COMPOSER_UI below exists): it is the chat's.
609
+ function uiOf(box) { return box.ui || COMPOSER_UI; }
610
+ function boxChanged(box) { var ui = uiOf(box); if (ui.changed) ui.changed(box); }
611
+ /**
612
+ * 6.99.27: what a SESSION turn takes, which is more than the chat does. The agent gets each file's place on the Mac and
613
+ * opens it with its own tools, so any type goes, up to `maxBytes` (the Mac's threadTurnFileBytes, 64 MB) and up to
614
+ * 100 MB for an MP4 or MOV (the media store's video cap). Photos go as taken: kind 'photo' only for the chip.
615
+ * QA 6.99.27 (Skeptic W3): the 100 MB is only for a file whose type is video/mp4 or video/quicktime (or, with no type,
616
+ * whose name says so). Any other video (WebM, MKV) is a turn file on the Mac, whose cap is 64 MB, so it is refused
617
+ * here before the upload rather than after it.
618
+ */
619
+ function classifyForSession(file, maxBytes) {
620
+ var type = String(file.type || '').toLowerCase(), ext = extOf(file.name);
621
+ var kindOf = type || VIDEO_EXT[ext] || '';
622
+ var video = kindOf === 'video/mp4' || kindOf === 'video/quicktime';
623
+ var cap = video ? MAX_VIDEO : maxBytes;
624
+ if (file.size > cap) return { refuse: file.name + ' is ' + mb(file.size) + '. A session takes ' + (video ? 'a video' : 'a file') + ' up to ' + mb(cap) + '.' };
625
+ if (!file.size) return { refuse: file.name + ' is empty.' };
626
+ if (/^image\//.test(type) || PHOTO_EXT[ext]) {
627
+ var heic = /^image\/hei[cf]/.test(type) || ext === 'heic' || ext === 'heif';
628
+ return { kind: 'photo', mime: type || 'application/octet-stream', showable: !heic || heicReadable() };
629
+ }
630
+ if (video) return { kind: 'video', mime: kindOf };
631
+ return { kind: DOC_EXT[ext] ? 'doc' : 'file', mime: type && type !== 'application/octet-stream' ? type : DOC_EXT[ext] || 'application/octet-stream' };
632
+ }
633
+ function dropThumb(t) { if (t.raw && /^blob:/.test(t.thumb || '')) { try { URL.revokeObjectURL(t.thumb); } catch (_e) {} } }
634
+ function objectUrl(file) { try { return URL.createObjectURL(file); } catch (_e) { return ''; } }
635
+ function boxAdd(box, files) {
570
636
  var list = Array.prototype.slice.call(files || []);
571
- if (!list.length) return;
572
637
  var refusals = [];
573
638
  list.forEach(function (file) {
574
- if (k.atts.length >= MAX_ATTS) { refusals.push('Up to 5 attachments per message. ' + file.name + ' was not added.'); return; }
575
- var c = classify(file);
639
+ if (box.atts.length >= MAX_ATTS) { refusals.push('Up to 5 attachments per message. ' + file.name + ' was not added.'); return; }
640
+ var c = box.anyBytes ? classifyForSession(file, box.anyBytes) : classify(file);
576
641
  if (c.refuse) { refusals.push(c.refuse); return; }
577
642
  var t = { id: uuid(), file: file, name: file.name || (c.kind === 'photo' ? 'Photo' : 'File'), size: file.size, kind: c.kind, mime: c.mime || '', state: 'reading', progress: 0, mediaId: '', thumb: '', error: '' };
578
- k.atts.push(t);
579
- startUpload(k, t);
643
+ // 6.99.27, a session turn: the file as it is, never resized or converted (the agent reads the original), and the
644
+ // upload says so, so the Mac keeps a format the media store does not read (lib/turn-file-store.ts).
645
+ if (box.anyBytes) { t.raw = true; t.purpose = 'session-turn'; if (c.kind === 'photo' && c.showable) t.thumb = objectUrl(file); }
646
+ box.atts.push(t);
647
+ startUpload(box, t);
580
648
  });
581
- k.signedOut = false;
649
+ if (list.length) box.signedOut = false;
650
+ return refusals;
651
+ }
652
+ function addFiles(k, files) {
653
+ if (!files || !files.length) return;
654
+ var refusals = boxAdd(k, files);
582
655
  setNote(k, refusals.join(' '));
583
656
  paintChips(k); paintButtons(k);
584
657
  }
@@ -588,18 +661,21 @@
588
661
  if (t.state === 'failed') return t.error || 'Did not upload';
589
662
  return sizeLabel(t.size);
590
663
  }
591
- function chipHtml(t) {
664
+ // attr: the click attribute the box's own handler reads ('data-cos' here, 'data-ta' on Today, 'data-action' in the
665
+ // Ask sheet), so a chip's Remove never reaches another box's handler.
666
+ function chipHtml(t, attr) {
667
+ attr = attr || 'data-cos';
592
668
  var icon = t.thumb ? '<img src="' + esc(t.thumb) + '" alt="">' : '<svg class="icon" aria-hidden="true"><use href="#i-' + (t.kind === 'video' ? 'video' : t.kind === 'photo' ? 'photo' : 'doc') + '"/></svg>';
593
669
  return '<div class="cc-chip ' + t.state + '" data-chip="' + t.id + '"><span class="cc-chip-thumb">' + icon + '</span>' +
594
670
  '<span class="cc-chip-text"><span class="cc-chip-name">' + esc(t.name) + '</span><span class="cc-chip-meta" data-chip-meta>' + esc(chipMeta(t)) + '</span></span>' +
595
- (t.state === 'failed' ? '<button type="button" class="link-btn cc-chip-retry" data-cos="att-retry" data-att="' + t.id + '">Retry</button>' : '') +
596
- '<button type="button" class="cc-chip-x" data-cos="att-remove" data-att="' + t.id + '" aria-label="Remove ' + esc(t.name) + '"><span aria-hidden="true">×</span></button>' +
671
+ (t.state === 'failed' ? '<button type="button" class="link-btn cc-chip-retry" ' + attr + '="att-retry" data-att="' + t.id + '">Retry</button>' : '') +
672
+ '<button type="button" class="cc-chip-x" ' + attr + '="att-remove" data-att="' + t.id + '" aria-label="Remove ' + esc(t.name) + '"><span aria-hidden="true">×</span></button>' +
597
673
  (t.state === 'uploading' || t.state === 'reading' ? '<span class="cc-chip-bar" data-chip-bar style="width:' + Math.round(t.progress * 100) + '%"></span>' : '') + '</div>';
598
674
  }
599
675
  function paintChips(k) {
600
676
  var form = formOf(k), box = form && form.querySelector('[data-cos-chips]');
601
677
  if (!box) return;
602
- box.innerHTML = k.atts.map(chipHtml).join('');
678
+ box.innerHTML = k.atts.map(function (t) { return chipHtml(t); }).join('');
603
679
  box.classList.toggle('hidden', !k.atts.length);
604
680
  }
605
681
  // Progress moves the bar and the words only, never the chip row (a tap on Remove must not miss).
@@ -610,11 +686,60 @@
610
686
  var bar = chip.querySelector('[data-chip-bar]'); if (bar) bar.style.width = Math.round(t.progress * 100) + '%';
611
687
  }
612
688
  function findAtt(k, id) { for (var i = 0; i < k.atts.length; i++) if (k.atts[i].id === id) return k.atts[i]; return null; }
613
- function removeAtt(k, id) {
614
- k.atts = k.atts.filter(function (t) { if (t.id === id && t.xhr) { try { t.xhr.abort(); } catch (_e) {} } return t.id !== id; });
615
- if (!uploadsFailed(k) && /did not upload/i.test(k.note)) setNote(k, '');
616
- paintChips(k); paintButtons(k);
689
+ function boxRemove(box, id) {
690
+ box.atts = box.atts.filter(function (t) { if (t.id === id) { if (t.xhr) { try { t.xhr.abort(); } catch (_e) {} } dropThumb(t); } return t.id !== id; });
691
+ var ui = uiOf(box); if (ui.removed) ui.removed(box);
692
+ boxChanged(box);
693
+ }
694
+ function removeAtt(k, id) { boxRemove(k, id); }
695
+ function boxRetry(box, id) {
696
+ var t = findAtt(box, id);
697
+ if (!t) return false;
698
+ box.signedOut = false;
699
+ startUpload(box, t);
700
+ boxChanged(box);
701
+ return true;
617
702
  }
703
+ // QA 6.99.27: the files that went with a message leave the box. That is not a Remove, so ui.removed is not told (the
704
+ // reader's clears the status line, which may be telling the person about the message that just took them). Answers
705
+ // what left, enough to put each back as a kept chip (boxRestore) if the message does not go after all.
706
+ function boxTake(box, ids) {
707
+ var went = [];
708
+ box.atts = box.atts.filter(function (t) {
709
+ if (!t.mediaId || ids.indexOf(t.mediaId) < 0) return true;
710
+ went.push({ mediaId: t.mediaId, name: t.name, size: t.size, kind: t.kind, mime: t.mime, keptAt: t.keptAt || 0 });
711
+ dropThumb(t);
712
+ return false;
713
+ });
714
+ if (went.length) boxChanged(box);
715
+ return went;
716
+ }
717
+ // Files the Mac already keeps, back on the box as ready chips (no upload), at most five in all. Answers how many are
718
+ // on the box afterwards out of those asked for.
719
+ function boxRestore(box, files) {
720
+ var back = 0;
721
+ (files || []).forEach(function (f) {
722
+ if (!f || !f.mediaId) return;
723
+ if (box.atts.some(function (t) { return t.mediaId === f.mediaId; })) { back++; return; }
724
+ if (box.atts.length >= MAX_ATTS) return;
725
+ box.atts.push({ id: uuid(), file: null, name: f.name || 'File', size: f.size || 0, kind: f.kind || 'file', mime: f.mime || '', state: 'ready', progress: 1, mediaId: f.mediaId, thumb: '', error: '', keptAt: f.keptAt || 0 });
726
+ back++;
727
+ });
728
+ if (back) boxChanged(box);
729
+ return back;
730
+ }
731
+ // Everything goes, an upload still running included (its request is stopped, and its late answer finds no record).
732
+ function boxClear(box) {
733
+ box.atts.forEach(function (t) { if (t.xhr) { try { t.xhr.abort(); } catch (_e) {} } dropThumb(t); });
734
+ box.atts = [];
735
+ boxChanged(box);
736
+ }
737
+ // The chat's composers, as boxes.
738
+ var COMPOSER_UI = {
739
+ changed: function (k) { paintChips(k); paintButtons(k); },
740
+ removed: function (k) { if (!uploadsFailed(k) && /did not upload/i.test(k.note)) setNote(k, ''); },
741
+ signedOut: function (k) { setNote(k, 'This browser was signed out. Your message and files are kept. Sign in, then tap Retry on each file.'); }
742
+ };
618
743
  /** A photo, drawn into a canvas at most 1024 px on its longest edge, as a JPEG at quality 0.85. */
619
744
  function resizePhoto(file) {
620
745
  return new Promise(function (resolve, reject) {
@@ -640,46 +765,82 @@
640
765
  * with credentials 'same-origin' (the cookie goes with any same-origin XHR) plus X-COS-Web: 1, which a cookie write
641
766
  * needs. A 401 here never opens the sign-in screen: the chip says so and the message stays (see uploadError).
642
767
  */
643
- function uploadRequest(path, body, headers, onProgress, t) {
768
+ // extra: more headers (6.99.27: X-COS-Purpose for a session turn's file).
769
+ // QA 6.99.27 (User 8): an upload that makes no progress for a minute stops and says so, and one that stopped while
770
+ // the page was hidden (the phone locked, the browser in the background) says that, never that the Mac did not answer.
771
+ var UPLOAD_STALL_MS = 60000;
772
+ function uploadRequest(path, body, headers, onProgress, t, extra) {
644
773
  return new Promise(function (resolve, reject) {
645
774
  var xhr = new XMLHttpRequest();
646
775
  t.xhr = xhr;
776
+ var stall = null, stalled = false, wasHidden = !!document.hidden;
777
+ var onVisibility = function () { if (document.hidden) wasHidden = true; };
778
+ var watch = function () { clearTimeout(stall); stall = setTimeout(function () { stalled = true; try { xhr.abort(); } catch (_e) {} }, UPLOAD_STALL_MS); };
779
+ var settle = function () {
780
+ clearTimeout(stall); stall = null; t.xhr = null;
781
+ if (document.removeEventListener) document.removeEventListener('visibilitychange', onVisibility);
782
+ };
783
+ // The bytes are all sent: the Mac is reading the file, which is not a stall.
784
+ var sent = function () { clearTimeout(stall); stall = null; };
785
+ var stopped = function () {
786
+ var err = wasHidden || document.hidden ? new Error('The upload stopped while the phone was locked. Retry it.') : new Error('The upload stopped. Nothing moved for a minute. Retry it.');
787
+ err.code = wasHidden || document.hidden ? 'upload_locked' : 'upload_stalled';
788
+ return err;
789
+ };
790
+ if (document.addEventListener) document.addEventListener('visibilitychange', onVisibility);
647
791
  xhr.open('POST', path, true);
648
792
  xhr.setRequestHeader('X-COS-Web', '1');
649
793
  Object.keys(headers).forEach(function (h) { xhr.setRequestHeader(h, headers[h]); });
650
- if (xhr.upload) xhr.upload.onprogress = function (e) { if (e.lengthComputable && e.total) onProgress(e.loaded / e.total); };
794
+ if (extra) Object.keys(extra).forEach(function (h) { xhr.setRequestHeader(h, extra[h]); });
795
+ if (xhr.upload) {
796
+ xhr.upload.onprogress = function (e) {
797
+ if (e.lengthComputable && e.total) { onProgress(e.loaded / e.total); if (e.loaded >= e.total) { sent(); return; } }
798
+ watch();
799
+ };
800
+ xhr.upload.onload = sent;
801
+ }
651
802
  xhr.onload = function () {
652
- t.xhr = null;
803
+ settle();
653
804
  var data = {}; try { data = JSON.parse(xhr.responseText || '{}'); } catch (_e) { data = {}; }
654
805
  if (xhr.status >= 200 && xhr.status < 300) return resolve(data);
655
806
  var err = new Error('The Mac answered ' + xhr.status + '.'); err.status = xhr.status;
656
807
  err.code = data && (typeof data.error === 'string' ? data.error : data.error && data.error.code) || '';
657
808
  reject(err);
658
809
  };
659
- xhr.onerror = function () { t.xhr = null; var err = new Error('The Mac did not answer.'); err.code = 'network'; reject(err); };
660
- xhr.onabort = function () { t.xhr = null; var err = new Error('Removed.'); err.code = 'aborted'; reject(err); };
810
+ xhr.onerror = function () {
811
+ settle();
812
+ if (wasHidden || document.hidden) return reject(stopped());
813
+ var err = new Error('The Mac did not answer.'); err.code = 'network'; reject(err);
814
+ };
815
+ xhr.onabort = function () {
816
+ settle();
817
+ if (stalled) return reject(stopped());
818
+ var err = new Error('Removed.'); err.code = 'aborted'; reject(err);
819
+ };
661
820
  xhr.send(body);
821
+ watch();
662
822
  });
663
823
  }
664
824
  var UPLOAD_COPY = {
665
825
  batch_too_large: 'Too large for one upload.', too_many_images: 'Up to 5 attachments per message.',
666
826
  media_processing_unavailable: 'The Mac cannot read photos right now.', unsupported_kind: 'COS cannot read this kind of file.',
667
827
  unsupported_media_type: 'COS cannot read this kind of file.', file_too_large: 'Too large for COS.', attachment_too_large: 'Too large for COS.',
668
- server_maintenance: 'The Mac is updating. Retry in a minute.', network: 'The Mac did not answer. Retry when it is awake.'
828
+ server_maintenance: 'The Mac is updating. Retry in a minute.', network: 'The Mac did not answer. Retry when it is awake.',
829
+ upload_locked: 'The upload stopped while the phone was locked. Retry it.', upload_stalled: 'The upload stopped. Nothing moved for a minute. Retry it.'
669
830
  };
670
- function uploadError(k, t, err) {
831
+ function uploadError(box, t, err) {
671
832
  if (err && err.code === 'aborted') return;
672
833
  t.state = 'failed';
673
834
  if (err && err.status === 401) {
674
835
  // Signed out mid-message (the browser session expired or every browser was signed out). Nothing is thrown away:
675
836
  // the words and the files stay, and Sign in again opens the sign-in screen on purpose, not by surprise.
676
837
  t.error = 'Signed out';
677
- k.signedOut = true;
678
- setNote(k, 'This browser was signed out. Your message and files are kept. Sign in, then tap Retry on each file.');
838
+ box.signedOut = true;
839
+ var ui = uiOf(box); if (ui.signedOut) ui.signedOut(box);
679
840
  } else {
680
841
  t.error = uploadErrorText(err);
681
842
  }
682
- paintChips(k); paintButtons(k);
843
+ boxChanged(box);
683
844
  }
684
845
  /**
685
846
  * One file to the media store, the way every web upload goes: a photo resized to a 1024 px JPEG and posted to /api/media,
@@ -689,7 +850,7 @@
689
850
  */
690
851
  function uploadMedia(t, hooks) {
691
852
  var at = new Date(t.file.lastModified || Date.now()).toISOString();
692
- if (t.kind === 'photo') {
853
+ if (t.kind === 'photo' && !t.raw) {
693
854
  return resizePhoto(t.file).then(function (dataUrl) {
694
855
  var data = dataUrl.slice(dataUrl.indexOf(',') + 1);
695
856
  var bytes = Math.floor(data.length * 3 / 4);
@@ -704,7 +865,7 @@
704
865
  'Content-Type': t.mime || 'application/octet-stream',
705
866
  'X-COS-Filename': encodeURIComponent(t.name),
706
867
  'X-COS-Captured-At': at
707
- }, function (p) { hooks.progress(p); }, t).then(function (d) { return d && d.attachment; });
868
+ }, function (p) { hooks.progress(p); }, t, t.purpose ? { 'X-COS-Purpose': t.purpose } : null).then(function (d) { return d && d.attachment; });
708
869
  }
709
870
  /** The chip's words for a failed upload (never the sign-in case, which each caller says its own way). */
710
871
  function uploadErrorText(err) {
@@ -712,22 +873,22 @@
712
873
  if (err && !err.status && !err.code) return err.message || 'Did not upload';
713
874
  return UPLOAD_COPY[err && err.code] || (err && err.status === 413 ? 'Too large for COS.' : 'Did not upload');
714
875
  }
715
- function startUpload(k, t) {
716
- t.state = 'reading'; t.progress = 0; t.error = '';
876
+ function startUpload(box, t) {
877
+ t.state = 'reading'; t.progress = 0; t.error = ''; t.mediaId = '';
717
878
  var step = uploadMedia(t, {
718
- uploading: function () { t.state = 'uploading'; paintChips(k); paintButtons(k); },
719
- progress: function (p) { t.progress = p; paintProgress(k, t); }
879
+ uploading: function () { t.state = 'uploading'; boxChanged(box); },
880
+ progress: function (p) { t.progress = p; paintProgress(box, t); }
720
881
  });
721
882
  step.then(function (ref) {
722
- if (!findAtt(k, t.id)) return;
883
+ if (!findAtt(box, t.id)) return;
723
884
  if (!ref || !ref.id) { var e = new Error('The Mac did not keep the file.'); e.code = ''; throw e; }
724
- t.mediaId = ref.id; t.state = 'ready'; t.progress = 1;
725
- paintChips(k); paintButtons(k);
885
+ t.mediaId = ref.id; t.state = 'ready'; t.progress = 1; t.keptAt = Date.now();
886
+ boxChanged(box);
726
887
  }).catch(function (err) {
727
- if (!findAtt(k, t.id)) return;
728
- if (err && err.code === 'photo_too_large') { t.state = 'failed'; t.error = uploadErrorText(err); paintChips(k); paintButtons(k); return; }
729
- if (err && !err.status && !err.code) { t.state = 'failed'; t.error = uploadErrorText(err); paintChips(k); paintButtons(k); return; }
730
- uploadError(k, t, err);
888
+ if (!findAtt(box, t.id)) return;
889
+ if (err && err.code === 'photo_too_large') { t.state = 'failed'; t.error = uploadErrorText(err); boxChanged(box); return; }
890
+ if (err && !err.status && !err.code) { t.state = 'failed'; t.error = uploadErrorText(err); boxChanged(box); return; }
891
+ uploadError(box, t, err);
731
892
  });
732
893
  }
733
894
  function readyAtts(k) { return k.atts.filter(function (t) { return t.state === 'ready' && t.mediaId; }); }
@@ -945,8 +1106,13 @@
945
1106
  return ensureCaps().then(function (caps) {
946
1107
  if (!caps || !caps.jobs) { var off = new Error('Ask needs the durable query jobs switch on your Mac.'); off.code = 'durable_query_jobs_disabled'; throw off; }
947
1108
  return new Promise(function (resolve, reject) {
1109
+ // 11/07: files attached in the Ask sheet go with the question, as from the chat's own clip (ready ids only:
1110
+ // the sheet never asks while one is uploading or failed).
1111
+ var o = {};
1112
+ if (extra && extra.context) o.context = extra.context;
1113
+ if (extra && Array.isArray(extra.attachmentIds) && extra.attachmentIds.length) { o.attachmentIds = extra.attachmentIds.slice(0, MAX_ATTS); o.atts = Array.isArray(extra.atts) ? extra.atts.slice(0, MAX_ATTS) : []; }
948
1114
  submitAsk(String(text || '').trim(), null, function (ok) { if (ok) resolve(['cos']); else reject(new Error('Not sent. The question is in Ask COS; try again there.')); },
949
- extra && extra.context ? { context: extra.context } : undefined);
1115
+ o.context || o.attachmentIds ? o : undefined);
950
1116
  });
951
1117
  });
952
1118
  }
@@ -1647,8 +1813,8 @@
1647
1813
  if (what === 'cancel') return cancel(t.getAttribute('data-cid'));
1648
1814
  if (what === 'att-remove') return removeAtt(kOf(t), t.getAttribute('data-att'));
1649
1815
  if (what === 'att-retry') {
1650
- var k = kOf(t), att = findAtt(k, t.getAttribute('data-att'));
1651
- if (att) { k.signedOut = false; setNote(k, ''); startUpload(k, att); paintChips(k); paintButtons(k); }
1816
+ var k = kOf(t);
1817
+ if (findAtt(k, t.getAttribute('data-att'))) { setNote(k, ''); boxRetry(k, t.getAttribute('data-att')); }
1652
1818
  return;
1653
1819
  }
1654
1820
  if (what === 'sign-in') { if (W.showAuth) W.showAuth('Sign in again. Your message and files are kept; tap Retry on each file after.'); return; }
@@ -1730,6 +1896,34 @@
1730
1896
  setOnChange: function (fn) { C.onChange = fn; }, setOnAnswered: function (fn) { C.onAnswered = fn; },
1731
1897
  // Test and render hooks: what the chat holds right now.
1732
1898
  classify: classify, uploadMedia: uploadMedia, uploadErrorText: uploadErrorText, uuid: uuid,
1899
+ // 11/07: the attach kit, for Today's reply and note boxes and the Ask sheet (see "the attach kit" above).
1900
+ attach: {
1901
+ max: MAX_ATTS, accept: ACCEPT,
1902
+ // opts.anyBytes (a session turn): any file up to that size, sent as it is (classifyForSession).
1903
+ box: function (ui, opts) { return { atts: [], signedOut: false, ui: ui || {}, anyBytes: opts && opts.anyBytes > 0 ? opts.anyBytes : 0 }; },
1904
+ add: boxAdd, remove: boxRemove, retry: boxRetry, clear: boxClear,
1905
+ take: boxTake, restore: boxRestore,
1906
+ pending: uploadsPending, failed: uploadsFailed,
1907
+ ids: function (box) { return readyAtts(box).slice(0, MAX_ATTS).map(function (t) { return t.mediaId; }); },
1908
+ sent: function (box) { return readyAtts(box).slice(0, MAX_ATTS).map(function (t) { return { name: t.name, kind: t.kind, thumb: t.thumb }; }); },
1909
+ chipsHtml: function (box, attr) { return box.atts.map(function (t) { return chipHtml(t, attr); }).join(''); },
1910
+ clipHtml: clipHtml, cameraHtml: cameraHtml, limitLine: limitLine,
1911
+ // Files on the clipboard (a pasted screenshot), or none.
1912
+ pasted: function (e) { var f = e && e.clipboardData && e.clipboardData.files; return f && f.length ? f : null; },
1913
+ // Files dragged over `zone`: marks it while they hover, and answers the files on the drop. Null for anything else.
1914
+ dragged: function (e, zone) {
1915
+ var types = e && e.dataTransfer && e.dataTransfer.types;
1916
+ if (!types || Array.prototype.indexOf.call(types, 'Files') < 0) return null;
1917
+ e.preventDefault();
1918
+ if (e.type === 'dragover' || e.type === 'dragenter') { zone.classList.add('dragging'); return null; }
1919
+ zone.classList.remove('dragging');
1920
+ return e.type === 'drop' ? e.dataTransfer.files : null;
1921
+ },
1922
+ // How many files a session turn takes on this Mac (threadTurnAttachments); 0 means words only.
1923
+ turnFiles: function () { return C.caps && C.caps.turnFiles > 0 ? C.caps.turnFiles : 0; },
1924
+ turnFileBytes: function () { return C.caps && C.caps.turnFiles > 0 && C.caps.turnFileBytes > 0 ? C.caps.turnFileBytes : 0; },
1925
+ askOn: askOn, ensureCaps: ensureCaps
1926
+ },
1733
1927
  // 6.99.19: Prep. meetings.js reads the capability from here (one /api/health read for the page).
1734
1928
  prepCaps: prepCaps,
1735
1929
  // Today's Next up (app.js prepNext): Prep me for that meeting, as the chat's own Prep me does. With Prep on the Mac
@@ -2352,7 +2352,13 @@
2352
2352
  function prepCapsNow() { var cc = W.cosChat; return cc && typeof cc.prepCaps === 'function' ? cc.prepCaps() : null; }
2353
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
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; }); }
2355
+ function prepBoundTo(d, sid) { return ((d && d.bindings) || []).some(function (b) { return b && b.sessionId === sid && !b.removedAt && b.state !== 'applying'; }); }
2356
+ // An interrupted bind owns frozen rows at its original revision. A later editable draft is not its retry.
2357
+ function prepPendingBind(d, sid) {
2358
+ return ((d && d.bindings) || []).filter(function (b) {
2359
+ return b && b.sessionId === sid && !b.removedAt && b.state === 'applying' && Number.isSafeInteger(b.revision) && b.revision >= 0;
2360
+ })[0] || null;
2361
+ }
2356
2362
  function prepReadyFiles(d) { return ((d && d.files) || []).filter(function (x) { return x && x.status === 'ready'; }); }
2357
2363
  function prepHasNotes(d) { return !!String((d && d.steer) || '').trim(); }
2358
2364
  // Whether this live view may offer a prep at all: the area is reserved exactly when this is true.
@@ -2376,7 +2382,7 @@
2376
2382
  if (S.lprep !== p) return;
2377
2383
  notePrepInstance(r.body);
2378
2384
  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);
2385
+ return d && typeof d.id === 'string' && !prepBoundTo(d, f.sid) && (prepPendingBind(d, f.sid) || prepHasNotes(d) || prepReadyFiles(d).length > 0);
2380
2386
  }).slice(0, 5) : [];
2381
2387
  p.state = r.status === 200 ? 'ready' : 'failed';
2382
2388
  paintLivePrep();
@@ -2388,7 +2394,7 @@
2388
2394
  return prepHasNotes(d) ? (n ? 'its notes and ' + files : 'its notes') : 'its ' + files;
2389
2395
  }
2390
2396
  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>';
2397
+ 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…' : prepPendingBind(d, p.sid) ? 'Retry add prep' : 'Add to this meeting') + '</button>';
2392
2398
  }
2393
2399
  function prepQuiet(text, warn) { return '<div class="mt-prepline" role="status"><span class="mt-prepline-text' + (warn ? ' mt-amber' : '') + '">' + esc(text) + '</span></div>'; }
2394
2400
  function livePrepHtml() {
@@ -2402,14 +2408,14 @@
2402
2408
  else if (!p.drafts.length) html = prepQuiet('No prep for this meeting. Make one from a Prep me card in COS chat.');
2403
2409
  else if (p.drafts.length === 1) {
2404
2410
  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';
2411
+ var text = p.note || (prepPendingBind(d, p.sid) ? 'Finish adding the saved prep for ' + title + '. Later edits are not included.' : 'Prep for ' + title + (p.changed === d.id ? ' changed since it was offered: add ' : ' is ready: add ') + prepWhat(d) + ' to this meeting');
2406
2412
  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
2413
  } else {
2408
2414
  // Several: the person picks. Each row says what it adds; the list scrolls inside the same box.
2409
2415
  html = '<div class="mt-prepline list"><ul class="mt-preplist" aria-label="' + esc(p.drafts.length + ' preps are ready for this meeting') + '">' +
2410
2416
  (p.note ? '<li class="mt-prepnote"><span class="mt-prepline-text mt-amber">' + esc(p.note) + '</span></li>' : '') +
2411
2417
  p.drafts.map(function (d) {
2412
- var label = (cleanTitle(d.title) || 'Prep') + (p.changed === d.id ? ' (changed)' : '') + ': ' + prepWhat(d).replace(/^its /, '');
2418
+ var label = (cleanTitle(d.title) || 'Prep') + (prepPendingBind(d, p.sid) ? ': finish adding the saved prep. Later edits are not included.' : (p.changed === d.id ? ' (changed)' : '') + ': ' + prepWhat(d).replace(/^its /, ''));
2413
2419
  return '<li><span class="mt-prepline-text" title="' + esc(label) + '">' + esc(label) + '</span>' + prepAddBtn(p, d) + '</li>';
2414
2420
  }).join('') + '</ul></div>';
2415
2421
  }
@@ -2432,16 +2438,21 @@
2432
2438
  // v1.1: the bind names the revision the offer described ({revision, sessionId, serverInstanceId, surface}), so a newer
2433
2439
  // version nobody has seen is never attached. The server id is the newest one read: capabilities.prep or the most
2434
2440
  // recent Prep answer (W.cosChat.prepInstance), else Meeting Ask's, which is the same id.
2435
- function bindOnce(sid, d) {
2441
+ function bindOnce(sid, d, retry) {
2436
2442
  var cc = W.cosChat, known = cc && typeof cc.prepInstance === 'function' ? cc.prepInstance() : '';
2437
2443
  return (known ? Promise.resolve(known) : loadConfig().then(function (conf) { return conf && conf.serverInstanceId; })).then(function (inst) {
2438
2444
  if (!inst) throw new Error(S.conf.notice || 'Meeting assistance is not available on this server.');
2439
2445
  // 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' } });
2446
+ return answer('/api/prep/drafts/' + encodeURIComponent(d.id) + '/bind', { method: 'POST', headers: { 'X-COS-Expected-Server-Instance': inst }, body: { revision: retry ? retry.revision : d.revision, sessionId: sid, serverInstanceId: inst, surface: 'web' } });
2441
2447
  }).then(function (a) {
2442
2448
  var b = a.body || {}, code = typeof b.error === 'string' ? b.error : (b.error && b.error.code) || b.reason || '';
2443
2449
  if (a.status >= 200 && a.status < 300 || a.status === 409) notePrepInstance(b);
2444
- if (a.status >= 200 && a.status < 300) return b;
2450
+ if (a.status >= 200 && a.status < 300) {
2451
+ if (retry && !((b.draft && b.draft.bindings) || []).some(function (bound) {
2452
+ return bound && bound.sessionId === sid && bound.revision === retry.revision && bound.state === 'applied' && !bound.removedAt;
2453
+ })) throw new Error('The server has not confirmed adding this prep. Retry add prep.');
2454
+ return b;
2455
+ }
2445
2456
  // A different server than the one that offered it: the prep is read again (amendment 19) and the line says so.
2446
2457
  // Adding it takes another tap, against the server that answers now.
2447
2458
  if (a.status === 409 && code === 'server_instance_mismatch') {
@@ -2469,7 +2480,7 @@
2469
2480
  if (!f || !p || p.sid !== f.sid || p.busy || p.done || f.endedAt) return;
2470
2481
  var d = p.drafts.filter(function (x) { return x.id === id; })[0]; if (!d) return;
2471
2482
  p.busy = id; p.note = ''; paintLivePrep();
2472
- bindOnce(f.sid, d).then(function (b) {
2483
+ bindOnce(f.sid, d, prepPendingBind(d, f.sid)).then(function (b) {
2473
2484
  p.busy = ''; p.done = (b && b.draft) || d;
2474
2485
  var a = A(f.sid);
2475
2486
  // The meeting's files as the bind answered them, when it answered Meeting Ask files; and read again either way.