@gotcos/glasses-server 7.0.18 → 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.
@@ -555,6 +555,7 @@
555
555
  if (r.pane !== 'meetings' && state.livePoll) { clearInterval(state.livePoll); state.livePoll = null; }
556
556
 
557
557
  var reading = r.args.length > 0 && !isTabRoute(r.args);
558
+ if (typeof sxFrameSync === 'function') sxFrameSync();
558
559
  var paneEl = $('pane-' + r.pane);
559
560
  if (paneEl) paneEl.classList.toggle('reading', reading);
560
561
  var split = paneEl && paneEl.querySelector('.split');
@@ -627,7 +628,28 @@
627
628
  function closeMenu() { $('section-menu').classList.add('hidden'); }
628
629
 
629
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
+ }
630
651
  function showAuth(message) {
652
+ if (typeof firstPaintDone === 'function') firstPaintDone('auth'); // harnesses that run this alone have none
631
653
  readerImagesStop($('session-turns')); readerImagesStop($('ta-last'));
632
654
  if (typeof liveHide === 'function') { liveHide(); liveStopWatch(); LIVE.mode = 'idle'; }
633
655
  var alreadyOpen = !$('auth-gate').classList.contains('hidden');
@@ -635,6 +657,10 @@
635
657
  if (typeof authOffline === 'function') authOffline(false);
636
658
  $('app-shell').classList.add('hidden');
637
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');
638
664
  // A later 401 from a pane still polling (W.api passes no message) must not blank what the gate already says:
639
665
  // the reason it opened, or "That code did not match" while the person retypes.
640
666
  if (!message && alreadyOpen) return;
@@ -643,6 +669,7 @@
643
669
  }
644
670
  function signedInOnce() { var shell = typeof $ === 'function' ? $('app-shell') : null; return !!(shell && !shell.classList.contains('hidden')) || CONN.lapsed; }
645
671
  function showShell(info) {
672
+ if (typeof firstPaintDone === 'function') firstPaintDone('shell'); // harnesses that run this alone have none
646
673
  if (typeof signinRemember === 'function') signinRemember(info);
647
674
  if (typeof authOffline === 'function') authOffline(false);
648
675
  $('auth-gate').classList.add('hidden');
@@ -681,7 +708,9 @@
681
708
  function sessionList(maxAgeMs) {
682
709
  if (SL.data && Date.now() - SL.at < (maxAgeMs || 0)) return Promise.resolve(SL.data);
683
710
  if (SL.inflight) return SL.inflight;
684
- 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) {
685
714
  // 7.0.5: a sessions read (Sessions pane or Today) that follows a failed one is a reconnect: Today's next summary
686
715
  // ask says so (requestBrief), once.
687
716
  if (SL.failed) { SL.failed = false; if (typeof BRIEF === 'object' && BRIEF) BRIEF.reconnect = true; }
@@ -1732,14 +1761,17 @@
1732
1761
  if (!list) return;
1733
1762
  if (sxQueryActive()) { paintSessionsHero(); return; }
1734
1763
  if (structural) { renderSessionGroups(); return; }
1735
- var amb = sxAmbiguous(sxVisible());
1764
+ var amb = sxAmbiguous(sxVisible()), moved = false;
1736
1765
  (rows || []).forEach(function (s) {
1737
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;
1738
1770
  var k = splitSessionId(s.provider, s.session_id || s.id), want = k.provider + '|' + k.id;
1739
1771
  var els = list.querySelectorAll('[data-session]');
1740
1772
  for (var i = 0; i < els.length; i++) {
1741
1773
  if (els[i].getAttribute('data-session') !== want) continue;
1742
- var html = sxRow(s, null, amb);
1774
+ var html = live.html;
1743
1775
  if (els[i].__liveHtml !== html) {
1744
1776
  var tmp = document.createElement('div'); tmp.innerHTML = html;
1745
1777
  var next = tmp.firstElementChild || tmp.firstChild;
@@ -1748,6 +1780,7 @@
1748
1780
  break;
1749
1781
  }
1750
1782
  });
1783
+ if (moved) sxRegroupLater();
1751
1784
  paintSessionsHero();
1752
1785
  }
1753
1786
  // ── Today's header line and Refresh ──
@@ -2476,7 +2509,8 @@
2476
2509
  }
2477
2510
  var SX = {
2478
2511
  rows: null, dropped: null, enabled: true, loading: false, error: '', loadedAt: 0,
2479
- 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']),
2480
2514
  view: sxPref('view', 'sessions', ['sessions', 'usage', 'cost']),
2481
2515
  recency: sxPref('recency', 'newest', ['newest', 'oldest', 'match']),
2482
2516
  q: '', instant: [], remote: [], searching: false, searchError: '', ranFor: '', semantic: null, semanticReason: '', searchSeq: 0, searchTimer: null,
@@ -2506,7 +2540,9 @@
2506
2540
 
2507
2541
  // ClaudeSession.title: the job label, else the name, else the workspace, else the id.
2508
2542
  function sxTitle(s) {
2509
- 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();
2510
2546
  return t || String(s.project || '').trim() || String(s.session_id || s.id || 'Session');
2511
2547
  }
2512
2548
  function sxWarmWords(t) { t = String(t || '').trim().toLowerCase(); return t === 'ready' || t.indexOf('this is an automated local readiness check') === 0; }
@@ -2619,7 +2655,8 @@
2619
2655
  '<div class="cx-hero-text"><h1 class="cx-title">Sessions</h1><p class="cx-detail-line">' + esc(detail) + liveHeroNote(view) + '</p></div>' +
2620
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>' +
2621
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>' : '');
2622
- 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;
2623
2660
  views.innerHTML = '<div class="cx-switch" role="group" aria-label="Sessions view">' +
2624
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('') +
2625
2662
  '</div>' +
@@ -2629,6 +2666,115 @@
2629
2666
  var split = $('sessions-split'), usage = $('sessions-usage');
2630
2667
  if (split) split.classList.toggle('hidden', view !== 'sessions');
2631
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
+ });
2632
2778
  }
