@gotcos/glasses-server 6.99.25 → 6.99.33

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 (53) hide show
  1. package/CHANGELOG.md +224 -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 +42 -9
  33. package/server/web-control/app.js +975 -114
  34. package/server/web-control/control.css +12 -6
  35. package/server/web-control/index.html +6 -1
  36. package/server/web-control/panes/cos-chat.css +27 -3
  37. package/server/web-control/panes/cos-chat.js +240 -46
  38. package/server/web-control/panes/meetings.css +5 -2
  39. package/server/web-control/panes/meetings.js +21 -10
  40. package/server/web-control/panes/memories.css +1 -1
  41. package/server/web-control/panes/memories.js +3 -8
  42. package/server/web-control/panes/questions.css +56 -2
  43. package/server/web-control/panes/questions.js +454 -56
  44. package/server/web-control/panes/reader-media.js +154 -0
  45. package/server/web-control/panes/sessions.css +20 -7
  46. package/server/web-control/panes/settings.js +34 -2
  47. package/server/web-control/panes/speakers.css +3 -5
  48. package/server/web-control/panes/speakers.js +7 -4
  49. package/server/web-control/panes/threads.js +3 -2
  50. package/server/web-control/panes/today-actions.css +81 -3
  51. package/server/web-control/panes/today-actions.js +527 -62
  52. package/server/web-control/panes/work.css +1 -1
  53. package/server/web-control/shell.js +3 -0
@@ -6,7 +6,8 @@
6
6
 
7
7
  :root {
8
8
  --tint: var(--accent);
9
- --tertiary: #8b8378;
9
+ /* 6.99.30 (design QA): #8b8378 measured 3.29:1 on the light page; this reads at 4.5:1 or better. Dark is unchanged. */
10
+ --tertiary: #6e675d;
10
11
  --gold: #b98a3e;
11
12
  --green: #2f7d4f;
12
13
  --amber: #a8641c;
@@ -64,8 +65,9 @@
64
65
  .rail-settings { min-height: 40px; }
65
66
  }
66
67
 
