@gotcos/glasses-server 6.99.25 → 6.99.33
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +224 -0
- package/package.json +1 -1
- package/server/index.ts +46 -4
- package/server/lib/agent-session-output-images.ts +280 -0
- package/server/lib/agent-session-store.ts +3 -1
- package/server/lib/agent-session-turns.ts +18 -7
- package/server/lib/client-liveness.ts +38 -3
- package/server/lib/codex-subagent-activity.ts +36 -0
- package/server/lib/g2-turn-provenance.ts +679 -0
- package/server/lib/media-store.ts +41 -2
- package/server/lib/permission-broker.ts +94 -17
- package/server/lib/queue-turn-evidence.ts +19 -2
- package/server/lib/session-halt.ts +15 -1
- package/server/lib/session-stream-events.ts +4 -0
- package/server/lib/session-subagents.ts +155 -0
- package/server/lib/session-turn-attachments.ts +245 -0
- package/server/lib/thread-turn-queue-deliver.ts +17 -2
- package/server/lib/thread-turn-queue.ts +10 -0
- package/server/lib/today-brief.ts +731 -0
- package/server/lib/turn-file-store.ts +202 -0
- package/server/lib/turn-footer.ts +81 -0
- package/server/routes/agent-session-bindings.ts +164 -16
- package/server/routes/agent-sessions.ts +77 -8
- package/server/routes/client-instance.ts +12 -1
- package/server/routes/cursor-stop-followup.ts +30 -1
- package/server/routes/g2-turns.ts +46 -0
- package/server/routes/health.ts +9 -0
- package/server/routes/media.ts +32 -8
- package/server/routes/permission-broker.ts +47 -4
- package/server/routes/thread-turn-queue.ts +148 -8
- package/server/routes/today-brief.ts +148 -0
- package/server/web-control/app.css +42 -9
- package/server/web-control/app.js +975 -114
- package/server/web-control/control.css +12 -6
- package/server/web-control/index.html +6 -1
- package/server/web-control/panes/cos-chat.css +27 -3
- package/server/web-control/panes/cos-chat.js +240 -46
- package/server/web-control/panes/meetings.css +5 -2
- package/server/web-control/panes/meetings.js +21 -10
- package/server/web-control/panes/memories.css +1 -1
- package/server/web-control/panes/memories.js +3 -8
- package/server/web-control/panes/questions.css +56 -2
- package/server/web-control/panes/questions.js +454 -56
- package/server/web-control/panes/reader-media.js +154 -0
- package/server/web-control/panes/sessions.css +20 -7
- package/server/web-control/panes/settings.js +34 -2
- package/server/web-control/panes/speakers.css +3 -5
- package/server/web-control/panes/speakers.js +7 -4
- package/server/web-control/panes/threads.js +3 -2
- package/server/web-control/panes/today-actions.css +81 -3
- package/server/web-control/panes/today-actions.js +527 -62
- package/server/web-control/panes/work.css +1 -1
- package/server/web-control/shell.js +3 -0
|
@@ -58,6 +58,19 @@
|
|
|
58
58
|
if (diff === 1) return 'tomorrow';
|
|
59
59
|
return d.toLocaleDateString(undefined, { weekday: 'long' });
|
|
60
60
|
}
|
|
61
|
+
// 6.99.30 (design QA): a day in a row, in plain words: "Today", else "Nov 7" ("Nov 7, 2025" in another year). Takes
|
|
62
|
+
// a YYYY-MM-DD (a meeting's date, read as that calendar day, never shifted by time zone) or a Date. Anything else comes
|
|
63
|
+
// back as it was.
|
|
64
|
+
var MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
65
|
+
function plainDay(v, now) {
|
|
66
|
+
var y, m, d;
|
|
67
|
+
if (v instanceof Date && isFinite(v.getTime())) { y = v.getFullYear(); m = v.getMonth(); d = v.getDate(); }
|
|
68
|
+
else { var p = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(v == null ? '' : v)); if (!p) return v == null ? '' : String(v); y = +p[1]; m = +p[2] - 1; d = +p[3]; }
|
|
69
|
+
if (m < 0 || m > 11 || d < 1 || d > 31) return String(v);
|
|
70
|
+
var n = now || new Date();
|
|
71
|
+
if (y === n.getFullYear() && m === n.getMonth() && d === n.getDate()) return 'Today';
|
|
72
|
+
return MONTHS[m] + ' ' + d + (y === n.getFullYear() ? '' : ', ' + y);
|
|
73
|
+
}
|
|
61
74
|
var PROVIDERS = { claude: 'Claude Code', codex: 'Codex', cursor: 'Cursor', glasses: 'Glasses', cos: 'COS' };
|
|
62
75
|
function providerLabel(p) { return PROVIDERS[p] || (p ? p.charAt(0).toUpperCase() + p.slice(1) : 'Session'); }
|
|
63
76
|
function badge(p) { var cls = PROVIDERS[p] ? (p === 'cos' ? 'glasses' : p) : 'work'; return '<span class="badge ' + esc(cls) + '">' + esc(providerLabel(p)) + '</span>'; }
|
|
@@ -100,9 +113,14 @@
|
|
|
100
113
|
var limit = o.timeoutMs != null ? o.timeoutMs : (method === 'GET' ? API_READ_TIMEOUT_MS : 0);
|
|
101
114
|
var ctrl = limit && window.AbortController ? new AbortController() : null;
|
|
102
115
|
var timer = ctrl ? setTimeout(function () { ctrl.abort(); }, limit) : null;
|
|
116
|
+
var sentAt = Date.now();
|
|
103
117
|
return fetch(path, { method: method, headers: headers, credentials: 'same-origin', body: body, signal: ctrl ? ctrl.signal : undefined }).then(function (res) {
|
|
104
|
-
|
|
118
|
+
// Blob bodies keep their deadline after headers: a stalled download must release its queue slot.
|
|
119
|
+
if (timer && o.responseType !== 'blob') clearTimeout(timer);
|
|
120
|
+
sxNoteClock(res, sentAt);
|
|
121
|
+
noteServer(true);
|
|
105
122
|
if (res.status === 401 && path.indexOf('/api/web-session/') !== 0) { showAuth(); var gone = new Error('Signed out. Connect this browser again.'); gone.status = 401; throw gone; }
|
|
123
|
+
if (o.responseType === 'blob' && res.ok) return res.blob();
|
|
106
124
|
return res.text().then(function (text) {
|
|
107
125
|
var data = {};
|
|
108
126
|
try { data = text ? JSON.parse(text) : {}; } catch (_e) { data = {}; }
|
|
@@ -118,17 +136,74 @@
|
|
|
118
136
|
}
|
|
119
137
|
return data;
|
|
120
138
|
});
|
|
139
|
+
}).then(function (value) {
|
|
140
|
+
if (timer) clearTimeout(timer);
|
|
141
|
+
return value;
|
|
121
142
|
}, function (err) {
|
|
122
143
|
if (timer) clearTimeout(timer);
|
|
123
144
|
if (err && err.name === 'AbortError' && ctrl && ctrl.signal.aborted) {
|
|
145
|
+
// 6.99.32 (QA 6.99.30 canary W1): a read the Mac never answered in time means it is not answering. A write has
|
|
146
|
+
// no deadline unless its caller set one, and a slow save is not a Mac that went away, so only reads say so.
|
|
147
|
+
if (method === 'GET' && !(SERVER.lastUpAt >= sentAt)) noteServer(false);
|
|
124
148
|
var late = new Error('Your Mac did not answer in ' + Math.round(limit / 1000) + ' seconds. Check that it is awake, then try again.');
|
|
125
149
|
late.code = 'timeout'; throw late;
|
|
126
150
|
}
|
|
151
|
+
// 6.99.30: the browser's own words for a request that never reached the Mac ("Failed to fetch" in Chrome and
|
|
152
|
+
// Comet, "Load failed" in Safari, "NetworkError..." in Firefox) never reach the page. Anything with no status
|
|
153
|
+
// that is not a timeout or an abort the caller asked for is the Mac out of reach, said once, here.
|
|
154
|
+
if (err && err.status == null && !err.code && err.name !== 'AbortError') { noteServer(false); throw unreachableError(err); }
|
|
127
155
|
throw err;
|
|
128
156
|
});
|
|
129
157
|
}
|
|
130
158
|
var API_READ_TIMEOUT_MS = 20000;
|
|
131
159
|
|
|
160
|
+
// ── what every request learns about the Mac (6.99.30) ──
|
|
161
|
+
// api() above and apiAnswer() (the Sessions reader's transport) share these; they sit here, between api() and the
|
|
162
|
+
// state, so a test that loads api() alone has them.
|
|
163
|
+
// 6.99.30: the one sentence for a request that never reached the Mac (api() and apiAnswer() below, and the panes with
|
|
164
|
+
// their own fetch: COSWeb.unreachableCopy). Never the browser's own "Failed to fetch" or "Load failed".
|
|
165
|
+
var UNREACHABLE_COPY = 'Could not reach COS on your Mac. Check that it is awake and on Tailscale, then try again.';
|
|
166
|
+
function unreachableError(cause) { var e = new Error(UNREACHABLE_COPY); e.code = 'unreachable'; e.cause = cause; return e; }
|
|
167
|
+
|
|
168
|
+
// 6.99.30 (QA: clock skew): the Mac's clock, as this page's offset from it. The session card's one state (sxTruth)
|
|
169
|
+
// weighs the Mac's own times (state_since, a row's activity) against moments this page stamped with its own clock (a
|
|
170
|
+
// gate answer, a Stop). A phone a minute behind made every Mac time look a minute newer, so a stale Idle beat a
|
|
171
|
+
// Working the page had just been told about. Each answer's Date header (whole seconds, read at the midpoint of the
|
|
172
|
+
// request) gives the offset; the answer with the shortest round trip in the last ten minutes wins, so one slow answer
|
|
173
|
+
// never moves it. Zero until the first answer. A missing or unreadable header changes nothing.
|
|
174
|
+
var SX_CLOCK = { skew: 0, rtt: Infinity, at: 0 };
|
|
175
|
+
var SX_CLOCK_KEEP_MS = 600000;
|
|
176
|
+
function sxNoteClock(res, sentAt) {
|
|
177
|
+
var got = Date.now(), h = null;
|
|
178
|
+
try { h = res && res.headers && typeof res.headers.get === 'function' ? res.headers.get('date') : null; } catch (_e) { h = null; }
|
|
179
|
+
var server = h ? Date.parse(h) : NaN;
|
|
180
|
+
if (!isFinite(server) || typeof sentAt !== 'number') return;
|
|
181
|
+
var rtt = Math.max(0, got - sentAt);
|
|
182
|
+
// The header drops the milliseconds: on average the Mac's moment is half a second after the stamped second.
|
|
183
|
+
var skew = Math.round(server + 500 - (sentAt + rtt / 2));
|
|
184
|
+
if (rtt <= SX_CLOCK.rtt || got - SX_CLOCK.at > SX_CLOCK_KEEP_MS) { SX_CLOCK.skew = skew; SX_CLOCK.rtt = rtt; SX_CLOCK.at = got; }
|
|
185
|
+
}
|
|
186
|
+
// 6.99.30 (QA: "Server connected" stayed green for five minutes after the Mac went away): every request says whether
|
|
187
|
+
// the Mac answered. A request that never reached it marks it down; any HTTP answer, whatever its status, marks it up.
|
|
188
|
+
// QA 6.99.30 (Skeptic): never a 502. Nothing sits in front of the Mac (direct HTTPS over Tailscale) and COS sends its
|
|
189
|
+
// own 502s (an unsupported Memories workspace, a bad Meetings Ask answer), so a 502 flapped the rail, the question
|
|
190
|
+
// cards and an open sheet down and up again. The rail's server line, Today's sheet and the question cards hear each change at once
|
|
191
|
+
// (COSWeb.onServer). null until the first answer.
|
|
192
|
+
// lastUpAt (6.99.32, QA Skeptic W-2): when the Mac last answered anything, on this page's clock. A read that timed out
|
|
193
|
+
// marks it down only if nothing has answered since that read went out (a slow image whose headers already came, a busy
|
|
194
|
+
// Mac whose question poll keeps answering), so a slow but alive Mac never flaps.
|
|
195
|
+
var SERVER = { up: null, hooks: [], lastUpAt: 0 };
|
|
196
|
+
function noteServer(up) {
|
|
197
|
+
up = !!up;
|
|
198
|
+
if (up) SERVER.lastUpAt = Date.now();
|
|
199
|
+
if (SERVER.up === up) return;
|
|
200
|
+
SERVER.up = up;
|
|
201
|
+
SERVER.hooks.slice().forEach(function (fn) { try { fn(up); } catch (err) { console.error(err); } });
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** This page's moment `t` (its own Date.now()) on the Mac's clock. */
|
|
205
|
+
function sxServerTime(t) { return t == null ? t : t + SX_CLOCK.skew; }
|
|
206
|
+
|
|
132
207
|
// ── state ───────────────────────────────────────────────
|
|
133
208
|
var state = {
|
|
134
209
|
pane: 'today', serverName: '', tailnetUrl: '',
|
|
@@ -204,6 +279,17 @@
|
|
|
204
279
|
if (location.hash === hash) route(); else location.hash = hash;
|
|
205
280
|
}
|
|
206
281
|
function isPhone() { return !window.matchMedia('(min-width: 600px)').matches; }
|
|
282
|
+
/** A keyboard and a fine pointer are the way in (a Mac, a PC): Return sends. Touch screens type a new line. */
|
|
283
|
+
function keyboardFirst() { return !!(window.matchMedia && window.matchMedia('(hover: hover) and (pointer: fine)').matches); }
|
|
284
|
+
// Return in a text box sends only where a keyboard and a pointer are the way in (cos-chat.js keyboardFirst): never on
|
|
285
|
+
// a touch screen, whatever its width (6.99.30: the unfolded Duo's on-screen keyboard sent on Return), and never
|
|
286
|
+
// mid-composition (an IME's Return picks the word; keyCode 229 is Safari's composing key). Shift-Return is a new line.
|
|
287
|
+
// QA 6.99.30 (Skeptic): Cmd-Return or Ctrl-Return sends on every device, so a Bluetooth keyboard on the Duo or an iPad
|
|
288
|
+
// (a coarse pointer, so plain Return is a new line there) still has a send key.
|
|
289
|
+
function returnSends(e) {
|
|
290
|
+
if (!e || e.key !== 'Enter' || e.shiftKey || e.isComposing || e.keyCode === 229 || !e.target || e.target.tagName !== 'TEXTAREA') return false;
|
|
291
|
+
return !!(e.metaKey || e.ctrlKey || keyboardFirst());
|
|
292
|
+
}
|
|
207
293
|
function route(forceChanged) {
|
|
208
294
|
var r = parseRoute();
|
|
209
295
|
var changedPane = forceChanged === true || r.pane !== state.pane || !document.querySelector('#pane-' + r.pane + ':not(.hidden)');
|
|
@@ -215,7 +301,7 @@
|
|
|
215
301
|
var railPane = r.pane === 'usage' ? 'sessions' : r.pane;
|
|
216
302
|
document.querySelectorAll('.rail-btn,.rail-settings').forEach(function (b) { if (b.getAttribute('data-pane') === railPane) b.setAttribute('aria-current', 'page'); else b.removeAttribute('aria-current'); });
|
|
217
303
|
if (state.stream && !(r.pane === 'sessions' && r.args.length === 2)) { state.stream.close(); state.stream = null; }
|
|
218
|
-
if (!(r.pane === 'sessions' && r.args.length === 2)) { clearInterval(state.turnPoll); if (state.reading) clearInterval(state.reading.heldTimer); state.reading = null; }
|
|
304
|
+
if (!(r.pane === 'sessions' && r.args.length === 2)) { readerImagesStop($('session-turns')); clearInterval(state.turnPoll); if (state.reading) clearInterval(state.reading.heldTimer); state.reading = null; }
|
|
219
305
|
if (r.pane !== 'meetings' && state.livePoll) { clearInterval(state.livePoll); state.livePoll = null; }
|
|
220
306
|
|
|
221
307
|
var reading = r.args.length > 0 && !isTabRoute(r.args);
|
|
@@ -292,6 +378,7 @@
|
|
|
292
378
|
|
|
293
379
|
// ── auth ────────────────────────────────────────────────
|
|
294
380
|
function showAuth(message) {
|
|
381
|
+
readerImagesStop($('session-turns')); readerImagesStop($('ta-last'));
|
|
295
382
|
var alreadyOpen = !$('auth-gate').classList.contains('hidden');
|
|
296
383
|
$('app-shell').classList.add('hidden');
|
|
297
384
|
$('auth-gate').classList.remove('hidden');
|
|
@@ -332,16 +419,35 @@
|
|
|
332
419
|
// stories all asked for it separately. Now there is one copy: a request younger than maxAgeMs is
|
|
333
420
|
// answered from it, a request already on the wire is shared, and panes can paint the last copy at
|
|
334
421
|
// once while a fresh one loads (sessionListCached).
|
|
335
|
-
var SL = { data: null, at: 0, inflight: null };
|
|
422
|
+
var SL = { data: null, at: 0, inflight: null, waiting: null };
|
|
336
423
|
function sessionList(maxAgeMs) {
|
|
337
424
|
if (SL.data && Date.now() - SL.at < (maxAgeMs || 0)) return Promise.resolve(SL.data);
|
|
338
425
|
if (SL.inflight) return SL.inflight;
|
|
339
426
|
SL.inflight = api('/api/agent-sessions?limit=80&sort=updated').then(function (d) {
|
|
340
|
-
SL.data = d; SL.at = Date.now(); SL.inflight = null;
|
|
427
|
+
SL.data = d; SL.at = Date.now(); SL.inflight = null;
|
|
428
|
+
noteWaiting(d);
|
|
429
|
+
return d;
|
|
341
430
|
}, function (err) { SL.inflight = null; throw err; });
|
|
342
431
|
return SL.inflight;
|
|
343
432
|
}
|
|
344
433
|
function sessionListCached() { return SL.data ? { data: SL.data, at: SL.at } : null; }
|
|
434
|
+
// 6.99.27 (Miles 11/07 09:29, "a big delay to see the questions"): a session that has just gone to waiting may have
|
|
435
|
+
// parked a question for this page. The question poll (panes/questions.js) reads at once rather than at its next turn.
|
|
436
|
+
function questionsNudge() {
|
|
437
|
+
var q = window.COSWeb && window.COSWeb.sessionQuestions;
|
|
438
|
+
if (q && typeof q.nudge === 'function') { try { q.nudge(); } catch (_e) {} }
|
|
439
|
+
}
|
|
440
|
+
function noteWaiting(d) {
|
|
441
|
+
var now = {}, fresh = false;
|
|
442
|
+
((d && d.sessions) || []).forEach(function (s) {
|
|
443
|
+
if (!s || sxState(s) !== 'waiting') return;
|
|
444
|
+
var k = splitSessionId(s.provider, s.session_id || s.id), key = k.provider + '|' + String(k.id).toLowerCase();
|
|
445
|
+
now[key] = 1;
|
|
446
|
+
if (!SL.waiting || !SL.waiting[key]) fresh = true;
|
|
447
|
+
});
|
|
448
|
+
SL.waiting = now;
|
|
449
|
+
if (fresh) questionsNudge();
|
|
450
|
+
}
|
|
345
451
|
|
|
346
452
|
// ── the session pet (Today) ─────────────────────────────
|
|
347
453
|
// Same pet and the same pose rule as COS Control (PetSpritePose.resolve): waiting with nothing running
|
|
@@ -367,27 +473,99 @@
|
|
|
367
473
|
return merged;
|
|
368
474
|
}
|
|
369
475
|
var PET_HEIGHT = 140;
|
|
370
|
-
|
|
371
|
-
|
|
476
|
+
// 6.99.27 (Miles 11/07 09:29, "there wasn't an option for the pet to interact with the questions"): the questions
|
|
477
|
+
// this page holds (panes/questions.js), the ones that can be answered here. One key per session, as Needs you keys
|
|
478
|
+
// them.
|
|
479
|
+
function petQuestions() {
|
|
480
|
+
var q = window.COSWeb && window.COSWeb.sessionQuestions;
|
|
481
|
+
var items = q && typeof q.itemsFor === 'function' ? q.itemsFor('') : [];
|
|
482
|
+
return (items || []).filter(function (it) { return it && it.sessionId; });
|
|
483
|
+
}
|
|
484
|
+
function petQKey(it) { var k = splitSessionId(it.provider, it.sessionId); return k.provider + '|' + k.id; }
|
|
485
|
+
function petPose(sessions, questions) {
|
|
486
|
+
var working = 0, waiting = 0, alive = 0, asked = {};
|
|
487
|
+
var qs = questions || [];
|
|
488
|
+
qs.forEach(function (it) { asked[petQKey(it).toLowerCase()] = 1; });
|
|
372
489
|
sessions.forEach(function (s) {
|
|
373
490
|
var st = sxState(s);
|
|
374
491
|
// Working is the Running card's own test (running_active), so the caption under the pet never
|
|
375
492
|
// disagrees with the Running count beside it (11/06: the pet said 3 while the card said 2).
|
|
376
493
|
if (s.running_active) working++;
|
|
377
|
-
|
|
494
|
+
// A session whose question the caption already leads with is not counted again as waiting on you.
|
|
495
|
+
else if (st === 'waiting' && !asked[petKey(s).toLowerCase()]) waiting++;
|
|
378
496
|
if (s.running || s.running_active) alive++;
|
|
379
497
|
});
|
|
380
498
|
var pose;
|
|
381
|
-
|
|
499
|
+
// COS Control's rule (PetSpritePose.resolve): attention outranks every count pose. A question waiting here is the
|
|
500
|
+
// web's attention: it needs you, and this page can answer it.
|
|
501
|
+
if (qs.length) pose = 'attention';
|
|
502
|
+
else if (waiting > 0 && working === 0) pose = 'waiting';
|
|
382
503
|
else if (working + waiting >= 4) pose = 'swarm';
|
|
383
504
|
else if (working + waiting === 3) pose = 'trio';
|
|
384
505
|
else if (working + waiting === 2) pose = 'duel';
|
|
385
506
|
else if (working + waiting === 1) pose = 'working';
|
|
386
507
|
else pose = alive > 0 ? 'patrol' : 'idle';
|
|
387
|
-
|
|
508
|
+
// QA 6.99.27 (User 6): a permission request is an approval, counted apart from the questions.
|
|
509
|
+
var approvals = qs.filter(function (it) { return it.kind === 'approval'; }).length;
|
|
510
|
+
return { pose: pose, working: working, waiting: waiting, questions: qs.length - approvals, approvals: approvals };
|
|
511
|
+
}
|
|
512
|
+
// COS Control's fallbacks (PetSpritePose.fallbackPoses) for a pet that has no strip for the pose.
|
|
513
|
+
var PET_FALLBACK = { attention: ['error', 'waiting', 'idle'], patrol: ['idle'], duel: ['swarm', 'working', 'idle'], trio: ['swarm', 'duel', 'working', 'idle'],
|
|
514
|
+
swarm: ['duel', 'trio', 'working', 'idle'], working: ['idle'], waiting: ['idle'] };
|
|
515
|
+
function petPoseIn(poses, want) {
|
|
516
|
+
if (poses[want]) return want;
|
|
517
|
+
var chain = PET_FALLBACK[want] || [];
|
|
518
|
+
for (var i = 0; i < chain.length; i++) if (poses[chain[i]]) return chain[i];
|
|
519
|
+
return poses.idle ? 'idle' : Object.keys(poses)[0];
|
|
520
|
+
}
|
|
521
|
+
// 6.99.26 (Miles 11/07, "Open what matters"): the sessions behind the pet's caption, by the same test petPose
|
|
522
|
+
// counts with, so a tap opens what the caption says. In play: waiting on you, then running. Done today rides along in
|
|
523
|
+
// the list, never in the count.
|
|
524
|
+
function petPlay(sessions) {
|
|
525
|
+
var waiting = [], running = [], done = [], today = new Date().toDateString();
|
|
526
|
+
(sessions || []).forEach(function (s) {
|
|
527
|
+
var st = sxState(s);
|
|
528
|
+
if (s.running_active) running.push(s);
|
|
529
|
+
else if (st === 'waiting') waiting.push(s);
|
|
530
|
+
else if ((st === 'recent' || st === 'stale') && !sxKeepWarm(s)) {
|
|
531
|
+
var d = toDate(s.last_activity_at || s.state_since);
|
|
532
|
+
if (d && d.toDateString() === today) done.push(s);
|
|
533
|
+
}
|
|
534
|
+
});
|
|
535
|
+
return { waiting: waiting, running: running, done: done };
|
|
536
|
+
}
|
|
537
|
+
function petKey(s) { var k = splitSessionId(s.provider, s.session_id || s.id); return k.provider + '|' + k.id; }
|
|
538
|
+
// One session in play opens its own sheet (waiting: the question card or reply box; else the Running peek); several
|
|
539
|
+
// open the short list; none does nothing.
|
|
540
|
+
function petTap(t) {
|
|
541
|
+
// A question waiting here comes first: one session's question opens its card in the overlay; questions from
|
|
542
|
+
// several sessions open the list, question rows first.
|
|
543
|
+
var qs = petQuestions(), qkeys = {};
|
|
544
|
+
qs.forEach(function (it) { qkeys[petQKey(it)] = 1; });
|
|
545
|
+
var qk = Object.keys(qkeys);
|
|
546
|
+
if (qk.length === 1) return openTodayAction(t, 'wait', qk[0]);
|
|
547
|
+
if (qk.length > 1) return openTodayAction(t, 'pet', '');
|
|
548
|
+
var p = petPlay(state.sessions || []), n = p.waiting.length + p.running.length;
|
|
549
|
+
if (!n) return;
|
|
550
|
+
if (n > 1) return openTodayAction(t, 'pet', '');
|
|
551
|
+
var s = p.waiting[0] || p.running[0];
|
|
552
|
+
openTodayAction(t, sxState(s) === 'waiting' ? 'wait' : 'run', petKey(s));
|
|
553
|
+
}
|
|
554
|
+
function petButtonState(box, c, label) {
|
|
555
|
+
var btn = box.querySelector('.pet-btn');
|
|
556
|
+
if (!btn) return;
|
|
557
|
+
var any = c.working + c.waiting + (c.questions || 0) + (c.approvals || 0) > 0;
|
|
558
|
+
btn.setAttribute('aria-label', any ? label + '. Open' : label);
|
|
559
|
+
btn.setAttribute('aria-disabled', any ? 'false' : 'true');
|
|
388
560
|
}
|
|
389
561
|
function petCaption(c) {
|
|
390
562
|
var parts = [];
|
|
563
|
+
// A question or an approval waiting here leads (6.99.27): "1 question for you", "1 approval for you", or
|
|
564
|
+
// "1 question and 1 approval for you" (QA 6.99.27, User 6: approvals were counted as questions).
|
|
565
|
+
var asks = [];
|
|
566
|
+
if (c.questions) asks.push(c.questions === 1 ? '1 question' : c.questions + ' questions');
|
|
567
|
+
if (c.approvals) asks.push(c.approvals === 1 ? '1 approval' : c.approvals + ' approvals');
|
|
568
|
+
if (asks.length) parts.push(asks.join(' and ') + ' for you');
|
|
391
569
|
if (c.working) parts.push(c.working + ' running');
|
|
392
570
|
if (c.waiting) parts.push(c.waiting + ' waiting on you');
|
|
393
571
|
return parts.length ? parts.join(', ') : 'Nothing running';
|
|
@@ -418,17 +596,17 @@
|
|
|
418
596
|
var PET_CHANGE = '<button type="button" class="pet-change" data-goto="settings/pet">Change pet</button>';
|
|
419
597
|
// A still pet (a gallery pick, Cloud Puff, or a single image on the Mac): one picture for every state.
|
|
420
598
|
function renderPetStill(box, m, sessions) {
|
|
421
|
-
var c = petPose(sessions), label = petCaption(c);
|
|
599
|
+
var c = petPose(sessions, petQuestions()), label = petCaption(c);
|
|
422
600
|
petStop(); PET.pose = 'still:' + m.still;
|
|
423
601
|
var img = box.querySelector('.pet-still');
|
|
424
602
|
if (!img || img.getAttribute('src') !== m.still) {
|
|
425
|
-
box.innerHTML = '<button type="button" class="pet-btn" data-
|
|
603
|
+
box.innerHTML = '<button type="button" class="pet-btn" data-today="pet"><img class="pet-still" alt="" src="' + esc(m.still) + '"><span class="pet-caption"></span></button>' + PET_CHANGE;
|
|
426
604
|
img = box.querySelector('.pet-still');
|
|
427
605
|
img.addEventListener('error', function () { box.classList.add('hidden'); });
|
|
428
606
|
img.addEventListener('load', function () { img.classList.toggle('pixel', img.naturalHeight > 0 && img.naturalHeight < 140); });
|
|
429
607
|
}
|
|
430
608
|
box.querySelector('.pet-caption').textContent = label;
|
|
431
|
-
box
|
|
609
|
+
petButtonState(box, c, label);
|
|
432
610
|
box.classList.remove('hidden');
|
|
433
611
|
}
|
|
434
612
|
function renderPet(sessions) {
|
|
@@ -448,15 +626,14 @@
|
|
|
448
626
|
if (!now.show || now.pet !== prefs.pet) return;
|
|
449
627
|
if (!m || !m.available) { box.classList.add('hidden'); petStop(); return; }
|
|
450
628
|
if (m.kind === 'still') { renderPetStill(box, m, sessions); return; }
|
|
451
|
-
var c = petPose(sessions);
|
|
452
|
-
var pose = m.poses
|
|
629
|
+
var c = petPose(sessions, petQuestions());
|
|
630
|
+
var pose = petPoseIn(m.poses, c.pose);
|
|
453
631
|
var spec = m.poses[pose];
|
|
454
632
|
var label = petCaption(c);
|
|
455
633
|
box.classList.remove('hidden');
|
|
456
634
|
var cap = box.querySelector('.pet-caption');
|
|
457
635
|
if (cap) cap.textContent = label;
|
|
458
|
-
|
|
459
|
-
if (btn) btn.setAttribute('aria-label', 'Sessions: ' + label);
|
|
636
|
+
petButtonState(box, c, label);
|
|
460
637
|
if (PET.pose === pose && PET.timer) return;
|
|
461
638
|
var token = ++PET.token;
|
|
462
639
|
petLoadImages(spec).then(function () {
|
|
@@ -464,10 +641,10 @@
|
|
|
464
641
|
petStop();
|
|
465
642
|
PET.pose = pose; PET.frame = 0;
|
|
466
643
|
if (!box.querySelector('.pet-sprite')) {
|
|
467
|
-
box.innerHTML = '<button type="button" class="pet-btn" data-
|
|
644
|
+
box.innerHTML = '<button type="button" class="pet-btn" data-today="pet"><span class="pet-sprite" aria-hidden="true"></span><span class="pet-caption"></span></button>' + PET_CHANGE;
|
|
468
645
|
}
|
|
469
646
|
box.querySelector('.pet-caption').textContent = label;
|
|
470
|
-
box
|
|
647
|
+
petButtonState(box, c, label);
|
|
471
648
|
var el = box.querySelector('.pet-sprite'), scale = PET_HEIGHT / spec.height;
|
|
472
649
|
el.style.width = Math.round(spec.frameWidth * scale) + 'px';
|
|
473
650
|
petPaint(el, spec, 0, scale);
|
|
@@ -531,6 +708,7 @@
|
|
|
531
708
|
renderRunning(working, open);
|
|
532
709
|
if (!offline) renderPet(sessions);
|
|
533
710
|
paintNeeds();
|
|
711
|
+
renderWeather(welcome);
|
|
534
712
|
renderNext(welcome);
|
|
535
713
|
loadTodayIntake();
|
|
536
714
|
setBadge('sessions', working.length > 0);
|
|
@@ -563,11 +741,94 @@
|
|
|
563
741
|
'<span class="rec-copy"><strong>Recording now</strong><span>' + esc(rec.source + (rec.startTime ? ', ' + span(rec.startTime) + ' so far' : '')) + '</span></span>' +
|
|
564
742
|
'<span class="btn small">Live view</span></button>';
|
|
565
743
|
}
|
|
744
|
+
// ── The Today summary (6.99.26 candidate) ───────────────
|
|
745
|
+
// "The orchestrator's eyes" (Miles, 11/07): two or three sentences on what the sessions add up to, written by Haiku on
|
|
746
|
+
// the server (lib/today-brief.ts). Today sends what it already read: session titles, states, how long, each session's
|
|
747
|
+
// latest line and what needs you. The server decides whether a new one is written (off unless switched on, only when
|
|
748
|
+
// the inputs changed, 10 minutes apart, 60 a day, a breaker). Today never waits for it: the counts line paints at once
|
|
749
|
+
// and the summary replaces it when one arrives. Never shown over a session list that was not read.
|
|
750
|
+
var BRIEF = { view: null, inflight: false, sentKey: '', sentAt: 0 };
|
|
751
|
+
// 6.99.26 QA: the same rows are asked about again only after five minutes (a Today that sits open still learns what
|
|
752
|
+
// the server reads itself: sub-agents and Work cards); changed rows are asked about at once, one ask at a time.
|
|
753
|
+
var BRIEF_RESEND_MS = 5 * 60000, BRIEF_OFF_RECHECK_MS = 5 * 60000, BRIEF_TICK_MS = 60000;
|
|
754
|
+
function briefMinutes(v) { var d = toDate(v); return d ? Math.max(0, Math.round((Date.now() - d.getTime()) / 60000)) : 0; }
|
|
755
|
+
function briefInputs(m) {
|
|
756
|
+
var sessions = [];
|
|
757
|
+
(state.sessions || []).forEach(function (s) {
|
|
758
|
+
if (sxKeepWarm(s)) return;
|
|
759
|
+
var kind = sxState(s) === 'waiting' ? 'waiting' : s.running_active ? 'working' : s.running ? 'open' : '';
|
|
760
|
+
if (!kind) return; // only what Today counts: waiting, working and open
|
|
761
|
+
var k = splitSessionId(s.provider, s.session_id || s.id);
|
|
762
|
+
sessions.push({ title: sessionTitle(s), provider: k.provider, state: kind,
|
|
763
|
+
minutes: briefMinutes(kind === 'open' ? (s.last_activity_at || s.state_since) : (s.state_since || s.last_activity_at)),
|
|
764
|
+
line: String(s.discussion_summary || '').slice(0, 400), waiting: kind === 'waiting' ? askedWhy(s) : '',
|
|
765
|
+
// Only so the server can read this session's sub-agents (names, tasks, status); it never reaches the model.
|
|
766
|
+
key: k.provider + '|' + k.id,
|
|
767
|
+
agents: s.subagent_activity ? { active: s.subagent_activity.active, completed: s.subagent_activity.completed, total: s.subagent_activity.total } : undefined });
|
|
768
|
+
});
|
|
769
|
+
var needs = m.rows.filter(function (r) { return r.kind !== 'session'; }).slice(0, 12).map(function (r) {
|
|
770
|
+
if (r.kind === 'work') return { title: r.item.sessionTitle || r.item.title || 'Handoff', why: needWhy(r.item) };
|
|
771
|
+
return { title: r.title, why: r.why || '' };
|
|
772
|
+
});
|
|
773
|
+
// Today folds all but waiting sessions under "can wait until tomorrow" in quiet hours; the summary says the same.
|
|
774
|
+
var later = m.later.filter(function (r) { return r.kind !== 'session'; }).length;
|
|
775
|
+
return { sessions: sessions.slice(0, 40), needs: needs, later: Math.min(later, needs.length), sessionsRead: TODAY.sessionsState === 'answered', needsComplete: !!m.settled,
|
|
776
|
+
unread: m.failed.slice(), recording: !!TODAY.recording };
|
|
777
|
+
}
|
|
778
|
+
// Asked once every source answered or failed: at once when what Today read changed, else at most every five minutes
|
|
779
|
+
// (the server gates the spend; this only saves requests). When the switch is off, every five minutes.
|
|
780
|
+
function requestBrief(m) {
|
|
781
|
+
if (BRIEF.inflight || state.pane !== 'today') return; // renderStory already answered an offline Today
|
|
782
|
+
if (Object.keys(m.states).some(function (k) { return m.states[k] === 'pending'; })) return;
|
|
783
|
+
var body = briefInputs(m);
|
|
784
|
+
var key = JSON.stringify([body.sessions.map(function (s) { return [s.title, s.state, s.line, s.waiting, s.agents]; }), body.needs, body.later, body.sessionsRead, body.needsComplete, body.unread, body.recording]);
|
|
785
|
+
var since = Date.now() - BRIEF.sentAt;
|
|
786
|
+
if (BRIEF.view && BRIEF.view.state === 'off' ? since < BRIEF_OFF_RECHECK_MS : key === BRIEF.sentKey && since < BRIEF_RESEND_MS) return;
|
|
787
|
+
BRIEF.inflight = true; BRIEF.sentKey = key; BRIEF.sentAt = Date.now();
|
|
788
|
+
api('/api/today/brief', { method: 'POST', body: body, timeoutMs: 45000 }).then(function (v) {
|
|
789
|
+
BRIEF.view = v && typeof v === 'object' ? v : null;
|
|
790
|
+
}, function () { /* keep what was shown: the counts line or the last summary */ }).then(function () {
|
|
791
|
+
BRIEF.inflight = false;
|
|
792
|
+
if (TODAY.model && state.pane === 'today') renderStory(TODAY.model);
|
|
793
|
+
});
|
|
794
|
+
}
|
|
795
|
+
/** The summary to show, or null: only with text, only over a session list that was read. */
|
|
796
|
+
function briefShown() {
|
|
797
|
+
var v = BRIEF.view;
|
|
798
|
+
if (!v || !v.text || (v.state !== 'fresh' && v.state !== 'stale') || TODAY.sessionsState !== 'answered') return null;
|
|
799
|
+
return v;
|
|
800
|
+
}
|
|
801
|
+
// Forget the summary (Settings turned it on or off): the next Today paint asks at once.
|
|
802
|
+
function resetBrief() { BRIEF.view = null; BRIEF.sentKey = ''; BRIEF.sentAt = 0; }
|
|
803
|
+
// Every minute while Today is on screen: "Updated N min ago" moves on, and an unchanged Today asks again once its
|
|
804
|
+
// five minutes are up (requestBrief's own throttle). Hidden or another pane: nothing.
|
|
805
|
+
setInterval(function () {
|
|
806
|
+
if (document.hidden || state.pane !== 'today' || !TODAY.model || TODAY.offline) return;
|
|
807
|
+
requestBrief(TODAY.model);
|
|
808
|
+
var v = briefShown(), when = document.querySelector('#today-story .story-when');
|
|
809
|
+
if (v && when) when.textContent = briefWhen(v, TODAY.model);
|
|
810
|
+
}, BRIEF_TICK_MS);
|
|
811
|
+
function briefWhen(v, m) {
|
|
812
|
+
var mins = briefMinutes(v.at);
|
|
813
|
+
var out = 'Updated ' + (mins < 1 ? 'just now' : span(v.at) + ' ago');
|
|
814
|
+
// Stale says so once it is five minutes old: a summary from a minute ago over one new reply is still the picture.
|
|
815
|
+
if (v.state === 'stale' && mins >= 5) out += '. Things have moved since';
|
|
816
|
+
if (m.failed.length) out += '. Couldn’t read ' + listWords(m.failed) + ', so this may not be everything';
|
|
817
|
+
return out + '.';
|
|
818
|
+
}
|
|
819
|
+
|
|
566
820
|
// The headline: COS Control's brief (Models.swift TodayHome.brief) over the same rows Needs you lists.
|
|
567
821
|
function renderStory(m) {
|
|
568
822
|
var el = $('today-story');
|
|
569
823
|
el.className = 'story';
|
|
570
824
|
if (TODAY.offline) { el.textContent = 'COS on ' + state.serverName + ' is not answering right now. Today fills in when it does.'; return; }
|
|
825
|
+
// The model's summary when there is one (it never blocks: the counts line below shows until it lands). The test
|
|
826
|
+
// harnesses that run this block alone have no requestBrief, so it is looked up, never assumed.
|
|
827
|
+
if (typeof requestBrief === 'function') {
|
|
828
|
+
requestBrief(m);
|
|
829
|
+
var brief = briefShown();
|
|
830
|
+
if (brief) { el.innerHTML = esc(brief.text) + '<span class="story-when">' + esc(briefWhen(brief, m)) + '</span>'; return; }
|
|
831
|
+
}
|
|
571
832
|
var first = '';
|
|
572
833
|
if (m.sessionsWaiting > 0) first = countWord(m.sessionsWaiting) + ' ' + plural(m.sessionsWaiting, 'session is', 'sessions are') + ' waiting on you';
|
|
573
834
|
else if (m.now.length > 0) first = countWord(m.now.length) + ' ' + plural(m.now.length, 'thing needs', 'things need') + ' you' + (m.settled ? '' : ' so far');
|
|
@@ -658,6 +919,29 @@
|
|
|
658
919
|
if (s.waiting_kind === 'question' && d) return d.length > 160 ? d.slice(0, 159) + '…' : d;
|
|
659
920
|
return 'Waiting for you' + (sxProvider(s.provider) === 'claude' ? '. Paused until you answer.' : '');
|
|
660
921
|
}
|
|
922
|
+
// 6.99.26: where a waiting session's question can be answered. 'here': this page holds it (panes/questions.js), as
|
|
923
|
+
// cards in its waiting sheet. 'mac': a Claude session's own prompt (or a held one that went back to the Mac), which
|
|
924
|
+
// only the Mac can answer, in person or through Screens. 'answered': answered on some screen in the last two minutes,
|
|
925
|
+
// the list not caught up yet. '': a reply answers it (a Codex or Cursor session).
|
|
926
|
+
function sessionQs() { var q = window.COSWeb && window.COSWeb.sessionQuestions; return q && q.itemsFor ? q : null; }
|
|
927
|
+
function askedHere(items) {
|
|
928
|
+
var it = items[0];
|
|
929
|
+
if (!it) return '';
|
|
930
|
+
if (it.kind === 'approval') return it.approval && it.approval.summary ? 'Wants to run ' + clipWords(it.approval.summary, 80) : '';
|
|
931
|
+
var q = (it.questions || [])[0];
|
|
932
|
+
return q && q.question ? clipWords(q.question, 160) : '';
|
|
933
|
+
}
|
|
934
|
+
function clipWords(text, n) { var t = String(text || '').replace(/\s+/g, ' ').trim(); return t.length > n ? t.slice(0, n - 1) + '…' : t; }
|
|
935
|
+
function whereFor(provider, id) {
|
|
936
|
+
var q = sessionQs();
|
|
937
|
+
var held = q ? q.itemsFor(id) : [], mac = q && q.atMacFor ? q.atMacFor(id) : [];
|
|
938
|
+
if (held.length) return { where: 'here', ask: askedHere(held) };
|
|
939
|
+
if (q && q.answeredFor && q.answeredFor(id)) return { where: 'answered', ask: '' };
|
|
940
|
+
var words = mac.length && q.wordsOf ? q.wordsOf(mac[0].id) : null;
|
|
941
|
+
var ask = words ? clipWords(words.questions[0] || (words.summary ? 'Wants to run ' + words.summary : ''), 160) : '';
|
|
942
|
+
if (mac.length || sxProvider(provider) === 'claude') return { where: 'mac', ask: ask };
|
|
943
|
+
return { where: '', ask: '' };
|
|
944
|
+
}
|
|
661
945
|
function needsModel() {
|
|
662
946
|
var states = { sessions: TODAY.sessionsState, work: TODAY.workState, voices: TODAY.voices.state, memories: TODAY.memories.state };
|
|
663
947
|
var rows = [], seen = {};
|
|
@@ -665,7 +949,19 @@
|
|
|
665
949
|
if (sxKeepWarm(s) || sxState(s) !== 'waiting') return;
|
|
666
950
|
var k = splitSessionId(s.provider, s.session_id || s.id);
|
|
667
951
|
seen[k.provider + '|' + String(k.id).toLowerCase()] = 1;
|
|
668
|
-
|
|
952
|
+
var w = whereFor(k.provider, k.id);
|
|
953
|
+
rows.push({ kind: 'session', tier: 1, title: askedTitle(s), why: askedWhy(s), since: s.state_since || s.last_activity_at, key: k.provider + '|' + k.id, where: w.where, ask: w.ask });
|
|
954
|
+
});
|
|
955
|
+
// A question this page holds for a session the list has not shown waiting yet: its own row, so every held
|
|
956
|
+
// question is in front of you (two at once each get one).
|
|
957
|
+
var qs = sessionQs();
|
|
958
|
+
(qs ? qs.itemsFor('') : []).forEach(function (it) {
|
|
959
|
+
var k = splitSessionId(it.provider, it.sessionId), sk = k.provider + '|' + String(k.id).toLowerCase();
|
|
960
|
+
if (!k.id || seen[sk]) return;
|
|
961
|
+
seen[sk] = 1;
|
|
962
|
+
var s = (state.sessions || []).filter(function (x) { var xk = splitSessionId(x.provider, x.session_id || x.id); return xk.provider === k.provider && String(xk.id).toLowerCase() === String(k.id).toLowerCase(); })[0];
|
|
963
|
+
var title = s ? askedTitle(s) : it.kind === 'approval' ? 'A session wants your permission' : 'A session asked you a question';
|
|
964
|
+
rows.push({ kind: 'session', tier: 1, title: title, why: 'Waiting for you. Paused until you answer.', since: it.createdAt, key: k.provider + '|' + k.id, where: 'here', ask: askedHere([it]) });
|
|
669
965
|
});
|
|
670
966
|
(TODAY.needs || []).forEach(function (it) {
|
|
671
967
|
var k = splitSessionId(it.provider, it.sessionId);
|
|
@@ -721,9 +1017,11 @@
|
|
|
721
1017
|
(k.id ? '<div class="need-actions"><button type="button" class="btn small' + (i === 0 ? ' primary' : '') + '" data-session="' + esc(k.provider) + '|' + esc(k.id) + '">Open session</button></div>' : '') + '</div>';
|
|
722
1018
|
}
|
|
723
1019
|
if (r.kind === 'session') {
|
|
724
|
-
// The session's question shows in the overlay's peek, with its cards to answer.
|
|
1020
|
+
// The session's question shows in the overlay's peek, with its cards to answer. Under it, where it can be answered.
|
|
1021
|
+
var q = sessionQs(), whereLine = r.where === 'here' ? 'Answer it here.' : r.where === 'mac' ? (q && q.macLine) || '' : r.where === 'answered' ? (q && q.answeredLine) || '' : '';
|
|
725
1022
|
return '<div class="need-row">' +
|
|
726
|
-
'<button type="button" class="need-open" data-today="wait" data-key="' + esc(r.key) + '">' + top(r.title) + '<span class="need-foot">' + esc(r.why) + '</span
|
|
1023
|
+
'<button type="button" class="need-open" data-today="wait" data-key="' + esc(r.key) + '">' + top(r.title) + '<span class="need-foot">' + esc(r.ask || r.why) + '</span>' +
|
|
1024
|
+
(whereLine ? '<span class="need-foot need-where" data-where="' + esc(r.where) + '">' + esc(whereLine) + '</span>' : '') + '</button>' +
|
|
727
1025
|
'<div class="need-actions"><button type="button" class="btn small' + (i === 0 ? ' primary' : '') + '" data-session="' + esc(r.key) + '">Open session</button></div></div>';
|
|
728
1026
|
}
|
|
729
1027
|
var go2 = r.kind === 'voices' ? 'speakers' : 'memories/view/review', label = r.kind === 'voices' ? 'Name' : 'Review';
|
|
@@ -739,18 +1037,32 @@
|
|
|
739
1037
|
var html = visible.map(needRowHtml).join('');
|
|
740
1038
|
if (shown.length > visible.length) html += '<button type="button" class="need-more" data-today-needs="all">Show ' + (shown.length - visible.length) + ' more</button>';
|
|
741
1039
|
if (m.later.length && !TODAY.showLater) html += '<button type="button" class="need-more" data-today-needs="later">' + esc(m.later.length + (m.later.length === 1 ? ' thing can' : ' things can') + ' wait until tomorrow. Show') + '</button>';
|
|
742
|
-
|
|
743
|
-
|
|
1040
|
+
// 6.99.30 (design QA): no empty line above a failed read (it left a 45 px gap), and one retry row, never one per
|
|
1041
|
+
// source: with the Mac not answering (sessions and Work both unread, or more than two sources) it says so once.
|
|
1042
|
+
if (!m.rows.length) html = m.failed.length ? '' : '<p class="card-empty">' + esc(m.settled ? 'Nothing is waiting on you.' : 'Reading sessions, Work, voices and memories.') + '</p>';
|
|
1043
|
+
if (m.failed.length) {
|
|
1044
|
+
var silent = TODAY.offline || m.failed.length > 2;
|
|
1045
|
+
html += '<button type="button" class="need-more need-failed" data-today-needs="retry">' + esc(silent ? 'Your Mac is not answering. Try again.' : 'Couldn’t read ' + listWords(m.failed) + '. Try again') + '</button>';
|
|
1046
|
+
}
|
|
744
1047
|
list.innerHTML = html;
|
|
745
1048
|
}
|
|
1049
|
+
// Weather sits under the date at the top left (Miles, 11/07), where the eye starts, not inside Next up.
|
|
1050
|
+
// It shows whether or not a meeting is next.
|
|
1051
|
+
function renderWeather(w) {
|
|
1052
|
+
var el = $('today-weather'); if (!el) return;
|
|
1053
|
+
var x = w && w.weather;
|
|
1054
|
+
if (!x || !x.temp) { el.classList.add('hidden'); el.textContent = ''; return; }
|
|
1055
|
+
var desc = String(x.desc || '').toLowerCase(), place = x.location ? String(x.location).split(',')[0] : '';
|
|
1056
|
+
el.textContent = x.temp + (desc ? ' and ' + desc : '') + (place ? ' in ' + place : '');
|
|
1057
|
+
el.classList.remove('hidden');
|
|
1058
|
+
}
|
|
746
1059
|
function renderNext(w) {
|
|
747
1060
|
var card = $('next-card');
|
|
748
1061
|
var n = w && w.nextEvent; var d = n && toDate(n.time);
|
|
749
1062
|
if (!d || !n.title) { card.classList.add('hidden'); return; }
|
|
750
|
-
var weather = w.weather && w.weather.temp ? w.weather.temp + ' and ' + String(w.weather.desc || '').toLowerCase() + (w.weather.location ? ' in ' + w.weather.location.split(',')[0] : '') : '';
|
|
751
1063
|
var dw = dayWord(d);
|
|
752
1064
|
$('next-body').innerHTML = '<span class="when">' + esc(clock(d)) + '</span><span><span class="what">' + esc(n.title) + '</span>' +
|
|
753
|
-
'<span class="sub" style="display:block">' + esc(
|
|
1065
|
+
'<span class="sub" style="display:block">' + esc(dw.charAt(0).toUpperCase() + dw.slice(1)) + '</span></span>';
|
|
754
1066
|
// Control's Next up (todayPrepButtons): Prep me opens the Prep composer in the COS chat (panes/cos-chat.js prepFor),
|
|
755
1067
|
// Open meeting opens Meetings. Without a server to answer, Prep me is not offered.
|
|
756
1068
|
var prep = !TODAY.offline && window.COSWeb && window.COSWeb.cosChat && window.COSWeb.cosChat.prepFor;
|
|
@@ -800,8 +1112,8 @@
|
|
|
800
1112
|
}).join('') + '<p class="intake-foot">Moves to Work at midnight.</p>';
|
|
801
1113
|
card.classList.remove('hidden');
|
|
802
1114
|
}
|
|
803
|
-
function openTodayAction(t) {
|
|
804
|
-
var kind = t.getAttribute('data-today'), key = t.getAttribute('data-key') || '';
|
|
1115
|
+
function openTodayAction(t, kindIn, keyIn) {
|
|
1116
|
+
var kind = kindIn || t.getAttribute('data-today'), key = keyIn != null ? keyIn : t.getAttribute('data-key') || '';
|
|
805
1117
|
ensurePane('todayActions', 4000).then(function (m) {
|
|
806
1118
|
if (!m || !m.open) throw new Error('The Today overlay did not start.');
|
|
807
1119
|
m.open(kind, key, t);
|
|
@@ -811,9 +1123,9 @@
|
|
|
811
1123
|
// history entry, which the fallback replaces, so Back from there still lands on Today.
|
|
812
1124
|
var s = String(kind === 'run' || kind === 'wait' ? key : t.getAttribute('data-session-key') || '').split('|');
|
|
813
1125
|
var session = (kind === 'run' || kind === 'wait' || kind === 'need') && !!s[1];
|
|
814
|
-
var dest = session ? ['sessions', s[0], s.slice(1).join('|')] : ['work'].concat(kind === 'need' ? [] : ['sort']);
|
|
1126
|
+
var dest = session ? ['sessions', s[0], s.slice(1).join('|')] : kind === 'pet' ? ['sessions'] : ['work'].concat(kind === 'need' ? [] : ['sort']);
|
|
815
1127
|
console.error('[today] the overlay did not open (' + kind + '); opening ' + dest.join('/') + ' instead.', err);
|
|
816
|
-
toast(session ? 'Could not open that here. Opening the session.' : 'Could not open that here. Opening it in Work.');
|
|
1128
|
+
toast(session ? 'Could not open that here. Opening the session.' : kind === 'pet' ? 'Could not open that here. Opening Sessions.' : 'Could not open that here. Opening it in Work.');
|
|
817
1129
|
if (history.state && history.state.cosTodayOverlay) location.replace('#/' + dest.map(encodeURIComponent).join('/'));
|
|
818
1130
|
else go(dest[0], dest.slice(1));
|
|
819
1131
|
});
|
|
@@ -1059,6 +1371,7 @@
|
|
|
1059
1371
|
var meta = sxBadge(key.provider) +
|
|
1060
1372
|
(s.pinned ? '<span class="sx-pinned">PINNED</span>' : '') +
|
|
1061
1373
|
(label ? '<span class="sx-chip ' + st + '">' + esc(label) + '</span>' : '') +
|
|
1374
|
+
(notSentFor(key.provider, key.id) ? '<span class="sx-chip refused" title="' + esc(notSentFor(key.provider, key.id).line) + '">Not sent</span>' : '') +
|
|
1062
1375
|
(upd ? '<span class="sx-when">Updated ' + esc(sxShortDate(upd)) + '</span>' : '') +
|
|
1063
1376
|
(ambiguous[title] && opened ? '<span class="sx-opened" title="Another session on screen has the same name. This one was opened ' + esc(sxShortDate(opened)) + '.">Opened ' + esc(sxShortDate(opened)) + '</span>' : '') +
|
|
1064
1377
|
(s.project && String(s.project) !== title ? '<span class="sx-ws">' + esc(s.project) + '</span>' : '');
|
|
@@ -1335,18 +1648,49 @@
|
|
|
1335
1648
|
}
|
|
1336
1649
|
function sessionPath(provider, id, tail) { return '/api/agent-sessions/' + encodeURIComponent(provider) + '/' + encodeURIComponent(id) + (tail || ''); }
|
|
1337
1650
|
|
|
1651
|
+
// 6.99.30 (QA, held from Codex for .30): a queued message the server refused or let expire is said where the person
|
|
1652
|
+
// looks, not only in the Sessions reader: a "Not sent" line in Today's sheet for that session and a chip on its row in
|
|
1653
|
+
// the Sessions list. What the server's queue said last, per session, in this browser for a day; each read of the
|
|
1654
|
+
// queue replaces it, so a row the server drops clears the mark.
|
|
1655
|
+
var NOT_SENT_KEY = 'cos_web_not_sent', NOT_SENT_KEEP_MS = 86400000;
|
|
1656
|
+
function notSentRows(rows) { return (rows || []).filter(function (t) { return t && (t.status === 'refused' || t.status === 'expired'); }); }
|
|
1657
|
+
function notSentLine(t) {
|
|
1658
|
+
var why = String((t && t.reasonCopy) || (t && t.status === 'expired' ? 'the thread stayed busy too long.' : 'the session refused it.')).trim();
|
|
1659
|
+
return /^not sent\b/i.test(why) ? why : 'Not sent: ' + why.charAt(0).toLowerCase() + why.slice(1);
|
|
1660
|
+
}
|
|
1661
|
+
function noteNotSent(provider, id, rows) {
|
|
1662
|
+
var all = readStore(NOT_SENT_KEY), k = provider + '|' + id, now = Date.now(), list = notSentRows(rows);
|
|
1663
|
+
Object.keys(all).forEach(function (x) { if (!all[x] || now - (Number(all[x].at) || 0) > NOT_SENT_KEEP_MS) delete all[x]; });
|
|
1664
|
+
if (list.length) all[k] = { n: list.length, line: notSentLine(list[list.length - 1]).slice(0, 240), at: now }; else delete all[k];
|
|
1665
|
+
writeStore(NOT_SENT_KEY, all);
|
|
1666
|
+
}
|
|
1667
|
+
function notSentFor(provider, id) {
|
|
1668
|
+
var v = readStore(NOT_SENT_KEY)[provider + '|' + id];
|
|
1669
|
+
return v && Date.now() - (Number(v.at) || 0) <= NOT_SENT_KEEP_MS ? v : null;
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1338
1672
|
// Like api(), but a 4xx refusal comes back as { status, body } instead of throwing, because
|
|
1339
1673
|
// these routes answer "busy", "changed" or "not now" with a body the person should read.
|
|
1674
|
+
// A timeout here stays the caller's (an abort: "may have saved", a probe that gives up), never a down mark; api()
|
|
1675
|
+
// reads and the question poll are what say a hung Mac is not answering (6.99.32).
|
|
1340
1676
|
function apiAnswer(path, opts) {
|
|
1341
1677
|
var o = opts || {};
|
|
1342
1678
|
var ctrl = typeof AbortController === 'function' ? new AbortController() : null;
|
|
1343
1679
|
var timer = ctrl && o.timeoutMs ? setTimeout(function () { ctrl.abort(); }, o.timeoutMs) : null;
|
|
1344
1680
|
var headers = { 'X-COS-Web': '1' };
|
|
1345
1681
|
if (o.body !== undefined) headers['Content-Type'] = 'application/json';
|
|
1682
|
+
var sentAt = Date.now();
|
|
1346
1683
|
return fetch(path, { method: o.method || 'GET', headers: headers, credentials: 'same-origin', body: o.body !== undefined ? JSON.stringify(o.body) : undefined, signal: ctrl ? ctrl.signal : undefined })
|
|
1347
1684
|
.then(function (res) {
|
|
1685
|
+
sxNoteClock(res, sentAt);
|
|
1686
|
+
noteServer(true);
|
|
1348
1687
|
if (res.status === 401) { showAuth(); throw new Error('Signed out. Connect this browser again.'); }
|
|
1349
1688
|
return res.text().then(function (t) { var b = {}; try { b = t ? JSON.parse(t) : {}; } catch (_e) { b = {}; } return { status: res.status, body: b || {} }; });
|
|
1689
|
+
}, function (err) {
|
|
1690
|
+
// A timeout this call asked for stays an abort (its callers say "may have saved"); the rest never reached the Mac.
|
|
1691
|
+
if (err && err.name === 'AbortError') throw err;
|
|
1692
|
+
noteServer(false);
|
|
1693
|
+
throw unreachableError(err);
|
|
1350
1694
|
})
|
|
1351
1695
|
.finally(function () { if (timer) clearTimeout(timer); });
|
|
1352
1696
|
}
|
|
@@ -1358,7 +1702,11 @@
|
|
|
1358
1702
|
function writeStore(key, v) { try { localStorage.setItem(key, JSON.stringify(v)); } catch (_e) {} }
|
|
1359
1703
|
function saveHeld(r) {
|
|
1360
1704
|
var all = readStore(HELD_KEY), k = r.provider + '|' + r.id;
|
|
1361
|
-
var keep = r.held.filter(function (h) { return !h.cancelled; }).map(function (h) {
|
|
1705
|
+
var keep = r.held.filter(function (h) { return !h.cancelled; }).map(function (h) {
|
|
1706
|
+
var o = { text: h.text, key: h.key, since: h.since };
|
|
1707
|
+
if (h.ids && h.ids.length) { o.ids = h.ids; if (h.files && h.files.length) o.files = h.files; }
|
|
1708
|
+
return o;
|
|
1709
|
+
});
|
|
1362
1710
|
if (keep.length) all[k] = keep; else delete all[k];
|
|
1363
1711
|
writeStore(HELD_KEY, all);
|
|
1364
1712
|
}
|
|
@@ -1393,6 +1741,7 @@
|
|
|
1393
1741
|
function openSession(provider, id) {
|
|
1394
1742
|
var reader = $('session-reader');
|
|
1395
1743
|
if (!provider || !id) return;
|
|
1744
|
+
readerImagesStop($('session-turns'));
|
|
1396
1745
|
if (state.stream) { state.stream.close(); state.stream = null; }
|
|
1397
1746
|
clearInterval(state.turnPoll);
|
|
1398
1747
|
if (state.reading) { clearInterval(state.reading.heldTimer); clearInterval(state.reading.feedTimer); }
|
|
@@ -1422,15 +1771,21 @@
|
|
|
1422
1771
|
'<p class="sx-copy-note hidden" id="session-copy-note" role="status"></p>' +
|
|
1423
1772
|
'<div class="sx-chat">' +
|
|
1424
1773
|
'<div id="session-verdict"></div>' +
|
|
1425
|
-
'<form class="composer" id="session-form" data-provider="' + esc(provider) + '" data-id="' + esc(id) + '">' +
|
|
1774
|
+
'<form class="composer att-composer" id="session-form" data-provider="' + esc(provider) + '" data-id="' + esc(id) + '">' +
|
|
1775
|
+
'<div class="cc-chips att-files hidden" id="session-files"></div>' +
|
|
1426
1776
|
'<textarea id="session-input" rows="1" placeholder="Continue this session…" enterkeyhint="send" aria-label="Continue this session"></textarea>' +
|
|
1427
|
-
'<div class="composer-actions"><button type="button" class="btn small" data-action="fork-session" title="Copy this session and send the message to the copy">Fork</button>' +
|
|
1428
|
-
'<button type="submit" class="btn primary small">Send</button></div>' +
|
|
1777
|
+
'<div class="composer-actions"><span id="session-clip" class="att-clip"></span><button type="button" class="btn small" data-action="fork-session" title="Copy this session and send the message to the copy">Fork</button>' +
|
|
1778
|
+
'<button type="submit" class="btn primary small" id="session-send">Send</button></div>' +
|
|
1429
1779
|
'</form><div class="status-line" id="session-status"></div>' +
|
|
1430
1780
|
'<p class="sx-caution hidden" id="session-caution"></p>' +
|
|
1431
1781
|
'<div id="session-queue"></div>' +
|
|
1432
1782
|
'</div>';
|
|
1433
|
-
|
|
1783
|
+
readerImagesMount($('session-turns'), r);
|
|
1784
|
+
sxNoteList(r, d);
|
|
1785
|
+
setLive({});
|
|
1786
|
+
paintReaderFiles();
|
|
1787
|
+
// The Mac's answer about files may not be in yet: ask once, then draw the clip.
|
|
1788
|
+
var kitR = readerKit(); if (kitR && !kitR.turnFiles()) kitR.ensureCaps().then(paintReaderFiles, function () {});
|
|
1434
1789
|
renderHeld(state.reading);
|
|
1435
1790
|
loadQueue(provider, id);
|
|
1436
1791
|
sxProbeAttach(r);
|
|
@@ -1449,20 +1804,30 @@
|
|
|
1449
1804
|
// ACTIVITY (ActivityWindow.swift SessionActivityFeed 6800-6890, SessionLiveFeed.swift): the state
|
|
1450
1805
|
// word with its clock, the current prompt, the last eight tool lines, and the honest counters.
|
|
1451
1806
|
// The reducer follows SessionLiveFeed.applying: a reseed (seq 1 after anything) starts over.
|
|
1452
|
-
function newFeed() { return { transport: 'idle', agentState: null, stateSince: null, waitingKind: null, waitingDetail: null, failure: null, lastReply: null, prompt: '', tools: [], lastSeq: 0, lastEventAt: null, missed: 0, connected: false, fallback: null }; }
|
|
1807
|
+
function newFeed() { return { transport: 'idle', transportSeenAt: null, agentState: null, stateSince: null, stateSeenAt: null, seenMemo: null, statusAt: null, waitingKind: null, waitingDetail: null, failure: null, lastReply: null, prompt: '', tools: [], lastSeq: 0, lastEventAt: null, missed: 0, connected: false, fallback: null }; }
|
|
1453
1808
|
function applyFeed(r, e) {
|
|
1454
1809
|
if (typeof e.seq !== 'number') return;
|
|
1455
1810
|
var f = r.feed;
|
|
1456
|
-
if (e.seq === 1 && f.lastSeq > 0) { var keep = f.connected; f = r.feed = newFeed(); f.connected = keep; }
|
|
1811
|
+
if (e.seq === 1 && f.lastSeq > 0) { var keep = f.connected, seenMemo = f.seenMemo; f = r.feed = newFeed(); f.connected = keep; f.seenMemo = seenMemo; }
|
|
1457
1812
|
else if (f.lastSeq > 0 && e.seq > f.lastSeq + 1) f.missed += e.seq - f.lastSeq - 1;
|
|
1458
1813
|
f.lastSeq = e.seq;
|
|
1459
1814
|
f.lastEventAt = typeof e.at === 'number' ? new Date(e.at) : new Date();
|
|
1460
1815
|
f.connected = true;
|
|
1461
1816
|
if (f.fallback === 'reconnecting') f.fallback = null;
|
|
1462
1817
|
if (e.kind === 'status') {
|
|
1463
|
-
|
|
1818
|
+
f.statusAt = f.lastEventAt;
|
|
1819
|
+
// When this page first saw each state (its own clock): the Stop's echo window is measured by it. The
|
|
1820
|
+
// memo outlives a reseed, so a reconnect that repeats a state seen before a Stop is not news.
|
|
1821
|
+
var memo = f.seenMemo || (f.seenMemo = {});
|
|
1822
|
+
if (e.state) {
|
|
1823
|
+
if (!memo.stream || memo.stream.key !== e.state) memo.stream = { key: e.state, seen: Date.now() };
|
|
1824
|
+
f.transport = e.state; f.transportSeenAt = memo.stream.seen;
|
|
1825
|
+
}
|
|
1464
1826
|
if (e.agent_state) {
|
|
1465
|
-
|
|
1827
|
+
var since = toDate(e.state_since), hookKey = e.agent_state + '|' + sxMs(since);
|
|
1828
|
+
if (!memo.hook || memo.hook.key !== hookKey) memo.hook = { key: hookKey, seen: Date.now() };
|
|
1829
|
+
f.stateSeenAt = memo.hook.seen;
|
|
1830
|
+
f.agentState = e.agent_state; f.stateSince = since;
|
|
1466
1831
|
f.waitingKind = e.waiting_kind || null; f.waitingDetail = e.waiting_detail || null; f.failure = e.failure || null;
|
|
1467
1832
|
if (e.last_reply) f.lastReply = e.last_reply;
|
|
1468
1833
|
}
|
|
@@ -1476,16 +1841,110 @@
|
|
|
1476
1841
|
// `other` is the stream's verb for every tool it has no verb for; its target names the tool ("Used SendMessage").
|
|
1477
1842
|
var SX_VERBS = { read: 'Read', edit: 'Edited', write: 'Edited', bash: 'Ran', run: 'Ran', search: 'Searched', grep: 'Searched', glob: 'Searched', fetch: 'Fetched', web: 'Fetched', agent: 'Delegated', task: 'Delegated', other: 'Used' };
|
|
1478
1843
|
function sxToolLine(t) { var v = String(t.verb || 'tool'); var w = SX_VERBS[v] || v.charAt(0).toUpperCase() + v.slice(1); return t.target ? w + ' ' + t.target : w; }
|
|
1479
|
-
//
|
|
1480
|
-
//
|
|
1844
|
+
// ONE STATE FOR THE SESSION CARD (6.99.29). Miles, 11/07 11:01: Today's session overlay showed
|
|
1845
|
+
// "Idle 40s" over "Working on: Yes install and proceed", beside "Working right now" and "The
|
|
1846
|
+
// session is busy" while the session ran its turn. Each piece read its own source: the badge the
|
|
1847
|
+
// hooks' derived state (which a turn delivered through the peer inbox does not move), the composer
|
|
1848
|
+
// the server's write gate, the send note the park answer, and a delivered live turn was read as a
|
|
1849
|
+
// finished one. Now every piece of the card (the badge word and clock, the prompt line's label,
|
|
1850
|
+
// Stop, the composer's park note and the reader's state line) reads sxTruth, and each signal says
|
|
1851
|
+
// what it saw and WHEN that became true; the freshest wins:
|
|
1852
|
+
// hook the stream's derived agent_state, since its state_since
|
|
1853
|
+
// stream the stream's own state, when no derived state rides it, as of that status line
|
|
1854
|
+
// list the session row or detail: running_active as of its last activity, else since state_since
|
|
1855
|
+
// gate the server's write gate: native_thread_working on the attachability probe, a refused
|
|
1856
|
+
// turn or a parked send, or a turn delivered live into the open session, as of the answer;
|
|
1857
|
+
// a later free answer, or a COS child's turn that finished, clears it
|
|
1858
|
+
// stop this page's Stop (and a finished COS child turn): idle as of then, and no working signal
|
|
1859
|
+
// that ARRIVED in the next SX_STOP_ECHO_MS counts (the stream's status trails a Stop). The
|
|
1860
|
+
// window is the page's own clock (`seen`, when this page observed each signal), never the
|
|
1861
|
+
// signal's server time, and a turn this page sends ends it (continueSession clears stoppedAt).
|
|
1862
|
+
// Waiting, failed and ended, from the hook or the session row, are the session's own words and stay:
|
|
1863
|
+
// a gate or stream that sees a busy file does not unseat a question (QA 6.99.29: the row and the
|
|
1864
|
+
// hook carry the same state_since, so the row's Waiting must hold as the hook's does). Signals with
|
|
1865
|
+
// the same time are ordered gate, stream, list, hook, stop: the hook's word wins a tie. Idle yields
|
|
1866
|
+
// only to working seen more than SX_IDLE_GRACE_MS after it, the server's rule for the flush that
|
|
1867
|
+
// trails a Stop (TRANSCRIPT_AFTER_IDLE_GRACE_MS).
|
|
1868
|
+
var SX_IDLE_GRACE_MS = 5000, SX_STOP_ECHO_MS = 8000, SX_GATE_RECHECK_MS = 20000, SX_PROBE_TIMEOUT_MS = 15000;
|
|
1869
|
+
var SX_TIE_RANK = { gate: 0, stream: 1, list: 2, hook: 3, stop: 4 };
|
|
1870
|
+
function sxMs(v) {
|
|
1871
|
+
var t = v instanceof Date ? v.getTime() : typeof v === 'number' ? v : typeof v === 'string' && v ? Date.parse(v) : NaN;
|
|
1872
|
+
return isFinite(t) ? t : null;
|
|
1873
|
+
}
|
|
1874
|
+
var SX_HOOK_WORD = { running: 'Working', waiting: 'Waiting', failed: 'Failed', ended: 'Done', idle: 'Idle' };
|
|
1875
|
+
function sxSignals(r) {
|
|
1876
|
+
var f = r.feed || {}, out = [];
|
|
1877
|
+
// `seen`: when this page observed the signal (its own clock), for the Stop's echo window.
|
|
1878
|
+
if (f.agentState && SX_HOOK_WORD[f.agentState]) out.push({ word: SX_HOOK_WORD[f.agentState], at: sxMs(f.stateSince) || sxMs(f.statusAt) || 0, src: 'hook', seen: sxMs(f.stateSeenAt) });
|
|
1879
|
+
else if (f.statusAt) out.push({ word: f.transport === 'working' ? 'Working' : f.transport === 'done' ? 'Done' : 'Idle', at: sxMs(f.statusAt), src: 'stream', seen: sxMs(f.transportSeenAt) });
|
|
1880
|
+
if (r.list && r.list.at != null) out.push({ word: r.list.word, at: r.list.at, src: 'list', seen: sxMs(r.list.seen) });
|
|
1881
|
+
// A gate answer and a Stop are this page's own moments: ranked on the Mac's clock (sxServerTime) against the Mac's
|
|
1882
|
+
// own times, and kept on this page's clock as `seen` for the echo window (6.99.30, QA: clock skew).
|
|
1883
|
+
if (r.gate && r.gate.busy && r.gate.at != null) out.push({ word: 'Working', at: sxServerTime(r.gate.at), src: 'gate', seen: r.gate.at });
|
|
1884
|
+
var stop = sxMs(r.stoppedAt);
|
|
1885
|
+
if (stop !== null) out.push({ word: 'Idle', at: sxServerTime(stop), src: 'stop', seen: stop });
|
|
1886
|
+
return out;
|
|
1887
|
+
}
|
|
1888
|
+
/** { word, at, src }: the one state the whole card shows. Null before any signal. */
|
|
1889
|
+
function sxTruth(r) {
|
|
1890
|
+
var stop = sxMs(r.stoppedAt);
|
|
1891
|
+
var all = sxSignals(r).sort(function (a, b) { return a.at - b.at || SX_TIE_RANK[a.src] - SX_TIE_RANK[b.src]; });
|
|
1892
|
+
var best = null;
|
|
1893
|
+
all.forEach(function (c) {
|
|
1894
|
+
if (!best) { best = c; return; }
|
|
1895
|
+
if (c.word === 'Working') {
|
|
1896
|
+
// The Stop's echo, judged by when this page saw the signal (a signal with no arrival time: by its own).
|
|
1897
|
+
var seen = c.seen != null ? c.seen : c.at;
|
|
1898
|
+
if (stop !== null && seen >= stop && seen - stop < SX_STOP_ECHO_MS) return;
|
|
1899
|
+
if (stop !== null && seen < stop) return;
|
|
1900
|
+
if ((best.src === 'hook' || best.src === 'list') && (c.src === 'gate' || c.src === 'stream') && /^(Waiting|Failed|Done)$/.test(best.word)) return;
|
|
1901
|
+
if (best.word === 'Idle' && best.src !== 'stop' && c.src !== best.src && c.at <= best.at + SX_IDLE_GRACE_MS) return;
|
|
1902
|
+
}
|
|
1903
|
+
best = c;
|
|
1904
|
+
});
|
|
1905
|
+
return best;
|
|
1906
|
+
}
|
|
1907
|
+
/** Settle the page's working flag from the one state; true when it changed. */
|
|
1908
|
+
function sxSettle(r) {
|
|
1909
|
+
var t = sxTruth(r), working = !!(t && t.word === 'Working');
|
|
1910
|
+
r.live = r.live || {};
|
|
1911
|
+
var was = !!r.live.working;
|
|
1912
|
+
r.live.working = working;
|
|
1913
|
+
r.live.waiting = !!(t && t.word === 'Waiting');
|
|
1914
|
+
if (!working) { r.live.step = null; r.live.prose = null; }
|
|
1915
|
+
return was !== working;
|
|
1916
|
+
}
|
|
1917
|
+
/** What the server's write gate said, and when. A free answer clears an earlier busy one. */
|
|
1918
|
+
function sxNoteGate(r, busy) { r.gate = { busy: !!busy, at: Date.now() }; }
|
|
1919
|
+
/** The list or detail row's view, timed by its own evidence. */
|
|
1920
|
+
function sxNoteList(r, row) {
|
|
1921
|
+
if (!row) return;
|
|
1922
|
+
var waiting = sxState(row) === 'waiting', working = !!row.running_active && !waiting;
|
|
1923
|
+
var at = working ? (sxMs(row.last_activity_at) || sxMs(row.state_since)) : (sxMs(row.state_since) || sxMs(row.last_activity_at));
|
|
1924
|
+
// A row with no time of its own is the oldest word: the live feed outranks it the moment it speaks.
|
|
1925
|
+
var word = working ? 'Working' : waiting ? 'Waiting' : 'Idle', was = r.list;
|
|
1926
|
+
// Arrival is the first time this page saw THIS word at THIS time: a re-read of the same row is not news.
|
|
1927
|
+
r.list = { word: word, at: at != null ? at : 0, seen: was && was.word === word && was.at === (at != null ? at : 0) && was.seen != null ? was.seen : Date.now() };
|
|
1928
|
+
}
|
|
1929
|
+
/** A gate busy answer that decides the card is re-asked while the card shows it: 20 s after the last attempt, and
|
|
1930
|
+
* each failed attempt doubles the wait, up to 16 times (QA 6.99.29: a failing probe was re-asked every second).
|
|
1931
|
+
* Every attempt is stamped (gate.triedAt) and ends within SX_PROBE_TIMEOUT_MS (6.99.30: before that a probe that
|
|
1932
|
+
* never answered left r.probing set, and the gate was never asked again). So a busy gate stands only while the
|
|
1933
|
+
* server keeps answering busy, or keeps not answering, and a fresher signal (a Stop, the hook going idle more than
|
|
1934
|
+
* the grace after it) still outranks it. `at` stays the time of the last ANSWER: a failure is no evidence of busy. */
|
|
1935
|
+
function sxGateStale(r) {
|
|
1936
|
+
var t = sxTruth(r);
|
|
1937
|
+
if (!(t && t.src === 'gate' && r.gate) || r.probing) return false;
|
|
1938
|
+
var last = Math.max(r.gate.at || 0, r.gate.triedAt || 0), fails = r.gate.failures || 0;
|
|
1939
|
+
return Date.now() - last > SX_GATE_RECHECK_MS * Math.min(Math.pow(2, fails), 16);
|
|
1940
|
+
}
|
|
1481
1941
|
function sxFeedWord(r) {
|
|
1482
|
-
var
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
return 'Idle';
|
|
1942
|
+
var t = sxTruth(r);
|
|
1943
|
+
return t ? t.word : 'Idle';
|
|
1944
|
+
}
|
|
1945
|
+
/** The prompt line: what the session works on, or what it was last asked. */
|
|
1946
|
+
function sxPromptLine(r, text) {
|
|
1947
|
+
return (sxFeedWord(r) === 'Working' ? 'Working on: ' : 'Last asked: ') + text;
|
|
1489
1948
|
}
|
|
1490
1949
|
function sxFeedDetail(f, word) {
|
|
1491
1950
|
if (word === 'Waiting' && f.agentState === 'waiting') {
|
|
@@ -1500,10 +1959,13 @@
|
|
|
1500
1959
|
if (word === 'Idle' && f.agentState === 'idle') { var reply = String(f.lastReply || '').trim(); return reply ? 'last reply: ' + reply : ''; }
|
|
1501
1960
|
return '';
|
|
1502
1961
|
}
|
|
1503
|
-
function sxElapsed(f) {
|
|
1504
|
-
var
|
|
1962
|
+
function sxElapsed(f, r) {
|
|
1963
|
+
var t = r ? sxTruth(r) : null;
|
|
1964
|
+
var since = t && t.at ? new Date(t.at) : f.stateSince || f.lastEventAt;
|
|
1505
1965
|
if (!since) return '';
|
|
1506
|
-
|
|
1966
|
+
// QA 6.99.30 (Skeptic): every moment here is on the Mac's clock (the truth's own time, or the Mac's state_since), so
|
|
1967
|
+
// now is too; a phone a minute behind read "0s" for a minute.
|
|
1968
|
+
var s = Math.max(0, Math.floor((sxServerTime(Date.now()) - since.getTime()) / 1000));
|
|
1507
1969
|
if (s < 60) return s + 's';
|
|
1508
1970
|
if (s < 3600) return Math.floor(s / 60) + 'm ' + (s % 60) + 's';
|
|
1509
1971
|
return Math.floor(s / 3600) + 'h ' + Math.floor((s % 3600) / 60) + 'm';
|
|
@@ -1513,6 +1975,7 @@
|
|
|
1513
1975
|
if (reason === 'http_503') return 'The server has no room for another live feed right now; showing the stored turns.';
|
|
1514
1976
|
if (reason === 'reconnecting') return 'Reconnecting to the live feed…';
|
|
1515
1977
|
if (reason === 'stream_lost') return 'The live feed stopped answering; showing the stored turns. Reopen the session to try again.';
|
|
1978
|
+
if (reason === 'unreachable') return 'Your Mac is not answering. This is the last state it sent.';
|
|
1516
1979
|
return 'Live feed unavailable (' + reason + '); showing the stored turns.';
|
|
1517
1980
|
}
|
|
1518
1981
|
// The block keeps one size whatever it holds (/qa: the turns below moved while Miles read, as tool
|
|
@@ -1533,7 +1996,7 @@
|
|
|
1533
1996
|
var word = sxFeedWord(r), detail = sxFeedDetail(f, word);
|
|
1534
1997
|
html += '<div class="sx-act-state"><span class="sx-dot ' + word.toLowerCase() + '" aria-hidden="true"></span><strong>' + word + '</strong>' +
|
|
1535
1998
|
'<span class="sx-act-detail">' + esc(detail) + '</span>' +
|
|
1536
|
-
'<span class="sx-act-clock" id="session-elapsed">' + esc(sxElapsed(f)) + '</span>' +
|
|
1999
|
+
'<span class="sx-act-clock" id="session-elapsed">' + esc(sxElapsed(f, r)) + '</span>' +
|
|
1537
2000
|
(live.working ? '<button type="button" class="cx-btn quiet sx-act-stop" data-action="stop-session">Stop</button>' : '') + '</div>' +
|
|
1538
2001
|
'<p class="sx-act-prompt">' + esc(f.prompt) + '</p>' +
|
|
1539
2002
|
'<div class="sx-act-tail">' + f.tools.map(function (t) { return '<div class="sx-act-tool">' + esc(sxToolLine(t)) + '</div>'; }).join('') +
|
|
@@ -1542,13 +2005,15 @@
|
|
|
1542
2005
|
html += '<p class="sx-act-fallback">' + esc(f.fallback ? sxFallbackLine(f.fallback) : 'Connecting to the live feed…') + '</p>';
|
|
1543
2006
|
}
|
|
1544
2007
|
box.innerHTML = html;
|
|
1545
|
-
// The clock ticks only while the session works or waits (Control 0.5.337).
|
|
2008
|
+
// The clock ticks only while the session works or waits (Control 0.5.337). A Working that only the write gate
|
|
2009
|
+
// says is re-asked even with no live feed (QA 6.99.29), so it cannot stand forever once the feed is lost.
|
|
1546
2010
|
clearInterval(r.feedTimer); r.feedTimer = null;
|
|
1547
|
-
var
|
|
1548
|
-
if ((f.connected || f.lastSeq > 0) && (w === 'Working' || w === 'Waiting')) {
|
|
2011
|
+
var t = sxTruth(r), w = t ? t.word : 'Idle';
|
|
2012
|
+
if (((f.connected || f.lastSeq > 0) && (w === 'Working' || w === 'Waiting')) || (t && t.src === 'gate' && w === 'Working')) {
|
|
1549
2013
|
r.feedTimer = setInterval(function () {
|
|
1550
2014
|
if (state.reading !== r) { clearInterval(r.feedTimer); return; }
|
|
1551
|
-
var el = $('session-elapsed'); if (el) el.textContent = sxElapsed(r.feed);
|
|
2015
|
+
var el = $('session-elapsed'); if (el) el.textContent = sxElapsed(r.feed, r);
|
|
2016
|
+
if (sxGateStale(r)) sxProbeAttach(r);
|
|
1552
2017
|
}, 1000);
|
|
1553
2018
|
}
|
|
1554
2019
|
}
|
|
@@ -1571,7 +2036,7 @@
|
|
|
1571
2036
|
var es;
|
|
1572
2037
|
try { es = new EventSource(sessionPath(provider, id, '/stream?seed=turn')); } catch (_e) { if (r0) { r0.feed.fallback = 'unsupported'; paintActivity(r0); } return; }
|
|
1573
2038
|
state.stream = es;
|
|
1574
|
-
var wasWorking = false;
|
|
2039
|
+
var wasWorking = false, wasWaiting = false;
|
|
1575
2040
|
es.onerror = function () {
|
|
1576
2041
|
var r = state.reading;
|
|
1577
2042
|
if (!r || r.id !== id || state.stream !== es) return;
|
|
@@ -1586,15 +2051,17 @@
|
|
|
1586
2051
|
if (!r || r.id !== id) return;
|
|
1587
2052
|
applyFeed(r, e);
|
|
1588
2053
|
if (e.kind === 'status') {
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
var waiting = e.state === 'waiting' || e.state === 'needs_input' || e.agent_state === 'waiting';
|
|
2054
|
+
// 6.99.29: the one state (sxTruth), never this line's own reading of itself.
|
|
2055
|
+
setLive({});
|
|
2056
|
+
var working = !!(r.live && r.live.working);
|
|
2057
|
+
var waiting = !working && (e.state === 'waiting' || e.state === 'needs_input' || e.agent_state === 'waiting');
|
|
1593
2058
|
var el = $('session-state');
|
|
1594
2059
|
if (el) el.textContent = working ? 'Working now' : waiting ? 'Waiting for you' : 'Open';
|
|
1595
|
-
setLive({ working: working });
|
|
1596
2060
|
if (wasWorking && !working) { refreshTurns(provider, id); replyEnded(r); }
|
|
1597
2061
|
wasWorking = working;
|
|
2062
|
+
// Gone to waiting: its question may be parked for this page now (6.99.27).
|
|
2063
|
+
if (waiting && !working && !wasWaiting) questionsNudge();
|
|
2064
|
+
wasWaiting = waiting && !working;
|
|
1598
2065
|
} else if (e.kind === 'tool') {
|
|
1599
2066
|
setLive({ step: [e.verb, e.target].filter(Boolean).join(' ') });
|
|
1600
2067
|
} else if (e.kind === 'prose' && e.text) {
|
|
@@ -1606,16 +2073,20 @@
|
|
|
1606
2073
|
}
|
|
1607
2074
|
function setLive(patch) {
|
|
1608
2075
|
var r = state.reading; if (!r) return;
|
|
1609
|
-
|
|
1610
|
-
|
|
2076
|
+
// The working flag is never set here: it is the one state's (sxSettle).
|
|
2077
|
+
var p = Object.assign({}, patch); delete p.working;
|
|
2078
|
+
r.live = Object.assign(r.live || {}, p);
|
|
2079
|
+
var moved = sxSettle(r);
|
|
1611
2080
|
paintActivity(r);
|
|
2081
|
+
if (moved) renderVerdict(r);
|
|
1612
2082
|
}
|
|
1613
2083
|
function refreshTurns(provider, id) {
|
|
2084
|
+
var reading = state.reading;
|
|
1614
2085
|
api(sessionPath(provider, id, '?turns=' + SX_TURNS)).then(function (d) {
|
|
1615
2086
|
var r = state.reading;
|
|
1616
|
-
if (!r || r.id !== id) return;
|
|
2087
|
+
if (!r || r !== reading || r.id !== id || r.provider !== provider) return;
|
|
1617
2088
|
var turns = Array.isArray(d && d.recent_turns) ? d.recent_turns : [];
|
|
1618
|
-
var box = $('session-turns'); if (box) sxMergeTurns(box, r.turns || [], turns, d);
|
|
2089
|
+
var box = $('session-turns'); if (box) { sxMergeTurns(box, r.turns || [], turns, d); readerImagesMount(box, r); }
|
|
1619
2090
|
// The header keeps the subtitle it opened with: a rewrap above the turns would move them.
|
|
1620
2091
|
r.detail = d; r.turns = turns; r.more = d.recent_turns_more === true;
|
|
1621
2092
|
renderHeld(r);
|
|
@@ -1638,13 +2109,21 @@
|
|
|
1638
2109
|
var box = $('session-queue'); if (!box || !r || r.id !== id) return;
|
|
1639
2110
|
var rows = (q.status === 200 && q.body && q.body.turns) || [];
|
|
1640
2111
|
var waiting = rows.filter(function (t) { return t.status === 'waiting' || t.status === 'queued' || t.status === 'pending'; });
|
|
2112
|
+
// QA 6.99.29: a queued message the server refused (or let expire) stays in view with the server's reason,
|
|
2113
|
+
// instead of vanishing from the line.
|
|
2114
|
+
var notSent = notSentRows(rows);
|
|
2115
|
+
if (q.status === 200) noteNotSent(provider, id, rows);
|
|
1641
2116
|
if (q.status === 200 && r.queued !== waiting.length) { r.queued = waiting.length; paintActivity(r); }
|
|
1642
|
-
if (!waiting.length) { box.innerHTML = ''; return; }
|
|
1643
|
-
box.innerHTML = '<div class="sx-queue"
|
|
2117
|
+
if (!waiting.length && !notSent.length) { box.innerHTML = ''; return; }
|
|
2118
|
+
box.innerHTML = '<div class="sx-queue">' + (waiting.length ? '<p class="sx-queue-head">' + (waiting.length === 1 ? '1 follow-up queued. It sends when this session is available.' : waiting.length + ' follow-ups queued. They send in order when this session is available.') + '</p>' : '') +
|
|
1644
2119
|
waiting.map(function (t) {
|
|
1645
2120
|
var mine = t.owner !== 'codex' && parkedByMe(r, t.clientTurnId);
|
|
1646
2121
|
return '<div class="sx-queue-row"><span class="sx-queue-state">' + esc(sxQueueLine(t)) + '</span><span class="sx-queue-text">' + esc(t.preview || t.prompt || 'Message') + '</span>' +
|
|
1647
2122
|
(mine ? '<button type="button" class="cx-btn quiet" data-action="unqueue" data-turn-key="' + esc(t.clientTurnId) + '" title="Cancel this queued message. It will not be sent.">Cancel</button>' : '') + '</div>';
|
|
2123
|
+
}).join('') +
|
|
2124
|
+
notSent.map(function (t) {
|
|
2125
|
+
return '<div class="sx-queue-row sx-queue-refused"><span class="sx-queue-state">Not sent</span><span class="sx-queue-text">' + esc(t.preview || t.prompt || 'Message') +
|
|
2126
|
+
'<span class="sx-queue-reason">' + esc(notSentLine(t)) + '</span></span></div>';
|
|
1648
2127
|
}).join('') + '</div>';
|
|
1649
2128
|
}).catch(function () {});
|
|
1650
2129
|
}
|
|
@@ -1654,21 +2133,33 @@
|
|
|
1654
2133
|
// and anything else is the server's refusal, word for word.
|
|
1655
2134
|
function sxProbeAttach(r) {
|
|
1656
2135
|
// Today's overlay probes its own session object (r.view): the same verdict, painted by its view.
|
|
1657
|
-
|
|
2136
|
+
r.probing = true;
|
|
2137
|
+
// Every attempt is stamped; a failed one backs the next off (sxGateStale). A gate answer resets both.
|
|
2138
|
+
if (r.gate) r.gate.triedAt = Date.now();
|
|
2139
|
+
var failed = function () { if (r.gate) r.gate.failures = (r.gate.failures || 0) + 1; };
|
|
2140
|
+
return apiAnswer(sessionPath(r.provider, r.id, '/attachability'), { timeoutMs: SX_PROBE_TIMEOUT_MS }).then(function (a) {
|
|
1658
2141
|
if (state.reading !== r && !r.view) return;
|
|
1659
|
-
if (a.status === 404) { r.verdict = { attachable: false, reason: 'route_absent', reasonCopy: 'Update the COS server to continue a thread from here.', ownerCount: 0 }; viewOf(r).verdict(r); return; }
|
|
2142
|
+
if (a.status === 404) { failed(); r.verdict = { attachable: false, reason: 'route_absent', reasonCopy: 'Update the COS server to continue a thread from here.', ownerCount: 0 }; viewOf(r).verdict(r); return; }
|
|
1660
2143
|
var b = a.body || {};
|
|
1661
|
-
if (a.status !== 200 || typeof b.attachable !== 'boolean') return;
|
|
2144
|
+
if (a.status !== 200 || typeof b.attachable !== 'boolean') { failed(); return; }
|
|
1662
2145
|
var owners = Number(b.ownerCount) || 0;
|
|
1663
2146
|
r.verdict = { attachable: b.attachable, reason: String(b.reason || ''), reasonCopy: String(b.reasonCopy || ''), ownerCount: owners, caution: b.attachable === true && owners > 0 };
|
|
1664
|
-
|
|
1665
|
-
|
|
2147
|
+
// 6.99.29: the gate's answer is a signal of the one state, as of now.
|
|
2148
|
+
sxNoteGate(r, b.attachable === false && b.reason === 'native_thread_working');
|
|
2149
|
+
sxRepaint(r);
|
|
2150
|
+
}).catch(failed).finally(function () { r.probing = false; });
|
|
1666
2151
|
}
|
|
1667
2152
|
var SX_PARK = {
|
|
1668
2153
|
cursor: 'Working right now. A message you send goes into this Cursor chat when its turn ends.',
|
|
1669
2154
|
codex: 'Working right now. A message you send joins the Codex app’s queue for this thread.',
|
|
1670
2155
|
claude: 'Working right now. A message you send waits and lands when this turn ends.'
|
|
1671
2156
|
};
|
|
2157
|
+
/** The composer's "Working right now" note: a busy gate verdict, and the one state agrees. */
|
|
2158
|
+
function sxParkNoteShows(r) {
|
|
2159
|
+
var v = r.verdict;
|
|
2160
|
+
if (!v || v.attachable !== false || !/^(native_thread_working|native_target_busy)$/.test(v.reason)) return false;
|
|
2161
|
+
return sxFeedWord(r) === 'Working' || v.reason === 'native_target_busy';
|
|
2162
|
+
}
|
|
1672
2163
|
function sxSupplement(reason) {
|
|
1673
2164
|
if (reason === 'native_target_busy') return 'It frees itself within 30 minutes. Detaching is not available in Control yet.';
|
|
1674
2165
|
if (reason === 'native_target_fenced') return 'Fences can be released from the Fences card in this panel.';
|
|
@@ -1694,7 +2185,8 @@
|
|
|
1694
2185
|
(f.retry ? '<button type="button" class="cx-btn quiet" data-sx="retry">Retry</button>' : '') +
|
|
1695
2186
|
(f.fork ? forkBtn : ''));
|
|
1696
2187
|
} else if (v && v.attachable === false && /^(native_thread_working|native_target_busy)$/.test(v.reason)) {
|
|
1697
|
-
|
|
2188
|
+
// 6.99.29: "Working right now" only while the one state says so.
|
|
2189
|
+
if (sxParkNoteShows(r)) html = '<div class="sx-park"><p>' + esc(SX_PARK[sxProvider(r.provider)]) + '</p>' + (/fork/i.test(v.reasonCopy) ? '<div class="sx-box-actions">' + forkBtn + '</div>' : '') + '</div>';
|
|
1698
2190
|
} else if (v && v.attachable === false && v.reasonCopy) {
|
|
1699
2191
|
html = refusalBox(v.reasonCopy, sxSupplement(v.reason), /fork/i.test(v.reasonCopy) ? forkBtn : '');
|
|
1700
2192
|
}
|
|
@@ -1707,14 +2199,23 @@
|
|
|
1707
2199
|
}
|
|
1708
2200
|
}
|
|
1709
2201
|
|
|
2202
|
+
/** Repaint whichever card shows this session after its one state moved (the reader or a view). */
|
|
2203
|
+
function sxRepaint(r) {
|
|
2204
|
+
sxSettle(r);
|
|
2205
|
+
if (r.view) { if (typeof r.view.stateChanged === 'function') r.view.stateChanged(r); else r.view.verdict(r); return; }
|
|
2206
|
+
if (state.reading === r) { paintActivity(r); renderVerdict(r); }
|
|
2207
|
+
}
|
|
1710
2208
|
function sessionStatus(text, kind, actionHtml) {
|
|
1711
2209
|
var el = $('session-status'); if (!el) return;
|
|
1712
2210
|
el.className = 'status-line' + (kind === 'error' ? ' error' : '');
|
|
1713
2211
|
el.innerHTML = esc(text || '') + (actionHtml || '');
|
|
1714
2212
|
}
|
|
1715
|
-
|
|
2213
|
+
// 11/07: the files sent with the words (media ids) are part of the draft: other files are another message, with a
|
|
2214
|
+
// key of its own, so the server never answers a new message with an old one's replay.
|
|
2215
|
+
function draftFor(text, r, ids) {
|
|
1716
2216
|
r = r || state.reading;
|
|
1717
|
-
|
|
2217
|
+
var files = Array.isArray(ids) ? ids.slice() : [];
|
|
2218
|
+
if (!r.draft || r.draft.text !== text || (r.draft.ids || []).join(',') !== files.join(',')) r.draft = { text: text, key: newTurnKey(), ids: files };
|
|
1718
2219
|
return r.draft;
|
|
1719
2220
|
}
|
|
1720
2221
|
// The box is cleared only of the very words that were sent; anything typed since stays.
|
|
@@ -1723,11 +2224,12 @@
|
|
|
1723
2224
|
if (input && input.value.trim() === String(text).trim()) { input.value = ''; autoGrow(input); }
|
|
1724
2225
|
if (state.reading && state.reading.draft && state.reading.draft.text === text) state.reading.draft = null;
|
|
1725
2226
|
}
|
|
1726
|
-
// Words go back in the box only if the box is empty; otherwise the status line keeps them.
|
|
1727
|
-
|
|
2227
|
+
// Words go back in the box only if the box is empty; otherwise the status line keeps them. `files` says what became
|
|
2228
|
+
// of the message's files (heldFilesBack).
|
|
2229
|
+
function returnToBox(text, why, files) {
|
|
1728
2230
|
var input = $('session-input');
|
|
1729
|
-
if (input && !input.value.trim()) { input.value = text; autoGrow(input); sessionStatus(why + ' Your message is back in the box.', 'error'); }
|
|
1730
|
-
else sessionStatus(why + ' Not sent: “' + String(text).slice(0, 80) + (text.length > 80 ? '…' : '') + '”', 'error');
|
|
2231
|
+
if (input && !input.value.trim()) { input.value = text; autoGrow(input); sessionStatus(why + ' Your message is back in the box.' + (files || ''), 'error'); }
|
|
2232
|
+
else sessionStatus(why + ' Not sent: “' + String(text).slice(0, 80) + (text.length > 80 ? '…' : '') + '”' + (files || ''), 'error');
|
|
1731
2233
|
}
|
|
1732
2234
|
// Your words show in the conversation the moment the Mac accepts them; the next refresh of
|
|
1733
2235
|
// the transcript replaces the bubble with the real turn.
|
|
@@ -1761,11 +2263,96 @@
|
|
|
1761
2263
|
};
|
|
1762
2264
|
function viewOf(r) { return (r && r.view) || READER_VIEW; }
|
|
1763
2265
|
|
|
2266
|
+
// ── files with a message to a session (6.99.27, Miles: "We still don't have the ability to take picture, or add photo
|
|
2267
|
+
// or files from the web") ──
|
|
2268
|
+
// The COS chat's attach kit (panes/cos-chat.js, W.cosChat.attach), as on Today's reply box: Take photo and the clip,
|
|
2269
|
+
// any file the Mac takes on a turn (threadTurnFileBytes, 64 MB; a video 100 MB), up to 5, sent as it is. The ids go
|
|
2270
|
+
// with the words; the server writes where each file is on the Mac under them. One box per session, kept in this tab.
|
|
2271
|
+
var READER_FILES = {};
|
|
2272
|
+
function readerKit() { var c = window.COSWeb && window.COSWeb.cosChat; return c && c.attach ? c.attach : null; }
|
|
2273
|
+
function readerFilesOn() { var k = readerKit(); return !!(k && k.turnFiles() > 0 && state.reading); }
|
|
2274
|
+
function readerBox(r) {
|
|
2275
|
+
var kit = readerKit(); if (!kit || !r) return null;
|
|
2276
|
+
var key = r.provider + '|' + r.id;
|
|
2277
|
+
if (!READER_FILES[key]) READER_FILES[key] = kit.box({
|
|
2278
|
+
changed: function (box) { if (state.reading && READER_FILES[state.reading.provider + '|' + state.reading.id] === box) paintReaderFiles(); },
|
|
2279
|
+
removed: function () { sessionStatus(''); },
|
|
2280
|
+
signedOut: function () { sessionStatus('This browser was signed out. Your message and files are kept. Sign in, then tap Retry on each file.', 'error'); }
|
|
2281
|
+
}, { anyBytes: kit.turnFileBytes() });
|
|
2282
|
+
if (!READER_FILES[key].anyBytes && !READER_FILES[key].atts.length && kit.turnFileBytes()) READER_FILES[key].anyBytes = kit.turnFileBytes();
|
|
2283
|
+
return READER_FILES[key];
|
|
2284
|
+
}
|
|
2285
|
+
function readerFilesState(box) {
|
|
2286
|
+
var kit = readerKit();
|
|
2287
|
+
if (!kit || !box || !box.atts.length) return 'none';
|
|
2288
|
+
return kit.pending(box) ? 'waiting' : kit.failed(box) ? 'failed' : 'ready';
|
|
2289
|
+
}
|
|
2290
|
+
function paintReaderFiles() {
|
|
2291
|
+
var kit = readerKit(), r = state.reading, slot = $('session-clip'), list = $('session-files');
|
|
2292
|
+
if (!slot || !list) return;
|
|
2293
|
+
if (!readerFilesOn()) { slot.innerHTML = ''; list.innerHTML = ''; list.classList.add('hidden'); return; }
|
|
2294
|
+
var box = readerBox(r), full = box.atts.length >= kit.max, mode = String(box.anyBytes || 0);
|
|
2295
|
+
if (slot.getAttribute('data-mode') !== mode) { slot.innerHTML = kit.cameraHtml('data-sx-file') + kit.clipHtml('data-sx-file', { any: box.anyBytes }); slot.setAttribute('data-mode', mode); }
|
|
2296
|
+
Array.prototype.forEach.call(slot.querySelectorAll('.cc-attach'), function (l) { l.classList.toggle('full', full); });
|
|
2297
|
+
Array.prototype.forEach.call(slot.querySelectorAll('[data-sx-file]'), function (i) { i.disabled = full || !!r.sending; });
|
|
2298
|
+
list.innerHTML = kit.chipsHtml(box, 'data-action') + (box.atts.length ? '<p class="att-limit">' + esc(kit.limitLine(box)) + '</p>' : '');
|
|
2299
|
+
list.classList.toggle('hidden', !box.atts.length);
|
|
2300
|
+
// Send and Fork wait while a file uploads. A failed file never greys them out (QA 6.99.27): a tap says why.
|
|
2301
|
+
var st = readerFilesState(box), form = $('session-form');
|
|
2302
|
+
if (!form || r.sending) return;
|
|
2303
|
+
var send = $('session-send'), forkBtn = form.querySelector('[data-action="fork-session"]');
|
|
2304
|
+
if (send) { send.disabled = st === 'waiting'; send.textContent = st === 'waiting' ? 'Uploading' : 'Send'; }
|
|
2305
|
+
if (forkBtn) forkBtn.disabled = st === 'waiting';
|
|
2306
|
+
}
|
|
2307
|
+
function addReaderFiles(list) {
|
|
2308
|
+
var kit = readerKit(), r = state.reading; if (!kit || !r || !readerFilesOn() || !list || !list.length) return;
|
|
2309
|
+
var refusals = kit.add(readerBox(r), list);
|
|
2310
|
+
if (refusals.length) sessionStatus(refusals.join(' '), 'error');
|
|
2311
|
+
paintReaderFiles();
|
|
2312
|
+
}
|
|
2313
|
+
// The files that went with a message leave the box; any added since stay. Taken, not removed: the status line stays
|
|
2314
|
+
// (a held message's Don't send is on it). Answers what went, for a held message to keep.
|
|
2315
|
+
function readerFilesSent(box, ids) {
|
|
2316
|
+
var kit = readerKit(); if (!kit || !box || !ids || !ids.length) return [];
|
|
2317
|
+
return kit.take(box, ids);
|
|
2318
|
+
}
|
|
2319
|
+
// QA 6.99.27: a held message keeps what its files were, so if it does not go, it comes back whole (heldFilesBack).
|
|
2320
|
+
function keepHeldFiles(r, text, ids, went) {
|
|
2321
|
+
if (!went || !went.length) return;
|
|
2322
|
+
r.held.forEach(function (h) { if (!h.files && h.text === text && (h.ids || []).join(',') === ids.join(',')) h.files = went; });
|
|
2323
|
+
saveHeld(r);
|
|
2324
|
+
}
|
|
2325
|
+
// The Mac keeps a file no turn took for 4 hours (lib/turn-file-store.ts, and the media store's own rule); this
|
|
2326
|
+
// trusts a file for 5 minutes less. Past that it says the file has gone rather than offer one the send would refuse.
|
|
2327
|
+
var HELD_FILE_KEEP_MS = 4 * 60 * 60000 - 5 * 60000;
|
|
2328
|
+
function heldFilesBack(r, h) {
|
|
2329
|
+
var ids = h.ids || []; if (!ids.length) return '';
|
|
2330
|
+
var kit = readerKit(), box = readerFilesOn() ? readerBox(r) : null, now = Date.now();
|
|
2331
|
+
var known = (h.files || []).filter(function (f) { return f && ids.indexOf(f.mediaId) >= 0; });
|
|
2332
|
+
var kept = known.filter(function (f) { return f.keptAt > 0 && now - f.keptAt < HELD_FILE_KEEP_MS; });
|
|
2333
|
+
var back = box && kept.length ? kit.restore(box, kept) : 0;
|
|
2334
|
+
var lost = ids.length - back, expired = known.length - kept.length;
|
|
2335
|
+
var said = !back ? '' : back === ids.length ? (back === 1 ? ' Its file is attached again.' : ' Its ' + back + ' files are attached again.')
|
|
2336
|
+
: ' ' + back + ' of its files ' + (back === 1 ? 'is' : 'are') + ' attached again.';
|
|
2337
|
+
if (!lost) return said;
|
|
2338
|
+
var gone = lost === expired
|
|
2339
|
+
? (lost === 1 ? ' The file it carried was kept for 4 hours and has gone.' : ' The ' + lost + ' files it carried were kept for 4 hours and have gone.')
|
|
2340
|
+
: (lost === 1 ? ' A file it carried did not come back.' : ' ' + lost + ' files it carried did not come back.');
|
|
2341
|
+
return said + gone + ' Attach ' + (lost === 1 ? 'it' : 'them') + ' again.';
|
|
2342
|
+
}
|
|
2343
|
+
|
|
1764
2344
|
function sendSessionTurn(fork, opts) {
|
|
1765
2345
|
var r = state.reading; var form = $('session-form');
|
|
1766
2346
|
if (!r || !form) return;
|
|
1767
2347
|
var input = $('session-input'), text = input.value.trim();
|
|
1768
2348
|
if (!text) { sessionStatus(fork ? 'Write what the copy should do first.' : 'Write a message first.', 'error'); return; }
|
|
2349
|
+
// 6.99.27: files. Never before every upload has finished, never with one that failed. A fork carries them too: its
|
|
2350
|
+
// first turn is this message, so the copy gets the same files (the fork route writes their paths as a turn does).
|
|
2351
|
+
var fb = readerFilesOn() ? readerBox(r) : null, fst = readerFilesState(fb);
|
|
2352
|
+
if (fst === 'waiting') { sessionStatus('A file is still uploading. ' + (fork ? 'Fork' : 'Send') + ' when it finishes.'); return; }
|
|
2353
|
+
if (fst === 'failed') { sessionStatus('A file did not upload. Retry it or remove it.', 'error'); return; }
|
|
2354
|
+
var fileIds = fst === 'ready' ? readerKit().ids(fb) : [];
|
|
2355
|
+
if (fileIds.length && !fork) opts = Object.assign({}, opts || {}, { attachmentIds: fileIds });
|
|
1769
2356
|
// Control's caution (SessionChatVerdict.caution): another app holds this session and looks
|
|
1770
2357
|
// idle, so the first send asks before it writes. A fork writes to a copy and never asks.
|
|
1771
2358
|
if (!fork && r.verdict && r.verdict.caution && !r.cautionAck) { r.cautionAsk = true; renderVerdict(r); return; }
|
|
@@ -1775,8 +2362,12 @@
|
|
|
1775
2362
|
if (r.refusal || r.cautionAsk) { r.refusal = null; r.cautionAsk = false; renderVerdict(r); }
|
|
1776
2363
|
r.sending = true;
|
|
1777
2364
|
var btns = form.querySelectorAll('button'); btns.forEach(function (b) { b.disabled = true; });
|
|
1778
|
-
var done = function () { r.sending = false; btns.forEach(function (b) { b.disabled = false; }); };
|
|
1779
|
-
(fork ? forkSession(r, text) : continueSession(r, text, opts || {})
|
|
2365
|
+
var done = function () { r.sending = false; btns.forEach(function (b) { b.disabled = false; }); paintReaderFiles(); };
|
|
2366
|
+
(fork ? forkSession(r, text, fileIds) : continueSession(r, text, opts || {}).then(function (how) {
|
|
2367
|
+
// The Mac took the message (sent, waiting in line, or held until the reply ends): its files went with it.
|
|
2368
|
+
if (/^(sent|parked|held)$/.test(how)) { var went = readerFilesSent(fb, fileIds); if (how === 'held') keepHeldFiles(r, text, fileIds, went); }
|
|
2369
|
+
return how;
|
|
2370
|
+
})).then(done, function (err) {
|
|
1780
2371
|
done();
|
|
1781
2372
|
if (state.reading !== r) return;
|
|
1782
2373
|
var why = err && err.name === 'AbortError'
|
|
@@ -1794,7 +2385,9 @@
|
|
|
1794
2385
|
// Resolves to 'sent' | 'parked' | 'held' | 'refused' | 'changed'.
|
|
1795
2386
|
function continueSession(r, text, opts) {
|
|
1796
2387
|
var provider = r.provider, id = r.id;
|
|
1797
|
-
var draft = opts.held ? { text: opts.held.text, key: opts.held.key } : draftFor(text, r), view = viewOf(r);
|
|
2388
|
+
var draft = opts.held ? { text: opts.held.text, key: opts.held.key, ids: opts.held.ids || [] } : draftFor(text, r, opts.attachmentIds), view = viewOf(r);
|
|
2389
|
+
// Files go only to a server that said it takes them (threadTurnAttachments); the caller shows no clip otherwise.
|
|
2390
|
+
var files = draft.ids && draft.ids.length ? { attachmentIds: draft.ids } : {};
|
|
1798
2391
|
if (!opts.held) view.status(r, 'Sending');
|
|
1799
2392
|
var bound = bindingFor(provider, id);
|
|
1800
2393
|
var attach = bound ? Promise.resolve({ ok: true, binding: bound }) : apiAnswer(sessionPath(provider, id, '/attach'), { method: 'POST', body: { cosSessionId: cosSessionId() }, timeoutMs: 20000 }).then(function (a) {
|
|
@@ -1823,9 +2416,12 @@
|
|
|
1823
2416
|
}
|
|
1824
2417
|
return 'refused';
|
|
1825
2418
|
}
|
|
2419
|
+
// QA 6.99.29: a turn this page sends starts new work, so the last Stop (or finished COS turn) no longer
|
|
2420
|
+
// mutes the working signals that follow (a held follow-up goes 800 ms after the end, inside the echo window).
|
|
2421
|
+
r.stoppedAt = null;
|
|
1826
2422
|
return apiAnswer('/api/agent-sessions/bindings/' + encodeURIComponent(at.binding.bindingId) + '/turns', {
|
|
1827
2423
|
method: 'POST', timeoutMs: 25000,
|
|
1828
|
-
body: Object.assign({ prompt: draft.text, epoch: at.binding.epoch, targetKey: targetKey(provider, id), clientTurnId: draft.key }, opts.acknowledgedRevision ? { acknowledgedRevision: opts.acknowledgedRevision } : {}),
|
|
2424
|
+
body: Object.assign({ prompt: draft.text, epoch: at.binding.epoch, targetKey: targetKey(provider, id), clientTurnId: draft.key }, files, opts.acknowledgedRevision ? { acknowledgedRevision: opts.acknowledgedRevision } : {}),
|
|
1829
2425
|
}).then(function (t) {
|
|
1830
2426
|
var b = t.body || {};
|
|
1831
2427
|
if (t.status >= 200 && t.status < 300 && (b.outcome === 'queued' || b.outcome === 'completed')) {
|
|
@@ -1833,6 +2429,8 @@
|
|
|
1833
2429
|
else { view.pending(r, draft.text, b.replayed ? 'already sent' : 'sent'); view.clear(r, draft.text); }
|
|
1834
2430
|
view.status(r, b.replayed ? 'Already sent. Watching for the reply.' : 'Sent. Watching for the reply.');
|
|
1835
2431
|
view.watch(r, at.binding.bindingId, draft.key);
|
|
2432
|
+
// 6.99.29: a turn that went live is in the open session, which now works on it.
|
|
2433
|
+
if (b.outcome === 'completed' && b.via === 'live') { sxNoteGate(r, true); try { sxRepaint(r); } catch (_e) {} }
|
|
1836
2434
|
return 'sent';
|
|
1837
2435
|
}
|
|
1838
2436
|
var reason = b.reason || '';
|
|
@@ -1867,7 +2465,7 @@
|
|
|
1867
2465
|
var HELD_MAX_MS = 5 * 60000, HELD_RETRY_MS = 8000;
|
|
1868
2466
|
function holdUntilReplyEnds(r, draft) {
|
|
1869
2467
|
if (!r.held.some(function (h) { return h.key === draft.key; })) {
|
|
1870
|
-
r.held.push({ text: draft.text, key: draft.key, since: Date.now() });
|
|
2468
|
+
r.held.push({ text: draft.text, key: draft.key, since: Date.now(), ids: draft.ids || [] });
|
|
1871
2469
|
showPendingTurn(draft.text, 'sends after this reply', draft.key);
|
|
1872
2470
|
saveHeld(r);
|
|
1873
2471
|
}
|
|
@@ -1903,8 +2501,8 @@
|
|
|
1903
2501
|
if (bubble) { bubble.removeAttribute('data-held-key'); bubble.querySelector('.who').textContent = 'You, waiting in line'; }
|
|
1904
2502
|
} else {
|
|
1905
2503
|
if (bubble) bubble.remove();
|
|
1906
|
-
|
|
1907
|
-
|
|
2504
|
+
// The whole message comes back: its words, and its files where the Mac still keeps them.
|
|
2505
|
+
returnToBox(h.text, how === 'cancelled' ? 'Not sent.' : copy || 'Not sent.', heldFilesBack(r, h));
|
|
1908
2506
|
}
|
|
1909
2507
|
if (!r.held.some(function (x) { return !x.cancelled; })) clearInterval(r.heldTimer);
|
|
1910
2508
|
}
|
|
@@ -1921,7 +2519,7 @@
|
|
|
1921
2519
|
var view = viewOf(r);
|
|
1922
2520
|
return apiAnswer(sessionPath(r.provider, r.id, '/queued-turns'), {
|
|
1923
2521
|
method: 'POST', timeoutMs: 15000,
|
|
1924
|
-
body: { cosSessionId: cosSessionId(), clientTurnId: draft.key, prompt: draft.text },
|
|
2522
|
+
body: Object.assign({ cosSessionId: cosSessionId(), clientTurnId: draft.key, prompt: draft.text }, draft.ids && draft.ids.length ? { attachmentIds: draft.ids } : {}),
|
|
1925
2523
|
}).then(function (q) {
|
|
1926
2524
|
var b = q.body || {};
|
|
1927
2525
|
if (b.queued === true || b.error === 'duplicate_turn') {
|
|
@@ -1930,10 +2528,14 @@
|
|
|
1930
2528
|
else { view.pending(r, draft.text, 'waiting in line'); view.clear(r, draft.text); }
|
|
1931
2529
|
view.status(r, b.error === 'duplicate_turn' ? 'Already waiting to send.' : (b.position ? 'The session is busy. Yours is #' + (b.position + 1) + ' in line and sends on its own.' : 'The session is busy. Yours sends next, on its own.'));
|
|
1932
2530
|
view.queue(r);
|
|
2531
|
+
// 6.99.29: "The session is busy" is the gate's word as of now; the card shows the same. Last, and
|
|
2532
|
+
// never allowed to cost the parked message its settlement.
|
|
2533
|
+
if (b.queued === true && refusal && refusal.reason === 'native_thread_working') { sxNoteGate(r, true); try { sxRepaint(r); } catch (_e) {} }
|
|
1933
2534
|
return 'parked';
|
|
1934
2535
|
}
|
|
1935
2536
|
if (b.error === 'thread_free' && !opts.freeRetry) return continueSession(r, draft.text, Object.assign({}, opts, { freeRetry: true }));
|
|
1936
|
-
|
|
2537
|
+
// 11/07: a file that could not be kept refuses the park with its own words, which say which step to take.
|
|
2538
|
+
var copy = (b.attachmentError && b.reasonCopy) || (refusal && refusal.reasonCopy) || (b.error === 'queue_full' ? 'That session already has a full line of waiting messages.' : 'COS could not send or queue that.');
|
|
1937
2539
|
if (opts.held) settleHeld(r, opts.held, 'refused', copy); else view.status(r, copy + ' It is still in the box.', 'error');
|
|
1938
2540
|
return 'refused';
|
|
1939
2541
|
});
|
|
@@ -1949,10 +2551,13 @@
|
|
|
1949
2551
|
apiAnswer('/api/agent-sessions/bindings/' + encodeURIComponent(bindingId) + '/turns/' + encodeURIComponent(key)).then(function (s) {
|
|
1950
2552
|
var o = s.body && s.body.outcome;
|
|
1951
2553
|
if (o === 'completed') {
|
|
1952
|
-
clearInterval(state.turnPoll);
|
|
1953
|
-
//
|
|
1954
|
-
|
|
1955
|
-
|
|
2554
|
+
clearInterval(state.turnPoll); refreshTurns(provider, id);
|
|
2555
|
+
// 6.99.29: a turn delivered LIVE is in the open session, which is now working on it; only a
|
|
2556
|
+
// COS child's finished turn is the end of the work. Either way the one state settles it.
|
|
2557
|
+
if (s.body.via === 'live') { sxNoteGate(r, true); sessionStatus('Sent into the open session.'); }
|
|
2558
|
+
else { r.stoppedAt = Date.now(); sessionStatus('Done.'); }
|
|
2559
|
+
setLive({}); var st = $('session-state'); if (st) st.textContent = r.live && r.live.working ? 'Working now' : 'Open';
|
|
2560
|
+
if (s.body.via !== 'live') replyEnded(r);
|
|
1956
2561
|
} else if (o === 'refused') { clearInterval(state.turnPoll); sessionStatus(s.body.reasonCopy || 'The session refused that message.', 'error'); replyEnded(r); }
|
|
1957
2562
|
else if (o === 'ambiguous') { clearInterval(state.turnPoll); sessionStatus(s.body.reasonCopy || 'COS cannot tell whether that landed. Look at the session before sending it again.', 'error'); }
|
|
1958
2563
|
}).catch(function () {});
|
|
@@ -1961,13 +2566,15 @@
|
|
|
1961
2566
|
|
|
1962
2567
|
// Fork copies the session and runs the message there. One key per draft, so a second tap or
|
|
1963
2568
|
// a resend after a timeout finds the same job instead of making a second copy.
|
|
1964
|
-
function forkSession(r, text) {
|
|
1965
|
-
|
|
2569
|
+
function forkSession(r, text, ids) {
|
|
2570
|
+
ids = Array.isArray(ids) ? ids : [];
|
|
2571
|
+
// Other files are another fork, with its own key.
|
|
2572
|
+
if (!r.forkDraft || r.forkDraft.text !== text || (r.forkDraft.ids || []).join(',') !== ids.join(',')) r.forkDraft = { text: text, key: 'fk' + newTurnKey().slice(2), ids: ids.slice() };
|
|
1966
2573
|
var forkId = r.forkDraft.key;
|
|
1967
2574
|
sessionStatus('Copying the session. This can take up to a minute.');
|
|
1968
2575
|
var settle = function (b) {
|
|
1969
2576
|
if (b.forked === true) {
|
|
1970
|
-
r.forkDraft = null; clearBoxOf(text);
|
|
2577
|
+
r.forkDraft = null; clearBoxOf(text); readerFilesSent(readerFilesOn() ? readerBox(r) : null, ids);
|
|
1971
2578
|
sessionStatus('Forked. The copy has your message and shows in Sessions in a moment.');
|
|
1972
2579
|
setTimeout(function () { if (state.pane === 'sessions') loadSessions(true); }, 4000);
|
|
1973
2580
|
return true;
|
|
@@ -1975,7 +2582,7 @@
|
|
|
1975
2582
|
if (b.forked === false || b.state === 'failed' || b.state === 'refused') { sessionStatus((b.reasonCopy || 'COS could not copy that session.'), 'error'); return true; }
|
|
1976
2583
|
return false;
|
|
1977
2584
|
};
|
|
1978
|
-
return apiAnswer(sessionPath(r.provider, r.id, '/fork'), { method: 'POST', timeoutMs: 65000, body: { prompt: text, cosSessionId: cosSessionId(), clientForkId: forkId } }).then(function (f) {
|
|
2585
|
+
return apiAnswer(sessionPath(r.provider, r.id, '/fork'), { method: 'POST', timeoutMs: 65000, body: Object.assign({ prompt: text, cosSessionId: cosSessionId(), clientForkId: forkId }, ids.length ? { attachmentIds: ids } : {}) }).then(function (f) {
|
|
1979
2586
|
if (settle(f.body || {})) return;
|
|
1980
2587
|
if (f.status !== 202) { sessionStatus('COS could not copy that session.', 'error'); return; }
|
|
1981
2588
|
// 202: running in the background. Follow its job for up to three minutes.
|
|
@@ -2000,7 +2607,7 @@
|
|
|
2000
2607
|
if (b.cancelled) {
|
|
2001
2608
|
r.stoppedAt = Date.now();
|
|
2002
2609
|
clearInterval(state.turnPoll);
|
|
2003
|
-
setLive({
|
|
2610
|
+
setLive({}); var st = $('session-state'); if (st) st.textContent = 'Open';
|
|
2004
2611
|
setTimeout(function () { if (state.reading === r) refreshTurns(r.provider, r.id); }, 1500);
|
|
2005
2612
|
}
|
|
2006
2613
|
}).catch(function (err) { sessionStatus(err.message, 'error'); });
|
|
@@ -2014,6 +2621,36 @@
|
|
|
2014
2621
|
});
|
|
2015
2622
|
}
|
|
2016
2623
|
|
|
2624
|
+
// Output previews are separate from composer attachments. Only the DTO fields reach the lazy viewer.
|
|
2625
|
+
function readerImagesHtml(turn) {
|
|
2626
|
+
var images = turn && Array.isArray(turn.outputImages) ? turn.outputImages.filter(function (x) {
|
|
2627
|
+
return x && /^ai_[a-f0-9]{64}$/.test(x.id || '') && (x.state === 'available' || x.state === 'unavailable');
|
|
2628
|
+
}).map(function (x) { return { id: x.id, label: String(x.label || 'Output image').slice(0, 180), state: x.state, reason: x.reason }; }) : [];
|
|
2629
|
+
return images.length ? '<div data-reader-images="' + esc(JSON.stringify(images)) + '"><p class="rm-status" role="status">Loading image previews…</p></div>' : '';
|
|
2630
|
+
}
|
|
2631
|
+
function readerImagesStop(host) {
|
|
2632
|
+
if (!host) return;
|
|
2633
|
+
host.__cosImageOwner = null;
|
|
2634
|
+
if (window.COSWeb.readerMedia) window.COSWeb.readerMedia.unmount(host);
|
|
2635
|
+
}
|
|
2636
|
+
function readerImagesMount(host, r) {
|
|
2637
|
+
if (!host || !r) return;
|
|
2638
|
+
if (!host.querySelectorAll('[data-reader-images]').length) { readerImagesStop(host); return; }
|
|
2639
|
+
var owner = host.__cosImageOwner = {};
|
|
2640
|
+
ensurePane('readerMedia', 4000).then(function (kit) {
|
|
2641
|
+
if (host.__cosImageOwner !== owner || host.isConnected === false) return;
|
|
2642
|
+
if (!kit) throw new Error('Image previews could not load.');
|
|
2643
|
+
kit.mount(host, r.provider, r.id);
|
|
2644
|
+
}).catch(function () {
|
|
2645
|
+
if (host.__cosImageOwner !== owner || host.isConnected === false) return;
|
|
2646
|
+
host.querySelectorAll('[data-reader-images]').forEach(function (slot) {
|
|
2647
|
+
slot.textContent = 'Image previews could not load. ';
|
|
2648
|
+
var retry = document.createElement('button'); retry.type = 'button'; retry.className = 'link-btn'; retry.textContent = 'Retry images';
|
|
2649
|
+
retry.addEventListener('click', function () { readerImagesMount(host, r); }); slot.appendChild(retry);
|
|
2650
|
+
});
|
|
2651
|
+
});
|
|
2652
|
+
}
|
|
2653
|
+
|
|
2017
2654
|
// Copy session (Control's Kickstart brief, HelperSources/main.swift claudeKickstartCopy). Control
|
|
2018
2655
|
// builds it from the Mac's transcript; the web builds it from the turns the server serves and
|
|
2019
2656
|
// the session's first prompt, so it keeps the original request and the newest context.
|
|
@@ -2146,7 +2783,7 @@
|
|
|
2146
2783
|
// One turn's identity, for telling which turns a refresh brought that the page already shows.
|
|
2147
2784
|
function sxTurnSig(t) {
|
|
2148
2785
|
var text = String(t.text || t.content || '').trim();
|
|
2149
|
-
return (String(t.role || '').toLowerCase() === 'user' ? 'u' : 'a') + '|' + (t.at || '') + '|' + text.length + '|' + text.slice(0, 48);
|
|
2786
|
+
return (String(t.role || '').toLowerCase() === 'user' ? 'u' : 'a') + '|' + (t.at || '') + '|' + text.length + '|' + text.slice(0, 48) + '|' + readerImagesHtml(t);
|
|
2150
2787
|
}
|
|
2151
2788
|
function sxTurnHtml(t) {
|
|
2152
2789
|
var role = String(t.role || '').toLowerCase() === 'user' ? 'user' : 'assistant';
|
|
@@ -2155,7 +2792,7 @@
|
|
|
2155
2792
|
var when = at ? (Date.now() - at.getTime() < 86400000 ? clock(at) : sxShortDate(at)) : '';
|
|
2156
2793
|
return '<div class="turn ' + role + ' sx-turn" data-sig="' + esc(sxTurnSig(t)) + '"><span class="who"><span>' + (role === 'user' ? 'YOU' : 'ASSISTANT') + '</span>' +
|
|
2157
2794
|
(when ? '<span class="sx-turn-when">' + esc(when) + '</span>' : '') + '</span>' +
|
|
2158
|
-
(role === 'user' ? '<div class="sx-plain">' + esc(text) + '</div>' : '<div class="sx-md">' + sxMd(text) + '</div>') + '</div>';
|
|
2795
|
+
(role === 'user' ? '<div class="sx-plain">' + esc(text) + '</div>' : '<div class="sx-md">' + sxMd(text) + '</div>' + readerImagesHtml(t)) + '</div>';
|
|
2159
2796
|
}
|
|
2160
2797
|
// A refresh never rebuilds what you are reading (Miles, /qa: "start to scroll and then it reloads
|
|
2161
2798
|
// the section"). The server's window slides as a session grows, so the turns the page already
|
|
@@ -2200,7 +2837,8 @@
|
|
|
2200
2837
|
return h.slice(0, 8) + '-' + h.slice(8, 12) + '-' + h.slice(12, 16) + '-' + h.slice(16, 20) + '-' + h.slice(20);
|
|
2201
2838
|
}
|
|
2202
2839
|
var TODAY_REFERENCE = 'What Today in COS Control shows right now';
|
|
2203
|
-
|
|
2840
|
+
// files: { attachmentIds, atts } from the Ask sheet's clip (askSheetFiles), or nothing.
|
|
2841
|
+
function askCos(prompt, statusEl, files) {
|
|
2204
2842
|
// 6.99.12: "it shows Sent but never shows up" (Miles, 11/04). With Ask COS in Messages, the sheet hands its words to
|
|
2205
2843
|
// that composer (panes/cos-chat.js submitAsk), so the answer streams on screen and lands as a numbered turn. The
|
|
2206
2844
|
// request below is the fallback for a page without it; it resolves to the Messages route to open.
|
|
@@ -2209,17 +2847,27 @@
|
|
|
2209
2847
|
// quoted reference, the query-jobs field for context. Asked from any other pane, nothing extra is sent.
|
|
2210
2848
|
var context = state.pane === 'today' ? ensurePane('todayActions', 1500).then(function (m) {
|
|
2211
2849
|
// A broken summary never costs the Ask: it goes without it, and the console says why.
|
|
2212
|
-
|
|
2850
|
+
var ctx;
|
|
2851
|
+
try { ctx = m && m.summary ? m.summary() : ''; } catch (err) { console.error('[today] the Today summary failed; asking without it.', err); return ''; }
|
|
2852
|
+
// The model's summary of the sessions leads it when there is one, inside the same cap; past the cap it is left out.
|
|
2853
|
+
var b = typeof briefShown === 'function' ? briefShown() : null;
|
|
2854
|
+
var lead = b ? 'Summary of the sessions, written by a model ' + (briefMinutes(b.at) < 1 ? 'just now' : span(b.at) + ' ago') + (b.state === 'stale' ? ' (things have moved since)' : '') + ': ' + b.text : '';
|
|
2855
|
+
return ctx && lead && lead.length + 1 + ctx.length <= ((m && m.summaryMax) || 4000) ? lead + '\n' + ctx : ctx;
|
|
2213
2856
|
}, function () { return ''; }) : Promise.resolve('');
|
|
2214
2857
|
if (chat && chat.askFromSheet) {
|
|
2215
2858
|
setStatus(statusEl, 'Sending…');
|
|
2216
|
-
return context.then(function (ctx) {
|
|
2859
|
+
return context.then(function (ctx) {
|
|
2860
|
+
var extra = files ? { attachmentIds: files.attachmentIds, atts: files.atts } : {};
|
|
2861
|
+
if (ctx) extra.context = ctx;
|
|
2862
|
+
return chat.askFromSheet(prompt, ctx || files ? extra : undefined);
|
|
2863
|
+
}).then(function (where) { setStatus(statusEl, ''); return where; }, function (err) {
|
|
2217
2864
|
setStatus(statusEl, err.message, true);
|
|
2218
2865
|
throw err;
|
|
2219
2866
|
});
|
|
2220
2867
|
}
|
|
2221
2868
|
setStatus(statusEl, 'Sent. COS is answering.');
|
|
2222
2869
|
var body = { clientJobId: webJobId(), generation: 1, query: prompt, source: 'web', activityToolMode: 'status' };
|
|
2870
|
+
if (files && files.attachmentIds && files.attachmentIds.length) body.attachmentIds = files.attachmentIds;
|
|
2223
2871
|
return context.then(function (ctx) {
|
|
2224
2872
|
if (ctx) body.reference = { query: TODAY_REFERENCE, response: ctx };
|
|
2225
2873
|
return api('/api/query-jobs', { method: 'POST', body: body, timeoutMs: 30000 });
|
|
@@ -3901,12 +4549,87 @@
|
|
|
3901
4549
|
var q = ($('ask-q') && $('ask-q').value.trim()) || '';
|
|
3902
4550
|
g.innerHTML = esc(ASK_FILE.busy ? 'Filing\u2026' : 'File to Work inbox') + fileNoteHtml(q);
|
|
3903
4551
|
}
|
|
4552
|
+
// ── files with Ask COS (11/07, Miles: "Sometimes I need to pass context via a screenshot, photo or file") ──
|
|
4553
|
+
// The COS chat's own clip, chips, limits and upload (panes/cos-chat.js, W.cosChat.attach): photos, PDF, TXT, MD, CSV,
|
|
4554
|
+
// JSON, XLSX, MP4 and MOV, up to 5, pasted, dropped or picked. They go with Ask COS only; the sheet's other rows take
|
|
4555
|
+
// words. Ask COS waits for every upload, and a file that failed has to be retried or removed first. Closing the sheet
|
|
4556
|
+
// keeps them for the next Ask in this tab.
|
|
4557
|
+
var ASK_FILES = null, ASK_SENDING = false;
|
|
4558
|
+
function askKit() { var c = window.COSWeb && window.COSWeb.cosChat; return c && c.attach ? c.attach : null; }
|
|
4559
|
+
function askFiles() {
|
|
4560
|
+
var kit = askKit(); if (!kit) return null;
|
|
4561
|
+
if (!ASK_FILES) ASK_FILES = kit.box({
|
|
4562
|
+
changed: paintAskFiles,
|
|
4563
|
+
removed: function () { setStatus($('ask-status'), ''); },
|
|
4564
|
+
signedOut: function () { setStatus($('ask-status'), 'This browser was signed out. Your files are kept. Sign in, then tap Retry on each file.', true); }
|
|
4565
|
+
});
|
|
4566
|
+
return ASK_FILES;
|
|
4567
|
+
}
|
|
4568
|
+
function askFilesState() {
|
|
4569
|
+
var kit = askKit(), b = ASK_FILES;
|
|
4570
|
+
if (!kit || !b || !b.atts.length) return 'none';
|
|
4571
|
+
return kit.pending(b) ? 'waiting' : kit.failed(b) ? 'failed' : 'ready';
|
|
4572
|
+
}
|
|
4573
|
+
function paintAskClip() {
|
|
4574
|
+
var slot = $('ask-clip'), kit = askKit(); if (!slot) return;
|
|
4575
|
+
var on = !!(kit && kit.askOn());
|
|
4576
|
+
if (!on) { slot.innerHTML = ''; return; }
|
|
4577
|
+
// 6.99.27: Take photo beside the clip (a phone or tablet only; kit.cameraHtml).
|
|
4578
|
+
if (!slot.querySelector('[data-ask-file]')) slot.innerHTML = kit.cameraHtml('data-ask-file') + kit.clipHtml('data-ask-file');
|
|
4579
|
+
var full = !!(ASK_FILES && ASK_FILES.atts.length >= kit.max);
|
|
4580
|
+
Array.prototype.forEach.call(slot.querySelectorAll ? slot.querySelectorAll('.cc-attach') : [], function (l) { l.classList.toggle('full', full); });
|
|
4581
|
+
Array.prototype.forEach.call(slot.querySelectorAll ? slot.querySelectorAll('[data-ask-file]') : [], function (i) { i.disabled = full || ASK_SENDING; });
|
|
4582
|
+
}
|
|
4583
|
+
function paintAskFiles() {
|
|
4584
|
+
var kit = askKit(), box = $('ask-files'), b = ASK_FILES; if (!box || !kit) return;
|
|
4585
|
+
var st = askFilesState();
|
|
4586
|
+
box.innerHTML = st === 'none' ? '' : kit.chipsHtml(b, 'data-action') + '<p class="ask-files-note">' + esc(
|
|
4587
|
+
st === 'waiting' ? 'Uploading. Ask COS waits for it.' : st === 'failed' ? 'A file did not upload. Retry it or remove it, then Ask COS.'
|
|
4588
|
+
: (b.atts.length === 1 ? 'This file goes' : 'These files go') + ' with Ask COS. Write the question above.') + '</p>';
|
|
4589
|
+
box.classList.toggle('hidden', st === 'none');
|
|
4590
|
+
paintAskClip(); paintAskRow();
|
|
4591
|
+
}
|
|
4592
|
+
function addAskFiles(list) {
|
|
4593
|
+
var kit = askKit(), b = askFiles(); if (!kit || !b || !list || !list.length) return;
|
|
4594
|
+
var refusals = kit.add(b, list);
|
|
4595
|
+
setStatus($('ask-status'), refusals.join(' '), !!refusals.length);
|
|
4596
|
+
paintAskFiles();
|
|
4597
|
+
}
|
|
4598
|
+
// The Ask COS row says what it sends, and waits while a file uploads.
|
|
4599
|
+
function askRowNote(q) {
|
|
4600
|
+
var n = ASK_FILES ? ASK_FILES.atts.length : 0;
|
|
4601
|
+
// The files first: on the folded phone the line is cut, and they are what changed.
|
|
4602
|
+
return '“' + q + '”' + (n ? ' with ' + (n === 1 ? '1 file' : n + ' files') : '') + (state.pane === 'today' ? (n ? ' and' : ' with') + ' what Today shows' : '');
|
|
4603
|
+
}
|
|
4604
|
+
function paintAskRow() {
|
|
4605
|
+
var row = document.querySelector('#ask-results [data-action="ask-cos"]'); if (!row) return;
|
|
4606
|
+
var st = askFilesState();
|
|
4607
|
+
row.disabled = ASK_SENDING || st === 'waiting' || st === 'failed';
|
|
4608
|
+
var note = row.querySelector('.note'), q = ($('ask-q') && $('ask-q').value.trim()) || '';
|
|
4609
|
+
if (note) note.textContent = ASK_SENDING ? 'Sending…' : st === 'waiting' ? 'Waiting for the upload' : askRowNote(q);
|
|
4610
|
+
}
|
|
4611
|
+
function askSheetSend(q) {
|
|
4612
|
+
if (ASK_SENDING) return;
|
|
4613
|
+
var kit = askKit(), b = ASK_FILES, st = askFilesState();
|
|
4614
|
+
if (st === 'waiting') { setStatus($('ask-status'), 'A file is still uploading. Ask COS when it finishes.'); return; }
|
|
4615
|
+
if (st === 'failed') { setStatus($('ask-status'), 'A file did not upload. Retry it or remove it, then Ask COS.', true); return; }
|
|
4616
|
+
var files = st === 'ready' ? { attachmentIds: kit.ids(b), atts: kit.sent(b) } : null;
|
|
4617
|
+
ASK_SENDING = true; paintAskRow(); paintAskClip();
|
|
4618
|
+
var done = function () { ASK_SENDING = false; paintAskRow(); paintAskClip(); };
|
|
4619
|
+
askCos(q, $('ask-status'), files).then(function (where) {
|
|
4620
|
+
done();
|
|
4621
|
+
// Sent: these files went with it. Anything added while it was sending stays for the next Ask.
|
|
4622
|
+
if (files && b) b.atts.filter(function (t) { return files.attachmentIds.indexOf(t.mediaId) >= 0; }).forEach(function (t) { kit.remove(b, t.id); });
|
|
4623
|
+
closeAsk(); go('messages', Array.isArray(where) ? where : []);
|
|
4624
|
+
}).catch(done);
|
|
4625
|
+
}
|
|
3904
4626
|
function openAsk(prefill) {
|
|
3905
4627
|
loadFileDomains().then(function () { if (!$('ask-sheet').classList.contains('hidden') && $('ask-q').value.trim()) renderAsk(); });
|
|
3906
4628
|
$('ask-sheet').classList.remove('hidden');
|
|
3907
4629
|
document.body.style.overflow = 'hidden';
|
|
3908
4630
|
var input = $('ask-q'); input.value = prefill || '';
|
|
3909
|
-
renderAsk();
|
|
4631
|
+
renderAsk(); paintAskFiles(); paintAskClip();
|
|
4632
|
+
var kit = askKit(); if (kit && !kit.askOn()) kit.ensureCaps().then(paintAskClip, function () {});
|
|
3910
4633
|
setTimeout(function () { input.focus(); }, 30);
|
|
3911
4634
|
}
|
|
3912
4635
|
function closeAsk() { $('ask-sheet').classList.add('hidden'); document.body.style.overflow = ''; }
|
|
@@ -3923,12 +4646,13 @@
|
|
|
3923
4646
|
}
|
|
3924
4647
|
var jumps = JUMPS.filter(function (j) { return j[1].toLowerCase().indexOf(q.toLowerCase()) === 0; });
|
|
3925
4648
|
out.innerHTML = '<div class="list" style="margin-top:14px">' +
|
|
3926
|
-
'<button type="button" class="nav-row" data-action="ask-cos"><svg class="icon"><use href="#i-ask"/></svg><span class="grow">Ask COS<span class="note"
|
|
4649
|
+
'<button type="button" class="nav-row" data-action="ask-cos"><svg class="icon"><use href="#i-ask"/></svg><span class="grow">Ask COS<span class="note">' + esc(askRowNote(q)) + '</span></span></button>' +
|
|
3927
4650
|
'<button type="button" class="nav-row" data-action="ask-file"' + (ASK_FILE.busy ? ' disabled' : '') + '><svg class="icon"><use href="#i-work"/></svg><span class="grow">' + (ASK_FILE.busy ? 'Filing\u2026' : 'File to Work inbox') + fileNoteHtml(q) + '</span></button>' +
|
|
3928
4651
|
'<button type="button" class="nav-row" data-action="ask-meaning"><svg class="icon"><use href="#i-sessions"/></svg><span class="grow">Find a session about this<span class="note">Ranks every session by meaning</span></span></button>' +
|
|
3929
4652
|
'<button type="button" class="nav-row" data-action="ask-work"><svg class="icon"><use href="#i-work"/></svg><span class="grow">Find a task about this<span class="note">Searches Work by meaning</span></span></button>' +
|
|
3930
4653
|
jumps.map(function (j) { return '<button type="button" class="nav-row" data-goto="' + j[0] + '"><svg class="icon"><use href="#i-' + j[0] + '"/></svg><span class="grow">Go to ' + esc(j[1]) + '</span><span class="chev"></span></button>'; }).join('') +
|
|
3931
4654
|
'</div><div id="ask-hits"></div><p class="status-line" id="ask-status"></p>';
|
|
4655
|
+
paintAskRow();
|
|
3932
4656
|
clearTimeout(renderAsk.t);
|
|
3933
4657
|
if (q.length >= 2) renderAsk.t = setTimeout(function () {
|
|
3934
4658
|
api('/api/agent-sessions/search?q=' + encodeURIComponent(q) + '&limit=6').then(function (d) {
|
|
@@ -3972,7 +4696,7 @@
|
|
|
3972
4696
|
return;
|
|
3973
4697
|
}
|
|
3974
4698
|
if (t.hasAttribute('data-open-ask')) return openAsk();
|
|
3975
|
-
if (t.hasAttribute('data-today')) return openTodayAction(t);
|
|
4699
|
+
if (t.hasAttribute('data-today')) return t.getAttribute('data-today') === 'pet' ? petTap(t) : openTodayAction(t);
|
|
3976
4700
|
if (t.hasAttribute('data-today-next')) return prepNext();
|
|
3977
4701
|
if (t.hasAttribute('data-today-needs')) {
|
|
3978
4702
|
var which = t.getAttribute('data-today-needs');
|
|
@@ -4004,7 +4728,18 @@
|
|
|
4004
4728
|
}
|
|
4005
4729
|
if (action === 'expand-turn') { var tr = document.querySelector('.turn[data-turn="' + t.getAttribute('data-turn') + '"]'); if (tr) tr.classList.remove('clamped'); t.remove(); return; }
|
|
4006
4730
|
var q = $('ask-q').value.trim();
|
|
4007
|
-
if (action === '
|
|
4731
|
+
if ((action === 'att-remove' || action === 'att-retry') && t.closest('#session-form') && state.reading) {
|
|
4732
|
+
var kitR = readerKit(), boxR = kitR && readerBox(state.reading); if (!boxR) return;
|
|
4733
|
+
sessionStatus('');
|
|
4734
|
+
if (action === 'att-remove') kitR.remove(boxR, t.getAttribute('data-att')); else kitR.retry(boxR, t.getAttribute('data-att'));
|
|
4735
|
+
return;
|
|
4736
|
+
}
|
|
4737
|
+
if (inSheet && (action === 'att-remove' || action === 'att-retry') && ASK_FILES) {
|
|
4738
|
+
var kitA = askKit(); setStatus($('ask-status'), '');
|
|
4739
|
+
if (action === 'att-remove') kitA.remove(ASK_FILES, t.getAttribute('data-att')); else kitA.retry(ASK_FILES, t.getAttribute('data-att'));
|
|
4740
|
+
return;
|
|
4741
|
+
}
|
|
4742
|
+
if (action === 'ask-cos' && q) return askSheetSend(q);
|
|
4008
4743
|
if (action === 'ask-file' && q) return fileToInbox(q);
|
|
4009
4744
|
if (action === 'ask-meaning' && q) { $('ask-hits').innerHTML = skeletonRows(3); findByMeaning(q).then(function (html) { var el = $('ask-hits'); if (el) el.innerHTML = html; }); return; }
|
|
4010
4745
|
if (action === 'ask-work' && q) {
|
|
@@ -4026,7 +4761,7 @@
|
|
|
4026
4761
|
.catch(function (err) {
|
|
4027
4762
|
// The server names what failed (wrong token, wrong code, the glasses code, lockout).
|
|
4028
4763
|
// No status means the request never reached COS (the browser's own "Load failed").
|
|
4029
|
-
showAuth(err && err.status ? err.message :
|
|
4764
|
+
showAuth(err && err.status ? err.message : UNREACHABLE_COPY);
|
|
4030
4765
|
});
|
|
4031
4766
|
} else if (f.id === 'session-form') sendSessionTurn(false);
|
|
4032
4767
|
else if (f.id === 'sessions-search-form') { $('sessions-q').blur(); searchSessions(); }
|
|
@@ -4035,16 +4770,47 @@
|
|
|
4035
4770
|
}
|
|
4036
4771
|
});
|
|
4037
4772
|
|
|
4773
|
+
// The Ask sheet's clip, a pasted screenshot in its box, and files dropped on it (askFiles above).
|
|
4774
|
+
document.addEventListener('change', function (e) {
|
|
4775
|
+
if (e.target && e.target.hasAttribute && e.target.hasAttribute('data-ask-file')) { addAskFiles(e.target.files); e.target.value = ''; }
|
|
4776
|
+
if (e.target && e.target.hasAttribute && e.target.hasAttribute('data-sx-file')) { addReaderFiles(e.target.files); e.target.value = ''; }
|
|
4777
|
+
});
|
|
4778
|
+
// The Sessions reader's box: a pasted screenshot, and files dropped on it.
|
|
4779
|
+
document.addEventListener('paste', function (e) {
|
|
4780
|
+
if (!e.target || e.target.id !== 'session-input') return;
|
|
4781
|
+
var kit = readerKit(), files = kit && readerFilesOn() ? kit.pasted(e) : null;
|
|
4782
|
+
if (files) { e.preventDefault(); addReaderFiles(files); }
|
|
4783
|
+
});
|
|
4784
|
+
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(function (type) {
|
|
4785
|
+
document.addEventListener(type, function (e) {
|
|
4786
|
+
var form = e.target && e.target.closest ? e.target.closest('#session-form') : null, kit = readerKit();
|
|
4787
|
+
if (!form || !kit || !readerFilesOn()) return;
|
|
4788
|
+
var files = kit.dragged(e, form); if (files) addReaderFiles(files);
|
|
4789
|
+
});
|
|
4790
|
+
});
|
|
4791
|
+
document.addEventListener('paste', function (e) {
|
|
4792
|
+
if (!e.target || e.target.id !== 'ask-q') return;
|
|
4793
|
+
var kit = askKit(), files = kit && kit.askOn() ? kit.pasted(e) : null;
|
|
4794
|
+
if (files) { e.preventDefault(); addAskFiles(files); }
|
|
4795
|
+
});
|
|
4796
|
+
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(function (type) {
|
|
4797
|
+
var sheet = $('ask-sheet'); if (!sheet) return;
|
|
4798
|
+
sheet.addEventListener(type, function (e) {
|
|
4799
|
+
var kit = askKit(), zone = sheet.querySelector('.sheet'); if (!kit || !zone || !kit.askOn()) return;
|
|
4800
|
+
var files = kit.dragged(e, zone); if (files) addAskFiles(files);
|
|
4801
|
+
});
|
|
4802
|
+
});
|
|
4038
4803
|
document.addEventListener('input', function (e) {
|
|
4039
4804
|
if (e.target.tagName === 'TEXTAREA') autoGrow(e.target);
|
|
4040
4805
|
if (e.target.id === 'ask-q') renderAsk();
|
|
4041
4806
|
if (e.target.id === 'sessions-q' && !e.target.value.trim()) renderSessionGroups(state.sessions);
|
|
4042
4807
|
});
|
|
4043
4808
|
document.addEventListener('keydown', function (e) {
|
|
4809
|
+
if (window.COSWeb.readerMedia && window.COSWeb.readerMedia.isOpen()) return;
|
|
4044
4810
|
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { e.preventDefault(); openAsk(); return; }
|
|
4045
4811
|
if (e.key === 'Escape' && !$('ask-sheet').classList.contains('hidden')) { closeAsk(); return; }
|
|
4046
4812
|
if (e.key === 'Escape' && !$('section-menu').classList.contains('hidden')) { closeMenu(); $('section-btn').focus(); return; }
|
|
4047
|
-
if (e
|
|
4813
|
+
if (returnSends(e)) { e.preventDefault(); var form = e.target.closest('form'); if (form) form.requestSubmit(); }
|
|
4048
4814
|
});
|
|
4049
4815
|
document.querySelectorAll('[data-mode]').forEach(function (b) {
|
|
4050
4816
|
b.addEventListener('click', function () {
|
|
@@ -4055,6 +4821,17 @@
|
|
|
4055
4821
|
});
|
|
4056
4822
|
window.addEventListener('hashchange', route);
|
|
4057
4823
|
window.addEventListener('scroll', onScroll, { passive: true });
|
|
4824
|
+
// 6.99.30: back from the browser's back-forward cache (a swipe back, the app switcher) the open reader's live stream
|
|
4825
|
+
// is closed and the page's last reading is however old the visit was. Open the stream again and read the turns.
|
|
4826
|
+
window.addEventListener('pageshow', function (e) {
|
|
4827
|
+
if (!e || !e.persisted) return;
|
|
4828
|
+
var r = state.reading;
|
|
4829
|
+
if (r && r.provider && r.id) {
|
|
4830
|
+
if (state.stream) { try { state.stream.close(); } catch (_e) {} state.stream = null; }
|
|
4831
|
+
watchSession(r.provider, r.id); refreshTurns(r.provider, r.id);
|
|
4832
|
+
}
|
|
4833
|
+
if (state.pane === 'today' && !$('app-shell').classList.contains('hidden')) loadToday();
|
|
4834
|
+
});
|
|
4058
4835
|
document.addEventListener('visibilitychange', function () {
|
|
4059
4836
|
if (document.hidden || $('app-shell').classList.contains('hidden')) return;
|
|
4060
4837
|
// Coming back to the page refreshes Today in place. Other panes keep what is on screen:
|
|
@@ -4095,10 +4872,80 @@
|
|
|
4095
4872
|
});
|
|
4096
4873
|
}
|
|
4097
4874
|
|
|
4875
|
+
// ── the visible viewport, read one way (6.99.30) ──────
|
|
4876
|
+
// Today's session sheet (panes/today-actions.js) and the question card's footer on Today (panes/questions.js) both
|
|
4877
|
+
// place things above whatever covers the bottom of the page: an on-screen keyboard, a hardware keyboard's strip, or a
|
|
4878
|
+
// browser's own bottom bar (Comet, Chrome, Safari's, whichever: nothing here knows any browser's bar). Only what the
|
|
4879
|
+
// browser reports is used: window.visualViewport's height, offsetTop and scale against the layout viewport.
|
|
4880
|
+
// layoutH the layout viewport's height. Read whenever the page is not pinch-zoomed (then innerHeight is the layout
|
|
4881
|
+
// viewport everywhere) and kept while it is, since some browsers report the zoomed size there.
|
|
4882
|
+
// inset what covers the bottom, in unzoomed CSS px: layoutH less the visible height times the zoom. A pinch
|
|
4883
|
+
// zoom alone is no inset (half the height at 2x is all of the page).
|
|
4884
|
+
// top, height the box to fill, in layout px. Unzoomed it is the visible viewport itself (offsetTop is where a
|
|
4885
|
+
// browser panned the page to keep a focused field in view). Zoomed it is the layout viewport less the
|
|
4886
|
+
// inset, so a pinch zoom magnifies the sheet instead of shrinking its layout into the zoomed window.
|
|
4887
|
+
// below what of the layout viewport lies under that box: the backdrop alone reaches down over it.
|
|
4888
|
+
// No visualViewport (old browsers): the whole layout viewport, nothing covered.
|
|
4889
|
+
var VP = { layoutH: 0 };
|
|
4890
|
+
function viewportFit() {
|
|
4891
|
+
var vv = window.visualViewport, de = document.documentElement;
|
|
4892
|
+
var inner = window.innerHeight || (de && de.clientHeight) || 0;
|
|
4893
|
+
var scale = vv && vv.scale > 0 ? vv.scale : 1, zoomed = scale > 1.01;
|
|
4894
|
+
if (!zoomed || !VP.layoutH) VP.layoutH = zoomed ? Math.max(inner, (de && de.clientHeight) || 0) : inner;
|
|
4895
|
+
var H = VP.layoutH;
|
|
4896
|
+
if (!vv || !(vv.height > 0)) return { layoutH: H, top: 0, height: H, inset: 0, below: 0, scale: 1, zoomed: false, vv: false };
|
|
4897
|
+
var inset = Math.max(0, Math.round(H - vv.height * scale));
|
|
4898
|
+
var top, height;
|
|
4899
|
+
if (!zoomed) { top = Math.max(0, Math.round(vv.offsetTop || 0)); height = Math.min(Math.round(vv.height), H); }
|
|
4900
|
+
else { height = H - inset; top = Math.min(Math.max(0, Math.round(vv.offsetTop || 0)), inset); }
|
|
4901
|
+
if (top + height > H) top = Math.max(0, H - height);
|
|
4902
|
+
return { layoutH: H, top: top, height: height, inset: inset, below: Math.max(0, H - top - height), scale: scale, zoomed: zoomed, vv: true };
|
|
4903
|
+
}
|
|
4904
|
+
|
|
4905
|
+
// ── layout breadcrumbs (6.99.30) ──────────────────────
|
|
4906
|
+
// When a sheet lands wrong on a phone nobody here holds, the numbers have to come back on their own. A breadcrumb
|
|
4907
|
+
// goes to the server's existing client diagnostics log (POST /api/diag/client, routes/diag.ts: 10 MB with one
|
|
4908
|
+
// rotation, 30 lines per 10 s per client) when an overlay opens and each time its keyboard state changes. Numbers and
|
|
4909
|
+
// a browser family only: no address, no text, no ids, never the user agent string itself. At most one identical
|
|
4910
|
+
// line per 2 s and LAYOUT_CRUMB_MAX a page load, fire and forget.
|
|
4911
|
+
var LAYOUT_CRUMB_MAX = 40, LAYOUT_CRUMB_GAP_MS = 2000;
|
|
4912
|
+
var crumbs = { sent: 0, last: '', lastAt: 0 };
|
|
4913
|
+
/** The browser family, from the UA string, for the breadcrumb. Comet first: it says Chrome or Safari too. */
|
|
4914
|
+
function uaClass(ua) {
|
|
4915
|
+
ua = String(ua == null ? (navigator && navigator.userAgent) || '' : ua);
|
|
4916
|
+
var ios = /iPhone|iPad|iPod/.test(ua) || (/Macintosh/.test(ua) && typeof navigator !== 'undefined' && navigator.maxTouchPoints > 1);
|
|
4917
|
+
var android = /Android/.test(ua);
|
|
4918
|
+
var os = ios ? 'ios' : android ? 'android' : /Mac OS X|Macintosh/.test(ua) ? 'mac' : /Windows/.test(ua) ? 'windows' : /Linux|CrOS/.test(ua) ? 'linux' : 'other';
|
|
4919
|
+
var hints = typeof navigator !== 'undefined' && navigator.userAgentData && navigator.userAgentData.brands ? navigator.userAgentData.brands.map(function (b) { return b && b.brand; }).join(' ') : '';
|
|
4920
|
+
var brand = /Comet\//i.test(ua) || /\bComet\b/i.test(ua) || /\bComet\b/i.test(hints) ? 'comet'
|
|
4921
|
+
: /CriOS\//.test(ua) ? 'crios' : /FxiOS\//.test(ua) ? 'firefox' : /EdgiOS\/|Edg\//.test(ua) ? 'edge'
|
|
4922
|
+
: /Firefox\//.test(ua) ? 'firefox' : /Chrome\/|Chromium\//.test(ua) ? 'chrome'
|
|
4923
|
+
: /Safari\//.test(ua) ? 'safari' : 'other';
|
|
4924
|
+
return { os: os, brand: brand };
|
|
4925
|
+
}
|
|
4926
|
+
/** One breadcrumb: `event` (open, kb), the viewport as the browser reports it, and the overlay's own state. */
|
|
4927
|
+
function layoutCrumb(event, extra) {
|
|
4928
|
+
try {
|
|
4929
|
+
var vv = window.visualViewport, de = document.documentElement, ua = uaClass();
|
|
4930
|
+
var r = function (n) { return typeof n === 'number' && isFinite(n) ? Math.round(n * 100) / 100 : null; };
|
|
4931
|
+
var o = (screen && screen.orientation && screen.orientation.type) || (typeof window.orientation === 'number' ? 'deg' + window.orientation : '');
|
|
4932
|
+
var data = { what: String(event || ''), ua: ua.brand, os: ua.os, iw: r(window.innerWidth), ih: r(window.innerHeight), ch: r(de && de.clientHeight),
|
|
4933
|
+
vvh: vv ? r(vv.height) : null, vvw: vv ? r(vv.width) : null, vvt: vv ? r(vv.offsetTop) : null, vvs: vv ? r(vv.scale) : null,
|
|
4934
|
+
orient: String(o).slice(0, 24), coarse: !!(window.matchMedia && window.matchMedia('(pointer: coarse)').matches) };
|
|
4935
|
+
var x = extra || {};
|
|
4936
|
+
Object.keys(x).forEach(function (k) { var v = x[k]; if (/^[a-z][a-zA-Z0-9]{0,15}$/.test(k) && (typeof v === 'number' || typeof v === 'boolean' || (typeof v === 'string' && v.length <= 24))) data[k] = typeof v === 'number' ? r(v) : v; });
|
|
4937
|
+
var key = JSON.stringify(data), now = Date.now();
|
|
4938
|
+
if (crumbs.sent >= LAYOUT_CRUMB_MAX || (key === crumbs.last && now - crumbs.lastAt < LAYOUT_CRUMB_GAP_MS)) return false;
|
|
4939
|
+
crumbs.sent++; crumbs.last = key; crumbs.lastAt = now;
|
|
4940
|
+
api('/api/diag/client', { method: 'POST', body: { ts: now, level: 'info', event: 'web.layout', data: data }, timeoutMs: 5000 }).catch(function () {});
|
|
4941
|
+
return true;
|
|
4942
|
+
} catch (_e) { return false; }
|
|
4943
|
+
}
|
|
4944
|
+
|
|
4098
4945
|
// ── the shared kit for pane modules (panes/*.js) ───────
|
|
4099
4946
|
window.COSWeb = {
|
|
4100
4947
|
$: $, esc: esc, md: md, plain: plain, api: api, toast: toast, ago: ago, clock: clock, toDate: toDate,
|
|
4101
|
-
plural: plural, countWord: countWord, dayWord: dayWord, providerLabel: providerLabel, domainLabel: domainLabel,
|
|
4948
|
+
plural: plural, countWord: countWord, dayWord: dayWord, plainDay: plainDay, providerLabel: providerLabel, domainLabel: domainLabel,
|
|
4102
4949
|
skeletonRows: skeletonRows, emptyBox: emptyBox, setStatus: setStatus, autoGrow: autoGrow,
|
|
4103
4950
|
readStore: readStore, writeStore: writeStore, copyText: copyText, isPhone: isPhone,
|
|
4104
4951
|
state: state, titles: TITLES, go: go, parseRoute: parseRoute, goBack: goBack,
|
|
@@ -4106,6 +4953,7 @@
|
|
|
4106
4953
|
sessionList: sessionList,
|
|
4107
4954
|
petPrefs: petPrefs,
|
|
4108
4955
|
setPetPrefs: setPetPrefs,
|
|
4956
|
+
resetTodayBrief: resetBrief,
|
|
4109
4957
|
onRoute: function (fn) { routeHooks.push(fn); },
|
|
4110
4958
|
onShell: function (fn) { shellHooks.push(fn); },
|
|
4111
4959
|
// The detail title after "COS Control > Section" (Control's breadcrumb). Pane modules set it
|
|
@@ -4117,7 +4965,13 @@
|
|
|
4117
4965
|
// Control's session state (the Sessions pane's sxState) and warm-up test, so the rail's
|
|
4118
4966
|
// "N waiting on you, N working" counts exactly what the pane lists.
|
|
4119
4967
|
sessionState: function (s) { return sxState(s); },
|
|
4120
|
-
isKeepWarm: function (s) { return sxKeepWarm(s); }
|
|
4968
|
+
isKeepWarm: function (s) { return sxKeepWarm(s); },
|
|
4969
|
+
// 6.99.30: the visible viewport read one way, layout breadcrumbs, the one unreachable sentence, Return-sends.
|
|
4970
|
+
viewportFit: viewportFit, layoutCrumb: layoutCrumb, uaClass: uaClass, unreachableCopy: UNREACHABLE_COPY, keyboardFirst: keyboardFirst,
|
|
4971
|
+
serverUp: function () { return SERVER.up; },
|
|
4972
|
+
// Panes with their own fetch (the questions poll) say whether the Mac answered, so a "down" mark never outlives it.
|
|
4973
|
+
noteServer: function (up) { noteServer(up); },
|
|
4974
|
+
onServer: function (fn) { SERVER.hooks.push(fn); }
|
|
4121
4975
|
};
|
|
4122
4976
|
// Today as a place to act (panes/today-actions.js, Miles 11/06): what the Today cards show, a repaint after a
|
|
4123
4977
|
// decision, and the Sessions reader's own pieces, so the overlay sends and reads through the reader's code.
|
|
@@ -4139,7 +4993,11 @@
|
|
|
4139
4993
|
railLine: function () { return TODAY.model ? { text: todayRailLine(TODAY.model), hot: TODAY.model.now.length > 0 } : null; },
|
|
4140
4994
|
markGone: function (id, gone) { if (gone) TODAY.gone[id] = 1; else delete TODAY.gone[id]; paintTodayIntake(); },
|
|
4141
4995
|
reloadIntake: function () { return loadTodayIntake(); },
|
|
4142
|
-
refresh: function () { if (state.pane === 'today') loadToday(); }
|
|
4996
|
+
refresh: function () { if (state.pane === 'today') loadToday(); },
|
|
4997
|
+
// 6.99.26: the pet's sessions (petPlay), and a repaint of Needs you when the held questions change.
|
|
4998
|
+
petPlay: function () { return petPlay(state.sessions || []); },
|
|
4999
|
+
// 6.99.27: the pet's caption and pose follow the held questions too.
|
|
5000
|
+
questionsChanged: function () { if (TODAY.at) { paintNeeds(); if (state.pane === 'today' && !TODAY.offline) renderPet(state.sessions || []); } }
|
|
4143
5001
|
};
|
|
4144
5002
|
window.COSWeb.sessionKit = {
|
|
4145
5003
|
// A session object for continueSession, with the overlay's own view (READER_VIEW's methods).
|
|
@@ -4150,7 +5008,10 @@
|
|
|
4150
5008
|
newKey: newTurnKey,
|
|
4151
5009
|
probe: function (r) { return sxProbeAttach(r); },
|
|
4152
5010
|
path: sessionPath, answer: apiAnswer,
|
|
4153
|
-
|
|
5011
|
+
notSent: { rows: notSentRows, line: notSentLine, note: noteNotSent, get: notSentFor },
|
|
5012
|
+
imageHtml: readerImagesHtml, imagesMount: readerImagesMount, imagesStop: readerImagesStop,
|
|
5013
|
+
feed: { fresh: newFeed, apply: applyFeed, word: sxFeedWord, detail: sxFeedDetail, tool: sxToolLine, elapsed: sxElapsed, fallback: sxFallbackLine,
|
|
5014
|
+
truth: sxTruth, settle: sxSettle, noteList: sxNoteList, noteGate: sxNoteGate, gateStale: sxGateStale, promptLine: sxPromptLine, parkNoteShows: sxParkNoteShows },
|
|
4154
5015
|
title: sxTitle, stateOf: sxState, stateLabel: function (s) { return sxStateLabel(s, sxState(s)); },
|
|
4155
5016
|
parkCopy: function (p) { return SX_PARK[sxProvider(p)]; }, providerLabel: sxProviderLabel, markdown: sxMd
|
|
4156
5017
|
};
|
|
@@ -4163,7 +5024,7 @@
|
|
|
4163
5024
|
history.replaceState({}, document.title, location.pathname + location.hash);
|
|
4164
5025
|
api('/api/web-session/login', { method: 'POST', body: { code: linkCode } }).then(showShell)
|
|
4165
5026
|
.catch(function (err) {
|
|
4166
|
-
showAuth(err && err.status ? err.message :
|
|
5027
|
+
showAuth(err && err.status ? err.message : UNREACHABLE_COPY);
|
|
4167
5028
|
var box = $('auth-code'); if (box) box.value = linkCode;
|
|
4168
5029
|
});
|
|
4169
5030
|
return;
|
|
@@ -4175,7 +5036,7 @@
|
|
|
4175
5036
|
.catch(function (err) { showAuth(err && err.status ? err.message : 'That sign-in link did not work. Get a new one from COS Control on your Mac or the COS Glasses app, or use a code or the token.'); });
|
|
4176
5037
|
return;
|
|
4177
5038
|
}
|
|
4178
|
-
api('/api/web-session/status').then(function (st) { if (st && st.authenticated) showShell(st); else showAuth(); }).catch(function () { showAuth(
|
|
5039
|
+
api('/api/web-session/status').then(function (st) { if (st && st.authenticated) showShell(st); else showAuth(); }).catch(function () { showAuth(UNREACHABLE_COPY); });
|
|
4179
5040
|
}
|
|
4180
5041
|
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); else boot();
|
|
4181
5042
|
})();
|