2633
2779
 
2634
2780
  // 7.0.1: the hero's counts say when they are not live ("Reconnecting", "as of 2 min ago"); nothing without a stream.
@@ -2668,6 +2814,8 @@
2668
2814
  state.sessions = SX.rows;
2669
2815
  if (sxQueryActive()) SX.instant = sxInstant(SX.q);
2670
2816
  renderSessionGroups();
2817
+ sxLoadOlder(force === true);
2818
+ if (typeof sxLoadFolders === 'function') sxLoadFolders(force === true);
2671
2819
  }).catch(function (err) {
2672
2820
  SX.loading = false;
2673
2821
  SX.error = (err && err.message) || 'Sessions did not load';
@@ -2693,36 +2841,409 @@
2693
2841
  }
2694
2842
  // Control's row: glyph, title, a snippet on search hits, then badge · PINNED · state · Updated ·
2695
2843
  // Opened (twins only, amber) · workspace; a match chip and a chevron on the right.
2696
- 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) {
2697
2865
  var key = splitSessionId(s.provider, s.session_id || s.id);
2698
2866
  var r = parseRoute();
2699
2867
  var selected = r.pane === 'sessions' && r.args[0] === key.provider && r.args[1] === key.id;
2700
- var st = sxState(s), label = sxStateLabel(s, st), title = sxTitle(s);
2701
- var upd = toDate(s.modified), opened = toDate(s.created);
2702
- var meta = sxBadge(key.provider) +
2703
- (s.pinned ? '<span class="sx-pinned">PINNED</span>' : '') +
2704
- (label ? '<span class="sx-chip ' + st + '">' + esc(label) + '</span>' : '') +
2705
- (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>' : '') +
2706
- (upd ? '<span class="sx-when">Updated ' + esc(sxShortDate(upd)) + '</span>' : '') +
2707
- (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>' : '') +
2708
- (s.project && String(s.project) !== title ? '<span class="sx-ws">' + esc(s.project) + '</span>' : '');
2709
- 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') : '';
2710
2895
  // An instant title hit's snippet is the title itself; it says nothing twice.
2711
2896
  var snippet = hit && hit.snippet ? plain(hit.snippet).replace(/\s+/g, ' ').trim() : '';
2712
- return '<button type="button" class="cx-row sx-row" data-session="' + esc(key.provider) + '|' + esc(key.id) + '"' + (selected ? ' aria-selected="true"' : '') + '>' +
2713
- sxGlyph(key.provider) +
2714
- '<span class="cx-row-body"><span class="sx-title">' + esc(title) + '</span>' +
2715
- (snippet && snippet !== title ? '<span class="sx-snippet">' + esc(snippet) + '</span>' : '') +
2716
- '<span class="sx-meta">' + meta + '</span></span>' +
2717
- '<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 };
2718
2973
  }
2719
2974
  function sxListHtml() {
2720
2975
  if (!SX.rows) return SX.error ? sxEmpty(SX.error) : skeletonRows(6);
2721
2976
  if (!SX.enabled && !SX.rows.length) return sxEmpty('AI sessions are off. Turn on Show AI sessions in Settings, Work and agents.');
2722
- var vis = sxVisible();
2723
- if (!vis.length) return sxEmpty(SX.error || sxEmptyCopy());
2724
- var amb = sxAmbiguous(vis);
2725
- 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
+ }
2726
3247
  }
2727
3248
  // The list, under the hero. The name stays: the shared input handler calls it when the search clears.
2728
3249
  function renderSessionGroups() {
@@ -2737,8 +3258,14 @@
2737
3258
  var note = sxQueryActive() && SX.ranFor === SX.q && SX.semantic === false ? sxSemanticHint(SX.semanticReason) : '';
2738
3259
  hint.textContent = note; hint.classList.toggle('hidden', !note);
2739
3260
  }
3261
+ sxListWatch();
3262
+ sxPaintListHead();
2740
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);
2741
3267
  list.innerHTML = sxQueryActive() ? sxSearchHtml() : sxListHtml();