67
- /* Full rail (Control's at window width 1100 and up): name and story beside the glyph. */
68
- @media (min-width: 1100px) {
68
+ /* Full rail (Control's at window width 1100 and up, on a screen tall enough for it: the same clause as app.css's desktop
69
+ step, so a wide short window keeps the icon rail and its short-screen sizes). */
70
+ @media (min-width: 1100px) and (min-height: 721px) {
69
71
  .app-shell { grid-template-columns: 248px minmax(0, 1fr); }
70
72
  .rail { align-items: stretch; padding: calc(var(--safe-top) + 18px) 12px 14px; gap: 2px; }
71
73
  .rail-head { flex-direction: row; align-items: center; gap: 8px; padding: 0 8px 14px; }
@@ -145,7 +147,7 @@
145
147
  .cx-btn:disabled { opacity: 0.45; }
146
148
  .cx-btn.primary { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
147
149
  .cx-btn.quiet { border-color: var(--panel-border); }
148
- @media (max-width: 599px) { .cx-btn { height: 36px; font-size: 14.5px; } }
150
+ @media (max-width: 599px) { .cx-btn { font-size: 14.5px; } }
149
151
 
150
152
  /* ── text switch (COSBrand: words with a 2pt gold rule, never pills) ── */
151
153
  .cx-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 16px; margin: 12px 0 10px; }
@@ -161,7 +163,6 @@
161
163
  .cx-check input { width: 16px; height: 16px; accent-color: var(--gold); }
162
164
  @media (max-width: 599px) {
163
165
  .cx-search { max-width: none; flex-basis: 100%; order: 3; }
164
- .cx-search .field { min-height: 40px; font-size: 16px; }
165
166
  .cx-switch button { font-size: 15px; padding: 8px 0 9px; }
166
167
  }
167
168
 
@@ -280,7 +281,7 @@
280
281
  .cx-chip { display: inline-flex; align-items: center; gap: 4px; height: 28px; padding: 0 4px 0 10px; border-radius: 14px; background: var(--panel); border: 1px solid var(--panel-border); font-size: 13px; color: var(--ink); }
281
282
  .cx-chip button, .cx-x { width: 24px; height: 24px; border-radius: 12px; color: var(--sub); font-size: 15px; line-height: 1; display: grid; place-items: center; }
282
283
  .cx-chip.more { padding-right: 10px; color: var(--sub); }
283
- @media (max-width: 599px) { .cx-inline-form .field { min-height: 40px; font-size: 16px; } .cx-chip { height: 32px; } }
284
+ @media (max-width: 599px) { .cx-chip { height: 32px; } }
284
285
 
285
286
  /* ── Settings (Control's hub categories) ──────────────── */
286
287
  .cx-set-cat { margin-top: 8px; }
@@ -323,3 +324,8 @@ html, body { overscroll-behavior-y: none; }
323
324
  .intake-meeting { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink); }
324
325
  .intake-n { font: 600 12px/1.6 var(--mono); color: var(--sub); }
325
326
  .intake-foot { margin: 6px 0 0; font-size: 12.5px; color: var(--tertiary); }
327
+
328
+ /* 6.99.30 (design QA, the unfolded Duo at 1430 px): Settings and Dictation are forms and short reads, not boards, so
329
+ their content stops at 720 px instead of running lines to 137 characters; prose lines stop at about 68 characters. */
330
+ #pane-settings, #pane-dictation { max-width: 720px; }
331
+ .cx-set-desc, .cx-set-blurb, .wk-fine { max-width: 68ch; }
@@ -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>
@@ -121,6 +122,7 @@
121
122
  <div>
122
123
  <h1 class="pane-title">Today</h1>
123
124
  <p id="today-date" class="today-date"></p>
125
+ <p id="today-weather" class="today-weather hidden"></p>
124
126
  </div>
125
127
  <button type="button" class="ask-bar" data-open-ask>
126
128
  <svg class="icon"><use href="#i-ask"/></svg>
@@ -253,8 +255,10 @@
253
255
  <div class="sheet-grip"></div>
254
256
  <form class="sheet-head" id="ask-form">
255
257
  <input id="ask-q" class="field" type="search" placeholder="Ask COS or find a session" autocomplete="off" enterkeyhint="go">
258
+ <span id="ask-clip" class="ask-clip"></span>
256
259
  <button type="button" class="link-btn" id="ask-close">Cancel</button>
257
260
  </form>
261
+ <div id="ask-files" class="cc-chips ask-files hidden" aria-live="polite"></div>
258
262
  <div id="ask-results"></div>
259
263
  </div>
260
264
  </div>
@@ -276,7 +280,8 @@
276
280
  "dictation": "/control/panes/dictation.js",
277
281
  "settings": "/control/panes/settings.js",
278
282
  "memories": "/control/panes/memories.js",
279
- "todayActions": "/control/panes/today-actions.js"
283
+ "todayActions": "/control/panes/today-actions.js",
284
+ "readerMedia": "/control/panes/reader-media.js"
280
285
  }</script>
281
286
  <!-- The Knowledge graph explorer, loaded by panes/memories.js when the graph first opens. -->
