@gotcos/glasses-server 6.99.12 → 6.99.16

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.
@@ -11,14 +11,24 @@
11
11
  Steer and Cancel are the existing query-job routes. The Model and Effort menus come from /api/models (6.99.12).
12
12
  - Drop (outlined, Drop it on only): POST /api/drops, then the receipt with Undo and Move. A drop the server reads as a
13
13
  question runs the Ask the same way and is linked back (POST /api/drops/:id/ask), as COS Control does.
14
+ 6.99.13: Drop takes the composer's files too (up to capabilities.dropIt.attachments, 5), as attachmentIds. The
15
+ server keeps them with the drop; the receipt lists each one with Open, and Undo removes them. A drop read as a
16
+ question sends its files with the Ask. An older server without that field keeps the words-only note.
14
17
  - The clip (6.99.12) attaches photos, PDF, TXT, MD, CSV, JSON, XLSX, MP4 and MOV, as the Even app does: photos are
15
18
  resized to a 1024 px JPEG and posted to /api/media, other files go raw to /api/media/file, and the Ask carries up to
16
- five attachmentIds. Paste and drag-drop add files too.
19
+ five attachmentIds. Paste and drag-drop add files too. Drop sends them too where the server says it takes them.
17
20
  With a keyboard, Return asks and Option-Return drops; Shift-Return is a new line.
18
21
 
19
22
  The same composer answers in the message reader (6.99.12): Reply sends that turn's sessionId, so the answer continues
20
23
  that conversation when it is still live (lib/web-ask-admission.ts).
21
24
 
25
+ 6.99.16, the COS thread (capabilities.dropIt.thread, routes/cos-thread.ts): each Ask from the chat's own composer
26
+ reads GET /api/cos-thread first. `continue` sends that sessionId; `fresh` sends none and carries the server's digest
27
+ as the Ask's `reference` (the query-jobs field for quoted context, so the Messages row shows only the question).
28
+ While an Ask in the thread is still answering, the next one continues its conversation without asking. When an Ask
29
+ finishes, POST /api/cos-thread/turn once with its answer. "New thread" in the header posts /api/cos-thread/new.
30
+ Without the capability, the old rule stands: one conversation per day, kept in this browser.
31
+
22
32
  The rail badge on Messages is a number for `waiting` (GET /api/inbox/summary) and a dot for unread only. It is read
23
33
  with the shell's existing status read (shell.js checkServer), never on a timer of its own, and painting it touches
24
34
  only the badge, never the pane. */
@@ -41,7 +51,10 @@
41
51
  models: null, // [{ value, label, available }]
42
52
  host: null,
43
53
  onChange: null, // messages.js: re-render when Drop it or Ask turns on or off
44
- onAnswered: null // messages.js: re-read Recent when a web Ask lands
54
+ onAnswered: null, // messages.js: re-read Recent when a web Ask lands
55
+ thread: null, // 6.99.16: the last GET /api/cos-thread answer
56
+ threadCarried: '', // the thread's newest session started fresh: 'summary' (it carried a digest) or 'plain'
57
+ threadBusy: false
45
58
  };
46
59
  var saved = W.readStore(STORE);
47
60
  C.model = typeof saved.model === 'string' ? saved.model : '';
@@ -76,20 +89,30 @@
76
89
  // ── capabilities and the rail badge ───────────────────
77
90
  // Drop it on: the pinned COS chat. Ask on (durable query jobs): the composer, with or without Drop it.
78
91
  function dropOn() { return !!(C.caps && C.caps.dropIt); }
92
+ function dropFiles() { return C.caps && C.caps.dropIt && C.caps.dropFiles > 0 ? C.caps.dropFiles : 0; }
79
93
  function askOn() { return !!(C.caps && C.caps.jobs); }
80
94
  function on() { return dropOn() || askOn(); }
95
+ // 6.99.16: the COS thread, advertised inside dropIt while Drop it is on.
96
+ function threadOn() { return !!(C.caps && C.caps.dropIt && C.caps.thread); }
81
97
  function setCaps(h) {
82
98
  var caps = (h && h.capabilities) || {};
83
99
  var drop = caps.dropIt;
84
100
  var next = {
85
101
  dropIt: !!(drop && drop.enabled === true),
86
- jobs: caps.durableQueryJobs === true || h && h.durableQueryJobs === true || !!(caps.durableQueryJobs && caps.durableQueryJobs.enabled),
87
- steer: !!(C.caps && C.caps.steer)
102
+ // 6.99.13: how many files a drop takes. 0 (an older server): Drop is words only and says so.
103
+ dropFiles: drop && drop.enabled === true && Number.isInteger(drop.attachments) && drop.attachments > 0 ? Math.min(drop.attachments, MAX_ATTS) : 0,
104
+ // 6.99.16: /api/health carries it at features.durableQueryJobs (a boolean); /api/models at capabilities.durableQueryJobs.enabled.
105
+ jobs: caps.durableQueryJobs === true || h && h.durableQueryJobs === true || !!(caps.durableQueryJobs && caps.durableQueryJobs.enabled) ||
106
+ !!(h && h.features && h.features.durableQueryJobs === true),
107
+ steer: !!(C.caps && C.caps.steer),
108
+ thread: !!(drop && drop.enabled === true && drop.thread && drop.thread.protocol === 1)
88
109
  };
89
- var changed = !C.caps || C.caps.dropIt !== next.dropIt || C.caps.jobs !== next.jobs;
110
+ var changed = !C.caps || C.caps.dropIt !== next.dropIt || C.caps.jobs !== next.jobs || C.caps.thread !== next.thread;
111
+ var filesChanged = !!C.caps && C.caps.dropFiles !== next.dropFiles;
90
112
  C.caps = next;
91
113
  if (!next.dropIt) { clearRail(); C.summary = null; }
92
114
  if (changed && C.onChange) C.onChange();
115
+ else if (filesChanged) paintButtons(CHAT);
93
116
  if (next.dropIt) refreshSummary();
94
117
  }
