@gotcos/glasses-server 6.98.0 → 6.99.12

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 (89) hide show
  1. package/CHANGELOG.md +158 -0
  2. package/package.json +2 -1
  3. package/server/index.ts +147 -9
  4. package/server/lib/api-auth.ts +31 -14
  5. package/server/lib/claude-bridge.ts +21 -2
  6. package/server/lib/codex-bridge.ts +21 -2
  7. package/server/lib/display-bus.ts +3 -0
  8. package/server/lib/drop-destinations.ts +134 -0
  9. package/server/lib/drop-history-search.ts +97 -0
  10. package/server/lib/drop-p2-boot.ts +60 -0
  11. package/server/lib/drop-p2-runtime.ts +237 -0
  12. package/server/lib/drop-p2-settings.ts +174 -0
  13. package/server/lib/drop-resolution.ts +168 -0
  14. package/server/lib/drop-router.ts +325 -0
  15. package/server/lib/drop-service.ts +739 -0
  16. package/server/lib/drop-settings.ts +45 -0
  17. package/server/lib/drop-store.ts +428 -0
  18. package/server/lib/drop-when.ts +265 -0
  19. package/server/lib/glasses-pairing.ts +15 -0
  20. package/server/lib/learning-needs-you.ts +111 -0
  21. package/server/lib/live-cues-v2-data.ts +336 -26
  22. package/server/lib/live-cues-v2-settings.ts +2 -1
  23. package/server/lib/live-cues-v2.ts +9 -5
  24. package/server/lib/meeting-consolidation-run.ts +169 -0
  25. package/server/lib/meeting-store.ts +61 -0
  26. package/server/lib/meeting-title-steer.ts +200 -0
  27. package/server/lib/network-policy.ts +84 -6
  28. package/server/lib/permission-broker.ts +11 -0
  29. package/server/lib/ping-budget.ts +149 -0
  30. package/server/lib/ping-engine.ts +245 -0
  31. package/server/lib/ping-feed-sink.ts +33 -0
  32. package/server/lib/ping-sources.ts +192 -0
  33. package/server/lib/query-job-coordinator.ts +5 -2
  34. package/server/lib/query-job-runtime.ts +45 -4
  35. package/server/lib/query-job-types.ts +10 -1
  36. package/server/lib/reminder-scheduler.ts +153 -0
  37. package/server/lib/tailnet-self-name.ts +103 -0
  38. package/server/lib/tailscale-nudge.ts +40 -2
  39. package/server/lib/task-store.ts +32 -0
  40. package/server/lib/web-ask-admission.ts +56 -0
  41. package/server/lib/web-live-chunks.ts +273 -0
  42. package/server/lib/web-memories-bridge.ts +323 -0
  43. package/server/lib/web-session-auth.ts +492 -0
  44. package/server/lib/web-usage-bridge.ts +154 -0
  45. package/server/lib/work-search.ts +14 -1
  46. package/server/lib/your-move-count.ts +58 -0
  47. package/server/routes/display.ts +11 -1
  48. package/server/routes/drops.ts +260 -0
  49. package/server/routes/health.ts +16 -0
  50. package/server/routes/live-cues.ts +5 -4
  51. package/server/routes/meeting.ts +61 -5
  52. package/server/routes/permission-broker.ts +23 -3
  53. package/server/routes/transcribe-stream.ts +34 -3
  54. package/server/routes/web-control.ts +569 -0
  55. package/server/routes/welcome-context.ts +2 -1
  56. package/server/web-control/app.css +519 -0
  57. package/server/web-control/app.js +3643 -0
  58. package/server/web-control/control.css +312 -0
  59. package/server/web-control/fonts/DMSans.ttf +0 -0
  60. package/server/web-control/fonts/Fraunces.ttf +0 -0
  61. package/server/web-control/fonts/JetBrainsMono.ttf +0 -0
  62. package/server/web-control/index.html +275 -0
  63. package/server/web-control/mark.svg +1 -0
  64. package/server/web-control/memories/d3.min.js +2 -0
  65. package/server/web-control/memories/graph-explorer.css +200 -0
  66. package/server/web-control/memories/graph-explorer.js +748 -0
  67. package/server/web-control/memories/memories-app.js +1561 -0
  68. package/server/web-control/memories/memories-theme.css +73 -0
  69. package/server/web-control/memories/memories.html +72 -0
  70. package/server/web-control/memories/memory-stewardship.js +63 -0
  71. package/server/web-control/memories/memory-workspace.js +216 -0
  72. package/server/web-control/panes/cos-chat.css +157 -0
  73. package/server/web-control/panes/cos-chat.js +973 -0
  74. package/server/web-control/panes/dictation.js +173 -0
  75. package/server/web-control/panes/meetings.css +257 -0
  76. package/server/web-control/panes/meetings.js +2264 -0
  77. package/server/web-control/panes/messages.js +711 -0
  78. package/server/web-control/panes/questions.css +32 -0
  79. package/server/web-control/panes/questions.js +275 -0
  80. package/server/web-control/panes/sessions.css +452 -0
  81. package/server/web-control/panes/settings.js +292 -0
  82. package/server/web-control/panes/speakers.css +269 -0
  83. package/server/web-control/panes/speakers.js +2755 -0
  84. package/server/web-control/panes/threads.js +200 -0
  85. package/server/web-control/panes/work.css +261 -0
  86. package/server/web-control/panes/work.js +1667 -0
  87. package/server/web-control/shell.js +294 -0
  88. package/server/web-control/theme.js +25 -0
  89. package/server/web-control/vendor/qrcode-encoder.js +10 -0
