@gotcos/glasses-server 6.98.1 → 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.
Files changed (98) hide show
  1. package/CHANGELOG.md +227 -0
  2. package/package.json +2 -1
  3. package/server/index.ts +170 -11
  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/cos-thread.ts +582 -0
  8. package/server/lib/display-bus.ts +3 -0
  9. package/server/lib/drop-attachments.ts +55 -0
  10. package/server/lib/drop-destinations.ts +134 -0
  11. package/server/lib/drop-history-search.ts +97 -0
  12. package/server/lib/drop-p2-boot.ts +60 -0
  13. package/server/lib/drop-p2-runtime.ts +237 -0
  14. package/server/lib/drop-p2-settings.ts +174 -0
  15. package/server/lib/drop-resolution.ts +168 -0
  16. package/server/lib/drop-router.ts +325 -0
  17. package/server/lib/drop-service.ts +830 -0
  18. package/server/lib/drop-settings.ts +56 -0
  19. package/server/lib/drop-store.ts +450 -0
  20. package/server/lib/drop-when.ts +265 -0
  21. package/server/lib/glasses-pairing.ts +15 -0
  22. package/server/lib/learning-needs-you.ts +111 -0
  23. package/server/lib/live-cues-v2-settings.ts +2 -1
  24. package/server/lib/live-cues-v2.ts +6 -4
  25. package/server/lib/media-store.ts +80 -0
  26. package/server/lib/meeting-batch-transcribe.ts +15 -2
  27. package/server/lib/meeting-consolidation-run.ts +169 -0
  28. package/server/lib/meeting-store.ts +61 -0
  29. package/server/lib/meeting-title-steer.ts +200 -0
  30. package/server/lib/network-policy.ts +84 -6
  31. package/server/lib/permission-broker.ts +13 -0
  32. package/server/lib/phone-recording.ts +1 -1
  33. package/server/lib/ping-budget.ts +149 -0
  34. package/server/lib/ping-engine.ts +245 -0
  35. package/server/lib/ping-feed-sink.ts +33 -0
  36. package/server/lib/ping-sources.ts +192 -0
  37. package/server/lib/query-job-coordinator.ts +5 -2
  38. package/server/lib/query-job-runtime.ts +45 -4
  39. package/server/lib/query-job-types.ts +10 -1
  40. package/server/lib/reminder-scheduler.ts +153 -0
  41. package/server/lib/tailnet-self-name.ts +103 -0
  42. package/server/lib/tailscale-nudge.ts +40 -2
  43. package/server/lib/task-store.ts +32 -0
  44. package/server/lib/web-ask-admission.ts +56 -0
  45. package/server/lib/web-live-chunks.ts +273 -0
  46. package/server/lib/web-memories-bridge.ts +323 -0
  47. package/server/lib/web-session-auth.ts +492 -0
  48. package/server/lib/web-usage-bridge.ts +154 -0
  49. package/server/lib/work-search.ts +14 -1
  50. package/server/lib/your-move-count.ts +58 -0
  51. package/server/routes/cos-thread.ts +79 -0
  52. package/server/routes/display.ts +11 -1
  53. package/server/routes/drops.ts +262 -0
  54. package/server/routes/health.ts +16 -0
  55. package/server/routes/live-cues.ts +5 -4
  56. package/server/routes/meeting.ts +61 -5
  57. package/server/routes/permission-broker.ts +23 -3
  58. package/server/routes/transcribe-stream.ts +43 -4
  59. package/server/routes/web-control.ts +569 -0
  60. package/server/routes/welcome-context.ts +2 -1
  61. package/server/scripts/web-live-cls/cdp.mjs +57 -0
  62. package/server/scripts/web-live-cls/measure.mjs +80 -0
  63. package/server/scripts/web-live-cls/mock.mjs +97 -0
  64. package/server/scripts/web-live-cls/render.mjs +26 -0
  65. package/server/web-control/app.css +519 -0
  66. package/server/web-control/app.js +3643 -0
  67. package/server/web-control/control.css +325 -0
  68. package/server/web-control/fonts/DMSans.ttf +0 -0
  69. package/server/web-control/fonts/Fraunces.ttf +0 -0
  70. package/server/web-control/fonts/JetBrainsMono.ttf +0 -0
  71. package/server/web-control/index.html +275 -0
  72. package/server/web-control/mark.svg +1 -0
  73. package/server/web-control/memories/d3.min.js +2 -0
  74. package/server/web-control/memories/graph-explorer.css +200 -0
  75. package/server/web-control/memories/graph-explorer.js +748 -0
  76. package/server/web-control/memories/memories-app.js +1561 -0
  77. package/server/web-control/memories/memories-theme.css +73 -0
  78. package/server/web-control/memories/memories.html +72 -0
  79. package/server/web-control/memories/memory-stewardship.js +63 -0
  80. package/server/web-control/memories/memory-workspace.js +216 -0
  81. package/server/web-control/panes/cos-chat.css +174 -0
  82. package/server/web-control/panes/cos-chat.js +1118 -0
  83. package/server/web-control/panes/dictation.js +173 -0
  84. package/server/web-control/panes/meetings.css +314 -0
  85. package/server/web-control/panes/meetings.js +2568 -0
  86. package/server/web-control/panes/messages.js +718 -0
  87. package/server/web-control/panes/questions.css +32 -0
  88. package/server/web-control/panes/questions.js +275 -0
  89. package/server/web-control/panes/sessions.css +452 -0
  90. package/server/web-control/panes/settings.js +292 -0
  91. package/server/web-control/panes/speakers.css +269 -0
  92. package/server/web-control/panes/speakers.js +2755 -0
  93. package/server/web-control/panes/threads.js +200 -0
  94. package/server/web-control/panes/work.css +261 -0
  95. package/server/web-control/panes/work.js +1667 -0
  96. package/server/web-control/shell.js +294 -0
  97. package/server/web-control/theme.js +25 -0
  98. package/server/web-control/vendor/qrcode-encoder.js +10 -0