95
118
  // The shell's status read (shell.js checkServer) hands its /api/health answer here.
@@ -260,14 +283,16 @@
260
283
  }
261
284
 
262
285
  // ── the chat ──────────────────────────────────────────
263
- function chatMode() { return dropOn() ? 'drop' : 'ask'; }
286
+ function chatMode() { return dropOn() ? (threadOn() ? 'drop-thread' : 'drop') : 'ask'; }
264
287
  function chatSkeleton() {
265
288
  var drop = dropOn();
266
289
  return '<section class="cc" data-cos-chat data-mode="' + chatMode() + '" data-tint="messages" aria-label="' + (drop ? 'COS chat' : 'Ask COS') + '">' +
267
290
  '<header class="cc-head"><span class="cc-avatar big">' + MARK + '</span><div class="cc-head-text"><h2 class="cc-title">' + (drop ? 'COS' : 'Ask COS') + '</h2>' +
268
291
  '<p class="cc-sub">' + (drop ? 'Text it anything: a thought, an ask, a reminder. It texts back.' : 'Ask anything, with photos or files. Each answer lands in Messages with its number, here and on the glasses.') + '</p></div>' +
269
292
  '<div class="cc-head-acts"><button type="button" class="link-btn cc-all" data-cos="all">All messages</button>' +
270
- (drop ? '<button type="button" class="link-btn" data-cos="read-all">Mark all read</button>' : '') + '</div></header>' +
293
+ (drop ? '<button type="button" class="link-btn" data-cos="read-all">Mark all read</button>' : '') +
294
+ (threadOn() ? '<button type="button" class="link-btn" data-cos="new-thread">New thread</button>' : '') + '</div>' +
295
+ (threadOn() ? '<p class="cc-thread" data-cos-thread role="status">' + esc(threadLine()) + '</p>' : '') + '</header>' +
271
296
  '<div class="cc-feed" data-cos-feed aria-live="polite"></div>' +
272
297
  composerHtml(CHAT, { drop: drop, placeholder: drop ? 'Ask COS, or drop a thought, a task, a reminder' : 'Ask COS anything', label: 'Message to COS' }) +
273
298
  '</section>';
@@ -283,6 +308,7 @@
283
308
  }
284
309
  if (!visible) return;
285
310
  loadModels();
311
+ if (threadOn()) loadThread();
286
312
  // Ask only: no inbox to read; the chat holds this page's Asks. Inbox lines from a Drop it session are not shown.
287
313
  if (!dropOn()) { if (C.feedMode !== 'ask') { C.items = []; C.feedState = 'ready'; C.feedMode = 'ask'; } paintFeed(true); return; }
288
314
  if (C.items === null || C.feedState === 'error' || C.feedMode !== 'drop') loadFeed(); else paintFeed(true);
@@ -338,7 +364,8 @@
338
364
  }).join('') + '</ol>';
339
365
  }
340
366
  var rest = acts.filter(function (a) { return !/^choose:/.test(a.id); });
341
- if (item.needsReply) rest.push({ id: 'dismiss', label: 'Dismiss' });
367
+ // A P2 follow-up already carries its own Dismiss (6.99.11); add one only to an item that does not (6.99.15: it showed twice).
368
+ if (item.needsReply && !rest.some(function (a) { return a.id === 'dismiss'; })) rest.push({ id: 'dismiss', label: 'Dismiss' });
342
369
  if (rest.length) {
343
370
  html += '<div class="cc-acts">' + rest.map(function (a) {
344
371
  var cls = PRIMARY[a.id] ? 'cx-btn primary' : 'link-btn cc-text-btn';
@@ -360,16 +387,30 @@
360
387
  return '<button type="button" class="cc-choice" data-cos="move-to" data-item="' + esc(item.id) + '" data-dest="' + o.dest + '"' + (o.domain ? ' data-domain="' + esc(o.domain) + '"' : '') + '>' + esc(o.label) + '</button>';
361
388
  }).join('') + '<button type="button" class="link-btn cc-text-btn" data-cos="move-cancel">Keep it where it is</button></div>';
362
389
  }