3268
+ sxRestoreAnchor(col, at);
2742
3269
  }
2743
3270
 
2744
3271
  // Search (ActivityWindow.swift sessionsSearchBar 2652-2712, ControllerModel scheduleSessionSearch):
@@ -2956,15 +3483,22 @@
2956
3483
  // keep their data-action names, which the shared listener handles.
2957
3484
  var sxPane = $('pane-sessions');
2958
3485
  if (sxPane) {
2959
- if ($('sessions-recency')) $('sessions-recency').value = SX.recency;
3486
+ sxPaintSort();
2960
3487
  sxPane.addEventListener('click', function (e) {
2961
3488
  var t = e.target.closest('[data-sx]');
2962
3489
  if (!t || !sxPane.contains(t)) return;
2963
3490
  var act = t.getAttribute('data-sx'), rd = state.reading;
2964
- 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);
2965
3497
  if (act === 'clock') { SX.clock = t.getAttribute('data-clock'); sxSave(); return renderSessionGroups(); }
2966
3498
  if (act === 'refresh') {
3499
+ sxViewMenu(false);
2967
3500
  if (SX.view !== 'sessions') return renderUsageView(true);
3501
+ sxLoadOlder(true);
2968
3502
  SX.hooksAt = 0;
2969
3503
  loadSessions(true);
2970
3504
  if (sxQueryActive()) searchSessions();
@@ -2992,9 +3526,23 @@
2992
3526
  renderSessionGroups();
2993
3527
  SX.searchTimer = setTimeout(searchSessions, 350);
2994
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
+ });
2995
3536
  sxPane.addEventListener('change', function (e) {
2996
3537
  var id = e.target.id;
2997
- 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
+ }
2998
3546
  // Custom dates opens the date sheet; the menu goes back to the range in force if it is cancelled.
2999
3547
  if (id === 'usage-period') { if (e.target.value === U_CUSTOM) return uOpenRange(e.target, true); SX.uPeriod = e.target.value; uPeriodChanged(); return paintUsage(); }
3000
3548
  if (id === 'usage-tool') { SX.uTool = e.target.value; SX.uModel = 'All models'; SX.uToolBefore = null; uSliceChanged(); return paintUsage(); }
@@ -3013,6 +3561,12 @@
3013
3561
  // window) only means the choice lasts this visit. Below a pane's own breakpoint the divider is hidden by CSS and the
3014
3562
  // kit does nothing (o.media), so a phone's layout never changes.
3015
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;
3016
3570
  // o: key (storage), prefix (class names: <prefix>-sized, -full and -dragging), cssVar (the share, default
3017
3571
  // --<prefix>-list-share), label (what the divider sizes, for screen readers), sized (the column the share sizes,
3018
3572
  // default the list), side ('end' when that column is on the right: a side panel grows as the line moves left),
@@ -3048,7 +3602,7 @@
3048
3602
  split.classList.toggle(PRE + '-sized', share != null);
3049
3603
  if (share != null) split.style.setProperty(VAR, share.toFixed(2) + '%'); else split.style.removeProperty(VAR);
3050
3604
  split.classList.toggle(PRE + '-full', full);
3051
- 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'); });
3052
3606
  aria();
3053
3607
  }
3054
3608
  // px: the sized column's width asked for; kept inside both columns' minimums for the split's width now.
@@ -3061,6 +3615,26 @@
3061
3615
  if (persist) save();
3062
3616
  }
