@gotcos/glasses-server 7.0.7 → 7.0.27
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 +305 -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 +129 -23
- package/server/lib/agent-session-older.ts +218 -0
- package/server/lib/agent-session-recent.ts +321 -0
- package/server/lib/agent-session-store.ts +102 -20
- 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/cos-started-sessions.ts +101 -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/list-model-memo.ts +85 -0
- package/server/lib/listener-startup.ts +18 -0
- package/server/lib/meeting-batch-transcribe.ts +32 -5
- package/server/lib/model-efforts.ts +40 -0
- 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-organizer.ts +305 -0
- package/server/lib/session-state-derive.ts +36 -0
- package/server/lib/session-stream-events.ts +39 -3
- package/server/lib/thread-turn-queue.ts +24 -0
- 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/agent-sessions.ts +106 -2
- package/server/routes/drops.ts +7 -2
- package/server/routes/g2-turns.ts +35 -0
- package/server/routes/health.ts +14 -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/session-organizer.ts +149 -0
- package/server/routes/sessions.ts +15 -24
- package/server/routes/thread-turn-queue.ts +50 -14
- 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 +45 -0
- package/server/web-control/app.js +1680 -108
- package/server/web-control/apple-touch-icon.png +0 -0
- package/server/web-control/index.html +54 -17
- 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/sessions.css +280 -0
- 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 +121 -6
- package/server/web-control/panes/work.css +3 -0
- package/server/web-control/panes/work.js +185 -38
- package/server/web-control/theme.js +33 -0
- 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'];
|
|
@@ -455,6 +555,7 @@
|
|
|
455
555
|
if (r.pane !== 'meetings' && state.livePoll) { clearInterval(state.livePoll); state.livePoll = null; }
|
|
456
556
|
|
|
457
557
|
var reading = r.args.length > 0 && !isTabRoute(r.args);
|
|
558
|
+
if (typeof sxFrameSync === 'function') sxFrameSync();
|
|
458
559
|
var paneEl = $('pane-' + r.pane);
|
|
459
560
|
if (paneEl) paneEl.classList.toggle('reading', reading);
|
|
460
561
|
var split = paneEl && paneEl.querySelector('.split');
|
|
@@ -527,25 +628,57 @@
|
|
|
527
628
|
function closeMenu() { $('section-menu').classList.add('hidden'); }
|
|
528
629
|
|
|
529
630
|
// ── auth ────────────────────────────────────────────────
|
|
631
|
+
// 7.0.19: the first paint (index.html #first-paint) ends the moment the sign-in card or the app shows, whichever
|
|
632
|
+
// comes first, once. One layout report says how long it took, in the phone's own numbers: ms from navigation start to
|
|
633
|
+
// the browser's first contentful paint (-1 when none was recorded before this point) and to this moment.
|
|
634
|
+
var FIRST_PAINT = { done: false };
|
|
635
|
+
function firstPaintDone(via) {
|
|
636
|
+
if (FIRST_PAINT.done) return false;
|
|
637
|
+
FIRST_PAINT.done = true;
|
|
638
|
+
document.documentElement.classList.remove('fp-wait');
|
|
639
|
+
try {
|
|
640
|
+
var perf = window.performance, fcp = perf && perf.getEntriesByName ? perf.getEntriesByName('first-contentful-paint')[0] : null;
|
|
641
|
+
var shown = perf && perf.now ? Math.round(perf.now()) : -1;
|
|
642
|
+
// dcl: the browser's DOMContentLoaded; boot: when every script had run and boot started; rtt: the sign-in check's
|
|
643
|
+
// round trip (-1 for a sign-in link, which checks with a login instead).
|
|
644
|
+
var nav = perf && perf.getEntriesByType ? perf.getEntriesByType('navigation')[0] : null;
|
|
645
|
+
var dcl = nav && typeof nav.domContentLoadedEventStart === 'number' ? Math.round(nav.domContentLoadedEventStart) : -1;
|
|
646
|
+
if (typeof layoutCrumb === 'function') layoutCrumb('open-time', { fcp: fcp ? Math.round(fcp.startTime) : -1, shown: shown, via: via,
|
|
647
|
+
dcl: dcl, boot: FIRST_PAINT.boot == null ? -1 : FIRST_PAINT.boot, rtt: FIRST_PAINT.rtt == null ? -1 : FIRST_PAINT.rtt });
|
|
648
|
+
} catch (_e) { /* timing is a report only */ }
|
|
649
|
+
return true;
|
|
650
|
+
}
|
|
530
651
|
function showAuth(message) {
|
|
652
|
+
if (typeof firstPaintDone === 'function') firstPaintDone('auth'); // harnesses that run this alone have none
|
|
531
653
|
readerImagesStop($('session-turns')); readerImagesStop($('ta-last'));
|
|
532
654
|
if (typeof liveHide === 'function') { liveHide(); liveStopWatch(); LIVE.mode = 'idle'; }
|
|
533
655
|
var alreadyOpen = !$('auth-gate').classList.contains('hidden');
|
|
656
|
+
if (typeof CONN === 'object' && !$('app-shell').classList.contains('hidden')) CONN.lapsed = true;
|
|
657
|
+
if (typeof authOffline === 'function') authOffline(false);
|
|
534
658
|
$('app-shell').classList.add('hidden');
|
|
535
659
|
$('auth-gate').classList.remove('hidden');
|
|
660
|
+
// 7.0.23 regression (lapse_check, 08:2x): a frame class left on keeps html and body at one screen with overflow
|
|
661
|
+
// hidden, so the gate could not scroll and Connect sat below a keyboard-height screen. The gate is a normal page;
|
|
662
|
+
// showShell's route() puts the frame back (sxFrameSync) when the shell returns.
|
|
663
|
+
document.documentElement.classList.remove('sx-frame', 'mx-frame');
|
|
536
664
|
// A later 401 from a pane still polling (W.api passes no message) must not blank what the gate already says:
|
|
537
665
|
// the reason it opened, or "That code did not match" while the person retypes.
|
|
538
666
|
if (!message && alreadyOpen) return;
|
|
539
667
|
setStatus($('auth-error'), message || '', true);
|
|
540
668
|
$('auth-error').classList.toggle('hidden', !message);
|
|
541
669
|
}
|
|
670
|
+
function signedInOnce() { var shell = typeof $ === 'function' ? $('app-shell') : null; return !!(shell && !shell.classList.contains('hidden')) || CONN.lapsed; }
|
|
542
671
|
function showShell(info) {
|
|
672
|
+
if (typeof firstPaintDone === 'function') firstPaintDone('shell'); // harnesses that run this alone have none
|
|
673
|
+
if (typeof signinRemember === 'function') signinRemember(info);
|
|
674
|
+
if (typeof authOffline === 'function') authOffline(false);
|
|
543
675
|
$('auth-gate').classList.add('hidden');
|
|
544
676
|
$('app-shell').classList.remove('hidden');
|
|
545
677
|
state.serverName = (info && info.serverName) || 'Your Mac';
|
|
546
678
|
$('host-name').textContent = state.serverName.replace(/-/g, ' ');
|
|
547
679
|
loadTailnetUrl();
|
|
548
680
|
runHooks(shellHooks, info);
|
|
681
|
+
if (typeof connPaint === 'function') connPaint();
|
|
549
682
|
route();
|
|
550
683
|
if (typeof liveStart === 'function') liveStart(); // 7.0.1: the one live stream for this page (a no-op if it is already open)
|
|
551
684
|
// Reloaded with Today's overlay open: its history entry is still current. The overlay opens the same thing again
|
|
@@ -575,7 +708,9 @@
|
|
|
575
708
|
function sessionList(maxAgeMs) {
|
|
576
709
|
if (SL.data && Date.now() - SL.at < (maxAgeMs || 0)) return Promise.resolve(SL.data);
|
|
577
710
|
if (SL.inflight) return SL.inflight;
|
|
578
|
-
|
|
711
|
+
// kept=1 (server 7.0.25, lead 10:29): only a client that asks gets the kept rows (COS pins, folders, COS-started
|
|
712
|
+
// sessions past the cap); the Mac app and the lens, which do not ask, get 7.0.18's list unchanged.
|
|
713
|
+
SL.inflight = api('/api/agent-sessions?limit=80&sort=updated&kept=1').then(function (d) {
|
|
579
714
|
// 7.0.5: a sessions read (Sessions pane or Today) that follows a failed one is a reconnect: Today's next summary
|
|
580
715
|
// ask says so (requestBrief), once.
|
|
581
716
|
if (SL.failed) { SL.failed = false; if (typeof BRIEF === 'object' && BRIEF) BRIEF.reconnect = true; }
|
|
@@ -1282,7 +1417,8 @@
|
|
|
1282
1417
|
sessionList(force === true ? 0 : 15000).catch(function () { return null; }),
|
|
1283
1418
|
api('/api/work-board/your-move').catch(function () { return null; }),
|
|
1284
1419
|
api('/api/welcome-context').catch(function () { return null; }),
|
|
1285
|
-
api('/api/meetings/live-chunks').catch(function () { return null; })
|
|
1420
|
+
api('/api/meetings/live-chunks').catch(function () { return null; }),
|
|
1421
|
+
loadRunJobs()
|
|
1286
1422
|
]).then(function (res) {
|
|
1287
1423
|
if (gen !== TODAY.gen) return;
|
|
1288
1424
|
var sessions = (res[0] && res[0].sessions) || [];
|
|
@@ -1305,6 +1441,7 @@
|
|
|
1305
1441
|
TODAY.recording = false;
|
|
1306
1442
|
|
|
1307
1443
|
renderRunning(working, open);
|
|
1444
|
+
runJobsSchedule();
|
|
1308
1445
|
if (!offline) renderPet(sessions);
|
|
1309
1446
|
paintNeeds();
|
|
1310
1447
|
renderWeather(welcome);
|
|
@@ -1342,7 +1479,7 @@
|
|
|
1342
1479
|
var box = $('today-rec');
|
|
1343
1480
|
if (!rec) { box.innerHTML = ''; return; }
|
|
1344
1481
|
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>' +
|
|
1482
|
+
'<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
1483
|
'<span class="btn small">Live view</span></button>';
|
|
1347
1484
|
}
|
|
1348
1485
|
// ── The Today summary (6.99.26 candidate) ───────────────
|
|
@@ -1350,8 +1487,10 @@
|
|
|
1350
1487
|
// the server (lib/today-brief.ts). Today sends what it already read: session titles, states, how long, each session's
|
|
1351
1488
|
// latest line and what needs you. The server decides whether a new one is written (off unless switched on, only when
|
|
1352
1489
|
// 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
|
-
|
|
1490
|
+
// and the summary replaces it when one arrives. T1 spec (lead 23:04, same rule as Control 0.5.388): today's summary
|
|
1491
|
+
// stays through a pending or failed session read (its age line already says what could not be read), the block keeps
|
|
1492
|
+
// its tallest height so nothing under it moves while Miles reads, and unchanged text is never written again.
|
|
1493
|
+
var BRIEF = { view: null, inflight: false, sentKey: '', sentAt: 0, reconnect: false, heldPx: 0, heldDay: '', heldWidth: -1, shownKind: '', shownText: '', shownEl: null };
|
|
1355
1494
|
// 6.99.26 QA: the same rows are asked about again only after five minutes (a Today that sits open still learns what
|
|
1356
1495
|
// the server reads itself: sub-agents and Work cards); changed rows are asked about at once, one ask at a time.
|
|
1357
1496
|
var BRIEF_RESEND_MS = 5 * 60000, BRIEF_OFF_RECHECK_MS = 5 * 60000, BRIEF_TICK_MS = 60000, BRIEF_LIVE_GAP_MS = 60000;
|
|
@@ -1412,14 +1551,43 @@
|
|
|
1412
1551
|
if (TODAY.model && state.pane === 'today') renderStory(TODAY.model);
|
|
1413
1552
|
});
|
|
1414
1553
|
}
|
|
1415
|
-
/** The summary to show, or null: only with text,
|
|
1554
|
+
/** The summary to show, or null: only with text, fresh or stale, and written today. T1 spec 0: a session read that is
|
|
1555
|
+
* pending or failed no longer swaps it for the counts line (an offline Today still hides it, in renderStory). */
|
|
1416
1556
|
function briefShown() {
|
|
1417
1557
|
var v = BRIEF.view;
|
|
1418
|
-
if (!v || !v.text || (v.state !== 'fresh' && v.state !== 'stale')
|
|
1558
|
+
if (!v || !v.text || (v.state !== 'fresh' && v.state !== 'stale')) return null;
|
|
1559
|
+
var at = toDate(v.at);
|
|
1560
|
+
if (!at || at.toDateString() !== new Date().toDateString()) return null;
|
|
1419
1561
|
return v;
|
|
1420
1562
|
}
|
|
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; }
|
|
1563
|
+
// Forget the summary (Settings turned it on or off): the next Today paint asks at once, and the block may shrink.
|
|
1564
|
+
function resetBrief() { BRIEF.view = null; BRIEF.sentKey = ''; BRIEF.sentAt = 0; BRIEF.heldPx = 0; }
|
|
1565
|
+
// T1 spec 1 and 2: #today-story keeps the tallest height it has had, so a shorter rewrite, or the fall back to the
|
|
1566
|
+
// counts line, never pulls the page up under Miles. It may shrink on a new local day, when its width changes (resize,
|
|
1567
|
+
// Duo fold or unfold, rotation reflow the text anyway), after Refresh lands and after resetBrief; never on a rewrite.
|
|
1568
|
+
function storyHold(el) {
|
|
1569
|
+
if (!el || !el.style || typeof el.getBoundingClientRect !== 'function') return;
|
|
1570
|
+
var day = new Date().toDateString(), w = el.clientWidth || 0;
|
|
1571
|
+
if (BRIEF.heldDay !== day || Math.abs(w - BRIEF.heldWidth) >= 1) { BRIEF.heldPx = 0; BRIEF.heldDay = day; BRIEF.heldWidth = w; }
|
|
1572
|
+
el.style.minHeight = '';
|
|
1573
|
+
var h = Math.ceil(el.getBoundingClientRect().height || 0);
|
|
1574
|
+
if (h > BRIEF.heldPx) BRIEF.heldPx = h;
|
|
1575
|
+
el.style.minHeight = BRIEF.heldPx ? BRIEF.heldPx + 'px' : '';
|
|
1576
|
+
}
|
|
1577
|
+
// T1 spec 3: Today repaints on every session move; the same words are not written again (no reflow, no aria-live
|
|
1578
|
+
// re-announcement). Only the age line under a summary moves on.
|
|
1579
|
+
function storyWrite(el, kind, text, when) {
|
|
1580
|
+
if (BRIEF.shownEl !== el || BRIEF.shownKind !== kind || BRIEF.shownText !== text) {
|
|
1581
|
+
if (kind === 'brief') el.innerHTML = esc(text) + '<span class="story-when">' + esc(when) + '</span>';
|
|
1582
|
+
else el.textContent = text;
|
|
1583
|
+
BRIEF.shownEl = el; BRIEF.shownKind = kind; BRIEF.shownText = text;
|
|
1584
|
+
} else if (kind === 'brief') {
|
|
1585
|
+
var w = typeof document !== 'undefined' && document.querySelector ? document.querySelector('#today-story .story-when') : null;
|
|
1586
|
+
if (w) { if (w.textContent !== when) w.textContent = when; }
|
|
1587
|
+
else el.innerHTML = esc(text) + '<span class="story-when">' + esc(when) + '</span>';
|
|
1588
|
+
}
|
|
1589
|
+
storyHold(el);
|
|
1590
|
+
}
|
|
1423
1591
|
// Every minute while Today is on screen: "Updated N min ago" moves on, and an unchanged Today asks again once its
|
|
1424
1592
|
// five minutes are up (requestBrief's own throttle). Hidden or another pane: nothing.
|
|
1425
1593
|
setInterval(function () {
|
|
@@ -1441,13 +1609,22 @@
|
|
|
1441
1609
|
function renderStory(m) {
|
|
1442
1610
|
var el = $('today-story');
|
|
1443
1611
|
el.className = 'story';
|
|
1444
|
-
|
|
1612
|
+
// Today round 2: "Ask about this" shows under a model summary only (paintTodayAsk, below the Today block). The test
|
|
1613
|
+
// harnesses that run this block alone do not have it, so it is looked up, never assumed.
|
|
1614
|
+
var askBox = typeof paintTodayAsk === 'function' ? paintTodayAsk : function () {};
|
|
1615
|
+
// The harnesses that run this block alone have no storyWrite either: a plain write there.
|
|
1616
|
+
var write = typeof storyWrite === 'function' ? storyWrite : function (e, kind, text, when) {
|
|
1617
|
+
if (kind === 'brief') e.innerHTML = esc(text) + '<span class="story-when">' + esc(when) + '</span>'; else e.textContent = text;
|
|
1618
|
+
};
|
|
1619
|
+
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
1620
|
// The model's summary when there is one (it never blocks: the counts line below shows until it lands). The test
|
|
1446
1621
|
// harnesses that run this block alone have no requestBrief, so it is looked up, never assumed.
|
|
1622
|
+
askBox(null);
|
|
1447
1623
|
if (typeof requestBrief === 'function') {
|
|
1448
1624
|
requestBrief(m);
|
|
1449
1625
|
var brief = briefShown();
|
|
1450
|
-
|
|
1626
|
+
askBox(brief);
|
|
1627
|
+
if (brief) { write(el, 'brief', brief.text, briefWhen(brief, m)); return; }
|
|
1451
1628
|
}
|
|
1452
1629
|
var first = '';
|
|
1453
1630
|
if (m.sessionsWaiting > 0) first = countWord(m.sessionsWaiting) + ' ' + plural(m.sessionsWaiting, 'session is', 'sessions are') + ' waiting on you';
|
|
@@ -1471,7 +1648,7 @@
|
|
|
1471
1648
|
var others = m.failed.filter(function (n) { return n !== 'sessions'; });
|
|
1472
1649
|
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
1650
|
else if (unread) out += ' This may not be everything that needs you.';
|
|
1474
|
-
el
|
|
1651
|
+
write(el, 'counts', out);
|
|
1475
1652
|
}
|
|
1476
1653
|
function listWords(names) { return names.length < 2 ? names.join('') : names.slice(0, -1).join(', ') + ' and ' + names[names.length - 1]; }
|
|
1477
1654
|
function runRow(s, activeNow) {
|
|
@@ -1493,14 +1670,16 @@
|
|
|
1493
1670
|
'<span class="run-meta">' + metaHtml + '</span></button>';
|
|
1494
1671
|
}
|
|
1495
1672
|
function renderRunning(working, open) {
|
|
1496
|
-
|
|
1673
|
+
// W1: COS's own jobs (questions it is answering, routines, Work runs) count as running too, as on the Mac.
|
|
1674
|
+
var jobs = typeof runJobsHtml === 'function' ? runJobsHtml() : { html: '', n: 0 };
|
|
1675
|
+
$('run-count').textContent = working.length + jobs.n ? String(working.length + jobs.n) : '';
|
|
1497
1676
|
var list = $('run-list');
|
|
1498
1677
|
// 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; }
|
|
1678
|
+
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
1679
|
var shown = working.slice(0, 3);
|
|
1501
|
-
var html = shown.map(function (s) { return runRow(s, true); }).join('');
|
|
1680
|
+
var html = jobs.html + shown.map(function (s) { return runRow(s, true); }).join('');
|
|
1502
1681
|
if (!shown.length) {
|
|
1503
|
-
html = open.slice(0, 2).map(function (s) { return runRow(s, false); }).join('');
|
|
1682
|
+
html = jobs.html + open.slice(0, 2).map(function (s) { return runRow(s, false); }).join('');
|
|
1504
1683
|
if (!html) html = '<div class="run-empty">Nothing running. Start a session on your Mac and it shows here.</div>';
|
|
1505
1684
|
}
|
|
1506
1685
|
var more = (working.length - shown.length) + (shown.length ? open.length : Math.max(0, open.length - 2));
|
|
@@ -1508,6 +1687,57 @@
|
|
|
1508
1687
|
list.innerHTML = html;
|
|
1509
1688
|
if (typeof liveTickStart === 'function' && list.querySelector && list.querySelector('[data-live-since]')) liveTickStart();
|
|
1510
1689
|
}
|
|
1690
|
+
// ── Running beyond sessions (W1, 7.0.x): audit 02 F10, Q1 M6. The same job rows COS Control 0.5.386 draws
|
|
1691
|
+
// (Models.swift TodayHome running rows, b02d1ad 14185-14207), from the same server list, GET /api/query-jobs/active
|
|
1692
|
+
// (titles only, never a prompt or an answer):
|
|
1693
|
+
// person "A question to COS" row, "COS is answering, N min", Open in Messages
|
|
1694
|
+
// routine "Routine" row, "running since 7:30 PM", Open in Messages
|
|
1695
|
+
// task "A captured task" row, "a captured task, on the server, N min", In Sessions
|
|
1696
|
+
// other never a row: it is a Control Work session or a meeting review, already shown as the row it is
|
|
1697
|
+
// 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
|
|
1698
|
+
// own handoff store and is not visible to this page.
|
|
1699
|
+
var RUN_JOB_ROW = {
|
|
1700
|
+
person: { word: 'Question', title: 'A question to COS', open: 'messages', label: 'Open in Messages' },
|
|
1701
|
+
routine: { word: 'Routine', title: 'Routine', open: 'messages', label: 'Open in Messages' },
|
|
1702
|
+
task: { word: 'Work', title: 'A captured task', open: 'sessions', label: 'In Sessions' },
|
|
1703
|
+
};
|
|
1704
|
+
var RUN_JOBS = { items: null, timer: null };
|
|
1705
|
+
var RUN_JOBS_POLL_MS = 15000;
|
|
1706
|
+
function runJobValid(j) { return j && typeof j.jobId === 'string' && /^[A-Za-z0-9_-]{1,80}$/.test(j.jobId) && RUN_JOB_ROW[j.origin]; }
|
|
1707
|
+
function loadRunJobs() {
|
|
1708
|
+
return api('/api/query-jobs/active').then(function (d) {
|
|
1709
|
+
// An older server answers 400 or no protocol: no jobs row, never an error line (Control keys off the field, not the status).
|
|
1710
|
+
RUN_JOBS.items = d && d.protocolVersion === 1 && d.enabled !== false && Array.isArray(d.items) ? d.items.filter(runJobValid).slice(0, 8) : [];
|
|
1711
|
+
}, function () { RUN_JOBS.items = null; });
|
|
1712
|
+
}
|
|
1713
|
+
function runJobsSchedule() {
|
|
1714
|
+
if (RUN_JOBS.timer) { clearTimeout(RUN_JOBS.timer); RUN_JOBS.timer = null; }
|
|
1715
|
+
if (!RUN_JOBS.items || !RUN_JOBS.items.length) return;
|
|
1716
|
+
RUN_JOBS.timer = setTimeout(function () {
|
|
1717
|
+
RUN_JOBS.timer = null;
|
|
1718
|
+
// Only while Today is in front: a hidden page reads nothing (it reads again when Today comes back).
|
|
1719
|
+
if (state.pane !== 'today' || document.hidden) return;
|
|
1720
|
+
loadRunJobs().then(function () { renderRunning(TODAY.working || [], TODAY.open || []); runJobsSchedule(); });
|
|
1721
|
+
}, RUN_JOBS_POLL_MS);
|
|
1722
|
+
}
|
|
1723
|
+
function runJobMinutes(j) { return Math.max(0, Math.round((Number(j.elapsedMs) || 0) / 60000)); }
|
|
1724
|
+
function runJobRow(j) {
|
|
1725
|
+
var kind = RUN_JOB_ROW[j.origin], mins = runJobMinutes(j);
|
|
1726
|
+
var started = new Date(Date.now() - (Number(j.elapsedMs) || 0));
|
|
1727
|
+
var meta = j.origin === 'routine' ? 'running since ' + clock(started)
|
|
1728
|
+
: j.origin === 'person' ? 'COS is answering, ' + mins + ' min'
|
|
1729
|
+
: 'a captured task, on the server, ' + mins + ' min';
|
|
1730
|
+
return '<div class="run-row run-job"><span class="run-who"><span class="badge work">' + esc(kind.word) + '</span></span>' +
|
|
1731
|
+
'<span class="run-title">' + esc(plain(j.title || '').trim() || kind.title) + '</span>' +
|
|
1732
|
+
'<span class="run-meta">' + esc(meta) + '</span>' +
|
|
1733
|
+
'<span class="row-actions"><button type="button" class="link-btn" data-goto="' + kind.open + '">' + esc(kind.label) + '</button></span></div>';
|
|
1734
|
+
}
|
|
1735
|
+
function runJobsHtml() {
|
|
1736
|
+
var items = RUN_JOBS.items || [];
|
|
1737
|
+
return { html: items.map(runJobRow).join(''), n: items.length };
|
|
1738
|
+
}
|
|
1739
|
+
// ── end running beyond sessions
|
|
1740
|
+
|
|
1511
1741
|
// ── live state on screen (7.0.1): Today's counts and rows, the pet, the Sessions rows, the header line ──
|
|
1512
1742
|
// The live model (above) calls this, batched, after a frame or a list read moved something.
|
|
1513
1743
|
function liveRepaint(rows, structural) {
|
|
@@ -1531,14 +1761,17 @@
|
|
|
1531
1761
|
if (!list) return;
|
|
1532
1762
|
if (sxQueryActive()) { paintSessionsHero(); return; }
|
|
1533
1763
|
if (structural) { renderSessionGroups(); return; }
|
|
1534
|
-
var amb = sxAmbiguous(sxVisible());
|
|
1764
|
+
var amb = sxAmbiguous(sxVisible()), moved = false;
|
|
1535
1765
|
(rows || []).forEach(function (s) {
|
|
1536
1766
|
if (!s || sxKeepWarm(s)) return;
|
|
1767
|
+
// Checked before the row is looked for: a row in a folded group is not drawn, and its move still counts.
|
|
1768
|
+
var live = sxLiveRow(s, amb);
|
|
1769
|
+
if (live.moved) moved = true;
|
|
1537
1770
|
var k = splitSessionId(s.provider, s.session_id || s.id), want = k.provider + '|' + k.id;
|
|
1538
1771
|
var els = list.querySelectorAll('[data-session]');
|
|
1539
1772
|
for (var i = 0; i < els.length; i++) {
|
|
1540
1773
|
if (els[i].getAttribute('data-session') !== want) continue;
|
|
1541
|
-
var html =
|
|
1774
|
+
var html = live.html;
|
|
1542
1775
|
if (els[i].__liveHtml !== html) {
|
|
1543
1776
|
var tmp = document.createElement('div'); tmp.innerHTML = html;
|
|
1544
1777
|
var next = tmp.firstElementChild || tmp.firstChild;
|
|
@@ -1547,6 +1780,7 @@
|
|
|
1547
1780
|
break;
|
|
1548
1781
|
}
|
|
1549
1782
|
});
|
|
1783
|
+
if (moved) sxRegroupLater();
|
|
1550
1784
|
paintSessionsHero();
|
|
1551
1785
|
}
|
|
1552
1786
|
// ── Today's header line and Refresh ──
|
|
@@ -1599,6 +1833,8 @@
|
|
|
1599
1833
|
BRIEF.inflight = false;
|
|
1600
1834
|
if (v && typeof v === 'object') { BRIEF.view = v; BRIEF.sentAt = Date.now(); }
|
|
1601
1835
|
if (v && v.reason === 'refresh_throttled') TR.retryAt = Date.now() + Math.max(1000, Number(v.retryAfterMs) || 30000);
|
|
1836
|
+
// T1 spec 2: Refresh re-fits the block to what it now holds.
|
|
1837
|
+
BRIEF.heldPx = 0;
|
|
1602
1838
|
if (TODAY.model && state.pane === 'today') renderStory(TODAY.model);
|
|
1603
1839
|
return done(refreshWords(v));
|
|
1604
1840
|
}, function (err) {
|
|
@@ -1670,8 +1906,29 @@
|
|
|
1670
1906
|
// Control's askedTitle and askedWhy for a session waiting on you.
|
|
1671
1907
|
// Control's TodayHome: guarded(title) ?? "A session". Never the workspace path or the session id: this line is shown
|
|
1672
1908
|
// on Today and goes into the Today summary sent to the model, which carries no ids.
|
|
1909
|
+
// ── A turn that ended asking you (7.0.18, X1 contract v1; Miles 2026-11-10 07:14) ──
|
|
1910
|
+
// A Claude session ended its turn with the app's next-step box: a question and reply buttons. The server marks the row
|
|
1911
|
+
// (waiting_source 'turn_end', waiting_choices: 0 to 2 labels) only while COS_TURN_END_ASKS is on. Unlike a question at
|
|
1912
|
+
// the Mac's prompt, a reply IS the answer here, so Today and the reader offer the choices and the reply box. What is
|
|
1913
|
+
// sent is the contract's exact text: it never reads as typed at the desk, and the session may ask him to confirm.
|
|
1914
|
+
var TURN_END_PREFIX = 'Answer sent from COS Control Web through COS, not typed in this session: ';
|
|
1915
|
+
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.';
|
|
1916
|
+
/** The turn-end ask on a session row, or null: any other row, an older server, or the switch off. */
|
|
1917
|
+
function turnEndOf(s) {
|
|
1918
|
+
if (!s || s.waiting_source !== 'turn_end' || s.agent_state !== 'waiting') return null;
|
|
1919
|
+
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); };
|
|
1920
|
+
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) : [];
|
|
1921
|
+
return { ask: clean(s.waiting_detail, 160), choices: choices };
|
|
1922
|
+
}
|
|
1923
|
+
/** The exact words a choice or a reply sends (contract v1): attributed to COS Control Web, never to the desk. */
|
|
1924
|
+
function turnEndPrompt(text) { return TURN_END_PREFIX + turnEndBare(text); }
|
|
1925
|
+
/** The words as he wrote them: a message that came back to the box already carries the prefix, never twice. */
|
|
1926
|
+
function turnEndBare(text) { var t = String(text || '').trim(); return t.indexOf(TURN_END_PREFIX) === 0 ? t.slice(TURN_END_PREFIX.length).trim() : t; }
|
|
1673
1927
|
function askedTitle(s) {
|
|
1674
1928
|
var t = plain(s.display_label || s.custom_title || '').replace(/\s+/g, ' ').trim() || 'A session';
|
|
1929
|
+
// The harnesses that run this block alone have no turnEndOf: looked up, never assumed.
|
|
1930
|
+
var te = typeof turnEndOf === 'function' ? turnEndOf(s) : null;
|
|
1931
|
+
if (te) return te.ask ? t + ' asked: ' + te.ask : t + ' asked you something';
|
|
1675
1932
|
if (sxProvider(s.provider) !== 'claude') return t + ' is waiting for your reply';
|
|
1676
1933
|
if (s.waiting_kind === 'plan') return t + ' has a plan for you to approve';
|
|
1677
1934
|
if (s.waiting_kind === 'permission') return t + ' wants your permission';
|
|
@@ -1682,6 +1939,8 @@
|
|
|
1682
1939
|
var TOOL_NAMES = ['AskUserQuestion', 'ExitPlanMode', 'Bash', 'BashOutput', 'KillShell', 'Edit', 'Write', 'Read', 'Grep', 'Glob', 'LS', 'Task', 'Agent',
|
|
1683
1940
|
'WebFetch', 'WebSearch', 'TodoWrite', 'NotebookEdit', 'MultiEdit', 'SlashCommand', 'Skill', 'ToolSearch'];
|
|
1684
1941
|
function askedWhy(s) {
|
|
1942
|
+
var te = typeof turnEndOf === 'function' ? turnEndOf(s) : null;
|
|
1943
|
+
if (te) return te.choices.length ? 'Tap a choice or reply here' : 'Reply here';
|
|
1685
1944
|
if (s.waiting_kind === 'plan') return 'Has a plan for you to approve';
|
|
1686
1945
|
var d = String(s.waiting_detail || '').trim();
|
|
1687
1946
|
if (TOOL_NAMES.indexOf(d) >= 0) d = '';
|
|
@@ -1692,7 +1951,8 @@
|
|
|
1692
1951
|
return d.indexOf('[home]') >= 0 ? 'Wants to run (home folder hidden) ' + shown : 'Wants to run ' + shown;
|
|
1693
1952
|
}
|
|
1694
1953
|
if (s.waiting_kind === 'question' && d) return d.length > 160 ? d.slice(0, 159) + '…' : d;
|
|
1695
|
-
|
|
1954
|
+
// W1 A5: a Claude session waits on its own prompt; the card under it says where, once.
|
|
1955
|
+
return sxProvider(s.provider) === 'claude' ? 'Waiting for your answer' : 'Waiting for you';
|
|
1696
1956
|
}
|
|
1697
1957
|
// 6.99.26: where a waiting session's question can be answered. 'here': this page holds it (panes/questions.js), as
|
|
1698
1958
|
// 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 +1967,111 @@
|
|
|
1707
1967
|
return q && q.question ? clipWords(q.question, 160) : '';
|
|
1708
1968
|
}
|
|
1709
1969
|
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) {
|
|
1970
|
+
function whereFor(provider, id, row) {
|
|
1711
1971
|
var q = sessionQs();
|
|
1712
1972
|
var held = q ? q.itemsFor(id) : [], mac = q && q.atMacFor ? q.atMacFor(id) : [];
|
|
1713
1973
|
if (held.length) return { where: 'here', ask: askedHere(held) };
|
|
1974
|
+
// 7.0.18: a turn that ended asking him is answered by a reply, here or anywhere: never "at the Mac".
|
|
1975
|
+
var te = row && typeof turnEndOf === 'function' ? turnEndOf(row) : null;
|
|
1976
|
+
if (te) return { where: 'turnend', ask: te.ask, choices: te.choices };
|
|
1714
1977
|
if (q && q.answeredFor && q.answeredFor(id)) return { where: 'answered', ask: '' };
|
|
1715
|
-
|
|
1978
|
+
// X1 contract: a card built from the server's `waiting` list carries its own words; a held one gone back to the Mac
|
|
1979
|
+
// has the words this page saw while it was held.
|
|
1980
|
+
var first = mac[0];
|
|
1981
|
+
var words = first && first.fromWaiting ? { questions: (first.questions || []).map(function (x) { return x.question; }), summary: '' }
|
|
1982
|
+
: first && q.wordsOf ? q.wordsOf(first.id) : null;
|
|
1716
1983
|
var ask = words ? clipWords(words.questions[0] || (words.summary ? 'Wants to run ' + words.summary : ''), 160) : '';
|
|
1717
|
-
if (mac.length
|
|
1984
|
+
if (mac.length) return { where: 'mac', ask: ask };
|
|
1985
|
+
// DESIGN.md step 4 #5: the Mac line only for a Claude row that waits on a question or an approval, never for any
|
|
1986
|
+
// waiting Claude session. A row the page has not read says nothing about it, so it keeps the Mac line as before.
|
|
1987
|
+
var kind = row ? String(row.waiting_kind || '') : '';
|
|
1988
|
+
if (sxProvider(provider) === 'claude' && (!row || kind === 'question' || kind === 'permission')) return { where: 'mac', ask: ask };
|
|
1718
1989
|
return { where: '', ask: '' };
|
|
1719
1990
|
}
|
|
1991
|
+
// ── At the Mac's prompt, in the session reader (W1 A5) ──
|
|
1992
|
+
// Miles 11/09: "when I actually navigate to the session, there is no ability to answer the question." The reader cannot
|
|
1993
|
+
// 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
|
|
1994
|
+
// has them (a held question it saw, or the server's waiting line) and plainly when it does not. Nothing here sends.
|
|
1995
|
+
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." };
|
|
1996
|
+
/** 7.0.18: the reader's card for a turn that ended asking him: the question, a button per choice, the honest line. */
|
|
1997
|
+
function readerTurnEndHtml(r, row) {
|
|
1998
|
+
var te = turnEndOf(row);
|
|
1999
|
+
if (!te) return '';
|
|
2000
|
+
var title = plain((row && (row.display_label || row.custom_title)) || '').replace(/\s+/g, ' ').trim() || 'This session';
|
|
2001
|
+
var busy = !!(r && r.teSending);
|
|
2002
|
+
return '<p class="sx-mac-q-title"><strong>' + esc(te.ask ? title + ' asked:' : title + ' asked you something') + '</strong></p>' +
|
|
2003
|
+
(te.ask ? '<blockquote class="ta-quote sx-te-ask">' + esc(te.ask) + '</blockquote>' : '') +
|
|
2004
|
+
(te.choices.length ? '<div class="sx-te-choices">' + te.choices.map(function (c, i) {
|
|
2005
|
+
return '<button type="button" class="cx-btn' + (i === 0 ? ' primary' : '') + '" data-sx="turnend" data-i="' + i + '"' + (busy ? ' disabled' : '') + '>' + esc(c) + '</button>';
|
|
2006
|
+
}).join('') + '</div>' : '') +
|
|
2007
|
+
'<p class="sx-note sx-te-line">' + esc(TURN_END_LINE) + '</p>';
|
|
2008
|
+
}
|
|
2009
|
+
function readerMacHtml(provider, id, row) {
|
|
2010
|
+
var w = whereFor(provider, id, row);
|
|
2011
|
+
if (w.where !== 'mac') return '';
|
|
2012
|
+
// X1 contract: when the server listed this question (`waiting`), the shared card list mounted above draws it, with
|
|
2013
|
+
// its options. This block is only for a question it did not list (an older server, or asked before a restart).
|
|
2014
|
+
var qs0 = sessionQs();
|
|
2015
|
+
if (qs0 && qs0.atMacFor && qs0.atMacFor(id).length) return '';
|
|
2016
|
+
var q = sessionQs();
|
|
2017
|
+
var why = row ? askedWhy(row) : '';
|
|
2018
|
+
var words = w.ask || (why && !/^Waiting for (you|your answer)/.test(why) ? why : '');
|
|
2019
|
+
return '<p class="sx-mac-q-title"><strong>Waiting for your answer at the Mac</strong></p>' +
|
|
2020
|
+
(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>') +
|
|
2021
|
+
'<p class="sx-note sx-mac-q-line">' + esc((q && q.macLine) || MAC_Q.line) + '</p>';
|
|
2022
|
+
}
|
|
2023
|
+
function readerRowOf(r) {
|
|
2024
|
+
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;
|
|
2025
|
+
}
|
|
2026
|
+
// 7.0.18: a tap on a choice sends the contract's exact text through the reader's own Continue (continueSession), so the
|
|
2027
|
+
// queue, the held reply and the turn watch behave as for any message. The outcome is said in the status line.
|
|
2028
|
+
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.'; };
|
|
2029
|
+
var TURN_END_GONE = 'Already answered on another screen, or the session moved on. Nothing was sent.';
|
|
2030
|
+
function sendTurnEndChoice(r, i) {
|
|
2031
|
+
if (!r || r.sending) return;
|
|
2032
|
+
var te = turnEndOf(readerRowOf(r));
|
|
2033
|
+
if (!te) { sessionStatus(TURN_END_GONE); readerMacPaint(r, r.waitingNow); return; }
|
|
2034
|
+
var label = te.choices[i];
|
|
2035
|
+
if (!label) return;
|
|
2036
|
+
r.sending = true; r.teSending = true; readerMacPaint(r, r.waitingNow);
|
|
2037
|
+
var done = function () { r.sending = false; r.teSending = false; if (state.reading === r) readerMacPaint(r, r.waitingNow); };
|
|
2038
|
+
return continueSession(r, turnEndPrompt(label), {}).then(function (how) {
|
|
2039
|
+
done();
|
|
2040
|
+
if (state.reading !== r) return how;
|
|
2041
|
+
if (how === 'sent') sessionStatus(TURN_END_SENT(label));
|
|
2042
|
+
else if (how === 'changed') {
|
|
2043
|
+
// The box under it would offer Continue anyway for the composer's words, not this choice: a choice says it plainly.
|
|
2044
|
+
r.refusal = null; r.sendAnyway = null; renderVerdict(r);
|
|
2045
|
+
sessionStatus('The session moved on before this reached it. \u201c' + label + '\u201d was not sent.', 'error');
|
|
2046
|
+
} else if (how === 'refused') sessionStatus('The session did not take \u201c' + label + '\u201d. Try again, or answer it on your Mac.', 'error');
|
|
2047
|
+
return how;
|
|
2048
|
+
}, function (err) {
|
|
2049
|
+
done();
|
|
2050
|
+
if (state.reading !== r) return;
|
|
2051
|
+
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.';
|
|
2052
|
+
sessionStatus('Not sent: ' + why, 'error');
|
|
2053
|
+
});
|
|
2054
|
+
}
|
|
2055
|
+
function readerMacPaint(r, waiting) {
|
|
2056
|
+
var el = $('session-mac-q');
|
|
2057
|
+
if (!el || !r) return;
|
|
2058
|
+
r.waitingNow = !!waiting;
|
|
2059
|
+
var row = readerRowOf(r);
|
|
2060
|
+
// 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).
|
|
2061
|
+
var te = typeof turnEndOf === 'function' ? turnEndOf(row) : null;
|
|
2062
|
+
r.turnEnd = te;
|
|
2063
|
+
var html = te ? readerTurnEndHtml(r, row) : waiting ? readerMacHtml(r.provider, r.id, row) : '';
|
|
2064
|
+
if (el.innerHTML !== html) el.innerHTML = html;
|
|
2065
|
+
el.hidden = !html;
|
|
2066
|
+
// Q1 M3: the Send box under it says plainly that a message is not the answer, whichever block shows the question
|
|
2067
|
+
// (this one, a Mac card or a held card above).
|
|
2068
|
+
var qs1 = sessionQs(), open = (!!html && !te) || !!(qs1 && ((qs1.itemsFor && qs1.itemsFor(r.id).length) || (qs1.atMacFor && qs1.atMacFor(r.id).length)));
|
|
2069
|
+
var box = $('session-input');
|
|
2070
|
+
// 7.0.18: here a reply IS the answer (sent with the contract's attribution), so the box says so.
|
|
2071
|
+
if (box && box.setAttribute) box.setAttribute('placeholder', te ? 'Or write your own answer' : open ? 'Message this session (not an answer)' : 'Continue this session…');
|
|
2072
|
+
}
|
|
2073
|
+
// ── end at the Mac's prompt
|
|
2074
|
+
|
|
1720
2075
|
function needsModel() {
|
|
1721
2076
|
var states = { sessions: TODAY.sessionsState, work: TODAY.workState, voices: TODAY.voices.state, memories: TODAY.memories.state };
|
|
1722
2077
|
var rows = [], seen = {};
|
|
@@ -1724,8 +2079,8 @@
|
|
|
1724
2079
|
if (sxKeepWarm(s) || sxState(s) !== 'waiting') return;
|
|
1725
2080
|
var k = splitSessionId(s.provider, s.session_id || s.id);
|
|
1726
2081
|
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 });
|
|
2082
|
+
var w = whereFor(k.provider, k.id, s);
|
|
2083
|
+
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
2084
|
});
|
|
1730
2085
|
// A question this page holds for a session the list has not shown waiting yet: its own row, so every held
|
|
1731
2086
|
// question is in front of you (two at once each get one).
|
|
@@ -1801,7 +2156,8 @@
|
|
|
1801
2156
|
// The session's question shows in the overlay's peek, with its cards to answer. Under it, where it can be answered.
|
|
1802
2157
|
var q = sessionQs(), whereLine = r.where === 'here' ? 'Answer it here.' : r.where === 'mac' ? (q && q.macLine) || '' : r.where === 'answered' ? (q && q.answeredLine) || '' : '';
|
|
1803
2158
|
return '<div class="need-row">' +
|
|
1804
|
-
|
|
2159
|
+
// 7.0.18: a turn-end row's title already says the question, so its foot says how to answer.
|
|
2160
|
+
'<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
2161
|
(whereLine ? '<span class="need-foot need-where" data-where="' + esc(r.where) + '">' + esc(whereLine) + '</span>' : '') + '</button>' +
|
|
1806
2162
|
'<div class="need-actions"><button type="button" class="btn small' + (i === 0 ? ' primary' : '') + '" data-session="' + esc(r.key) + '">Open session</button></div></div>';
|
|
1807
2163
|
}
|
|
@@ -1847,30 +2203,183 @@
|
|
|
1847
2203
|
// Control's Next up (todayPrepButtons): Prep me opens the Prep composer in the COS chat (panes/cos-chat.js prepFor),
|
|
1848
2204
|
// Open meeting opens Meetings. Without a server to answer, Prep me is not offered.
|
|
1849
2205
|
var prep = !TODAY.offline && window.COSWeb && window.COSWeb.cosChat && window.COSWeb.cosChat.prepFor;
|
|
2206
|
+
// Prep on every row (Miles 11/09): "Add context" opens the same inline panel the later rows' Prep opens, for this
|
|
2207
|
+
// meeting. Not on a hold (the server's nextEvent.hold), and not while neither Prep nor Ask is on the Mac.
|
|
2208
|
+
var key = nextPrepKey(n.title, d), ctx = !n.hold && nextPrepMode();
|
|
2209
|
+
if (ctx) NEXT_PREP.rows[key] = { title: n.title, start: d.toISOString() };
|
|
1850
2210
|
$('next-actions').innerHTML = (prep ? '<button type="button" class="btn small primary" data-today-next="prep">Prep me</button>' : '') +
|
|
2211
|
+
(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
2212
|
'<button type="button" class="link-btn" data-goto="meetings">Open meeting</button>';
|
|
2213
|
+
NEXT_PREP.firstKey = ctx ? key : '';
|
|
2214
|
+
paintNextPrepSlot($('next-first-prep'), ctx && NEXT_PREP.open === key ? key : '');
|
|
1852
2215
|
card.classList.remove('hidden');
|
|
1853
2216
|
}
|
|
1854
|
-
// PH plan P1: the rest of today from GET /api/calendar/today (selected calendars
|
|
2217
|
+
// PH plan P1: the rest of today from GET /api/calendar/today (selected calendars), under Next up.
|
|
1855
2218
|
// An aging snapshot says when it was read. Not connected or nothing more: the block stays hidden.
|
|
2219
|
+
// 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"
|
|
2220
|
+
// (opens Meetings) when the calendar has more meetings left today than are shown. Desktop Control 0.5.384 matches.
|
|
1856
2221
|
function loadCalendarLater(n, d) {
|
|
2222
|
+
var LATER_ROWS = 4;
|
|
1857
2223
|
var box = $('next-later'); if (!box) return;
|
|
1858
|
-
if (!n || !n.title || !d) {
|
|
2224
|
+
if (!n || !n.title || !d) { NEXT_PREP.later = null; clearLater(box); return; }
|
|
1859
2225
|
var gen = TODAY.gen;
|
|
1860
2226
|
api('/api/calendar/today').then(function (c) {
|
|
1861
2227
|
if (gen !== TODAY.gen) return;
|
|
1862
|
-
if (!c || !c.connected) {
|
|
2228
|
+
if (!c || !c.connected) { NEXT_PREP.later = null; clearLater(box); return; }
|
|
1863
2229
|
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
|
-
|
|
2230
|
+
var list = c.next || [];
|
|
2231
|
+
var rows = list.filter(function (r) { return !r.allDay && !same(r); }).slice(0, LATER_ROWS);
|
|
2232
|
+
// What is left today past the rows on screen (the event above counts when it is one of today's).
|
|
2233
|
+
var shown = rows.length + (list.some(same) ? 1 : 0), more = (Number(c.remainingCount) || 0) - shown;
|
|
2234
|
+
NEXT_PREP.later = { rows: rows, more: more, asOf: c.stale && c.asOf ? toDate(c.asOf) : null };
|
|
2235
|
+
paintCalendarLater();
|
|
2236
|
+
}, function () { NEXT_PREP.later = null; clearLater(box); });
|
|
2237
|
+
}
|
|
2238
|
+
// Emptied, and its last write forgotten, so the next read that has rows writes them again (writeKeepingPrep skips a
|
|
2239
|
+
// write that matches the last one).
|
|
2240
|
+
function clearLater(box) { box.classList.add('hidden'); box.innerHTML = ''; box.cosPrepHtml = ''; }
|
|
2241
|
+
// The later rows from the last calendar read. Each meeting that is not a hold (the server's `hold`) gets a quiet
|
|
2242
|
+
// Prep, and the open row's panel sits under it. Written only when the markup changed, so a poll never takes the words
|
|
2243
|
+
// or the caret from the panel; when it is written, the words, the focus and the caret are put back.
|
|
2244
|
+
function paintCalendarLater() {
|
|
2245
|
+
var box = $('next-later'), L = NEXT_PREP.later; if (!box || !L) return;
|
|
2246
|
+
var mode = nextPrepMode(), openKey = '';
|
|
2247
|
+
var html = L.rows.map(function (r) {
|
|
2248
|
+
var rd = toDate(r.start), key = rd ? nextPrepKey(r.title, rd) : '', can = !!(mode && key && r.hold !== true);
|
|
2249
|
+
if (can) { NEXT_PREP.rows[key] = { title: r.title, start: rd.toISOString() }; if (NEXT_PREP.open === key) openKey = key; }
|
|
2250
|
+
return '<div class="later-row"><span class="later-when">' + esc(rd ? clock(rd) : '') + '</span><span class="later-what">' + esc(r.title) + '</span>' +
|
|
2251
|
+
(can ? '<button type="button" class="link-btn later-prep" data-next-prep="' + esc(key) + '" aria-expanded="' + (NEXT_PREP.open === key) + '">Prep</button>' : '') + '</div>' +
|
|
2252
|
+
(can && NEXT_PREP.open === key ? nextPrepPanelHtml(key, mode) : '');
|
|
2253
|
+
}).join('');
|
|
2254
|
+
if (L.more > 0) html += '<button type="button" class="link-btn later-more" data-goto="meetings">' + esc('+' + L.more + ' more today') + '</button>';
|
|
2255
|
+
if (L.asOf) html += '<div class="later-asof">' + esc('Calendar as of ' + clock(L.asOf)) + '</div>';
|
|
2256
|
+
// Shown before it is written: autoGrow measures the box, and a hidden one measures 0.
|
|
2257
|
+
box.classList.toggle('hidden', !html);
|
|
2258
|
+
writeKeepingPrep(box, html, openKey);
|
|
2259
|
+
}
|
|
2260
|
+
|
|
2261
|
+
// ── Prep on every Next up row (Miles typed 2026-11-09 13:00 CST) ─────────
|
|
2262
|
+
// "Inline options to prep for a given meeting which starts the gotCOS ask and gives us the ability to add in more
|
|
2263
|
+
// context for the prep." Prep on a later row (or Add context beside the first row's Prep me) opens a small panel
|
|
2264
|
+
// under that row: the context box, Add files, Start prep and Cancel. One panel at a time; Escape closes it. The words
|
|
2265
|
+
// and files are kept per meeting (title plus start) while the page polls. Start prep goes to Messages, then COS, and
|
|
2266
|
+
// hands them to cos-chat.js prepStart: the Prep composer for that meeting (the context joins its notes, the files go
|
|
2267
|
+
// 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
|
|
2268
|
+
// as its quoted reference. Neither on (or the Mac not answering): no Prep on any row.
|
|
2269
|
+
var NEXT_PREP = { open: '', rows: {}, drafts: {}, later: null, firstKey: '', busy: false, capsAsked: false };
|
|
2270
|
+
function nextPrepKey(title, d) { return String(title || '') + '|' + (d ? d.toISOString() : ''); }
|
|
2271
|
+
function nextPrepChat() { return typeof window !== 'undefined' && window.COSWeb && window.COSWeb.cosChat || null; }
|
|
2272
|
+
/** 'prep' (Prep on the Mac), 'ask' (Ask only), or '' (neither, the Mac not answering, or not known yet). */
|
|
2273
|
+
function nextPrepMode() {
|
|
2274
|
+
var chat = nextPrepChat();
|
|
2275
|
+
if (TODAY.offline || !chat || !chat.prepStart) return '';
|
|
2276
|
+
if (chat.known && !chat.known()) {
|
|
2277
|
+
// The chat reads /api/health once per page; paint the rows again when it lands.
|
|
2278
|
+
if (!NEXT_PREP.capsAsked && chat.ensureCaps) {
|
|
2279
|
+
NEXT_PREP.capsAsked = true;
|
|
2280
|
+
chat.ensureCaps().then(function () { if (TODAY.welcome) renderNext(TODAY.welcome); paintCalendarLater(); }, function () {});
|
|
2281
|
+
}
|
|
2282
|
+
return '';
|
|
2283
|
+
}
|
|
2284
|
+
if (chat.prepCaps && chat.prepCaps()) return 'prep';
|
|
2285
|
+
return chat.askOn && chat.askOn() ? 'ask' : '';
|
|
2286
|
+
}
|
|
2287
|
+
function nextPrepDraft(key) { return NEXT_PREP.drafts[key] || (NEXT_PREP.drafts[key] = { text: '', files: [] }); }
|
|
2288
|
+
function nextPrepMax() { var chat = nextPrepChat(), c = chat && chat.prepCaps && chat.prepCaps(); return c || { maxFiles: 10, steerChars: 4000 }; }
|
|
2289
|
+
function nextPrepPanelHtml(key, mode) {
|
|
2290
|
+
var row = NEXT_PREP.rows[key] || { title: '' }, dr = nextPrepDraft(key), chat = nextPrepChat();
|
|
2291
|
+
var label = 'Add context for this prep (goals, people, what you want out of it)';
|
|
2292
|
+
var files = mode === 'prep' && dr.files.length ? '<ul class="next-prep-files">' + dr.files.map(function (f, i) {
|
|
2293
|
+
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>';
|
|
2294
|
+
}).join('') + '</ul>' : '';
|
|
2295
|
+
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>' +
|
|
2296
|
+
'<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>' : '';
|
|
2297
|
+
return '<form class="next-prep" data-next-prep-form data-key="' + esc(key) + '" autocomplete="off" aria-label="' + esc('Prep for ' + row.title) + '">' +
|
|
2298
|
+
'<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>' +
|
|
2299
|
+
files +
|
|
2300
|
+
'<div class="next-prep-acts">' + add + '<span class="next-prep-gap"></span>' +
|
|
2301
|
+
'<button type="button" class="link-btn next-prep-cancel" data-next-prep-cancel>Cancel</button>' +
|
|
2302
|
+
'<button type="submit" class="cx-btn primary next-prep-start"' + (NEXT_PREP.busy ? ' disabled' : '') + '>Start prep</button></div>' +
|
|
2303
|
+
'</form>';
|
|
2304
|
+
}
|
|
2305
|
+
// The first row's panel, in its own slot under Prep me and Open meeting.
|
|
2306
|
+
function paintNextPrepSlot(slot, key) {
|
|
2307
|
+
if (!slot) return;
|
|
2308
|
+
var mode = key ? nextPrepMode() : '';
|
|
2309
|
+
// Shown before it is written: autoGrow measures the box, and a hidden one measures 0.
|
|
2310
|
+
slot.classList.toggle('hidden', !(key && mode));
|
|
2311
|
+
writeKeepingPrep(slot, key && mode ? nextPrepPanelHtml(key, mode) : '', key);
|
|
2312
|
+
}
|
|
2313
|
+
/** Writes `html` into `el` unless it is already there, then puts the open panel's words back (and the focus and caret). */
|
|
2314
|
+
function writeKeepingPrep(el, html, key) {
|
|
2315
|
+
if (el.cosPrepHtml === html) return;
|
|
2316
|
+
var doc = typeof document !== 'undefined' ? document : null, active = doc && doc.activeElement;
|
|
2317
|
+
var had = !!(active && el.contains && el.contains(active) && active.hasAttribute && active.hasAttribute('data-next-prep-steer'));
|
|
2318
|
+
var a = had ? active.selectionStart : 0, b = had ? active.selectionEnd : 0;
|
|
2319
|
+
el.innerHTML = html;
|
|
2320
|
+
el.cosPrepHtml = html;
|
|
2321
|
+
var box = key && el.querySelector ? el.querySelector('[data-next-prep-steer]') : null;
|
|
2322
|
+
if (!box) return;
|
|
2323
|
+
box.value = nextPrepDraft(key).text;
|
|
2324
|
+
autoGrow(box);
|
|
2325
|
+
if (had) { box.focus(); try { box.setSelectionRange(a, b); } catch (_e) {} }
|
|
2326
|
+
}
|
|
2327
|
+
function repaintNextPrep() {
|
|
2328
|
+
if (TODAY.welcome && TODAY.welcome.nextEvent) renderNext(TODAY.welcome);
|
|
2329
|
+
paintCalendarLater();
|
|
2330
|
+
}
|
|
2331
|
+
function toggleNextPrep(key) {
|
|
2332
|
+
NEXT_PREP.open = NEXT_PREP.open === key ? '' : key;
|
|
2333
|
+
repaintNextPrep();
|
|
2334
|
+
if (!NEXT_PREP.open || typeof document === 'undefined') return;
|
|
2335
|
+
var box = document.querySelector('[data-next-prep-form] [data-next-prep-steer]');
|
|
2336
|
+
if (box) { box.focus(); if (box.scrollIntoView) box.scrollIntoView({ block: 'nearest' }); }
|
|
2337
|
+
}
|
|
2338
|
+
// Escape closes the panel and keeps the words (they come back when it opens again); Cancel closes it and drops them.
|
|
2339
|
+
function closeNextPrep(drop) {
|
|
2340
|
+
var key = NEXT_PREP.open; if (!key) return false;
|
|
2341
|
+
if (drop) delete NEXT_PREP.drafts[key];
|
|
2342
|
+
NEXT_PREP.open = '';
|
|
2343
|
+
repaintNextPrep();
|
|
2344
|
+
var opener = typeof document !== 'undefined' && typeof CSS !== 'undefined' && CSS.escape ? document.querySelector('[data-next-prep="' + CSS.escape(key) + '"]') : null;
|
|
2345
|
+
if (opener && opener.focus) opener.focus();
|
|
2346
|
+
return true;
|
|
2347
|
+
}
|
|
2348
|
+
function nextPrepInput(box) { var key = NEXT_PREP.open; if (key) nextPrepDraft(key).text = box.value; }
|
|
2349
|
+
function addNextPrepFiles(list) {
|
|
2350
|
+
var key = NEXT_PREP.open; if (!key) return;
|
|
2351
|
+
var dr = nextPrepDraft(key), max = nextPrepMax().maxFiles, chat = nextPrepChat(), refused = [];
|
|
2352
|
+
Array.prototype.slice.call(list || []).forEach(function (f) {
|
|
2353
|
+
if (dr.files.length >= max) { refused.push('A prep holds ' + max + ' files. ' + (f.name || 'A file') + ' was not added.'); return; }
|
|
2354
|
+
var c = chat && chat.classify ? chat.classify(f) : {};
|
|
2355
|
+
if (c && c.refuse) { refused.push(c.refuse); return; }
|
|
2356
|
+
if (c && c.kind === 'video') { refused.push((f.name || 'That video') + ' is a video. Prep reads documents and the text in images.'); return; }
|
|
2357
|
+
dr.files.push(f);
|
|
2358
|
+
});
|
|
2359
|
+
if (refused.length) toast(refused.join(' '));
|
|
2360
|
+
repaintNextPrep();
|
|
2361
|
+
}
|
|
2362
|
+
function removeNextPrepFile(i) {
|
|
2363
|
+
var key = NEXT_PREP.open; if (!key) return;
|
|
2364
|
+
nextPrepDraft(key).files.splice(Number(i), 1);
|
|
2365
|
+
repaintNextPrep();
|
|
2366
|
+
}
|
|
2367
|
+
function startNextPrep() {
|
|
2368
|
+
var key = NEXT_PREP.open, row = key && NEXT_PREP.rows[key], chat = nextPrepChat();
|
|
2369
|
+
if (!row || NEXT_PREP.busy || !chat || !chat.prepStart) return;
|
|
2370
|
+
var mode = nextPrepMode(); if (!mode) return;
|
|
2371
|
+
var dr = nextPrepDraft(key);
|
|
2372
|
+
var extra = { steer: dr.text.trim(), files: mode === 'prep' ? dr.files.slice() : [] };
|
|
2373
|
+
NEXT_PREP.busy = true; NEXT_PREP.open = '';
|
|
2374
|
+
repaintNextPrep();
|
|
2375
|
+
go('messages', ['cos']);
|
|
2376
|
+
// The words and files stay with the meeting until the prep (or the Ask) is on its way; a failure keeps them.
|
|
2377
|
+
chat.prepStart(row.title, row.start, extra).then(function (how) {
|
|
2378
|
+
if (how) delete NEXT_PREP.drafts[key];
|
|
2379
|
+
else toast('Prep is not on for this Mac.');
|
|
2380
|
+
}, function (err) {
|
|
2381
|
+
toast((err && err.message) || 'Prep did not start. Your context is still under the meeting on Today.');
|
|
2382
|
+
}).then(function () { NEXT_PREP.busy = false; });
|
|
1874
2383
|
}
|
|
1875
2384
|
function prepNext() {
|
|
1876
2385
|
var n = TODAY.welcome && TODAY.welcome.nextEvent, d = n && toDate(n.time), chat = window.COSWeb && window.COSWeb.cosChat;
|
|
@@ -1878,6 +2387,53 @@
|
|
|
1878
2387
|
go('messages', ['cos']);
|
|
1879
2388
|
chat.prepFor(n.title, d ? d.toISOString() : null).catch(function (err) { toast((err && err.message) || 'Prep me did not start.'); });
|
|
1880
2389
|
}
|
|
2390
|
+
// ── Ask about this (Today round 2, Miles 11/09) ─────────
|
|
2391
|
+
// "Ask COS from this page about the summary and take action coming off of that summary": one box under the model's
|
|
2392
|
+
// summary. Send goes to Messages, then COS, and submits an Ask (panes/cos-chat.js askAbout) that carries the summary,
|
|
2393
|
+
// when it was written and the sessions it read, as the Ask's quoted reference (data, never instructions; no transcript
|
|
2394
|
+
// text, only the last message into each session). COS can then answer, or pass an instruction to a session by its
|
|
2395
|
+
// title through the Ask's own relay ("Sent to <title>" on the answer card). Hidden without a model summary, without
|
|
2396
|
+
// Ask on the Mac (capabilities: durable query jobs) or while the Mac does not answer.
|
|
2397
|
+
function paintTodayAsk(brief) {
|
|
2398
|
+
var form = $('today-ask'); if (!form) return;
|
|
2399
|
+
var chat = window.COSWeb && window.COSWeb.cosChat;
|
|
2400
|
+
// Ask's capability is read once per page (the chat's /api/health read); paint again when it lands.
|
|
2401
|
+
if (brief && chat && chat.known && !chat.known() && chat.ensureCaps && !TODAY.askCaps) {
|
|
2402
|
+
TODAY.askCaps = true;
|
|
2403
|
+
chat.ensureCaps().then(function () { if (TODAY.model && state.pane === 'today') renderStory(TODAY.model); }, function () {});
|
|
2404
|
+
}
|
|
2405
|
+
// T1 spec 0: the box stays while a session read is pending or failed; briefShown decides whether there is a summary.
|
|
2406
|
+
var on = !!(brief && !TODAY.offline && chat && chat.askAbout && chat.askOn && chat.askOn());
|
|
2407
|
+
form.classList.toggle('hidden', !on);
|
|
2408
|
+
}
|
|
2409
|
+
// The sessions the summary read: the server's own list with the summary (7.0.10+ round 2 `sessions`: title, provider,
|
|
2410
|
+
// state, when it last moved, the last message into it), else what this page holds for the same rows.
|
|
2411
|
+
function todayAskContext(b) {
|
|
2412
|
+
var rows = [];
|
|
2413
|
+
if (Array.isArray(b.sessions) && b.sessions.length) {
|
|
2414
|
+
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) : '' }); });
|
|
2415
|
+
} else {
|
|
2416
|
+
(state.sessions || []).forEach(function (s) {
|
|
2417
|
+
if (sxKeepWarm(s)) return;
|
|
2418
|
+
var kind = sxState(s) === 'waiting' ? 'waiting' : s.running_active ? 'working' : s.running ? 'open' : '';
|
|
2419
|
+
if (!kind) return;
|
|
2420
|
+
var k = splitSessionId(s.provider, s.session_id || s.id);
|
|
2421
|
+
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: '' });
|
|
2422
|
+
});
|
|
2423
|
+
}
|
|
2424
|
+
return { summary: String(b.text || ''), at: b.at || null, sessions: rows };
|
|
2425
|
+
}
|
|
2426
|
+
function sendTodayAsk() {
|
|
2427
|
+
var input = $('today-ask-input'), text = input ? input.value.trim() : '';
|
|
2428
|
+
var chat = window.COSWeb && window.COSWeb.cosChat, b = briefShown();
|
|
2429
|
+
if (!text || TODAY.askBusy || !b || !chat || !chat.askAbout) return;
|
|
2430
|
+
TODAY.askBusy = true;
|
|
2431
|
+
var ctx = todayAskContext(b);
|
|
2432
|
+
go('messages', ['cos']);
|
|
2433
|
+
chat.askAbout(text, ctx).then(function () { input.value = ''; autoGrow(input); }, function (err) {
|
|
2434
|
+
toast((err && err.message) || 'Ask did not send. Your words are still in the box on Today.');
|
|
2435
|
+
}).then(function () { TODAY.askBusy = false; });
|
|
2436
|
+
}
|
|
1881
2437
|
// From today's meetings: Work's suggestions from meetings held today. They stay here until
|
|
1882
2438
|
// midnight, then join Sort in Work; Review opens them in Work's Sort.
|
|
1883
2439
|
function localDay(d) { return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); }
|
|
@@ -1953,7 +2509,8 @@
|
|
|
1953
2509
|
}
|
|
1954
2510
|
var SX = {
|
|
1955
2511
|
rows: null, dropped: null, enabled: true, loading: false, error: '', loadedAt: 0,
|
|
1956
|
-
|
|
2512
|
+
// 7.0.23 step 2b: Pinned is a group now, not a clock; a saved 'pinned' reads as Updated.
|
|
2513
|
+
clock: sxPref('clock', 'updated', ['updated', 'opened']),
|
|
1957
2514
|
view: sxPref('view', 'sessions', ['sessions', 'usage', 'cost']),
|
|
1958
2515
|
recency: sxPref('recency', 'newest', ['newest', 'oldest', 'match']),
|
|
1959
2516
|
q: '', instant: [], remote: [], searching: false, searchError: '', ranFor: '', semantic: null, semanticReason: '', searchSeq: 0, searchTimer: null,
|
|
@@ -1983,7 +2540,9 @@
|
|
|
1983
2540
|
|
|
1984
2541
|
// ClaudeSession.title: the job label, else the name, else the workspace, else the id.
|
|
1985
2542
|
function sxTitle(s) {
|
|
1986
|
-
|
|
2543
|
+
// 7.0.23 step 2b-2: a title renamed in COS (S1's title_override) is the title everywhere; the agent's own stays
|
|
2544
|
+
// in display_label, shown under it in the list and still searchable.
|
|
2545
|
+
var t = plain(s.title_override || s.display_label || s.custom_title || '').replace(/\s+/g, ' ').trim();
|
|
1987
2546
|
return t || String(s.project || '').trim() || String(s.session_id || s.id || 'Session');
|
|
1988
2547
|
}
|
|
1989
2548
|
function sxWarmWords(t) { t = String(t || '').trim().toLowerCase(); return t === 'ready' || t.indexOf('this is an automated local readiness check') === 0; }
|
|
@@ -2096,7 +2655,8 @@
|
|
|
2096
2655
|
'<div class="cx-hero-text"><h1 class="cx-title">Sessions</h1><p class="cx-detail-line">' + esc(detail) + liveHeroNote(view) + '</p></div>' +
|
|
2097
2656
|
'<div class="cx-actions"><button type="button" class="cx-btn quiet" data-sx="refresh"' + (busy ? ' disabled' : '') + '><svg class="icon"><use href="#i-refresh"/></svg>Refresh</button></div>' +
|
|
2098
2657
|
(stats.length ? '<div class="cx-stats">' + stats.map(function (s) { return '<span class="cx-stat"><b>' + esc(typeof s[0] === 'number' ? s[0].toLocaleString() : s[0]) + '</b><span>' + s[1] + '</span></span>'; }).join('') + '</div>' : '');
|
|
2099
|
-
|
|
2658
|
+
// 7.0.23 step 2b: Updated and Opened are the sort menu now, and Pinned a group; no clock switches here.
|
|
2659
|
+
var clockOn = false;
|
|
2100
2660
|
views.innerHTML = '<div class="cx-switch" role="group" aria-label="Sessions view">' +
|
|
2101
2661
|
Object.keys(SX_VIEWS).map(function (v) { return '<button type="button" data-sx="view" data-view="' + v + '" aria-pressed="' + (v === view) + '">' + SX_VIEWS[v] + '</button>'; }).join('') +
|
|
2102
2662
|
'</div>' +
|
|
@@ -2106,6 +2666,115 @@
|
|
|
2106
2666
|
var split = $('sessions-split'), usage = $('sessions-usage');
|
|
2107
2667
|
if (split) split.classList.toggle('hidden', view !== 'sessions');
|
|
2108
2668
|
if (usage) usage.classList.toggle('hidden', view === 'sessions');
|
|
2669
|
+
// 7.0.23 step 2b: in Sessions the list's own head (the Sessions menu, Search, the quiet line, the sort menu) takes
|
|
2670
|
+
// the place of this header and the switches; Usage and COS cost keep them, so there is a way back.
|
|
2671
|
+
hero.classList.toggle('hidden', view === 'sessions');
|
|
2672
|
+
// Under 600 px the list's own Sessions button steps aside for the top bar's (sessions.css), and this switch stays
|
|
2673
|
+
// the way to Usage and COS cost there; from 600 px the Sessions menu is that way.
|
|
2674
|
+
views.classList.toggle('sx-wide-hide', view === 'sessions');
|
|
2675
|
+
sxFrameSync();
|
|
2676
|
+
}
|
|
2677
|
+
// 7.0.23 (step 1 of the chat-style Sessions, DESIGN_PLAN v2 "The frame"): while Sessions shows its list and reader,
|
|
2678
|
+
// html.sx-frame makes the page one screen, never taller: the app shell is the visible box (--vv-top, --vv-h from
|
|
2679
|
+
// viewportFit, the same kit kb-chat uses), and the rail, the list and the reader's body each scroll by themselves, with
|
|
2680
|
+
// the reply area fixed at the bottom of the reader (panes/sessions.css). Usage and COS cost keep the page's own scroll.
|
|
2681
|
+
// Layout, not a proven fix for the 20:16 report: the frame reports where the rail, the list and the reply box sit
|
|
2682
|
+
// against the visible area (sxFrameCrumb).
|
|
2683
|
+
function sxFrameSync() {
|
|
2684
|
+
// Never while the sign-in gate shows (the shell is hidden): the gate must scroll like a page.
|
|
2685
|
+
var de = document.documentElement, shell = $('app-shell'), on = state.pane === 'sessions' && SX.view === 'sessions' && !(shell && shell.classList.contains('hidden'));
|
|
2686
|
+
if (de && de.classList) de.classList.toggle('sx-frame', on);
|
|
2687
|
+
if (on) sxFrameHook();
|
|
2688
|
+
return on;
|
|
2689
|
+
}
|
|
2690
|
+
// Where the frame's parts sit against the VISIBLE area (step 1's position report, lead 23:00): on a reader's open, and
|
|
2691
|
+
// KB_AFTER_MS after an on-screen keyboard closes while the frame is on. The visible area is viewportFit's box (a
|
|
2692
|
+
// visible height under 80 px is no reading, so it is innerHeight from 0, and fb says so) and, across, visualViewport's
|
|
2693
|
+
// offsetLeft and width. For the rail, the list, the reader and the reply box: their top, bottom, left and right in
|
|
2694
|
+
// the same coordinates, and whether each lies inside the visible area (railIn, listIn, replyIn: within 1 px on every
|
|
2695
|
+
// side; a part that is not shown reads -1 and false). Numbers and fixed labels only, at most SX_FRAME_MAX a page load.
|
|
2696
|
+
var SX_FRAME_MAX = 8, SX_FRAME = { n: 0, hooked: false };
|
|
2697
|
+
function sxFrameCrumb(why) {
|
|
2698
|
+
if (SX_FRAME.n >= SX_FRAME_MAX || !document.documentElement.classList.contains('sx-frame') || typeof layoutCrumb !== 'function') return false;
|
|
2699
|
+
try {
|
|
2700
|
+
var v = viewportFit(), vv = window.visualViewport, iw = window.innerWidth || 0;
|
|
2701
|
+
var left = vv && !v.bad && typeof vv.offsetLeft === 'number' ? vv.offsetLeft : 0;
|
|
2702
|
+
var width = vv && !v.bad && vv.width > 0 ? vv.width : iw;
|
|
2703
|
+
var box = { t: v.top, b: v.top + v.height, l: left, r: left + width };
|
|
2704
|
+
var out = { why: String(why || '').slice(0, 24), fb: !!v.bad, boxT: box.t, boxB: box.b, boxL: box.l, boxR: box.r };
|
|
2705
|
+
var part = function (name, el) {
|
|
2706
|
+
var r = el && el.getBoundingClientRect && el.offsetParent !== null ? el.getBoundingClientRect() : null;
|
|
2707
|
+
var shown = !!(r && r.width > 0 && r.height > 0);
|
|
2708
|
+
out[name + 'T'] = shown ? r.top : -1; out[name + 'B'] = shown ? r.bottom : -1; out[name + 'L'] = shown ? r.left : -1; out[name + 'R'] = shown ? r.right : -1;
|
|
2709
|
+
out[name + 'In'] = shown && r.top >= box.t - 1 && r.bottom <= box.b + 1 && r.left >= box.l - 1 && r.right <= box.r + 1;
|
|
2710
|
+
};
|
|
2711
|
+
part('rail', document.querySelector('.rail'));
|
|
2712
|
+
part('list', $('sessions-list-col'));
|
|
2713
|
+
part('rd', $('session-reader'));
|
|
2714
|
+
part('reply', $('session-form'));
|
|
2715
|
+
if (layoutCrumb('sx-frame', out)) { SX_FRAME.n++; return true; }
|
|
2716
|
+
} catch (_e) { /* a report never stops the page */ }
|
|
2717
|
+
return false;
|
|
2718
|
+
}
|
|
2719
|
+
// The keyboard's after-close moment (app.js lrAfter, KB_AFTER_MS), heard once, reported while the frame is on.
|
|
2720
|
+
function sxFrameHook() {
|
|
2721
|
+
if (SX_FRAME.hooked || !window.COSWeb || typeof window.COSWeb.onKeyboard !== 'function') return;
|
|
2722
|
+
SX_FRAME.hooked = true;
|
|
2723
|
+
window.COSWeb.onKeyboard(function (what) { if (what === 'after') sxFrameCrumb('kb-after'); });
|
|
2724
|
+
}
|
|
2725
|
+
// "Go to the question" (Q1 23:40): shown in the dock while the session's question cards have something and the end of
|
|
2726
|
+
// the conversation, where they are, is out of view (the person scrolled up). Read on the body's scroll, its size
|
|
2727
|
+
// changes and the cards' redraws.
|
|
2728
|
+
function sxGoQuestionPaint() {
|
|
2729
|
+
var line = $('session-goq'), body = $('session-body'), q = $('session-questions');
|
|
2730
|
+
if (!line) return false;
|
|
2731
|
+
var has = !!(q && q.children && q.children.length && String(q.textContent || '').trim());
|
|
2732
|
+
var hidden = false;
|
|
2733
|
+
if (has && body && body.getBoundingClientRect && q.getBoundingClientRect) hidden = q.getBoundingClientRect().top >= body.getBoundingClientRect().bottom - 8;
|
|
2734
|
+
var show = has && hidden && document.documentElement.classList.contains('sx-frame');
|
|
2735
|
+
// Its place: just above the dock, whatever the dock's height is now (the reader holds it in --sx-dock-h).
|
|
2736
|
+
var dock = $('session-dock'), rd = line.parentElement;
|
|
2737
|
+
if (show && dock && rd && rd.style && dock.getBoundingClientRect) rd.style.setProperty('--sx-dock-h', Math.round(dock.getBoundingClientRect().height) + 'px');
|
|
2738
|
+
line.hidden = !show;
|
|
2739
|
+
return show;
|
|
2740
|
+
}
|
|
2741
|
+
// The reader's own scroll (#session-body); the window's only where the frame is off.
|
|
2742
|
+
// What mounts after the first paint (the session's question cards, the queue, the activity, image previews) changes
|
|
2743
|
+
// the body's height and would leave it short of the end. So the body stays at the newest turn while its size or its
|
|
2744
|
+
// content changes, until the person scrolls it themself: a wheel, touch, pointer or key on the body within the last
|
|
2745
|
+
// SX_PIN_USER_MS, then a scroll that leaves the end by more than 4 px. A scroll the browser makes on its own (scroll
|
|
2746
|
+
// anchoring while turns and previews land) never counts. Two more passes (250 and 900 ms) cover a browser without
|
|
2747
|
+
// ResizeObserver.
|
|
2748
|
+
var SX_NEWEST_AGAIN_MS = [250, 900], SX_PIN_USER_MS = 1000;
|
|
2749
|
+
var SX_PIN = { body: null, ro: null, put: -1, userAt: 0 };
|
|
2750
|
+
function sxUnpin() { if (SX_PIN.ro) { try { SX_PIN.ro.disconnect(); } catch (_e) {} } SX_PIN.ro = null; SX_PIN.body = null; }
|
|
2751
|
+
function sxToNewest() {
|
|
2752
|
+
var body = $('session-body');
|
|
2753
|
+
if (!(body && document.documentElement.classList.contains('sx-frame'))) { window.scrollTo(0, document.documentElement.scrollHeight); return; }
|
|
2754
|
+
var bottom = function () { body.scrollTop = body.scrollHeight; SX_PIN.put = body.scrollTop; };
|
|
2755
|
+
bottom();
|
|
2756
|
+
if (SX_PIN.body !== body) {
|
|
2757
|
+
sxUnpin();
|
|
2758
|
+
SX_PIN.body = body;
|
|
2759
|
+
var touched = function () { SX_PIN.userAt = Date.now(); };
|
|
2760
|
+
['wheel', 'touchstart', 'pointerdown', 'keydown'].forEach(function (t) { body.addEventListener(t, touched, { passive: true }); });
|
|
2761
|
+
body.addEventListener('scroll', sxGoQuestionPaint, { passive: true });
|
|
2762
|
+
body.addEventListener('scroll', function () {
|
|
2763
|
+
if (SX_PIN.body !== body || Date.now() - SX_PIN.userAt > SX_PIN_USER_MS) return;
|
|
2764
|
+
if (Math.abs(body.scrollTop - SX_PIN.put) > 4 && body.scrollTop < body.scrollHeight - body.clientHeight - 4) sxUnpin();
|
|
2765
|
+
}, { passive: true });
|
|
2766
|
+
if (typeof ResizeObserver === 'function') {
|
|
2767
|
+
// The body and everything in it (the note, the turns, Copy session, its note): any of them can grow.
|
|
2768
|
+
SX_PIN.ro = new ResizeObserver(function () { if (SX_PIN.body === body && body.isConnected) bottom(); sxGoQuestionPaint(); });
|
|
2769
|
+
SX_PIN.ro.observe(body);
|
|
2770
|
+
[].forEach.call(body.children, function (c) { SX_PIN.ro.observe(c); });
|
|
2771
|
+
// The dock too: files added to the reply box change its height, and Go to the question sits just above it.
|
|
2772
|
+
if ($('session-dock')) SX_PIN.ro.observe($('session-dock'));
|
|
2773
|
+
}
|
|
2774
|
+
}
|
|
2775
|
+
SX_NEWEST_AGAIN_MS.forEach(function (ms) {
|
|
2776
|
+
setTimeout(function () { if (body.isConnected && SX_PIN.body === body) bottom(); }, ms);
|
|
2777
|
+
});
|
|
2109
2778
|
}
|
|
2110
2779
|
|
|
2111
2780
|
// 7.0.1: the hero's counts say when they are not live ("Reconnecting", "as of 2 min ago"); nothing without a stream.
|
|
@@ -2145,6 +2814,8 @@
|
|
|
2145
2814
|
state.sessions = SX.rows;
|
|
2146
2815
|
if (sxQueryActive()) SX.instant = sxInstant(SX.q);
|
|
2147
2816
|
renderSessionGroups();
|
|
2817
|
+
sxLoadOlder(force === true);
|
|
2818
|
+
if (typeof sxLoadFolders === 'function') sxLoadFolders(force === true);
|
|
2148
2819
|
}).catch(function (err) {
|
|
2149
2820
|
SX.loading = false;
|
|
2150
2821
|
SX.error = (err && err.message) || 'Sessions did not load';
|
|
@@ -2170,36 +2841,409 @@
|
|
|
2170
2841
|
}
|
|
2171
2842
|
// Control's row: glyph, title, a snippet on search hits, then badge · PINNED · state · Updated ·
|
|
2172
2843
|
// Opened (twins only, amber) · workspace; a match chip and a chevron on the right.
|
|
2173
|
-
|
|
2844
|
+
// ── the grouped list (7.0.23, step 2b-1 of the chat-style Sessions; DESIGN_PLAN v2 "The list", the 23:33 mock) ──
|
|
2845
|
+
// A row, full or compact. sxRow stays the one renderer: live updates (liveSessionRows) replace a row's root, the
|
|
2846
|
+
// element with data-session, with this; search draws its hits with it (a snippet and the Keyword or Meaning tag).
|
|
2847
|
+
// Line two is the product's own state words, then the project, in plain words with commas: Running; Waiting and its
|
|
2848
|
+
// kind; Stale; Failed and its reason; "2 queued"; Not sent; "Pinned in Claude" for an agent's own pin; "Opened
|
|
2849
|
+
// Nov 9" when two rows on screen share a title. The left edge is green running, accent waiting, red failed, and the
|
|
2850
|
+
// state is always in the words too. A compact row (days 8 to 30, the short form from /older) is the title and date.
|
|
2851
|
+
function sxRowWords(s, st, key) {
|
|
2852
|
+
var w = [];
|
|
2853
|
+
if (st === 'running') w.push('Running');
|
|
2854
|
+
else if (st === 'waiting') { var k = sxWaitingKind(s.waiting_kind, s.waiting_detail); w.push(k ? 'Waiting, ' + k.charAt(0).toLowerCase() + k.slice(1) : 'Waiting'); }
|
|
2855
|
+
else if (st === 'stale') w.push('Stale');
|
|
2856
|
+
else if (st === 'error') w.push(s.failure ? 'Failed, ' + String(s.failure).replace(/_/g, ' ') : 'Failed');
|
|
2857
|
+
var queued = Number(s.queued_turns) || 0;
|
|
2858
|
+
if (queued > 0) w.push(queued + ' queued');
|
|
2859
|
+
var ns = notSentFor(key.provider, key.id);
|
|
2860
|
+
if (ns) w.push(ns.label || 'Not sent');
|
|
2861
|
+
if (s.pinned) w.push('Pinned in ' + sxProviderLabel(key.provider));
|
|
2862
|
+
return w;
|
|
2863
|
+
}
|
|
2864
|
+
function sxRow(s, hit, ambiguous, compact) {
|
|
2174
2865
|
var key = splitSessionId(s.provider, s.session_id || s.id);
|
|
2175
2866
|
var r = parseRoute();
|
|
2176
2867
|
var selected = r.pane === 'sessions' && r.args[0] === key.provider && r.args[1] === key.id;
|
|
2177
|
-
var
|
|
2178
|
-
var
|
|
2179
|
-
var
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2868
|
+
var short = s.form === 'short', st = short ? '' : sxState(s), title = sxTitle(s);
|
|
2869
|
+
var when = toDate(SX.clock === 'opened' ? (s.created || s.modified) : s.modified);
|
|
2870
|
+
var edge = st === 'running' ? ' running' : st === 'waiting' ? ' waiting' : st === 'error' ? ' failed' : '';
|
|
2871
|
+
var id = esc(key.provider) + '|' + esc(key.id), rowKey = key.provider + '|' + key.id;
|
|
2872
|
+
var org = typeof SXF === 'object' && SXF.ok === true;
|
|
2873
|
+
if (org && SXF.renaming && SXF.renaming.toLowerCase() === rowKey.toLowerCase()) {
|
|
2874
|
+
// data-edit, not data-session, while the form is open: a form's Save (or Enter, which the browser turns into a
|
|
2875
|
+
// click on Save) must not reach the page's row handler and open the session.
|
|
2876
|
+
return '<div class="sx-r editing" data-edit="' + id + '">' + sxInlineHtml('rename', rowKey, 'Rename this session', s.title_override || title,
|
|
2877
|
+
s.title_override ? 'The agent\u2019s own title stays underneath and in search.' : '') + '</div>';
|
|
2878
|
+
}
|
|
2879
|
+
var agent = s.title_override && s.display_label && String(s.display_label).trim() !== title ? plain(String(s.display_label)).replace(/\s+/g, ' ').trim() : '';
|
|
2880
|
+
var head = '<span class="sx-r-title">' + esc(title) + '</span>' + (when ? '<span class="sx-r-time">' + esc(sxShortDate(when)) + '</span>' : '') +
|
|
2881
|
+
(agent ? '<span class="sx-r-orig">' + esc(agent) + '</span>' : '');
|
|
2882
|
+
var more = org && !short && !compact ? '<button type="button" class="sx-r-more" data-sx="row-menu" data-key="' + id + '" aria-haspopup="menu" aria-expanded="false" aria-label="' + esc('More for ' + title) + '"><span aria-hidden="true">\u2026</span></button>' : '';
|
|
2883
|
+
if (compact || short) {
|
|
2884
|
+
return '<div class="sx-r compact' + edge + '" data-session="' + id + '"' + (selected ? ' aria-selected="true"' : '') + '>' +
|
|
2885
|
+
'<button type="button" class="sx-r-open" data-session="' + id + '"' + (selected ? ' aria-selected="true"' : '') + '>' + head + '</button></div>';
|
|
2886
|
+
}
|
|
2887
|
+
var words = sxRowWords(s, st, key);
|
|
2888
|
+
var opened = toDate(s.created);
|
|
2889
|
+
if (ambiguous && ambiguous[title] && opened) words.push('Opened ' + sxShortDate(opened));
|
|
2890
|
+
var proj = s.project && String(s.project) !== title ? String(s.project) : '';
|
|
2891
|
+
var line = words.concat(proj ? [proj] : []).join(', ');
|
|
2892
|
+
var failedWord = st === 'error' ? words[0] : '';
|
|
2893
|
+
var lineHtml = failedWord ? '<b class="failed">' + esc(failedWord) + '</b>' + esc(line.slice(failedWord.length)) : esc(line);
|
|
2894
|
+
var match = hit ? (hit.match === 'both' ? 'Keyword and meaning' : hit.match === 'semantic' ? 'Meaning' : 'Keyword') : '';
|
|
2187
2895
|
// An instant title hit's snippet is the title itself; it says nothing twice.
|
|
2188
2896
|
var snippet = hit && hit.snippet ? plain(hit.snippet).replace(/\s+/g, ' ').trim() : '';
|
|
2189
|
-
return '<
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
'
|
|
2194
|
-
|
|
2897
|
+
return '<div class="sx-r full' + edge + '" data-session="' + id + '"' + (selected ? ' aria-selected="true"' : '') + '><span class="sx-r-edge" aria-hidden="true"></span>' +
|
|
2898
|
+
'<button type="button" class="sx-r-open" data-session="' + id + '"' + (selected ? ' aria-selected="true"' : '') + '>' + head +
|
|
2899
|
+
'<span class="sx-r-sub">' + sxGlyph(key.provider) + (line ? '<span class="t">' + lineHtml + '</span>' : '') + (match ? '<span class="sx-tag">' + match + '</span>' : '') + '</span>' +
|
|
2900
|
+
(snippet && snippet !== title ? '<span class="sx-r-snip">' + esc(snippet) + '</span>' : '') +
|
|
2901
|
+
'</button>' + more + '</div>';
|
|
2902
|
+
}
|
|
2903
|
+
|
|
2904
|
+
// Groups, top to bottom; a session shows once. Needs you is what Today's needsModel counts for a session: waiting, or
|
|
2905
|
+
// a question this page holds for it; plus failed (DESIGN_PLAN v2). Working now is running. Pinned is an agent's own
|
|
2906
|
+
// pin or a COS pin. Then by day of the sort's time: Today, Yesterday, Earlier this week as full rows; Last week and
|
|
2907
|
+
// Earlier this month as compact rows, folded shut with a count. Days are counted back from today (2 to 6 days ago is
|
|
2908
|
+
// Earlier this week, 7 to 13 Last week, 14 to 30 Earlier this month), so no group is empty only because of the
|
|
2909
|
+
// calendar.
|
|
2910
|
+
var SX_GROUPS = [['needs', 'Needs you', 'state'], ['working', 'Working now', 'state'], ['pinned', 'Pinned', ''], ['today', 'Today', ''],
|
|
2911
|
+
['yesterday', 'Yesterday', ''], ['week', 'Earlier this week', ''], ['lastweek', 'Last week', 'compact'], ['month', 'Earlier this month', 'compact']];
|
|
2912
|
+
var SX_DAY_MS = 86400000, SX_REST_MS = 2000, SX_EMPTY_HOLD_MS = 5000;
|
|
2913
|
+
// place: each row's group as last drawn at rest; seen: when Needs you and Working now last had rows; open: unfolded
|
|
2914
|
+
// compact groups; older: /older's short rows (olderOk false: an older server, 404).
|
|
2915
|
+
var SXL = { place: null, down: false, touchAt: 0, scrollAt: 0, timer: 0, seen: {}, open: {}, older: [], olderN: null, olderOk: null, olderNext: null, olderAt: 0, olderBusy: false };
|
|
2916
|
+
function sxRowKey(s) { var k = splitSessionId(s.provider, s.session_id || s.id); return k.provider + '|' + String(k.id).toLowerCase(); }
|
|
2917
|
+
function sxDayStart(t) { var d = new Date(t); d.setHours(0, 0, 0, 0); return d.getTime(); }
|
|
2918
|
+
function sxSortTime(s) { return SX.clock === 'opened' ? (sxTime(s.created) || sxTime(s.modified)) : sxTime(s.modified); }
|
|
2919
|
+
function sxHeldKeys() {
|
|
2920
|
+
var qs = typeof sessionQs === 'function' ? sessionQs() : null, out = {};
|
|
2921
|
+
(qs && qs.itemsFor ? qs.itemsFor('') : []).forEach(function (it) { var k = splitSessionId(it.provider, it.sessionId); if (k.id) out[k.provider + '|' + String(k.id).toLowerCase()] = 1; });
|
|
2922
|
+
return out;
|
|
2923
|
+
}
|
|
2924
|
+
function sxGroupFor(s, now, held) {
|
|
2925
|
+
if (s.form !== 'short') {
|
|
2926
|
+
var st = sxState(s);
|
|
2927
|
+
if (st === 'waiting' || st === 'error' || held[sxRowKey(s)]) return 'needs';
|
|
2928
|
+
if (st === 'running') return 'working';
|
|
2929
|
+
}
|
|
2930
|
+
if (s.pinned || s.cos_pinned) return 'pinned';
|
|
2931
|
+
if (s.folder_id && typeof SXF === 'object' && SXF.byId[s.folder_id]) return 'f:' + s.folder_id;
|
|
2932
|
+
var t = sxSortTime(s), today = sxDayStart(now);
|
|
2933
|
+
if (t >= today) return 'today';
|
|
2934
|
+
if (t >= today - SX_DAY_MS) return 'yesterday';
|
|
2935
|
+
if (t >= today - 6 * SX_DAY_MS) return 'week';
|
|
2936
|
+
if (t >= today - 13 * SX_DAY_MS) return 'lastweek';
|
|
2937
|
+
return 'month';
|
|
2938
|
+
}
|
|
2939
|
+
// The main list's rows, then /older's short rows it does not have (the main row wins on a repeat).
|
|
2940
|
+
function sxAllRows() {
|
|
2941
|
+
var main = sxReal(), have = {};
|
|
2942
|
+
main.forEach(function (s) { have[sxRowKey(s)] = 1; });
|
|
2943
|
+
return main.concat((SXL.older || []).filter(function (s) { return !sxKeepWarm(s) && !have[sxRowKey(s)]; }));
|
|
2944
|
+
}
|
|
2945
|
+
// At rest: nothing pressed, no touch or scroll in the last SX_REST_MS, and no row holding keyboard focus.
|
|
2946
|
+
function sxListAtRest() {
|
|
2947
|
+
if (SXL.down) return false;
|
|
2948
|
+
var now = Date.now();
|
|
2949
|
+
if (now - SXL.touchAt < SX_REST_MS || now - SXL.scrollAt < SX_REST_MS) return false;
|
|
2950
|
+
var a = document.activeElement;
|
|
2951
|
+
return !(a && a.closest && a.closest('#sessions-list [data-session]'));
|
|
2952
|
+
}
|
|
2953
|
+
function sxRegroupLater(ms) {
|
|
2954
|
+
if (SXL.timer) return;
|
|
2955
|
+
SXL.timer = setTimeout(function () {
|
|
2956
|
+
SXL.timer = 0;
|
|
2957
|
+
if (state.pane !== 'sessions' || SX.view !== 'sessions' || sxQueryActive()) return;
|
|
2958
|
+
if (sxListAtRest()) renderSessionGroups(); else sxRegroupLater();
|
|
2959
|
+
}, ms || SX_REST_MS + 50);
|
|
2960
|
+
}
|
|
2961
|
+
// A group's row form: compact in Last week and Earlier this month, full under Needs you and Working now, and elsewhere
|
|
2962
|
+
// short when the row is one of /older's short rows (the same rule sxListHtml draws with).
|
|
2963
|
+
function sxGroupCompact(g, s) {
|
|
2964
|
+
for (var i = 0; i < SX_GROUPS.length; i++) if (SX_GROUPS[i][0] === g) return SX_GROUPS[i][2] === 'compact' ? true : SX_GROUPS[i][2] === 'state' ? false : s.form === 'short';
|
|
2965
|
+
return s.form === 'short';
|
|
2966
|
+
}
|
|
2967
|
+
// A live state change (liveSessionRows' paint without a structural change, Q1 07:22 FIX 1): the row is redrawn where
|
|
2968
|
+
// it was placed, in that group's form, and when its group would now differ (Working now to Needs you, say) the list
|
|
2969
|
+
// asks for a regroup, which the rest rule applies (at once at rest, held while the list is pressed, scrolled or focused).
|
|
2970
|
+
function sxLiveRow(s, amb) {
|
|
2971
|
+
var k = sxRowKey(s), placed = SXL.place ? SXL.place[k] : null, want = sxGroupFor(s, Date.now(), sxHeldKeys());
|
|
2972
|
+
return { html: sxRow(s, null, amb, sxGroupCompact(placed || want, s)), moved: !!placed && placed !== want };
|
|
2195
2973
|
}
|
|
2196
2974
|
function sxListHtml() {
|
|
2197
2975
|
if (!SX.rows) return SX.error ? sxEmpty(SX.error) : skeletonRows(6);
|
|
2198
2976
|
if (!SX.enabled && !SX.rows.length) return sxEmpty('AI sessions are off. Turn on Show AI sessions in Settings, Work and agents.');
|
|
2199
|
-
var
|
|
2200
|
-
if (!
|
|
2201
|
-
var
|
|
2202
|
-
|
|
2977
|
+
var all = sxAllRows();
|
|
2978
|
+
if (!all.length) return sxEmpty(SX.error || sxEmptyCopy());
|
|
2979
|
+
var now = Date.now(), held = sxHeldKeys(), want = {}, by = {}, moves = 0;
|
|
2980
|
+
all.forEach(function (s) { want[sxRowKey(s)] = sxGroupFor(s, now, held); });
|
|
2981
|
+
// A row changes group only at rest; held moves apply together on the next rest (sxRegroupLater).
|
|
2982
|
+
if (!SXL.place || sxListAtRest()) SXL.place = want;
|
|
2983
|
+
else Object.keys(want).forEach(function (k) { if (!(k in SXL.place)) SXL.place[k] = want[k]; else if (SXL.place[k] !== want[k]) moves++; });
|
|
2984
|
+
if (moves) sxRegroupLater();
|
|
2985
|
+
all.forEach(function (s) { var k = sxRowKey(s), g = SXL.place[k] || want[k]; (by[g] = by[g] || []).push(s); });
|
|
2986
|
+
var amb = sxAmbiguous(all.filter(function (s) { return s.form !== 'short'; }));
|
|
2987
|
+
var html = '', holdUntil = 0;
|
|
2988
|
+
// Folders sit after Pinned, each a heading that folds (open by default), with its own menu; New folder's field
|
|
2989
|
+
// opens at the top of this area.
|
|
2990
|
+
var groups = [];
|
|
2991
|
+
SX_GROUPS.forEach(function (g) {
|
|
2992
|
+
groups.push(g);
|
|
2993
|
+
if (g[0] === 'pinned' && typeof SXF === 'object' && SXF.ok) {
|
|
2994
|
+
if (SXF.newOpen) groups.push(['newfolder', '', 'form']);
|
|
2995
|
+
SXF.folders.forEach(function (f) { groups.push(['f:' + f.id, f.name, 'folder']); });
|
|
2996
|
+
}
|
|
2997
|
+
});
|
|
2998
|
+
groups.forEach(function (g) {
|
|
2999
|
+
if (g[2] === 'form') { html += '<section class="sx-g sx-g-form">' + sxInlineHtml('new-folder', '', 'New folder', '', '') + '</section>'; return; }
|
|
3000
|
+
if (g[2] === 'folder') {
|
|
3001
|
+
var fid = g[0].slice(2), frows = (by[g[0]] || []).sort(function (a, b) { return sxSortTime(b) - sxSortTime(a); }), fopen = SXL.open[g[0]] !== false;
|
|
3002
|
+
if (SXF.folderRenaming === fid) { html += '<section class="sx-g" data-g="' + esc(g[0]) + '">' + sxInlineHtml('folder-rename', fid, 'Rename folder', g[1], '') + '</section>'; return; }
|
|
3003
|
+
html += '<section class="sx-g sx-folder" data-g="' + esc(g[0]) + '"><div class="sx-gh-row"><button type="button" class="sx-gh" data-sx="fold" data-g="' + esc(g[0]) + '" aria-expanded="' + fopen + '">' +
|
|
3004
|
+
'<span class="fold"><span class="chev" aria-hidden="true"></span><span class="nm">' + esc(g[1]) + '</span></span><span class="n">' + frows.length + '</span></button>' +
|
|
3005
|
+
'<button type="button" class="sx-r-more" data-sx="folder-menu" data-id="' + esc(fid) + '" aria-haspopup="menu" aria-expanded="false" aria-label="' + esc('Folder menu for ' + g[1]) + '"><span aria-hidden="true">\u2026</span></button></div>' +
|
|
3006
|
+
(fopen ? (frows.length ? '<div class="sx-rows">' + frows.map(function (s) { return sxRow(s, null, amb, s.form === 'short'); }).join('') + '</div>'
|
|
3007
|
+
: '<p class="sx-empty-folder">Nothing here yet. Move a session in from its menu.</p>') : '') + '</section>';
|
|
3008
|
+
return;
|
|
3009
|
+
}
|
|
3010
|
+
var id = g[0], rows = (by[id] || []).sort(function (a, b) { return sxSortTime(b) - sxSortTime(a); }), n = rows.length;
|
|
3011
|
+
if (g[2] === 'state') {
|
|
3012
|
+
if (n) SXL.seen[id] = now;
|
|
3013
|
+
else if (SXL.seen[id] && now - SXL.seen[id] < SX_EMPTY_HOLD_MS) holdUntil = Math.max(holdUntil, SXL.seen[id] + SX_EMPTY_HOLD_MS);
|
|
3014
|
+
else return;
|
|
3015
|
+
html += '<section class="sx-g" data-g="' + id + '"><h3 class="sx-gh state ' + id + '"><span>' + g[1] + '</span><span class="n">' + n + '</span></h3>' +
|
|
3016
|
+
'<div class="sx-rows">' + rows.map(function (s) { return sxRow(s, null, amb); }).join('') + '</div></section>';
|
|
3017
|
+
return;
|
|
3018
|
+
}
|
|
3019
|
+
if (!n) return;
|
|
3020
|
+
if (g[2] === 'compact') {
|
|
3021
|
+
var open = !!SXL.open[id];
|
|
3022
|
+
html += '<section class="sx-g" data-g="' + id + '"><button type="button" class="sx-gh" data-sx="fold" data-g="' + id + '" aria-expanded="' + open + '">' +
|
|
3023
|
+
'<span class="fold"><span class="chev" aria-hidden="true"></span>' + g[1] + '</span><span class="n">' + n + '</span></button>' +
|
|
3024
|
+
(open ? '<div class="sx-rows">' + rows.map(function (s) { return sxRow(s, null, amb, true); }).join('') + '</div>' +
|
|
3025
|
+
(id === 'month' && SXL.olderNext ? '<p class="sx-more-older"><button type="button" class="cx-btn quiet" data-sx="older-more">Show older</button></p>' : '') : '') + '</section>';
|
|
3026
|
+
return;
|
|
3027
|
+
}
|
|
3028
|
+
html += '<section class="sx-g" data-g="' + id + '"><h3 class="sx-gh"><span>' + g[1] + '</span><span class="n">' + n + '</span></h3>' +
|
|
3029
|
+
'<div class="sx-rows">' + rows.map(function (s) { return sxRow(s, null, amb, s.form === 'short'); }).join('') + '</div></section>';
|
|
3030
|
+
});
|
|
3031
|
+
// A Needs you or Working now heading at 0 stays for SX_EMPTY_HOLD_MS, then the list draws again without it.
|
|
3032
|
+
if (holdUntil) sxRegroupLater(holdUntil - now + 20);
|
|
3033
|
+
return '<div class="sx-list sx-groups">' + html + '</div>';
|
|
3034
|
+
}
|
|
3035
|
+
// The list keeps its place across a redraw: the top visible row stays where it was in the list's own scroll.
|
|
3036
|
+
function sxAnchor(col) {
|
|
3037
|
+
if (!col || !col.getBoundingClientRect) return null;
|
|
3038
|
+
var top = col.getBoundingClientRect().top, rows = col.querySelectorAll('.sx-r[data-session]');
|
|
3039
|
+
for (var i = 0; i < rows.length; i++) { var b = rows[i].getBoundingClientRect(); if (b.bottom > top) return { key: rows[i].getAttribute('data-session'), top: b.top }; }
|
|
3040
|
+
return null;
|
|
3041
|
+
}
|
|
3042
|
+
function sxRestoreAnchor(col, a) {
|
|
3043
|
+
if (!col || !a) return;
|
|
3044
|
+
var rows = col.querySelectorAll('.sx-r[data-session]');
|
|
3045
|
+
for (var i = 0; i < rows.length; i++) if (rows[i].getAttribute('data-session') === a.key) { col.scrollTop += rows[i].getBoundingClientRect().top - a.top; return; }
|
|
3046
|
+
}
|
|
3047
|
+
// GET /api/agent-sessions/older (server step 2a): the short rows for days 8 to 30 and the kept ones, newest first,
|
|
3048
|
+
// paged; read when the list loads (at most once a minute) and for Show older, never on the poll. An older server
|
|
3049
|
+
// answers 404: the list stays at 7 days and says so.
|
|
3050
|
+
var SX_OLDER_MS = 60000;
|
|
3051
|
+
function sxLoadOlder(force, more) {
|
|
3052
|
+
if (SXL.olderBusy || SXL.olderOk === false) return Promise.resolve();
|
|
3053
|
+
if (!more && !force && SXL.olderAt && Date.now() - SXL.olderAt < SX_OLDER_MS) return Promise.resolve();
|
|
3054
|
+
SXL.olderBusy = true;
|
|
3055
|
+
var q = '?days=30&limit=200' + (more && SXL.olderNext ? '&before=' + encodeURIComponent(SXL.olderNext) : '');
|
|
3056
|
+
return api('/api/agent-sessions/older' + q).then(function (d) {
|
|
3057
|
+
var rows = (d && Array.isArray(d.sessions) ? d.sessions : []).map(function (x) { return Object.assign({}, x, { form: 'short' }); });
|
|
3058
|
+
SXL.older = more ? (SXL.older || []).concat(rows) : rows;
|
|
3059
|
+
if (!more && typeof d.older_than_window === 'number') SXL.olderN = d.older_than_window;
|
|
3060
|
+
SXL.olderNext = d && d.next_before ? d.next_before : null;
|
|
3061
|
+
SXL.olderOk = true; SXL.olderAt = Date.now();
|
|
3062
|
+
}, function (err) {
|
|
3063
|
+
if (err && (err.status === 404 || err.code === 'not_found')) SXL.olderOk = false;
|
|
3064
|
+
}).then(function () { SXL.olderBusy = false; if (state.pane === 'sessions' && SX.view === 'sessions' && !sxQueryActive()) renderSessionGroups(); });
|
|
3065
|
+
}
|
|
3066
|
+
// The one quiet line at the top of the list: how far back it reaches and how many more are found by search.
|
|
3067
|
+
function sxQuietLine() {
|
|
3068
|
+
if (SXL.olderOk) return 'Last 30 days. ' + (SXL.olderN ? SXL.olderN + ' older through search.' : 'Older ones through search.');
|
|
3069
|
+
var age = sxDropped().age;
|
|
3070
|
+
return 'Last 7 days. ' + (age ? age + ' older through search.' : 'Older ones through search.');
|
|
3071
|
+
}
|
|
3072
|
+
// The sort menu: Updated or Opened; while searching, Best match or Updated.
|
|
3073
|
+
function sxPaintSort() {
|
|
3074
|
+
var sel = $('sessions-sort'); if (!sel) return;
|
|
3075
|
+
var searching = sxQueryActive();
|
|
3076
|
+
var opts = searching ? [['match', 'Best match'], ['newest', 'Updated']] : [['updated', 'Updated'], ['opened', 'Opened']];
|
|
3077
|
+
var cur = searching ? (SX.recency === 'match' ? 'match' : 'newest') : (SX.clock === 'opened' ? 'opened' : 'updated');
|
|
3078
|
+
var html = opts.map(function (o) { return '<option value="' + o[0] + '">' + o[1] + '</option>'; }).join('');
|
|
3079
|
+
if (sel.__html !== html) { sel.innerHTML = html; sel.__html = html; }
|
|
3080
|
+
sel.value = cur;
|
|
3081
|
+
}
|
|
3082
|
+
function sxPaintListHead() {
|
|
3083
|
+
var nf = $('sessions-new-folder'); if (nf) nf.classList.toggle('hidden', !(typeof SXF === 'object' && SXF.ok === true));
|
|
3084
|
+
var on = $('sessions-org-note'); if (on) { var nt = typeof SXF === 'object' ? SXF.note : ''; on.textContent = nt; on.classList.toggle('hidden', !nt); }
|
|
3085
|
+
var line = $('sessions-quiet');
|
|
3086
|
+
if (line) { var t = sxQueryActive() ? '' : sxQuietLine(); line.textContent = t; line.classList.toggle('hidden', !t); }
|
|
3087
|
+
sxPaintSort();
|
|
3088
|
+
}
|
|
3089
|
+
// The Sessions menu: Sessions, Usage, COS cost, and Refresh.
|
|
3090
|
+
function sxViewMenu(open) {
|
|
3091
|
+
var btn = $('sessions-view-btn'), menu = $('sessions-view-menu');
|
|
3092
|
+
if (!btn || !menu) return;
|
|
3093
|
+
if (open == null) open = menu.classList.contains('hidden');
|
|
3094
|
+
menu.classList.toggle('hidden', !open);
|
|
3095
|
+
btn.setAttribute('aria-expanded', String(open));
|
|
3096
|
+
if (open) { var first = menu.querySelector('[role="menuitem"]'); if (first && first.focus) first.focus(); }
|
|
3097
|
+
}
|
|
3098
|
+
function sxListWatch() {
|
|
3099
|
+
var col = $('sessions-list-col');
|
|
3100
|
+
if (!col || col.__sxWatch) return;
|
|
3101
|
+
col.__sxWatch = true;
|
|
3102
|
+
var down = function () { SXL.down = true; SXL.touchAt = Date.now(); };
|
|
3103
|
+
var up = function () { SXL.down = false; SXL.touchAt = Date.now(); sxRegroupLater(); };
|
|
3104
|
+
var moved = function () { SXL.scrollAt = Date.now(); };
|
|
3105
|
+
col.addEventListener('pointerdown', down, { passive: true });
|
|
3106
|
+
col.addEventListener('touchstart', down, { passive: true });
|
|
3107
|
+
['pointerup', 'pointercancel', 'touchend', 'touchcancel'].forEach(function (t) { col.addEventListener(t, up, { passive: true }); });
|
|
3108
|
+
col.addEventListener('scroll', moved, { passive: true });
|
|
3109
|
+
col.addEventListener('wheel', moved, { passive: true });
|
|
3110
|
+
document.addEventListener('click', function (e) {
|
|
3111
|
+
var menu = $('sessions-view-menu');
|
|
3112
|
+
if (menu && !menu.classList.contains('hidden') && !e.target.closest('#sessions-view-menu') && !e.target.closest('#sessions-view-btn')) sxViewMenu(false);
|
|
3113
|
+
});
|
|
3114
|
+
// The row and folder menu lives on <body> (outside this pane): its items are handled here; a tap outside closes it.
|
|
3115
|
+
document.addEventListener('click', function (e) {
|
|
3116
|
+
var item = e.target.closest && e.target.closest('#sessions-pop [data-sx]');
|
|
3117
|
+
if (item) { e.stopPropagation(); return sxOrgClick(item.getAttribute('data-sx'), item); }
|
|
3118
|
+
if (SXF.pop && !(e.target.closest && (e.target.closest('#sessions-pop') || e.target.closest('[data-sx="row-menu"],[data-sx="folder-menu"]')))) sxPopClose();
|
|
3119
|
+
}, true);
|
|
3120
|
+
document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && SXF.pop) { var b = SXF.pop; sxPopClose(); if (b && b.focus) b.focus(); } });
|
|
3121
|
+
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') { var menu = $('sessions-view-menu'); if (menu && !menu.classList.contains('hidden')) { sxViewMenu(false); var b = $('sessions-view-btn'); if (b) b.focus(); } } });
|
|
3122
|
+
}
|
|
3123
|
+
// ── folders, COS pins and rename (7.0.23, step 2b-2; S1's organizer, contract at 469a334) ──
|
|
3124
|
+
// One store on the Mac (GET/POST/PATCH/DELETE /api/session-folders, PATCH /api/agent-sessions/:p/:id/organizer),
|
|
3125
|
+
// shared by the phone and the Mac app. Every change is one small request; after it the folders and the list are
|
|
3126
|
+
// read again. Folder names and renamed titles are untrusted text: drawn with esc() only, never as markup, never in a
|
|
3127
|
+
// URL (S1 rule 6). An older server answers 404 to GET /api/session-folders: no More button and no New folder, and
|
|
3128
|
+
// the list is as before.
|
|
3129
|
+
var SXF = { ok: null, folders: [], byId: {}, at: 0, busy: false, note: '', newOpen: false, renaming: '', folderRenaming: '', armDelete: '', pop: null };
|
|
3130
|
+
var SXF_REASONS = {
|
|
3131
|
+
folder_name_taken: 'A folder already has that name.', invalid_name: 'A folder name is 1 to 60 characters.', too_many_folders: 'That is the most folders this Mac keeps (200).',
|
|
3132
|
+
invalid_title: 'A title is 1 to 120 characters.', alias_conflict: 'Claude is still settling which session this is. Try again in a moment.',
|
|
3133
|
+
session_not_found: 'This session is no longer on the Mac.', folder_not_found: 'That folder is gone. The list has been read again.',
|
|
3134
|
+
too_many_entries: 'This Mac keeps folders, pins and titles for 5,000 sessions at most.', organizer_unavailable: 'Folders, pins and titles are not available on the Mac right now.'
|
|
3135
|
+
};
|
|
3136
|
+
function sxOrgWords(err) { var c = err && (err.code || ''); return SXF_REASONS[c] || 'That did not save. Check that the Mac is awake, then try again.'; }
|
|
3137
|
+
function sxLoadFolders(force) {
|
|
3138
|
+
if (SXF.ok === false || SXF.busy) return Promise.resolve();
|
|
3139
|
+
if (!force && SXF.at && Date.now() - SXF.at < SX_OLDER_MS) return Promise.resolve();
|
|
3140
|
+
SXF.busy = true;
|
|
3141
|
+
return api('/api/session-folders').then(function (d) {
|
|
3142
|
+
SXF.folders = (d && Array.isArray(d.folders) ? d.folders : []).filter(function (f) { return f && typeof f.id === 'string'; });
|
|
3143
|
+
SXF.byId = {}; SXF.folders.forEach(function (f) { SXF.byId[f.id] = f; });
|
|
3144
|
+
SXF.ok = true; SXF.at = Date.now();
|
|
3145
|
+
}, function (err) { if (err && err.status === 404) SXF.ok = false; }).then(function () {
|
|
3146
|
+
SXF.busy = false;
|
|
3147
|
+
if (state.pane === 'sessions' && SX.view === 'sessions' && !sxQueryActive()) renderSessionGroups();
|
|
3148
|
+
});
|
|
3149
|
+
}
|
|
3150
|
+
// A move the person asked for (pin, folder, rename, a deleted folder) applies at once, like a sort change: the rest
|
|
3151
|
+
// rule holds only moves the person did not make.
|
|
3152
|
+
function sxOrgDone(note) {
|
|
3153
|
+
SXF.note = note || ''; SXF.at = 0; SXL.place = null;
|
|
3154
|
+
// A list read already in flight started before this change: wait for it, then read again, so the list shows it.
|
|
3155
|
+
var settled = function () { return SL.inflight ? SL.inflight.then(function () {}, function () {}) : Promise.resolve(); };
|
|
3156
|
+
// The placement is reset once the new list is in (a redraw in between, with the old rows, would use it up).
|
|
3157
|
+
return sxLoadFolders(true).then(settled).then(function () { return loadSessions(true); }).then(function () { SXL.place = null; renderSessionGroups(); });
|
|
3158
|
+
}
|
|
3159
|
+
function sxOrgFail(err) { SXF.note = sxOrgWords(err); sxPaintListHead(); }
|
|
3160
|
+
// A text field inside the list keeps the list from redrawing over what is being typed.
|
|
3161
|
+
function sxEditing() { var a = document.activeElement; return !!(a && a.closest && a.closest('#sessions-list form.sx-inline')); }
|
|
3162
|
+
function sxRowByKey(key) { return sxAllRows().filter(function (s) { return sxRowKey(s) === String(key).toLowerCase(); })[0] || null; }
|
|
3163
|
+
function sxOrgPatch(key, body) {
|
|
3164
|
+
var s = sxRowByKey(key); if (!s) return Promise.resolve();
|
|
3165
|
+
var k = splitSessionId(s.provider, s.session_id || s.id);
|
|
3166
|
+
return api('/api/agent-sessions/' + encodeURIComponent(k.provider) + '/' + encodeURIComponent(k.id) + '/organizer', { method: 'PATCH', body: body });
|
|
3167
|
+
}
|
|
3168
|
+
function sxInlineHtml(kind, id, label, value, hint) {
|
|
3169
|
+
return '<form class="sx-inline" data-sx-form="' + kind + '" data-id="' + esc(id) + '"><label class="sx-inline-label" for="sx-inline-' + kind + '">' + esc(label) + '</label>' +
|
|
3170
|
+
'<input class="field" id="sx-inline-' + kind + '" maxlength="' + (kind === 'rename' ? 120 : 60) + '" autocomplete="off" enterkeyhint="done" value="' + esc(value || '') + '">' +
|
|
3171
|
+
(hint ? '<p class="sx-inline-hint">' + esc(hint) + '</p>' : '') +
|
|
3172
|
+
'<div class="sx-inline-acts"><button type="submit" class="cx-btn primary">' + (kind === 'new-folder' ? 'Create' : 'Save') + '</button>' +
|
|
3173
|
+
(kind === 'rename' && value ? '<button type="button" class="cx-btn quiet" data-sx="rename-clear" data-id="' + esc(id) + '">Use the agent’s title</button>' : '') +
|
|
3174
|
+
'<button type="button" class="cx-btn quiet" data-sx="inline-cancel">Cancel</button></div></form>';
|
|
3175
|
+
}
|
|
3176
|
+
// The row's menu: Pin or Unpin (a COS pin); an agent's own pin is said and has no Unpin; Move to folder; Rename.
|
|
3177
|
+
function sxRowMenuHtml(s, key, moving) {
|
|
3178
|
+
var k = splitSessionId(s.provider, s.session_id || s.id);
|
|
3179
|
+
if (moving) {
|
|
3180
|
+
var cur = s.folder_id || '';
|
|
3181
|
+
return (SXF.folders.length ? SXF.folders.map(function (f) {
|
|
3182
|
+
return '<button type="button" role="menuitemradio" aria-checked="' + (cur === f.id) + '" data-sx="org-move" data-key="' + esc(key) + '" data-folder="' + esc(f.id) + '">' + esc(f.name) + '</button>';
|
|
3183
|
+
}).join('') : '<p class="sx-pop-note">No folders yet. Make one with New folder at the top of the list.</p>') +
|
|
3184
|
+
'<button type="button" role="menuitemradio" aria-checked="' + (!cur) + '" data-sx="org-move" data-key="' + esc(key) + '" data-folder="">No folder</button>';
|
|
3185
|
+
}
|
|
3186
|
+
// An agent's own pin is not COS's to undo; a COS pin under it still is (Q1 07:22 a).
|
|
3187
|
+
return (s.pinned ? '<p class="sx-pop-note">Pinned in ' + sxProviderLabel(k.provider) + '</p>' +
|
|
3188
|
+
(s.cos_pinned ? '<button type="button" role="menuitem" data-sx="org-pin" data-key="' + esc(key) + '">Unpin in COS</button>' : '')
|
|
3189
|
+
: '<button type="button" role="menuitem" data-sx="org-pin" data-key="' + esc(key) + '">' + (s.cos_pinned ? 'Unpin' : 'Pin') + '</button>') +
|
|
3190
|
+
'<button type="button" role="menuitem" data-sx="org-move-menu" data-key="' + esc(key) + '">Move to folder</button>' +
|
|
3191
|
+
'<button type="button" role="menuitem" data-sx="org-rename" data-key="' + esc(key) + '">Rename</button>';
|
|
3192
|
+
}
|
|
3193
|
+
function sxFolderMenuHtml(id) {
|
|
3194
|
+
return '<button type="button" role="menuitem" data-sx="folder-rename" data-id="' + esc(id) + '">Rename</button>' +
|
|
3195
|
+
'<button type="button" role="menuitem" data-sx="folder-delete" data-id="' + esc(id) + '">' + (SXF.armDelete === id ? 'Tap again to delete the folder. Its sessions go back to their day group.' : 'Delete folder') + '</button>';
|
|
3196
|
+
}
|
|
3197
|
+
// One small menu at a time, under its button, inside the visible frame; Escape or a tap outside closes it.
|
|
3198
|
+
function sxPopClose() {
|
|
3199
|
+
var pop = $('sessions-pop'); if (pop) { pop.classList.add('hidden'); pop.innerHTML = ''; }
|
|
3200
|
+
if (SXF.pop && SXF.pop.setAttribute) SXF.pop.setAttribute('aria-expanded', 'false');
|
|
3201
|
+
SXF.pop = null;
|
|
3202
|
+
}
|
|
3203
|
+
function sxPopOpen(btn, html, label) {
|
|
3204
|
+
var pop = $('sessions-pop');
|
|
3205
|
+
if (!pop) { pop = document.createElement('div'); pop.id = 'sessions-pop'; pop.className = 'sx-menu sx-pop hidden'; pop.setAttribute('role', 'menu'); document.body.appendChild(pop); }
|
|
3206
|
+
if (SXF.pop && SXF.pop !== btn && SXF.pop.setAttribute) SXF.pop.setAttribute('aria-expanded', 'false');
|
|
3207
|
+
pop.setAttribute('aria-label', label || 'Menu');
|
|
3208
|
+
pop.innerHTML = html; pop.classList.remove('hidden');
|
|
3209
|
+
SXF.pop = btn; btn.setAttribute('aria-expanded', 'true');
|
|
3210
|
+
var r = btn.getBoundingClientRect(), w = pop.offsetWidth || 220, h = pop.offsetHeight || 160, vw = window.innerWidth || 0, vh = window.innerHeight || 0;
|
|
3211
|
+
pop.style.left = Math.max(8, Math.min(r.right - w, vw - w - 8)) + 'px';
|
|
3212
|
+
pop.style.top = (r.bottom + 4 + h > vh - 8 ? Math.max(8, r.top - 4 - h) : r.bottom + 4) + 'px';
|
|
3213
|
+
var first = pop.querySelector('[role^="menuitem"]'); if (first && first.focus) first.focus();
|
|
3214
|
+
}
|
|
3215
|
+
function sxOrgClick(act, t) {
|
|
3216
|
+
var key = t.getAttribute('data-key') || '', id = t.getAttribute('data-id') || '', s = key ? sxRowByKey(key) : null;
|
|
3217
|
+
if (act === 'row-menu') { if (SXF.pop === t) return sxPopClose(); return s ? sxPopOpen(t, sxRowMenuHtml(s, key, false), 'Session menu') : null; }
|
|
3218
|
+
if (act === 'org-move-menu') { var b = SXF.pop; return s && b ? sxPopOpen(b, sxRowMenuHtml(s, key, true), 'Move to folder') : null; }
|
|
3219
|
+
if (act === 'folder-menu') { SXF.armDelete = ''; if (SXF.pop === t) return sxPopClose(); return sxPopOpen(t, sxFolderMenuHtml(id), 'Folder menu'); }
|
|
3220
|
+
if (act === 'org-pin' && s) { sxPopClose(); return sxOrgPatch(key, { cos_pinned: !s.cos_pinned }).then(function () { return sxOrgDone(); }, sxOrgFail); }
|
|
3221
|
+
if (act === 'org-move' && s) { sxPopClose(); var f = t.getAttribute('data-folder') || null; return sxOrgPatch(key, { folder_id: f }).then(function () { return sxOrgDone(); }, sxOrgFail); }
|
|
3222
|
+
if (act === 'org-rename') { sxPopClose(); SXF.renaming = key; SXF.note = ''; renderSessionGroups(); var i = $('sx-inline-rename'); if (i) { i.focus(); i.select && i.select(); } return; }
|
|
3223
|
+
if (act === 'rename-clear') { SXF.renaming = ''; return sxOrgPatch(id, { title: null }).then(function () { return sxOrgDone(); }, sxOrgFail); }
|
|
3224
|
+
if (act === 'new-folder') { SXF.newOpen = true; SXF.note = ''; renderSessionGroups(); var n = $('sx-inline-new-folder'); if (n) n.focus(); return; }
|
|
3225
|
+
if (act === 'folder-rename') { sxPopClose(); SXF.folderRenaming = id; SXF.note = ''; renderSessionGroups(); var fr = $('sx-inline-folder-rename'); if (fr) { fr.focus(); fr.select && fr.select(); } return; }
|
|
3226
|
+
if (act === 'folder-delete') {
|
|
3227
|
+
if (SXF.armDelete !== id) { SXF.armDelete = id; if (SXF.pop) sxPopOpen(SXF.pop, sxFolderMenuHtml(id), 'Folder menu'); return; }
|
|
3228
|
+
SXF.armDelete = ''; sxPopClose();
|
|
3229
|
+
return api('/api/session-folders/' + encodeURIComponent(id), { method: 'DELETE' }).then(function () { return sxOrgDone(); }, sxOrgFail);
|
|
3230
|
+
}
|
|
3231
|
+
if (act === 'inline-cancel') { SXF.newOpen = false; SXF.renaming = ''; SXF.folderRenaming = ''; var a = document.activeElement; if (a && a.blur) a.blur(); return renderSessionGroups(); }
|
|
3232
|
+
}
|
|
3233
|
+
function sxOrgSubmit(form) {
|
|
3234
|
+
var kind = form.getAttribute('data-sx-form'), id = form.getAttribute('data-id') || '', input = form.querySelector('input');
|
|
3235
|
+
var v = input ? String(input.value || '').trim() : '';
|
|
3236
|
+
if (kind === 'new-folder') {
|
|
3237
|
+
if (!v) { SXF.note = SXF_REASONS.invalid_name; return sxPaintListHead(); }
|
|
3238
|
+
return api('/api/session-folders', { method: 'POST', body: { name: v } }).then(function () { SXF.newOpen = false; if (input) input.blur(); return sxOrgDone(); }, sxOrgFail);
|
|
3239
|
+
}
|
|
3240
|
+
if (kind === 'folder-rename') {
|
|
3241
|
+
if (!v) { SXF.note = SXF_REASONS.invalid_name; return sxPaintListHead(); }
|
|
3242
|
+
return api('/api/session-folders/' + encodeURIComponent(id), { method: 'PATCH', body: { name: v } }).then(function () { SXF.folderRenaming = ''; if (input) input.blur(); return sxOrgDone(); }, sxOrgFail);
|
|
3243
|
+
}
|
|
3244
|
+
if (kind === 'rename') {
|
|
3245
|
+
return sxOrgPatch(id, { title: v || null }).then(function () { SXF.renaming = ''; if (input) input.blur(); return sxOrgDone(); }, sxOrgFail);
|
|
3246
|
+
}
|
|
2203
3247
|
}
|
|
2204
3248
|
// The list, under the hero. The name stays: the shared input handler calls it when the search clears.
|
|
2205
3249
|
function renderSessionGroups() {
|
|
@@ -2214,8 +3258,14 @@
|
|
|
2214
3258
|
var note = sxQueryActive() && SX.ranFor === SX.q && SX.semantic === false ? sxSemanticHint(SX.semanticReason) : '';
|
|
2215
3259
|
hint.textContent = note; hint.classList.toggle('hidden', !note);
|
|
2216
3260
|
}
|
|
3261
|
+
sxListWatch();
|
|
3262
|
+
sxPaintListHead();
|
|
2217
3263
|
var list = $('sessions-list'); if (!list) return;
|
|
3264
|
+
// A field in the list being typed in (New folder, a rename) is never drawn over; the list waits for it.
|
|
3265
|
+
if (!sxQueryActive() && typeof sxEditing === 'function' && sxEditing()) { sxRegroupLater(); return; }
|
|
3266
|
+
var col = $('sessions-list-col'), at = sxQueryActive() ? null : sxAnchor(col);
|
|
2218
3267
|
list.innerHTML = sxQueryActive() ? sxSearchHtml() : sxListHtml();
|
|
3268
|
+
sxRestoreAnchor(col, at);
|
|
2219
3269
|
}
|
|
2220
3270
|
|
|
2221
3271
|
// Search (ActivityWindow.swift sessionsSearchBar 2652-2712, ControllerModel scheduleSessionSearch):
|
|
@@ -2322,20 +3372,84 @@
|
|
|
2322
3372
|
renderSessionGroups();
|
|
2323
3373
|
});
|
|
2324
3374
|
}
|
|
2325
|
-
//
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
3375
|
+
// ── Meaning search (W1, 7.0.x): "Find a session about this", as COS Control's command panel does it
|
|
3376
|
+
// (SetupAssistant.swift 1476-1900). POST /api/agent-sessions/find takes exactly { query, consentedProvider, stage }
|
|
3377
|
+
// (routes/session-find.ts:45-51). Nothing is sent before the person says yes for the decision model that decides search
|
|
3378
|
+
// now. A yes for Jev is no yes for SubQ. The yes is kept in this browser only (the Mac keeps its own).
|
|
3379
|
+
var MEANING_STORE = 'cos.web.meaningConsent';
|
|
3380
|
+
var MEANING_NAME = { jev: 'Jev', subq: 'SubQ' };
|
|
3381
|
+
var MEANING_TITLE = { consent_required: 'Meaning search needs your okay', provider_changed: 'The decision model changed',
|
|
3382
|
+
decision_not_configured: 'Meaning search needs a key', jev_not_configured: 'Meaning search needs a key', sessions_off: 'Sessions are off',
|
|
3383
|
+
find_off: 'Meaning search is off', cap_reached: 'Meaning search is used up for today', breaker_open: 'Meaning search is paused',
|
|
3384
|
+
query_too_short: 'Type a few more words', failed: 'Meaning search did not answer' };
|
|
3385
|
+
var MEANING_STRENGTH = { strong: 'Strong match', likely: 'Likely match', possible: 'Possible match' };
|
|
3386
|
+
var MEANING = { view: null, at: 0 };
|
|
3387
|
+
function meaningChoices() {
|
|
3388
|
+
try { var v = JSON.parse(localStorage.getItem(MEANING_STORE) || '{}'); return v && typeof v === 'object' && !Array.isArray(v) ? v : {}; } catch (e) { return {}; }
|
|
3389
|
+
}
|
|
3390
|
+
function meaningChoose(provider, choice) {
|
|
3391
|
+
if (!MEANING_NAME[provider]) return;
|
|
3392
|
+
var c = meaningChoices();
|
|
3393
|
+
if (choice === 'on' || choice === 'never') c[provider] = choice; else delete c[provider];
|
|
3394
|
+
try { localStorage.setItem(MEANING_STORE, JSON.stringify(c)); } catch (e) { /* private mode: the question comes back next time */ }
|
|
3395
|
+
}
|
|
3396
|
+
// GET /api/decision-provider: the model that decides search now, whether its key is saved, and whether this server can
|
|
3397
|
+
// find sessions by meaning at all (capabilities.sessionFind). Read again after a minute, or after a refusal.
|
|
3398
|
+
function meaningSettings(force) {
|
|
3399
|
+
if (!force && MEANING.view && Date.now() - MEANING.at < 60000) return Promise.resolve(MEANING.view);
|
|
3400
|
+
return api('/api/decision-provider').then(function (d) {
|
|
3401
|
+
var p = d && d.effective ? d.effective.search : null;
|
|
3402
|
+
p = MEANING_NAME[p] ? p : null;
|
|
3403
|
+
MEANING.view = { provider: p, keyed: !!(p && d.configured && d.configured[p] === true), capable: !!(d && d.capabilities && d.capabilities.sessionFind === true) };
|
|
3404
|
+
MEANING.at = Date.now();
|
|
3405
|
+
return MEANING.view;
|
|
3406
|
+
});
|
|
2338
3407
|
}
|
|
3408
|
+
function meaningConsentText(provider) {
|
|
3409
|
+
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 ' +
|
|
3410
|
+
MEANING_NAME[provider] + '. Emails, links, home-folder paths and key-shaped text are removed first. Names and other details are not.' +
|
|
3411
|
+
(provider === 'subq' ? ' SubQ keeps what it is sent.' : '');
|
|
3412
|
+
}
|
|
3413
|
+
function meaningConsentHtml(provider) {
|
|
3414
|
+
// Lead decision (11/09): the choice is kept in this browser only, and the card says so.
|
|
3415
|
+
return '<div class="list meaning-consent"><p class="empty"><strong>Find sessions by meaning with ' + esc(MEANING_NAME[provider]) + '?</strong>' + esc(meaningConsentText(provider)) +
|
|
3416
|
+
' ' + esc('Your choice applies to this browser only.') + '</p>' +
|
|
3417
|
+
'<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>' +
|
|
3418
|
+
'<button type="button" class="btn small" data-action="meaning-consent" data-provider="' + esc(provider) + '" data-choice="never">Never</button></div></div>';
|
|
3419
|
+
}
|
|
3420
|
+
function meaningRows(q, d) {
|
|
3421
|
+
var rows = (d && Array.isArray(d.rows)) ? d.rows : [];
|
|
3422
|
+
return '<h2 class="group-title"><span>About \u201c' + esc(q) + '\u201d</span><span>' + rows.length + '</span></h2><div class="list">' + rows.map(function (r) {
|
|
3423
|
+
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,
|
|
3424
|
+
modified: r.modified, last_activity_at: r.last_activity_at, alive: r.alive, pinned: r.pinned, slug: r.slug };
|
|
3425
|
+
return sessionRow(s, MEANING_STRENGTH[r.strength] || MEANING_STRENGTH.possible);
|
|
3426
|
+
}).join('') + '</div>';
|
|
3427
|
+
}
|
|
3428
|
+
function meaningRun(q, provider, stage) {
|
|
3429
|
+
return api('/api/agent-sessions/find', { method: 'POST', body: { query: q, consentedProvider: provider, stage: stage }, timeoutMs: 90000 }).then(function (d) {
|
|
3430
|
+
if (d && d.available === false) {
|
|
3431
|
+
if (d.reason === 'provider_changed') MEANING.view = null;
|
|
3432
|
+
return emptyBox(MEANING_TITLE[d.reason] || 'Meaning search did not run', d.message || '');
|
|
3433
|
+
}
|
|
3434
|
+
var rows = (d && Array.isArray(d.rows)) ? d.rows : [];
|
|
3435
|
+
// The first stage reads the newest 254 sessions; with nothing there, look once further back (Control's "Looking further back").
|
|
3436
|
+
if (!rows.length && stage === 'first') return meaningRun(q, provider, 'full');
|
|
3437
|
+
if (!rows.length) return emptyBox('Nothing close by meaning', 'Checked ' + (Number(d && d.asked) || 0) + ' sessions. Try other words, or search by words.');
|
|
3438
|
+
return meaningRows(q, d);
|
|
3439
|
+
});
|
|
3440
|
+
}
|
|
3441
|
+
function findByMeaning(q) {
|
|
3442
|
+
return meaningSettings(false).then(function (m) {
|
|
3443
|
+
if (!m.capable) return emptyBox('Meaning search is not on here', 'This COS server cannot find sessions by meaning. Search by words instead.');
|
|
3444
|
+
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.');
|
|
3445
|
+
var choice = meaningChoices()[m.provider];
|
|
3446
|
+
if (choice === 'never') return emptyBox('Meaning search is off in this browser', 'You chose Never for ' + MEANING_NAME[m.provider] + '.') +
|
|
3447
|
+
'<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>';
|
|
3448
|
+
if (choice !== 'on') return meaningConsentHtml(m.provider);
|
|
3449
|
+
return meaningRun(q, m.provider, 'first');
|
|
3450
|
+
}).catch(function (err) { return emptyBox('Meaning search did not run', (err && err.message) || ''); });
|
|
3451
|
+
}
|
|
3452
|
+
// ── end meaning search
|
|
2339
3453
|
|
|
2340
3454
|
// The hooks banner (ActivityWindow.swift 2867-2905). Installing hooks writes to the Mac's Claude
|
|
2341
3455
|
// settings, which stays a Mac action, so the web names where to do it.
|
|
@@ -2369,15 +3483,22 @@
|
|
|
2369
3483
|
// keep their data-action names, which the shared listener handles.
|
|
2370
3484
|
var sxPane = $('pane-sessions');
|
|
2371
3485
|
if (sxPane) {
|
|
2372
|
-
|
|
3486
|
+
sxPaintSort();
|
|
2373
3487
|
sxPane.addEventListener('click', function (e) {
|
|
2374
3488
|
var t = e.target.closest('[data-sx]');
|
|
2375
3489
|
if (!t || !sxPane.contains(t)) return;
|
|
2376
3490
|
var act = t.getAttribute('data-sx'), rd = state.reading;
|
|
2377
|
-
if (act === 'view') return setSessionsView(t.getAttribute('data-view'));
|
|
3491
|
+
if (act === 'view') { sxViewMenu(false); return setSessionsView(t.getAttribute('data-view')); }
|
|
3492
|
+
if (act === 'view-menu') return sxViewMenu();
|
|
3493
|
+
if (act === 'goto-question') { sxToNewest(); sxGoQuestionPaint(); var gq = $('session-questions'), first = gq && gq.querySelector('button, input, [tabindex]'); if (first && first.focus) first.focus({ preventScroll: true }); return; }
|
|
3494
|
+
if (act === 'fold') { var g = t.getAttribute('data-g'), isF = g.indexOf('f:') === 0; SXL.open[g] = isF ? SXL.open[g] === false : !SXL.open[g]; return renderSessionGroups(); }
|
|
3495
|
+
if (/^(row-menu|org-|folder-|new-folder|rename-clear|inline-cancel)/.test(act)) { e.stopPropagation(); return sxOrgClick(act, t); }
|
|
3496
|
+
if (act === 'older-more') return sxLoadOlder(false, true);
|
|
2378
3497
|
if (act === 'clock') { SX.clock = t.getAttribute('data-clock'); sxSave(); return renderSessionGroups(); }
|
|
2379
3498
|
if (act === 'refresh') {
|
|
3499
|
+
sxViewMenu(false);
|
|
2380
3500
|
if (SX.view !== 'sessions') return renderUsageView(true);
|
|
3501
|
+
sxLoadOlder(true);
|
|
2381
3502
|
SX.hooksAt = 0;
|
|
2382
3503
|
loadSessions(true);
|
|
2383
3504
|
if (sxQueryActive()) searchSessions();
|
|
@@ -2390,6 +3511,7 @@
|
|
|
2390
3511
|
if (act === 'retry' && rd) { rd.refusal = null; renderVerdict(rd); return sendSessionTurn(!!rd.retryFork); }
|
|
2391
3512
|
if (act === 'caution-send' && rd) { rd.cautionAck = true; rd.cautionAsk = false; renderVerdict(rd); return sendSessionTurn(false); }
|
|
2392
3513
|
if (act === 'caution-cancel' && rd) { rd.cautionAsk = false; renderVerdict(rd); return; }
|
|
3514
|
+
if (act === 'turnend' && rd) return sendTurnEndChoice(rd, Number(t.getAttribute('data-i')));
|
|
2393
3515
|
if (act === 'usage-clear') { SX.uTool = 'All tools'; SX.uModel = 'All models'; SX.uToolBefore = null; uSliceChanged(); return paintUsage(); }
|
|
2394
3516
|
if (act === 'usage-range') return uOpenRange(t, true);
|
|
2395
3517
|
});
|
|
@@ -2404,9 +3526,23 @@
|
|
|
2404
3526
|
renderSessionGroups();
|
|
2405
3527
|
SX.searchTimer = setTimeout(searchSessions, 350);
|
|
2406
3528
|
});
|
|
3529
|
+
// 7.0.23 step 2b-2: New folder and the renames are inline forms in the list.
|
|
3530
|
+
sxPane.addEventListener('submit', function (e) {
|
|
3531
|
+
var f = e.target;
|
|
3532
|
+
if (!f || !f.matches || !f.matches('form.sx-inline')) return;
|
|
3533
|
+
e.preventDefault();
|
|
3534
|
+
sxOrgSubmit(f);
|
|
3535
|
+
});
|
|
2407
3536
|
sxPane.addEventListener('change', function (e) {
|
|
2408
3537
|
var id = e.target.id;
|
|
2409
|
-
|
|
3538
|
+
// 7.0.23 step 2b: one sort menu. Updated or Opened orders the groups (the rows move together: the list redraws
|
|
3539
|
+
// at once, not on rest, because the person chose it); while searching it is Best match or Updated.
|
|
3540
|
+
if (id === 'sessions-sort') {
|
|
3541
|
+
var v = e.target.value;
|
|
3542
|
+
if (sxQueryActive()) SX.recency = v === 'match' ? 'match' : 'newest';
|
|
3543
|
+
else { SX.clock = v === 'opened' ? 'opened' : 'updated'; SXL.place = null; }
|
|
3544
|
+
sxSave(); return renderSessionGroups();
|
|
3545
|
+
}
|
|
2410
3546
|
// Custom dates opens the date sheet; the menu goes back to the range in force if it is cancelled.
|
|
2411
3547
|
if (id === 'usage-period') { if (e.target.value === U_CUSTOM) return uOpenRange(e.target, true); SX.uPeriod = e.target.value; uPeriodChanged(); return paintUsage(); }
|
|
2412
3548
|
if (id === 'usage-tool') { SX.uTool = e.target.value; SX.uModel = 'All models'; SX.uToolBefore = null; uSliceChanged(); return paintUsage(); }
|
|
@@ -2425,6 +3561,12 @@
|
|
|
2425
3561
|
// window) only means the choice lasts this visit. Below a pane's own breakpoint the divider is hidden by CSS and the
|
|
2426
3562
|
// kit does nothing (o.media), so a phone's layout never changes.
|
|
2427
3563
|
var SX_SPLIT_KEY = 'cos_web_sessions_split', SX_LIST_MIN = 240, SX_READER_MIN = 380, SX_HANDLE_W = 18, SX_KEY_STEP = 16, SX_DOUBLE_MS = 350;
|
|
3564
|
+
// 11/10 (lead 20:39): Miles squeezes the reader on his Duo's Home Screen web app and the page breaks; WebKit at scale 1
|
|
3565
|
+
// does not (cw = scw, reader floor 380). One layout report when a drag ends, so his phone says what the page did:
|
|
3566
|
+
// the split, list and reader widths, the share, clientWidth and scrollWidth, visualViewport.pageTop, pageLeft and
|
|
3567
|
+
// offsetLeft, scrollX, and the top, left and right of the rail, the list and the other column (layoutCrumb adds the
|
|
3568
|
+
// scale and offsetTop itself). Numbers only, at most SPLIT_DRAG_MAX a page load.
|
|
3569
|
+
var SPLIT_DRAG = { n: 0 }, SPLIT_DRAG_MAX = 6;
|
|
2428
3570
|
// o: key (storage), prefix (class names: <prefix>-sized, -full and -dragging), cssVar (the share, default
|
|
2429
3571
|
// --<prefix>-list-share), label (what the divider sizes, for screen readers), sized (the column the share sizes,
|
|
2430
3572
|
// default the list), side ('end' when that column is on the right: a side panel grows as the line moves left),
|
|
@@ -2460,7 +3602,7 @@
|
|
|
2460
3602
|
split.classList.toggle(PRE + '-sized', share != null);
|
|
2461
3603
|
if (share != null) split.style.setProperty(VAR, share.toFixed(2) + '%'); else split.style.removeProperty(VAR);
|
|
2462
3604
|
split.classList.toggle(PRE + '-full', full);
|
|
2463
|
-
document.querySelectorAll(FULL_SEL).forEach(function (b) { b.setAttribute('aria-pressed', String(full)); b.textContent = full ? 'Show list' : 'Full width'; });
|
|
3605
|
+
document.querySelectorAll(FULL_SEL).forEach(function (b) { b.setAttribute('aria-pressed', String(full)); b.textContent = full ? 'Show list' : (o.hideText || 'Full width'); });
|
|
2464
3606
|
aria();
|
|
2465
3607
|
}
|
|
2466
3608
|
// px: the sized column's width asked for; kept inside both columns' minimums for the split's width now.
|
|
@@ -2473,6 +3615,26 @@
|
|
|
2473
3615
|
if (persist) save();
|
|
2474
3616
|
}
|
|
2475
3617
|
function reset() { share = null; paint(); save(); }
|
|
3618
|
+
function dragReport() {
|
|
3619
|
+
if (SPLIT_DRAG.n >= SPLIT_DRAG_MAX || typeof layoutCrumb !== 'function') return;
|
|
3620
|
+
try {
|
|
3621
|
+
var de = document.documentElement, vv = window.visualViewport, t = total(), lw = listWidth(),
|
|
3622
|
+
hw = handle.getBoundingClientRect ? handle.getBoundingClientRect().width : HANDLE_W,
|
|
3623
|
+
rail = document.querySelector('.rail'), list = split.querySelector(LIST_SEL), other = null;
|
|
3624
|
+
// The other column: the split's child that is neither the sized column nor the divider.
|
|
3625
|
+
for (var c = split.firstElementChild; c; c = c.nextElementSibling) if (c !== list && c !== handle) other = c;
|
|
3626
|
+
var vn = function (k) { return vv && typeof vv[k] === 'number' ? vv[k] : -1; };
|
|
3627
|
+
var rr = rail ? rail.getBoundingClientRect() : null, lr = list ? list.getBoundingClientRect() : null, or = other ? other.getBoundingClientRect() : null;
|
|
3628
|
+
// Up and down (pt, the tops) and sideways (pl, ol, sx, the left and right edges): a rail or list that left the
|
|
3629
|
+
// screen to the side shows here too (Q1 20:44).
|
|
3630
|
+
if (layoutCrumb('split-drag', { key: String(KEY).slice(0, 24), t: t, sz: lw, rw: t - lw - hw, share: share == null ? -1 : share,
|
|
3631
|
+
cw: de.clientWidth, scw: de.scrollWidth, pt: vn('pageTop'), pl: vn('pageLeft'), ol: vn('offsetLeft'),
|
|
3632
|
+
sx: typeof window.scrollX === 'number' ? window.scrollX : -1,
|
|
3633
|
+
railTop: rr ? rr.top : -1, railL: rr ? rr.left : -1, railR: rr ? rr.right : -1,
|
|
3634
|
+
listTop: lr ? lr.top : -1, listL: lr ? lr.left : -1, listR: lr ? lr.right : -1,
|
|
3635
|
+
rdL: or ? or.left : -1, rdR: or ? or.right : -1 })) SPLIT_DRAG.n++;
|
|
3636
|
+
} catch (_e) { /* a report never stops a drag */ }
|
|
3637
|
+
}
|
|
2476
3638
|
handle.addEventListener('pointerdown', function (e) {
|
|
2477
3639
|
if (e.pointerType === 'mouse' && e.button !== 0) return;
|
|
2478
3640
|
if (narrow()) return;
|
|
@@ -2493,7 +3655,7 @@
|
|
|
2493
3655
|
handle.removeEventListener('pointermove', move); handle.removeEventListener('pointerup', end); handle.removeEventListener('pointercancel', end);
|
|
2494
3656
|
split.classList.remove(PRE + '-dragging');
|
|
2495
3657
|
// The lift's own position counts: a browser may fold the last moves into it.
|
|
2496
|
-
if (moved) { if (ev.type === 'pointerup') setWidth(start + dx(ev), false); save(); lastTap = 0; return; }
|
|
3658
|
+
if (moved) { if (ev.type === 'pointerup') setWidth(start + dx(ev), false); save(); lastTap = 0; dragReport(); return; }
|
|
2497
3659
|
if (ev.type !== 'pointerup') return;
|
|
2498
3660
|
var now = Date.now();
|
|
2499
3661
|
if (now - lastTap < SX_DOUBLE_MS) { lastTap = 0; reset(); } else lastTap = now;
|
|
@@ -2519,7 +3681,8 @@
|
|
|
2519
3681
|
};
|
|
2520
3682
|
}
|
|
2521
3683
|
// Sessions: the first pane with the divider, unchanged (its markup is in index.html, its CSS in panes/sessions.css).
|
|
2522
|
-
|
|
3684
|
+
// 7.0.23: in Sessions the button says what it does, Hide list (DESIGN_PLAN v2); Memories and Messages keep Full width.
|
|
3685
|
+
var SX_SPLIT_OPTS = { key: SX_SPLIT_KEY, prefix: 'sx', label: 'Session list', fullSel: '[data-sx="full"]', media: '(min-width: 760px)', hideText: 'Hide list',
|
|
2523
3686
|
listMin: SX_LIST_MIN, readerMin: SX_READER_MIN, handleW: SX_HANDLE_W };
|
|
2524
3687
|
function sxSplitKit(split, handle, storage) { return splitKit(split, handle, storage, SX_SPLIT_OPTS); }
|
|
2525
3688
|
// The other panes (W.splitKit): the divider's markup, a Full width button, and mount, which binds the kit to a split
|
|
@@ -2702,7 +3865,7 @@
|
|
|
2702
3865
|
// Wide layouts: give the reader the whole width, or bring the list back (sxSplitKit). Hidden under 760 px by CSS.
|
|
2703
3866
|
function sxFullButton() {
|
|
2704
3867
|
var full = !!(SX_SPLIT && SX_SPLIT.state().full);
|
|
2705
|
-
return '<button type="button" class="cx-btn quiet sx-full-btn" data-sx="full" aria-controls="sessions-list-col" aria-pressed="' + full + '">' + (full ? 'Show list' : '
|
|
3868
|
+
return '<button type="button" class="cx-btn quiet sx-full-btn" data-sx="full" aria-controls="sessions-list-col" aria-pressed="' + full + '">' + (full ? 'Show list' : 'Hide list') + '</button>';
|
|
2706
3869
|
}
|
|
2707
3870
|
function openSession(provider, id) {
|
|
2708
3871
|
var reader = $('session-reader');
|
|
@@ -2729,12 +3892,24 @@
|
|
|
2729
3892
|
reader.innerHTML =
|
|
2730
3893
|
'<div class="sx-reader-head"><div class="sx-head-line">' + sxBadge(provider) + '<h2 class="sx-reader-title">' + esc(title) + '</h2>' + sxFullButton() + '</div>' +
|
|
2731
3894
|
'<p class="sx-subtitle">' + esc(sxSubtitle(d, provider, turns, r.more)) + '</p></div>' +
|
|
3895
|
+
// 7.0.23 frame: the conversation scrolls by itself between the header and the dock.
|
|
3896
|
+
'<div class="sx-body" id="session-body">' +
|
|
3897
|
+
(trunc ? '<p class="sx-note">' + esc(trunc) + '</p>' : '') +
|
|
3898
|
+
'<div class="turns sx-turns" id="session-turns">' + renderTurns(turns, d) + '</div>' +
|
|
3899
|
+
'<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>' +
|
|
3900
|
+
'<span class="sx-foot-note">Kickstart brief for another agent. Not a Claude Code resume.</span></div>' +
|
|
3901
|
+
'<p class="sx-copy-note hidden" id="session-copy-note" role="status"></p>' +
|
|
3902
|
+
// DESIGN.md step 4 #3: this session's question cards (held and answerable, or at the Mac with its words), the
|
|
3903
|
+
// same component Today's sheet mounts. 7.0.23: at the end of the conversation, the newest thing in it, right
|
|
3904
|
+
// above the reply box: a card is too tall for the dock on the Duo (it hid the choices or the reply box), and
|
|
3905
|
+
// here it opens in view with the reader and scrolls with it.
|
|
3906
|
+
'<div class="sx-questions" id="session-questions"></div>' +
|
|
3907
|
+
'</div>' +
|
|
3908
|
+
// The dock, fixed at the bottom of the reader: what it is doing now (with Stop), the Mac's question, then the
|
|
3909
|
+
// verdict, the reply box, its status and the queue, all as before.
|
|
3910
|
+
'<div class="sx-dock" id="session-dock">' +
|
|
2732
3911
|
'<div class="sx-activity" id="session-live" aria-live="polite"></div>' +
|
|
2733
|
-
|
|
2734
|
-
'<div class="turns sx-turns" id="session-turns">' + renderTurns(turns, d) + '</div>' +
|
|
2735
|
-
'<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
|
-
'<span class="sx-foot-note">Kickstart brief for another agent. Not a Claude Code resume.</span></div>' +
|
|
2737
|
-
'<p class="sx-copy-note hidden" id="session-copy-note" role="status"></p>' +
|
|
3912
|
+
'<div class="sx-mac-q" id="session-mac-q" role="status" hidden></div>' +
|
|
2738
3913
|
'<div class="sx-chat">' +
|
|
2739
3914
|
'<div id="session-verdict"></div>' +
|
|
2740
3915
|
'<form class="composer att-composer" id="session-form" data-provider="' + esc(provider) + '" data-id="' + esc(id) + '">' +
|
|
@@ -2745,13 +3920,20 @@
|
|
|
2745
3920
|
'</form><div class="status-line" id="session-status"></div>' +
|
|
2746
3921
|
'<p class="sx-caution hidden" id="session-caution"></p>' +
|
|
2747
3922
|
'<div id="session-queue"></div>' +
|
|
2748
|
-
'</div>'
|
|
3923
|
+
'</div></div>' +
|
|
3924
|
+
// Q1 (23:40): the question cards end the conversation; scrolled up away from them, a button over the dock's top
|
|
3925
|
+
// edge says one waits. It sits in the reader, not the dock, because the dock scrolls by itself as a last resort.
|
|
3926
|
+
'<p class="sx-goq" id="session-goq" hidden><span>A question is waiting at the end.</span><button type="button" class="cx-btn quiet" data-sx="goto-question">Go to the question</button></p>';
|
|
2749
3927
|
readerImagesMount($('session-turns'), r);
|
|
2750
3928
|
sxNoteList(r, d);
|
|
2751
3929
|
setLive({});
|
|
3930
|
+
if (typeof readerMacPaint === 'function') readerMacPaint(r, sxState(d) === 'waiting');
|
|
3931
|
+
var qk = window.COSWeb.sessionQuestions, qhost = $('session-questions');
|
|
3932
|
+
if (qk && qk.mount && qhost) r.qMount = qk.mount(qhost, id, function () { if (state.reading === r && typeof readerMacPaint === 'function') readerMacPaint(r, r.waitingNow); if (typeof sxGoQuestionPaint === 'function') sxGoQuestionPaint(); });
|
|
2752
3933
|
paintReaderFiles();
|
|
3934
|
+
paintWebKeyOffer(r);
|
|
2753
3935
|
// 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 () {});
|
|
3936
|
+
var kitR = readerKit(); if (kitR && !kitR.turnFiles()) kitR.ensureCaps().then(function () { paintReaderFiles(); paintWebKeyOffer(r); }, function () {});
|
|
2755
3937
|
renderHeld(state.reading);
|
|
2756
3938
|
loadQueue(provider, id);
|
|
2757
3939
|
sxProbeAttach(r);
|
|
@@ -2759,7 +3941,8 @@
|
|
|
2759
3941
|
sessionStatus('Waiting for the current reply. ' + (state.reading.held.length > 1 ? state.reading.held.length + ' messages send' : 'Yours sends') + ' as soon as it finishes. ', null, '<button type="button" class="link-btn" data-action="drop-after">Don’t send</button>');
|
|
2760
3942
|
startHeldTimer(state.reading);
|
|
2761
3943
|
}
|
|
2762
|
-
if (turns.length) requestAnimationFrame(function () { window.scrollTo(0, document.documentElement.scrollHeight); });
|
|
3944
|
+
if (turns.length) requestAnimationFrame(function () { if (typeof sxToNewest === 'function') sxToNewest(); else window.scrollTo(0, document.documentElement.scrollHeight); });
|
|
3945
|
+
requestAnimationFrame(function () { if (typeof sxFrameCrumb === 'function') sxFrameCrumb('open'); });
|
|
2763
3946
|
watchSession(provider, id);
|
|
2764
3947
|
}).catch(function (err) {
|
|
2765
3948
|
if (state.reading !== r) return;
|
|
@@ -3023,6 +4206,7 @@
|
|
|
3023
4206
|
var waiting = !working && (e.state === 'waiting' || e.state === 'needs_input' || e.agent_state === 'waiting');
|
|
3024
4207
|
var el = $('session-state');
|
|
3025
4208
|
if (el) el.textContent = working ? 'Working now' : waiting ? 'Waiting for you' : 'Open';
|
|
4209
|
+
if (typeof readerMacPaint === 'function') readerMacPaint(r, waiting);
|
|
3026
4210
|
if (wasWorking && !working) { refreshTurns(provider, id); replyEnded(r); }
|
|
3027
4211
|
wasWorking = working;
|
|
3028
4212
|
// Gone to waiting: its question may be parked for this page now (6.99.27).
|
|
@@ -3069,6 +4253,22 @@
|
|
|
3069
4253
|
if (p === 2) return '3rd in line';
|
|
3070
4254
|
return (p + 1) + 'th in line';
|
|
3071
4255
|
}
|
|
4256
|
+
// Why a waiting follow-up is held (server 7.0.25, S1: waiting_reason on the row, a short code the drain decided).
|
|
4257
|
+
// Plain words for the codes we know; any other well-formed code reads as a plain wait; a malformed value is not shown.
|
|
4258
|
+
var SX_HOLD_WORDS = {
|
|
4259
|
+
files_wait_for_turn_end: 'It has files, so it waits for the current turn to end.',
|
|
4260
|
+
turn_open: 'Waiting for the current turn to end.', turn_not_ended: 'Waiting for the current turn to end.',
|
|
4261
|
+
native_thread_working: 'Waiting for the current turn to end.',
|
|
4262
|
+
live_desktop_process: 'Waiting while another app has this session open.',
|
|
4263
|
+
native_target_busy: 'Waiting: another COS turn holds this session.',
|
|
4264
|
+
native_target_fenced: 'Waiting for this session to be released.',
|
|
4265
|
+
cancel_hold: 'Held for 2 minutes after a stop.'
|
|
4266
|
+
};
|
|
4267
|
+
function sxQueueWhy(t) {
|
|
4268
|
+
var c = t && t.waiting_reason;
|
|
4269
|
+
if (typeof c !== 'string' || !/^[a-z0-9_]{1,48}$/.test(c)) return '';
|
|
4270
|
+
return Object.prototype.hasOwnProperty.call(SX_HOLD_WORDS, c) ? SX_HOLD_WORDS[c] : 'Waiting for this session to be free.';
|
|
4271
|
+
}
|
|
3072
4272
|
function loadQueue(provider, id) {
|
|
3073
4273
|
apiAnswer(sessionPath(provider, id, '/queued-turns')).then(function (q) {
|
|
3074
4274
|
var r = state.reading;
|
|
@@ -3084,7 +4284,9 @@
|
|
|
3084
4284
|
box.innerHTML = '<div class="sx-queue">' + (waiting.length ? '<p class="sx-queue-head">' + (waiting.length === 1 ? '1 follow-up queued. It sends when this session is available.' : waiting.length + ' follow-ups queued. They send in order when this session is available.') + '</p>' : '') +
|
|
3085
4285
|
waiting.map(function (t) {
|
|
3086
4286
|
var mine = t.owner !== 'codex' && parkedByMe(r, t.clientTurnId);
|
|
3087
|
-
|
|
4287
|
+
var why = sxQueueWhy(t);
|
|
4288
|
+
return '<div class="sx-queue-row"><span class="sx-queue-state">' + esc(sxQueueLine(t)) + '</span><span class="sx-queue-text">' + esc(t.preview || t.prompt || 'Message') +
|
|
4289
|
+
(why ? '<span class="sx-queue-reason">' + esc(why) + '</span>' : '') + '</span>' +
|
|
3088
4290
|
(mine ? '<button type="button" class="cx-btn quiet" data-action="unqueue" data-turn-key="' + esc(t.clientTurnId) + '" title="Cancel this queued message. It will not be sent.">Cancel</button>' : '') + '</div>';
|
|
3089
4291
|
}).join('') +
|
|
3090
4292
|
notSent.map(function (t) {
|
|
@@ -3200,7 +4402,8 @@
|
|
|
3200
4402
|
// The box is cleared only of the very words that were sent; anything typed since stays.
|
|
3201
4403
|
function clearBoxOf(text) {
|
|
3202
4404
|
var input = $('session-input');
|
|
3203
|
-
|
|
4405
|
+
// 7.0.18: an answer to a turn-end ask was sent with the attribution in front; the box holds his words without it.
|
|
4406
|
+
if (input && (input.value.trim() === String(text).trim() || input.value.trim() === turnEndBare(text))) { input.value = ''; autoGrow(input); }
|
|
3204
4407
|
if (state.reading && state.reading.draft && state.reading.draft.text === text) state.reading.draft = null;
|
|
3205
4408
|
}
|
|
3206
4409
|
// Words go back in the box only if the box is empty; otherwise the status line keeps them. `files` says what became
|
|
@@ -3220,7 +4423,7 @@
|
|
|
3220
4423
|
if (heldKey) el.setAttribute('data-held-key', heldKey);
|
|
3221
4424
|
el.innerHTML = '<span class="who">You, ' + esc(note) + '</span>' + md(text);
|
|
3222
4425
|
box.appendChild(el);
|
|
3223
|
-
requestAnimationFrame(function () { window.scrollTo(0, document.documentElement.scrollHeight); });
|
|
4426
|
+
requestAnimationFrame(function () { if (typeof sxToNewest === 'function') sxToNewest(); else window.scrollTo(0, document.documentElement.scrollHeight); });
|
|
3224
4427
|
}
|
|
3225
4428
|
function heldBubble(key) { return document.querySelector('#session-turns .turn.pending[data-held-key="' + key.replace(/[^A-Za-z0-9_-]/g, '') + '"]'); }
|
|
3226
4429
|
function renderHeld(r) {
|
|
@@ -3347,6 +4550,9 @@
|
|
|
3347
4550
|
// during the first attach could park the same text in the queue while the first lands.
|
|
3348
4551
|
if (r.sending) return;
|
|
3349
4552
|
if (r.refusal || r.cautionAsk) { r.refusal = null; r.cautionAsk = false; renderVerdict(r); }
|
|
4553
|
+
// 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
|
|
4554
|
+
// same attribution as a choice. A fork goes to a copy and is not an answer, so it is sent as written.
|
|
4555
|
+
if (!fork && r.turnEnd && turnEndOf(readerRowOf(r))) text = turnEndPrompt(text);
|
|
3350
4556
|
r.sending = true;
|
|
3351
4557
|
var btns = form.querySelectorAll('button'); btns.forEach(function (b) { b.disabled = true; });
|
|
3352
4558
|
var done = function () { r.sending = false; btns.forEach(function (b) { b.disabled = false; }); paintReaderFiles(); };
|
|
@@ -3449,6 +4655,205 @@
|
|
|
3449
4655
|
});
|
|
3450
4656
|
}
|
|
3451
4657
|
|
|
4658
|
+
// ── BEGIN web-key handoff (6.99.31, Phase 3, review W2) ──
|
|
4659
|
+
// With the Mac's webKeySigner capability (only with COS_WEB_KEY_AUTHORITY on) and an enrolled phone key, the session
|
|
4660
|
+
// composer offers "Approve on this phone". That is a TOP-LEVEL navigation to the signer page on port 3144 carrying a
|
|
4661
|
+
// one-use handoff id in the fragment. The words stay on the Mac until the signer claims them; they are never in a URL.
|
|
4662
|
+
// Without the capability nothing here runs: no request, no button, the composer exactly as before.
|
|
4663
|
+
var WEB_KEY = { status: null, statusAt: 0, loading: null, generation: 0, views: [], refreshing: null, navigation: null };
|
|
4664
|
+
var WEB_KEY_COPY = {
|
|
4665
|
+
no_phone_key: 'No phone has a key yet. Set one up on the approval page first.',
|
|
4666
|
+
attachments_not_signable: 'Files cannot be approved on the phone yet. Remove them, or use Send.',
|
|
4667
|
+
words_too_long: 'That message is too long to approve on the phone. Shorten it, or use Send.',
|
|
4668
|
+
handoff_cap_reached: 'Too many approvals are waiting. Finish one, or wait two minutes.',
|
|
4669
|
+
origin_unavailable: 'Your Mac does not know its Tailscale name yet, so the approval page cannot open.',
|
|
4670
|
+
web_key_authority_off: 'Phone approval is turned off on the Mac.'
|
|
4671
|
+
};
|
|
4672
|
+
function webKeyCapable() { var k = readerKit(); return !!(k && typeof k.webKeySigner === 'function' && k.webKeySigner() === 1); }
|
|
4673
|
+
function webKeyStatus() {
|
|
4674
|
+
var age = Date.now() - WEB_KEY.statusAt;
|
|
4675
|
+
if (WEB_KEY.status && age >= 0 && age < 30000) return Promise.resolve(WEB_KEY.status);
|
|
4676
|
+
if (!WEB_KEY.loading) {
|
|
4677
|
+
var generation = WEB_KEY.generation;
|
|
4678
|
+
WEB_KEY.loading = apiAnswer('/api/web-key/status', { timeoutMs: 15000 }).then(function (a) {
|
|
4679
|
+
if (generation !== WEB_KEY.generation) return null;
|
|
4680
|
+
WEB_KEY.loading = null;
|
|
4681
|
+
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;
|
|
4682
|
+
WEB_KEY.statusAt = Date.now();
|
|
4683
|
+
return WEB_KEY.status;
|
|
4684
|
+
}, function () { if (generation === WEB_KEY.generation) { WEB_KEY.loading = null; WEB_KEY.status = null; } return null; });
|
|
4685
|
+
}
|
|
4686
|
+
return WEB_KEY.loading;
|
|
4687
|
+
}
|
|
4688
|
+
function webKeySetupUrl() {
|
|
4689
|
+
var host = location.hostname;
|
|
4690
|
+
// The passkey belongs to this Mac's full MagicDNS host, never an IP, local alias or public suffix.
|
|
4691
|
+
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;
|
|
4692
|
+
return 'https://' + host + ':3144/';
|
|
4693
|
+
}
|
|
4694
|
+
function phoneApprovalView(r, opts) {
|
|
4695
|
+
opts = opts || {};
|
|
4696
|
+
var prefix = opts.prefix || 'session';
|
|
4697
|
+
return { prefix: prefix, form: $(prefix + '-form'), send: $(prefix + '-send'), input: $(prefix + '-input'),
|
|
4698
|
+
live: opts.live || function () { return state.reading === r; },
|
|
4699
|
+
status: opts.status || sessionStatus, files: opts.files || function () { return readerFilesState(readerFilesOn() ? readerBox(r) : null); } };
|
|
4700
|
+
}
|
|
4701
|
+
function phoneApprovalViewLive(view) {
|
|
4702
|
+
return view.live() && view.form && view.send && view.form === $(view.prefix + '-form') && view.send === $(view.prefix + '-send');
|
|
4703
|
+
}
|
|
4704
|
+
function clearPhoneOffer(r, view) {
|
|
4705
|
+
var offer = view.form.querySelector('[data-phone-offer]'), packets = view.form.querySelector('[data-phone-packets]');
|
|
4706
|
+
if (offer && offer.parentNode) offer.parentNode.removeChild(offer);
|
|
4707
|
+
if (packets && packets.parentNode) packets.parentNode.removeChild(packets);
|
|
4708
|
+
r.phonePackets = {}; r.phonePacketSha256 = ''; r.phoneDraft = null;
|
|
4709
|
+
}
|
|
4710
|
+
function refreshWebKeyOffers() {
|
|
4711
|
+
if (!webKeyCapable() || WEB_KEY.refreshing) return;
|
|
4712
|
+
WEB_KEY.status = null; WEB_KEY.statusAt = 0; WEB_KEY.loading = null; WEB_KEY.generation += 1;
|
|
4713
|
+
WEB_KEY.views = WEB_KEY.views.filter(function (entry) { return phoneApprovalViewLive(entry.view); });
|
|
4714
|
+
if (!WEB_KEY.views.length) return;
|
|
4715
|
+
var views = WEB_KEY.views.slice();
|
|
4716
|
+
views.forEach(function (entry) { clearPhoneOffer(entry.r, entry.view); });
|
|
4717
|
+
var fresh = webKeyStatus(); WEB_KEY.refreshing = fresh;
|
|
4718
|
+
fresh.then(function () { if (WEB_KEY.refreshing === fresh) WEB_KEY.refreshing = null; });
|
|
4719
|
+
views.forEach(function (entry) { paintWebKeyOffer(entry.r, entry.opts); });
|
|
4720
|
+
}
|
|
4721
|
+
// Safari can restore this exact DOM after enrollment or revocation on the separate signer origin.
|
|
4722
|
+
// Refetch once per persisted return; closed/replaced readers are pruned and no timer is installed.
|
|
4723
|
+
// (Guarded: it runs at load, and a host without window events, the web-control vm harnesses, must still load.)
|
|
4724
|
+
if (window.addEventListener) window.addEventListener('pageshow', function (e) {
|
|
4725
|
+
if (!e || !e.persisted) return;
|
|
4726
|
+
var nav = WEB_KEY.navigation; WEB_KEY.navigation = null;
|
|
4727
|
+
if (nav && nav.token.completed && nav.r.phoneNavigation === nav.token) {
|
|
4728
|
+
if (nav.r.sending === nav.token) nav.r.sending = false;
|
|
4729
|
+
nav.r.phoneNavigation = null;
|
|
4730
|
+
}
|
|
4731
|
+
refreshWebKeyOffers();
|
|
4732
|
+
});
|
|
4733
|
+
function phonePacketDetails(p, sha) {
|
|
4734
|
+
return 'Requested ' + p.action + ': ' + p.artifact + ' ' + p.version + '\nChannel: ' + p.channel + '\nDigest: ' + p.digest +
|
|
4735
|
+
'\nReceiver: ' + p.provider + ' / ' + p.sessionId + '\nExpires: ' + new Date(p.exp).toISOString() + '\nPacket: ' + sha +
|
|
4736
|
+
'\nThis request approves nothing until you review it and confirm on the phone.';
|
|
4737
|
+
}
|
|
4738
|
+
function paintWebKeyOffer(r, opts) {
|
|
4739
|
+
if (!webKeyCapable()) return;
|
|
4740
|
+
var view = phoneApprovalView(r, opts);
|
|
4741
|
+
if (!phoneApprovalViewLive(view)) return;
|
|
4742
|
+
WEB_KEY.views = WEB_KEY.views.filter(function (entry) { return phoneApprovalViewLive(entry.view) && entry.view.form !== view.form; });
|
|
4743
|
+
WEB_KEY.views.push({ r: r, opts: opts, view: view });
|
|
4744
|
+
var generation = WEB_KEY.generation;
|
|
4745
|
+
webKeyStatus().then(function (st) {
|
|
4746
|
+
if (generation !== WEB_KEY.generation || !phoneApprovalViewLive(view)) return;
|
|
4747
|
+
if (!st) { clearPhoneOffer(r, view); return; }
|
|
4748
|
+
var offer = view.form.querySelector('[data-phone-offer]');
|
|
4749
|
+
if (offer && !!view.form.querySelector('[data-phone-setup]') === (st.activeKeys === 0)) return;
|
|
4750
|
+
if (offer || st.activeKeys === 0) clearPhoneOffer(r, view);
|
|
4751
|
+
if (st.activeKeys === 0) {
|
|
4752
|
+
var setupUrl = webKeySetupUrl(); if (!setupUrl) return;
|
|
4753
|
+
var setup = document.createElement('a');
|
|
4754
|
+
setup.className = 'btn small'; setup.setAttribute('data-phone-offer', '1');
|
|
4755
|
+
setup.setAttribute('data-phone-setup', '1'); setup.setAttribute('href', setupUrl); setup.setAttribute('target', '_top');
|
|
4756
|
+
setup.textContent = 'Set up phone approvals';
|
|
4757
|
+
view.send.parentNode.insertBefore(setup, view.send);
|
|
4758
|
+
return;
|
|
4759
|
+
}
|
|
4760
|
+
var b = document.createElement('button');
|
|
4761
|
+
b.type = 'button'; b.className = 'btn small'; b.setAttribute('data-phone-offer', '1');
|
|
4762
|
+
b.setAttribute(view.prefix === 'ta' ? 'data-ta' : 'data-action', view.prefix === 'ta' ? 'approve-phone' : 'approve-on-phone');
|
|
4763
|
+
b.title = 'Review an exact request on the approval page, then confirm with your phone passkey';
|
|
4764
|
+
b.textContent = 'Approve on this phone';
|
|
4765
|
+
view.send.parentNode.insertBefore(b, view.send);
|
|
4766
|
+
return apiAnswer('/api/web-key/packets?provider=' + encodeURIComponent(r.provider) + '&sessionId=' + encodeURIComponent(r.id)).then(function (a) {
|
|
4767
|
+
if (generation !== WEB_KEY.generation || !phoneApprovalViewLive(view) || view.form.querySelector('[data-phone-offer]') !== b) return;
|
|
4768
|
+
var rows = a.status === 200 && a.body && a.body.ok === true && Array.isArray(a.body.packets) ? a.body.packets : [];
|
|
4769
|
+
rows = rows.filter(function (row) { var p = row && row.packet; return p && p.provider === r.provider && p.sessionId === r.id &&
|
|
4770
|
+
(p.action === 'install' || p.action === 'publish') && Number.isSafeInteger(p.exp) && p.exp >= Date.now() && /^[0-9a-f]{64}$/.test(row.packetSha256 || ''); });
|
|
4771
|
+
r.phonePackets = {}; rows.forEach(function (row) { r.phonePackets[row.packetSha256] = row.packet; });
|
|
4772
|
+
if (!rows.length) return;
|
|
4773
|
+
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');
|
|
4774
|
+
box.className = 'phone-approval-packet'; box.setAttribute('data-phone-packets', '1'); select.id = view.prefix + '-phone-packet'; detail.id = view.prefix + '-phone-details';
|
|
4775
|
+
label.textContent = 'What to approve'; label.setAttribute('for', select.id);
|
|
4776
|
+
var message = document.createElement('option'); message.value = ''; message.textContent = 'Message only — no release approval'; select.appendChild(message);
|
|
4777
|
+
rows.forEach(function (row) { var option = document.createElement('option'), p = row.packet; option.value = row.packetSha256;
|
|
4778
|
+
option.textContent = p.action + ' ' + p.artifact + ' ' + p.version + ' / ' + p.channel; select.appendChild(option); });
|
|
4779
|
+
select.value = r.phonePacketSha256 || '';
|
|
4780
|
+
var update = function () { r.phonePacketSha256 = select.value; var p = r.phonePackets[select.value];
|
|
4781
|
+
detail.textContent = p ? phonePacketDetails(p, select.value) : 'Message only. This authorizes no install, publish or push.';
|
|
4782
|
+
if (p && view.input && !view.input.value.trim()) view.input.value = 'Approve ' + p.action + ' ' + p.artifact + ' ' + p.version + ' on ' + p.channel + '.';
|
|
4783
|
+
};
|
|
4784
|
+
select.addEventListener('change', update); update();
|
|
4785
|
+
summary.textContent = 'Review release request'; disclosure.appendChild(summary); disclosure.appendChild(detail);
|
|
4786
|
+
box.appendChild(label); box.appendChild(select); box.appendChild(disclosure); view.form.insertBefore(box, view.form.firstChild);
|
|
4787
|
+
}, function () { /* No packet is selected implicitly when this read fails. */ });
|
|
4788
|
+
});
|
|
4789
|
+
}
|
|
4790
|
+
// Only the signer on this same host, port 3144, path /, with nothing but the handoff id in the fragment.
|
|
4791
|
+
function webKeySignerUrlOk(u) {
|
|
4792
|
+
try {
|
|
4793
|
+
var x = new URL(u);
|
|
4794
|
+
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);
|
|
4795
|
+
} catch (_e) { return false; }
|
|
4796
|
+
}
|
|
4797
|
+
function approveOnPhone(opts) {
|
|
4798
|
+
opts = opts || {};
|
|
4799
|
+
var r = opts.r || state.reading; if (!r || r.sending) return Promise.resolve('busy');
|
|
4800
|
+
var view = phoneApprovalView(r, opts), say = view.status;
|
|
4801
|
+
if (!view.live()) return Promise.resolve('stale');
|
|
4802
|
+
var input = view.input, text = input ? input.value.trim() : '';
|
|
4803
|
+
if (!text) { say('Write a message first.', 'error'); return Promise.resolve('empty'); }
|
|
4804
|
+
if (view.files() !== 'none') { say(WEB_KEY_COPY.attachments_not_signable, 'error'); return Promise.resolve('files'); }
|
|
4805
|
+
var provider = r.provider, id = r.id, packetSha = r.phonePacketSha256 || '';
|
|
4806
|
+
var packet = packetSha && r.phonePackets && r.phonePackets[packetSha];
|
|
4807
|
+
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'); }
|
|
4808
|
+
var draft = r.phoneDraft;
|
|
4809
|
+
if (!draft || draft.text !== text || draft.packetSha256 !== packetSha) draft = r.phoneDraft = { text: text, key: newTurnKey(), packetSha256: packetSha };
|
|
4810
|
+
var bound = bindingFor(provider, id);
|
|
4811
|
+
// Other senders assign boolean true. This unique truthy owner lets late callbacks and a BFCache return
|
|
4812
|
+
// release only this phone operation, never a newer ordinary send or another phone operation.
|
|
4813
|
+
var operation = { completed: false };
|
|
4814
|
+
r.phoneNavigation = operation; r.sending = operation;
|
|
4815
|
+
var ownsSending = function () { return r.sending === operation && r.phoneNavigation === operation; };
|
|
4816
|
+
var releaseSending = function () { if (ownsSending()) { r.sending = false; r.phoneNavigation = null; } };
|
|
4817
|
+
say('Opening the approval page.');
|
|
4818
|
+
var attach = bound ? Promise.resolve(bound) : apiAnswer(sessionPath(provider, id, '/attach'), { method: 'POST', body: { cosSessionId: cosSessionId() }, timeoutMs: 20000 }).then(function (a) {
|
|
4819
|
+
if (a.status >= 200 && a.status < 300 && a.body.attached === true && a.body.bindingId && typeof a.body.epoch === 'number') {
|
|
4820
|
+
var b = { bindingId: a.body.bindingId, epoch: a.body.epoch, at: Date.now() };
|
|
4821
|
+
rememberBinding(provider, id, b);
|
|
4822
|
+
return b;
|
|
4823
|
+
}
|
|
4824
|
+
return null;
|
|
4825
|
+
});
|
|
4826
|
+
return attach.then(function (b) {
|
|
4827
|
+
if (!ownsSending() || !phoneApprovalViewLive(view)) return 'stale';
|
|
4828
|
+
if (!b) { say('COS cannot write to this session right now.', 'error'); return 'refused'; }
|
|
4829
|
+
var request = { bindingId: b.bindingId, epoch: b.epoch, targetKey: targetKey(provider, id), clientTurnId: draft.key, prompt: draft.text };
|
|
4830
|
+
if (packetSha) request.packetSha256 = packetSha;
|
|
4831
|
+
return apiAnswer('/api/web-key/handoff', { method: 'POST', timeoutMs: 15000, body: request }).then(function (h) {
|
|
4832
|
+
if (!ownsSending() || !phoneApprovalViewLive(view)) return 'stale';
|
|
4833
|
+
var body = h.body || {};
|
|
4834
|
+
if (h.status === 201 && body.ok === true && webKeySignerUrlOk(body.signerUrl)) {
|
|
4835
|
+
say('Opening the approval page.');
|
|
4836
|
+
location.assign(body.signerUrl);
|
|
4837
|
+
operation.completed = true;
|
|
4838
|
+
WEB_KEY.navigation = { r: r, token: operation };
|
|
4839
|
+
return 'opened';
|
|
4840
|
+
}
|
|
4841
|
+
if (h.status === 201) { say('Open Control from your Mac’s Tailscale name to approve on this phone.', 'error'); return 'refused'; }
|
|
4842
|
+
if (/^(binding_changed|unknown_binding)$/.test(body.error || '') && !opts.reattached) {
|
|
4843
|
+
rememberBinding(provider, id, null);
|
|
4844
|
+
releaseSending();
|
|
4845
|
+
return approveOnPhone(Object.assign({}, opts, { r: r, reattached: true }));
|
|
4846
|
+
}
|
|
4847
|
+
say(WEB_KEY_COPY[body.error] || 'The Mac did not open an approval for that message.', 'error');
|
|
4848
|
+
return 'refused';
|
|
4849
|
+
});
|
|
4850
|
+
}).then(function (how) { if (how !== 'opened') releaseSending(); return how; }, function () {
|
|
4851
|
+
if (ownsSending()) { releaseSending(); say('Could not reach your Mac.', 'error'); }
|
|
4852
|
+
return 'failed';
|
|
4853
|
+
});
|
|
4854
|
+
}
|
|
4855
|
+
// ── END web-key handoff ──
|
|
4856
|
+
|
|
3452
4857
|
var HELD_MAX_MS = 5 * 60000, HELD_RETRY_MS = 8000;
|
|
3453
4858
|
function holdUntilReplyEnds(r, draft) {
|
|
3454
4859
|
if (!r.held.some(function (h) { return h.key === draft.key; })) {
|
|
@@ -3871,15 +5276,23 @@
|
|
|
3871
5276
|
box.insertAdjacentHTML('beforeend', fresh.map(sxTurnHtml).join(''));
|
|
3872
5277
|
return;
|
|
3873
5278
|
}
|
|
5279
|
+
// In the frame the conversation scrolls inside #session-body: the card kept is the first one showing in it, and the
|
|
5280
|
+
// body (not the window) moves back by what the redraw moved it.
|
|
5281
|
+
var body = document.documentElement.classList.contains('sx-frame') ? $('session-body') : null;
|
|
5282
|
+
var edge = body && body.getBoundingClientRect ? body.getBoundingClientRect().top : 0;
|
|
3874
5283
|
var anchor = null, top = 0, cards = box.querySelectorAll('.turn[data-sig]');
|
|
3875
5284
|
for (var i = 0; i < cards.length; i++) {
|
|
3876
5285
|
var rect = cards[i].getBoundingClientRect();
|
|
3877
|
-
if (rect.bottom >
|
|
5286
|
+
if (rect.bottom > edge) { anchor = cards[i].getAttribute('data-sig'); top = rect.top; break; }
|
|
3878
5287
|
}
|
|
3879
5288
|
box.innerHTML = renderTurns(next, d);
|
|
5289
|
+
// Still at the newest turn since it opened (sxToNewest's pin): stay there, rather than keep a card in place.
|
|
5290
|
+
if (body && typeof SX_PIN === 'object' && SX_PIN.body === body) { body.scrollTop = body.scrollHeight; SX_PIN.put = body.scrollTop; return; }
|
|
3880
5291
|
if (!anchor) return;
|
|
3881
5292
|
var same = [].filter.call(box.querySelectorAll('.turn[data-sig]'), function (el) { return el.getAttribute('data-sig') === anchor; })[0];
|
|
3882
|
-
if (same)
|
|
5293
|
+
if (!same) return;
|
|
5294
|
+
if (body) body.scrollTop += same.getBoundingClientRect().top - top;
|
|
5295
|
+
else window.scrollBy(0, same.getBoundingClientRect().top - top);
|
|
3883
5296
|
}
|
|
3884
5297
|
|
|
3885
5298
|
// ── Ask COS (the Ask sheet's first row) ─────────────────
|
|
@@ -5723,11 +7136,12 @@
|
|
|
5723
7136
|
|
|
5724
7137
|
// ── events (one delegated listener) ─────────────────────
|
|
5725
7138
|
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');
|
|
7139
|
+
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
7140
|
if (!t) { if (e.target.id === 'ask-sheet') closeAsk(); if (e.target.id === 'section-menu') closeMenu(); return; }
|
|
5728
7141
|
var inSheet = !!t.closest('#ask-sheet');
|
|
5729
7142
|
if (t.id === 'topbar-back') return goBack();
|
|
5730
7143
|
if (t.id === 'section-btn') return openMenu();
|
|
7144
|
+
if (t.id === 'auth-retry') { t.disabled = true; t.textContent = 'Checking'; bootStatus().then(function () { t.disabled = false; t.textContent = 'Try again'; }); return; }
|
|
5731
7145
|
if (t.id === 'auth-switch') {
|
|
5732
7146
|
var toToken = $('auth-token-block').classList.contains('hidden');
|
|
5733
7147
|
$('auth-token-block').classList.toggle('hidden', !toToken);
|
|
@@ -5756,6 +7170,9 @@
|
|
|
5756
7170
|
if (t.hasAttribute('data-open-ask')) return openAsk();
|
|
5757
7171
|
if (t.hasAttribute('data-today')) return t.getAttribute('data-today') === 'pet' ? petTap(t) : openTodayAction(t);
|
|
5758
7172
|
if (t.hasAttribute('data-today-next')) return prepNext();
|
|
7173
|
+
if (t.hasAttribute('data-next-prep')) return toggleNextPrep(t.getAttribute('data-next-prep'));
|
|
7174
|
+
if (t.hasAttribute('data-next-prep-cancel')) return closeNextPrep(true);
|
|
7175
|
+
if (t.hasAttribute('data-next-prep-remove')) return removeNextPrepFile(t.getAttribute('data-next-prep-remove'));
|
|
5759
7176
|
if (t.hasAttribute('data-today-needs')) {
|
|
5760
7177
|
var which = t.getAttribute('data-today-needs');
|
|
5761
7178
|
if (which === 'all') TODAY.showAllNeeds = true;
|
|
@@ -5775,6 +7192,9 @@
|
|
|
5775
7192
|
if (t.hasAttribute('data-speakers')) return go('speakers', [t.getAttribute('data-speakers')]);
|
|
5776
7193
|
var action = t.getAttribute('data-action');
|
|
5777
7194
|
if (action === 'fork-session') return sendSessionTurn(true);
|
|
7195
|
+
if (action === 'conn-retry') return connRetry(true);
|
|
7196
|
+
if (action === 'conn-hide-signin') { CONN.hideFor = signinEndsAt(); return connPaint(); }
|
|
7197
|
+
if (action === 'approve-on-phone') return approveOnPhone();
|
|
5778
7198
|
if (action === 'send-anyway') { var rev = state.reading && state.reading.sendAnyway; if (state.reading) state.reading.sendAnyway = null; return sendSessionTurn(false, { acknowledgedRevision: rev }); }
|
|
5779
7199
|
if (action === 'stop-session') return stopSession();
|
|
5780
7200
|
if (action === 'drop-after') return dropHeld();
|
|
@@ -5800,6 +7220,7 @@
|
|
|
5800
7220
|
if (action === 'ask-cos' && q) return askSheetSend(q);
|
|
5801
7221
|
if (action === 'ask-file' && q) return fileToInbox(q);
|
|
5802
7222
|
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; }
|
|
7223
|
+
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
7224
|
if (action === 'ask-work' && q) {
|
|
5804
7225
|
closeAsk(); go('work');
|
|
5805
7226
|
// The Work pane (panes/work.js) owns its search box; hand it the query.
|
|
@@ -5822,6 +7243,8 @@
|
|
|
5822
7243
|
showAuth(err && err.status ? err.message : UNREACHABLE_COPY);
|
|
5823
7244
|
});
|
|
5824
7245
|
} else if (f.id === 'session-form') sendSessionTurn(false);
|
|
7246
|
+
else if (f.id === 'today-ask') sendTodayAsk();
|
|
7247
|
+
else if (f.hasAttribute && f.hasAttribute('data-next-prep-form')) startNextPrep();
|
|
5825
7248
|
else if (f.id === 'sessions-search-form') { $('sessions-q').blur(); searchSessions(); }
|
|
5826
7249
|
else if (f.id === 'ask-form') {
|
|
5827
7250
|
var hits = $('ask-hits'); if (hits && $('ask-q').value.trim().length >= 2) renderAsk();
|
|
@@ -5832,6 +7255,7 @@
|
|
|
5832
7255
|
document.addEventListener('change', function (e) {
|
|
5833
7256
|
if (e.target && e.target.hasAttribute && e.target.hasAttribute('data-ask-file')) { addAskFiles(e.target.files); e.target.value = ''; }
|
|
5834
7257
|
if (e.target && e.target.hasAttribute && e.target.hasAttribute('data-sx-file')) { addReaderFiles(e.target.files); e.target.value = ''; }
|
|
7258
|
+
if (e.target && e.target.hasAttribute && e.target.hasAttribute('data-next-prep-file')) { addNextPrepFiles(e.target.files); e.target.value = ''; }
|
|
5835
7259
|
});
|
|
5836
7260
|
// The Sessions reader's box: a pasted screenshot, and files dropped on it.
|
|
5837
7261
|
document.addEventListener('paste', function (e) {
|
|
@@ -5861,6 +7285,7 @@
|
|
|
5861
7285
|
document.addEventListener('input', function (e) {
|
|
5862
7286
|
if (e.target.tagName === 'TEXTAREA') autoGrow(e.target);
|
|
5863
7287
|
if (e.target.id === 'ask-q') renderAsk();
|
|
7288
|
+
if (e.target.hasAttribute && e.target.hasAttribute('data-next-prep-steer')) nextPrepInput(e.target);
|
|
5864
7289
|
if (e.target.id === 'sessions-q' && !e.target.value.trim()) renderSessionGroups(state.sessions);
|
|
5865
7290
|
});
|
|
5866
7291
|
document.addEventListener('keydown', function (e) {
|
|
@@ -5868,6 +7293,7 @@
|
|
|
5868
7293
|
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { e.preventDefault(); openAsk(); return; }
|
|
5869
7294
|
if (e.key === 'Escape' && !$('ask-sheet').classList.contains('hidden')) { closeAsk(); return; }
|
|
5870
7295
|
if (e.key === 'Escape' && !$('section-menu').classList.contains('hidden')) { closeMenu(); $('section-btn').focus(); return; }
|
|
7296
|
+
if (e.key === 'Escape' && NEXT_PREP.open && closeNextPrep(false)) { e.preventDefault(); return; }
|
|
5871
7297
|
if (returnSends(e)) { e.preventDefault(); var form = e.target.closest('form'); if (form) form.requestSubmit(); }
|
|
5872
7298
|
});
|
|
5873
7299
|
document.querySelectorAll('[data-mode]').forEach(function (b) {
|
|
@@ -5971,6 +7397,15 @@
|
|
|
5971
7397
|
// fixed composer clears to sit above the keyboard. 0 when the web view itself shrank.
|
|
5972
7398
|
var VP = { layoutH: 0 };
|
|
5973
7399
|
VP.maxH = 0; VP.w = -1; VP.free = 0; VP.typing = false; VP.blurAt = 0;
|
|
7400
|
+
// 7.0.23 (lead 23:02, from Miles's phone rows 11/10): on the unfolded Duo, after a keyboard closed, visualViewport
|
|
7401
|
+
// reported a height of 0 (with an offset anywhere from 0 to 787) for minutes at a time, while innerHeight was back at
|
|
7402
|
+
// 787 within 0.3 s. A visible height under VV_MIN_PX is never a real reading, whatever causes it: it is treated as no
|
|
7403
|
+
// reading, the box is innerHeight from offset 0, and it is counted (VP.badN runs of it, VP.badReads reads), which every
|
|
7404
|
+
// layout report carries as vz and vzr. Why 80: the smallest real visible height in his 99 rows that day is 359.98
|
|
7405
|
+
// (folded, keyboard up), a phone on its side with the keyboard up still shows more than 80, and under 80 nothing fits
|
|
7406
|
+
// a reply box and a header anyway. Without a keyboard innerHeight is the visible height, so the fallback costs nothing.
|
|
7407
|
+
var VV_MIN_PX = 80;
|
|
7408
|
+
VP.bad = false; VP.badN = 0; VP.badReads = 0;
|
|
5974
7409
|
var VP_SLACK_PX = 20, VP_SETTLE_MS = 1000;
|
|
5975
7410
|
function vpTyping() {
|
|
5976
7411
|
var a = document.activeElement;
|
|
@@ -6006,7 +7441,13 @@
|
|
|
6006
7441
|
}
|
|
6007
7442
|
else if (!VP.layoutH) VP.layoutH = Math.max(inner, (de && de.clientHeight) || 0);
|
|
6008
7443
|
var H = VP.layoutH;
|
|
6009
|
-
if (!vv
|
|
7444
|
+
if (!vv) return { layoutH: H, top: 0, height: H, inset: 0, below: 0, dock: 0, scale: 1, zoomed: false, vv: false };
|
|
7445
|
+
if (!(vv.height >= VV_MIN_PX)) {
|
|
7446
|
+
VP.badReads++;
|
|
7447
|
+
if (!VP.bad) { VP.bad = true; VP.badN++; }
|
|
7448
|
+
return { layoutH: H, top: 0, height: Math.round(inner) || H, inset: 0, below: 0, dock: 0, scale: 1, zoomed: false, vv: false, bad: true };
|
|
7449
|
+
}
|
|
7450
|
+
VP.bad = false;
|
|
6010
7451
|
var inset = Math.max(0, Math.round(H - vv.height * scale));
|
|
6011
7452
|
var top, height;
|
|
6012
7453
|
if (!zoomed) { top = Math.max(0, Math.round(vv.offsetTop || 0)); height = Math.min(Math.round(vv.height), H); }
|
|
@@ -6049,7 +7490,8 @@
|
|
|
6049
7490
|
var o = (screen && screen.orientation && screen.orientation.type) || (typeof window.orientation === 'number' ? 'deg' + window.orientation : '');
|
|
6050
7491
|
var data = { what: String(event || ''), ua: ua.brand, os: ua.os, iw: r(window.innerWidth), ih: r(window.innerHeight), ch: r(de && de.clientHeight),
|
|
6051
7492
|
vvh: vv ? r(vv.height) : null, vvw: vv ? r(vv.width) : null, vvt: vv ? r(vv.offsetTop) : null, vvs: vv ? r(vv.scale) : null,
|
|
6052
|
-
orient: String(o).slice(0, 24), coarse: !!(window.matchMedia && window.matchMedia('(pointer: coarse)').matches)
|
|
7493
|
+
orient: String(o).slice(0, 24), coarse: !!(window.matchMedia && window.matchMedia('(pointer: coarse)').matches),
|
|
7494
|
+
vz: VP.badN, vzr: VP.badReads };
|
|
6053
7495
|
var x = extra || {};
|
|
6054
7496
|
Object.keys(x).forEach(function (k) { var v = x[k]; if (/^[a-z][a-zA-Z0-9]{0,15}$/.test(k) && (typeof v === 'number' || typeof v === 'boolean' || (typeof v === 'string' && v.length <= 24))) data[k] = typeof v === 'number' ? r(v) : v; });
|
|
6055
7497
|
var key = JSON.stringify(data), now = Date.now();
|
|
@@ -6141,6 +7583,7 @@
|
|
|
6141
7583
|
questionsChanged: function () { if (TODAY.at) { paintNeeds(); if (state.pane === 'today' && !TODAY.offline) renderPet(state.sessions || []); } }
|
|
6142
7584
|
};
|
|
6143
7585
|
window.COSWeb.sessionKit = {
|
|
7586
|
+
phoneOffer: paintWebKeyOffer, approvePhone: approveOnPhone,
|
|
6144
7587
|
// A session object for continueSession, with the overlay's own view (READER_VIEW's methods).
|
|
6145
7588
|
session: function (provider, id, view) {
|
|
6146
7589
|
return { provider: provider, id: id, draft: null, held: [], view: view, verdict: null, refusal: null, cautionAck: false, feed: newFeed(), live: null, queued: 0 };
|
|
@@ -6174,7 +7617,7 @@
|
|
|
6174
7617
|
// through COSWeb.onKeyboard; COSWeb.keyboardUp says whether it is up now. Which composer the keyboard is for is the
|
|
6175
7618
|
// pane's to mark (panes/cos-chat.js sets html.kb-chat and .cc-kb).
|
|
6176
7619
|
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: '' };
|
|
7620
|
+
var LR = { up: false, frame: 0, after: 0, cycle: 0, focusN: 0, device: false, field: null, hooks: [], box: '', zoomed: false, touchAt: 0 };
|
|
6178
7621
|
function lrPane() { var r = parseRoute(); return String(r.pane + (r.args[0] === 'cos' ? '.cos' : '')).slice(0, 24); }
|
|
6179
7622
|
function lrKind(a) {
|
|
6180
7623
|
var tag = String((a && a.tagName) || '').toLowerCase();
|
|
@@ -6199,13 +7642,44 @@
|
|
|
6199
7642
|
return typeof v === 'number' && isFinite(v) ? v : null;
|
|
6200
7643
|
}
|
|
6201
7644
|
function lrMatch(q) { try { return !!window.matchMedia(q).matches; } catch (_e) { return false; } }
|
|
7645
|
+
// The page wider than the screen (the Duo, 11/10: scale 0.85, scroll width 449 for a 382 screen). Two causes look the
|
|
7646
|
+
// same from outside; these numbers narrow it: cw, the layout viewport; dw, the width this device shows at scale 1;
|
|
7647
|
+
// scw, the document's scroll width. cw = scw, both above dw, says only that the layout viewport itself is wider: a
|
|
7648
|
+
// browser or phone setting, or iOS widening the layout to fit content (Q1 15:27); nothing is named or scanned then, so
|
|
7649
|
+
// '' is not proof of a setting. Our page overflowing a layout that stayed at the device width shows as scw above cw
|
|
7650
|
+
// (the Duo's 11/10 rows: scroll width 449 while the width was 382 at scale 1): then `wide` names the
|
|
7651
|
+
// element that sticks out, the first in page order whose right edge passes cw. Page order puts a parent before its
|
|
7652
|
+
// children, so that element's parent ends within cw: it is the one that sticks out of its container, never a
|
|
7653
|
+
// stretched container (those end at cw; Q1 15:20). One scan per layout (cached by cw and scw), at most 4000 elements.
|
|
7654
|
+
var LR_WIDE = { key: '', out: null };
|
|
7655
|
+
function lrWide() {
|
|
7656
|
+
var num = function (v) { return typeof v === 'number' && isFinite(v) ? Math.round(v) : null; };
|
|
7657
|
+
var de = document.documentElement, vv = window.visualViewport, out = { cw: num(de && de.clientWidth), dw: null, scw: null, wide: '' };
|
|
7658
|
+
try {
|
|
7659
|
+
var se = document.scrollingElement || de;
|
|
7660
|
+
out.scw = num(se && se.scrollWidth);
|
|
7661
|
+
out.dw = vv ? num(vv.width * vv.scale) : num(window.innerWidth);
|
|
7662
|
+
if (!(out.cw && out.scw && out.scw > out.cw + 1)) return out;
|
|
7663
|
+
var key = out.cw + 'x' + out.scw;
|
|
7664
|
+
if (LR_WIDE.key === key && LR_WIDE.out) { out.wide = LR_WIDE.out.wide; return out; }
|
|
7665
|
+
var all = document.body ? document.body.getElementsByTagName('*') : [], edge = out.cw + 1, sx = window.scrollX || 0;
|
|
7666
|
+
for (var i = 0; i < all.length && i < 4000; i++) {
|
|
7667
|
+
var el = all[i], r = el.getBoundingClientRect();
|
|
7668
|
+
if (!(r.width > 0 && r.right + sx > edge)) continue;
|
|
7669
|
+
out.wide = (el.tagName.toLowerCase() + (el.id ? '#' + el.id : (typeof el.className === 'string' && el.className.trim() ? '.' + el.className.trim().split(/\s+/)[0] : ''))).slice(0, 24);
|
|
7670
|
+
break;
|
|
7671
|
+
}
|
|
7672
|
+
LR_WIDE.key = key; LR_WIDE.out = { wide: out.wide };
|
|
7673
|
+
} catch (_e) { /* a report only */ }
|
|
7674
|
+
return out;
|
|
7675
|
+
}
|
|
6202
7676
|
function lrDevice() {
|
|
6203
7677
|
if (LR.device) return;
|
|
6204
7678
|
LR.device = true;
|
|
6205
|
-
var sc = window.screen || {};
|
|
7679
|
+
var sc = window.screen || {}, w = lrWide();
|
|
6206
7680
|
layoutCrumb('device', { sw: sc.width, sh: sc.height, sah: sc.availHeight, oh: window.outerHeight, ow: window.outerWidth, dpr: window.devicePixelRatio,
|
|
6207
7681
|
sat: lrProbe('padding-top:env(safe-area-inset-top)', 'paddingTop'), sab: lrProbe('padding-bottom:env(safe-area-inset-bottom)', 'paddingBottom'),
|
|
6208
|
-
anyCoarse: lrMatch('(any-pointer: coarse)'), shortTouch: lrMatch(SHORT_TOUCH_MQ), phone: isPhone() });
|
|
7682
|
+
anyCoarse: lrMatch('(any-pointer: coarse)'), shortTouch: lrMatch(SHORT_TOUCH_MQ), phone: isPhone(), cw: w.cw, dw: w.dw, scw: w.scw, wide: w.wide });
|
|
6209
7683
|
}
|
|
6210
7684
|
// The focus line is for touch screens only (QA W3): on a Mac it would only spend the budget.
|
|
6211
7685
|
function lrFocus() {
|
|
@@ -6215,7 +7689,9 @@
|
|
|
6215
7689
|
if (!lrMatch('(pointer: coarse)')) return;
|
|
6216
7690
|
try { fs = parseFloat(getComputedStyle(a).fontSize); } catch (_e) { fs = null; }
|
|
6217
7691
|
var s = lrScroll();
|
|
6218
|
-
|
|
7692
|
+
// The width numbers only when this focus line will be sent (its own budget), so a capped page never scans.
|
|
7693
|
+
var w = typeof crumbs === 'object' && crumbs && crumbs.focus >= LAYOUT_FOCUS_MAX ? { cw: null, dw: null, wide: '' } : lrWide();
|
|
7694
|
+
layoutCrumb('focus', { pane: lrPane(), field: lrKind(a), fs: fs, sy: s.sy, smax: s.smax, scw: s.sw, kb: LR.up, n: ++LR.focusN, cw: w.cw, dw: w.dw, wide: w.wide });
|
|
6219
7695
|
}
|
|
6220
7696
|
function lrHooks(what, v) { LR.hooks.forEach(function (fn) { try { fn(what, v); } catch (err) { console.error(err); } }); }
|
|
6221
7697
|
function lrGap(v) {
|
|
@@ -6232,9 +7708,21 @@
|
|
|
6232
7708
|
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
7709
|
dvh: lrProbe('height:100dvh', 'h'), svh: lrProbe('height:100svh', 'h'), lvh: lrProbe('height:100lvh', 'h'), lh: v.layoutH });
|
|
6234
7710
|
}
|
|
7711
|
+
// 7.0.17: when the page's zoom crosses 1.01 either way, one breadcrumb (inside layoutCrumb's caps): the scale, the
|
|
7712
|
+
// pane, the focused element's tag (never its text or value) and how long since the last touch. A double-tap zoom
|
|
7713
|
+
// reads as a few hundred ms after a touch with no field focused; a focus zoom as an INPUT or TEXTAREA. Read only:
|
|
7714
|
+
// nothing here changes the zoom.
|
|
7715
|
+
function zoomCrumb(v) {
|
|
7716
|
+
var zoomed = !!(v && v.zoomed);
|
|
7717
|
+
if (zoomed === LR.zoomed) return false;
|
|
7718
|
+
LR.zoomed = zoomed;
|
|
7719
|
+
var a = document.activeElement, tag = a && a !== document.body && a.tagName ? String(a.tagName).toLowerCase().slice(0, 24) : 'none';
|
|
7720
|
+
return layoutCrumb(zoomed ? 'zoom-in' : 'zoom-out', { pane: lrPane(), scale: v.scale, tag: tag, sinceTouch: LR.touchAt ? Date.now() - LR.touchAt : -1 });
|
|
7721
|
+
}
|
|
6235
7722
|
function lrApply() {
|
|
6236
7723
|
LR.frame = 0;
|
|
6237
7724
|
var v = viewportFit(), de = document.documentElement;
|
|
7725
|
+
zoomCrumb(v);
|
|
6238
7726
|
// Written only when they change: a pinch or pan fires this every frame, and each write restyles the page.
|
|
6239
7727
|
var box = v.top + '|' + v.height + '|' + v.dock, moved = box !== LR.box;
|
|
6240
7728
|
if (moved && de && de.style && de.style.setProperty) {
|
|
@@ -6262,23 +7750,45 @@
|
|
|
6262
7750
|
if (vv && vv.addEventListener) { vv.addEventListener('resize', lrFit); vv.addEventListener('scroll', lrFit); }
|
|
6263
7751
|
window.addEventListener('resize', lrFit); window.addEventListener('orientationchange', lrFit); window.addEventListener('pageshow', lrFit);
|
|
6264
7752
|
document.addEventListener('focusin', function () { lrFocus(); lrFit(); });
|
|
7753
|
+
// 7.0.17: when the last touch landed, for the zoom breadcrumb only. Passive: it never blocks a gesture.
|
|
7754
|
+
document.addEventListener('touchstart', function () { LR.touchAt = Date.now(); }, { capture: true, passive: true });
|
|
6265
7755
|
document.addEventListener('focusout', function () { setTimeout(lrFit, 0); });
|
|
6266
7756
|
shellHooks.push(function () { lrDevice(); lrFit(); });
|
|
6267
7757
|
})();
|
|
7758
|
+
// 7.0.17 (Miles 2026-11-10, the Duo web app zooming in and out around the keyboard): on iOS only, the viewport is
|
|
7759
|
+
// capped at maximum-scale=1, once, as the page loads. Other platforms keep the viewport exactly as it is. Never while
|
|
7760
|
+
// the page is already zoomed (a reload mid-zoom), so the zoom is never changed under the person's fingers; nothing
|
|
7761
|
+
// calls this again after load.
|
|
7762
|
+
function viewportCap(meta, ua, vv) {
|
|
7763
|
+
if (!meta || ua.os !== 'ios') return false;
|
|
7764
|
+
if (vv && vv.scale > 1.01) return false;
|
|
7765
|
+
var content = String(meta.getAttribute('content') || '');
|
|
7766
|
+
if (/maximum-scale\s*=/.test(content)) return false;
|
|
7767
|
+
meta.setAttribute('content', content + (content ? ', ' : '') + 'maximum-scale=1');
|
|
7768
|
+
return true;
|
|
7769
|
+
}
|
|
7770
|
+
try { viewportCap(document.querySelector('meta[name="viewport"]'), uaClass(), window.visualViewport); } catch (_e) { /* the viewport stays as it is */ }
|
|
6268
7771
|
window.COSWeb.keyboardUp = function () { return LR.up; };
|
|
6269
7772
|
window.COSWeb.onKeyboard = function (fn) { if (typeof fn === 'function') LR.hooks.push(fn); };
|
|
6270
7773
|
// ── end layout reports
|
|
6271
7774
|
|
|
6272
7775
|
// Every pane module is a plain script after this one; boot waits for all of them.
|
|
6273
7776
|
function boot() {
|
|
7777
|
+
// The open-time report's clock (ms since navigation start, -1 where there is none) and the two numbers boot gives it:
|
|
7778
|
+
// when boot started (every script had run) and the sign-in check's round trip. Kept inside boot, and FIRST_PAINT
|
|
7779
|
+
// looked up, never assumed, because the harnesses that run boot alone have neither.
|
|
7780
|
+
var openClock = function () { try { return typeof performance === 'object' && performance && performance.now ? performance.now() : -1; } catch (_e) { return -1; } };
|
|
7781
|
+
var openTimes = typeof FIRST_PAINT === 'object' && FIRST_PAINT ? FIRST_PAINT : null;
|
|
7782
|
+
var openRtt = function (asked) { if (openTimes && asked >= 0 && openTimes.rtt == null) openTimes.rtt = Math.round(openClock() - asked); };
|
|
7783
|
+
if (openTimes && openTimes.boot == null) openTimes.boot = Math.round(openClock());
|
|
6274
7784
|
var params = new URLSearchParams(location.search);
|
|
6275
7785
|
var linkCode = params.get('code');
|
|
6276
7786
|
if (linkCode && !params.get('handoff')) {
|
|
6277
7787
|
history.replaceState({}, document.title, location.pathname + location.hash);
|
|
6278
7788
|
api('/api/web-session/login', { method: 'POST', body: { code: linkCode } }).then(showShell)
|
|
6279
7789
|
.catch(function (err) {
|
|
6280
|
-
|
|
6281
|
-
var box = $('auth-code'); if (box) box.value = linkCode;
|
|
7790
|
+
if (!(err && err.status)) { showAuth(UNREACHABLE_COPY); var box0 = $('auth-code'); if (box0) box0.value = linkCode; return; }
|
|
7791
|
+
return linkFailed(err.message, function () { var box = $('auth-code'); if (box) box.value = linkCode; });
|
|
6282
7792
|
});
|
|
6283
7793
|
return;
|
|
6284
7794
|
}
|
|
@@ -6286,10 +7796,72 @@
|
|
|
6286
7796
|
if (handoff) {
|
|
6287
7797
|
history.replaceState({}, document.title, location.pathname + location.hash);
|
|
6288
7798
|
api('/api/web-session/login', { method: 'POST', body: { handoff: handoff } }).then(showShell)
|
|
6289
|
-
.catch(function (err) {
|
|
7799
|
+
.catch(function (err) {
|
|
7800
|
+
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.');
|
|
7801
|
+
return linkFailed(err.message);
|
|
7802
|
+
});
|
|
6290
7803
|
return;
|
|
6291
7804
|
}
|
|
6292
|
-
|
|
7805
|
+
// The status read stays here in boot() itself (routes/web-phone-link.test.ts runs boot alone and pins it).
|
|
7806
|
+
// The open-time report carries its round trip (firstPaintDone).
|
|
7807
|
+
var asked = openClock();
|
|
7808
|
+
api('/api/web-session/status').then(function (st) { openRtt(asked); return bootAnswer(st); }, function (err) { openRtt(asked); return bootFail(err); });
|
|
7809
|
+
}
|
|
7810
|
+
// 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
|
|
7811
|
+
// old QR, a reopened tab). Ask status first: signed in opens the app and says the link was old; signed out shows the
|
|
7812
|
+
// refusal as before, with nothing else changed. A status read that fails also shows the refusal.
|
|
7813
|
+
var OLD_LINK_COPY = 'That sign-in link was old or already used. This browser is still signed in.';
|
|
7814
|
+
function linkFailed(message, after) {
|
|
7815
|
+
return api('/api/web-session/status').then(function (st) {
|
|
7816
|
+
if (st && st.authenticated) { showShell(st); toast(OLD_LINK_COPY); return; }
|
|
7817
|
+
showAuth(message); if (after) after();
|
|
7818
|
+
}, function () { showAuth(message); if (after) after(); });
|
|
7819
|
+
}
|
|
7820
|
+
// The same read on every retry of the not-answering card.
|
|
7821
|
+
function bootStatus() {
|
|
7822
|
+
return api('/api/web-session/status').then(function (st) { return bootAnswer(st); }, function (err) { return bootFail(err); });
|
|
7823
|
+
}
|
|
7824
|
+
function bootAnswer(st) {
|
|
7825
|
+
if (st && st.authenticated) return showShell(st);
|
|
7826
|
+
// 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).
|
|
7827
|
+
showAuth(typeof signinKnown === 'function' && signinKnown() ? signinLapsedCopy() : undefined);
|
|
7828
|
+
}
|
|
7829
|
+
function bootFail(err) {
|
|
7830
|
+
// 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.
|
|
7831
|
+
if (err && err.status) showAuth(err.message); else { showAuth(); authOffline(true); }
|
|
7832
|
+
}
|
|
7833
|
+
// Q1 M2: the not-answering card checks again every 30 s while the page is in front, and stops while it is hidden.
|
|
7834
|
+
var AUTH_RETRY = { timer: null, ms: 30000 };
|
|
7835
|
+
function authRetrySchedule(on) {
|
|
7836
|
+
if (AUTH_RETRY.timer) { clearTimeout(AUTH_RETRY.timer); AUTH_RETRY.timer = null; }
|
|
7837
|
+
if (!on || document.hidden) return;
|
|
7838
|
+
AUTH_RETRY.timer = setTimeout(function () { AUTH_RETRY.timer = null; if (!document.hidden) bootStatus(); }, AUTH_RETRY.ms);
|
|
6293
7839
|
}
|
|
6294
|
-
|
|
7840
|
+
document.addEventListener('visibilitychange', function () {
|
|
7841
|
+
var box = $('auth-offline');
|
|
7842
|
+
var showing = !!(box && !box.classList.contains('hidden'));
|
|
7843
|
+
if (!showing) return;
|
|
7844
|
+
if (document.hidden) authRetrySchedule(false); else bootStatus();
|
|
7845
|
+
});
|
|
7846
|
+
// The sign-in card's "Your Mac is not answering" state (boot only): the form hides until the Mac answers.
|
|
7847
|
+
function authOffline(on) {
|
|
7848
|
+
var box = $('auth-offline'), form = $('auth-form');
|
|
7849
|
+
if (!box || !form) return;
|
|
7850
|
+
box.classList.toggle('hidden', !on); form.classList.toggle('hidden', !!on);
|
|
7851
|
+
var title = $('auth-title'), intro = $('auth-intro');
|
|
7852
|
+
var offline = typeof connOffline === 'function' && connOffline();
|
|
7853
|
+
if (title) title.textContent = on ? (offline ? 'This device is offline' : 'COS on your Mac is not answering') : 'Connect this browser';
|
|
7854
|
+
if (intro) intro.classList.toggle('hidden', !!on);
|
|
7855
|
+
var text = $('auth-offline-text');
|
|
7856
|
+
if (text && on) text.textContent = offline ? 'Connect to the internet and Tailscale. This page checks again every 30 seconds.'
|
|
7857
|
+
: 'Your Mac may be asleep, or this device may be offline or off Tailscale. This page checks again every 30 seconds.';
|
|
7858
|
+
authRetrySchedule(!!on);
|
|
7859
|
+
if (on) { var err = $('auth-error'); if (err) { setStatus(err, '', true); err.classList.add('hidden'); } }
|
|
7860
|
+
}
|
|
7861
|
+
if (document.readyState === 'loading' && !cosBootAfter()) document.addEventListener('DOMContentLoaded', boot);
|
|
7862
|
+
else if (cosBootAfter()) cosBootAfter()(boot);
|
|
7863
|
+
else boot();
|
|
7864
|
+
// The scripts come from theme.js's loader (index.html #boot-scripts), which runs boot once every one of them has run
|
|
7865
|
+
// (window.CosBoot.after). Without it (a page from before the loader), as before.
|
|
7866
|
+
function cosBootAfter() { return window.CosBoot && typeof window.CosBoot.after === 'function' ? window.CosBoot.after : null; }
|
|
6295
7867
|
})();
|