@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.
Files changed (97) hide show
  1. package/.env.example +19 -0
  2. package/CHANGELOG.md +305 -0
  3. package/docs/web-key-verifier-contract.md +378 -0
  4. package/managed-runtime-contract.json +9 -1
  5. package/package.json +10 -3
  6. package/server/bootstrap.ts +10 -0
  7. package/server/index.ts +129 -23
  8. package/server/lib/agent-session-older.ts +218 -0
  9. package/server/lib/agent-session-recent.ts +321 -0
  10. package/server/lib/agent-session-store.ts +102 -20
  11. package/server/lib/api-auth.ts +37 -1
  12. package/server/lib/calendar-meetings.ts +70 -0
  13. package/server/lib/calendar-source.ts +54 -9
  14. package/server/lib/claude-hooks-installer.ts +111 -12
  15. package/server/lib/conversation.ts +56 -28
  16. package/server/lib/cors-reject-log.ts +51 -0
  17. package/server/lib/cos-started-sessions.ts +101 -0
  18. package/server/lib/disk-io-meter.ts +202 -0
  19. package/server/lib/drop-service.ts +47 -12
  20. package/server/lib/drop-store.ts +21 -1
  21. package/server/lib/event-loop-stall.ts +62 -0
  22. package/server/lib/full-hold-engine.ts +114 -0
  23. package/server/lib/full-hold.ts +124 -0
  24. package/server/lib/g2-turn-provenance.ts +128 -9
  25. package/server/lib/list-model-memo.ts +85 -0
  26. package/server/lib/listener-startup.ts +18 -0
  27. package/server/lib/meeting-batch-transcribe.ts +32 -5
  28. package/server/lib/model-efforts.ts +40 -0
  29. package/server/lib/permission-broker.ts +443 -11
  30. package/server/lib/secret-path-guard.ts +4 -1
  31. package/server/lib/session-cache-writer.ts +8 -1
  32. package/server/lib/session-hook-events.ts +23 -1
  33. package/server/lib/session-hooks-runtime.ts +34 -0
  34. package/server/lib/session-log.ts +313 -4
  35. package/server/lib/session-organizer.ts +305 -0
  36. package/server/lib/session-state-derive.ts +36 -0
  37. package/server/lib/session-stream-events.ts +39 -3
  38. package/server/lib/thread-turn-queue.ts +24 -0
  39. package/server/lib/today-brief.ts +522 -58
  40. package/server/lib/turn-end-ask.ts +320 -0
  41. package/server/lib/turn-footer.ts +3 -3
  42. package/server/lib/web-control-integrity.ts +160 -0
  43. package/server/lib/web-control-offnet-page.ts +58 -0
  44. package/server/lib/web-control-static.ts +53 -9
  45. package/server/lib/web-key-authority.ts +372 -0
  46. package/server/lib/web-key-envelope.ts +98 -0
  47. package/server/lib/web-key-handoff.ts +135 -0
  48. package/server/lib/web-key-origin.ts +21 -0
  49. package/server/lib/web-key-passkey.ts +155 -0
  50. package/server/lib/web-key-registry.ts +825 -0
  51. package/server/lib/web-key-signer-app.ts +152 -0
  52. package/server/lib/web-session-auth.ts +133 -15
  53. package/server/lib/whisper-local.ts +93 -7
  54. package/server/lib/whisper-preview.ts +5 -3
  55. package/server/lib/work-orchestrator/snapshot.ts +17 -3
  56. package/server/lib/work-review-runtime.ts +17 -4
  57. package/server/routes/agent-session-bindings.ts +16 -1
  58. package/server/routes/agent-session-stream.ts +7 -0
  59. package/server/routes/agent-sessions.ts +106 -2
  60. package/server/routes/drops.ts +7 -2
  61. package/server/routes/g2-turns.ts +35 -0
  62. package/server/routes/health.ts +14 -1
  63. package/server/routes/live-events.ts +6 -1
  64. package/server/routes/permission-broker.ts +26 -0
  65. package/server/routes/query-jobs.ts +3 -0
  66. package/server/routes/session-organizer.ts +149 -0
  67. package/server/routes/sessions.ts +15 -24
  68. package/server/routes/thread-turn-queue.ts +50 -14
  69. package/server/routes/today-brief.ts +91 -36
  70. package/server/routes/web-control.ts +37 -4
  71. package/server/routes/web-key.ts +284 -0
  72. package/server/routes/welcome-context.ts +6 -2
  73. package/server/routes/work-orchestrator.ts +12 -7
  74. package/server/scripts/write-web-control-manifest.mjs +43 -0
  75. package/server/web-control/.manifest.json +44 -0
  76. package/server/web-control/app.css +45 -0
  77. package/server/web-control/app.js +1680 -108
  78. package/server/web-control/apple-touch-icon.png +0 -0
  79. package/server/web-control/index.html +54 -17
  80. package/server/web-control/panes/cos-chat.css +52 -6
  81. package/server/web-control/panes/cos-chat.js +364 -32
  82. package/server/web-control/panes/questions.js +135 -14
  83. package/server/web-control/panes/sessions.css +280 -0
  84. package/server/web-control/panes/settings.js +1 -1
  85. package/server/web-control/panes/today-actions.css +35 -0
  86. package/server/web-control/panes/today-actions.js +121 -6
  87. package/server/web-control/panes/work.css +3 -0
  88. package/server/web-control/panes/work.js +185 -38
  89. package/server/web-control/theme.js +33 -0
  90. package/server/web-signer/.manifest.json +11 -0
  91. package/server/web-signer/index.html +74 -0
  92. package/server/web-signer/signer-proof.js +348 -0
  93. package/server/web-signer/signer-store.js +39 -0
  94. package/server/web-signer/signer.css +23 -0
  95. package/server/web-signer/signer.js +341 -0
  96. package/shared/web-approval-packets.golden.json +124 -0
  97. 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