3063
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
+ }
3064
3638
  handle.addEventListener('pointerdown', function (e) {
3065
3639
  if (e.pointerType === 'mouse' && e.button !== 0) return;
3066
3640
  if (narrow()) return;
@@ -3081,7 +3655,7 @@
3081
3655
  handle.removeEventListener('pointermove', move); handle.removeEventListener('pointerup', end); handle.removeEventListener('pointercancel', end);
3082
3656
  split.classList.remove(PRE + '-dragging');
3083
3657
  // The lift's own position counts: a browser may fold the last moves into it.
3084
- 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; }
3085
3659
  if (ev.type !== 'pointerup') return;
3086
3660
  var now = Date.now();
3087
3661
  if (now - lastTap < SX_DOUBLE_MS) { lastTap = 0; reset(); } else lastTap = now;
@@ -3107,7 +3681,8 @@
3107
3681
  };
3108
3682
  }
3109
3683
  // Sessions: the first pane with the divider, unchanged (its markup is in index.html, its CSS in panes/sessions.css).
3110
- 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',
3111
3686
  listMin: SX_LIST_MIN, readerMin: SX_READER_MIN, handleW: SX_HANDLE_W };
3112
3687
  function sxSplitKit(split, handle, storage) { return splitKit(split, handle, storage, SX_SPLIT_OPTS); }
3113
3688
  // The other panes (W.splitKit): the divider's markup, a Full width button, and mount, which binds the kit to a split
@@ -3290,7 +3865,7 @@
3290
3865
  // Wide layouts: give the reader the whole width, or bring the list back (sxSplitKit). Hidden under 760 px by CSS.
3291
3866
  function sxFullButton() {
3292
3867
  var full = !!(SX_SPLIT && SX_SPLIT.state().full);
3293
- 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>';
3294
3869
  }
