@gotcos/glasses-server 7.0.7 → 7.0.18
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/.env.example +19 -0
- package/CHANGELOG.md +170 -0
- package/docs/web-key-verifier-contract.md +378 -0
- package/managed-runtime-contract.json +9 -1
- package/package.json +10 -3
- package/server/bootstrap.ts +10 -0
- package/server/index.ts +126 -23
- package/server/lib/agent-session-recent.ts +321 -0
- package/server/lib/agent-session-store.ts +3 -3
- package/server/lib/api-auth.ts +37 -1
- package/server/lib/calendar-meetings.ts +70 -0
- package/server/lib/calendar-source.ts +54 -9
- package/server/lib/claude-hooks-installer.ts +111 -12
- package/server/lib/conversation.ts +56 -28
- package/server/lib/cors-reject-log.ts +51 -0
- package/server/lib/disk-io-meter.ts +202 -0
- package/server/lib/drop-service.ts +47 -12
- package/server/lib/drop-store.ts +21 -1
- package/server/lib/event-loop-stall.ts +62 -0
- package/server/lib/full-hold-engine.ts +114 -0
- package/server/lib/full-hold.ts +124 -0
- package/server/lib/g2-turn-provenance.ts +128 -9
- package/server/lib/listener-startup.ts +18 -0
- package/server/lib/meeting-batch-transcribe.ts +32 -5
- package/server/lib/permission-broker.ts +443 -11
- package/server/lib/secret-path-guard.ts +4 -1
- package/server/lib/session-cache-writer.ts +8 -1
- package/server/lib/session-hook-events.ts +23 -1
- package/server/lib/session-hooks-runtime.ts +34 -0
- package/server/lib/session-log.ts +313 -4
- package/server/lib/session-state-derive.ts +36 -0
- package/server/lib/session-stream-events.ts +25 -1
- package/server/lib/today-brief.ts +522 -58
- package/server/lib/turn-end-ask.ts +320 -0
- package/server/lib/turn-footer.ts +3 -3
- package/server/lib/web-control-integrity.ts +160 -0
- package/server/lib/web-control-offnet-page.ts +58 -0
- package/server/lib/web-control-static.ts +53 -9
- package/server/lib/web-key-authority.ts +372 -0
- package/server/lib/web-key-envelope.ts +98 -0
- package/server/lib/web-key-handoff.ts +135 -0
- package/server/lib/web-key-origin.ts +21 -0
- package/server/lib/web-key-passkey.ts +155 -0
- package/server/lib/web-key-registry.ts +825 -0
- package/server/lib/web-key-signer-app.ts +152 -0
- package/server/lib/web-session-auth.ts +133 -15
- package/server/lib/whisper-local.ts +93 -7
- package/server/lib/whisper-preview.ts +5 -3
- package/server/lib/work-orchestrator/snapshot.ts +17 -3
- package/server/lib/work-review-runtime.ts +17 -4
- package/server/routes/agent-session-bindings.ts +16 -1
- package/server/routes/agent-session-stream.ts +7 -0
- package/server/routes/drops.ts +7 -2
- package/server/routes/g2-turns.ts +35 -0
- package/server/routes/health.ts +11 -1
- package/server/routes/live-events.ts +6 -1
- package/server/routes/permission-broker.ts +26 -0
- package/server/routes/query-jobs.ts +3 -0
- package/server/routes/sessions.ts +15 -24
- package/server/routes/thread-turn-queue.ts +16 -9
- package/server/routes/today-brief.ts +91 -36
- package/server/routes/web-control.ts +37 -4
- package/server/routes/web-key.ts +284 -0
- package/server/routes/welcome-context.ts +6 -2
- package/server/routes/work-orchestrator.ts +12 -7
- package/server/scripts/write-web-control-manifest.mjs +43 -0
- package/server/web-control/.manifest.json +44 -0
- package/server/web-control/app.css +36 -0
- package/server/web-control/app.js +953 -57
- package/server/web-control/apple-touch-icon.png +0 -0
- package/server/web-control/index.html +14 -1
- package/server/web-control/panes/cos-chat.css +52 -6
- package/server/web-control/panes/cos-chat.js +364 -32
- package/server/web-control/panes/questions.js +135 -14
- package/server/web-control/panes/settings.js +1 -1
- package/server/web-control/panes/today-actions.css +35 -0
- package/server/web-control/panes/today-actions.js +75 -6
- package/server/web-control/panes/work.css +3 -0
- package/server/web-control/panes/work.js +178 -35
- package/server/web-signer/.manifest.json +11 -0
- package/server/web-signer/index.html +74 -0
- package/server/web-signer/signer-proof.js +348 -0
- package/server/web-signer/signer-store.js +39 -0
- package/server/web-signer/signer.css +23 -0
- package/server/web-signer/signer.js +341 -0
- package/shared/web-approval-packets.golden.json +124 -0
- package/shared/web-turn-envelope.ts +271 -0
|
@@ -266,7 +266,10 @@
|
|
|
266
266
|
if (timer && o.responseType !== 'blob') clearTimeout(timer);
|
|
267
267
|
sxNoteClock(res, sentAt);
|
|
268
268
|
noteServer(true);
|
|
269
|
-
|
|
269
|
+
// A request that renewed this browser's sign-in says so (lib/api-auth.ts): `<new expiry> <30-day cap>`.
|
|
270
|
+
var renewedTo = res.headers && typeof res.headers.get === 'function' ? res.headers.get('X-COS-Web-Session') : null;
|
|
271
|
+
if (renewedTo && typeof signinRemember === 'function') { var rt = String(renewedTo).split(' '); signinRemember({ expiresAt: rt[0], renewUntil: rt[1] }); }
|
|
272
|
+
if (res.status === 401 && path.indexOf('/api/web-session/') !== 0) { showAuth(typeof signinLapsedCopy === 'function' && signedInOnce() ? signinLapsedCopy() : undefined); var gone = new Error('Signed out. Connect this browser again.'); gone.status = 401; throw gone; }
|
|
270
273
|
if (o.responseType === 'blob' && res.ok) return res.blob();
|
|
271
274
|
return res.text().then(function (text) {
|
|
272
275
|
var data = {};
|
|
@@ -357,6 +360,103 @@
|
|
|
357
360
|
sessions: [], sessionsMode: 'words', workTasks: [], workOpenStages: { complete: false },
|
|
358
361
|
stream: null, lastLoad: {}, livePoll: null
|
|
359
362
|
};
|
|
363
|
+
// ── Mac reachability and sign-in lifetime (W1, 7.0.x): audit 02 F2, F3, F23 ──
|
|
364
|
+
// F3: with no offline shell (that needs a service worker, so HTTPS), an open page says plainly when the Mac stops answering,
|
|
365
|
+
// when it last heard from it, and offers Retry, instead of spinners. Retry asks GET /api/health (public, no state) and then
|
|
366
|
+
// reloads the pane on screen. While the Mac is down it asks again every 30 s, only while the page is visible.
|
|
367
|
+
// F2: a sign-in lasts 7 days and, since 7.0.x, renews while in use up to 30 days (lib/web-session-auth.ts). Status and the
|
|
368
|
+
// login answer carry this browser's own expiresAt; within a day of it the page says when it ends and how to sign in again.
|
|
369
|
+
// Sign-in (7.0.x, Miles 2026-11-09 "Renew while in use, 30-day cap"): `ends` is this cookie's expiry, `cap` the latest a
|
|
370
|
+
// renewal can reach (30 days from the first sign-in). A server without renewal sends no cap: then the cookie's end is the end.
|
|
371
|
+
var CONN = { timer: null, retrying: false, signin: null, hideFor: 0, lapsed: false };
|
|
372
|
+
var CONN_RETRY_MS = 30000, SIGNIN_WARN_MS = 24 * 3600000, SIGNIN_STORE = 'cos.web.signinEnds';
|
|
373
|
+
// Names the row, never the Settings page: that page was renamed in COS Control 0.5.387, and a phone may pair with either build.
|
|
374
|
+
var SIGNIN_HOW = 'On your Mac, open COS Control Settings and choose Open COS on your phone. Or get a code from another signed-in browser, or from the COS Glasses app.';
|
|
375
|
+
function connWhen(ms) {
|
|
376
|
+
if (!ms) return '';
|
|
377
|
+
var d = new Date(ms), same = new Date().toDateString() === d.toDateString();
|
|
378
|
+
return same ? clock(d) : d.toLocaleDateString(undefined, { weekday: 'short' }) + ' ' + clock(d);
|
|
379
|
+
}
|
|
380
|
+
function signinRemember(info) {
|
|
381
|
+
var ends = info && info.expiresAt ? Date.parse(info.expiresAt) : NaN, cap = info && info.renewUntil ? Date.parse(info.renewUntil) : NaN;
|
|
382
|
+
// Q1: a signed-in answer with no expiry (the token, or an older server) leaves nothing to warn about.
|
|
383
|
+
if (info && info.authenticated === true && !info.expiresAt) {
|
|
384
|
+
CONN.signin = null;
|
|
385
|
+
try { localStorage.removeItem(SIGNIN_STORE); } catch (e) { /* nothing kept */ }
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
if (!isFinite(ends) || ends <= 0) return;
|
|
389
|
+
CONN.signin = { ends: ends, cap: isFinite(cap) && cap >= ends ? cap : 0 };
|
|
390
|
+
try { localStorage.setItem(SIGNIN_STORE, JSON.stringify(CONN.signin)); } catch (e) { /* the notice still works for this page */ }
|
|
391
|
+
if (typeof connPaint === 'function') connPaint();
|
|
392
|
+
}
|
|
393
|
+
function signinKnown() {
|
|
394
|
+
if (CONN.signin) return CONN.signin;
|
|
395
|
+
try {
|
|
396
|
+
var raw = localStorage.getItem(SIGNIN_STORE), v = raw ? JSON.parse(raw) : null;
|
|
397
|
+
if (typeof v === 'number' && v > 0) return { ends: v, cap: 0 }; // the earlier page kept the expiry alone
|
|
398
|
+
if (v && typeof v.ends === 'number' && v.ends > 0) return { ends: v.ends, cap: typeof v.cap === 'number' ? v.cap : 0 };
|
|
399
|
+
} catch (e) { /* nothing known */ }
|
|
400
|
+
return null;
|
|
401
|
+
}
|
|
402
|
+
// When this sign-in really ends while it is in use: the 30-day cap where the server renews, else the cookie's own end.
|
|
403
|
+
function signinEndsAt() { var k = signinKnown(); return k ? (k.cap || k.ends) : 0; }
|
|
404
|
+
// Why the sign-in gate opened after this page had been signed in: the 30 days, no use in the cookie's last 2 days (its
|
|
405
|
+
// only renewal window, so a browser used on day 4 still ends on day 7), or a sign-out. Q1 R-3.
|
|
406
|
+
function signinLapsedCopy() {
|
|
407
|
+
var k = signinKnown(), soon = Date.now() + 60000;
|
|
408
|
+
if (k && k.cap && k.cap <= soon) return 'Your sign-in on this browser reached its 30 days. ' + SIGNIN_HOW;
|
|
409
|
+
if (k && k.cap && k.ends <= soon) return 'Your sign-in on this browser ended because COS was not used here in its last 2 days. ' + SIGNIN_HOW;
|
|
410
|
+
if (k && !k.cap && k.ends <= soon) return 'Your sign-in on this browser ended. Sign-ins last 7 days. ' + SIGNIN_HOW;
|
|
411
|
+
return 'This browser is signed out. ' + SIGNIN_HOW;
|
|
412
|
+
}
|
|
413
|
+
// Q1 M1: a request that never got an answer cannot tell a sleeping Mac from this device being offline or off
|
|
414
|
+
// Tailscale, so the line names both. Only the browser's own "offline" is certain enough to say alone.
|
|
415
|
+
function connOffline() { return typeof navigator !== 'undefined' && navigator && navigator.onLine === false; }
|
|
416
|
+
function connTitle() { return connOffline() ? 'This device is offline.' : 'COS on your Mac is not answering.'; }
|
|
417
|
+
function connWhy() {
|
|
418
|
+
return connOffline() ? 'Connect to the internet and Tailscale, then try again.'
|
|
419
|
+
: 'Your Mac may be asleep, or this device may be offline or off Tailscale.';
|
|
420
|
+
}
|
|
421
|
+
function connHtml() {
|
|
422
|
+
var html = '';
|
|
423
|
+
if (SERVER.up === false) {
|
|
424
|
+
html += '<div class="conn-line" role="status"><strong>' + esc(connTitle()) + '</strong><span class="conn-sub">' +
|
|
425
|
+
esc((SERVER.lastUpAt ? 'Last updated ' + connWhen(SERVER.lastUpAt) + '. ' : '') + 'What is on screen may be out of date. ' + connWhy()) + '</span>' +
|
|
426
|
+
'<button type="button" class="btn small" data-action="conn-retry"' + (CONN.retrying ? ' disabled' : '') + '>' + (CONN.retrying ? 'Checking' : 'Retry') + '</button></div>';
|
|
427
|
+
}
|
|
428
|
+
var ends = signinEndsAt(), left = ends - Date.now();
|
|
429
|
+
if (ends && left > 0 && left <= SIGNIN_WARN_MS && CONN.hideFor !== ends) {
|
|
430
|
+
html += '<div class="conn-line" role="status"><strong>Sign-in here ends ' + esc(connWhen(ends)) + '.</strong><span class="conn-sub">' +
|
|
431
|
+
esc('Sign in again before then from COS Control Settings on your Mac: Open COS on your phone.') + '</span>' +
|
|
432
|
+
'<button type="button" class="btn small" data-action="conn-hide-signin">Hide</button></div>';
|
|
433
|
+
}
|
|
434
|
+
return html;
|
|
435
|
+
}
|
|
436
|
+
function connPaint() {
|
|
437
|
+
var box = $('conn-notice'); if (!box) return;
|
|
438
|
+
var html = connHtml();
|
|
439
|
+
if (box.innerHTML !== html) box.innerHTML = html;
|
|
440
|
+
box.hidden = !html;
|
|
441
|
+
}
|
|
442
|
+
function connSchedule() {
|
|
443
|
+
if (CONN.timer) { clearTimeout(CONN.timer); CONN.timer = null; }
|
|
444
|
+
if (SERVER.up !== false) return;
|
|
445
|
+
CONN.timer = setTimeout(function () { CONN.timer = null; if (!document.hidden) connRetry(false); else connSchedule(); }, CONN_RETRY_MS);
|
|
446
|
+
}
|
|
447
|
+
function connRetry(byHand) {
|
|
448
|
+
if (CONN.retrying) return Promise.resolve(false);
|
|
449
|
+
CONN.retrying = true; connPaint();
|
|
450
|
+
return api('/api/health', { timeoutMs: 10000 }).then(function () { return true; }, function () { return false; }).then(function (up) {
|
|
451
|
+
CONN.retrying = false;
|
|
452
|
+
if (up) { connPaint(); if (typeof route === 'function') route(); }
|
|
453
|
+
else { if (byHand) noteServer(false); connPaint(); connSchedule(); }
|
|
454
|
+
return up;
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
SERVER.hooks.push(function () { connPaint(); connSchedule(); });
|
|
458
|
+
// ── end Mac reachability and sign-in lifetime
|
|
459
|
+
|
|
360
460
|
// Control's order (ActivitySection.visibleSections): Today, then Messages to Dictation. Usage is a
|
|
361
461
|
// view inside Sessions on the Mac; its route stays so old links work, and the rail marks Sessions.
|
|
362
462
|
var ROOT_PANES = ['today', 'messages', 'speakers', 'meetings', 'memories', 'threads', 'sessions', 'work', 'dictation', 'usage', 'settings'];
|
|
@@ -531,6 +631,8 @@
|
|
|
531
631
|
readerImagesStop($('session-turns')); readerImagesStop($('ta-last'));
|
|
532
632
|
if (typeof liveHide === 'function') { liveHide(); liveStopWatch(); LIVE.mode = 'idle'; }
|
|
533
633
|
var alreadyOpen = !$('auth-gate').classList.contains('hidden');
|
|
634
|
+
if (typeof CONN === 'object' && !$('app-shell').classList.contains('hidden')) CONN.lapsed = true;
|
|
635
|
+
if (typeof authOffline === 'function') authOffline(false);
|
|
534
636
|
$('app-shell').classList.add('hidden');
|
|
535
637
|
$('auth-gate').classList.remove('hidden');
|
|
536
638
|
// A later 401 from a pane still polling (W.api passes no message) must not blank what the gate already says:
|
|
@@ -539,13 +641,17 @@
|
|
|
539
641
|
setStatus($('auth-error'), message || '', true);
|
|
540
642
|
$('auth-error').classList.toggle('hidden', !message);
|
|
541
643
|
}
|
|
644
|
+
function signedInOnce() { var shell = typeof $ === 'function' ? $('app-shell') : null; return !!(shell && !shell.classList.contains('hidden')) || CONN.lapsed; }
|
|
542
645
|
function showShell(info) {
|
|
646
|
+
if (typeof signinRemember === 'function') signinRemember(info);
|
|
647
|
+
if (typeof authOffline === 'function') authOffline(false);
|
|
543
648
|
$('auth-gate').classList.add('hidden');
|
|
544
649
|
$('app-shell').classList.remove('hidden');
|
|
545
650
|
state.serverName = (info && info.serverName) || 'Your Mac';
|
|
546
651
|
$('host-name').textContent = state.serverName.replace(/-/g, ' ');
|
|
547
652
|
loadTailnetUrl();
|
|
548
653
|
runHooks(shellHooks, info);
|
|
654
|
+
if (typeof connPaint === 'function') connPaint();
|
|
549
655
|
route();
|
|
550
656
|
if (typeof liveStart === 'function') liveStart(); // 7.0.1: the one live stream for this page (a no-op if it is already open)
|
|
551
657
|
// Reloaded with Today's overlay open: its history entry is still current. The overlay opens the same thing again
|
|
@@ -1282,7 +1388,8 @@
|
|
|
1282
1388
|
sessionList(force === true ? 0 : 15000).catch(function () { return null; }),
|
|
1283
1389
|
api('/api/work-board/your-move').catch(function () { return null; }),
|
|
1284
1390
|
api('/api/welcome-context').catch(function () { return null; }),
|
|
1285
|
-
api('/api/meetings/live-chunks').catch(function () { return null; })
|
|
1391
|
+
api('/api/meetings/live-chunks').catch(function () { return null; }),
|
|
1392
|
+
loadRunJobs()
|
|
1286
1393
|
]).then(function (res) {
|
|
1287
1394
|
if (gen !== TODAY.gen) return;
|
|
1288
1395
|
var sessions = (res[0] && res[0].sessions) || [];
|
|
@@ -1305,6 +1412,7 @@
|
|
|
1305
1412
|
TODAY.recording = false;
|
|
1306
1413
|
|
|
1307
1414
|
renderRunning(working, open);
|
|
1415
|
+
runJobsSchedule();
|
|
1308
1416
|
if (!offline) renderPet(sessions);
|
|
1309
1417
|
paintNeeds();
|
|
1310
1418
|
renderWeather(welcome);
|
|
@@ -1342,7 +1450,7 @@
|
|
|
1342
1450
|
var box = $('today-rec');
|
|
1343
1451
|
if (!rec) { box.innerHTML = ''; return; }
|
|
1344
1452
|
box.innerHTML = '<button type="button" class="rec-banner" data-goto="meetings"><span class="rec-dot"></span>' +
|
|
1345
|
-
'<span class="rec-copy"><strong>Recording now</strong><span>' + esc(rec.source + (rec.startTime ? ', ' + span(rec.startTime) + ' so far' : '')) + '</span></span>' +
|
|
1453
|
+
'<span class="rec-copy"><strong>Recording now</strong><span>' + esc(rec.source + (rec.startTime ? ', ' + span(rec.startTime) + ' so far' : '') + (rec.source === SOURCES.mac ? '. Stop it in COS Control on your Mac.' : '')) + '</span></span>' +
|
|
1346
1454
|
'<span class="btn small">Live view</span></button>';
|
|
1347
1455
|
}
|
|
1348
1456
|
// ── The Today summary (6.99.26 candidate) ───────────────
|
|
@@ -1350,8 +1458,10 @@
|
|
|
1350
1458
|
// the server (lib/today-brief.ts). Today sends what it already read: session titles, states, how long, each session's
|
|
1351
1459
|
// latest line and what needs you. The server decides whether a new one is written (off unless switched on, only when
|
|
1352
1460
|
// the inputs changed, 10 minutes apart, the Summaries per day cap, a breaker). Today never waits for it: the counts line paints at once
|
|
1353
|
-
// and the summary replaces it when one arrives.
|
|
1354
|
-
|
|
1461
|
+
// and the summary replaces it when one arrives. T1 spec (lead 23:04, same rule as Control 0.5.388): today's summary
|
|
1462
|
+
// stays through a pending or failed session read (its age line already says what could not be read), the block keeps
|
|
1463
|
+
// its tallest height so nothing under it moves while Miles reads, and unchanged text is never written again.
|
|
1464
|
+
var BRIEF = { view: null, inflight: false, sentKey: '', sentAt: 0, reconnect: false, heldPx: 0, heldDay: '', heldWidth: -1, shownKind: '', shownText: '', shownEl: null };
|
|
1355
1465
|
// 6.99.26 QA: the same rows are asked about again only after five minutes (a Today that sits open still learns what
|
|
1356
1466
|
// the server reads itself: sub-agents and Work cards); changed rows are asked about at once, one ask at a time.
|
|
1357
1467
|
var BRIEF_RESEND_MS = 5 * 60000, BRIEF_OFF_RECHECK_MS = 5 * 60000, BRIEF_TICK_MS = 60000, BRIEF_LIVE_GAP_MS = 60000;
|
|
@@ -1412,14 +1522,43 @@
|
|
|
1412
1522
|
if (TODAY.model && state.pane === 'today') renderStory(TODAY.model);
|
|
1413
1523
|
});
|
|
1414
1524
|
}
|
|
1415
|
-
/** The summary to show, or null: only with text,
|
|
1525
|
+
/** The summary to show, or null: only with text, fresh or stale, and written today. T1 spec 0: a session read that is
|
|
1526
|
+
* pending or failed no longer swaps it for the counts line (an offline Today still hides it, in renderStory). */
|
|
1416
1527
|
function briefShown() {
|
|
1417
1528
|
var v = BRIEF.view;
|
|
1418
|
-
if (!v || !v.text || (v.state !== 'fresh' && v.state !== 'stale')
|
|
1529
|
+
if (!v || !v.text || (v.state !== 'fresh' && v.state !== 'stale')) return null;
|
|
1530
|
+
var at = toDate(v.at);
|
|
1531
|
+
if (!at || at.toDateString() !== new Date().toDateString()) return null;
|
|
1419
1532
|
return v;
|
|
1420
1533
|
}
|
|
1421
|
-
// Forget the summary (Settings turned it on or off): the next Today paint asks at once.
|
|
1422
|
-
function resetBrief() { BRIEF.view = null; BRIEF.sentKey = ''; BRIEF.sentAt = 0; }
|
|
1534
|
+
// Forget the summary (Settings turned it on or off): the next Today paint asks at once, and the block may shrink.
|
|
1535
|
+
function resetBrief() { BRIEF.view = null; BRIEF.sentKey = ''; BRIEF.sentAt = 0; BRIEF.heldPx = 0; }
|
|
1536
|
+
// T1 spec 1 and 2: #today-story keeps the tallest height it has had, so a shorter rewrite, or the fall back to the
|
|
1537
|
+
// counts line, never pulls the page up under Miles. It may shrink on a new local day, when its width changes (resize,
|
|
1538
|
+
// Duo fold or unfold, rotation reflow the text anyway), after Refresh lands and after resetBrief; never on a rewrite.
|
|
1539
|
+
function storyHold(el) {
|
|
1540
|
+
if (!el || !el.style || typeof el.getBoundingClientRect !== 'function') return;
|
|
1541
|
+
var day = new Date().toDateString(), w = el.clientWidth || 0;
|
|
1542
|
+
if (BRIEF.heldDay !== day || Math.abs(w - BRIEF.heldWidth) >= 1) { BRIEF.heldPx = 0; BRIEF.heldDay = day; BRIEF.heldWidth = w; }
|
|
1543
|
+
el.style.minHeight = '';
|
|
1544
|
+
var h = Math.ceil(el.getBoundingClientRect().height || 0);
|
|
1545
|
+
if (h > BRIEF.heldPx) BRIEF.heldPx = h;
|
|
1546
|
+
el.style.minHeight = BRIEF.heldPx ? BRIEF.heldPx + 'px' : '';
|
|
1547
|
+
}
|
|
1548
|
+
// T1 spec 3: Today repaints on every session move; the same words are not written again (no reflow, no aria-live
|
|
1549
|
+
// re-announcement). Only the age line under a summary moves on.
|
|
1550
|
+
function storyWrite(el, kind, text, when) {
|
|
1551
|
+
if (BRIEF.shownEl !== el || BRIEF.shownKind !== kind || BRIEF.shownText !== text) {
|
|
1552
|
+
if (kind === 'brief') el.innerHTML = esc(text) + '<span class="story-when">' + esc(when) + '</span>';
|
|
1553
|
+
else el.textContent = text;
|
|
1554
|
+
BRIEF.shownEl = el; BRIEF.shownKind = kind; BRIEF.shownText = text;
|
|
1555
|
+
} else if (kind === 'brief') {
|
|
1556
|
+
var w = typeof document !== 'undefined' && document.querySelector ? document.querySelector('#today-story .story-when') : null;
|
|
1557
|
+
if (w) { if (w.textContent !== when) w.textContent = when; }
|
|
1558
|
+
else el.innerHTML = esc(text) + '<span class="story-when">' + esc(when) + '</span>';
|
|
1559
|
+
}
|
|
1560
|
+
storyHold(el);
|
|
1561
|
+
}
|
|
1423
1562
|
// Every minute while Today is on screen: "Updated N min ago" moves on, and an unchanged Today asks again once its
|
|
1424
1563
|
// five minutes are up (requestBrief's own throttle). Hidden or another pane: nothing.
|
|
1425
1564
|
setInterval(function () {
|
|
@@ -1441,13 +1580,22 @@
|
|
|
1441
1580
|
function renderStory(m) {
|
|
1442
1581
|
var el = $('today-story');
|
|
1443
1582
|
el.className = 'story';
|
|
1444
|
-
|
|
1583
|
+
// Today round 2: "Ask about this" shows under a model summary only (paintTodayAsk, below the Today block). The test
|
|
1584
|
+
// harnesses that run this block alone do not have it, so it is looked up, never assumed.
|
|
1585
|
+
var askBox = typeof paintTodayAsk === 'function' ? paintTodayAsk : function () {};
|
|
1586
|
+
// The harnesses that run this block alone have no storyWrite either: a plain write there.
|
|
1587
|
+
var write = typeof storyWrite === 'function' ? storyWrite : function (e, kind, text, when) {
|
|
1588
|
+
if (kind === 'brief') e.innerHTML = esc(text) + '<span class="story-when">' + esc(when) + '</span>'; else e.textContent = text;
|
|
1589
|
+
};
|
|
1590
|
+
if (TODAY.offline) { askBox(null); write(el, 'offline', 'COS on ' + state.serverName + ' is not answering right now. Today fills in when it does.'); return; }
|
|
1445
1591
|
// The model's summary when there is one (it never blocks: the counts line below shows until it lands). The test
|
|
1446
1592
|
// harnesses that run this block alone have no requestBrief, so it is looked up, never assumed.
|
|
1593
|
+
askBox(null);
|
|
1447
1594
|
if (typeof requestBrief === 'function') {
|
|
1448
1595
|
requestBrief(m);
|
|
1449
1596
|
var brief = briefShown();
|
|
1450
|
-
|
|
1597
|
+
askBox(brief);
|
|
1598
|
+
if (brief) { write(el, 'brief', brief.text, briefWhen(brief, m)); return; }
|
|
1451
1599
|
}
|
|
1452
1600
|
var first = '';
|
|
1453
1601
|
if (m.sessionsWaiting > 0) first = countWord(m.sessionsWaiting) + ' ' + plural(m.sessionsWaiting, 'session is', 'sessions are') + ' waiting on you';
|
|
@@ -1471,7 +1619,7 @@
|
|
|
1471
1619
|
var others = m.failed.filter(function (n) { return n !== 'sessions'; });
|
|
1472
1620
|
if (!m.settled && !m.now.length && !m.later.length) out += (unread ? others : m.failed).length ? ' Couldn\u2019t read ' + listWords(unread ? others : m.failed) + ', so this may not be everything.' : unread ? ' This may not be everything that needs you.' : ' Still reading what needs you.';
|
|
1473
1621
|
else if (unread) out += ' This may not be everything that needs you.';
|
|
1474
|
-
el
|
|
1622
|
+
write(el, 'counts', out);
|
|
1475
1623
|
}
|
|
1476
1624
|
function listWords(names) { return names.length < 2 ? names.join('') : names.slice(0, -1).join(', ') + ' and ' + names[names.length - 1]; }
|
|
1477
1625
|
function runRow(s, activeNow) {
|
|
@@ -1493,14 +1641,16 @@
|
|
|
1493
1641
|
'<span class="run-meta">' + metaHtml + '</span></button>';
|
|
1494
1642
|
}
|
|
1495
1643
|
function renderRunning(working, open) {
|
|
1496
|
-
|
|
1644
|
+
// W1: COS's own jobs (questions it is answering, routines, Work runs) count as running too, as on the Mac.
|
|
1645
|
+
var jobs = typeof runJobsHtml === 'function' ? runJobsHtml() : { html: '', n: 0 };
|
|
1646
|
+
$('run-count').textContent = working.length + jobs.n ? String(working.length + jobs.n) : '';
|
|
1497
1647
|
var list = $('run-list');
|
|
1498
1648
|
// Never "Nothing running" about a list that was not read.
|
|
1499
|
-
if (TODAY.sessionsState === 'failed') { list.innerHTML = '<button type="button" class="run-more run-failed" data-today-needs="retry">Couldn\u2019t read sessions. Try again</button>'; return; }
|
|
1649
|
+
if (TODAY.sessionsState === 'failed') { list.innerHTML = jobs.html + '<button type="button" class="run-more run-failed" data-today-needs="retry">Couldn\u2019t read sessions. Try again</button>'; return; }
|
|
1500
1650
|
var shown = working.slice(0, 3);
|
|
1501
|
-
var html = shown.map(function (s) { return runRow(s, true); }).join('');
|
|
1651
|
+
var html = jobs.html + shown.map(function (s) { return runRow(s, true); }).join('');
|
|
1502
1652
|
if (!shown.length) {
|
|
1503
|
-
html = open.slice(0, 2).map(function (s) { return runRow(s, false); }).join('');
|
|
1653
|
+
html = jobs.html + open.slice(0, 2).map(function (s) { return runRow(s, false); }).join('');
|
|
1504
1654
|
if (!html) html = '<div class="run-empty">Nothing running. Start a session on your Mac and it shows here.</div>';
|
|
1505
1655
|
}
|
|
1506
1656
|
var more = (working.length - shown.length) + (shown.length ? open.length : Math.max(0, open.length - 2));
|
|
@@ -1508,6 +1658,57 @@
|
|
|
1508
1658
|
list.innerHTML = html;
|
|
1509
1659
|
if (typeof liveTickStart === 'function' && list.querySelector && list.querySelector('[data-live-since]')) liveTickStart();
|
|
1510
1660
|
}
|
|
1661
|
+
// ── Running beyond sessions (W1, 7.0.x): audit 02 F10, Q1 M6. The same job rows COS Control 0.5.386 draws
|
|
1662
|
+
// (Models.swift TodayHome running rows, b02d1ad 14185-14207), from the same server list, GET /api/query-jobs/active
|
|
1663
|
+
// (titles only, never a prompt or an answer):
|
|
1664
|
+
// person "A question to COS" row, "COS is answering, N min", Open in Messages
|
|
1665
|
+
// routine "Routine" row, "running since 7:30 PM", Open in Messages
|
|
1666
|
+
// task "A captured task" row, "a captured task, on the server, N min", In Sessions
|
|
1667
|
+
// other never a row: it is a Control Work session or a meeting review, already shown as the row it is
|
|
1668
|
+
// No Stop on any of them, as on the Mac: Control's only Stop is on a Work run it started itself, which lives in the Mac's
|
|
1669
|
+
// own handoff store and is not visible to this page.
|
|
1670
|
+
var RUN_JOB_ROW = {
|
|
1671
|
+
person: { word: 'Question', title: 'A question to COS', open: 'messages', label: 'Open in Messages' },
|
|
1672
|
+
routine: { word: 'Routine', title: 'Routine', open: 'messages', label: 'Open in Messages' },
|
|
1673
|
+
task: { word: 'Work', title: 'A captured task', open: 'sessions', label: 'In Sessions' },
|
|
1674
|
+
};
|
|
1675
|
+
var RUN_JOBS = { items: null, timer: null };
|
|
1676
|
+
var RUN_JOBS_POLL_MS = 15000;
|
|
1677
|
+
function runJobValid(j) { return j && typeof j.jobId === 'string' && /^[A-Za-z0-9_-]{1,80}$/.test(j.jobId) && RUN_JOB_ROW[j.origin]; }
|
|
1678
|
+
function loadRunJobs() {
|
|
1679
|
+
return api('/api/query-jobs/active').then(function (d) {
|
|
1680
|
+
// An older server answers 400 or no protocol: no jobs row, never an error line (Control keys off the field, not the status).
|
|
1681
|
+
RUN_JOBS.items = d && d.protocolVersion === 1 && d.enabled !== false && Array.isArray(d.items) ? d.items.filter(runJobValid).slice(0, 8) : [];
|
|
1682
|
+
}, function () { RUN_JOBS.items = null; });
|
|
1683
|
+
}
|
|
1684
|
+
function runJobsSchedule() {
|
|
1685
|
+
if (RUN_JOBS.timer) { clearTimeout(RUN_JOBS.timer); RUN_JOBS.timer = null; }
|
|
1686
|
+
if (!RUN_JOBS.items || !RUN_JOBS.items.length) return;
|
|
1687
|
+
RUN_JOBS.timer = setTimeout(function () {
|
|
1688
|
+
RUN_JOBS.timer = null;
|
|
1689
|
+
// Only while Today is in front: a hidden page reads nothing (it reads again when Today comes back).
|
|
1690
|
+
if (state.pane !== 'today' || document.hidden) return;
|
|
1691
|
+
loadRunJobs().then(function () { renderRunning(TODAY.working || [], TODAY.open || []); runJobsSchedule(); });
|
|
1692
|
+
}, RUN_JOBS_POLL_MS);
|
|
1693
|
+
}
|
|
1694
|
+
function runJobMinutes(j) { return Math.max(0, Math.round((Number(j.elapsedMs) || 0) / 60000)); }
|
|
1695
|
+
function runJobRow(j) {
|
|
1696
|
+
var kind = RUN_JOB_ROW[j.origin], mins = runJobMinutes(j);
|
|
1697
|
+
var started = new Date(Date.now() - (Number(j.elapsedMs) || 0));
|
|
1698
|
+
var meta = j.origin === 'routine' ? 'running since ' + clock(started)
|
|
1699
|
+
: j.origin === 'person' ? 'COS is answering, ' + mins + ' min'
|
|
1700
|
+
: 'a captured task, on the server, ' + mins + ' min';
|
|
1701
|
+
return '<div class="run-row run-job"><span class="run-who"><span class="badge work">' + esc(kind.word) + '</span></span>' +
|
|
1702
|
+
'<span class="run-title">' + esc(plain(j.title || '').trim() || kind.title) + '</span>' +
|
|
1703
|
+
'<span class="run-meta">' + esc(meta) + '</span>' +
|
|
1704
|
+
'<span class="row-actions"><button type="button" class="link-btn" data-goto="' + kind.open + '">' + esc(kind.label) + '</button></span></div>';
|
|
1705
|
+
}
|
|
1706
|
+
function runJobsHtml() {
|
|
1707
|
+
var items = RUN_JOBS.items || [];
|
|
1708
|
+
return { html: items.map(runJobRow).join(''), n: items.length };
|
|
1709
|
+
}
|
|
1710
|
+
// ── end running beyond sessions
|
|
1711
|
+
|
|
1511
1712
|
// ── live state on screen (7.0.1): Today's counts and rows, the pet, the Sessions rows, the header line ──
|
|
1512
1713
|
// The live model (above) calls this, batched, after a frame or a list read moved something.
|
|
1513
1714
|
function liveRepaint(rows, structural) {
|
|
@@ -1599,6 +1800,8 @@
|
|
|
1599
1800
|
BRIEF.inflight = false;
|
|
1600
1801
|
if (v && typeof v === 'object') { BRIEF.view = v; BRIEF.sentAt = Date.now(); }
|
|
1601
1802
|
if (v && v.reason === 'refresh_throttled') TR.retryAt = Date.now() + Math.max(1000, Number(v.retryAfterMs) || 30000);
|
|
1803
|
+
// T1 spec 2: Refresh re-fits the block to what it now holds.
|
|
1804
|
+
BRIEF.heldPx = 0;
|
|
1602
1805
|
if (TODAY.model && state.pane === 'today') renderStory(TODAY.model);
|
|
1603
1806
|
return done(refreshWords(v));
|
|
1604
1807
|
}, function (err) {
|
|
@@ -1670,8 +1873,29 @@
|
|
|
1670
1873
|
// Control's askedTitle and askedWhy for a session waiting on you.
|
|
1671
1874
|
// Control's TodayHome: guarded(title) ?? "A session". Never the workspace path or the session id: this line is shown
|
|
1672
1875
|
// on Today and goes into the Today summary sent to the model, which carries no ids.
|
|
1876
|
+
// ── A turn that ended asking you (7.0.18, X1 contract v1; Miles 2026-11-10 07:14) ──
|
|
1877
|
+
// A Claude session ended its turn with the app's next-step box: a question and reply buttons. The server marks the row
|
|
1878
|
+
// (waiting_source 'turn_end', waiting_choices: 0 to 2 labels) only while COS_TURN_END_ASKS is on. Unlike a question at
|
|
1879
|
+
// the Mac's prompt, a reply IS the answer here, so Today and the reader offer the choices and the reply box. What is
|
|
1880
|
+
// sent is the contract's exact text: it never reads as typed at the desk, and the session may ask him to confirm.
|
|
1881
|
+
var TURN_END_PREFIX = 'Answer sent from COS Control Web through COS, not typed in this session: ';
|
|
1882
|
+
var TURN_END_LINE = 'A choice or a reply here reaches the session as a message from COS, not typed in the session, and the session may ask you to confirm.';
|
|
1883
|
+
/** The turn-end ask on a session row, or null: any other row, an older server, or the switch off. */
|
|
1884
|
+
function turnEndOf(s) {
|
|
1885
|
+
if (!s || s.waiting_source !== 'turn_end' || s.agent_state !== 'waiting') return null;
|
|
1886
|
+
var clean = function (v, n) { return String(v == null ? '' : v).replace(/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2066-\u2069]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, n); };
|
|
1887
|
+
var choices = Array.isArray(s.waiting_choices) ? s.waiting_choices.filter(function (c) { return typeof c === 'string' && clean(c, 40); }).map(function (c) { return clean(c, 40); }).slice(0, 2) : [];
|
|
1888
|
+
return { ask: clean(s.waiting_detail, 160), choices: choices };
|
|
1889
|
+
}
|
|
1890
|
+
/** The exact words a choice or a reply sends (contract v1): attributed to COS Control Web, never to the desk. */
|
|
1891
|
+
function turnEndPrompt(text) { return TURN_END_PREFIX + turnEndBare(text); }
|
|
1892
|
+
/** The words as he wrote them: a message that came back to the box already carries the prefix, never twice. */
|
|
1893
|
+
function turnEndBare(text) { var t = String(text || '').trim(); return t.indexOf(TURN_END_PREFIX) === 0 ? t.slice(TURN_END_PREFIX.length).trim() : t; }
|
|
1673
1894
|
function askedTitle(s) {
|
|
1674
1895
|
var t = plain(s.display_label || s.custom_title || '').replace(/\s+/g, ' ').trim() || 'A session';
|
|
1896
|
+
// The harnesses that run this block alone have no turnEndOf: looked up, never assumed.
|
|
1897
|
+
var te = typeof turnEndOf === 'function' ? turnEndOf(s) : null;
|
|
1898
|
+
if (te) return te.ask ? t + ' asked: ' + te.ask : t + ' asked you something';
|
|
1675
1899
|
if (sxProvider(s.provider) !== 'claude') return t + ' is waiting for your reply';
|
|
1676
1900
|
if (s.waiting_kind === 'plan') return t + ' has a plan for you to approve';
|
|
1677
1901
|
if (s.waiting_kind === 'permission') return t + ' wants your permission';
|
|
@@ -1682,6 +1906,8 @@
|
|
|
1682
1906
|
var TOOL_NAMES = ['AskUserQuestion', 'ExitPlanMode', 'Bash', 'BashOutput', 'KillShell', 'Edit', 'Write', 'Read', 'Grep', 'Glob', 'LS', 'Task', 'Agent',
|
|
1683
1907
|
'WebFetch', 'WebSearch', 'TodoWrite', 'NotebookEdit', 'MultiEdit', 'SlashCommand', 'Skill', 'ToolSearch'];
|
|
1684
1908
|
function askedWhy(s) {
|
|
1909
|
+
var te = typeof turnEndOf === 'function' ? turnEndOf(s) : null;
|
|
1910
|
+
if (te) return te.choices.length ? 'Tap a choice or reply here' : 'Reply here';
|
|
1685
1911
|
if (s.waiting_kind === 'plan') return 'Has a plan for you to approve';
|
|
1686
1912
|
var d = String(s.waiting_detail || '').trim();
|
|
1687
1913
|
if (TOOL_NAMES.indexOf(d) >= 0) d = '';
|
|
@@ -1692,7 +1918,8 @@
|
|
|
1692
1918
|
return d.indexOf('[home]') >= 0 ? 'Wants to run (home folder hidden) ' + shown : 'Wants to run ' + shown;
|
|
1693
1919
|
}
|
|
1694
1920
|
if (s.waiting_kind === 'question' && d) return d.length > 160 ? d.slice(0, 159) + '…' : d;
|
|
1695
|
-
|
|
1921
|
+
// W1 A5: a Claude session waits on its own prompt; the card under it says where, once.
|
|
1922
|
+
return sxProvider(s.provider) === 'claude' ? 'Waiting for your answer' : 'Waiting for you';
|
|
1696
1923
|
}
|
|
1697
1924
|
// 6.99.26: where a waiting session's question can be answered. 'here': this page holds it (panes/questions.js), as
|
|
1698
1925
|
// cards in its waiting sheet. 'mac': a Claude session's own prompt (or a held one that went back to the Mac), which
|
|
@@ -1707,16 +1934,111 @@
|
|
|
1707
1934
|
return q && q.question ? clipWords(q.question, 160) : '';
|
|
1708
1935
|
}
|
|
1709
1936
|
function clipWords(text, n) { var t = String(text || '').replace(/\s+/g, ' ').trim(); return t.length > n ? t.slice(0, n - 1) + '…' : t; }
|
|
1710
|
-
function whereFor(provider, id) {
|
|
1937
|
+
function whereFor(provider, id, row) {
|
|
1711
1938
|
var q = sessionQs();
|
|
1712
1939
|
var held = q ? q.itemsFor(id) : [], mac = q && q.atMacFor ? q.atMacFor(id) : [];
|
|
1713
1940
|
if (held.length) return { where: 'here', ask: askedHere(held) };
|
|
1941
|
+
// 7.0.18: a turn that ended asking him is answered by a reply, here or anywhere: never "at the Mac".
|
|
1942
|
+
var te = row && typeof turnEndOf === 'function' ? turnEndOf(row) : null;
|
|
1943
|
+
if (te) return { where: 'turnend', ask: te.ask, choices: te.choices };
|
|
1714
1944
|
if (q && q.answeredFor && q.answeredFor(id)) return { where: 'answered', ask: '' };
|
|
1715
|
-
|
|
1945
|
+
// X1 contract: a card built from the server's `waiting` list carries its own words; a held one gone back to the Mac
|
|
1946
|
+
// has the words this page saw while it was held.
|
|
1947
|
+
var first = mac[0];
|
|
1948
|
+
var words = first && first.fromWaiting ? { questions: (first.questions || []).map(function (x) { return x.question; }), summary: '' }
|
|
1949
|
+
: first && q.wordsOf ? q.wordsOf(first.id) : null;
|
|
1716
1950
|
var ask = words ? clipWords(words.questions[0] || (words.summary ? 'Wants to run ' + words.summary : ''), 160) : '';
|
|
1717
|
-
if (mac.length
|
|
1951
|
+
if (mac.length) return { where: 'mac', ask: ask };
|
|
1952
|
+
// DESIGN.md step 4 #5: the Mac line only for a Claude row that waits on a question or an approval, never for any
|
|
1953
|
+
// waiting Claude session. A row the page has not read says nothing about it, so it keeps the Mac line as before.
|
|
1954
|
+
var kind = row ? String(row.waiting_kind || '') : '';
|
|
1955
|
+
if (sxProvider(provider) === 'claude' && (!row || kind === 'question' || kind === 'permission')) return { where: 'mac', ask: ask };
|
|
1718
1956
|
return { where: '', ask: '' };
|
|
1719
1957
|
}
|
|
1958
|
+
// ── At the Mac's prompt, in the session reader (W1 A5) ──
|
|
1959
|
+
// Miles 11/09: "when I actually navigate to the session, there is no ability to answer the question." The reader cannot
|
|
1960
|
+
// answer a question that waits on the Mac's own prompt either, so it says so at the top, with the words when this page
|
|
1961
|
+
// has them (a held question it saw, or the server's waiting line) and plainly when it does not. Nothing here sends.
|
|
1962
|
+
var MAC_Q = { line: "Answer this at your Mac's prompt. A message sent from here does not answer it; it waits until the question is answered at the Mac.", none: "COS cannot read this question's words from here. They show at the Mac's prompt." };
|
|
1963
|
+
/** 7.0.18: the reader's card for a turn that ended asking him: the question, a button per choice, the honest line. */
|
|
1964
|
+
function readerTurnEndHtml(r, row) {
|
|
1965
|
+
var te = turnEndOf(row);
|
|
1966
|
+
if (!te) return '';
|
|
1967
|
+
var title = plain((row && (row.display_label || row.custom_title)) || '').replace(/\s+/g, ' ').trim() || 'This session';
|
|
1968
|
+
var busy = !!(r && r.teSending);
|
|
1969
|
+
return '<p class="sx-mac-q-title"><strong>' + esc(te.ask ? title + ' asked:' : title + ' asked you something') + '</strong></p>' +
|
|
1970
|
+
(te.ask ? '<blockquote class="ta-quote sx-te-ask">' + esc(te.ask) + '</blockquote>' : '') +
|
|
1971
|
+
(te.choices.length ? '<div class="sx-te-choices">' + te.choices.map(function (c, i) {
|
|
1972
|
+
return '<button type="button" class="cx-btn' + (i === 0 ? ' primary' : '') + '" data-sx="turnend" data-i="' + i + '"' + (busy ? ' disabled' : '') + '>' + esc(c) + '</button>';
|
|
1973
|
+
}).join('') + '</div>' : '') +
|
|
1974
|
+
'<p class="sx-note sx-te-line">' + esc(TURN_END_LINE) + '</p>';
|
|
1975
|
+
}
|
|
1976
|
+
function readerMacHtml(provider, id, row) {
|
|
1977
|
+
var w = whereFor(provider, id, row);
|
|
1978
|
+
if (w.where !== 'mac') return '';
|
|
1979
|
+
// X1 contract: when the server listed this question (`waiting`), the shared card list mounted above draws it, with
|
|
1980
|
+
// its options. This block is only for a question it did not list (an older server, or asked before a restart).
|
|
1981
|
+
var qs0 = sessionQs();
|
|
1982
|
+
if (qs0 && qs0.atMacFor && qs0.atMacFor(id).length) return '';
|
|
1983
|
+
var q = sessionQs();
|
|
1984
|
+
var why = row ? askedWhy(row) : '';
|
|
1985
|
+
var words = w.ask || (why && !/^Waiting for (you|your answer)/.test(why) ? why : '');
|
|
1986
|
+
return '<p class="sx-mac-q-title"><strong>Waiting for your answer at the Mac</strong></p>' +
|
|
1987
|
+
(words ? '<blockquote class="ta-quote sx-mac-q-words">' + esc(words) + '</blockquote>' : '<p class="sx-note sx-mac-q-none">' + esc((q && q.macNoWords) || MAC_Q.none) + '</p>') +
|
|
1988
|
+
'<p class="sx-note sx-mac-q-line">' + esc((q && q.macLine) || MAC_Q.line) + '</p>';
|
|
1989
|
+
}
|
|
1990
|
+
function readerRowOf(r) {
|
|
1991
|
+
return (state.sessions || []).filter(function (s) { var k = splitSessionId(s.provider, s.session_id || s.id); return k.provider === r.provider && String(k.id).toLowerCase() === String(r.id).toLowerCase(); })[0] || null;
|
|
1992
|
+
}
|
|
1993
|
+
// 7.0.18: a tap on a choice sends the contract's exact text through the reader's own Continue (continueSession), so the
|
|
1994
|
+
// queue, the held reply and the turn watch behave as for any message. The outcome is said in the status line.
|
|
1995
|
+
var TURN_END_SENT = function (label) { return 'Sent \u201c' + label + '\u201d. It reaches the session as a message from COS; it may ask you to confirm.'; };
|
|
1996
|
+
var TURN_END_GONE = 'Already answered on another screen, or the session moved on. Nothing was sent.';
|
|
1997
|
+
function sendTurnEndChoice(r, i) {
|
|
1998
|
+
if (!r || r.sending) return;
|
|
1999
|
+
var te = turnEndOf(readerRowOf(r));
|
|
2000
|
+
if (!te) { sessionStatus(TURN_END_GONE); readerMacPaint(r, r.waitingNow); return; }
|
|
2001
|
+
var label = te.choices[i];
|
|
2002
|
+
if (!label) return;
|
|
2003
|
+
r.sending = true; r.teSending = true; readerMacPaint(r, r.waitingNow);
|
|
2004
|
+
var done = function () { r.sending = false; r.teSending = false; if (state.reading === r) readerMacPaint(r, r.waitingNow); };
|
|
2005
|
+
return continueSession(r, turnEndPrompt(label), {}).then(function (how) {
|
|
2006
|
+
done();
|
|
2007
|
+
if (state.reading !== r) return how;
|
|
2008
|
+
if (how === 'sent') sessionStatus(TURN_END_SENT(label));
|
|
2009
|
+
else if (how === 'changed') {
|
|
2010
|
+
// The box under it would offer Continue anyway for the composer's words, not this choice: a choice says it plainly.
|
|
2011
|
+
r.refusal = null; r.sendAnyway = null; renderVerdict(r);
|
|
2012
|
+
sessionStatus('The session moved on before this reached it. \u201c' + label + '\u201d was not sent.', 'error');
|
|
2013
|
+
} else if (how === 'refused') sessionStatus('The session did not take \u201c' + label + '\u201d. Try again, or answer it on your Mac.', 'error');
|
|
2014
|
+
return how;
|
|
2015
|
+
}, function (err) {
|
|
2016
|
+
done();
|
|
2017
|
+
if (state.reading !== r) return;
|
|
2018
|
+
var why = err && err.name === 'AbortError' ? 'No answer from your Mac yet. It may still land; tapping again is safe, it will not post twice.' : (err && err.message) || 'Could not reach your Mac.';
|
|
2019
|
+
sessionStatus('Not sent: ' + why, 'error');
|
|
2020
|
+
});
|
|
2021
|
+
}
|
|
2022
|
+
function readerMacPaint(r, waiting) {
|
|
2023
|
+
var el = $('session-mac-q');
|
|
2024
|
+
if (!el || !r) return;
|
|
2025
|
+
r.waitingNow = !!waiting;
|
|
2026
|
+
var row = readerRowOf(r);
|
|
2027
|
+
// 7.0.18: a turn that ended asking him gets its card whether or not the reader thinks it waits (the row is the truth).
|
|
2028
|
+
var te = typeof turnEndOf === 'function' ? turnEndOf(row) : null;
|
|
2029
|
+
r.turnEnd = te;
|
|
2030
|
+
var html = te ? readerTurnEndHtml(r, row) : waiting ? readerMacHtml(r.provider, r.id, row) : '';
|
|
2031
|
+
if (el.innerHTML !== html) el.innerHTML = html;
|
|
2032
|
+
el.hidden = !html;
|
|
2033
|
+
// Q1 M3: the Send box under it says plainly that a message is not the answer, whichever block shows the question
|
|
2034
|
+
// (this one, a Mac card or a held card above).
|
|
2035
|
+
var qs1 = sessionQs(), open = (!!html && !te) || !!(qs1 && ((qs1.itemsFor && qs1.itemsFor(r.id).length) || (qs1.atMacFor && qs1.atMacFor(r.id).length)));
|
|
2036
|
+
var box = $('session-input');
|
|
2037
|
+
// 7.0.18: here a reply IS the answer (sent with the contract's attribution), so the box says so.
|
|
2038
|
+
if (box && box.setAttribute) box.setAttribute('placeholder', te ? 'Or write your own answer' : open ? 'Message this session (not an answer)' : 'Continue this session…');
|
|
2039
|
+
}
|
|
2040
|
+
// ── end at the Mac's prompt
|
|
2041
|
+
|
|
1720
2042
|
function needsModel() {
|
|
1721
2043
|
var states = { sessions: TODAY.sessionsState, work: TODAY.workState, voices: TODAY.voices.state, memories: TODAY.memories.state };
|
|
1722
2044
|
var rows = [], seen = {};
|
|
@@ -1724,8 +2046,8 @@
|
|
|
1724
2046
|
if (sxKeepWarm(s) || sxState(s) !== 'waiting') return;
|
|
1725
2047
|
var k = splitSessionId(s.provider, s.session_id || s.id);
|
|
1726
2048
|
seen[k.provider + '|' + String(k.id).toLowerCase()] = 1;
|
|
1727
|
-
var w = whereFor(k.provider, k.id);
|
|
1728
|
-
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 });
|
|
2049
|
+
var w = whereFor(k.provider, k.id, s);
|
|
2050
|
+
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, choices: w.choices || [] });
|
|
1729
2051
|
});
|
|
1730
2052
|
// A question this page holds for a session the list has not shown waiting yet: its own row, so every held
|
|
1731
2053
|
// question is in front of you (two at once each get one).
|
|
@@ -1801,7 +2123,8 @@
|
|
|
1801
2123
|
// The session's question shows in the overlay's peek, with its cards to answer. Under it, where it can be answered.
|
|
1802
2124
|
var q = sessionQs(), whereLine = r.where === 'here' ? 'Answer it here.' : r.where === 'mac' ? (q && q.macLine) || '' : r.where === 'answered' ? (q && q.answeredLine) || '' : '';
|
|
1803
2125
|
return '<div class="need-row">' +
|
|
1804
|
-
|
|
2126
|
+
// 7.0.18: a turn-end row's title already says the question, so its foot says how to answer.
|
|
2127
|
+
'<button type="button" class="need-open" data-today="wait" data-key="' + esc(r.key) + '">' + top(r.title) + '<span class="need-foot">' + esc(r.where === 'turnend' ? r.why : (r.ask || r.why)) + '</span>' +
|
|
1805
2128
|
(whereLine ? '<span class="need-foot need-where" data-where="' + esc(r.where) + '">' + esc(whereLine) + '</span>' : '') + '</button>' +
|
|
1806
2129
|
'<div class="need-actions"><button type="button" class="btn small' + (i === 0 ? ' primary' : '') + '" data-session="' + esc(r.key) + '">Open session</button></div></div>';
|
|
1807
2130
|
}
|
|
@@ -1847,30 +2170,183 @@
|
|
|
1847
2170
|
// Control's Next up (todayPrepButtons): Prep me opens the Prep composer in the COS chat (panes/cos-chat.js prepFor),
|
|
1848
2171
|
// Open meeting opens Meetings. Without a server to answer, Prep me is not offered.
|
|
1849
2172
|
var prep = !TODAY.offline && window.COSWeb && window.COSWeb.cosChat && window.COSWeb.cosChat.prepFor;
|
|
2173
|
+
// Prep on every row (Miles 11/09): "Add context" opens the same inline panel the later rows' Prep opens, for this
|
|
2174
|
+
// meeting. Not on a hold (the server's nextEvent.hold), and not while neither Prep nor Ask is on the Mac.
|
|
2175
|
+
var key = nextPrepKey(n.title, d), ctx = !n.hold && nextPrepMode();
|
|
2176
|
+
if (ctx) NEXT_PREP.rows[key] = { title: n.title, start: d.toISOString() };
|
|
1850
2177
|
$('next-actions').innerHTML = (prep ? '<button type="button" class="btn small primary" data-today-next="prep">Prep me</button>' : '') +
|
|
2178
|
+
(ctx ? '<button type="button" class="link-btn next-prep-open" data-next-prep="' + esc(key) + '" aria-expanded="' + (NEXT_PREP.open === key) + '">Add context</button>' : '') +
|
|
1851
2179
|
'<button type="button" class="link-btn" data-goto="meetings">Open meeting</button>';
|
|
2180
|
+
NEXT_PREP.firstKey = ctx ? key : '';
|
|
2181
|
+
paintNextPrepSlot($('next-first-prep'), ctx && NEXT_PREP.open === key ? key : '');
|
|
1852
2182
|
card.classList.remove('hidden');
|
|
1853
2183
|
}
|
|
1854
|
-
// PH plan P1: the rest of today from GET /api/calendar/today (selected calendars
|
|
2184
|
+
// PH plan P1: the rest of today from GET /api/calendar/today (selected calendars), under Next up.
|
|
1855
2185
|
// An aging snapshot says when it was read. Not connected or nothing more: the block stays hidden.
|
|
2186
|
+
// Today round 2 (Miles 11/09): Next up is 5 rows in all, the event above plus up to 4 more, then "+N more today"
|
|
2187
|
+
// (opens Meetings) when the calendar has more meetings left today than are shown. Desktop Control 0.5.384 matches.
|
|
1856
2188
|
function loadCalendarLater(n, d) {
|
|
2189
|
+
var LATER_ROWS = 4;
|
|
1857
2190
|
var box = $('next-later'); if (!box) return;
|
|
1858
|
-
if (!n || !n.title || !d) {
|
|
2191
|
+
if (!n || !n.title || !d) { NEXT_PREP.later = null; clearLater(box); return; }
|
|
1859
2192
|
var gen = TODAY.gen;
|
|
1860
2193
|
api('/api/calendar/today').then(function (c) {
|
|
1861
2194
|
if (gen !== TODAY.gen) return;
|
|
1862
|
-
if (!c || !c.connected) {
|
|
2195
|
+
if (!c || !c.connected) { NEXT_PREP.later = null; clearLater(box); return; }
|
|
1863
2196
|
var same = function (r) { var rd = toDate(r.start); return r.title === n.title && rd && d && Math.abs(rd.getTime() - d.getTime()) < 60000; };
|
|
1864
|
-
var
|
|
1865
|
-
var
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
2197
|
+
var list = c.next || [];
|
|
2198
|
+
var rows = list.filter(function (r) { return !r.allDay && !same(r); }).slice(0, LATER_ROWS);
|
|
2199
|
+
// What is left today past the rows on screen (the event above counts when it is one of today's).
|
|
2200
|
+
var shown = rows.length + (list.some(same) ? 1 : 0), more = (Number(c.remainingCount) || 0) - shown;
|
|
2201
|
+
NEXT_PREP.later = { rows: rows, more: more, asOf: c.stale && c.asOf ? toDate(c.asOf) : null };
|
|
2202
|
+
paintCalendarLater();
|
|
2203
|
+
}, function () { NEXT_PREP.later = null; clearLater(box); });
|
|
2204
|
+
}
|
|
2205
|
+
// Emptied, and its last write forgotten, so the next read that has rows writes them again (writeKeepingPrep skips a
|
|
2206
|
+
// write that matches the last one).
|
|
2207
|
+
function clearLater(box) { box.classList.add('hidden'); box.innerHTML = ''; box.cosPrepHtml = ''; }
|
|
2208
|
+
// The later rows from the last calendar read. Each meeting that is not a hold (the server's `hold`) gets a quiet
|
|
2209
|
+
// Prep, and the open row's panel sits under it. Written only when the markup changed, so a poll never takes the words
|
|
2210
|
+
// or the caret from the panel; when it is written, the words, the focus and the caret are put back.
|
|
2211
|
+
function paintCalendarLater() {
|
|
2212
|
+
var box = $('next-later'), L = NEXT_PREP.later; if (!box || !L) return;
|
|
2213
|
+
var mode = nextPrepMode(), openKey = '';
|
|
2214
|
+
var html = L.rows.map(function (r) {
|
|
2215
|
+
var rd = toDate(r.start), key = rd ? nextPrepKey(r.title, rd) : '', can = !!(mode && key && r.hold !== true);
|
|
2216
|
+
if (can) { NEXT_PREP.rows[key] = { title: r.title, start: rd.toISOString() }; if (NEXT_PREP.open === key) openKey = key; }
|
|
2217
|
+
return '<div class="later-row"><span class="later-when">' + esc(rd ? clock(rd) : '') + '</span><span class="later-what">' + esc(r.title) + '</span>' +
|
|
2218
|
+
(can ? '<button type="button" class="link-btn later-prep" data-next-prep="' + esc(key) + '" aria-expanded="' + (NEXT_PREP.open === key) + '">Prep</button>' : '') + '</div>' +
|
|
2219
|
+
(can && NEXT_PREP.open === key ? nextPrepPanelHtml(key, mode) : '');
|
|
2220
|
+
}).join('');
|
|
2221
|
+
if (L.more > 0) html += '<button type="button" class="link-btn later-more" data-goto="meetings">' + esc('+' + L.more + ' more today') + '</button>';
|
|
2222
|
+
if (L.asOf) html += '<div class="later-asof">' + esc('Calendar as of ' + clock(L.asOf)) + '</div>';
|
|
2223
|
+
// Shown before it is written: autoGrow measures the box, and a hidden one measures 0.
|
|
2224
|
+
box.classList.toggle('hidden', !html);
|
|
2225
|
+
writeKeepingPrep(box, html, openKey);
|
|
2226
|
+
}
|
|
2227
|
+
|
|
2228
|
+
// ── Prep on every Next up row (Miles typed 2026-11-09 13:00 CST) ─────────
|
|
2229
|
+
// "Inline options to prep for a given meeting which starts the gotCOS ask and gives us the ability to add in more
|
|
2230
|
+
// context for the prep." Prep on a later row (or Add context beside the first row's Prep me) opens a small panel
|
|
2231
|
+
// under that row: the context box, Add files, Start prep and Cancel. One panel at a time; Escape closes it. The words
|
|
2232
|
+
// and files are kept per meeting (title plus start) while the page polls. Start prep goes to Messages, then COS, and
|
|
2233
|
+
// hands them to cos-chat.js prepStart: the Prep composer for that meeting (the context joins its notes, the files go
|
|
2234
|
+
// up through its own path, then Prep me runs), or, with Prep off on the Mac and Ask on, the plain Ask with the context
|
|
2235
|
+
// as its quoted reference. Neither on (or the Mac not answering): no Prep on any row.
|
|
2236
|
+
var NEXT_PREP = { open: '', rows: {}, drafts: {}, later: null, firstKey: '', busy: false, capsAsked: false };
|
|
2237
|
+
function nextPrepKey(title, d) { return String(title || '') + '|' + (d ? d.toISOString() : ''); }
|
|
2238
|
+
function nextPrepChat() { return typeof window !== 'undefined' && window.COSWeb && window.COSWeb.cosChat || null; }
|
|
2239
|
+
/** 'prep' (Prep on the Mac), 'ask' (Ask only), or '' (neither, the Mac not answering, or not known yet). */
|
|
2240
|
+
function nextPrepMode() {
|
|
2241
|
+
var chat = nextPrepChat();
|
|
2242
|
+
if (TODAY.offline || !chat || !chat.prepStart) return '';
|
|
2243
|
+
if (chat.known && !chat.known()) {
|
|
2244
|
+
// The chat reads /api/health once per page; paint the rows again when it lands.
|
|
2245
|
+
if (!NEXT_PREP.capsAsked && chat.ensureCaps) {
|
|
2246
|
+
NEXT_PREP.capsAsked = true;
|
|
2247
|
+
chat.ensureCaps().then(function () { if (TODAY.welcome) renderNext(TODAY.welcome); paintCalendarLater(); }, function () {});
|
|
2248
|
+
}
|
|
2249
|
+
return '';
|
|
2250
|
+
}
|
|
2251
|
+
if (chat.prepCaps && chat.prepCaps()) return 'prep';
|
|
2252
|
+
return chat.askOn && chat.askOn() ? 'ask' : '';
|
|
2253
|
+
}
|
|
2254
|
+
function nextPrepDraft(key) { return NEXT_PREP.drafts[key] || (NEXT_PREP.drafts[key] = { text: '', files: [] }); }
|
|
2255
|
+
function nextPrepMax() { var chat = nextPrepChat(), c = chat && chat.prepCaps && chat.prepCaps(); return c || { maxFiles: 10, steerChars: 4000 }; }
|
|
2256
|
+
function nextPrepPanelHtml(key, mode) {
|
|
2257
|
+
var row = NEXT_PREP.rows[key] || { title: '' }, dr = nextPrepDraft(key), chat = nextPrepChat();
|
|
2258
|
+
var label = 'Add context for this prep (goals, people, what you want out of it)';
|
|
2259
|
+
var files = mode === 'prep' && dr.files.length ? '<ul class="next-prep-files">' + dr.files.map(function (f, i) {
|
|
2260
|
+
return '<li><span class="next-prep-file">' + esc(f.name || 'File') + '</span><button type="button" class="link-btn" data-next-prep-remove="' + i + '" aria-label="' + esc('Remove ' + (f.name || 'file')) + '">Remove</button></li>';
|
|
2261
|
+
}).join('') + '</ul>' : '';
|
|
2262
|
+
var add = mode === 'prep' ? '<label class="cx-btn quiet next-prep-add" title="Add files to this prep"><svg class="icon" aria-hidden="true"><use href="#i-clip"/></svg><span>Add files</span>' +
|
|
2263
|
+
'<input type="file" class="cc-file" multiple accept="' + esc((chat && chat.prepAccept) || '') + '" data-next-prep-file aria-label="' + esc('Add files to the prep for ' + row.title) + '"></label>' : '';
|
|
2264
|
+
return '<form class="next-prep" data-next-prep-form data-key="' + esc(key) + '" autocomplete="off" aria-label="' + esc('Prep for ' + row.title) + '">' +
|
|
2265
|
+
'<textarea class="field next-prep-input" rows="2" maxlength="' + (mode === 'prep' ? nextPrepMax().steerChars : 2000) + '" data-next-prep-steer placeholder="' + esc(label) + '" aria-label="' + esc(label) + '"></textarea>' +
|
|
2266
|
+
files +
|
|
2267
|
+
'<div class="next-prep-acts">' + add + '<span class="next-prep-gap"></span>' +
|
|
2268
|
+
'<button type="button" class="link-btn next-prep-cancel" data-next-prep-cancel>Cancel</button>' +
|
|
2269
|
+
'<button type="submit" class="cx-btn primary next-prep-start"' + (NEXT_PREP.busy ? ' disabled' : '') + '>Start prep</button></div>' +
|
|
2270
|
+
'</form>';
|
|
2271
|
+
}
|
|
2272
|
+
// The first row's panel, in its own slot under Prep me and Open meeting.
|
|
2273
|
+
function paintNextPrepSlot(slot, key) {
|
|
2274
|
+
if (!slot) return;
|
|
2275
|
+
var mode = key ? nextPrepMode() : '';
|
|
2276
|
+
// Shown before it is written: autoGrow measures the box, and a hidden one measures 0.
|
|
2277
|
+
slot.classList.toggle('hidden', !(key && mode));
|
|
2278
|
+
writeKeepingPrep(slot, key && mode ? nextPrepPanelHtml(key, mode) : '', key);
|
|
2279
|
+
}
|
|
2280
|
+
/** Writes `html` into `el` unless it is already there, then puts the open panel's words back (and the focus and caret). */
|
|
2281
|
+
function writeKeepingPrep(el, html, key) {
|
|
2282
|
+
if (el.cosPrepHtml === html) return;
|
|
2283
|
+
var doc = typeof document !== 'undefined' ? document : null, active = doc && doc.activeElement;
|
|
2284
|
+
var had = !!(active && el.contains && el.contains(active) && active.hasAttribute && active.hasAttribute('data-next-prep-steer'));
|
|
2285
|
+
var a = had ? active.selectionStart : 0, b = had ? active.selectionEnd : 0;
|
|
2286
|
+
el.innerHTML = html;
|
|
2287
|
+
el.cosPrepHtml = html;
|
|
2288
|
+
var box = key && el.querySelector ? el.querySelector('[data-next-prep-steer]') : null;
|
|
2289
|
+
if (!box) return;
|
|
2290
|
+
box.value = nextPrepDraft(key).text;
|
|
2291
|
+
autoGrow(box);
|
|
2292
|
+
if (had) { box.focus(); try { box.setSelectionRange(a, b); } catch (_e) {} }
|
|
2293
|
+
}
|
|
2294
|
+
function repaintNextPrep() {
|
|
2295
|
+
if (TODAY.welcome && TODAY.welcome.nextEvent) renderNext(TODAY.welcome);
|
|
2296
|
+
paintCalendarLater();
|
|
2297
|
+
}
|
|
2298
|
+
function toggleNextPrep(key) {
|
|
2299
|
+
NEXT_PREP.open = NEXT_PREP.open === key ? '' : key;
|
|
2300
|
+
repaintNextPrep();
|
|
2301
|
+
if (!NEXT_PREP.open || typeof document === 'undefined') return;
|
|
2302
|
+
var box = document.querySelector('[data-next-prep-form] [data-next-prep-steer]');
|
|
2303
|
+
if (box) { box.focus(); if (box.scrollIntoView) box.scrollIntoView({ block: 'nearest' }); }
|
|
2304
|
+
}
|
|
2305
|
+
// Escape closes the panel and keeps the words (they come back when it opens again); Cancel closes it and drops them.
|
|
2306
|
+
function closeNextPrep(drop) {
|
|
2307
|
+
var key = NEXT_PREP.open; if (!key) return false;
|
|
2308
|
+
if (drop) delete NEXT_PREP.drafts[key];
|
|
2309
|
+
NEXT_PREP.open = '';
|
|
2310
|
+
repaintNextPrep();
|
|
2311
|
+
var opener = typeof document !== 'undefined' && typeof CSS !== 'undefined' && CSS.escape ? document.querySelector('[data-next-prep="' + CSS.escape(key) + '"]') : null;
|
|
2312
|
+
if (opener && opener.focus) opener.focus();
|
|
2313
|
+
return true;
|
|
2314
|
+
}
|
|
2315
|
+
function nextPrepInput(box) { var key = NEXT_PREP.open; if (key) nextPrepDraft(key).text = box.value; }
|
|
2316
|
+
function addNextPrepFiles(list) {
|
|
2317
|
+
var key = NEXT_PREP.open; if (!key) return;
|
|
2318
|
+
var dr = nextPrepDraft(key), max = nextPrepMax().maxFiles, chat = nextPrepChat(), refused = [];
|
|
2319
|
+
Array.prototype.slice.call(list || []).forEach(function (f) {
|
|
2320
|
+
if (dr.files.length >= max) { refused.push('A prep holds ' + max + ' files. ' + (f.name || 'A file') + ' was not added.'); return; }
|
|
2321
|
+
var c = chat && chat.classify ? chat.classify(f) : {};
|
|
2322
|
+
if (c && c.refuse) { refused.push(c.refuse); return; }
|
|
2323
|
+
if (c && c.kind === 'video') { refused.push((f.name || 'That video') + ' is a video. Prep reads documents and the text in images.'); return; }
|
|
2324
|
+
dr.files.push(f);
|
|
2325
|
+
});
|
|
2326
|
+
if (refused.length) toast(refused.join(' '));
|
|
2327
|
+
repaintNextPrep();
|
|
2328
|
+
}
|
|
2329
|
+
function removeNextPrepFile(i) {
|
|
2330
|
+
var key = NEXT_PREP.open; if (!key) return;
|
|
2331
|
+
nextPrepDraft(key).files.splice(Number(i), 1);
|
|
2332
|
+
repaintNextPrep();
|
|
2333
|
+
}
|
|
2334
|
+
function startNextPrep() {
|
|
2335
|
+
var key = NEXT_PREP.open, row = key && NEXT_PREP.rows[key], chat = nextPrepChat();
|
|
2336
|
+
if (!row || NEXT_PREP.busy || !chat || !chat.prepStart) return;
|
|
2337
|
+
var mode = nextPrepMode(); if (!mode) return;
|
|
2338
|
+
var dr = nextPrepDraft(key);
|
|
2339
|
+
var extra = { steer: dr.text.trim(), files: mode === 'prep' ? dr.files.slice() : [] };
|
|
2340
|
+
NEXT_PREP.busy = true; NEXT_PREP.open = '';
|
|
2341
|
+
repaintNextPrep();
|
|
2342
|
+
go('messages', ['cos']);
|
|
2343
|
+
// The words and files stay with the meeting until the prep (or the Ask) is on its way; a failure keeps them.
|
|
2344
|
+
chat.prepStart(row.title, row.start, extra).then(function (how) {
|
|
2345
|
+
if (how) delete NEXT_PREP.drafts[key];
|
|
2346
|
+
else toast('Prep is not on for this Mac.');
|
|
2347
|
+
}, function (err) {
|
|
2348
|
+
toast((err && err.message) || 'Prep did not start. Your context is still under the meeting on Today.');
|
|
2349
|
+
}).then(function () { NEXT_PREP.busy = false; });
|
|
1874
2350
|
}
|
|
1875
2351
|
function prepNext() {
|
|
1876
2352
|
var n = TODAY.welcome && TODAY.welcome.nextEvent, d = n && toDate(n.time), chat = window.COSWeb && window.COSWeb.cosChat;
|
|
@@ -1878,6 +2354,53 @@
|
|
|
1878
2354
|
go('messages', ['cos']);
|
|
1879
2355
|
chat.prepFor(n.title, d ? d.toISOString() : null).catch(function (err) { toast((err && err.message) || 'Prep me did not start.'); });
|
|
1880
2356
|
}
|
|
2357
|
+
// ── Ask about this (Today round 2, Miles 11/09) ─────────
|
|
2358
|
+
// "Ask COS from this page about the summary and take action coming off of that summary": one box under the model's
|
|
2359
|
+
// summary. Send goes to Messages, then COS, and submits an Ask (panes/cos-chat.js askAbout) that carries the summary,
|
|
2360
|
+
// when it was written and the sessions it read, as the Ask's quoted reference (data, never instructions; no transcript
|
|
2361
|
+
// text, only the last message into each session). COS can then answer, or pass an instruction to a session by its
|
|
2362
|
+
// title through the Ask's own relay ("Sent to <title>" on the answer card). Hidden without a model summary, without
|
|
2363
|
+
// Ask on the Mac (capabilities: durable query jobs) or while the Mac does not answer.
|
|
2364
|
+
function paintTodayAsk(brief) {
|
|
2365
|
+
var form = $('today-ask'); if (!form) return;
|
|
2366
|
+
var chat = window.COSWeb && window.COSWeb.cosChat;
|
|
2367
|
+
// Ask's capability is read once per page (the chat's /api/health read); paint again when it lands.
|
|
2368
|
+
if (brief && chat && chat.known && !chat.known() && chat.ensureCaps && !TODAY.askCaps) {
|
|
2369
|
+
TODAY.askCaps = true;
|
|
2370
|
+
chat.ensureCaps().then(function () { if (TODAY.model && state.pane === 'today') renderStory(TODAY.model); }, function () {});
|
|
2371
|
+
}
|
|
2372
|
+
// T1 spec 0: the box stays while a session read is pending or failed; briefShown decides whether there is a summary.
|
|
2373
|
+
var on = !!(brief && !TODAY.offline && chat && chat.askAbout && chat.askOn && chat.askOn());
|
|
2374
|
+
form.classList.toggle('hidden', !on);
|
|
2375
|
+
}
|
|
2376
|
+
// The sessions the summary read: the server's own list with the summary (7.0.10+ round 2 `sessions`: title, provider,
|
|
2377
|
+
// state, when it last moved, the last message into it), else what this page holds for the same rows.
|
|
2378
|
+
function todayAskContext(b) {
|
|
2379
|
+
var rows = [];
|
|
2380
|
+
if (Array.isArray(b.sessions) && b.sessions.length) {
|
|
2381
|
+
b.sessions.forEach(function (r) { if (r && r.title) rows.push({ title: String(r.title), provider: String(r.provider || 'claude'), state: String(r.state || ''), movedAt: r.movedAt || null, lastAsk: r.lastAsk ? String(r.lastAsk) : '' }); });
|
|
2382
|
+
} else {
|
|
2383
|
+
(state.sessions || []).forEach(function (s) {
|
|
2384
|
+
if (sxKeepWarm(s)) return;
|
|
2385
|
+
var kind = sxState(s) === 'waiting' ? 'waiting' : s.running_active ? 'working' : s.running ? 'open' : '';
|
|
2386
|
+
if (!kind) return;
|
|
2387
|
+
var k = splitSessionId(s.provider, s.session_id || s.id);
|
|
2388
|
+
rows.push({ title: sessionTitle(s), provider: k.provider, state: kind, movedAt: kind === 'open' ? (s.last_activity_at || s.state_since) : (s.state_since || s.last_activity_at), lastAsk: '' });
|
|
2389
|
+
});
|
|
2390
|
+
}
|
|
2391
|
+
return { summary: String(b.text || ''), at: b.at || null, sessions: rows };
|
|
2392
|
+
}
|
|
2393
|
+
function sendTodayAsk() {
|
|
2394
|
+
var input = $('today-ask-input'), text = input ? input.value.trim() : '';
|
|
2395
|
+
var chat = window.COSWeb && window.COSWeb.cosChat, b = briefShown();
|
|
2396
|
+
if (!text || TODAY.askBusy || !b || !chat || !chat.askAbout) return;
|
|
2397
|
+
TODAY.askBusy = true;
|
|
2398
|
+
var ctx = todayAskContext(b);
|
|
2399
|
+
go('messages', ['cos']);
|
|
2400
|
+
chat.askAbout(text, ctx).then(function () { input.value = ''; autoGrow(input); }, function (err) {
|
|
2401
|
+
toast((err && err.message) || 'Ask did not send. Your words are still in the box on Today.');
|
|
2402
|
+
}).then(function () { TODAY.askBusy = false; });
|
|
2403
|
+
}
|
|
1881
2404
|
// From today's meetings: Work's suggestions from meetings held today. They stay here until
|
|
1882
2405
|
// midnight, then join Sort in Work; Review opens them in Work's Sort.
|
|
1883
2406
|
function localDay(d) { return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); }
|
|
@@ -2322,20 +2845,84 @@
|
|
|
2322
2845
|
renderSessionGroups();
|
|
2323
2846
|
});
|
|
2324
2847
|
}
|
|
2325
|
-
//
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2848
|
+
// ── Meaning search (W1, 7.0.x): "Find a session about this", as COS Control's command panel does it
|
|
2849
|
+
// (SetupAssistant.swift 1476-1900). POST /api/agent-sessions/find takes exactly { query, consentedProvider, stage }
|
|
2850
|
+
// (routes/session-find.ts:45-51). Nothing is sent before the person says yes for the decision model that decides search
|
|
2851
|
+
// now. A yes for Jev is no yes for SubQ. The yes is kept in this browser only (the Mac keeps its own).
|
|
2852
|
+
var MEANING_STORE = 'cos.web.meaningConsent';
|
|
2853
|
+
var MEANING_NAME = { jev: 'Jev', subq: 'SubQ' };
|
|
2854
|
+
var MEANING_TITLE = { consent_required: 'Meaning search needs your okay', provider_changed: 'The decision model changed',
|
|
2855
|
+
decision_not_configured: 'Meaning search needs a key', jev_not_configured: 'Meaning search needs a key', sessions_off: 'Sessions are off',
|
|
2856
|
+
find_off: 'Meaning search is off', cap_reached: 'Meaning search is used up for today', breaker_open: 'Meaning search is paused',
|
|
2857
|
+
query_too_short: 'Type a few more words', failed: 'Meaning search did not answer' };
|
|
2858
|
+
var MEANING_STRENGTH = { strong: 'Strong match', likely: 'Likely match', possible: 'Possible match' };
|
|
2859
|
+
var MEANING = { view: null, at: 0 };
|
|
2860
|
+
function meaningChoices() {
|
|
2861
|
+
try { var v = JSON.parse(localStorage.getItem(MEANING_STORE) || '{}'); return v && typeof v === 'object' && !Array.isArray(v) ? v : {}; } catch (e) { return {}; }
|
|
2862
|
+
}
|
|
2863
|
+
function meaningChoose(provider, choice) {
|
|
2864
|
+
if (!MEANING_NAME[provider]) return;
|
|
2865
|
+
var c = meaningChoices();
|
|
2866
|
+
if (choice === 'on' || choice === 'never') c[provider] = choice; else delete c[provider];
|
|
2867
|
+
try { localStorage.setItem(MEANING_STORE, JSON.stringify(c)); } catch (e) { /* private mode: the question comes back next time */ }
|
|
2868
|
+
}
|
|
2869
|
+
// GET /api/decision-provider: the model that decides search now, whether its key is saved, and whether this server can
|
|
2870
|
+
// find sessions by meaning at all (capabilities.sessionFind). Read again after a minute, or after a refusal.
|
|
2871
|
+
function meaningSettings(force) {
|
|
2872
|
+
if (!force && MEANING.view && Date.now() - MEANING.at < 60000) return Promise.resolve(MEANING.view);
|
|
2873
|
+
return api('/api/decision-provider').then(function (d) {
|
|
2874
|
+
var p = d && d.effective ? d.effective.search : null;
|
|
2875
|
+
p = MEANING_NAME[p] ? p : null;
|
|
2876
|
+
MEANING.view = { provider: p, keyed: !!(p && d.configured && d.configured[p] === true), capable: !!(d && d.capabilities && d.capabilities.sessionFind === true) };
|
|
2877
|
+
MEANING.at = Date.now();
|
|
2878
|
+
return MEANING.view;
|
|
2879
|
+
});
|
|
2880
|
+
}
|
|
2881
|
+
function meaningConsentText(provider) {
|
|
2882
|
+
return 'Each search sends the words you type here, plus the title and first 150 characters of the first prompt from up to 508 recent sessions, to ' +
|
|
2883
|
+
MEANING_NAME[provider] + '. Emails, links, home-folder paths and key-shaped text are removed first. Names and other details are not.' +
|
|
2884
|
+
(provider === 'subq' ? ' SubQ keeps what it is sent.' : '');
|
|
2885
|
+
}
|
|
2886
|
+
function meaningConsentHtml(provider) {
|
|
2887
|
+
// Lead decision (11/09): the choice is kept in this browser only, and the card says so.
|
|
2888
|
+
return '<div class="list meaning-consent"><p class="empty"><strong>Find sessions by meaning with ' + esc(MEANING_NAME[provider]) + '?</strong>' + esc(meaningConsentText(provider)) +
|
|
2889
|
+
' ' + esc('Your choice applies to this browser only.') + '</p>' +
|
|
2890
|
+
'<div class="row-actions"><button type="button" class="btn small primary" data-action="meaning-consent" data-provider="' + esc(provider) + '" data-choice="on">Turn on</button>' +
|
|
2891
|
+
'<button type="button" class="btn small" data-action="meaning-consent" data-provider="' + esc(provider) + '" data-choice="never">Never</button></div></div>';
|
|
2892
|
+
}
|
|
2893
|
+
function meaningRows(q, d) {
|
|
2894
|
+
var rows = (d && Array.isArray(d.rows)) ? d.rows : [];
|
|
2895
|
+
return '<h2 class="group-title"><span>About \u201c' + esc(q) + '\u201d</span><span>' + rows.length + '</span></h2><div class="list">' + rows.map(function (r) {
|
|
2896
|
+
var s = { provider: r.provider, session_id: r.session_id, display_label: r.display_label, custom_title: r.custom_title, first_prompt: r.first_prompt, project: r.project,
|
|
2897
|
+
modified: r.modified, last_activity_at: r.last_activity_at, alive: r.alive, pinned: r.pinned, slug: r.slug };
|
|
2898
|
+
return sessionRow(s, MEANING_STRENGTH[r.strength] || MEANING_STRENGTH.possible);
|
|
2899
|
+
}).join('') + '</div>';
|
|
2338
2900
|
}
|
|
2901
|
+
function meaningRun(q, provider, stage) {
|
|
2902
|
+
return api('/api/agent-sessions/find', { method: 'POST', body: { query: q, consentedProvider: provider, stage: stage }, timeoutMs: 90000 }).then(function (d) {
|
|
2903
|
+
if (d && d.available === false) {
|
|
2904
|
+
if (d.reason === 'provider_changed') MEANING.view = null;
|
|
2905
|
+
return emptyBox(MEANING_TITLE[d.reason] || 'Meaning search did not run', d.message || '');
|
|
2906
|
+
}
|
|
2907
|
+
var rows = (d && Array.isArray(d.rows)) ? d.rows : [];
|
|
2908
|
+
// The first stage reads the newest 254 sessions; with nothing there, look once further back (Control's "Looking further back").
|
|
2909
|
+
if (!rows.length && stage === 'first') return meaningRun(q, provider, 'full');
|
|
2910
|
+
if (!rows.length) return emptyBox('Nothing close by meaning', 'Checked ' + (Number(d && d.asked) || 0) + ' sessions. Try other words, or search by words.');
|
|
2911
|
+
return meaningRows(q, d);
|
|
2912
|
+
});
|
|
2913
|
+
}
|
|
2914
|
+
function findByMeaning(q) {
|
|
2915
|
+
return meaningSettings(false).then(function (m) {
|
|
2916
|
+
if (!m.capable) return emptyBox('Meaning search is not on here', 'This COS server cannot find sessions by meaning. Search by words instead.');
|
|
2917
|
+
if (!m.provider || !m.keyed) return emptyBox('Meaning search needs a decision model', 'Choose Jev or SubQ in Settings, then save its key in COS Control on your Mac: search Settings there for Jev key or SubQ key.');
|
|
2918
|
+
var choice = meaningChoices()[m.provider];
|
|
2919
|
+
if (choice === 'never') return emptyBox('Meaning search is off in this browser', 'You chose Never for ' + MEANING_NAME[m.provider] + '.') +
|
|
2920
|
+
'<div class="row-actions"><button type="button" class="btn small" data-action="meaning-consent" data-provider="' + esc(m.provider) + '" data-choice="ask">Ask me again</button></div>';
|
|
2921
|
+
if (choice !== 'on') return meaningConsentHtml(m.provider);
|
|
2922
|
+
return meaningRun(q, m.provider, 'first');
|
|
2923
|
+
}).catch(function (err) { return emptyBox('Meaning search did not run', (err && err.message) || ''); });
|
|
2924
|
+
}
|
|
2925
|
+
// ── end meaning search
|
|
2339
2926
|
|
|
2340
2927
|
// The hooks banner (ActivityWindow.swift 2867-2905). Installing hooks writes to the Mac's Claude
|
|
2341
2928
|
// settings, which stays a Mac action, so the web names where to do it.
|
|
@@ -2390,6 +2977,7 @@
|
|
|
2390
2977
|
if (act === 'retry' && rd) { rd.refusal = null; renderVerdict(rd); return sendSessionTurn(!!rd.retryFork); }
|
|
2391
2978
|
if (act === 'caution-send' && rd) { rd.cautionAck = true; rd.cautionAsk = false; renderVerdict(rd); return sendSessionTurn(false); }
|
|
2392
2979
|
if (act === 'caution-cancel' && rd) { rd.cautionAsk = false; renderVerdict(rd); return; }
|
|
2980
|
+
if (act === 'turnend' && rd) return sendTurnEndChoice(rd, Number(t.getAttribute('data-i')));
|
|
2393
2981
|
if (act === 'usage-clear') { SX.uTool = 'All tools'; SX.uModel = 'All models'; SX.uToolBefore = null; uSliceChanged(); return paintUsage(); }
|
|
2394
2982
|
if (act === 'usage-range') return uOpenRange(t, true);
|
|
2395
2983
|
});
|
|
@@ -2730,12 +3318,16 @@
|
|
|
2730
3318
|
'<div class="sx-reader-head"><div class="sx-head-line">' + sxBadge(provider) + '<h2 class="sx-reader-title">' + esc(title) + '</h2>' + sxFullButton() + '</div>' +
|
|
2731
3319
|
'<p class="sx-subtitle">' + esc(sxSubtitle(d, provider, turns, r.more)) + '</p></div>' +
|
|
2732
3320
|
'<div class="sx-activity" id="session-live" aria-live="polite"></div>' +
|
|
3321
|
+
'<div class="sx-mac-q" id="session-mac-q" role="status" hidden></div>' +
|
|
2733
3322
|
(trunc ? '<p class="sx-note">' + esc(trunc) + '</p>' : '') +
|
|
2734
3323
|
'<div class="turns sx-turns" id="session-turns">' + renderTurns(turns, d) + '</div>' +
|
|
2735
3324
|
'<div class="sx-foot"><button type="button" class="cx-btn quiet" data-sx="copy-session" title="A kickstart brief for another agent, built from the turns COS serves. Not a Claude Code resume: it does not reopen this session.">Copy session</button>' +
|
|
2736
3325
|
'<span class="sx-foot-note">Kickstart brief for another agent. Not a Claude Code resume.</span></div>' +
|
|
2737
3326
|
'<p class="sx-copy-note hidden" id="session-copy-note" role="status"></p>' +
|
|
2738
3327
|
'<div class="sx-chat">' +
|
|
3328
|
+
// DESIGN.md step 4 #3: this session's question cards (held and answerable, or at the Mac with its words), above
|
|
3329
|
+
// the verdict and the Send box, the same component Today's sheet mounts, so the full hold lights up here.
|
|
3330
|
+
'<div class="sx-questions" id="session-questions"></div>' +
|
|
2739
3331
|
'<div id="session-verdict"></div>' +
|
|
2740
3332
|
'<form class="composer att-composer" id="session-form" data-provider="' + esc(provider) + '" data-id="' + esc(id) + '">' +
|
|
2741
3333
|
'<div class="cc-chips att-files hidden" id="session-files"></div>' +
|
|
@@ -2749,9 +3341,13 @@
|
|
|
2749
3341
|
readerImagesMount($('session-turns'), r);
|
|
2750
3342
|
sxNoteList(r, d);
|
|
2751
3343
|
setLive({});
|
|
3344
|
+
if (typeof readerMacPaint === 'function') readerMacPaint(r, sxState(d) === 'waiting');
|
|
3345
|
+
var qk = window.COSWeb.sessionQuestions, qhost = $('session-questions');
|
|
3346
|
+
if (qk && qk.mount && qhost) r.qMount = qk.mount(qhost, id, function () { if (state.reading === r && typeof readerMacPaint === 'function') readerMacPaint(r, r.waitingNow); });
|
|
2752
3347
|
paintReaderFiles();
|
|
3348
|
+
paintWebKeyOffer(r);
|
|
2753
3349
|
// The Mac's answer about files may not be in yet: ask once, then draw the clip.
|
|
2754
|
-
var kitR = readerKit(); if (kitR && !kitR.turnFiles()) kitR.ensureCaps().then(paintReaderFiles, function () {});
|
|
3350
|
+
var kitR = readerKit(); if (kitR && !kitR.turnFiles()) kitR.ensureCaps().then(function () { paintReaderFiles(); paintWebKeyOffer(r); }, function () {});
|
|
2755
3351
|
renderHeld(state.reading);
|
|
2756
3352
|
loadQueue(provider, id);
|
|
2757
3353
|
sxProbeAttach(r);
|
|
@@ -3023,6 +3619,7 @@
|
|
|
3023
3619
|
var waiting = !working && (e.state === 'waiting' || e.state === 'needs_input' || e.agent_state === 'waiting');
|
|
3024
3620
|
var el = $('session-state');
|
|
3025
3621
|
if (el) el.textContent = working ? 'Working now' : waiting ? 'Waiting for you' : 'Open';
|
|
3622
|
+
if (typeof readerMacPaint === 'function') readerMacPaint(r, waiting);
|
|
3026
3623
|
if (wasWorking && !working) { refreshTurns(provider, id); replyEnded(r); }
|
|
3027
3624
|
wasWorking = working;
|
|
3028
3625
|
// Gone to waiting: its question may be parked for this page now (6.99.27).
|
|
@@ -3200,7 +3797,8 @@
|
|
|
3200
3797
|
// The box is cleared only of the very words that were sent; anything typed since stays.
|
|
3201
3798
|
function clearBoxOf(text) {
|
|
3202
3799
|
var input = $('session-input');
|
|
3203
|
-
|
|
3800
|
+
// 7.0.18: an answer to a turn-end ask was sent with the attribution in front; the box holds his words without it.
|
|
3801
|
+
if (input && (input.value.trim() === String(text).trim() || input.value.trim() === turnEndBare(text))) { input.value = ''; autoGrow(input); }
|
|
3204
3802
|
if (state.reading && state.reading.draft && state.reading.draft.text === text) state.reading.draft = null;
|
|
3205
3803
|
}
|
|
3206
3804
|
// Words go back in the box only if the box is empty; otherwise the status line keeps them. `files` says what became
|
|
@@ -3347,6 +3945,9 @@
|
|
|
3347
3945
|
// during the first attach could park the same text in the queue while the first lands.
|
|
3348
3946
|
if (r.sending) return;
|
|
3349
3947
|
if (r.refusal || r.cautionAsk) { r.refusal = null; r.cautionAsk = false; renderVerdict(r); }
|
|
3948
|
+
// 7.0.18: while a turn-end ask is open, the box's words are his own answer ('Other' in the contract): sent with the
|
|
3949
|
+
// same attribution as a choice. A fork goes to a copy and is not an answer, so it is sent as written.
|
|
3950
|
+
if (!fork && r.turnEnd && turnEndOf(readerRowOf(r))) text = turnEndPrompt(text);
|
|
3350
3951
|
r.sending = true;
|
|
3351
3952
|
var btns = form.querySelectorAll('button'); btns.forEach(function (b) { b.disabled = true; });
|
|
3352
3953
|
var done = function () { r.sending = false; btns.forEach(function (b) { b.disabled = false; }); paintReaderFiles(); };
|
|
@@ -3449,6 +4050,205 @@
|
|
|
3449
4050
|
});
|
|
3450
4051
|
}
|
|
3451
4052
|
|
|
4053
|
+
// ── BEGIN web-key handoff (6.99.31, Phase 3, review W2) ──
|
|
4054
|
+
// With the Mac's webKeySigner capability (only with COS_WEB_KEY_AUTHORITY on) and an enrolled phone key, the session
|
|
4055
|
+
// composer offers "Approve on this phone". That is a TOP-LEVEL navigation to the signer page on port 3144 carrying a
|
|
4056
|
+
// one-use handoff id in the fragment. The words stay on the Mac until the signer claims them; they are never in a URL.
|
|
4057
|
+
// Without the capability nothing here runs: no request, no button, the composer exactly as before.
|
|
4058
|
+
var WEB_KEY = { status: null, statusAt: 0, loading: null, generation: 0, views: [], refreshing: null, navigation: null };
|
|
4059
|
+
var WEB_KEY_COPY = {
|
|
4060
|
+
no_phone_key: 'No phone has a key yet. Set one up on the approval page first.',
|
|
4061
|
+
attachments_not_signable: 'Files cannot be approved on the phone yet. Remove them, or use Send.',
|
|
4062
|
+
words_too_long: 'That message is too long to approve on the phone. Shorten it, or use Send.',
|
|
4063
|
+
handoff_cap_reached: 'Too many approvals are waiting. Finish one, or wait two minutes.',
|
|
4064
|
+
origin_unavailable: 'Your Mac does not know its Tailscale name yet, so the approval page cannot open.',
|
|
4065
|
+
web_key_authority_off: 'Phone approval is turned off on the Mac.'
|
|
4066
|
+
};
|
|
4067
|
+
function webKeyCapable() { var k = readerKit(); return !!(k && typeof k.webKeySigner === 'function' && k.webKeySigner() === 1); }
|
|
4068
|
+
function webKeyStatus() {
|
|
4069
|
+
var age = Date.now() - WEB_KEY.statusAt;
|
|
4070
|
+
if (WEB_KEY.status && age >= 0 && age < 30000) return Promise.resolve(WEB_KEY.status);
|
|
4071
|
+
if (!WEB_KEY.loading) {
|
|
4072
|
+
var generation = WEB_KEY.generation;
|
|
4073
|
+
WEB_KEY.loading = apiAnswer('/api/web-key/status', { timeoutMs: 15000 }).then(function (a) {
|
|
4074
|
+
if (generation !== WEB_KEY.generation) return null;
|
|
4075
|
+
WEB_KEY.loading = null;
|
|
4076
|
+
WEB_KEY.status = a.status === 200 && a.body && a.body.ok === true && Number.isSafeInteger(a.body.activeKeys) && a.body.activeKeys >= 0 ? { activeKeys: a.body.activeKeys } : null;
|
|
4077
|
+
WEB_KEY.statusAt = Date.now();
|
|
4078
|
+
return WEB_KEY.status;
|
|
4079
|
+
}, function () { if (generation === WEB_KEY.generation) { WEB_KEY.loading = null; WEB_KEY.status = null; } return null; });
|
|
4080
|
+
}
|
|
4081
|
+
return WEB_KEY.loading;
|
|
4082
|
+
}
|
|
4083
|
+
function webKeySetupUrl() {
|
|
4084
|
+
var host = location.hostname;
|
|
4085
|
+
// The passkey belongs to this Mac's full MagicDNS host, never an IP, local alias or public suffix.
|
|
4086
|
+
if (typeof host !== 'string' || host.length > 253 || !/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.){2,}ts\.net$/.test(host)) return null;
|
|
4087
|
+
return 'https://' + host + ':3144/';
|
|
4088
|
+
}
|
|
4089
|
+
function phoneApprovalView(r, opts) {
|
|
4090
|
+
opts = opts || {};
|
|
4091
|
+
var prefix = opts.prefix || 'session';
|
|
4092
|
+
return { prefix: prefix, form: $(prefix + '-form'), send: $(prefix + '-send'), input: $(prefix + '-input'),
|
|
4093
|
+
live: opts.live || function () { return state.reading === r; },
|
|
4094
|
+
status: opts.status || sessionStatus, files: opts.files || function () { return readerFilesState(readerFilesOn() ? readerBox(r) : null); } };
|
|
4095
|
+
}
|
|
4096
|
+
function phoneApprovalViewLive(view) {
|
|
4097
|
+
return view.live() && view.form && view.send && view.form === $(view.prefix + '-form') && view.send === $(view.prefix + '-send');
|
|
4098
|
+
}
|
|
4099
|
+
function clearPhoneOffer(r, view) {
|
|
4100
|
+
var offer = view.form.querySelector('[data-phone-offer]'), packets = view.form.querySelector('[data-phone-packets]');
|
|
4101
|
+
if (offer && offer.parentNode) offer.parentNode.removeChild(offer);
|
|
4102
|
+
if (packets && packets.parentNode) packets.parentNode.removeChild(packets);
|
|
4103
|
+
r.phonePackets = {}; r.phonePacketSha256 = ''; r.phoneDraft = null;
|
|
4104
|
+
}
|
|
4105
|
+
function refreshWebKeyOffers() {
|
|
4106
|
+
if (!webKeyCapable() || WEB_KEY.refreshing) return;
|
|
4107
|
+
WEB_KEY.status = null; WEB_KEY.statusAt = 0; WEB_KEY.loading = null; WEB_KEY.generation += 1;
|
|
4108
|
+
WEB_KEY.views = WEB_KEY.views.filter(function (entry) { return phoneApprovalViewLive(entry.view); });
|
|
4109
|
+
if (!WEB_KEY.views.length) return;
|
|
4110
|
+
var views = WEB_KEY.views.slice();
|
|
4111
|
+
views.forEach(function (entry) { clearPhoneOffer(entry.r, entry.view); });
|
|
4112
|
+
var fresh = webKeyStatus(); WEB_KEY.refreshing = fresh;
|
|
4113
|
+
fresh.then(function () { if (WEB_KEY.refreshing === fresh) WEB_KEY.refreshing = null; });
|
|
4114
|
+
views.forEach(function (entry) { paintWebKeyOffer(entry.r, entry.opts); });
|
|
4115
|
+
}
|
|
4116
|
+
// Safari can restore this exact DOM after enrollment or revocation on the separate signer origin.
|
|
4117
|
+
// Refetch once per persisted return; closed/replaced readers are pruned and no timer is installed.
|
|
4118
|
+
// (Guarded: it runs at load, and a host without window events, the web-control vm harnesses, must still load.)
|
|
4119
|
+
if (window.addEventListener) window.addEventListener('pageshow', function (e) {
|
|
4120
|
+
if (!e || !e.persisted) return;
|
|
4121
|
+
var nav = WEB_KEY.navigation; WEB_KEY.navigation = null;
|
|
4122
|
+
if (nav && nav.token.completed && nav.r.phoneNavigation === nav.token) {
|
|
4123
|
+
if (nav.r.sending === nav.token) nav.r.sending = false;
|
|
4124
|
+
nav.r.phoneNavigation = null;
|
|
4125
|
+
}
|
|
4126
|
+
refreshWebKeyOffers();
|
|
4127
|
+
});
|
|
4128
|
+
function phonePacketDetails(p, sha) {
|
|
4129
|
+
return 'Requested ' + p.action + ': ' + p.artifact + ' ' + p.version + '\nChannel: ' + p.channel + '\nDigest: ' + p.digest +
|
|
4130
|
+
'\nReceiver: ' + p.provider + ' / ' + p.sessionId + '\nExpires: ' + new Date(p.exp).toISOString() + '\nPacket: ' + sha +
|
|
4131
|
+
'\nThis request approves nothing until you review it and confirm on the phone.';
|
|
4132
|
+
}
|
|
4133
|
+
function paintWebKeyOffer(r, opts) {
|
|
4134
|
+
if (!webKeyCapable()) return;
|
|
4135
|
+
var view = phoneApprovalView(r, opts);
|
|
4136
|
+
if (!phoneApprovalViewLive(view)) return;
|
|
4137
|
+
WEB_KEY.views = WEB_KEY.views.filter(function (entry) { return phoneApprovalViewLive(entry.view) && entry.view.form !== view.form; });
|
|
4138
|
+
WEB_KEY.views.push({ r: r, opts: opts, view: view });
|
|
4139
|
+
var generation = WEB_KEY.generation;
|
|
4140
|
+
webKeyStatus().then(function (st) {
|
|
4141
|
+
if (generation !== WEB_KEY.generation || !phoneApprovalViewLive(view)) return;
|
|
4142
|
+
if (!st) { clearPhoneOffer(r, view); return; }
|
|
4143
|
+
var offer = view.form.querySelector('[data-phone-offer]');
|
|
4144
|
+
if (offer && !!view.form.querySelector('[data-phone-setup]') === (st.activeKeys === 0)) return;
|
|
4145
|
+
if (offer || st.activeKeys === 0) clearPhoneOffer(r, view);
|
|
4146
|
+
if (st.activeKeys === 0) {
|
|
4147
|
+
var setupUrl = webKeySetupUrl(); if (!setupUrl) return;
|
|
4148
|
+
var setup = document.createElement('a');
|
|
4149
|
+
setup.className = 'btn small'; setup.setAttribute('data-phone-offer', '1');
|
|
4150
|
+
setup.setAttribute('data-phone-setup', '1'); setup.setAttribute('href', setupUrl); setup.setAttribute('target', '_top');
|
|
4151
|
+
setup.textContent = 'Set up phone approvals';
|
|
4152
|
+
view.send.parentNode.insertBefore(setup, view.send);
|
|
4153
|
+
return;
|
|
4154
|
+
}
|
|
4155
|
+
var b = document.createElement('button');
|
|
4156
|
+
b.type = 'button'; b.className = 'btn small'; b.setAttribute('data-phone-offer', '1');
|
|
4157
|
+
b.setAttribute(view.prefix === 'ta' ? 'data-ta' : 'data-action', view.prefix === 'ta' ? 'approve-phone' : 'approve-on-phone');
|
|
4158
|
+
b.title = 'Review an exact request on the approval page, then confirm with your phone passkey';
|
|
4159
|
+
b.textContent = 'Approve on this phone';
|
|
4160
|
+
view.send.parentNode.insertBefore(b, view.send);
|
|
4161
|
+
return apiAnswer('/api/web-key/packets?provider=' + encodeURIComponent(r.provider) + '&sessionId=' + encodeURIComponent(r.id)).then(function (a) {
|
|
4162
|
+
if (generation !== WEB_KEY.generation || !phoneApprovalViewLive(view) || view.form.querySelector('[data-phone-offer]') !== b) return;
|
|
4163
|
+
var rows = a.status === 200 && a.body && a.body.ok === true && Array.isArray(a.body.packets) ? a.body.packets : [];
|
|
4164
|
+
rows = rows.filter(function (row) { var p = row && row.packet; return p && p.provider === r.provider && p.sessionId === r.id &&
|
|
4165
|
+
(p.action === 'install' || p.action === 'publish') && Number.isSafeInteger(p.exp) && p.exp >= Date.now() && /^[0-9a-f]{64}$/.test(row.packetSha256 || ''); });
|
|
4166
|
+
r.phonePackets = {}; rows.forEach(function (row) { r.phonePackets[row.packetSha256] = row.packet; });
|
|
4167
|
+
if (!rows.length) return;
|
|
4168
|
+
var box = document.createElement('div'), label = document.createElement('label'), select = document.createElement('select'), disclosure = document.createElement('details'), summary = document.createElement('summary'), detail = document.createElement('pre');
|
|
4169
|
+
box.className = 'phone-approval-packet'; box.setAttribute('data-phone-packets', '1'); select.id = view.prefix + '-phone-packet'; detail.id = view.prefix + '-phone-details';
|
|
4170
|
+
label.textContent = 'What to approve'; label.setAttribute('for', select.id);
|
|
4171
|
+
var message = document.createElement('option'); message.value = ''; message.textContent = 'Message only — no release approval'; select.appendChild(message);
|
|
4172
|
+
rows.forEach(function (row) { var option = document.createElement('option'), p = row.packet; option.value = row.packetSha256;
|
|
4173
|
+
option.textContent = p.action + ' ' + p.artifact + ' ' + p.version + ' / ' + p.channel; select.appendChild(option); });
|
|
4174
|
+
select.value = r.phonePacketSha256 || '';
|
|
4175
|
+
var update = function () { r.phonePacketSha256 = select.value; var p = r.phonePackets[select.value];
|
|
4176
|
+
detail.textContent = p ? phonePacketDetails(p, select.value) : 'Message only. This authorizes no install, publish or push.';
|
|
4177
|
+
if (p && view.input && !view.input.value.trim()) view.input.value = 'Approve ' + p.action + ' ' + p.artifact + ' ' + p.version + ' on ' + p.channel + '.';
|
|
4178
|
+
};
|
|
4179
|
+
select.addEventListener('change', update); update();
|
|
4180
|
+
summary.textContent = 'Review release request'; disclosure.appendChild(summary); disclosure.appendChild(detail);
|
|
4181
|
+
box.appendChild(label); box.appendChild(select); box.appendChild(disclosure); view.form.insertBefore(box, view.form.firstChild);
|
|
4182
|
+
}, function () { /* No packet is selected implicitly when this read fails. */ });
|
|
4183
|
+
});
|
|
4184
|
+
}
|
|
4185
|
+
// Only the signer on this same host, port 3144, path /, with nothing but the handoff id in the fragment.
|
|
4186
|
+
function webKeySignerUrlOk(u) {
|
|
4187
|
+
try {
|
|
4188
|
+
var x = new URL(u);
|
|
4189
|
+
return x.protocol === 'https:' && x.hostname === location.hostname && x.port === '3144' && x.pathname === '/' && !x.search && /^#h=[A-Za-z0-9_-]{16,64}$/.test(x.hash);
|
|
4190
|
+
} catch (_e) { return false; }
|
|
4191
|
+
}
|
|
4192
|
+
function approveOnPhone(opts) {
|
|
4193
|
+
opts = opts || {};
|
|
4194
|
+
var r = opts.r || state.reading; if (!r || r.sending) return Promise.resolve('busy');
|
|
4195
|
+
var view = phoneApprovalView(r, opts), say = view.status;
|
|
4196
|
+
if (!view.live()) return Promise.resolve('stale');
|
|
4197
|
+
var input = view.input, text = input ? input.value.trim() : '';
|
|
4198
|
+
if (!text) { say('Write a message first.', 'error'); return Promise.resolve('empty'); }
|
|
4199
|
+
if (view.files() !== 'none') { say(WEB_KEY_COPY.attachments_not_signable, 'error'); return Promise.resolve('files'); }
|
|
4200
|
+
var provider = r.provider, id = r.id, packetSha = r.phonePacketSha256 || '';
|
|
4201
|
+
var packet = packetSha && r.phonePackets && r.phonePackets[packetSha];
|
|
4202
|
+
if (packetSha && (!packet || packet.exp < Date.now() || packet.provider !== provider || packet.sessionId !== id)) { say('That release request expired or changed. Reopen this session to load its current request.', 'error'); return Promise.resolve('packet'); }
|
|
4203
|
+
var draft = r.phoneDraft;
|
|
4204
|
+
if (!draft || draft.text !== text || draft.packetSha256 !== packetSha) draft = r.phoneDraft = { text: text, key: newTurnKey(), packetSha256: packetSha };
|
|
4205
|
+
var bound = bindingFor(provider, id);
|
|
4206
|
+
// Other senders assign boolean true. This unique truthy owner lets late callbacks and a BFCache return
|
|
4207
|
+
// release only this phone operation, never a newer ordinary send or another phone operation.
|
|
4208
|
+
var operation = { completed: false };
|
|
4209
|
+
r.phoneNavigation = operation; r.sending = operation;
|
|
4210
|
+
var ownsSending = function () { return r.sending === operation && r.phoneNavigation === operation; };
|
|
4211
|
+
var releaseSending = function () { if (ownsSending()) { r.sending = false; r.phoneNavigation = null; } };
|
|
4212
|
+
say('Opening the approval page.');
|
|
4213
|
+
var attach = bound ? Promise.resolve(bound) : apiAnswer(sessionPath(provider, id, '/attach'), { method: 'POST', body: { cosSessionId: cosSessionId() }, timeoutMs: 20000 }).then(function (a) {
|
|
4214
|
+
if (a.status >= 200 && a.status < 300 && a.body.attached === true && a.body.bindingId && typeof a.body.epoch === 'number') {
|
|
4215
|
+
var b = { bindingId: a.body.bindingId, epoch: a.body.epoch, at: Date.now() };
|
|
4216
|
+
rememberBinding(provider, id, b);
|
|
4217
|
+
return b;
|
|
4218
|
+
}
|
|
4219
|
+
return null;
|
|
4220
|
+
});
|
|
4221
|
+
return attach.then(function (b) {
|
|
4222
|
+
if (!ownsSending() || !phoneApprovalViewLive(view)) return 'stale';
|
|
4223
|
+
if (!b) { say('COS cannot write to this session right now.', 'error'); return 'refused'; }
|
|
4224
|
+
var request = { bindingId: b.bindingId, epoch: b.epoch, targetKey: targetKey(provider, id), clientTurnId: draft.key, prompt: draft.text };
|
|
4225
|
+
if (packetSha) request.packetSha256 = packetSha;
|
|
4226
|
+
return apiAnswer('/api/web-key/handoff', { method: 'POST', timeoutMs: 15000, body: request }).then(function (h) {
|
|
4227
|
+
if (!ownsSending() || !phoneApprovalViewLive(view)) return 'stale';
|
|
4228
|
+
var body = h.body || {};
|
|
4229
|
+
if (h.status === 201 && body.ok === true && webKeySignerUrlOk(body.signerUrl)) {
|
|
4230
|
+
say('Opening the approval page.');
|
|
4231
|
+
location.assign(body.signerUrl);
|
|
4232
|
+
operation.completed = true;
|
|
4233
|
+
WEB_KEY.navigation = { r: r, token: operation };
|
|
4234
|
+
return 'opened';
|
|
4235
|
+
}
|
|
4236
|
+
if (h.status === 201) { say('Open Control from your Mac’s Tailscale name to approve on this phone.', 'error'); return 'refused'; }
|
|
4237
|
+
if (/^(binding_changed|unknown_binding)$/.test(body.error || '') && !opts.reattached) {
|
|
4238
|
+
rememberBinding(provider, id, null);
|
|
4239
|
+
releaseSending();
|
|
4240
|
+
return approveOnPhone(Object.assign({}, opts, { r: r, reattached: true }));
|
|
4241
|
+
}
|
|
4242
|
+
say(WEB_KEY_COPY[body.error] || 'The Mac did not open an approval for that message.', 'error');
|
|
4243
|
+
return 'refused';
|
|
4244
|
+
});
|
|
4245
|
+
}).then(function (how) { if (how !== 'opened') releaseSending(); return how; }, function () {
|
|
4246
|
+
if (ownsSending()) { releaseSending(); say('Could not reach your Mac.', 'error'); }
|
|
4247
|
+
return 'failed';
|
|
4248
|
+
});
|
|
4249
|
+
}
|
|
4250
|
+
// ── END web-key handoff ──
|
|
4251
|
+
|
|
3452
4252
|
var HELD_MAX_MS = 5 * 60000, HELD_RETRY_MS = 8000;
|
|
3453
4253
|
function holdUntilReplyEnds(r, draft) {
|
|
3454
4254
|
if (!r.held.some(function (h) { return h.key === draft.key; })) {
|
|
@@ -5723,11 +6523,12 @@
|
|
|
5723
6523
|
|
|
5724
6524
|
// ── events (one delegated listener) ─────────────────────
|
|
5725
6525
|
document.addEventListener('click', function (e) {
|
|
5726
|
-
var t = e.target.closest('[data-today-next],[data-today-needs],[data-today],[data-goto],[data-pane],[data-session],[data-meeting],[data-speakers],[data-action],[data-open-ask],[data-theme-choice],#topbar-back,#section-btn,#sign-out,#copy-link,#ask-close,#auth-switch,#auth-token-show,#device-code-btn');
|
|
6526
|
+
var t = e.target.closest('[data-today-next],[data-next-prep],[data-next-prep-cancel],[data-next-prep-remove],[data-today-needs],[data-today],[data-goto],[data-pane],[data-session],[data-meeting],[data-speakers],[data-action],[data-open-ask],[data-theme-choice],#topbar-back,#section-btn,#sign-out,#copy-link,#ask-close,#auth-switch,#auth-token-show,#auth-retry,#device-code-btn');
|
|
5727
6527
|
if (!t) { if (e.target.id === 'ask-sheet') closeAsk(); if (e.target.id === 'section-menu') closeMenu(); return; }
|
|
5728
6528
|
var inSheet = !!t.closest('#ask-sheet');
|
|
5729
6529
|
if (t.id === 'topbar-back') return goBack();
|
|
5730
6530
|
if (t.id === 'section-btn') return openMenu();
|
|
6531
|
+
if (t.id === 'auth-retry') { t.disabled = true; t.textContent = 'Checking'; bootStatus().then(function () { t.disabled = false; t.textContent = 'Try again'; }); return; }
|
|
5731
6532
|
if (t.id === 'auth-switch') {
|
|
5732
6533
|
var toToken = $('auth-token-block').classList.contains('hidden');
|
|
5733
6534
|
$('auth-token-block').classList.toggle('hidden', !toToken);
|
|
@@ -5756,6 +6557,9 @@
|
|
|
5756
6557
|
if (t.hasAttribute('data-open-ask')) return openAsk();
|
|
5757
6558
|
if (t.hasAttribute('data-today')) return t.getAttribute('data-today') === 'pet' ? petTap(t) : openTodayAction(t);
|
|
5758
6559
|
if (t.hasAttribute('data-today-next')) return prepNext();
|
|
6560
|
+
if (t.hasAttribute('data-next-prep')) return toggleNextPrep(t.getAttribute('data-next-prep'));
|
|
6561
|
+
if (t.hasAttribute('data-next-prep-cancel')) return closeNextPrep(true);
|
|
6562
|
+
if (t.hasAttribute('data-next-prep-remove')) return removeNextPrepFile(t.getAttribute('data-next-prep-remove'));
|
|
5759
6563
|
if (t.hasAttribute('data-today-needs')) {
|
|
5760
6564
|
var which = t.getAttribute('data-today-needs');
|
|
5761
6565
|
if (which === 'all') TODAY.showAllNeeds = true;
|
|
@@ -5775,6 +6579,9 @@
|
|
|
5775
6579
|
if (t.hasAttribute('data-speakers')) return go('speakers', [t.getAttribute('data-speakers')]);
|
|
5776
6580
|
var action = t.getAttribute('data-action');
|
|
5777
6581
|
if (action === 'fork-session') return sendSessionTurn(true);
|
|
6582
|
+
if (action === 'conn-retry') return connRetry(true);
|
|
6583
|
+
if (action === 'conn-hide-signin') { CONN.hideFor = signinEndsAt(); return connPaint(); }
|
|
6584
|
+
if (action === 'approve-on-phone') return approveOnPhone();
|
|
5778
6585
|
if (action === 'send-anyway') { var rev = state.reading && state.reading.sendAnyway; if (state.reading) state.reading.sendAnyway = null; return sendSessionTurn(false, { acknowledgedRevision: rev }); }
|
|
5779
6586
|
if (action === 'stop-session') return stopSession();
|
|
5780
6587
|
if (action === 'drop-after') return dropHeld();
|
|
@@ -5800,6 +6607,7 @@
|
|
|
5800
6607
|
if (action === 'ask-cos' && q) return askSheetSend(q);
|
|
5801
6608
|
if (action === 'ask-file' && q) return fileToInbox(q);
|
|
5802
6609
|
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; }
|
|
6610
|
+
if (action === 'meaning-consent') { meaningChoose(t.getAttribute('data-provider'), t.getAttribute('data-choice')); if (q) { $('ask-hits').innerHTML = skeletonRows(3); findByMeaning(q).then(function (html) { var el = $('ask-hits'); if (el) el.innerHTML = html; }); } return; }
|
|
5803
6611
|
if (action === 'ask-work' && q) {
|
|
5804
6612
|
closeAsk(); go('work');
|
|
5805
6613
|
// The Work pane (panes/work.js) owns its search box; hand it the query.
|
|
@@ -5822,6 +6630,8 @@
|
|
|
5822
6630
|
showAuth(err && err.status ? err.message : UNREACHABLE_COPY);
|
|
5823
6631
|
});
|
|
5824
6632
|
} else if (f.id === 'session-form') sendSessionTurn(false);
|
|
6633
|
+
else if (f.id === 'today-ask') sendTodayAsk();
|
|
6634
|
+
else if (f.hasAttribute && f.hasAttribute('data-next-prep-form')) startNextPrep();
|
|
5825
6635
|
else if (f.id === 'sessions-search-form') { $('sessions-q').blur(); searchSessions(); }
|
|
5826
6636
|
else if (f.id === 'ask-form') {
|
|
5827
6637
|
var hits = $('ask-hits'); if (hits && $('ask-q').value.trim().length >= 2) renderAsk();
|
|
@@ -5832,6 +6642,7 @@
|
|
|
5832
6642
|
document.addEventListener('change', function (e) {
|
|
5833
6643
|
if (e.target && e.target.hasAttribute && e.target.hasAttribute('data-ask-file')) { addAskFiles(e.target.files); e.target.value = ''; }
|
|
5834
6644
|
if (e.target && e.target.hasAttribute && e.target.hasAttribute('data-sx-file')) { addReaderFiles(e.target.files); e.target.value = ''; }
|
|
6645
|
+
if (e.target && e.target.hasAttribute && e.target.hasAttribute('data-next-prep-file')) { addNextPrepFiles(e.target.files); e.target.value = ''; }
|
|
5835
6646
|
});
|
|
5836
6647
|
// The Sessions reader's box: a pasted screenshot, and files dropped on it.
|
|
5837
6648
|
document.addEventListener('paste', function (e) {
|
|
@@ -5861,6 +6672,7 @@
|
|
|
5861
6672
|
document.addEventListener('input', function (e) {
|
|
5862
6673
|
if (e.target.tagName === 'TEXTAREA') autoGrow(e.target);
|
|
5863
6674
|
if (e.target.id === 'ask-q') renderAsk();
|
|
6675
|
+
if (e.target.hasAttribute && e.target.hasAttribute('data-next-prep-steer')) nextPrepInput(e.target);
|
|
5864
6676
|
if (e.target.id === 'sessions-q' && !e.target.value.trim()) renderSessionGroups(state.sessions);
|
|
5865
6677
|
});
|
|
5866
6678
|
document.addEventListener('keydown', function (e) {
|
|
@@ -5868,6 +6680,7 @@
|
|
|
5868
6680
|
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { e.preventDefault(); openAsk(); return; }
|
|
5869
6681
|
if (e.key === 'Escape' && !$('ask-sheet').classList.contains('hidden')) { closeAsk(); return; }
|
|
5870
6682
|
if (e.key === 'Escape' && !$('section-menu').classList.contains('hidden')) { closeMenu(); $('section-btn').focus(); return; }
|
|
6683
|
+
if (e.key === 'Escape' && NEXT_PREP.open && closeNextPrep(false)) { e.preventDefault(); return; }
|
|
5871
6684
|
if (returnSends(e)) { e.preventDefault(); var form = e.target.closest('form'); if (form) form.requestSubmit(); }
|
|
5872
6685
|
});
|
|
5873
6686
|
document.querySelectorAll('[data-mode]').forEach(function (b) {
|
|
@@ -6141,6 +6954,7 @@
|
|
|
6141
6954
|
questionsChanged: function () { if (TODAY.at) { paintNeeds(); if (state.pane === 'today' && !TODAY.offline) renderPet(state.sessions || []); } }
|
|
6142
6955
|
};
|
|
6143
6956
|
window.COSWeb.sessionKit = {
|
|
6957
|
+
phoneOffer: paintWebKeyOffer, approvePhone: approveOnPhone,
|
|
6144
6958
|
// A session object for continueSession, with the overlay's own view (READER_VIEW's methods).
|
|
6145
6959
|
session: function (provider, id, view) {
|
|
6146
6960
|
return { provider: provider, id: id, draft: null, held: [], view: view, verdict: null, refusal: null, cautionAck: false, feed: newFeed(), live: null, queued: 0 };
|
|
@@ -6174,7 +6988,7 @@
|
|
|
6174
6988
|
// through COSWeb.onKeyboard; COSWeb.keyboardUp says whether it is up now. Which composer the keyboard is for is the
|
|
6175
6989
|
// pane's to mark (panes/cos-chat.js sets html.kb-chat and .cc-kb).
|
|
6176
6990
|
var KB_UP_PX = 120, KB_AFTER_MS = 300;
|
|
6177
|
-
var LR = { up: false, frame: 0, after: 0, cycle: 0, focusN: 0, device: false, field: null, hooks: [], box: '' };
|
|
6991
|
+
var LR = { up: false, frame: 0, after: 0, cycle: 0, focusN: 0, device: false, field: null, hooks: [], box: '', zoomed: false, touchAt: 0 };
|
|
6178
6992
|
function lrPane() { var r = parseRoute(); return String(r.pane + (r.args[0] === 'cos' ? '.cos' : '')).slice(0, 24); }
|
|
6179
6993
|
function lrKind(a) {
|
|
6180
6994
|
var tag = String((a && a.tagName) || '').toLowerCase();
|
|
@@ -6232,9 +7046,21 @@
|
|
|
6232
7046
|
layoutCrumb('kb-after', { pane: lrPane(), n: LR.cycle, gap: gap, sy: s.sy, smax: s.smax, over: s.sy > s.smax, gapNow: lrGap(viewportFit()), syNow: s2.sy,
|
|
6233
7047
|
dvh: lrProbe('height:100dvh', 'h'), svh: lrProbe('height:100svh', 'h'), lvh: lrProbe('height:100lvh', 'h'), lh: v.layoutH });
|
|
6234
7048
|
}
|
|
7049
|
+
// 7.0.17: when the page's zoom crosses 1.01 either way, one breadcrumb (inside layoutCrumb's caps): the scale, the
|
|
7050
|
+
// pane, the focused element's tag (never its text or value) and how long since the last touch. A double-tap zoom
|
|
7051
|
+
// reads as a few hundred ms after a touch with no field focused; a focus zoom as an INPUT or TEXTAREA. Read only:
|
|
7052
|
+
// nothing here changes the zoom.
|
|
7053
|
+
function zoomCrumb(v) {
|
|
7054
|
+
var zoomed = !!(v && v.zoomed);
|
|
7055
|
+
if (zoomed === LR.zoomed) return false;
|
|
7056
|
+
LR.zoomed = zoomed;
|
|
7057
|
+
var a = document.activeElement, tag = a && a !== document.body && a.tagName ? String(a.tagName).toLowerCase().slice(0, 24) : 'none';
|
|
7058
|
+
return layoutCrumb(zoomed ? 'zoom-in' : 'zoom-out', { pane: lrPane(), scale: v.scale, tag: tag, sinceTouch: LR.touchAt ? Date.now() - LR.touchAt : -1 });
|
|
7059
|
+
}
|
|
6235
7060
|
function lrApply() {
|
|
6236
7061
|
LR.frame = 0;
|
|
6237
7062
|
var v = viewportFit(), de = document.documentElement;
|
|
7063
|
+
zoomCrumb(v);
|
|
6238
7064
|
// Written only when they change: a pinch or pan fires this every frame, and each write restyles the page.
|
|
6239
7065
|
var box = v.top + '|' + v.height + '|' + v.dock, moved = box !== LR.box;
|
|
6240
7066
|
if (moved && de && de.style && de.style.setProperty) {
|
|
@@ -6262,9 +7088,24 @@
|
|
|
6262
7088
|
if (vv && vv.addEventListener) { vv.addEventListener('resize', lrFit); vv.addEventListener('scroll', lrFit); }
|
|
6263
7089
|
window.addEventListener('resize', lrFit); window.addEventListener('orientationchange', lrFit); window.addEventListener('pageshow', lrFit);
|
|
6264
7090
|
document.addEventListener('focusin', function () { lrFocus(); lrFit(); });
|
|
7091
|
+
// 7.0.17: when the last touch landed, for the zoom breadcrumb only. Passive: it never blocks a gesture.
|
|
7092
|
+
document.addEventListener('touchstart', function () { LR.touchAt = Date.now(); }, { capture: true, passive: true });
|
|
6265
7093
|
document.addEventListener('focusout', function () { setTimeout(lrFit, 0); });
|
|
6266
7094
|
shellHooks.push(function () { lrDevice(); lrFit(); });
|
|
6267
7095
|
})();
|
|
7096
|
+
// 7.0.17 (Miles 2026-11-10, the Duo web app zooming in and out around the keyboard): on iOS only, the viewport is
|
|
7097
|
+
// capped at maximum-scale=1, once, as the page loads. Other platforms keep the viewport exactly as it is. Never while
|
|
7098
|
+
// the page is already zoomed (a reload mid-zoom), so the zoom is never changed under the person's fingers; nothing
|
|
7099
|
+
// calls this again after load.
|
|
7100
|
+
function viewportCap(meta, ua, vv) {
|
|
7101
|
+
if (!meta || ua.os !== 'ios') return false;
|
|
7102
|
+
if (vv && vv.scale > 1.01) return false;
|
|
7103
|
+
var content = String(meta.getAttribute('content') || '');
|
|
7104
|
+
if (/maximum-scale\s*=/.test(content)) return false;
|
|
7105
|
+
meta.setAttribute('content', content + (content ? ', ' : '') + 'maximum-scale=1');
|
|
7106
|
+
return true;
|
|
7107
|
+
}
|
|
7108
|
+
try { viewportCap(document.querySelector('meta[name="viewport"]'), uaClass(), window.visualViewport); } catch (_e) { /* the viewport stays as it is */ }
|
|
6268
7109
|
window.COSWeb.keyboardUp = function () { return LR.up; };
|
|
6269
7110
|
window.COSWeb.onKeyboard = function (fn) { if (typeof fn === 'function') LR.hooks.push(fn); };
|
|
6270
7111
|
// ── end layout reports
|
|
@@ -6277,8 +7118,8 @@
|
|
|
6277
7118
|
history.replaceState({}, document.title, location.pathname + location.hash);
|
|
6278
7119
|
api('/api/web-session/login', { method: 'POST', body: { code: linkCode } }).then(showShell)
|
|
6279
7120
|
.catch(function (err) {
|
|
6280
|
-
|
|
6281
|
-
var box = $('auth-code'); if (box) box.value = linkCode;
|
|
7121
|
+
if (!(err && err.status)) { showAuth(UNREACHABLE_COPY); var box0 = $('auth-code'); if (box0) box0.value = linkCode; return; }
|
|
7122
|
+
return linkFailed(err.message, function () { var box = $('auth-code'); if (box) box.value = linkCode; });
|
|
6282
7123
|
});
|
|
6283
7124
|
return;
|
|
6284
7125
|
}
|
|
@@ -6286,10 +7127,65 @@
|
|
|
6286
7127
|
if (handoff) {
|
|
6287
7128
|
history.replaceState({}, document.title, location.pathname + location.hash);
|
|
6288
7129
|
api('/api/web-session/login', { method: 'POST', body: { handoff: handoff } }).then(showShell)
|
|
6289
|
-
.catch(function (err) {
|
|
7130
|
+
.catch(function (err) {
|
|
7131
|
+
if (!(err && err.status)) return showAuth('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.');
|
|
7132
|
+
return linkFailed(err.message);
|
|
7133
|
+
});
|
|
6290
7134
|
return;
|
|
6291
7135
|
}
|
|
6292
|
-
|
|
7136
|
+
// The status read stays here in boot() itself (routes/web-phone-link.test.ts runs boot alone and pins it).
|
|
7137
|
+
api('/api/web-session/status').then(function (st) { return bootAnswer(st); }, function (err) { return bootFail(err); });
|
|
7138
|
+
}
|
|
7139
|
+
// S1 item 7 (lead go, 20:11): a link or code the server refused may be old while this browser is still signed in (an
|
|
7140
|
+
// old QR, a reopened tab). Ask status first: signed in opens the app and says the link was old; signed out shows the
|
|
7141
|
+
// refusal as before, with nothing else changed. A status read that fails also shows the refusal.
|
|
7142
|
+
var OLD_LINK_COPY = 'That sign-in link was old or already used. This browser is still signed in.';
|
|
7143
|
+
function linkFailed(message, after) {
|
|
7144
|
+
return api('/api/web-session/status').then(function (st) {
|
|
7145
|
+
if (st && st.authenticated) { showShell(st); toast(OLD_LINK_COPY); return; }
|
|
7146
|
+
showAuth(message); if (after) after();
|
|
7147
|
+
}, function () { showAuth(message); if (after) after(); });
|
|
7148
|
+
}
|
|
7149
|
+
// The same read on every retry of the not-answering card.
|
|
7150
|
+
function bootStatus() {
|
|
7151
|
+
return api('/api/web-session/status').then(function (st) { return bootAnswer(st); }, function (err) { return bootFail(err); });
|
|
7152
|
+
}
|
|
7153
|
+
function bootAnswer(st) {
|
|
7154
|
+
if (st && st.authenticated) return showShell(st);
|
|
7155
|
+
// Q1 S2: a browser that was signed in before learns why it is not now (the 30 days, no use in its last 2 days, a sign-out).
|
|
7156
|
+
showAuth(typeof signinKnown === 'function' && signinKnown() ? signinLapsedCopy() : undefined);
|
|
7157
|
+
}
|
|
7158
|
+
function bootFail(err) {
|
|
7159
|
+
// F23: a Mac that did not answer is not a sign-in problem. Say so, offer Try again, keep the form out of the way.
|
|
7160
|
+
if (err && err.status) showAuth(err.message); else { showAuth(); authOffline(true); }
|
|
7161
|
+
}
|
|
7162
|
+
// Q1 M2: the not-answering card checks again every 30 s while the page is in front, and stops while it is hidden.
|
|
7163
|
+
var AUTH_RETRY = { timer: null, ms: 30000 };
|
|
7164
|
+
function authRetrySchedule(on) {
|
|
7165
|
+
if (AUTH_RETRY.timer) { clearTimeout(AUTH_RETRY.timer); AUTH_RETRY.timer = null; }
|
|
7166
|
+
if (!on || document.hidden) return;
|
|
7167
|
+
AUTH_RETRY.timer = setTimeout(function () { AUTH_RETRY.timer = null; if (!document.hidden) bootStatus(); }, AUTH_RETRY.ms);
|
|
7168
|
+
}
|
|
7169
|
+
document.addEventListener('visibilitychange', function () {
|
|
7170
|
+
var box = $('auth-offline');
|
|
7171
|
+
var showing = !!(box && !box.classList.contains('hidden'));
|
|
7172
|
+
if (!showing) return;
|
|
7173
|
+
if (document.hidden) authRetrySchedule(false); else bootStatus();
|
|
7174
|
+
});
|
|
7175
|
+
// The sign-in card's "Your Mac is not answering" state (boot only): the form hides until the Mac answers.
|
|
7176
|
+
function authOffline(on) {
|
|
7177
|
+
var box = $('auth-offline'), form = $('auth-form');
|
|
7178
|
+
if (!box || !form) return;
|
|
7179
|
+
box.classList.toggle('hidden', !on); form.classList.toggle('hidden', !!on);
|
|
7180
|
+
var title = $('auth-title'), intro = $('auth-intro');
|
|
7181
|
+
var offline = typeof connOffline === 'function' && connOffline();
|
|
7182
|
+
if (title) title.textContent = on ? (offline ? 'This device is offline' : 'COS on your Mac is not answering') : 'Connect this browser';
|
|
7183
|
+
if (intro) intro.classList.toggle('hidden', !!on);
|
|
7184
|
+
var text = $('auth-offline-text');
|
|
7185
|
+
if (text && on) text.textContent = offline ? 'Connect to the internet and Tailscale. This page checks again every 30 seconds.'
|
|
7186
|
+
: 'Your Mac may be asleep, or this device may be offline or off Tailscale. This page checks again every 30 seconds.';
|
|
7187
|
+
authRetrySchedule(!!on);
|
|
7188
|
+
if (on) { var err = $('auth-error'); if (err) { setStatus(err, '', true); err.classList.add('hidden'); } }
|
|
6293
7189
|
}
|
|
6294
7190
|
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); else boot();
|
|
6295
7191
|
})();
|