@@ -0,0 +1,718 @@
1
+ /* COS Control Web: Messages, the same pane as Control's (ActivityWindow.swift messagesList 3205-3317,
2
+ archiveBody 3091-3203, messageDetail 5827, day 5469, chat 5611).
3
+ Recent is the server's rolling window of the newest turns (GET /api/sessions/today/all-messages),
4
+ newest first. Archive is the day list, its search, a day's chats and one chat's transcript.
5
+ Routes: #/messages, #/messages/archive, #/messages/m/<no>, #/messages/m/<no>/a/<attachment>,
6
+ #/messages/day/<date>, #/messages/day/<date>/<chat index>, and with Drop it on (6.99.10) or durable query jobs on
7
+ (6.99.12) #/messages/cos, the pinned COS chat or Ask COS (panes/cos-chat.js). Then the list and the chat share a split:
8
+ the list on the left and the chat on the right from 860 px, one at a time below it. The list rebuilds in its own
9
+ column, so the chat's composer is never rebuilt while it is in use. 6.99.12: the message reader ends with Reply, the
10
+ same composer, sending that turn's sessionId. */
11
+ (function () {
12
+ 'use strict';
13
+ var W = window.COSWeb;
14
+ var $ = W.$, esc = W.esc, api = W.api;
15
+ var pane = $('pane-messages');
16
+ var cos = W.cosChat;
17
+ function cosOn() { return !!(cos && cos.on()); }
18
+
19
+ var S = {
20
+ view: 'recent', // recent | archive
21
+ q: '', // the shared search text
22
+ recent: null, // turns, newest first
23
+ recentDate: '',
24
+ recentState: 'idle', // idle | loading | ready | error
25
+ recentError: '',
26
+ days: null, // archive index rows
27
+ daysState: 'idle',
28
+ archiveRan: '', // the query the archive search last ran for
29
+ archiveHits: null,
30
+ archiveMeta: '',
31
+ archiveBusy: false,
32
+ dayCache: {}, // date -> full day
33
+ onlyMatches: true
34
+ };
35
+
36
+ // ── helpers ───────────────────────────────────────────
37
+ function toDate(v) {
38
+ if (v == null || v === '') return null;
39
+ var n = typeof v === 'number' ? v : (/^\d+$/.test(String(v)) ? Number(v) : NaN);
40
+ var d = !isNaN(n) ? new Date(n > 1e10 ? n : n * 1000) : new Date(v);
41
+ return isNaN(d.getTime()) ? null : d;
42
+ }
43
+ function sameDay(a, b) { return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); }
44
+ function shortTime(d) { return d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }); }
45
+ // Control's row time: "Today 7:15 PM", "Yesterday 7:03 AM", "Oct 30, 7:03 AM", "Oct 30, 2025, 7:03 AM".
46
+ function timeLabel(v) {
47
+ var d = toDate(v);
48
+ if (!d) return '';
49
+ var now = new Date();
50
+ var y = new Date(now); y.setDate(now.getDate() - 1);
51
+ if (sameDay(d, now)) return 'Today ' + shortTime(d);
52
+ if (sameDay(d, y)) return 'Yesterday ' + shortTime(d);
53
+ var opts = { month: 'short', day: 'numeric' };
54
+ if (d.getFullYear() !== now.getFullYear()) opts.year = 'numeric';
55
+ return d.toLocaleDateString(undefined, opts) + ', ' + shortTime(d);
56
+ }
57
+ function stripMd(text) {
58
+ return String(text || '').replace(/```[\s\S]*?```/g, ' ').replace(/`([^`]*)`/g, '$1').replace(/!\[[^\]]*\]\([^)]*\)/g, '')
59
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1').replace(/^#{1,6}\s+/gm, '').replace(/^\s*[-*+]\s+/gm, '').replace(/^\s*\d+\.\s+/gm, '')
60
+ .replace(/\*\*|__|\*|_/g, '').replace(/\s+/g, ' ').trim();
61
+ }
62
+ function headline(q) {
63
+ var s = String(q || '').replace(/\s+/g, ' ').trim();
64
+ if (!s) return '(empty query)';
65
+ return s.length > 240 ? s.slice(0, 239) + '…' : s;
66
+ }
67
+ function modelOf(t) { var m = String(t.modelPreference || ''); return /^[A-Za-z0-9._:-]{1,64}$/.test(m) ? m : ''; }
68
+ // 6.99.15: the row says "Opus", not "opus"; a model id it does not know stays as the server wrote it.
69
+ var MODEL_NAMES = { opus: 'Opus', sonnet: 'Sonnet', fable: 'Fable', haiku: 'Haiku' };
70
+ function modelName(m) { return MODEL_NAMES[m] || m; }
71
+ // A preview of an answer whose progress lines were joined with no space ("now.Reading the tax"): one space after a
72
+ // sentence's end when the next sentence starts at once. Previews only; the reader shows the answer as written.
73
+ function previewText(text) { return stripMd(text).replace(/([a-z0-9)][.!?])([A-Z][a-z])/g, '$1 $2'); }
74
+ function originTag(t) { return t.origin === 'routine' ? 'Routine' : t.origin === 'task' ? 'Task' : ''; }
75
+ function originTitle(t) {
76
+ var id = t.originId ? ' (' + t.originId + ')' : '';
77
+ return t.origin === 'routine' ? 'Produced by a scheduled routine on your Mac' + id : t.origin === 'task' ? 'Produced by an agent working a task on your Mac' + id : '';
78
+ }
79
+ function numOf(t) { return t.no != null ? t.no : t.globalMsgNum; }
80
+ function keyOf(t, i) { var n = numOf(t); return n != null ? String(n) : 'i' + i; }
81
+ function atts(t) { return Array.isArray(t.attachments) ? t.attachments.slice(0, 5) : []; }
82
+ function attKind(a) {
83
+ var m = String(a.mime || '');
84
+ if (/^image\//.test(m)) return 'image';
85
+ if (/^video\//.test(m)) return 'video';
86
+ return 'file';
87
+ }
88
+ function attIcon(kinds) {
89
+ var uniq = kinds.filter(function (k, i) { return kinds.indexOf(k) === i; });
90
+ if (uniq.length > 1) return 'clip';
91
+ return uniq[0] === 'image' ? 'photo' : uniq[0] === 'video' ? 'video' : 'doc';
92
+ }
93
+ function attTitle(list) {
94
+ var kinds = list.map(attKind);
95
+ var uniq = kinds.filter(function (k, i) { return kinds.indexOf(k) === i; });
96
+ var n = list.length;
97
+ if (uniq.length > 1) return n + ' attachments';
98
+ return n + ' ' + (uniq[0] === 'image' ? W.plural(n, 'image', 'images') : uniq[0] === 'video' ? W.plural(n, 'video', 'videos') : W.plural(n, 'file', 'files'));
99
+ }
100
+ function isUserAtt(a) { return /^user_/.test(String(a.kind || '')); }
101
+ function stripLabel(list, user) {
102
+ var kinds = list.map(attKind);
103
+ var allImages = kinds.every(function (k) { return k === 'image'; });
104
+ if (user) return allImages ? (list.length === 1 ? 'YOUR IMAGE' : 'YOUR IMAGES') : 'YOUR ATTACHMENTS';
105
+ return allImages ? (list.length === 1 ? 'ANSWER IMAGE' : 'ANSWER IMAGES') : 'FROM COS';
106
+ }
107
+ function mediaUrl(a, variant) { return '/api/media/' + encodeURIComponent(a.id) + '/content?variant=' + variant; }
108
+ function sizeLabel(bytes) {
109
+ var b = Number(bytes) || 0;
110
+ if (!b) return '';
111
+ return b >= 1048576 ? (b / 1048576).toFixed(1) + ' MB' : Math.max(1, Math.round(b / 1024)) + ' KB';
112
+ }
113
+ function durLabel(ms) {
114
+ var s = Math.round((Number(ms) || 0) / 1000);
115
+ if (!s) return '';
116
+ return s >= 60 ? Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0') : s + 's';
117
+ }
118
+ function matches(t, q) {
119
+ var n = q.toLowerCase();
120
+ return String(t.query || '').toLowerCase().indexOf(n) >= 0 || String(t.text || '').toLowerCase().indexOf(n) >= 0;
121
+ }
122
+ // Highlight a search term in text already in the DOM (after Markdown), never in the HTML string.
123
+ function highlightIn(el, q) {
124
+ if (!el || !q || q.length < 2) return;
125
+ var needle = q.toLowerCase();
126
+ var walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null);
127
+ var nodes = [];
128
+ while (walker.nextNode()) nodes.push(walker.currentNode);
129
+ nodes.forEach(function (node) {
130
+ var text = node.nodeValue, lower = text.toLowerCase(), at = lower.indexOf(needle);
131
+ if (at < 0) return;
132
+ var frag = document.createDocumentFragment(), from = 0;
133
+ while (at >= 0) {
134
+ frag.appendChild(document.createTextNode(text.slice(from, at)));
135
+ var mark = document.createElement('mark'); mark.className = 'cx-mark'; mark.textContent = text.slice(at, at + needle.length);
136
+ frag.appendChild(mark);
137
+ from = at + needle.length; at = lower.indexOf(needle, from);
138
+ }
139
+ frag.appendChild(document.createTextNode(text.slice(from)));
140
+ node.parentNode.replaceChild(frag, node);
141
+ });
142
+ }
143
+
144
+ // ── archive topic and matches (helper archiveChatTopic / archiveChatMatches) ──
145
+ var STOP = ('alright okay right well hey please thanks yeah yes no want need like just going gonna let lets make made give given take look looking looked know think thing things that this these those there here then than with from into onto over about after before through have has had been was were are is am be being and but for the you your yours our ours their can could would should will shall may might must what when where which who whom why how all any some one two get got put run ran see saw say said tell told ask asked come came back also very really quick quickly actually basically kind sort little bit more most much many now today tomorrow yesterday still even only out off down again because does did doing done not dont cant wont its his her him she they them were weve ive ill lot way able undefined inside against recently using around something anything everything someone another however though while since until during without within across between maybe perhaps probably definitely essentially literally obviously honestly sure fine nice great better best worse worst first last next other others same different whole full part instead rather already always never sometimes often').split(' ');
146
+ var STOPSET = {}; STOP.forEach(function (w) { STOPSET[w] = 1; });
147
+ function chatTopic(exchanges) {
148
+ var counts = {}, order = {}, seen = 0;
149
+ exchanges.forEach(function (ex) {
150
+ if (ex.role !== 'user' || typeof ex.content !== 'string') return;
151
+ ex.content.toLowerCase().split(/[^\p{L}-]+/u).forEach(function (raw) {
152
+ var w = raw.replace(/^-+|-+$/g, '');
153
+ if (w.length < 4 || STOPSET[w]) return;
154
+ counts[w] = (counts[w] || 0) + 1;
155
+ if (order[w] == null) order[w] = seen++;
156
+ });
157
+ });
158
+ return Object.keys(counts).sort(function (a, b) { return counts[a] !== counts[b] ? counts[b] - counts[a] : order[a] - order[b]; }).slice(0, 4).join(' · ');
159
+ }
160
+ function editDistance(a, b, limit) {
161
+ if (!a.length) return b.length;
162
+ if (!b.length) return a.length;
163
+ var prev = [], cur = [], i, j;
164
+ for (j = 0; j <= b.length; j++) prev[j] = j;
165
+ for (i = 1; i <= a.length; i++) {
166
+ cur[0] = i; var best = cur[0];
167
+ for (j = 1; j <= b.length; j++) {
168
+ cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
169
+ best = Math.min(best, cur[j]);
170
+ }
171
+ if (best > limit) return limit + 1;
172
+ prev = cur.slice();
173
+ }
174
+ return prev[b.length];
175
+ }
176
+ function fuzzy(hay, needle) {
177
+ if (hay.indexOf(needle) >= 0) return true;
178
+ if (needle.length < 5) return false;
179
+ var allowed = needle.length >= 8 ? 2 : 1;
180
+ var words = hay.split(/[^\p{L}]+/u);
181
+ for (var i = 0; i < words.length; i++) {
182
+ var w = words[i];
183
+ if (Math.abs(w.length - needle.length) > allowed) continue;
184
+ if (editDistance(w, needle, allowed) <= allowed) return true;
185
+ }
186
+ return false;
187
+ }
188
+ function chatMatches(exchanges, q) {
189
+ var needle = q.toLowerCase(), count = 0, snippet = '', fuzzyOnly = false;
190
+ if (!needle) return { count: 0, snippet: '' };
191
+ exchanges.forEach(function (ex) {
192
+ if (typeof ex.content !== 'string') return;
193
+ var content = ex.content, hay = content.toLowerCase();
194
+ if (hay.indexOf(needle) < 0 && fuzzy(hay, needle)) {
195
+ fuzzyOnly = true; count += 1;
196
+ if (!snippet) snippet = content.slice(0, 150);
197
+ return;
198
+ }
199
+ var at = hay.indexOf(needle);
200
+ while (at >= 0) {
201
+ count += 1;
202
+ if (!snippet) {
203
+ var lo = Math.max(0, at - 60), hi = Math.min(hay.length, at + needle.length + 90);
204
+ snippet = (lo > 0 ? '…' : '') + content.slice(lo, hi).trim() + (hi < hay.length ? '…' : '');
205
+ }
206
+ at = hay.indexOf(needle, at + needle.length);
207
+ }
208
+ });
209
+ return { count: count, snippet: snippet };
210
+ }
211
+ function cleanSnippet(raw) {
212
+ var text = String(raw || '');
213
+ var marker = text.lastIndexOf('"content": "');
214
+ if (marker >= 0) text = text.slice(marker + 12);
215
+ ['"sessionId":', '"exchanges":', '"role":', '"user",', '"assistant",', '"timestamp":', '"content":', '[ {', '} ]', '{', '}', '[', ']'].forEach(function (junk) { text = text.split(junk).join(' '); });
216
+ text = text.split('\\n').join(' ').replace(/ {2,}/g, ' ');
217
+ return text.replace(/^[ "',:]+|[ "',:]+$/g, '');
218
+ }
219
+
220
+ // ── data ──────────────────────────────────────────────
221
+ function loadRecent(force) {
222
+ if (S.recentState === 'loading') return;
223
+ if (S.recent && !force) return;
224
+ S.recentState = 'loading';
225
+ render();
226
+ api('/api/sessions/today/all-messages?limit=30').then(function (d) {
227
+ var list = (d && d.messages) || [];
228
+ S.recentAt = Date.now();
229
+ S.recent = list.slice().reverse();
230
+ S.recentDate = (d && d.date) || '';
231
+ S.recentState = 'ready';
232
+ render();
233
+ }, function (err) {
234
+ S.recentState = 'error';
235
+ S.recentError = err && err.status === 401 ? 'signed-out' : 'failed';
236
+ render();
237
+ });
238
+ }
239
+ function loadDays(force) {
240
+ if (S.daysState === 'loading' || (S.days && !force)) return;
241
+ S.daysState = 'loading';
242
+ render();
243
+ api('/api/archive').then(function (d) {
244
+ S.days = (d && d.archives) || [];
245
+ S.daysState = 'ready';
246
+ render();
247
+ }, function (err) {
248
+ S.daysState = err && err.status === 404 ? 'absent' : 'error';
249
+ render();
250
+ });
251
+ }
252
+ function runArchiveSearch() {
253
+ var q = S.q.trim();
254
+ if (q.length < 2) return;
255
+ S.archiveBusy = true;
256
+ render();
257
+ api('/api/archive/search?q=' + encodeURIComponent(q)).then(function (d) {
258
+ S.archiveBusy = false;
259
+ S.archiveRan = q;
260
+ var hits = (d && d.hits) || [];
261
+ S.archiveHits = hits;
262
+ var scanned = Number(d && d.scannedDays) || 0;
263
+ S.archiveMeta = hits.length
264
+ ? hits.length + ' ' + W.plural(hits.length, 'day', 'days') + (d.truncated ? '+' : '') + ' · scanned ' + scanned + ' · ' + (Number(d.elapsedMs) || 0) + ' ms'
265
+ : 'No matches in ' + scanned + ' ' + W.plural(scanned, 'day', 'days') + '.';
266
+ render();
267
+ }, function (err) {
268
+ S.archiveBusy = false;
269
+ S.archiveRan = q;
270
+ S.archiveHits = [];
271
+ S.archiveMeta = err && err.status === 404 ? 'Update the COS server to browse the archive.' : (err.message || 'The archive search failed.');
272
+ render();
273
+ });
274
+ }
275
+ var dayLoading = {};
276
+ function loadDay(date) {
277
+ if (S.dayCache[date]) return Promise.resolve(S.dayCache[date]);
278
+ if (dayLoading[date]) return dayLoading[date];
279
+ dayLoading[date] = api('/api/archive/' + encodeURIComponent(date)).then(function (d) {
280
+ // A day can be megabytes: keep the three most recent opened days only.
281
+ S.dayCache[date] = d;
282
+ var keys = Object.keys(S.dayCache); if (keys.length > 3) delete S.dayCache[keys[0]];
283
+ return d;
284
+ })
285
+ .finally(function () { delete dayLoading[date]; });
286
+ return dayLoading[date];
287
+ }
288
+
289
+ // ── list view ─────────────────────────────────────────
290
+ function heroHtml() {
291
+ var detail;
292
+ if (S.recentState === 'loading' && !S.recent) detail = 'Loading…';
293
+ else if (S.recentState === 'error') detail = S.recentError === 'signed-out' ? 'Signed out' : 'Could not load messages';
294
+ else if (S.recent && S.recent.length) detail = S.recent.length + ' ' + W.plural(S.recent.length, 'message', 'messages') + ', newest first';
295
+ else if (S.recent) detail = 'No messages today';
296
+ else detail = 'Refresh to load';
297
+ return '<header class="cx-hero" data-tint="messages">' +
298
+ '<span class="cx-glyph"><svg class="icon"><use href="#i-messages"/></svg></span>' +
299
+ '<div class="cx-hero-text"><h1 class="cx-title">Recent messages</h1><p class="cx-detail-line">' + esc(detail) + '</p></div>' +
300
+ '<div class="cx-actions">' +
301
+ '<button type="button" class="cx-btn quiet" data-msg="handoff"' + (S.recent && S.recent.length ? '' : ' disabled') + '>Copy handoff</button>' +
302
+ '<button type="button" class="cx-btn quiet" data-msg="refresh"><svg class="icon"><use href="#i-refresh"/></svg>Refresh</button>' +
303
+ '</div></header>';
304
+ }
305
+ function controlsHtml() {
306
+ return '<div class="cx-controls">' +
307
+ '<div class="cx-switch" role="group" aria-label="Message view">' +
308
+ '<button type="button" data-msg="view" data-view="recent" aria-pressed="' + (S.view === 'recent') + '">Recent</button>' +
309
+ '<button type="button" data-msg="view" data-view="archive" aria-pressed="' + (S.view === 'archive') + '">Archive</button>' +
310
+ '</div>' +
311
+ '<form class="cx-search" data-msg-form="search" role="search">' +
312
+ '<input class="field" type="search" id="msg-q" placeholder="Search recent and archive" value="' + esc(S.q) + '" enterkeyhint="search" autocomplete="off" aria-label="Search recent and archive">' +
313
+ (S.archiveBusy ? '<span class="cx-time">…</span>' : '') +
314
+ (S.q ? '<button type="button" class="cx-clear" data-msg="clear">Clear</button>' : '') +
315
+ '</form></div>';
316
+ }
317
+ function crossingHtml() {
318
+ var q = S.q.trim();
319
+ if (q.length < 2) return '';
320
+ var recentN = (S.recent || []).filter(function (t) { return matches(t, q); }).length;
321
+ var arch = S.archiveBusy ? '…' : S.archiveRan !== q ? 'press return' : (S.archiveHits.length === 1 ? '1 day' : S.archiveHits.length + ' days');
322
+ var recentSide = 'Recent ' + recentN, archSide = 'Archive ' + arch;
323
+ return '<p class="cx-cross">' + (S.view === 'recent'
324
+ ? '<span>' + esc(recentSide) + '</span><span>·</span><button type="button" class="door" data-msg="view" data-view="archive">' + esc(archSide) + '<svg class="icon" aria-hidden="true"><use href="#i-chev"/></svg></button>'
325
+ : '<button type="button" class="door" data-msg="view" data-view="recent">' + esc(recentSide) + '<svg class="icon" aria-hidden="true"><use href="#i-chev"/></svg></button><span>·</span><span>' + esc(archSide) + '</span>') + '</p>';
326
+ }
327
+ // 6.99.15: one row is the ask as its title with the time beside it, the answer under it, and one quiet line of
328
+ // facts (number, Routine or Task, model, attachments). No glyph per row: every row was the same bubble.
329
+ function recentRowHtml(t, i) {
330
+ var a = atts(t);
331
+ var kinds = a.map(attKind);
332
+ var n = numOf(t);
333
+ var tag = originTag(t);
334
+ var model = modelOf(t);
335
+ var facts = [];
336
+ if (n != null) facts.push('<span class="cx-num">#' + esc(n) + '</span>');
337
+ if (tag) facts.push('<span class="cx-tag" title="' + esc(originTitle(t)) + '">' + tag + '</span>');
338
+ if (model) facts.push('<span class="cx-model">' + esc(modelName(model)) + '</span>');
339
+ if (a.length) facts.push('<span class="cx-att" title="' + esc(attTitle(a)) + '"><svg class="icon"><use href="#i-' + attIcon(kinds) + '"/></svg>' + esc(attTitle(a)) + '</span>');
340
+ var sub = previewText(t.text);
341
+ return '<button type="button" class="cx-row cx-msgrow" data-msg="open" data-key="' + esc(keyOf(t, i)) + '">' +
342
+ '<span class="cx-row-body">' +
343
+ '<span class="cx-msg-top"><span class="cx-row-title">' + esc(headline(t.query)) + '</span>' +
344
+ '<span class="cx-time">' + esc(timeLabel(t.timestamp)) + '</span></span>' +
345
+ (sub ? '<span class="cx-row-sub">' + esc(sub) + '</span>' : '') +
346
+ (facts.length ? '<span class="cx-meta cx-facts">' + facts.join('') + '</span>' : '') +
347
+ '</span><span class="chev"></span></button>';
348
+ }
349
+ // The pinned COS chat, first in Recent (Drop it on only, and not while a search narrows the list).
350
+ function pinHtml() { return cosOn() && S.q.trim().length < 2 ? cos.pinnedRowHtml(current[0] === 'cos') : ''; }
351
+ function recentBody() {
352
+ var pin = pinHtml();
353
+ if (pin) return recentBodyInner().replace('<div class="cx-list">', '<div class="cx-list">' + pin);
354
+ return recentBodyInner();
355
+ }
356
+ function recentBodyInner() {
357
+ if (S.recentState === 'loading' && !S.recent) return '<div class="cx-list">' + W.skeletonRows(5) + '</div>';
358
+ if (S.recentState === 'error') {
359
+ return '<div class="cx-list"><p class="cx-empty">' + (S.recentError === 'signed-out'
360
+ ? 'This browser is signed out. Connect it again.'
361
+ : 'Messages could not be loaded. Check that the Mac is awake, then refresh.') + '</p></div>';
362
+ }
363
+ var list = S.recent || [];
364
+ var q = S.q.trim();
365
+ var shown = q.length >= 2 ? list.filter(function (t) { return matches(t, q); }) : list;
366
+ if (!list.length) return '<div class="cx-list"><p class="cx-empty">No glasses messages have landed today.</p></div>';
367
+ if (!shown.length) {
368
+ var days = S.archiveRan === q && S.archiveHits ? S.archiveHits.length : 0;
369
+ return '<div class="cx-list"><p class="cx-empty">No recent message contains “' + esc(q) + '”' +
370
+ (days ? ', but ' + days + ' archived ' + W.plural(days, 'day', 'days') + ' ' + (days === 1 ? 'does' : 'do') + '.' : '. The archive goes back further.') + '</p></div>';
371
+ }
372
+ return '<div class="cx-list">' + shown.map(function (t) { return recentRowHtml(t, list.indexOf(t)); }).join('') + '</div>';
373
+ }
374
+ function archiveBody() {
375
+ var q = S.q.trim();
376
+ if (S.daysState === 'absent') return '<div class="cx-notice"><svg class="icon"><use href="#i-refresh"/></svg><span>Update the COS server to browse the archive.</span></div>';
377
+ if (S.daysState === 'error') return '<div class="cx-notice warn"><span>The archive could not be read. Refresh to try again.</span></div>';
378
+ if (S.archiveBusy) return '<p class="cx-note">Reading the archive…</p>';
379
+ if (q.length >= 2 && S.archiveRan === q) {
380
+ var hits = S.archiveHits || [];
381
+ var meta = '<p class="cx-note" style="margin:0 2px 10px">' + esc(S.archiveMeta) + '</p>';
382
+ if (!hits.length) return meta + '<div class="cx-list"><p class="cx-empty">No archived day matched that search.</p></div>';
383
+ return meta + '<div class="cx-list">' + hits.map(function (h) {
384
+ var snips = (h.snippets || []).slice(0, 2).map(cleanSnippet).filter(Boolean);
385
+ return '<button type="button" class="cx-row" data-msg="day" data-date="' + esc(h.date) + '">' +
386
+ '<span class="cx-row-glyph"><svg class="icon"><use href="#i-meetings"/></svg></span><span class="cx-row-body">' +
387
+ '<span class="cx-meta"><span class="cx-num">' + esc(h.date) + '</span><span class="cx-time">' + esc(h.matches + ' ' + W.plural(Number(h.matches), 'match', 'matches')) + '</span></span>' +
388
+ snips.map(function (s) { return '<span class="cx-row-sub three">' + esc(s) + '</span>'; }).join('') +
389
+ '</span><span class="chev"></span></button>';
390
+ }).join('') + '</div>';
391
+ }
392
+ if (!S.days) return '<div class="cx-list">' + W.skeletonRows(5) + '</div>';
393
+ if (!S.days.length) return '<div class="cx-list"><p class="cx-empty">Nothing is archived yet. Conversations move here at the end of each day.</p></div>';
394
+ return (q.length >= 2 ? '<p class="cx-note" style="margin:0 2px 10px">Press return to search the archive.</p>' : '') +
395
+ '<div class="cx-list">' + S.days.map(function (d) {
396
+ return '<button type="button" class="cx-row" data-msg="day" data-date="' + esc(d.date) + '">' +
397
+ '<span class="cx-row-glyph"><svg class="icon"><use href="#i-meetings"/></svg></span><span class="cx-row-body">' +
398
+ '<span class="cx-meta"><span class="cx-num">' + esc(d.date) + '</span><span class="cx-time">' + esc((d.chatCount || 0) + ' ' + W.plural(d.chatCount, 'chat', 'chats') + ' · ' + (d.exchangeCount || 0) + ' ' + W.plural(d.exchangeCount, 'message', 'messages')) + '</span></span>' +
399
+ (d.summary ? '<span class="cx-row-sub">' + esc(d.summary) + '</span>' : '') +
400
+ '</span><span class="chev"></span></button>';
401
+ }).join('') + '</div>';
402
+ }
403
+ // Drop it on: the list column and the chat column. Built once; each column then paints only itself.
404
+ function splitHost() {
405
+ var split = pane.querySelector('.msg-split');
406
+ if (!split) {
407
+ pane.innerHTML = '<div class="msg-split"><div class="msg-left" id="msg-left"></div><div class="msg-right" id="msg-right"></div></div>';
408
+ split = pane.querySelector('.msg-split');
409
+ }
410
+ split.setAttribute('data-show', current[0] === 'cos' ? 'chat' : 'list');
411
+ return split;
412
+ }
413
+ function renderList() {
414
+ var focus = document.activeElement && document.activeElement.id === 'msg-q';
415
+ var caret = focus ? document.activeElement.selectionStart : null;
416
+ var html = '<div class="cx" data-tint="messages">' + heroHtml() + controlsHtml() + crossingHtml() +
417
+ (S.view === 'recent' ? recentBody() : archiveBody()) + '</div>';
418
+ if (cosOn()) {
419
+ splitHost();
420
+ $('msg-left').innerHTML = html;
421
+ var chatShown = current[0] === 'cos' || cos.wide();
422
+ cos.mount($('msg-right'), chatShown);
423
+ if (current[0] === 'cos') W.setCrumb('COS');
424
+ } else pane.innerHTML = html;
425
+ if (focus) { var input = $('msg-q'); if (input) { input.focus(); try { input.setSelectionRange(caret, caret); } catch (_e) {} } }
426
+ }
427
+
428
+ // ── detail: one recent message ────────────────────────
429
+ function findRecent(key) {
430
+ var list = S.recent || [];
431
+ for (var i = 0; i < list.length; i++) if (keyOf(list[i], i) === key) return list[i];
432
+ return null;
433
+ }
434
+ function stripHtml(list, user, msgKey) {
435
+ if (!list.length) return '';
436
+ return '<p class="cx-strip-label">' + stripLabel(list, user) + '</p><div class="cx-strip">' + list.map(function (a) {
437
+ var kind = attKind(a);
438
+ var badge = kind === 'video' ? durLabel(a.durationMs) : sizeLabel(a.bytes || a.size);
439
+ var inner = kind === 'image'
440
+ ? '<img src="' + esc(mediaUrl(a, 'thumb')) + '" alt="' + esc(a.label || 'Image') + '" loading="lazy">'
441
+ : kind === 'video'
442
+ ? '<span class="cx-play"><svg class="icon"><use href="#i-video"/></svg></span>'
443
+ : '<svg class="icon"><use href="#i-doc"/></svg>';
444
+ var open = kind === 'image'
445
+ ? 'data-msg="att" data-key="' + esc(msgKey) + '" data-att="' + esc(a.id) + '"'
446
+ : 'data-msg="att-open" data-href="' + esc(mediaUrl(a, 'phone')) + '"';
447
+ return '<button type="button" class="cx-thumb" ' + open + ' title="' + esc(a.label || '') + '">' + inner +
448
+ (badge ? '<span class="cx-thumb-badge">' + esc(badge) + '</span>' : '') + '</button>';
449
+ }).join('') + '</div>';
450
+ }
451
+ function turnCards(t, msgKey) {
452
+ var a = atts(t);
453
+ return '<div class="cx-card you"><div class="cx-card-label">YOU</div><div class="cx-card-body" data-hl>' + esc(String(t.query || '').trim() || '(empty query)').replace(/\n/g, '<br>') + '</div></div>' +
454
+ stripHtml(a.filter(isUserAtt), true, msgKey) +
455
+ '<div class="cx-card"><div class="cx-card-label">COS</div><div class="cx-card-body md" data-hl>' + W.md(String(t.text || '')) + '</div></div>' +
456
+ stripHtml(a.filter(function (x) { return !isUserAtt(x); }), false, msgKey);
457
+ }
458
+ function copyTurnText(t) {
459
+ var label = numOf(t) != null ? 'Msg ' + numOf(t) : 'Msg';
460
+ return '[' + label + '] User: ' + (t.query || '') + '\n[' + label + '] COS: ' + (t.text || '');
461
+ }
462
+ function renderMessage(key, attId) {
463
+ var t = findRecent(key);
464
+ if (!t) {
465
+ // Opened cold (reload, deep link, iOS reloading the tab): load the recent window, and
466
+ // render() comes back here when it lands, or shows the failure with Retry.
467
+ if (S.recentState === 'error') { pane.innerHTML = retryBox('Messages could not be loaded. Check that the Mac is awake, then try again.'); return; }
468
+ if (S.recentState !== 'ready') { loadRecent(false); pane.innerHTML = '<div class="cx">' + W.skeletonRows(4) + '</div>'; return; }
469
+ pane.innerHTML = '<div class="cx"><p class="cx-empty">That message is no longer in the recent window. Look for it in the Archive.</p></div>';
470
+ return;
471
+ }
472
+ var n = numOf(t);
473
+ var title = n != null ? 'Message #' + n : 'Message';
474
+ if (attId) {
475
+ var a = atts(t).filter(function (x) { return x.id === attId; })[0];
476
+ if (a) {
477
+ W.setCrumb(title + ' · ' + (a.label || 'Image'));
478
+ pane.innerHTML = '<div class="cx"><div class="cx-detail-head"><div><h2 class="cx-detail-title">' + esc(a.label || 'Image') + '</h2>' +
479
+ '<p class="cx-detail-meta">' + esc((a.width && a.height ? a.width + ' × ' + a.height : '') + (a.width && a.height ? ' · ' : '') + title) + '</p></div>' +
480
+ '<div class="cx-actions"><a class="cx-btn quiet" href="' + esc(mediaUrl(a, 'phone')) + '" target="_blank" rel="noopener">Open full size</a></div></div>' +
481
+ '<figure class="cx-figure"><img src="' + esc(mediaUrl(a, 'phone')) + '" alt="' + esc(a.label || 'Image') + '"></figure></div>';
482
+ return;
483
+ }
484
+ }
485
+ // A refresh of Recent (a web Ask landed) must not rebuild the reader under a half-written reply.
486
+ var sig = String(t.text || '').length + ':' + atts(t).length + ':' + (cos && cos.askOn() ? 1 : 0);
487
+ var shown = pane.querySelector('[data-reader-key]');
488
+ if (shown && shown.getAttribute('data-reader-key') === key && shown.getAttribute('data-sig') === sig) { W.setCrumb(title); return; }
489
+ W.setCrumb(title);
490
+ var source = t.source === 'live' ? 'live' : t.source === 'archive' ? 'archive' : 'COS Glasses';
491
+ var meta = [timeLabel(t.timestamp), source, modelOf(t), t.origin || ''].filter(Boolean).join(' · ');
492
+ var reply = cos ? cos.replyHtml(key, t) : '';
493
+ pane.innerHTML = '<div class="cx" data-tint="messages" data-reader-key="' + esc(key) + '" data-sig="' + esc(sig) + '"><div class="cx-detail-head"><div><h2 class="cx-detail-title">' + esc(title) + '</h2>' +
494
+ '<p class="cx-detail-meta">' + esc(meta) + '</p></div>' +
495
+ '<div class="cx-actions"><button type="button" class="cx-btn quiet" data-msg="copy-turn" data-key="' + esc(key) + '">Copy turn</button></div></div>' +
496
+ turnCards(t, key) + reply + '</div>';
497
+ pane.querySelectorAll('[data-hl]').forEach(function (el) { highlightIn(el, S.q.trim()); });
498
+ if (reply) cos.mountReply(key);
499
+ }
500
+
501
+ // ── archive day and chat ──────────────────────────────
502
+ function renderDay(date) {
503
+ W.setCrumb(date);
504
+ pane.innerHTML = '<div class="cx">' + W.skeletonRows(4) + '</div>';
505
+ loadDay(date).then(function (d) {
506
+ if (!here(['day', date])) return;
507
+ var chats = (d && d.chats) || [];
508
+ var q = S.q.trim();
509
+ var msgs = chats.reduce(function (n, c) { return n + (Number(c.exchangeCount) || 0); }, 0);
510
+ var rows = chats.map(function (c, i) {
511
+ var ex = c.exchanges || [];
512
+ var m = q.length >= 2 ? chatMatches(ex, q) : { count: 0, snippet: '' };
513
+ return { c: c, i: c.id != null ? c.id : i, topic: chatTopic(ex), m: m };
514
+ });
515
+ var hits = rows.filter(function (r) { return r.m.count > 0; }).length;
516
+ var filtering = q.length >= 2 && hits > 0 && S.onlyMatches;
517
+ var shown = filtering ? rows.filter(function (r) { return r.m.count > 0; }) : rows;
518
+ var bar = q.length >= 2
519
+ ? '<div class="cx-controls"><span class="cx-detail-meta">“' + esc(q) + '” · ' + hits + ' of ' + chats.length + ' ' + W.plural(chats.length, 'chat', 'chats') + '</span>' +
520
+ (hits ? '<label class="cx-check"><input type="checkbox" data-msg="only"' + (S.onlyMatches ? ' checked' : '') + '> Only matches</label>' : '') + '</div>'
521
+ : '';
522
+ var body = !chats.length ? '<p class="cx-empty">No conversations were archived on ' + esc(date) + '.</p>'
523
+ : !shown.length ? '<p class="cx-empty">No chat on this day contains “' + esc(q) + '”.</p>'
524
+ : shown.map(function (r) {
525
+ var started = toDate(r.c.startedAt);
526
+ var line = 'Chat ' + (Number(r.i) + 1) + (started ? ' · ' + shortTime(started) : '') + ' · ' + (r.c.exchangeCount || (r.c.exchanges || []).length) + ' messages';
527
+ return '<button type="button" class="cx-row" data-msg="chat" data-date="' + esc(date) + '" data-index="' + esc(r.i) + '">' +
528
+ '<span class="cx-row-glyph"><svg class="icon"><use href="#i-messages"/></svg></span><span class="cx-row-body">' +
529
+ '<span class="cx-row-title strong">' + esc(r.topic || r.c.summary || 'Untitled chat') + '</span>' +
530
+ '<span class="cx-meta" style="margin-top:4px">' + (r.m.count ? '<span class="cx-cap ok">' + r.m.count + ' ' + W.plural(r.m.count, 'match', 'matches') + '</span>' : '') +
531
+ '<span class="cx-time">' + esc(line) + '</span></span>' +
532
+ (r.m.snippet ? '<span class="cx-row-sub three">' + esc(r.m.snippet) + '</span>' : r.c.summary ? '<span class="cx-row-sub">' + esc(r.c.summary) + '</span>' : '') +
533
+ '</span><span class="chev"></span></button>';
534
+ }).join('');
535
+ pane.innerHTML = '<div class="cx" data-tint="messages"><div class="cx-detail-head"><div><h2 class="cx-detail-title">' + esc(date) + '</h2>' +
536
+ '<p class="cx-detail-meta">' + esc(chats.length + ' ' + W.plural(chats.length, 'chat', 'chats') + ' · ' + msgs + ' ' + W.plural(msgs, 'message', 'messages')) + '</p></div></div>' +
537
+ bar + '<div class="cx-list">' + body + '</div></div>';
538
+ }, function (err) {
539
+ if (!here(['day', date])) return;
540
+ pane.innerHTML = err && err.status === 404 ? '<div class="cx"><div class="cx-notice warn">That day is no longer in the archive.</div></div>'
541
+ : retryBox(err && err.message ? err.message : 'The day could not be read.');
542
+ });
543
+ }
544
+ var find = { q: '', hits: [], at: 0 };
545
+ function renderChat(date, index) {
546
+ W.setCrumb(date + ' · chat ' + (Number(index) + 1));
547
+ pane.innerHTML = '<div class="cx">' + W.skeletonRows(4) + '</div>';
548
+ api('/api/archive/' + encodeURIComponent(date) + '/chats/' + encodeURIComponent(index) + '/messages').then(function (d) {
549
+ if (!here(['day', date, String(index)])) return;
550
+ var msgs = (d && d.messages) || [];
551
+ find.q = S.q.trim();
552
+ pane.innerHTML = '<div class="cx" data-tint="messages">' +
553
+ '<div class="cx-find"><input class="field" type="search" id="msg-find" placeholder="Find in this chat" value="' + esc(find.q) + '" autocomplete="off" aria-label="Find in this chat">' +
554
+ '<span class="cx-find-count" id="msg-find-count"></span>' +
555
+ '<button type="button" data-msg="find-prev" aria-label="Previous match"><svg class="icon"><use href="#i-back"/></svg></button>' +
556
+ '<button type="button" data-msg="find-next" aria-label="Next match"><svg class="icon"><use href="#i-chev"/></svg></button></div>' +
557
+ '<div class="cx-detail-head"><div><h2 class="cx-detail-title">Chat ' + (Number(index) + 1) + '</h2>' +
558
+ '<p class="cx-detail-meta">' + esc(date + ' · ' + msgs.length + ' ' + W.plural(msgs.length, 'message', 'messages')) + '</p></div></div>' +
559
+ (msgs.length ? msgs.map(function (t, i) {
560
+ var n = numOf(t);
561
+ return '<section class="cx-turn" data-turn="' + i + '"><div class="cx-meta" style="margin:0 2px 8px"><span class="cx-num">' + (n != null ? 'Message #' + esc(n) : 'Message') + '</span>' +
562
+ '<span class="cx-time">' + esc(timeLabel(t.timestamp)) + '</span><span class="cx-cap ok hidden" data-match-tag>match</span>' +
563
+ '<button type="button" class="link-btn" style="min-height:0;font-size:13px;margin-left:auto" data-msg="copy-chat-turn" data-i="' + i + '">Copy turn</button></div>' +
564
+ turnCards(t, 'chat-' + i) + '</section>';
565
+ }).join('') : '<p class="cx-empty">That chat holds no messages.</p>') + '</div>';
566
+ pane._chat = msgs;
567
+ applyFind(true);
568
+ }, function (err) {
569
+ if (!here(['day', date, String(index)])) return;
570
+ pane.innerHTML = err && err.status === 404 ? '<div class="cx"><p class="cx-empty">That chat is no longer in the archive.</p></div>'
571
+ : retryBox(err && err.message ? err.message : 'The chat could not be read.');
572
+ });
573
+ }
574
+ function applyFind(scroll) {
575
+ var q = find.q.toLowerCase();
576
+ var turns = Array.prototype.slice.call(pane.querySelectorAll('.cx-turn'));
577
+ find.hits = [];
578
+ turns.forEach(function (el, i) {
579
+ var t = (pane._chat || [])[i] || {};
580
+ var hit = q.length >= 2 && (String(t.query || '').toLowerCase().indexOf(q) >= 0 || String(t.text || '').toLowerCase().indexOf(q) >= 0);
581
+ el.classList.toggle('match', hit);
582
+ var tag = el.querySelector('[data-match-tag]'); if (tag) tag.classList.toggle('hidden', !hit);
583
+ if (hit) find.hits.push(el);
584
+ });
585
+ var count = $('msg-find-count');
586
+ if (count) count.textContent = q.length < 2 ? '' : find.hits.length ? (find.at + 1) + ' of ' + find.hits.length : 'no matches';
587
+ if (scroll && find.hits.length) { find.at = Math.min(find.at, find.hits.length - 1); find.hits[find.at].scrollIntoView({ block: 'center' }); if (count) count.textContent = (find.at + 1) + ' of ' + find.hits.length; }
588
+ }
589
+
590
+ // ── render and route ──────────────────────────────────
591
+ var current = [];
592
+ // Whether Messages is still showing these arguments: a late answer never paints over
593
+ // another pane, or over a different message, day or chat.
594
+ function here(args) {
595
+ var r = W.parseRoute();
596
+ return r.pane === 'messages' && r.args.join('/') === (args || []).join('/');
597
+ }
598
+ function retryBox(text) {
599
+ return '<div class="cx"><div class="cx-notice warn"><span>' + esc(text) + '</span></div><button type="button" class="cx-btn quiet" data-msg="retry">Try again</button></div>';
600
+ }
601
+ function render() {
602
+ var a = current;
603
+ if (W.parseRoute().pane !== 'messages') return;
604
+ if (!a.length || a[0] === 'archive' || a[0] === 'cos') return renderList();
605
+ if (a[0] === 'm') return renderMessage(a[1], a[2] === 'a' ? a[3] : null);
606
+ }
607
+ function load(args) {
608
+ current = args || [];
609
+ if (current[0] === 'cos') {
610
+ // The chat route before /api/health has answered: ask once, then come back. Drop it off sends the list.
611
+ if (!cos || (!cos.known() && !cosOn())) {
612
+ pane.innerHTML = '<div class="cx">' + W.skeletonRows(4) + '</div>';
613
+ if (cos) cos.ensureCaps().then(function () { if (here(['cos'])) load(['cos']); });
614
+ return;
615
+ }
616
+ if (!cosOn()) return W.go('messages');
617
+ S.view = 'recent';
618
+ loadRecent(!!S.recent && Date.now() - (S.recentAt || 0) > 60000);
619
+ renderList();
620
+ cos.refreshSummary();
621
+ return;
622
+ }
623
+ if (cos && !cos.known()) cos.ensureCaps();
624
+ if (cosOn()) cos.refreshSummary();
625
+ if (!current.length || current[0] === 'archive') {
626
+ S.view = current[0] === 'archive' ? 'archive' : 'recent';
627
+ // Older than a minute: refetch quietly, with the rows still on screen (the rail's "Last
628
+ // from the glasses" line refreshes too, and the two must not disagree).
629
+ loadRecent(!!S.recent && Date.now() - (S.recentAt || 0) > 60000);
630
+ if (S.view === 'archive') loadDays(false);
631
+ renderList();
632
+ } else if (current[0] === 'm') renderMessage(current[1], current[2] === 'a' ? current[3] : null);
633
+ else if (current[0] === 'day' && current.length === 2) renderDay(current[1]);
634
+ else if (current[0] === 'day' && current.length >= 3) renderChat(current[1], current[2]);
635
+ else renderList();
636
+ }
637
+
638
+ pane.addEventListener('click', function (e) {
639
+ var c = cos && e.target.closest('[data-cos]');
640
+ if (c && !c.disabled) { cos.onClick(c); return; }
641
+ var t = e.target.closest('[data-msg]');
642
+ if (!t || t.tagName === 'INPUT') return;
643
+ var act = t.getAttribute('data-msg');
644
+ if (act === 'view') return W.go('messages', t.getAttribute('data-view') === 'archive' ? ['archive'] : []);
645
+ if (act === 'cos') return W.go('messages', ['cos']);
646
+ if (act === 'refresh') { S.recentState = 'idle'; loadRecent(true); if (S.view === 'archive') { S.daysState = 'idle'; loadDays(true); } return; }
647
+ if (act === 'clear') { S.q = ''; S.archiveRan = ''; S.archiveHits = null; return renderList(); }
648
+ if (act === 'open') return W.go('messages', ['m', t.getAttribute('data-key')]);
649
+ if (act === 'day') return W.go('messages', ['day', t.getAttribute('data-date')]);
650
+ if (act === 'chat') return W.go('messages', ['day', t.getAttribute('data-date'), t.getAttribute('data-index')]);
651
+ if (act === 'att') return W.go('messages', ['m', t.getAttribute('data-key'), 'a', t.getAttribute('data-att')]);
652
+ if (act === 'att-open') { window.open(t.getAttribute('data-href'), '_blank', 'noopener'); return; }
653
+ if (act === 'retry') { if (S.recentState === 'error') { S.recentState = 'idle'; S.recent = null; } return load(current); }
654
+ if (act === 'handoff') {
655
+ var list = (S.recent || []).slice().reverse();
656
+ var nd = new Date(), day = S.recentDate || (nd.getFullYear() + '-' + String(nd.getMonth() + 1).padStart(2, '0') + '-' + String(nd.getDate()).padStart(2, '0'));
657
+ var lines = ['COS Glasses handoff · ' + day + ' · last ' + list.length + ' turns', 'Continue from this context. Global msg numbers refer to glasses history.', ''];
658
+ list.forEach(function (turn) { lines.push(copyTurnText(turn)); lines.push(''); });
659
+ W.copyText(lines.join('\n')).then(function () { W.toast('Copied'); }, function (err) { W.toast(err.message); });
660
+ return;
661
+ }
662
+ if (act === 'copy-turn') { var turn = findRecent(t.getAttribute('data-key')); if (turn) W.copyText(copyTurnText(turn)).then(function () { W.toast('Copied'); }, function (err) { W.toast(err.message); }); return; }
663
+ if (act === 'copy-chat-turn') { var ct = (pane._chat || [])[Number(t.getAttribute('data-i'))]; if (ct) W.copyText(copyTurnText(ct)).then(function () { W.toast('Copied'); }, function (err) { W.toast(err.message); }); return; }
664
+ if (act === 'find-next' || act === 'find-prev') {
665
+ if (!find.hits.length) return;
666
+ find.at = (find.at + (act === 'find-next' ? 1 : -1) + find.hits.length) % find.hits.length;
667
+ applyFind(true);
668
+ }
669
+ });
670
+ pane.addEventListener('change', function (e) {
671
+ if (cos) cos.onChange(e);
672
+ if (e.target.matches('[data-msg="only"]')) { S.onlyMatches = e.target.checked; renderDay(current[1]); }
673
+ });
674
+ pane.addEventListener('keydown', function (e) { if (cos) cos.onKey(e); });
675
+ // 6.99.12: paste a photo or file into a composer, or drop files on it.
676
+ pane.addEventListener('paste', function (e) { if (cos) cos.onPaste(e); });
677
+ ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(function (type) { pane.addEventListener(type, function (e) { if (cos) cos.onDrag(e); }); });
678
+ pane.addEventListener('input', function (e) {
679
+ if (cos) cos.onInput(e);
680
+ if (e.target.id === 'msg-q') {
681
+ S.q = e.target.value;
682
+ clearTimeout(pane._t);
683
+ pane._t = setTimeout(renderList, 120);
684
+ } else if (e.target.id === 'msg-find') {
685
+ find.q = e.target.value.trim(); find.at = 0; applyFind(true);
686
+ }
687
+ });
688
+ pane.addEventListener('submit', function (e) {
689
+ if (cos && cos.onSubmit(e)) return;
690
+ if (!e.target.matches('[data-msg-form="search"]')) return;
691
+ e.preventDefault();
692
+ S.q = ($('msg-q') || {}).value || S.q;
693
+ if (S.q.trim().length >= 2) {
694
+ if (S.view !== 'archive') { W.go('messages', ['archive']); }
695
+ runArchiveSearch();
696
+ }
697
+ });
698
+
699
+ if (cos) {
700
+ // Drop it or Ask turned on or off (a health read): Messages redraws as it now should.
701
+ cos.setOnChange(function () { if (W.parseRoute().pane === 'messages') load(current); });
702
+ // A web Ask landed: its turn is now in Recent.
703
+ cos.setOnAnswered(function () { loadRecent(true); });
704
+ }
705
+
706
+ W.registerPane('messages', {
707
+ load: load,
708
+ // Keeps the rows on screen while the new window loads (emptying it moved the page).
709
+ refresh: function () { S.recentState = 'idle'; loadRecent(true); },
710
+ // Back unwinds one step: a chat goes to its day, a day to the archive list, an image to its message.
711
+ up: function (args) {
712
+ if (args[0] === 'day' && args.length >= 3) return ['day', args[1]];
713
+ if (args[0] === 'day') return ['archive'];
714
+ if (args[0] === 'm' && args[2] === 'a') return ['m', args[1]];
715
+ return null;
716
+ }
717
+ });
718
+ })();