3295
3870
  function openSession(provider, id) {
3296
3871
  var reader = $('session-reader');
@@ -3317,17 +3892,25 @@
3317
3892
  reader.innerHTML =
3318
3893
  '<div class="sx-reader-head"><div class="sx-head-line">' + sxBadge(provider) + '<h2 class="sx-reader-title">' + esc(title) + '</h2>' + sxFullButton() + '</div>' +
3319
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">' +
3320
3911
  '<div class="sx-activity" id="session-live" aria-live="polite"></div>' +
3321
3912
  '<div class="sx-mac-q" id="session-mac-q" role="status" hidden></div>' +
3322
- (trunc ? '<p class="sx-note">' + esc(trunc) + '</p>' : '') +
3323
- '<div class="turns sx-turns" id="session-turns">' + renderTurns(turns, d) + '</div>' +
3324
- '<div class="sx-foot"><button type="button" class="cx-btn quiet" data-sx="copy-session" title="A kickstart brief for another agent, built from the turns COS serves. Not a Claude Code resume: it does not reopen this session.">Copy session</button>' +
3325
- '<span class="sx-foot-note">Kickstart brief for another agent. Not a Claude Code resume.</span></div>' +
3326
- '<p class="sx-copy-note hidden" id="session-copy-note" role="status"></p>' +
3327
3913
  '<div class="sx-chat">' +
3328
- // DESIGN.md step 4 #3: this session's question cards (held and answerable, or at the Mac with its words), above
3329
- // the verdict and the Send box, the same component Today's sheet mounts, so the full hold lights up here.
3330
- '<div class="sx-questions" id="session-questions"></div>' +
3331
3914
  '<div id="session-verdict"></div>' +
3332
3915
  '<form class="composer att-composer" id="session-form" data-provider="' + esc(provider) + '" data-id="' + esc(id) + '">' +
3333
3916
  '<div class="cc-chips att-files hidden" id="session-files"></div>' +
@@ -3337,13 +3920,16 @@
3337
3920
  '</form><div class="status-line" id="session-status"></div>' +
3338
3921
  '<p class="sx-caution hidden" id="session-caution"></p>' +
3339
3922
  '<div id="session-queue"></div>' +
3340
- '</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>';
3341
3927
  readerImagesMount($('session-turns'), r);
3342
3928
  sxNoteList(r, d);
3343
3929
  setLive({});
3344
3930
  if (typeof readerMacPaint === 'function') readerMacPaint(r, sxState(d) === 'waiting');
3345
3931
  var qk = window.COSWeb.sessionQuestions, qhost = $('session-questions');
3346
- if (qk && qk.mount && qhost) r.qMount = qk.mount(qhost, id, function () { if (state.reading === r && typeof readerMacPaint === 'function') readerMacPaint(r, r.waitingNow); });
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(); });
3347
3933
  paintReaderFiles();
3348
3934
  paintWebKeyOffer(r);
3349
3935
  // The Mac's answer about files may not be in yet: ask once, then draw the clip.
@@ -3355,7 +3941,8 @@
3355
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>');
3356
3942
  startHeldTimer(state.reading);
3357
3943
  }
3358
- 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'); });
3359
3946
  watchSession(provider, id);
3360
3947
  }).catch(function (err) {
3361
3948
  if (state.reading !== r) return;
@@ -3666,6 +4253,22 @@
3666
4253
  if (p === 2) return '3rd in line';
3667
4254
  return (p + 1) + 'th in line';
3668
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
+ }
3669
4272
  function loadQueue(provider, id) {
3670
4273
  apiAnswer(sessionPath(provider, id, '/queued-turns')).then(function (q) {
3671
4274
  var r = state.reading;
@@ -3681,7 +4284,9 @@
3681
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>' : '') +
3682
4285
  waiting.map(function (t) {
3683
4286
  var mine = t.owner !== 'codex' && parkedByMe(r, t.clientTurnId);
3684
- 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>' +
3685
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>';
3686
4291
  }).join('') +
3687
4292
  notSent.map(function (t) {
@@ -3818,7 +4423,7 @@
3818
4423
  if (heldKey) el.setAttribute('data-held-key', heldKey);
3819
4424
  el.innerHTML = '<span class="who">You, ' + esc(note) + '</span>' + md(text);
3820
4425
  box.appendChild(el);
3821
- requestAnimationFrame(function () { window.scrollTo(0, document.documentElement.scrollHeight); });
4426
+ requestAnimationFrame(function () { if (typeof sxToNewest === 'function') sxToNewest(); else window.scrollTo(0, document.documentElement.scrollHeight); });
3822
4427
  }
3823
4428
  function heldBubble(key) { return document.querySelector('#session-turns .turn.pending[data-held-key="' + key.replace(/[^A-Za-z0-9_-]/g, '') + '"]'); }
3824
4429
  function renderHeld(r) {
@@ -4671,15 +5276,23 @@
4671
5276
  box.insertAdjacentHTML('beforeend', fresh.map(sxTurnHtml).join(''));
4672
5277
  return;
4673
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;
4674
5283
  var anchor = null, top = 0, cards = box.querySelectorAll('.turn[data-sig]');
4675
5284
  for (var i = 0; i < cards.length; i++) {
4676
5285
  var rect = cards[i].getBoundingClientRect();
4677
- 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; }
4678
5287
  }
4679
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; }
4680
5291
  if (!anchor) return;
4681
5292
  var same = [].filter.call(box.querySelectorAll('.turn[data-sig]'), function (el) { return el.getAttribute('data-sig') === anchor; })[0];
4682
- 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);
4683
5296
  }
4684
5297
 
4685
5298
  // ── Ask COS (the Ask sheet's first row) ─────────────────
@@ -6784,6 +7397,15 @@
6784
7397
  // fixed composer clears to sit above the keyboard. 0 when the web view itself shrank.
6785
7398
  var VP = { layoutH: 0 };
6786
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;
6787
7409
  var VP_SLACK_PX = 20, VP_SETTLE_MS = 1000;
6788
7410
  function vpTyping() {
6789
7411
  var a = document.activeElement;
@@ -6819,7 +7441,13 @@
6819
7441
  }
6820
7442
  else if (!VP.layoutH) VP.layoutH = Math.max(inner, (de && de.clientHeight) || 0);
6821
7443
  var H = VP.layoutH;
6822
- 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;
6823
7451
  var inset = Math.max(0, Math.round(H - vv.height * scale));
6824
7452
  var top, height;
6825
7453
  if (!zoomed) { top = Math.max(0, Math.round(vv.offsetTop || 0)); height = Math.min(Math.round(vv.height), H); }
@@ -6862,7 +7490,8 @@
6862
7490
  var o = (screen && screen.orientation && screen.orientation.type) || (typeof window.orientation === 'number' ? 'deg' + window.orientation : '');
6863
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),
6864
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,
6865
- 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 };
6866
7495
  var x = extra || {};