@@ -0,0 +1,973 @@
1
+ /* COS Control Web: the pinned COS chat (Drop it plan sections 1 and 12, server 6.99.10) and, from 6.99.12, Ask COS.
2
+ Lives inside Messages: the pinned row is the first row of Recent (#/messages), the chat is #/messages/cos, and from
3
+ 860 px the two sit side by side.
4
+ - Drop it on (capabilities.dropIt.enabled): the pinned COS chat, as in 6.99.10.
5
+ - Drop it off, durable query jobs on (6.99.12): the same row and chat as "Ask COS", with Ask only. No inbox read.
6
+ - Both off: Messages renders exactly as before and this module draws nothing.
7
+
8
+ The composer has visible buttons, so a phone with no keyboard needs no key combination and no long press:
9
+ - Ask (filled): POST /api/query-jobs with source 'web', the glasses' own route. The server picks the conversation and
10
+ mints the message number, so the answer is a normal Messages turn (Recent on the Mac and here, and the lens list).
11
+ Steer and Cancel are the existing query-job routes. The Model and Effort menus come from /api/models (6.99.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
+ question runs the Ask the same way and is linked back (POST /api/drops/:id/ask), as COS Control does.
14
+ - The clip (6.99.12) attaches photos, PDF, TXT, MD, CSV, JSON, XLSX, MP4 and MOV, as the Even app does: photos are
15
+ 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.
17
+ With a keyboard, Return asks and Option-Return drops; Shift-Return is a new line.
18
+
19
+ The same composer answers in the message reader (6.99.12): Reply sends that turn's sessionId, so the answer continues
20
+ that conversation when it is still live (lib/web-ask-admission.ts).
21
+
22
+ The rail badge on Messages is a number for `waiting` (GET /api/inbox/summary) and a dot for unread only. It is read
23
+ with the shell's existing status read (shell.js checkServer), never on a timer of its own, and painting it touches
24
+ only the badge, never the pane. */
25
+ (function () {
26
+ 'use strict';
27
+ var W = window.COSWeb;
28
+ var $ = W.$, esc = W.esc, api = W.api;
29
+ var STORE = 'cos.web.cosChat';
30
+ var DRAFTS = 'cos.web.cosDrafts';
31
+
32
+ var C = {
33
+ caps: null, // null until /api/health is read: { dropIt, jobs, steer }
34
+ capsLoading: null,
35
+ summary: null, // { unread, waiting, lastLine, lastAt }
36
+ items: null, // the chat, oldest first
37
+ feedState: 'idle', // idle | loading | ready | error
38
+ feedError: '',
39
+ asks: [], // web Asks from this page: { cid, jobId, query, at, status, partial, response, no, sessionId, error, dropId, steering, atts, replyKey }
40
+ moving: '', // the feed item whose Move list is open
41
+ models: null, // [{ value, label, available }]
42
+ host: null,
43
+ 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
45
+ };
46
+ var saved = W.readStore(STORE);
47
+ C.model = typeof saved.model === 'string' ? saved.model : '';
48
+ C.effort = typeof saved.effort === 'string' ? saved.effort : '';
49
+
50
+ // ── small helpers ─────────────────────────────────────
51
+ function uuid() {
52
+ // crypto.randomUUID needs a secure context; a plain-http tailnet page is not one.
53
+ var b = new Uint8Array(16);
54
+ crypto.getRandomValues(b);
55
+ b[6] = (b[6] & 0x0f) | 0x40; b[8] = (b[8] & 0x3f) | 0x80;
56
+ var h = Array.prototype.map.call(b, function (x) { return (x + 0x100).toString(16).slice(1); }).join('');
57
+ return h.slice(0, 8) + '-' + h.slice(8, 12) + '-' + h.slice(12, 16) + '-' + h.slice(16, 20) + '-' + h.slice(20);
58
+ }
59
+ function save() { W.writeStore(STORE, { model: C.model, effort: C.effort, session: C.session || null }); }
60
+ function today() { var d = new Date(); return d.getFullYear() + '-' + (d.getMonth() + 1) + '-' + d.getDate(); }
61
+ // A web Ask continues the conversation it started today; a new day starts a new one.
62
+ C.session = saved.session && saved.session.day === today() ? saved.session : null;
63
+ function wide() { return window.matchMedia('(min-width: 860px)').matches; }
64
+ function keyboardFirst() { return window.matchMedia('(hover: hover) and (pointer: fine)').matches; }
65
+ function timeLabel(v) {
66
+ var d = W.toDate(v); if (!d) return '';
67
+ var now = new Date();
68
+ var same = d.toDateString() === now.toDateString();
69
+ var t = d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
70
+ return same ? t : d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + ', ' + t;
71
+ }
72
+ function clip(s, n) { s = String(s || '').replace(/\s+/g, ' ').trim(); return s.length > n ? s.slice(0, n - 1).trimEnd() + '…' : s; }
73
+ var SOURCE = { web: 'Typed on the web', mac: 'Typed on the Mac', g2: 'Said on the glasses', telegram: 'From Telegram' };
74
+ var MARK = '<svg class="icon" aria-hidden="true"><use href="#i-mark"/></svg>';
75
+
76
+ // ── capabilities and the rail badge ───────────────────
77
+ // Drop it on: the pinned COS chat. Ask on (durable query jobs): the composer, with or without Drop it.
78
+ function dropOn() { return !!(C.caps && C.caps.dropIt); }
79
+ function askOn() { return !!(C.caps && C.caps.jobs); }
80
+ function on() { return dropOn() || askOn(); }
81
+ function setCaps(h) {
82
+ var caps = (h && h.capabilities) || {};
83
+ var drop = caps.dropIt;
84
+ var next = {
85
+ 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)
88
+ };
89
+ var changed = !C.caps || C.caps.dropIt !== next.dropIt || C.caps.jobs !== next.jobs;
90
+ C.caps = next;
91
+ if (!next.dropIt) { clearRail(); C.summary = null; }
92
+ if (changed && C.onChange) C.onChange();
93
+ if (next.dropIt) refreshSummary();
94
+ }
95
+ // The shell's status read (shell.js checkServer) hands its /api/health answer here.
96
+ function fromHealth(h) { setCaps(h); }
97
+ function ensureCaps() {
98
+ if (C.caps) return Promise.resolve(C.caps);
99
+ if (!C.capsLoading) C.capsLoading = api('/api/health').then(function (h) { setCaps(h); return C.caps; }, function () { C.capsLoading = null; return null; });
100
+ return C.capsLoading;
101
+ }
102
+ function refreshSummary() {
103
+ if (!dropOn()) return Promise.resolve(null);
104
+ return api('/api/inbox/summary').then(function (s) {
105
+ C.summary = s; paintRail(); paintPin(); return s;
106
+ }, function (err) {
107
+ // Drop it turned off since the last health read: the route answers drop_it_off.
108
+ if (err && err.code === 'drop_it_off') { C.caps.dropIt = false; clearRail(); if (C.onChange) C.onChange(); }
109
+ return null;
110
+ });
111
+ }
112
+ function waitingOf(s) { var n = s && Number(s.waiting); return Number.isInteger(n) && n > 0 ? n : 0; }
113
+ function unreadOf(s) { var n = s && Number(s.unread); return Number.isInteger(n) && n > 0 ? n : 0; }
114
+ // A number for what waits on you, a dot for unread only. Created on first paint, so with Drop it off the rail's DOM
115
+ // is untouched.
116
+ function paintRail() {
117
+ var waiting = waitingOf(C.summary), unread = unreadOf(C.summary);
118
+ document.querySelectorAll('.rail-btn[data-pane="messages"], #section-menu [data-goto="messages"]').forEach(function (b) {
119
+ var count = b.querySelector('[data-inbox-count]');
120
+ if (!count) {
121
+ count = document.createElement('span'); count.className = 'inbox-count hidden'; count.setAttribute('data-inbox-count', '');
122
+ var dot = document.createElement('span'); dot.className = 'inbox-dot hidden'; dot.setAttribute('data-inbox-dot', '');
123
+ b.appendChild(count); b.appendChild(dot);
124
+ }
125
+ count.textContent = waiting > 99 ? '99+' : String(waiting);
126
+ count.classList.toggle('hidden', !waiting);
127
+ b.querySelector('[data-inbox-dot]').classList.toggle('hidden', !(unread && !waiting));
128
+ var label = waiting ? waiting + ' waiting on you' : unread ? unread + ' new in COS' : '';
129
+ b.setAttribute('aria-label', 'Messages' + (label ? ', ' + label : ''));
130
+ });
131
+ }
132
+ function clearRail() {
133
+ document.querySelectorAll('[data-inbox-count],[data-inbox-dot]').forEach(function (el) { el.classList.add('hidden'); });
134
+ document.querySelectorAll('.rail-btn[data-pane="messages"], #section-menu [data-goto="messages"]').forEach(function (b) {
135
+ if (b.querySelector('[data-inbox-count]')) b.setAttribute('aria-label', 'Messages');
136
+ });
137
+ }
138
+
139
+ // ── the pinned row (first in Recent) ──────────────────
140
+ function pinStatus() {
141
+ var s = C.summary, w = waitingOf(s), u = unreadOf(s);
142
+ return [w ? w + ' waiting' : '', u ? u + ' new' : ''].filter(Boolean).join(' · ');
143
+ }
144
+ function pinnedRowHtml(selected) {
145
+ var s = dropOn() ? C.summary : null;
146
+ var line = s && s.lastLine ? s.lastLine : dropOn() ? 'Text it anything: a thought, an ask, a reminder. It texts back.'
147
+ : 'Ask anything, with photos or files. The answer lands in Messages with its number.';
148
+ var status = dropOn() ? pinStatus() : '';
149
+ return '<button type="button" class="cx-row cc-pin" data-msg="cos"' + (selected ? ' aria-current="true"' : '') + '>' +
150
+ '<span class="cx-row-glyph cc-pin-glyph">' + MARK + '</span>' +
151
+ '<span class="cx-row-body"><span class="cx-meta"><span class="cx-num">' + (dropOn() ? 'COS' : 'Ask COS') + '</span>' +
152
+ '<span class="cc-pinned">Pinned</span>' +
153
+ '<span class="cx-time" data-cos-pin-time>' + esc(s && s.lastAt ? timeLabel(s.lastAt) : '') + '</span>' +
154
+ '<span class="cc-pin-status' + (waitingOf(s) ? ' hot' : '') + '" data-cos-pin-status>' + esc(status) + '</span></span>' +
155
+ '<span class="cx-row-title" data-cos-pin-line>' + esc(line) + '</span></span><span class="chev"></span></button>';
156
+ }
157
+ // Updates the row's words in place: the list around it is never rebuilt for a badge.
158
+ function paintPin() {
159
+ var s = C.summary;
160
+ document.querySelectorAll('[data-cos-pin-status]').forEach(function (el) { el.textContent = pinStatus(); el.classList.toggle('hot', !!waitingOf(s)); });
161
+ document.querySelectorAll('[data-cos-pin-time]').forEach(function (el) { el.textContent = s && s.lastAt ? timeLabel(s.lastAt) : ''; });
162
+ if (s && s.lastLine) document.querySelectorAll('[data-cos-pin-line]').forEach(function (el) { el.textContent = s.lastLine; });
163
+ }
164
+
165
+ // ── models and effort (6.99.12: /api/models, as the Even app offers them) ──
166
+ // Claude's tiers are always offered (the server resolves each to its newest model); Codex, Cursor and Ollama come from
167
+ // the Mac's live catalogs, and Cursor and Ollama only when the Mac says they are ready.
168
+ var CLAUDE_MENU = [['opus', 'Claude Opus'], ['fable', 'Claude Fable'], ['sonnet', 'Claude Sonnet']];
169
+ var ENGINE = { 'codex-frontier': 'Codex', 'codex-balanced': 'Codex', 'cursor-grok': 'Cursor', 'cursor-composer': 'Cursor', ollama: 'Ollama' };
170
+ var FALLBACK = { 'codex-frontier': 'Frontier', 'codex-balanced': 'Balanced', 'cursor-grok': 'Grok', 'cursor-composer': 'Composer', ollama: '' };
171
+ var EFFORTS = [['high', 'High'], ['xhigh', 'Extra high'], ['max', 'Max'], ['ultracode', 'Ultracode']];
172
+ function modelsFrom(m) {
173
+ var out = CLAUDE_MENU.map(function (c) { return { value: c[0], label: c[1], available: true }; });
174
+ var seen = { opus: 1, fable: 1, sonnet: 1 };
175
+ ((m && m.options) || []).forEach(function (o) {
176
+ var pref = o && o.preference;
177
+ if (!pref || seen[pref] || !ENGINE[pref]) return;
178
+ if (/^cursor-/.test(pref) && m.cursorReady !== true) return;
179
+ if (pref === 'ollama' && m.ollamaReady !== true) return;
180
+ seen[pref] = 1;
181
+ var name = String(o.displayName || o.id || FALLBACK[pref] || '').trim();
182
+ out.push({ value: pref, label: (ENGINE[pref] + (name ? ' ' + name : '')).slice(0, 48), available: true });
183
+ });
184
+ return out;
185
+ }
186
+ function loadModels() {
187
+ if (C.models) return;
188
+ C.models = [];
189
+ api('/api/models').then(function (m) {
190
+ C.models = modelsFrom(m);
191
+ if (C.model && !C.models.some(function (o) { return o.value === C.model; })) { C.model = ''; save(); }
192
+ // Steer exists only where the server says so (/api/models capabilities.durableQueryJobs.steer).
193
+ var j = m && m.capabilities && m.capabilities.durableQueryJobs;
194
+ if (C.caps) C.caps.steer = !!(j && j.steer);
195
+ paintModelMenu();
196
+ }, function () { C.models = []; paintModelMenu(); });
197
+ }
198
+ function modelMenuHtml() {
199
+ var list = C.models || [];
200
+ var opts = ['<option value="">Default</option>'].concat(list.map(function (m) {
201
+ return '<option value="' + esc(m.value) + '"' + (m.value === C.model ? ' selected' : '') + (m.available ? '' : ' disabled') + '>' + esc(m.label + (m.available ? '' : ' (not available)')) + '</option>';
202
+ }));
203
+ var efforts = ['<option value="">Default</option>'].concat(EFFORTS.map(function (e) {
204
+ return '<option value="' + e[0] + '"' + (e[0] === C.effort ? ' selected' : '') + '>' + e[1] + '</option>';
205
+ }));
206
+ return '<label class="cc-model"><span>Model</span><select class="field" data-cos-model aria-label="Model for Ask">' + opts.join('') + '</select></label>' +
207
+ '<label class="cc-model"><span>Effort</span><select class="field" data-cos-effort aria-label="Effort for Ask">' + efforts.join('') + '</select></label>';
208
+ }
209
+ function paintModelMenu() {
210
+ document.querySelectorAll('[data-cos-model-slot]').forEach(function (slot) {
211
+ // An open menu is never rebuilt under the finger (it would close the iOS picker).
212
+ if (document.activeElement && slot.contains(document.activeElement)) return;
213
+ slot.innerHTML = modelMenuHtml();
214
+ });
215
+ }
216
+
217
+ // ── composers: the chat's and the reader's Reply ──────
218
+ // One composer per place, so a half-written reply never leaks into the chat. Words survive a reload (localStorage,
219
+ // per composer); files survive a sign-in in this tab but not a reload.
220
+ var drafts = W.readStore(DRAFTS);
221
+ var K = {};
222
+ function composer(name) {
223
+ if (!K[name]) K[name] = { name: name, inputId: name === 'chat' ? 'cos-input' : 'cos-in-' + name.replace(/[^A-Za-z0-9_-]/g, '_'), draft: typeof drafts[name] === 'string' ? drafts[name] : '', atts: [], busy: '', note: '', sessionId: '', no: null };
224
+ return K[name];
225
+ }
226
+ var CHAT = composer('chat');
227
+ function saveDraft(k) {
228
+ var all = W.readStore(DRAFTS);
229
+ if (k.draft) all[k.name] = k.draft; else delete all[k.name];
230
+ // Keep the newest few: a reader opened on every turn would otherwise fill the store.
231
+ var keys = Object.keys(all); if (keys.length > 12) delete all[keys[0]];
232
+ W.writeStore(DRAFTS, all);
233
+ }
234
+ function formOf(k) { return document.querySelector('[data-cos-form="' + k.name + '"]'); }
235
+ function inputOf(k) { return document.getElementById(k.inputId); }
236
+ function kOf(el) { var f = el && el.closest && el.closest('[data-cos-form]'); return f ? composer(f.getAttribute('data-cos-form')) : CHAT; }
237
+ var ACCEPT = 'image/*,.heic,.heif,application/pdf,.pdf,text/plain,.txt,text/markdown,.md,text/csv,.csv,application/json,.json,' +
238
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,.xlsx,video/mp4,.mp4,video/quicktime,.mov';
239
+ function composerHtml(k, o) {
240
+ var drop = !!o.drop;
241
+ var hint = keyboardFirst() ? (drop ? 'Return asks. Option-Return drops. Shift-Return starts a new line.' : 'Return asks. Shift-Return starts a new line.') : '';
242
+ return '<form class="cc-composer" data-cos-form="' + esc(k.name) + '" autocomplete="off">' +
243
+ '<div class="cc-chips hidden" data-cos-chips></div>' +
244
+ '<textarea class="field cc-input" id="' + esc(k.inputId) + '" rows="1" maxlength="2000" placeholder="' + esc(o.placeholder) + '" aria-label="' + esc(o.label) + '"></textarea>' +
245
+ '<div class="cc-buttons">' +
246
+ '<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>' +
247
+ '<input type="file" class="cc-file" multiple accept="' + ACCEPT + '" data-cos-file aria-label="Attach photos or files"></label>' +
248
+ '<button type="button" class="cx-btn primary cc-ask" data-cos="ask">Ask</button>' +
249
+ (drop ? '<button type="button" class="cx-btn cc-drop" data-cos="drop">Drop</button>' : '') + '</div>' +
250
+ '<div class="cc-foot"><span class="cc-menus" data-cos-model-slot>' + (C.models ? modelMenuHtml() : '') + '</span>' +
251
+ (hint || drop ? '<span class="cc-hint">' + esc(hint + (drop ? (hint ? ' ' : '') + 'A drop never sends anything to anyone.' : '')) + '</span>' : '') + '</div>' +
252
+ '<p class="cc-note hidden" data-cos-note role="status"></p>' +
253
+ '</form>';
254
+ }
255
+ // After the HTML is in the page: the words, the chips and the buttons as the composer holds them.
256
+ function hydrate(k) {
257
+ var input = inputOf(k);
258
+ if (input) { input.value = k.draft; W.autoGrow(input); }
259
+ paintChips(k); paintButtons(k); setNote(k, k.note);
260
+ }
261
+
262
+ // ── the chat ──────────────────────────────────────────
263
+ function chatMode() { return dropOn() ? 'drop' : 'ask'; }
264
+ function chatSkeleton() {
265
+ var drop = dropOn();
266
+ return '<section class="cc" data-cos-chat data-mode="' + chatMode() + '" data-tint="messages" aria-label="' + (drop ? 'COS chat' : 'Ask COS') + '">' +
267
+ '<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
+ '<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
+ '<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>' +
271
+ '<div class="cc-feed" data-cos-feed aria-live="polite"></div>' +
272
+ composerHtml(CHAT, { drop: drop, placeholder: drop ? 'Ask COS, or drop a thought, a task, a reminder' : 'Ask COS anything', label: 'Message to COS' }) +
273
+ '</section>';
274
+ }
275
+ function mount(host, visible) {
276
+ if (!host) return;
277
+ C.host = host;
278
+ var el = host.querySelector('[data-cos-chat]');
279
+ // Drop it turned on or off: the chat's buttons differ, so it is drawn again (the words are kept).
280
+ if (!el || el.getAttribute('data-mode') !== chatMode()) {
281
+ host.innerHTML = chatSkeleton();
282
+ hydrate(CHAT);
283
+ }
284
+ if (!visible) return;
285
+ loadModels();
286
+ // Ask only: no inbox to read; the chat holds this page's Asks. Inbox lines from a Drop it session are not shown.
287
+ if (!dropOn()) { if (C.feedMode !== 'ask') { C.items = []; C.feedState = 'ready'; C.feedMode = 'ask'; } paintFeed(true); return; }
288
+ if (C.items === null || C.feedState === 'error' || C.feedMode !== 'drop') loadFeed(); else paintFeed(true);
289
+ }
290
+ function chatVisible() { var el = C.host && C.host.querySelector('[data-cos-chat]'); return !!(el && el.offsetParent !== null); }
291
+
292
+ function loadFeed() {
293
+ if (C.feedState === 'loading') return;
294
+ C.feedState = 'loading';
295
+ C.feedMode = 'drop';
296
+ if (C.items === null) paintFeed(false);
297
+ api('/api/inbox/feed?limit=100').then(function (d) {
298
+ C.items = (d && d.items) || [];
299
+ C.feedState = 'ready';
300
+ paintFeed(true);
301
+ markReadIfShown();
302
+ }, function (err) {
303
+ C.feedState = 'error';
304
+ C.feedError = err && err.code === 'drop_it_off' ? 'Drop it is off on this Mac.' : 'The chat could not be read. Check that the Mac is awake, then try again.';
305
+ paintFeed(false);
306
+ });
307
+ }
308
+ function mergeItems(list) {
309
+ if (!Array.isArray(list)) return;
310
+ if (!C.items) C.items = [];
311
+ list.forEach(function (it) {
312
+ var i = -1;
313
+ for (var k = 0; k < C.items.length; k++) if (C.items[k].id === it.id) { i = k; break; }
314
+ if (i >= 0) C.items[i] = it; else C.items.push(it);
315
+ });
316
+ C.items.sort(function (a, b) { return String(a.createdAt).localeCompare(String(b.createdAt)); });
317
+ }
318
+ function markReadIfShown(force) {
319
+ if (!dropOn() || !chatVisible() || !C.items || !C.items.length) return;
320
+ if (!force && C.summary && !unreadOf(C.summary)) return;
321
+ var last = C.items[C.items.length - 1];
322
+ api('/api/inbox/read', { method: 'POST', body: { through: last.id } }).then(function (r) {
323
+ if (C.summary) { C.summary.unread = r.unread; if (typeof r.waiting === 'number') C.summary.waiting = r.waiting; }
324
+ paintRail(); paintPin();
325
+ }, function () {});
326
+ }
327
+
328
+ // One feed item as a chat line: Miles's words on the right, COS on the left.
329
+ var PRIMARY = { open_card: 1, open_thread: 1, open_memory: 1, open_session: 1, open_messages: 1 };
330
+ function actionsHtml(item) {
331
+ if (item.state !== 'live') return '';
332
+ var acts = (item.actions || []).filter(function (a) { return a.id !== 'send_to_session'; });
333
+ var choices = acts.filter(function (a) { return /^choose:/.test(a.id); });
334
+ var html = '';
335
+ if (choices.length) {
336
+ html += '<ol class="cc-choices">' + choices.map(function (a, i) {
337
+ return '<li><button type="button" class="cc-choice" data-cos="act" data-item="' + esc(item.id) + '" data-act="' + esc(a.id) + '"><span class="cc-choice-n">' + (i + 1) + '</span>' + esc(a.label) + '</button></li>';
338
+ }).join('') + '</ol>';
339
+ }
340
+ var rest = acts.filter(function (a) { return !/^choose:/.test(a.id); });
341
+ if (item.needsReply) rest.push({ id: 'dismiss', label: 'Dismiss' });
342
+ if (rest.length) {
343
+ html += '<div class="cc-acts">' + rest.map(function (a) {
344
+ var cls = PRIMARY[a.id] ? 'cx-btn primary' : 'link-btn cc-text-btn';
345
+ return '<button type="button" class="' + cls + '" data-cos="act" data-item="' + esc(item.id) + '" data-act="' + esc(a.id) + '">' + esc(a.label) + '</button>';
346
+ }).join('') + '</div>';
347
+ }
348
+ if ((item.actions || []).some(function (a) { return a.id === 'send_to_session'; })) html += '<p class="cc-fine">Send it to the session from COS Control on your Mac.</p>';
349
+ if (C.moving === item.id) html += moveHtml(item);
350
+ return html;
351
+ }
352
+ function dropOf(item) {
353
+ for (var i = 0; i < (C.items || []).length; i++) if (C.items[i].threadId === item.threadId && C.items[i].kind === 'drop') return C.items[i];
354
+ return null;
355
+ }
356
+ function moveHtml(item) {
357
+ var opts = [{ dest: 'note', label: 'Just a note' }, { dest: 'memory', label: 'A memory' }, { dest: 'reminder', label: 'A reminder' }];
358
+ (item.links || []).forEach(function (l) { if (l.kind === 'card' && l.domain) opts.unshift({ dest: 'new_card', label: 'A new card in ' + l.domain, domain: l.domain }); });
359
+ return '<div class="cc-move" role="group" aria-label="Move it to"><p class="cc-fine">Move it to</p>' + opts.map(function (o) {
360
+ 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
+ }).join('') + '<button type="button" class="link-btn cc-text-btn" data-cos="move-cancel">Keep it where it is</button></div>';
362
+ }
363
+ function itemHtml(item) {
364
+ if (item.kind === 'drop' || item.kind === 'answer') {
365
+ 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>';
367
+ }
368
+ 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
+ return '<div class="cc-line cos' + (item.state !== 'live' ? ' done' : '') + (item.needsReply && item.state === 'live' ? ' waits' : '') + '" data-item-id="' + esc(item.id) + '">' +
370
+ '<span class="cc-avatar">' + MARK + '</span><div class="cc-card">' + quote +
371
+ (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>';
373
+ }
374
+ // The finished answer as Markdown; while it streams, plain words, so a half-written "**bold" never shows its stars.
375
+ function answerHtml(a) {
376
+ if (a.response) return W.md(a.response);
377
+ if (!a.partial) return '';
378
+ return '<p>' + esc(a.partial.replace(/\*\*|__|`|^#+\s*/gm, '')).replace(/\n{2,}/g, '</p><p>').replace(/\n/g, '<br>') + '</p>';
379
+ }
380
+ // What was attached to an Ask, as it was sent: the photo itself, or the file's kind and name.
381
+ function sentAttsHtml(list) {
382
+ if (!list || !list.length) return '';
383
+ return '<div class="cc-sent-atts">' + list.map(function (t) {
384
+ return '<span class="cc-sent-att" title="' + esc(t.name) + '">' + (t.thumb ? '<img src="' + esc(t.thumb) + '" alt="' + esc(t.name) + '">' : '<svg class="icon" aria-hidden="true"><use href="#i-' + (t.kind === 'video' ? 'video' : 'doc') + '"/></svg>') +
385
+ (t.thumb ? '' : '<span class="cc-sent-name">' + esc(clip(t.name, 22)) + '</span>') + '</span>';
386
+ }).join('') + '</div>';
387
+ }
388
+ function askState(a) {
389
+ return a.status === 'completed' ? 'Answered' : a.status === 'canceled' ? 'Canceled' : a.status === 'failed' || a.status === 'interrupted' ? 'Did not finish' : a.status === 'sending' ? 'Sending' : 'Answering';
390
+ }
391
+ function askHtml(a, linked) {
392
+ var running = a.status === 'sending' || a.status === 'queued' || a.status === 'running';
393
+ var title = a.no ? 'Message #' + a.no : 'Asking COS';
394
+ var body = answerHtml(a);
395
+ var acts = '';
396
+ if (running && a.jobId) {
397
+ acts = (C.caps && C.caps.steer && a.steerable !== false ? '<button type="button" class="link-btn cc-text-btn" data-cos="steer" data-cid="' + a.cid + '">Steer</button>' : '') +
398
+ '<button type="button" class="link-btn cc-text-btn" data-cos="cancel" data-cid="' + a.cid + '">Cancel</button>';
399
+ } else if (a.status === 'completed' && a.no) {
400
+ acts = '<button type="button" class="cx-btn primary" data-cos="open-msg" data-no="' + a.no + '">Open in Messages</button>';
401
+ }
402
+ // A drop that became a question already shows its words as the drop: only the answer card is added.
403
+ return (linked ? '' : '<div class="cc-line you"><div class="cc-bubble">' + esc(a.query) + '</div>' + sentAttsHtml(a.atts) + '<div class="cc-src">' + (a.replyKey ? 'Replied on the web' : 'Asked on the web') + '</div></div>') +
404
+ '<div class="cc-line cos" data-ask="' + a.cid + '"><span class="cc-avatar">' + MARK + '</span><div class="cc-card">' +
405
+ '<p class="cc-card-title"><span data-ask-title>' + esc(title) + '</span><span class="cx-time" data-ask-state>' + esc(askState(a)) + '</span></p>' +
406
+ '<div class="cc-answer md" data-ask-body>' + (body || '<span class="cc-wait">COS is answering. It also lands in Messages on the glasses.</span>') + '</div>' +
407
+ (a.error ? '<p class="cc-fine warn">' + esc(a.error) + '</p>' : '') +
408
+ '<div class="cc-acts" data-ask-acts>' + acts + '</div>' +
409
+ (a.steering ? '<form class="cc-steer" data-cos-steer="' + a.cid + '"><input class="field" type="text" maxlength="2000" placeholder="Steer the answer" aria-label="Steer the answer"><button type="submit" class="cx-btn">Send</button></form>' : '') +
410
+ '</div></div>';
411
+ }
412
+ function paintFeed(stick) {
413
+ var feed = C.host && C.host.querySelector('[data-cos-feed]');
414
+ if (!feed) return;
415
+ // Never rebuild under the finger: a focused steer box or an open Move list keeps the lines as they are.
416
+ if (document.activeElement && feed.contains(document.activeElement) && document.activeElement.tagName === 'INPUT') return;
417
+ var html;
418
+ if (C.items === null && C.feedState !== 'error') html = W.skeletonRows(3);
419
+ else if (C.feedState === 'error' && C.items === null) html = '<div class="cx-notice warn"><span>' + esc(C.feedError) + '</span></div><button type="button" class="cx-btn quiet" data-cos="retry">Try again</button>';
420
+ else {
421
+ var rows = (C.items || []).map(function (it) { return { at: it.createdAt, html: itemHtml(it) }; });
422
+ C.asks.forEach(function (a) { if (!a.replyKey) rows.push({ at: new Date(a.at + (a.dropId ? 1 : 0)).toISOString(), html: askHtml(a, !!a.dropId) }); });
423
+ rows.sort(function (a, b) { return String(a.at).localeCompare(String(b.at)); });
424
+ html = rows.length ? rows.map(function (r) { return r.html; }).join('')
425
+ : dropOn() ? '<p class="cc-empty">Nothing here yet. Ask COS something, or drop a thought and COS files it.</p>'
426
+ : '<p class="cc-empty">Ask COS anything. Attach photos or files with the clip.</p>';
427
+ }
428
+ var scroller = scrollerOf(feed);
429
+ var atBottom = scroller ? scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight < 80 : true;
430
+ feed.innerHTML = html;
431
+ if (stick || atBottom) scrollToEnd(feed);
432
+ }
433
+ // From 860 px the chat column scrolls by itself; on a phone the page does.
434
+ function scrollerOf(feed) { return wide() ? feed : document.scrollingElement; }
435
+ function scrollToEnd(feed) {
436
+ if (wide()) feed.scrollTop = feed.scrollHeight;
437
+ else if (W.parseRoute().args[0] === 'cos') window.scrollTo(0, document.body.scrollHeight);
438
+ }
439
+ // A running Ask updates its own card in place, so a poll never rebuilds the chat (or the reader).
440
+ function paintAsk(a) {
441
+ var el = document.querySelector('[data-ask="' + a.cid + '"]');
442
+ if (!el) return a.replyKey ? paintReplyFeed(a.replyKey) : paintFeed(false);
443
+ var running = a.status === 'sending' || a.status === 'queued' || a.status === 'running';
444
+ var title = el.querySelector('[data-ask-title]'); if (title) title.textContent = a.no ? 'Message #' + a.no : 'Asking COS';
445
+ var st = el.querySelector('[data-ask-state]'); if (st) st.textContent = askState(a);
446
+ var body = el.querySelector('[data-ask-body]'); var text = a.response || a.partial;
447
+ if (body && text && body.getAttribute('data-len') !== String(text.length) + (a.response ? 'r' : '')) { body.innerHTML = answerHtml(a); body.setAttribute('data-len', String(text.length) + (a.response ? 'r' : '')); }
448
+ if (!running) { if (a.replyKey) paintReplyFeed(a.replyKey); else paintFeed(false); }
449
+ }
450
+
451
+ function uploadsPending(k) { return k.atts.some(function (t) { return t.state === 'reading' || t.state === 'uploading'; }); }
452
+ function uploadsFailed(k) { return k.atts.some(function (t) { return t.state === 'failed'; }); }
453
+ function paintButtons(k) {
454
+ k = k || CHAT;
455
+ var form = formOf(k); if (!form) return;
456
+ var input = inputOf(k); var text = input ? input.value.trim() : '';
457
+ var ask = form.querySelector('[data-cos="ask"]'), drop = form.querySelector('[data-cos="drop"]');
458
+ var jobs = !C.caps || C.caps.jobs;
459
+ var waiting = uploadsPending(k), failed = uploadsFailed(k);
460
+ 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'; }
462
+ var attach = form.querySelector('.cc-attach'), picker = form.querySelector('[data-cos-file]');
463
+ if (attach) attach.classList.toggle('full', k.atts.length >= MAX_ATTS);
464
+ if (picker) picker.disabled = k.atts.length >= MAX_ATTS || !!k.busy;
465
+ if (!jobs) setNote(k, 'Ask needs the durable query jobs switch on your Mac.' + (drop ? ' Drop still works.' : ''));
466
+ else if (failed && !k.note) setNote(k, 'A file did not upload. Retry it or remove it, then Ask.');
467
+ }
468
+ function setNote(k, text) {
469
+ k.note = text || '';
470
+ var form = formOf(k);
471
+ var el = form && form.querySelector('[data-cos-note]');
472
+ if (!el) return;
473
+ el.innerHTML = esc(k.note) + (k.signedOut ? ' <button type="button" class="link-btn cc-text-btn" data-cos="sign-in">Sign in again</button>' : '');
474
+ el.classList.toggle('hidden', !k.note);
475
+ }
476
+
477
+ // ── attachments (6.99.12, the Even app's composer) ────
478
+ var MAX_ATTS = 5; // MAX_ATTACHMENTS_PER_PROMPT
479
+ var MAX_PHOTO = 2 * 1024 * 1024; // MAX_IMAGE_BYTES, after the resize
480
+ var MAX_DOC = 64 * 1024 * 1024; // MAX_OTHER_MEDIA_BYTES
481
+ var MAX_VIDEO = 100 * 1024 * 1024; // MAX_VIDEO_MEDIA_BYTES
482
+ var PHOTO_EDGE = 1024, PHOTO_QUALITY = 0.85;
483
+ var DOC_EXT = { pdf: 'application/pdf', txt: 'text/plain', md: 'text/markdown', csv: 'text/csv', json: 'application/json', xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' };
484
+ var VIDEO_EXT = { mp4: 'video/mp4', mov: 'video/quicktime', m4v: 'video/mp4' };
485
+ var PHOTO_EXT = { jpg: 1, jpeg: 1, png: 1, gif: 1, webp: 1, heic: 1, heif: 1 };
486
+ function extOf(name) { var m = /\.([A-Za-z0-9]+)$/.exec(String(name || '')); return m ? m[1].toLowerCase() : ''; }
487
+ function mb(bytes) { var n = bytes / 1048576; return (n >= 10 ? Math.round(n) : Math.round(n * 10) / 10) + ' MB'; }
488
+ function sizeLabel(bytes) { return bytes >= 1048576 ? mb(bytes) : Math.max(1, Math.round(bytes / 1024)) + ' KB'; }
489
+ function isSafari() {
490
+ var ua = (window.navigator && window.navigator.userAgent) || '';
491
+ return /safari/i.test(ua) && !/chrome|chromium|crios|fxios|edg|android|opr/i.test(ua);
492
+ }
493
+ /** What a file is to COS: 'photo', 'doc' or 'video', with a refusal sentence when it cannot be attached. */
494
+ function classify(file) {
495
+ var type = String(file.type || '').toLowerCase(), ext = extOf(file.name);
496
+ var heic = /^image\/hei[cf]/.test(type) || ext === 'heic' || ext === 'heif';
497
+ 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.' };
498
+ if (/^image\//.test(type) || PHOTO_EXT[ext]) return { kind: 'photo' };
499
+ if (/^video\//.test(type) || VIDEO_EXT[ext]) {
500
+ if (!VIDEO_EXT[ext] && type !== 'video/mp4' && type !== 'video/quicktime') return { refuse: file.name + ' is a video COS cannot read. Use MP4 or MOV.' };
501
+ if (file.size > MAX_VIDEO) return { refuse: file.name + ' is ' + mb(file.size) + '. The largest video COS takes is 100 MB.' };
502
+ return { kind: 'video', mime: type || VIDEO_EXT[ext] };
503
+ }
504
+ if (DOC_EXT[ext] || type === 'application/pdf' || type === 'text/plain' || type === 'text/csv' || type === 'text/markdown' || type === 'application/json') {
505
+ if (file.size > MAX_DOC) return { refuse: file.name + ' is ' + mb(file.size) + '. The largest file COS takes is 64 MB.' };
506
+ return { kind: 'doc', mime: type && type !== 'application/octet-stream' ? type : DOC_EXT[ext] || type };
507
+ }
508
+ return { refuse: file.name + ' cannot be attached. COS takes photos, PDF, TXT, MD, CSV, JSON, XLSX, MP4 and MOV.' };
509
+ }
510
+ function addFiles(k, files) {
511
+ var list = Array.prototype.slice.call(files || []);
512
+ if (!list.length) return;
513
+ var refusals = [];
514
+ list.forEach(function (file) {
515
+ if (k.atts.length >= MAX_ATTS) { refusals.push('Up to 5 attachments per message. ' + file.name + ' was not added.'); return; }
516
+ var c = classify(file);
517
+ if (c.refuse) { refusals.push(c.refuse); return; }
518
+ 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: '' };
519
+ k.atts.push(t);
520
+ startUpload(k, t);
521
+ });
522
+ k.signedOut = false;
523
+ setNote(k, refusals.join(' '));
524
+ paintChips(k); paintButtons(k);
525
+ }
526
+ function chipMeta(t) {
527
+ if (t.state === 'reading') return t.kind === 'photo' ? 'Resizing' : 'Preparing';
528
+ if (t.state === 'uploading') return 'Uploading ' + Math.round(t.progress * 100) + '%';
529
+ if (t.state === 'failed') return t.error || 'Did not upload';
530
+ return sizeLabel(t.size);
531
+ }
532
+ function chipHtml(t) {
533
+ 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>';
534
+ return '<div class="cc-chip ' + t.state + '" data-chip="' + t.id + '"><span class="cc-chip-thumb">' + icon + '</span>' +
535
+ '<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>' +
536
+ (t.state === 'failed' ? '<button type="button" class="link-btn cc-chip-retry" data-cos="att-retry" data-att="' + t.id + '">Retry</button>' : '') +
537
+ '<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>' +
538
+ (t.state === 'uploading' || t.state === 'reading' ? '<span class="cc-chip-bar" data-chip-bar style="width:' + Math.round(t.progress * 100) + '%"></span>' : '') + '</div>';
539
+ }
540
+ function paintChips(k) {
541
+ var form = formOf(k), box = form && form.querySelector('[data-cos-chips]');
542
+ if (!box) return;
543
+ box.innerHTML = k.atts.map(chipHtml).join('');
544
+ box.classList.toggle('hidden', !k.atts.length);
545
+ }
546
+ // Progress moves the bar and the words only, never the chip row (a tap on Remove must not miss).
547
+ function paintProgress(k, t) {
548
+ var chip = document.querySelector('[data-chip="' + t.id + '"]');
549
+ if (!chip) return;
550
+ var meta = chip.querySelector('[data-chip-meta]'); if (meta) meta.textContent = chipMeta(t);
551
+ var bar = chip.querySelector('[data-chip-bar]'); if (bar) bar.style.width = Math.round(t.progress * 100) + '%';
552
+ }
553
+ 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; }
554
+ function removeAtt(k, id) {
555
+ k.atts = k.atts.filter(function (t) { if (t.id === id && t.xhr) { try { t.xhr.abort(); } catch (_e) {} } return t.id !== id; });
556
+ if (!uploadsFailed(k) && /did not upload/i.test(k.note)) setNote(k, '');
557
+ paintChips(k); paintButtons(k);
558
+ }
559
+ /** A photo, drawn into a canvas at most 1024 px on its longest edge, as a JPEG at quality 0.85. */
560
+ function resizePhoto(file) {
561
+ return new Promise(function (resolve, reject) {
562
+ var url = URL.createObjectURL(file);
563
+ var img = new Image();
564
+ img.onload = function () {
565
+ var w0 = img.naturalWidth || img.width, h0 = img.naturalHeight || img.height;
566
+ var s = Math.min(1, PHOTO_EDGE / Math.max(w0, h0, 1));
567
+ var w = Math.max(1, Math.round(w0 * s)), h = Math.max(1, Math.round(h0 * s));
568
+ var canvas = document.createElement('canvas'); canvas.width = w; canvas.height = h;
569
+ var g = canvas.getContext('2d');
570
+ g.fillStyle = '#ffffff'; g.fillRect(0, 0, w, h); // a transparent PNG gets white, not black, as a JPEG
571
+ g.drawImage(img, 0, 0, w, h);
572
+ URL.revokeObjectURL(url);
573
+ resolve(canvas.toDataURL('image/jpeg', PHOTO_QUALITY));
574
+ };
575
+ img.onerror = function () { URL.revokeObjectURL(url); reject(new Error('This browser could not open that photo.')); };
576
+ img.src = url;
577
+ });
578
+ }
579
+ /**
580
+ * One upload with its progress. XMLHttpRequest, not fetch: fetch reports no upload progress. Same request as fetch
581
+ * with credentials 'same-origin' (the cookie goes with any same-origin XHR) plus X-COS-Web: 1, which a cookie write
582
+ * needs. A 401 here never opens the sign-in screen: the chip says so and the message stays (see uploadError).
583
+ */
584
+ function uploadRequest(path, body, headers, onProgress, t) {
585
+ return new Promise(function (resolve, reject) {
586
+ var xhr = new XMLHttpRequest();
587
+ t.xhr = xhr;
588
+ xhr.open('POST', path, true);
589
+ xhr.setRequestHeader('X-COS-Web', '1');
590
+ Object.keys(headers).forEach(function (h) { xhr.setRequestHeader(h, headers[h]); });
591
+ if (xhr.upload) xhr.upload.onprogress = function (e) { if (e.lengthComputable && e.total) onProgress(e.loaded / e.total); };
592
+ xhr.onload = function () {
593
+ t.xhr = null;
594
+ var data = {}; try { data = JSON.parse(xhr.responseText || '{}'); } catch (_e) { data = {}; }
595
+ if (xhr.status >= 200 && xhr.status < 300) return resolve(data);
596
+ var err = new Error('The Mac answered ' + xhr.status + '.'); err.status = xhr.status;
597
+ err.code = data && (typeof data.error === 'string' ? data.error : data.error && data.error.code) || '';
598
+ reject(err);
599
+ };
600
+ xhr.onerror = function () { t.xhr = null; var err = new Error('The Mac did not answer.'); err.code = 'network'; reject(err); };
601
+ xhr.onabort = function () { t.xhr = null; var err = new Error('Removed.'); err.code = 'aborted'; reject(err); };
602
+ xhr.send(body);
603
+ });
604
+ }
605
+ var UPLOAD_COPY = {
606
+ batch_too_large: 'Too large for one upload.', too_many_images: 'Up to 5 attachments per message.',
607
+ media_processing_unavailable: 'The Mac cannot read photos right now.', unsupported_kind: 'COS cannot read this kind of file.',
608
+ unsupported_media_type: 'COS cannot read this kind of file.', file_too_large: 'Too large for COS.', attachment_too_large: 'Too large for COS.',
609
+ server_maintenance: 'The Mac is updating. Retry in a minute.', network: 'The Mac did not answer. Retry when it is awake.'
610
+ };
611
+ function uploadError(k, t, err) {
612
+ if (err && err.code === 'aborted') return;
613
+ t.state = 'failed';
614
+ if (err && err.status === 401) {
615
+ // Signed out mid-message (the browser session expired or every browser was signed out). Nothing is thrown away:
616
+ // the words and the files stay, and Sign in again opens the sign-in screen on purpose, not by surprise.
617
+ t.error = 'Signed out';
618
+ k.signedOut = true;
619
+ setNote(k, 'This browser was signed out. Your message and files are kept. Sign in, then tap Retry on each file.');
620
+ } else {
621
+ t.error = UPLOAD_COPY[err && err.code] || (err && err.status === 413 ? 'Too large for COS.' : 'Did not upload');
622
+ }
623
+ paintChips(k); paintButtons(k);
624
+ }
625
+ function startUpload(k, t) {
626
+ t.state = 'reading'; t.progress = 0; t.error = '';
627
+ var at = new Date(t.file.lastModified || Date.now()).toISOString();
628
+ var step;
629
+ if (t.kind === 'photo') {
630
+ step = resizePhoto(t.file).then(function (dataUrl) {
631
+ var data = dataUrl.slice(dataUrl.indexOf(',') + 1);
632
+ var bytes = Math.floor(data.length * 3 / 4);
633
+ 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);
635
+ 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);
637
+ }).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
+ }
647
+ step.then(function (ref) {
648
+ if (!findAtt(k, t.id)) return;
649
+ if (!ref || !ref.id) { var e = new Error('The Mac did not keep the file.'); e.code = ''; throw e; }
650
+ t.mediaId = ref.id; t.state = 'ready'; t.progress = 1;
651
+ paintChips(k); paintButtons(k);
652
+ }).catch(function (err) {
653
+ 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; }
656
+ uploadError(k, t, err);
657
+ });
658
+ }
659
+ function readyAtts(k) { return k.atts.filter(function (t) { return t.state === 'ready' && t.mediaId; }); }
660
+ function clearAtts(k) { k.atts = []; paintChips(k); }
661
+
662
+ // ── Drop ──────────────────────────────────────────────
663
+ function doDrop() {
664
+ var k = CHAT;
665
+ 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; }
669
+ var dropId = uuid();
670
+ k.busy = 'drop'; setNote(k, ''); paintButtons(k);
671
+ var send = function (retry) {
672
+ return api('/api/drops', { method: 'POST', body: { dropId: dropId, text: text, source: 'web' }, timeoutMs: 30000 }).catch(function (err) {
673
+ // The same dropId is safe to send again: the server answers the first result.
674
+ if (retry && err && err.code === 'timeout') return send(false);
675
+ throw err;
676
+ });
677
+ };
678
+ send(true).then(function (ans) {
679
+ k.busy = ''; k.draft = ''; saveDraft(k);
680
+ var box = inputOf(k); if (box) { box.value = ''; W.autoGrow(box); }
681
+ mergeItems(ans.items);
682
+ paintFeed(true); paintButtons(k);
683
+ if (ans.next && ans.next.kind === 'ask' && C.caps && C.caps.jobs) submitAsk(ans.next.prompt, ans.drop.id);
684
+ refreshSummary();
685
+ }, function (err) {
686
+ 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.');
689
+ });
690
+ }
691
+
692
+ // ── Ask ───────────────────────────────────────────────
693
+ function doAsk(k) {
694
+ k = k || CHAT;
695
+ var input = inputOf(k); var text = input ? input.value.trim() : '';
696
+ if (!text || k.busy || uploadsPending(k) || uploadsFailed(k)) return;
697
+ // What is shown as sent is exactly what is sent: never more than the server's five per prompt.
698
+ var ready = readyAtts(k).slice(0, MAX_ATTS);
699
+ k.busy = 'ask'; setNote(k, ''); paintButtons(k);
700
+ var opts = { attachmentIds: ready.map(function (t) { return t.mediaId; }), atts: ready.map(function (t) { return { name: t.name, kind: t.kind, thumb: t.thumb }; }) };
701
+ if (k.name !== 'chat') { opts.sessionId = k.sessionId; opts.replyKey = k.name; }
702
+ submitAsk(text, null, function (ok) {
703
+ k.busy = '';
704
+ if (ok) { k.draft = ''; saveDraft(k); clearAtts(k); var box = inputOf(k); if (box) { box.value = ''; W.autoGrow(box); } }
705
+ paintButtons(k);
706
+ }, opts);
707
+ }
708
+ function submitAsk(text, dropId, done, opts) {
709
+ opts = opts || {};
710
+ var a = { cid: uuid(), query: text, at: Date.now(), status: 'sending', partial: '', dropId: dropId || '', atts: opts.atts || [], replyKey: opts.replyKey || '' };
711
+ C.asks.push(a);
712
+ if (a.replyKey) paintReplyFeed(a.replyKey); else paintFeed(true);
713
+ var body = { clientJobId: a.cid, generation: 1, query: text, source: 'web', activityToolMode: 'status' };
714
+ if (C.model) body.model = C.model;
715
+ if (C.effort) body.effort = C.effort;
716
+ if (C.session && C.session.id) body.sessionId = C.session.id;
717
+ // A reply continues the turn's own conversation (kept when it is still live; else the server starts a new one).
718
+ if (opts.sessionId) body.sessionId = opts.sessionId;
719
+ if (opts.attachmentIds && opts.attachmentIds.length) body.attachmentIds = opts.attachmentIds.slice(0, MAX_ATTS);
720
+ if (C.caps && C.caps.steer) body.steerable = true;
721
+ var admit = function (retry) {
722
+ return api('/api/query-jobs', { method: 'POST', body: body, timeoutMs: 30000 }).catch(function (err) {
723
+ // A lost answer: the job may exist. Ask for it by its client id before saying anything failed.
724
+ if (err && err.code === 'timeout') {
725
+ return api('/api/query-jobs/by-client/' + a.cid + '?generation=1').catch(function () { if (retry) return admit(false); throw err; });
726
+ }
727
+ throw err;
728
+ });
729
+ };
730
+ admit(true).then(function (d) {
731
+ var job = d && d.job; if (!job) throw new Error('The Mac did not take the question.');
732
+ take(a, job);
733
+ if (job.sessionId && !a.replyKey) { C.session = { id: job.sessionId, day: today() }; save(); }
734
+ if (dropId) api('/api/drops/' + encodeURIComponent(dropId) + '/ask', { method: 'POST', body: { clientJobId: a.cid, generation: 1 } }).catch(function () {});
735
+ if (done) done(true);
736
+ poll(a);
737
+ }, function (err) {
738
+ a.status = 'failed';
739
+ a.error = err && err.code === 'durable_query_jobs_disabled' ? 'Ask needs the durable query jobs switch on your Mac.'
740
+ : err && /attachment/.test(String(err.code || '')) ? 'An attachment could not be read: ' + ((err && err.message) || err.code) + ' Your words and files are still in the box.'
741
+ : 'Not sent: ' + ((err && err.message) || 'the Mac did not answer');
742
+ if (a.replyKey) paintReplyFeed(a.replyKey); else paintFeed(false);
743
+ if (done) done(false);
744
+ });
745
+ return a;
746
+ }
747
+ function take(a, job) {
748
+ a.jobId = job.jobId; a.status = job.status || a.status;
749
+ if (job.globalMsgNum) a.no = job.globalMsgNum;
750
+ if (job.sessionId) a.sessionId = job.sessionId;
751
+ if (typeof job.partialText === 'string') a.partial = job.partialText;
752
+ if (typeof job.response === 'string') a.response = job.response;
753
+ if (job.steerable === false) a.steerable = false;
754
+ if (job.error && (a.status === 'failed' || a.status === 'interrupted')) a.error = 'COS could not finish this answer. Ask again when you are ready.';
755
+ }
756
+ function poll(a) {
757
+ var started = Date.now();
758
+ var tick = function () {
759
+ if (!a.jobId) return;
760
+ api('/api/query-jobs/' + a.jobId + '?generation=1').then(function (d) {
761
+ if (d && d.job) take(a, d.job);
762
+ paintAsk(a);
763
+ if (['completed', 'failed', 'canceled', 'interrupted'].indexOf(a.status) >= 0) return finished(a);
764
+ if (Date.now() - started < 20 * 60000) setTimeout(tick, document.hidden ? 4000 : 1000);
765
+ }, function () { if (Date.now() - started < 20 * 60000) setTimeout(tick, 3000); });
766
+ };
767
+ setTimeout(tick, 600);
768
+ }
769
+ function finished(a) {
770
+ if (a.replyKey) paintReplyFeed(a.replyKey); else paintFeed(false);
771
+ if (C.onAnswered) C.onAnswered();
772
+ // A drop that became a question gets its answer as a follow-up: read the chat again.
773
+ if (a.dropId) { C.asks = C.asks.filter(function (x) { return x !== a; }); setTimeout(function () { loadFeedQuietly(); }, 300); }
774
+ refreshSummary();
775
+ }
776
+ function loadFeedQuietly() {
777
+ if (!dropOn()) return;
778
+ api('/api/inbox/feed?limit=100').then(function (d) { C.items = (d && d.items) || []; paintFeed(false); markReadIfShown(); }, function () {});
779
+ }
780
+ // The Ask sheet (app.js askCos) hands its words here, so the answer streams in the chat instead of vanishing.
781
+ function askFromSheet(text) {
782
+ return ensureCaps().then(function (caps) {
783
+ 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; }
784
+ return new Promise(function (resolve, reject) {
785
+ 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.')); });
786
+ });
787
+ });
788
+ }
789
+
790
+ // ── Reply in the message reader (6.99.12) ─────────────
791
+ function replyName(key) { return 'reply-' + String(key); }
792
+ function replyHtml(key, turn) {
793
+ if (!askOn()) return '';
794
+ var k = composer(replyName(key));
795
+ k.sessionId = turn && typeof turn.sessionId === 'string' ? turn.sessionId : '';
796
+ k.no = turn ? (turn.no != null ? turn.no : turn.globalMsgNum) : null;
797
+ var label = k.no != null ? 'Reply to Message #' + k.no : 'Reply';
798
+ return '<section class="cc cx-reply" data-reply-host="' + esc(key) + '" aria-label="' + esc(label) + '">' +
799
+ '<h3 class="cx-reply-title">Reply</h3>' +
800
+ '<p class="cx-reply-sub">' + esc(k.sessionId ? 'Continues this conversation. The answer lands in Messages with its own number.' : 'Starts a conversation. The answer lands in Messages with its own number.') + '</p>' +
801
+ '<div class="cc-feed cx-reply-feed" data-reply-feed="' + esc(key) + '" aria-live="polite">' + replyFeedHtml(key) + '</div>' +
802
+ composerHtml(k, { drop: false, placeholder: label, label: label }) + '</section>';
803
+ }
804
+ function replyFeedHtml(key) {
805
+ return C.asks.filter(function (a) { return a.replyKey === replyName(key); }).map(function (a) { return askHtml(a, false); }).join('');
806
+ }
807
+ function paintReplyFeed(name) {
808
+ var key = String(name).replace(/^reply-/, '');
809
+ var feed = document.querySelector('[data-reply-feed="' + key + '"]');
810
+ if (!feed) return;
811
+ if (document.activeElement && feed.contains(document.activeElement) && document.activeElement.tagName === 'INPUT') return;
812
+ feed.innerHTML = replyFeedHtml(key);
813
+ }
814
+ // messages.js calls this once the reader's HTML is in the page.
815
+ function mountReply(key) {
816
+ var k = composer(replyName(key));
817
+ hydrate(k);
818
+ loadModels();
819
+ }
820
+
821
+ // ── actions on a line ─────────────────────────────────
822
+ function act(itemId, action) {
823
+ var item = (C.items || []).filter(function (i) { return i.id === itemId; })[0];
824
+ if (!item) return;
825
+ if (action === 'move') { C.moving = C.moving === itemId ? '' : itemId; return paintFeed(false); }
826
+ if (action === 'open_card') return W.go('work');
827
+ if (action === 'open_thread') return W.go('threads');
828
+ if (action === 'open_memory') return W.go('memories');
829
+ if (action === 'open_session') return W.go('sessions');
830
+ if (action === 'open_messages') return W.go('messages');
831
+ // P2 (6.99.11): pings and follow-ups. Opens go where the item's link lives; Prep asks COS; Reply starts a reminder.
832
+ var linkOf = function (kind) { return (item.links || []).filter(function (l) { return l.kind === kind; })[0]; };
833
+ if (action === 'open_result') return W.go(linkOf('session') ? 'sessions' : linkOf('card') ? 'work' : 'messages');
834
+ if (action === 'open') { if (linkOf('thread')) return W.go('threads'); if (linkOf('meeting')) return W.go('meetings'); return; }
835
+ if (action === 'review') return W.go('work');
836
+ if (action === 'prep') { if (C.caps && C.caps.jobs) submitAsk('Prep me for ' + (item.title || 'my next meeting'), ''); return; }
837
+ if (action === 'reply') {
838
+ var box = document.getElementById('cos-input');
839
+ if (box) { box.value = 'remind me '; box.focus(); }
840
+ return;
841
+ }
842
+ var P2_POST = { reminder_done: 1, snooze_1h: 1, snooze_tomorrow: 1 };
843
+ var body = action === 'undo' || action === 'dismiss' || /^choose:/.test(action) || /^when:/.test(action) || P2_POST[action] ? { itemId: itemId, action: action } : null;
844
+ if (!body) return;
845
+ api('/api/inbox/action', { method: 'POST', body: body }).then(function (ans) {
846
+ if (ans && ans.items) mergeItems(ans.items);
847
+ if (ans && ans.item) mergeItems([ans.item]);
848
+ // A reminder action answers the finished item itself (merged above); dismiss answers counts only.
849
+ 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);
851
+ paintFeed(false); refreshSummary();
852
+ }, function (err) { W.toast((err && err.message) || 'That did not go through.'); });
853
+ }
854
+ function moveTo(itemId, dest, domain) {
855
+ var item = (C.items || []).filter(function (i) { return i.id === itemId; })[0];
856
+ if (!item) return;
857
+ var body = { dest: dest };
858
+ if (dest === 'new_card' && domain) body.target = { id: domain, label: domain, domain: domain };
859
+ api('/api/drops/' + encodeURIComponent(item.threadId) + '/move', { method: 'POST', body: body }).then(function (ans) {
860
+ C.moving = '';
861
+ mergeItems(ans.items); loadFeedQuietly(); refreshSummary();
862
+ }, function (err) { W.toast((err && err.message) || 'That could not be moved.'); });
863
+ }
864
+ function steer(cid, text) {
865
+ var a = C.asks.filter(function (x) { return x.cid === cid; })[0];
866
+ if (!a || !a.jobId || !text.trim()) return;
867
+ a.steering = false;
868
+ api('/api/query-jobs/' + a.jobId + '/steer', { method: 'POST', body: { generation: 1, steerId: uuid(), text: text.trim() } }).then(function (r) {
869
+ W.toast(r && r.state === 'accepted' ? 'COS has it.' : 'Too late to steer this one.');
870
+ }, function (err) { W.toast((err && err.message) || 'The steer did not go through.'); });
871
+ if (a.replyKey) paintReplyFeed(a.replyKey); else paintFeed(false);
872
+ }
873
+ function cancel(cid) {
874
+ var a = C.asks.filter(function (x) { return x.cid === cid; })[0];
875
+ if (!a || !a.jobId) return;
876
+ api('/api/query-jobs/' + a.jobId + '/cancel', { method: 'POST', body: { generation: 1 } }).then(function (d) { if (d && d.job) take(a, d.job); paintAsk(a); }, function (err) { W.toast((err && err.message) || 'Cancel did not go through.'); });
877
+ }
878
+
879
+ // ── events (delegated from the Messages pane) ─────────
880
+ function onClick(t) {
881
+ var what = t.getAttribute('data-cos');
882
+ if (what === 'ask') return doAsk(kOf(t));
883
+ if (what === 'drop') return doDrop();
884
+ if (what === 'all') return W.go('messages');
885
+ if (what === 'read-all') return markReadIfShown(true);
886
+ if (what === 'retry') { C.feedState = 'idle'; return loadFeed(); }
887
+ if (what === 'act') return act(t.getAttribute('data-item'), t.getAttribute('data-act'));
888
+ if (what === 'move-to') return moveTo(t.getAttribute('data-item'), t.getAttribute('data-dest'), t.getAttribute('data-domain'));
889
+ if (what === 'move-cancel') { C.moving = ''; return paintFeed(false); }
890
+ if (what === 'open-msg') return W.go('messages', ['m', t.getAttribute('data-no')]);
891
+ if (what === 'steer') {
892
+ var a = C.asks.filter(function (x) { return x.cid === t.getAttribute('data-cid'); })[0];
893
+ if (a) { a.steering = !a.steering; if (a.replyKey) paintReplyFeed(a.replyKey); else paintFeed(false); var f = document.querySelector('[data-cos-steer="' + a.cid + '"] input'); if (f) f.focus(); }
894
+ return;
895
+ }
896
+ if (what === 'cancel') return cancel(t.getAttribute('data-cid'));
897
+ if (what === 'att-remove') return removeAtt(kOf(t), t.getAttribute('data-att'));
898
+ if (what === 'att-retry') {
899
+ var k = kOf(t), att = findAtt(k, t.getAttribute('data-att'));
900
+ if (att) { k.signedOut = false; setNote(k, ''); startUpload(k, att); paintChips(k); paintButtons(k); }
901
+ return;
902
+ }
903
+ 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; }
904
+ if (what === 'quote') {
905
+ // Tapping "Re: ..." scrolls to the drop it answers.
906
+ var drop = C.host.querySelector('[data-drop-thread="' + t.getAttribute('data-thread') + '"]');
907
+ if (drop) drop.scrollIntoView({ block: 'center' });
908
+ }
909
+ }
910
+ function isComposerInput(el) { return !!(el && el.classList && el.classList.contains('cc-input')); }
911
+ function onKey(e) {
912
+ if (!isComposerInput(e.target) || e.key !== 'Enter' || e.isComposing || e.shiftKey) return;
913
+ e.preventDefault();
914
+ var k = kOf(e.target);
915
+ if (e.altKey) { if (k === CHAT && dropOn()) doDrop(); return; }
916
+ doAsk(k);
917
+ }
918
+ function onInput(e) {
919
+ if (isComposerInput(e.target)) { var k = kOf(e.target); k.draft = e.target.value; saveDraft(k); W.autoGrow(e.target); paintButtons(k); }
920
+ }
921
+ function onChangeEvent(e) {
922
+ var t = e.target;
923
+ if (!t || !t.hasAttribute) return;
924
+ if (t.hasAttribute('data-cos-model')) { C.model = t.value; save(); syncMenus(); }
925
+ else if (t.hasAttribute('data-cos-effort')) { C.effort = t.value; save(); syncMenus(); }
926
+ else if (t.hasAttribute('data-cos-file')) { addFiles(kOf(t), t.files); t.value = ''; }
927
+ }
928
+ // Two composers can be on screen (the chat beside a reader is not, but a reload can leave either): keep both menus true.
929
+ function syncMenus() {
930
+ document.querySelectorAll('[data-cos-model]').forEach(function (s) { if (s !== document.activeElement) s.value = C.model; });
931
+ document.querySelectorAll('[data-cos-effort]').forEach(function (s) { if (s !== document.activeElement) s.value = C.effort; });
932
+ }
933
+ function onSubmit(e) {
934
+ var f = e.target.closest && e.target.closest('[data-cos-steer]');
935
+ if (f) { e.preventDefault(); steer(f.getAttribute('data-cos-steer'), (f.querySelector('input') || {}).value || ''); return true; }
936
+ if (e.target.matches && e.target.matches('[data-cos-form]')) { e.preventDefault(); return true; }
937
+ return false;
938
+ }
939
+ // Paste a photo or a file into the box; drop files on the composer.
940
+ function onPaste(e) {
941
+ if (!isComposerInput(e.target)) return;
942
+ var files = e.clipboardData && e.clipboardData.files;
943
+ if (files && files.length) { e.preventDefault(); addFiles(kOf(e.target), files); }
944
+ }
945
+ function onDrag(e) {
946
+ var form = e.target && e.target.closest && e.target.closest('[data-cos-form]');
947
+ if (!form) return;
948
+ var types = e.dataTransfer && e.dataTransfer.types;
949
+ var hasFiles = types && Array.prototype.indexOf.call(types, 'Files') >= 0;
950
+ if (!hasFiles) return;
951
+ e.preventDefault();
952
+ if (e.type === 'dragover' || e.type === 'dragenter') { form.classList.add('dragging'); return; }
953
+ form.classList.remove('dragging');
954
+ if (e.type === 'drop') addFiles(composer(form.getAttribute('data-cos-form')), e.dataTransfer.files);
955
+ }
956
+ // Crossing 860 px shows the chat beside the list: load it then.
957
+ window.matchMedia('(min-width: 860px)').addEventListener('change', function (m) {
958
+ if (m.matches && on() && C.host && W.parseRoute().pane === 'messages') mount(C.host, true);
959
+ });
960
+ document.addEventListener('visibilitychange', function () {
961
+ if (!document.hidden && dropOn() && chatVisible()) loadFeedQuietly();
962
+ });
963
+
964
+ W.cosChat = {
965
+ on: on, dropOn: dropOn, askOn: askOn, known: function () { return !!C.caps; }, ensureCaps: ensureCaps, fromHealth: fromHealth, refreshSummary: refreshSummary,
966
+ pinnedRowHtml: pinnedRowHtml, mount: mount, wide: wide,
967
+ replyHtml: replyHtml, mountReply: mountReply, askFromSheet: askFromSheet,
968
+ onClick: onClick, onKey: onKey, onInput: onInput, onChange: onChangeEvent, onSubmit: onSubmit, onPaste: onPaste, onDrag: onDrag,
969
+ setOnChange: function (fn) { C.onChange = fn; }, setOnAnswered: function (fn) { C.onAnswered = fn; },
970
+ // Test and render hooks: what the chat holds right now.
971
+ _state: C, _composer: composer, _classify: classify, _modelsFrom: modelsFrom, _addFiles: addFiles
972
+ };
973
+ })();