- if (res.status === 401 && path.indexOf('/api/web-session/') !== 0) { showAuth(); var gone = new Error('Signed out. Connect this browser again.'); gone.status = 401; throw gone; }
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
- SL.inflight = api('/api/agent-sessions?limit=80&sort=updated').then(function (d) {
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. Never shown over a session list that was not read.
1354
- var BRIEF = { view: null, inflight: false, sentKey: '', sentAt: 0, reconnect: false };
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, only over a session list that was read. */
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') || TODAY.sessionsState !== 'answered') return null;
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
- if (TODAY.offline) { el.textContent = 'COS on ' + state.serverName + ' is not answering right now. Today fills in when it does.'; return; }
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
- if (brief) { el.innerHTML = esc(brief.text) + '<span class="story-when">' + esc(briefWhen(brief, m)) + '</span>'; return; }
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.textContent = out;
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
- $('run-count').textContent = working.length ? String(working.length) : '';
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 = sxRow(s, null, amb);
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
- return 'Waiting for you' + (sxProvider(s.provider) === 'claude' ? '. Paused until you answer.' : '');
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
- var words = mac.length && q.wordsOf ? q.wordsOf(mac[0].id) : null;
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 || sxProvider(provider) === 'claude') return { where: 'mac', ask: ask };
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
- '<button type="button" class="need-open" data-today="wait" data-key="' + esc(r.key) + '">' + top(r.title) + '<span class="need-foot">' + esc(r.ask || r.why) + '</span>' +
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, next 3), under Next up.
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) { box.classList.add('hidden'); box.innerHTML = ''; return; }
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) { box.classList.add('hidden'); box.innerHTML = ''; return; }
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 rows = (c.next || []).filter(function (r) { return !r.allDay && !same(r); }).slice(0, 2);
1865
- var asOf = c.stale && c.asOf ? toDate(c.asOf) : null;
1866
- var html = rows.map(function (r) {
1867
- var rd = toDate(r.start);
1868
- return '<div class="later-row"><span class="later-when">' + esc(rd ? clock(rd) : '') + '</span><span class="later-what">' + esc(r.title) + '</span></div>';
1869
- }).join('');
1870
- if (asOf) html += '<div class="later-asof">' + esc('Calendar as of ' + clock(asOf)) + '</div>';
1871
- box.innerHTML = html;
1872
- box.classList.toggle('hidden', !html);
1873
- }, function () { box.classList.add('hidden'); box.innerHTML = ''; });
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
- clock: sxPref('clock', 'updated', ['updated', 'opened', 'pinned']),
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
- var t = plain(s.display_label || s.custom_title || '').replace(/\s+/g, ' ').trim();
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
- var clockOn = view === 'sessions' && !sxQueryActive();
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
- function sxRow(s, hit, ambiguous) {
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 st = sxState(s), label = sxStateLabel(s, st), title = sxTitle(s);
2178
- var upd = toDate(s.modified), opened = toDate(s.created);
2179
- var meta = sxBadge(key.provider) +
2180
- (s.pinned ? '<span class="sx-pinned">PINNED</span>' : '') +
2181
- (label ? '<span class="sx-chip ' + st + '">' + esc(label) + '</span>' : '') +
2182
- (notSentFor(key.provider, key.id) ? '<span class="sx-chip refused" title="' + esc(notSentFor(key.provider, key.id).line) + '">' + esc(notSentFor(key.provider, key.id).label || 'Not sent') + '</span>' : '') +
2183
- (upd ? '<span class="sx-when">Updated ' + esc(sxShortDate(upd)) + '</span>' : '') +
2184
- (ambiguous[title] && opened ? '<span class="sx-opened" title="Another session on screen has the same name. This one was opened ' + esc(sxShortDate(opened)) + '.">Opened ' + esc(sxShortDate(opened)) + '</span>' : '') +
2185
- (s.project && String(s.project) !== title ? '<span class="sx-ws">' + esc(s.project) + '</span>' : '');
2186
- var match = hit ? (hit.match === 'both' ? 'Keyword + meaning' : hit.match === 'semantic' ? 'Meaning' : 'Keyword') : '';
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 '<button type="button" class="cx-row sx-row" data-session="' + esc(key.provider) + '|' + esc(key.id) + '"' + (selected ? ' aria-selected="true"' : '') + '>' +
2190
- sxGlyph(key.provider) +
2191
- '<span class="cx-row-body"><span class="sx-title">' + esc(title) + '</span>' +
2192
- (snippet && snippet !== title ? '<span class="sx-snippet">' + esc(snippet) + '</span>' : '') +
2193
- '<span class="sx-meta">' + meta + '</span></span>' +
2194
- '<span class="sx-side">' + (match ? '<span class="sx-match">' + match + '</span>' : '') + '<span class="chev"></span></span></button>';
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 vis = sxVisible();
2200
- if (!vis.length) return sxEmpty(SX.error || sxEmptyCopy());
2201
- var amb = sxAmbiguous(vis);
2202
- return '<div class="cx-list sx-list">' + vis.map(function (s) { return sxRow(s, null, amb); }).join('') + '</div>';
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
- // The meaning finder the Ask sheet offers ("Find a session about this").
2326
- function findByMeaning(q) {
2327
- return api('/api/agent-sessions/find', { method: 'POST', body: { query: q } }).then(function (d) {
2328
- if (d && d.available === false) return emptyBox('Meaning search is off', d.message || 'Turn on a decision provider in Settings.');
2329
- var results = (d && d.results) || [];
2330
- if (!results.length) return emptyBox('Nothing matched that meaning', 'Try different words, or search by Words.');
2331
- return '<h2 class="group-title"><span>About “' + esc(q) + '”</span><span>' + results.length + '</span></h2><div class="list">' + results.map(function (r) {
2332
- var s = r.session || r;
2333
- if (!s.provider && r.provider) s.provider = r.provider;
2334
- if (!s.session_id) s.session_id = r.sessionId || r.id;
2335
- return sessionRow(s, r.reason || s.project);
2336
- }).join('') + '</div>';
2337
- }).catch(function (err) { return emptyBox('Meaning search did not run', err.message); });
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
- if ($('sessions-recency')) $('sessions-recency').value = SX.recency;
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
- if (id === 'sessions-recency') { SX.recency = e.target.value; sxSave(); return renderSessionGroups(); }
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
- var SX_SPLIT_OPTS = { key: SX_SPLIT_KEY, prefix: 'sx', label: 'Session list', fullSel: '[data-sx="full"]', media: '(min-width: 760px)',
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' : 'Full width') + '</button>';
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
- (trunc ? '<p class="sx-note">' + esc(trunc) + '</p>' : '') +
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
- return '<div class="sx-queue-row"><span class="sx-queue-state">' + esc(sxQueueLine(t)) + '</span><span class="sx-queue-text">' + esc(t.preview || t.prompt || 'Message') + '</span>' +
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
- if (input && input.value.trim() === String(text).trim()) { input.value = ''; autoGrow(input); }
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 > 0) { anchor = cards[i].getAttribute('data-sig'); top = rect.top; break; }
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) window.scrollBy(0, same.getBoundingClientRect().top - top);
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 || !(vv.height > 0)) return { layoutH: H, top: 0, height: H, inset: 0, below: 0, dock: 0, scale: 1, zoomed: false, vv: false };
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
- layoutCrumb('focus', { pane: lrPane(), field: lrKind(a), fs: fs, sy: s.sy, smax: s.smax, scw: s.sw, kb: LR.up, n: ++LR.focusN });
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
- showAuth(err && err.status ? err.message : UNREACHABLE_COPY);
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) { showAuth(err && err.status ? err.message : 'That sign-in link did not work. Get a new one from COS Control on your Mac or the COS Glasses app, or use a code or the token.'); });
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
- api('/api/web-session/status').then(function (st) { if (st && st.authenticated) showShell(st); else showAuth(); }).catch(function () { showAuth(UNREACHABLE_COPY); });
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
- if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); else boot();
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
  })();