282
287
  <script type="application/json" id="memories-graph-assets">{
@@ -10,7 +10,7 @@
10
10
  .msg-split[data-show] > .msg-left, .msg-split[data-show] > .msg-right { display: block; }
11
11
  /* The list column is narrower than the pane: its header's buttons go under the title instead of over it. */
12
12
  .msg-left .cx-hero .cx-actions { grid-column: 1 / -1; justify-content: flex-start; }
13
- .msg-right { position: sticky; top: calc(56px + var(--safe-top, 0px)); height: calc(100vh - 72px - var(--safe-top, 0px)); min-height: 420px; }
13
+ .msg-right { position: sticky; top: calc(56px + var(--safe-top, 0px)); height: calc(100vh - 72px - var(--safe-top, 0px)); height: calc(100dvh - 72px - var(--safe-top, 0px)); min-height: 420px; }
14
14
  .msg-right .cc { height: 100%; display: flex; flex-direction: column; background: var(--panel); border: 1px solid var(--panel-border); border-radius: 16px; overflow: hidden; }
15
15
  .msg-right .cc-head { padding: 14px 16px 12px; }
16
16
  .msg-right .cc-feed { flex: 1; overflow-y: auto; padding: 12px 16px 8px; overscroll-behavior: contain; }
@@ -19,7 +19,7 @@
19
19
 
20
20
  /* Below 860 px the chat fills the screen, so the composer sits at the bottom even when the chat is short. */
21
21
  @media (max-width: 859px) {
22
- .msg-split[data-show='chat'] .cc { min-height: calc(100vh - 64px - var(--safe-top, 0px)); display: flex; flex-direction: column; }
22
+ .msg-split[data-show='chat'] .cc { min-height: calc(100vh - 64px - var(--safe-top, 0px)); min-height: calc(100dvh - 64px - var(--safe-top, 0px)); display: flex; flex-direction: column; }
23
23
  .msg-split[data-show='chat'] .cc-feed { flex: 1; }
24
24
  }
25
25
 
@@ -190,7 +190,7 @@
190
190
  .cc-prep-add:focus-within { outline: 2px solid var(--accent); outline-offset: 2px; }
191
191
  .cc-prep-acts .cx-btn { height: 40px; }
192
192
  @media (max-width: 480px) {
193
- .cc-prep-acts .cx-btn { height: 44px; min-width: 44px; }
193
+ .cc-prep-acts .cx-btn { min-width: 44px; }
194
194
  .cc-prep-head-acts button { min-height: 44px; }
195
195
  }
196
196
  .cc-prep-note { margin: 0; font-size: 13px; }
@@ -198,3 +198,27 @@
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
+ /* 6.99.30 (canary): the folded Sessions reader squeezed the box to about 101 px beside Fork, the clip and Send. Under
214
+ 430 px the words get their own full row and the buttons sit on the row below, at its right. */
215
+ @media (max-width: 429px) {
216
+ .att-composer > textarea { flex: 1 1 100%; }
217
+ .att-composer > .composer-actions { margin-left: auto; }
218
+ }
219
+ .att-composer .att-files.hidden { display: none; }
220
+ .att-clip, .ta-clip { display: inline-flex; gap: 6px; }
221
+ .att-clip:empty { display: none; }
222
+ .att-clip .cc-attach { height: 38px; width: 42px; border-radius: var(--radius-sm); }
223
+ .att-composer.dragging { outline: 2px dashed var(--accent); outline-offset: 4px; }
224
+ .cc-chips .att-limit { flex-basis: 100%; min-width: 0; max-width: 100%; margin: 0 2px; font-size: 12.5px; line-height: 1.35; color: var(--sub); white-space: normal; overflow-wrap: anywhere; }
@@ -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; }
@@ -1660,7 +1826,7 @@
1660
1826
  }
1661
1827
  function isComposerInput(el) { return !!(el && el.classList && el.classList.contains('cc-input')); }
1662
1828
  function onKey(e) {
1663
- if (!isComposerInput(e.target) || e.key !== 'Enter' || e.isComposing || e.shiftKey) return;
1829
+ if (!isComposerInput(e.target) || e.key !== 'Enter' || e.isComposing || e.keyCode === 229 || e.shiftKey) return;
1664
1830
  e.preventDefault();
1665
1831
  var k = kOf(e.target);
1666
1832
  if (e.altKey) { if (k === CHAT && dropOn()) doDrop(); 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
@@ -21,7 +21,10 @@
21
21
  .mt-wd { text-align: center; font: 600 10px/1 var(--mono); letter-spacing: 0.04em; color: var(--tertiary); padding: 2px 0 4px; }
22
22
  .mt-day, .mt-blank { min-height: 42px; }
23
23
  .mt-day { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; border-radius: 8px; color: var(--ink); font: 400 14px/1 var(--body); }
24
- .mt-day:disabled { opacity: 0.38; }
24
+ .mt-day:disabled { opacity: 1; }
25
+ /* 6.99.30 (design QA): a day with nothing to open read at 2.35:1 at 38% ink; the secondary colour keeps it quiet and
26
+ readable. */
27
+ .mt-day:disabled .mt-dnum { color: var(--sub); }
25
28
  .mt-day:not(:disabled):hover { background: var(--qopen); }
26
29
  .mt-day[aria-pressed='true'] { background: color-mix(in srgb, var(--tint) 18%, transparent); font-weight: 600; }
27
30
  .mt-day:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
@@ -353,5 +356,5 @@
353
356
  @media (min-width: 860px) {
354
357
  .mt-turns { height: clamp(320px, calc(100dvh - 400px), 1100px); }
355
358
  .mt-lgrid { grid-template-columns: minmax(0, 1fr) minmax(280px, min(380px, 38%)); align-items: start; column-gap: 24px; }
356
- .mt-lgrid > .mt-side-panel { position: sticky; top: calc(62px + var(--safe-top, 0px)); max-height: calc(100vh - 80px - var(--safe-top, 0px)); overflow: auto; }
359
+ .mt-lgrid > .mt-side-panel { position: sticky; top: calc(62px + var(--safe-top, 0px)); max-height: calc(100vh - 80px - var(--safe-top, 0px)); max-height: calc(100dvh - 80px - var(--safe-top, 0px)); overflow: auto; }
357
360
  }