390
+ // 6.99.13: a drop's files, each opening from the media store in a new tab. Under your words they are what you sent;
391
+ // on a receipt each says Open. Undo takes the links away with the files. The classes are cc-dfile*, never cc-file:
392
+ // .cc-file is the clip's invisible file input (opacity 0, absolute), and a link wearing it does not show.
393
+ function fileLinksHtml(item, mine) {
394
+ var files = (item.links || []).filter(function (l) { return l.kind === 'file' && MEDIA_ID.test(String(l.id)); });
395
+ if (!files.length) return '';
396
+ return '<div class="cc-dfiles' + (mine ? ' mine' : '') + '">' + files.map(function (f) {
397
+ var url = '/api/media/' + encodeURIComponent(f.id) + '/content';
398
+ var photo = /\.(jpe?g|png|gif|webp|heic|heif)$/i.test(String(f.label)) || f.label === 'Photo';
399
+ return '<a class="cc-dfile" href="' + esc(url + '?variant=phone') + '" target="_blank" rel="noopener" aria-label="Open ' + esc(f.label) + '">' +
400
+ '<span class="cc-dfile-thumb">' + (photo ? '<img src="' + esc(url + '?variant=thumb') + '" alt="" loading="lazy">' : '<svg class="icon" aria-hidden="true"><use href="#i-doc"/></svg>') + '</span>' +
401
+ '<span class="cc-dfile-name">' + esc(f.label) + '</span>' + (mine ? '' : '<span class="cc-dfile-open">Open</span>') + '</a>';
402
+ }).join('') + '</div>';
403
+ }
363
404
  function itemHtml(item) {
364
405
  if (item.kind === 'drop' || item.kind === 'answer') {
365
406
  return '<div class="cc-line you' + (item.kind === 'answer' ? ' answer' : '') + '"' + (item.kind === 'drop' ? ' data-drop-thread="' + esc(item.threadId) + '"' : '') + '><div class="cc-bubble">' + esc(item.line) + '</div>' +
366
- (item.kind === 'drop' ? '<div class="cc-src">' + esc(SOURCE[item.source] || '') + '</div>' : '') + '</div>';
407
+ (item.kind === 'drop' ? fileLinksHtml(item, true) + '<div class="cc-src">' + esc(SOURCE[item.source] || '') + '</div>' : '') + '</div>';
367
408
  }
368
409
  var quote = item.kind === 'followup' ? (function () { var d = dropOf(item); return d ? '<button type="button" class="cc-quote" data-cos="quote" data-thread="' + esc(item.threadId) + '">Re: ' + esc(clip(d.line, 70)) + '</button>' : ''; })() : '';
369
410
  return '<div class="cc-line cos' + (item.state !== 'live' ? ' done' : '') + (item.needsReply && item.state === 'live' ? ' waits' : '') + '" data-item-id="' + esc(item.id) + '">' +
370
411
  '<span class="cc-avatar">' + MARK + '</span><div class="cc-card">' + quote +
371
412
  (item.title ? '<p class="cc-card-title">' + esc(item.title) + '</p>' : '') +
372
- '<p class="cc-card-line">' + esc(item.line) + '</p>' + actionsHtml(item) + '</div></div>';
413
+ '<p class="cc-card-line">' + esc(item.line) + '</p>' + fileLinksHtml(item, false) + actionsHtml(item) + '</div></div>';
373
414
  }
374
415
  // The finished answer as Markdown; while it streams, plain words, so a half-written "**bold" never shows its stars.
375
416
  function answerHtml(a) {
@@ -458,7 +499,13 @@
458
499
  var jobs = !C.caps || C.caps.jobs;
459
500
  var waiting = uploadsPending(k), failed = uploadsFailed(k);
460
501
  if (ask) { ask.disabled = !text || !!k.busy || !jobs || waiting || failed; ask.textContent = k.busy === 'ask' ? 'Asking…' : waiting ? 'Uploading…' : (k.name === 'chat' ? 'Ask' : 'Reply'); }
461
- if (drop) { drop.disabled = !text || !!k.busy; drop.textContent = k.busy === 'drop' ? 'Dropping…' : 'Drop'; }
502
+ if (drop) {
503
+ // 6.99.13: with files on a server that takes them, Drop waits for the uploads as Ask does. Files with no words keep
504
+ // Drop tappable so the tap can say what is missing.
505
+ var withFiles = k.atts.length > 0 && dropFiles();
506
+ drop.disabled = !!k.busy || (!text && !withFiles) || (withFiles && (waiting || failed));
507
+ drop.textContent = k.busy === 'drop' ? 'Dropping…' : withFiles && waiting ? 'Uploading…' : 'Drop';
508
+ }
462
509
  var attach = form.querySelector('.cc-attach'), picker = form.querySelector('[data-cos-file]');
463
510
  if (attach) attach.classList.toggle('full', k.atts.length >= MAX_ATTS);
464
511
  if (picker) picker.disabled = k.atts.length >= MAX_ATTS || !!k.busy;
@@ -476,6 +523,7 @@
476
523
 
477
524
  // ── attachments (6.99.12, the Even app's composer) ────
478
525
  var MAX_ATTS = 5; // MAX_ATTACHMENTS_PER_PROMPT
526
+ var MEDIA_ID = /^m_[a-f0-9]{24}$/; // shared/media-attachment.ts MEDIA_ID_RE
479
527
  var MAX_PHOTO = 2 * 1024 * 1024; // MAX_IMAGE_BYTES, after the resize
480
528
  var MAX_DOC = 64 * 1024 * 1024; // MAX_OTHER_MEDIA_BYTES
481
529
  var MAX_VIDEO = 100 * 1024 * 1024; // MAX_VIDEO_MEDIA_BYTES
@@ -618,32 +666,47 @@
618
666
  k.signedOut = true;
619
667
  setNote(k, 'This browser was signed out. Your message and files are kept. Sign in, then tap Retry on each file.');
620
668
  } else {
621
- t.error = UPLOAD_COPY[err && err.code] || (err && err.status === 413 ? 'Too large for COS.' : 'Did not upload');
669
+ t.error = uploadErrorText(err);
622
670
  }