6867
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; });
6868
7497
  var key = JSON.stringify(data), now = Date.now();
@@ -7013,13 +7642,44 @@
7013
7642
  return typeof v === 'number' && isFinite(v) ? v : null;
7014
7643
  }
7015
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
+ }
7016
7676
  function lrDevice() {
7017
7677
  if (LR.device) return;
7018
7678
  LR.device = true;
7019
- var sc = window.screen || {};
7679
+ var sc = window.screen || {}, w = lrWide();
7020
7680
  layoutCrumb('device', { sw: sc.width, sh: sc.height, sah: sc.availHeight, oh: window.outerHeight, ow: window.outerWidth, dpr: window.devicePixelRatio,
7021
7681
  sat: lrProbe('padding-top:env(safe-area-inset-top)', 'paddingTop'), sab: lrProbe('padding-bottom:env(safe-area-inset-bottom)', 'paddingBottom'),
7022
- 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 });
7023
7683
  }
7024
7684
  // The focus line is for touch screens only (QA W3): on a Mac it would only spend the budget.
7025
7685
  function lrFocus() {
@@ -7029,7 +7689,9 @@
7029
7689
  if (!lrMatch('(pointer: coarse)')) return;
7030
7690
  try { fs = parseFloat(getComputedStyle(a).fontSize); } catch (_e) { fs = null; }
7031
7691
  var s = lrScroll();
7032
- 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 });
7033
7695
  }
7034
7696
  function lrHooks(what, v) { LR.hooks.forEach(function (fn) { try { fn(what, v); } catch (err) { console.error(err); } }); }
7035
7697
  function lrGap(v) {
@@ -7112,6 +7774,13 @@
7112
7774
 
7113
7775
  // Every pane module is a plain script after this one; boot waits for all of them.
7114
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());
7115
7784
  var params = new URLSearchParams(location.search);
7116
7785
  var linkCode = params.get('code');
7117
7786
  if (linkCode && !params.get('handoff')) {
@@ -7134,7 +7803,9 @@
7134
7803
  return;
7135
7804
  }
7136
7805
  // The status read stays here in boot() itself (routes/web-phone-link.test.ts runs boot alone and pins it).
7137
- api('/api/web-session/status').then(function (st) { return bootAnswer(st); }, function (err) { return bootFail(err); });
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); });
7138
7809
  }
7139
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
7140
7811
  // old QR, a reopened tab). Ask status first: signed in opens the app and says the link was old; signed out shows the
@@ -7187,5 +7858,10 @@
7187
7858
  authRetrySchedule(!!on);
7188
7859
  if (on) { var err = $('auth-error'); if (err) { setStatus(err, '', true); err.classList.add('hidden'); } }
7189
7860
  }
7190
- if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); else boot();
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; }
7191
7867
  })();