623
671
  paintChips(k); paintButtons(k);
624
672
  }
625
- function startUpload(k, t) {
626
- t.state = 'reading'; t.progress = 0; t.error = '';
673
+ /**
674
+ * 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,
675
+ * any other file raw to /api/media/file. Resolves with the media ref ({ id }). `t` is the upload's record ({ file, name,
676
+ * kind, mime }); it gets `xhr` while sending (Remove aborts it) and `thumb` for a photo. hooks.uploading() runs once the
677
+ * bytes start, hooks.progress(0..1) as they go. 6.99.14: the live meeting view's Add file uses this too.
678
+ */
679
+ function uploadMedia(t, hooks) {
627
680
  var at = new Date(t.file.lastModified || Date.now()).toISOString();
628
- var step;
629
681
  if (t.kind === 'photo') {
630
- step = resizePhoto(t.file).then(function (dataUrl) {
682
+ return resizePhoto(t.file).then(function (dataUrl) {
631
683
  var data = dataUrl.slice(dataUrl.indexOf(',') + 1);
632
684
  var bytes = Math.floor(data.length * 3 / 4);
633
685
  if (bytes > MAX_PHOTO) { var big = new Error('Over 2 MB even after resizing.'); big.code = 'photo_too_large'; throw big; }
634
- t.thumb = dataUrl; t.state = 'uploading'; paintChips(k); paintButtons(k);
686
+ t.thumb = dataUrl; hooks.uploading();
635
687
  var body = JSON.stringify({ images: [{ data: data, label: t.name.slice(0, 120), capturedAt: at }], kind: 'user_photo' });
636
- return uploadRequest('/api/media', body, { 'Content-Type': 'application/json' }, function (p) { t.progress = p; paintProgress(k, t); }, t);
688
+ return uploadRequest('/api/media', body, { 'Content-Type': 'application/json' }, function (p) { hooks.progress(p); }, t);
637
689
  }).then(function (d) { return d && d.attachments && d.attachments[0]; });
638
- } else {
639
- t.state = 'uploading';
640
- step = uploadRequest('/api/media/file', t.file, {
641
- 'Content-Type': t.mime || 'application/octet-stream',
642
- 'X-COS-Filename': encodeURIComponent(t.name),
643
- 'X-COS-Captured-At': at
644
- }, function (p) { t.progress = p; paintProgress(k, t); }, t).then(function (d) { return d && d.attachment; });
645
- paintChips(k);
646
690
  }
691
+ hooks.uploading();
692
+ return uploadRequest('/api/media/file', t.file, {
693
+ 'Content-Type': t.mime || 'application/octet-stream',
694
+ 'X-COS-Filename': encodeURIComponent(t.name),
695
+ 'X-COS-Captured-At': at
696
+ }, function (p) { hooks.progress(p); }, t).then(function (d) { return d && d.attachment; });
697
+ }
698
+ /** The chip's words for a failed upload (never the sign-in case, which each caller says its own way). */
699
+ function uploadErrorText(err) {
700
+ if (err && err.code === 'photo_too_large') return 'Over 2 MB even after resizing';
701
+ if (err && !err.status && !err.code) return err.message || 'Did not upload';
702
+ return UPLOAD_COPY[err && err.code] || (err && err.status === 413 ? 'Too large for COS.' : 'Did not upload');
703
+ }
704
+ function startUpload(k, t) {
705
+ t.state = 'reading'; t.progress = 0; t.error = '';
706
+ var step = uploadMedia(t, {
707
+ uploading: function () { t.state = 'uploading'; paintChips(k); paintButtons(k); },
708
+ progress: function (p) { t.progress = p; paintProgress(k, t); }
709
+ });
647
710
  step.then(function (ref) {
648
711
  if (!findAtt(k, t.id)) return;
649
712
  if (!ref || !ref.id) { var e = new Error('The Mac did not keep the file.'); e.code = ''; throw e; }
@@ -651,8 +714,8 @@
651
714
  paintChips(k); paintButtons(k);
652
715
  }).catch(function (err) {
653
716
  if (!findAtt(k, t.id)) return;
654
- if (err && err.code === 'photo_too_large') { t.state = 'failed'; t.error = 'Over 2 MB even after resizing'; paintChips(k); paintButtons(k); return; }
655
- if (err && !err.status && !err.code) { t.state = 'failed'; t.error = err.message || 'Did not upload'; paintChips(k); paintButtons(k); return; }
717
+ if (err && err.code === 'photo_too_large') { t.state = 'failed'; t.error = uploadErrorText(err); paintChips(k); paintButtons(k); return; }
718
+ if (err && !err.status && !err.code) { t.state = 'failed'; t.error = uploadErrorText(err); paintChips(k); paintButtons(k); return; }
656
719
  uploadError(k, t, err);
657
720
  });
658
721
  }
@@ -663,13 +726,20 @@
663
726
  function doDrop() {
664
727
  var k = CHAT;
665
728
  var input = inputOf(k); var text = input ? input.value.trim() : '';
666
- if (!text || k.busy) return;
667
- // A drop files words. Files go with an Ask (a drop takes no attachments yet).
668
- if (k.atts.length) { setNote(k, 'Drop takes words only for now. Ask sends the files with your words, or remove them to drop.'); return; }
729
+ if (k.busy) return;
730
+ // An older server files words only (no capabilities.dropIt.attachments): say so, and send nothing.
731
+ if (k.atts.length && !dropFiles()) { setNote(k, 'Drop takes words only on this Mac. Ask sends the files with your words, or remove them to drop.'); return; }
732
+ if (!text) { if (k.atts.length) setNote(k, 'Add a few words with the files so COS knows where they go.'); return; }
733
+ if (uploadsPending(k)) return;
734
+ if (uploadsFailed(k)) { setNote(k, 'A file did not upload. Retry it or remove it, then Drop.'); return; }
735
+ // What is shown as sent is exactly what is sent: never more than the server takes.
736
+ var ready = readyAtts(k).slice(0, dropFiles() || 0);
669
737
  var dropId = uuid();
738
+ var body = { dropId: dropId, text: text, source: 'web' };
739
+ if (ready.length) body.attachmentIds = ready.map(function (t) { return t.mediaId; });
670
740
  k.busy = 'drop'; setNote(k, ''); paintButtons(k);
671
741
  var send = function (retry) {
672
- return api('/api/drops', { method: 'POST', body: { dropId: dropId, text: text, source: 'web' }, timeoutMs: 30000 }).catch(function (err) {
742
+ return api('/api/drops', { method: 'POST', body: body, timeoutMs: 30000 }).catch(function (err) {
673
743
  // The same dropId is safe to send again: the server answers the first result.
674
744
  if (retry && err && err.code === 'timeout') return send(false);
675
745
  throw err;
@@ -678,14 +748,78 @@
678
748
  send(true).then(function (ans) {
679
749
  k.busy = ''; k.draft = ''; saveDraft(k);
680
750
  var box = inputOf(k); if (box) { box.value = ''; W.autoGrow(box); }
751
+ if (ready.length) clearAtts(k);
681
752
  mergeItems(ans.items);
682
753
  paintFeed(true); paintButtons(k);
683
- if (ans.next && ans.next.kind === 'ask' && C.caps && C.caps.jobs) submitAsk(ans.next.prompt, ans.drop.id);
754
+ if (ans.next && ans.next.kind === 'ask' && C.caps && C.caps.jobs) submitAsk(ans.next.prompt, ans.drop.id, null, askFilesOf(ans.next));
684
755
  refreshSummary();
685
756
  }, function (err) {
686
757
  k.busy = ''; paintButtons(k);
687
- // The words stay in the box until the server has them.
688
- setNote(k, err && err.code === 'drop_it_off' ? 'Drop it is off on this Mac.' : 'Not dropped: ' + ((err && err.message) || 'the Mac did not answer') + ' Your words are still here.');
758
+ // The words and the files stay in the box until the server has them.
759
+ var kept = ready.length ? ' Your words and files are still here.' : ' Your words are still here.';
760
+ // A refused file says why in the server's own words (attachment_expired, too_many_attachments, words_needed...).
761
+ setNote(k, err && err.code === 'drop_it_off' ? 'Drop it is off on this Mac.' : 'Not dropped: ' + ((err && err.message) || 'the Mac did not answer.') + kept);
762
+ });
763
+ }
764
+
765
+ // A drop read as a question runs the Ask with the drop's files (the server names them in next.attachmentIds).
766
+ function askFilesOf(next) {
767
+ var ids = next && Array.isArray(next.attachmentIds) ? next.attachmentIds.filter(function (id) { return MEDIA_ID.test(String(id)); }).slice(0, MAX_ATTS) : [];
768
+ return ids.length ? { attachmentIds: ids } : undefined;
769
+ }
770
+
771
+ // ── the COS thread (6.99.16) ──────────────────────────
772
+ var THREAD_REF = 'Summary carried over from earlier in this COS thread';
773
+ function threadLine() {
774
+ var t = C.thread && C.thread.thread, n = C.thread && C.thread.next;
775
+ var carried = C.threadCarried || (n && n.reason === 'manual' ? (n.digest ? 'summary' : 'plain') : '');
776
+ if (carried) return carried === 'summary' ? 'New thread. It carries a short summary of the last one.' : 'New thread.';
777
+ var asks = t && Number(t.asks) > 0 ? Number(t.asks) : 0;
778
+ return asks ? 'Continues your COS thread · ' + asks + (asks === 1 ? ' ask' : ' asks') : '';
779
+ }
780
+ function paintThread() {
781
+ var line = threadLine();
782
+ document.querySelectorAll('[data-cos-thread]').forEach(function (el) { el.textContent = line; });
783
+ }
784
+ function loadThread() {
785
+ return api('/api/cos-thread').then(function (t) { C.thread = t; paintThread(); return t; }, function () { return null; });
786
+ }
787
+ // What the next Ask in the thread sends: a sessionId to continue, or none and the digest to carry.
788
+ function threadPlan() {
789
+ // An Ask in the thread still answering: the next one continues its conversation (its turn is not recorded yet).
790
+ var live = C.asks.filter(function (x) { return x.threaded && x.sessionId && x.turnState !== 'done' && Date.now() - x.at < 20 * 60000; });
791
+ if (live.length) return Promise.resolve({ sessionId: live[live.length - 1].sessionId, digest: null });
792
+ return loadThread().then(function (t) {
793
+ var n = t && t.next;
794
+ if (n && n.mode === 'continue' && n.sessionId) return { sessionId: n.sessionId, digest: null };
795
+ if (n && n.mode === 'fresh') return { sessionId: '', digest: typeof n.digest === 'string' && n.digest ? n.digest : null };
796
+ // The thread could not be read: continue the session it last named, if any.
797
+ var last = C.thread && C.thread.thread && C.thread.thread.sessionId;
798
+ return { sessionId: last || '', digest: null };
799
+ });
800
+ }
801
+ // Once per Ask, when it has finished: the thread records it, with the answer when there is one.
802
+ function postTurn(a) {
803
+ if (!a.threaded || !a.sessionId || a.turnState) return;
804
+ a.turnState = 'posting';
805
+ var body = { clientId: a.cid, sessionId: a.sessionId, query: String(a.query).slice(0, 20000) };
806
+ if (a.status === 'completed' && typeof a.response === 'string' && a.response) body.answer = a.response.slice(0, 40000);
807
+ if (Number.isInteger(a.no) && a.no > 0) body.messageNo = a.no;
808
+ api('/api/cos-thread/turn', { method: 'POST', body: body }).then(function (t) {
809
+ a.turnState = 'done';
810
+ if (t && t.thread) { C.thread = t; paintThread(); }
811
+ }, function () { a.turnState = 'done'; });
812
+ }
813
+ function newThread() {
814
+ if (C.threadBusy) return;
815
+ C.threadBusy = true;
816
+ api('/api/cos-thread/new', { method: 'POST', body: {} }).then(function (t) {
817
+ C.threadBusy = false;
818
+ C.thread = t; C.threadCarried = '';
819
+ paintThread();
820
+ }, function (err) {
821
+ C.threadBusy = false;
822
+ W.toast((err && err.message) || 'New thread did not go through.');
689
823
  });
690
824
  }
691
825
 
@@ -713,7 +847,9 @@
713
847
  var body = { clientJobId: a.cid, generation: 1, query: text, source: 'web', activityToolMode: 'status' };
714
848
  if (C.model) body.model = C.model;
715
849
  if (C.effort) body.effort = C.effort;
716
- if (C.session && C.session.id) body.sessionId = C.session.id;
850
+ // 6.99.16: an Ask from the chat's own composer belongs to the COS thread (a Reply keeps its turn's conversation).
851
+ a.threaded = threadOn() && !opts.replyKey;
852
+ if (!a.threaded && C.session && C.session.id) body.sessionId = C.session.id;
717
853
  // A reply continues the turn's own conversation (kept when it is still live; else the server starts a new one).
718
854
  if (opts.sessionId) body.sessionId = opts.sessionId;
719
855
  if (opts.attachmentIds && opts.attachmentIds.length) body.attachmentIds = opts.attachmentIds.slice(0, MAX_ATTS);
@@ -727,10 +863,16 @@
727
863
  throw err;
728
864
  });
729
865
  };
730
- admit(true).then(function (d) {
866
+ var prepare = a.threaded ? threadPlan().then(function (plan) {
867
+ if (plan.sessionId) body.sessionId = plan.sessionId;
868
+ else if (plan.digest) body.reference = { query: THREAD_REF, response: plan.digest };
869
+ a.carried = plan.sessionId ? '' : plan.digest ? 'summary' : 'plain';
870
+ }) : Promise.resolve();
871
+ prepare.then(function () { return admit(true); }).then(function (d) {
731
872
  var job = d && d.job; if (!job) throw new Error('The Mac did not take the question.');
732
873
  take(a, job);
733
- if (job.sessionId && !a.replyKey) { C.session = { id: job.sessionId, day: today() }; save(); }
874
+ if (a.threaded) { C.threadCarried = a.carried; paintThread(); }
875
+ else if (job.sessionId && !a.replyKey) { C.session = { id: job.sessionId, day: today() }; save(); }
734
876
  if (dropId) api('/api/drops/' + encodeURIComponent(dropId) + '/ask', { method: 'POST', body: { clientJobId: a.cid, generation: 1 } }).catch(function () {});
735
877
  if (done) done(true);
736
878
  poll(a);
@@ -767,6 +909,7 @@
767
909
  setTimeout(tick, 600);
768
910
  }
769
911
  function finished(a) {
912
+ postTurn(a);
770
913
  if (a.replyKey) paintReplyFeed(a.replyKey); else paintFeed(false);
771
914
  if (C.onAnswered) C.onAnswered();
772
915
  // A drop that became a question gets its answer as a follow-up: read the chat again.
@@ -847,7 +990,7 @@
847
990
  if (ans && ans.item) mergeItems([ans.item]);
848
991
  // A reminder action answers the finished item itself (merged above); dismiss answers counts only.
849
992
  if (action === 'dismiss') item.state = 'done';
850
- if (ans && ans.next && ans.next.kind === 'ask' && C.caps && C.caps.jobs) submitAsk(ans.next.prompt, ans.drop.id);
993
+ if (ans && ans.next && ans.next.kind === 'ask' && C.caps && C.caps.jobs) submitAsk(ans.next.prompt, ans.drop.id, null, askFilesOf(ans.next));
851
994
  paintFeed(false); refreshSummary();
852
995
  }, function (err) { W.toast((err && err.message) || 'That did not go through.'); });
853
996
  }
@@ -883,6 +1026,7 @@
883
1026
  if (what === 'drop') return doDrop();
884
1027
  if (what === 'all') return W.go('messages');
885
1028
  if (what === 'read-all') return markReadIfShown(true);
1029
+ if (what === 'new-thread') return newThread();
886
1030
  if (what === 'retry') { C.feedState = 'idle'; return loadFeed(); }
887
1031
  if (what === 'act') return act(t.getAttribute('data-item'), t.getAttribute('data-act'));
888
1032
  if (what === 'move-to') return moveTo(t.getAttribute('data-item'), t.getAttribute('data-dest'), t.getAttribute('data-domain'));
@@ -968,6 +1112,7 @@
968
1112
  onClick: onClick, onKey: onKey, onInput: onInput, onChange: onChangeEvent, onSubmit: onSubmit, onPaste: onPaste, onDrag: onDrag,
969
1113
  setOnChange: function (fn) { C.onChange = fn; }, setOnAnswered: function (fn) { C.onAnswered = fn; },
970
1114
  // Test and render hooks: what the chat holds right now.
971
- _state: C, _composer: composer, _classify: classify, _modelsFrom: modelsFrom, _addFiles: addFiles
1115
+ classify: classify, uploadMedia: uploadMedia, uploadErrorText: uploadErrorText, uuid: uuid,
1116
+ _state: C, _composer: composer, _classify: classify, _modelsFrom: modelsFrom, _addFiles: addFiles, _itemHtml: itemHtml, _setCaps: setCaps, _threadLine: threadLine, _postTurn: postTurn
972
1117
  };
973
1118
  })();
@@ -226,10 +226,13 @@
226
226
  /* ── live transcript view ─────────────────────────────── */
227
227
  .mt-lhead { display: grid; gap: 4px; }
228
228
  .mt-ltitle { display: flex; align-items: center; gap: 10px; min-width: 0; }
229
+ .mt-ltitle .mt-dtitle { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
230
+ /* 6.99.14: each header line is one line whatever it says (a quiet label, a longer minute count), so the header never
231
+ changes height while it records and the transcript box under it never moves. */
232
+ .mt-lstatus, .mt-lsub { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
229
233
  .mt-lstatus { font-weight: 500; font-size: 13.5px; color: var(--ink); margin: 4px 0 0; }
230
234
  .mt-lsub { font-size: 13px; color: var(--tertiary); margin: 0; }
231
- .mt-handoff { font-size: 13px; margin: 2px 0 0; display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
232
- .mt-lhead .mt-bar { border-bottom: 0; margin-top: 6px; }
235
+ .mt-handoff { font-size: 13px; margin: 2px 0 0; display: flex; flex-wrap: wrap; gap: 8px; align-items: center; min-height: 50px; }
233
236
  /* Control's LiveMeetingFilesRow: one collapsed "Files · N" row; open, the names and the note. */
234
237
  .mt-files { border: 1px solid var(--hairline); border-radius: 9px; margin: 6px 0 4px; max-width: 520px; min-width: 0; }
235
238
  .mt-files-head { display: flex; align-items: center; gap: 8px; width: 100%; min-height: 36px; padding: 0 12px; font-size: 13px; font-weight: 600; color: var(--ink); text-align: left; }
@@ -243,15 +246,69 @@
243
246
  .mt-files-note { padding: 8px 12px 10px; font-size: 12px; }
244
247
  @media (prefers-reduced-motion: reduce) { .mt-files-head .icon:last-child { transition: none; } }
245
248
  .mt-lgrid { display: grid; gap: 16px; }
246
- .mt-lmain { min-width: 0; }
247
- .mt-turns { display: grid; gap: 22px; padding: 16px 0 14px; border-top: 1px solid var(--hairline); max-width: 700px; }
249
+ .mt-lmain { min-width: 0; position: relative; border-radius: 12px; }
250
+ /* 6.99.14: the transcript scrolls in its own box of fixed height (Control's ScrollView), so a new line moves only the
251
+ text inside it. overflow-anchor: none: the pane anchors the reader itself on every browser (iOS Safari has none), and
252
+ Chrome's own anchoring would correct a second time. */
253
+ .mt-tbox { position: relative; margin-top: 12px; }
254
+ .mt-turns { display: grid; gap: 22px; align-content: start; position: relative; padding: 16px 6px 14px 0; border-top: 1px solid var(--hairline);
255
+ height: clamp(240px, 52dvh, 640px); overflow-y: auto; overscroll-behavior: contain; overflow-anchor: none; -webkit-overflow-scrolling: touch; }
256
+ .mt-turns:focus { outline: none; }
257
+ .mt-turns:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
258
+ .mt-lturn { max-width: 700px; }
248
259
  .mt-lwho { display: flex; align-items: baseline; gap: 8px; margin-bottom: 6px; }
249
260
  .mt-lname { font-weight: 600; font-size: 12.5px; color: var(--sub); }
250
261
  .mt-lstamp { font: 400 11px/1 var(--mono); color: var(--tertiary); }
251
262
  .mt-lturn p { margin: 0 0 0.6em; font-size: 15px; line-height: 1.6; color: var(--ink); overflow-wrap: anywhere; }
252
263
  .mt-lturn p:last-child { margin-bottom: 0; }
253
264
  .mt-lempty { color: var(--sub); text-align: center; font-size: 14px; padding: 26px 8px; margin: 0; }
265
+ .mt-jump { position: absolute; left: 50%; bottom: 10px; transform: translateX(-50%); box-shadow: var(--shadow); background: var(--panel); }
266
+ .mt-jump[hidden] { display: none; }
267
+ /* The recording bar (Control's LiveRecordingBar): one row, 50 px, never wraps. Under 560 px of bar Add file is the
268
+ paperclip alone; under 340 px the still glyph takes the strip's place. */
269
+ .mt-lbarslot { container-type: inline-size; min-height: 50px; margin-top: 8px; }
270
+ .mt-lbar { display: flex; align-items: center; gap: 10px; height: 50px; padding: 0 8px 0 14px; border: 1px solid var(--panel-border);
271
+ border-radius: 11px; background: color-mix(in srgb, var(--panel) 70%, var(--page)); min-width: 0; }
272
+ .mt-lbar .mt-livedot { width: 9px; height: 9px; }
273
+ /* A fixed width (h:mm:ss in the mono face), so Starting, 9:59 and 1:00:00 never move the strip beside it. */
274
+ .mt-ltime { font: 500 12.5px/1 var(--mono); font-variant-numeric: tabular-nums; color: var(--ink); white-space: nowrap; width: 7ch; flex: none; overflow: hidden; }
275
+ .mt-ltime.starting { font: 400 12px/1 var(--body); color: var(--sub); }
276
+ .mt-meterwrap { position: relative; flex: 1 1 auto; min-width: 40px; height: 22px; display: flex; align-items: center; }
277
+ .mt-meter { display: block; width: 100%; height: 22px; --mt-bar: color-mix(in srgb, var(--accent) 85%, transparent); --mt-dot: color-mix(in srgb, var(--sub) 70%, transparent); }
278
+ /* The still glyph sits over the strip's box (no levels yet, or too narrow): shown and hidden by visibility, never by
279
+ display, so the switch moves nothing. */
280
+ .mt-glyph { position: absolute; left: 0; top: 6px; display: inline-flex; visibility: hidden; align-items: center; gap: 1.5px; height: 10px; }
281
+ .mt-glyph i { display: block; width: 2px; border-radius: 1px; background: color-mix(in srgb, var(--rec) 85%, transparent); }
282
+ .mt-glyph i:nth-child(1) { height: 6px; } .mt-glyph i:nth-child(2) { height: 9px; } .mt-glyph i:nth-child(3) { height: 7px; }
283
+ .mt-meterwrap.mt-nolevels .mt-meter { visibility: hidden; }
284
+ .mt-meterwrap.mt-nolevels .mt-glyph { visibility: visible; }
285
+ .mt-addfile, .mt-stopbtn { flex: none; white-space: nowrap; }
286
+ .mt-addfile { display: inline-flex; align-items: center; gap: 6px; }
287
+ .mt-addfile .icon { width: 14px; height: 14px; }
288
+ .mt-lbar .mt-side-note { flex: none; }
289
+ @container (max-width: 559px) { .mt-addlabel { display: none; } .mt-addfile { padding-left: 10px; padding-right: 10px; } }
290
+ @container (max-width: 339px) { .mt-meter { visibility: hidden; } .mt-glyph { visibility: visible; } .mt-meterwrap { flex: 0 0 40px; } }
291
+ .mt-lbarslot > .mt-stopline { margin: 8px 4px 0; }
292
+ .mt-lbarslot > .mt-confirm { margin-top: 8px; }
293
+ /* A file dragged over the pane: the column is outlined. Drawn only, so it moves nothing. */
294
+ .mt-lmain.dropping { outline: 2px dashed var(--accent); outline-offset: 4px; }
295
+ .mt-pick { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; overflow: hidden; clip: rect(0 0 0 0); }
296
+ #mt-lfiles:empty { display: none; }
297
+ #mt-lfiles { margin-top: 8px; }
298
+ .mt-files.live { max-width: none; }
299
+ .mt-files-head.quiet { color: var(--sub); font-weight: 500; cursor: default; }
300
+ .mt-files-head > span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
301
+ .mt-files-list li.mt-up { position: relative; grid-template-columns: minmax(0, 1fr) auto; column-gap: 10px; overflow: hidden; }
302
+ .mt-up .mt-files-name, .mt-up-meta { grid-column: 1; }
303
+ .mt-up-meta { font-size: 13px; line-height: 1.45; margin: 0; }
304
+ .mt-up-acts { grid-column: 2; grid-row: 1 / span 2; display: flex; gap: 6px; align-items: center; }
305
+ .mt-up-acts .mt-link { min-height: 28px; }
306
+ .mt-up-bar { position: absolute; left: 0; bottom: 0; height: 2px; background: var(--accent); transition: width 0.2s ease; }
307
+ @media (prefers-reduced-motion: reduce) { .mt-up-bar { transition: none; } }
308
+ #mt-lfiles .mt-files { margin-top: 0; }
309
+ #mt-lfoot .mt-bar { border-top: 0; }
254
310
  @media (min-width: 860px) {
311
+ .mt-turns { height: clamp(320px, calc(100dvh - 400px), 1100px); }
255
312
  .mt-lgrid { grid-template-columns: minmax(0, 1fr) minmax(280px, min(380px, 38%)); align-items: start; column-gap: 24px; }
256
313
  .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; }
257
314
  }