@gotcos/glasses-server 7.0.27 → 7.0.30

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.
@@ -245,9 +245,55 @@
245
245
  clearTimeout(toastTimer); toastTimer = setTimeout(function () { el.classList.add('hidden'); }, 3200);
246
246
  }
247
247
  function setStatus(el, text, isError) { if (!el) return; el.textContent = text || ''; el.classList.toggle('error', !!isError); }
248
- function autoGrow(ta) { ta.style.height = 'auto'; ta.style.height = Math.min(ta.scrollHeight, 140) + 'px'; }
248
+ // 7.0.29 (lead F2): an empty session reply box is as tall as its placeholder, so a placeholder that wraps (the rail's
249
+ // width at 382 px wraps "Message this session (not an answer)") is never cut off. Only #session-input; where the
250
+ // placeholder fits on one line the height is the same as before.
251
+ function autoGrow(ta) {
252
+ ta.style.height = 'auto';
253
+ var h = ta.scrollHeight;
254
+ if (!ta.value && ta.id === 'session-input' && ta.placeholder) h = Math.max(h, placeholderHeight(ta, ta.placeholder));
255
+ ta.style.height = Math.min(h, 140) + 'px';
256
+ }
257
+ // 7.0.29 (lead F2): with the rail at 382 px, "Message this session (not an answer)" wraps; with the keyboard up the box
258
+ // is capped under two lines, so the second line would be cut. Where the long words would wrap in the box's width, the
259
+ // box says the short form instead; checked again when the width changes (railSync). Elsewhere the words are as before.
260
+ function sxPlaceholderFit(box) {
261
+ if (!box || !box.setAttribute || !box.phLong) return;
262
+ var ph = box.phLong;
263
+ if (box.phShort) { var one = placeholderHeight(box, 'M'), two = placeholderHeight(box, box.phLong); if (one > 0 && two > one + 2) ph = box.phShort; }
264
+ if (!box.getAttribute || box.getAttribute('placeholder') !== ph) box.setAttribute('placeholder', ph);
265
+ if (!box.value && box.style) autoGrow(box);
266
+ }
267
+ // The placeholder's height in a hidden copy of the box: same width, font, padding and line height. The box's own value
268
+ // is never touched (setting it would drop its undo history and move the caret).
269
+ var PH_PROBE = null;
270
+ function placeholderHeight(ta, text) {
271
+ try {
272
+ if (typeof getComputedStyle !== 'function' || !document.body || !ta.getBoundingClientRect) return 0;
273
+ var cs = getComputedStyle(ta), w = ta.getBoundingClientRect().width;
274
+ if (!(w > 0)) return 0;
275
+ var c = PH_PROBE;
276
+ if (!c) { c = PH_PROBE = document.createElement('textarea'); c.setAttribute('aria-hidden', 'true'); c.tabIndex = -1; c.rows = 1; }
277
+ c.style.cssText = 'position:absolute;left:-9999px;top:0;visibility:hidden;height:auto;min-height:0;max-height:none;overflow:hidden;resize:none;border:0;' +
278
+ 'box-sizing:' + cs.boxSizing + ';width:' + w + 'px;padding:' + cs.paddingTop + ' ' + cs.paddingRight + ' ' + cs.paddingBottom + ' ' + cs.paddingLeft + ';' +
279
+ 'font:' + cs.font + ';letter-spacing:' + cs.letterSpacing + ';line-height:' + cs.lineHeight + ';white-space:' + cs.whiteSpace + ';word-break:' + cs.wordBreak + ';';
280
+ c.value = text;
281
+ if (c.parentNode !== document.body) document.body.appendChild(c);
282
+ return c.scrollHeight;
283
+ } catch (_e) { return 0; }
284
+ }
249
285
 
250
286
  // ── API ─────────────────────────────────────────────────
287
+ // 7.0.29 R1 (Q1 FIX 1): a write on its way to the Mac (a send, a save). theme.js's restyle check asks window.CosBusy
288
+ // before it reloads the page, so a reload never lands on a message in flight.
289
+ var WRITES = { n: 0 };
290
+ function writeStart(method) {
291
+ if (!method || method === 'GET') return function () {};
292
+ WRITES.n++;
293
+ var once = false;
294
+ return function () { if (!once) { once = true; WRITES.n = Math.max(0, WRITES.n - 1); } };
295
+ }
296
+ if (typeof window === 'object' && window) window.CosBusy = function () { return WRITES.n > 0 || !!(typeof state === 'object' && state && state.reading && state.reading.sending); };
251
297
  function api(path, opts) {
252
298
  var o = opts || {};
253
299
  var headers = { 'X-COS-Web': '1' };
@@ -260,7 +306,7 @@
260
306
  var limit = o.timeoutMs != null ? o.timeoutMs : (method === 'GET' ? API_READ_TIMEOUT_MS : 0);
261
307
  var ctrl = limit && window.AbortController ? new AbortController() : null;
262
308
  var timer = ctrl ? setTimeout(function () { ctrl.abort(); }, limit) : null;
263
- var sentAt = Date.now();
309
+ var sentAt = Date.now(), writeDone = typeof writeStart === 'function' ? writeStart(method) : function () {};
264
310
  return fetch(path, { method: method, headers: headers, credentials: 'same-origin', body: body, signal: ctrl ? ctrl.signal : undefined }).then(function (res) {
265
311
  // Blob bodies keep their deadline after headers: a stalled download must release its queue slot.
266
312
  if (timer && o.responseType !== 'blob') clearTimeout(timer);
@@ -288,9 +334,11 @@
288
334
  });
289
335
  }).then(function (value) {
290
336
  if (timer) clearTimeout(timer);
337
+ writeDone();
291
338
  return value;
292
339
  }, function (err) {
293
340
  if (timer) clearTimeout(timer);
341
+ writeDone();
294
342
  if (err && err.name === 'AbortError' && ctrl && ctrl.signal.aborted) {
295
343
  // 6.99.32 (QA 6.99.30 canary W1): a read the Mac never answered in time means it is not answering. A write has
296
344
  // no deadline unless its caller set one, and a slow save is not a Mac that went away, so only reads say so.
@@ -525,10 +573,187 @@
525
573
  var hash = '#/' + [pane].concat(args || []).map(encodeURIComponent).join('/');
526
574
  if (location.hash === hash) route(); else location.hash = hash;
527
575
  }
576
+ // ── Back by path (7.0.30, item 1: Anand on the 11-12 HC call, Miles's pick) ──
577
+ // Back goes where the person came from. Each address this page makes is marked in its own history entry
578
+ // (history.state.cosNav): this page load's id, the entry's position, and the address it came from. Back is then the
579
+ // browser's own Back (history.back), so the system Back and ours agree and Back adds no entry. With no mark (a deep
580
+ // link, a reload, the first page, or an entry an overlay took over), Back keeps the parent chain it always had, and it
581
+ // REPLACES the entry rather than adding one, so the two can never bounce between each other. The image viewer and
582
+ // Today's sheet push entries of their own (cosImageViewer, cosTodayOverlay): those are never marked, never stepped into,
583
+ // and an address made while one of them still sat behind gets no mark, because the entry behind it is not an address
584
+ // (its position is not the last address's position plus one).
585
+ var NAV = { doc: Math.random().toString(36).slice(2, 10), last: null, replace: null };
586
+ function navState() { try { return history.state; } catch (_e) { return null; } }
587
+ function navOverlay(st) { return !!(st && (st.cosImageViewer || st.cosTodayOverlay)); }
588
+ function navStamp() {
589
+ if (typeof history === 'undefined' || !history || typeof location === 'undefined') return; // a page without them: the parent chain
590
+ var hash = location.hash || '', st = navState(), pos;
591
+ if (navOverlay(st)) { NAV.last = null; NAV.replace = null; return; }
592
+ if (st && st.cosNav && st.cosNav.doc === NAV.doc) { NAV.last = { hash: hash, pos: st.cosNav.pos, back: st.cosNav.back, trail: st.cosNav.trail || null, label: st.cosNav.label || '' }; NAV.replace = null; return; }
593
+ if (st && st.cosNav) {
594
+ // Marked by an earlier load of this page (a reload): what lies behind it belongs to that load, so no path.
595
+ pos = st.cosNav.pos;
596
+ } else if (st) { NAV.last = null; NAV.replace = null; return; } // someone else's state: leave it alone
597
+ else pos = NAV.replace ? NAV.replace.pos : history.length - 1;
598
+ var back = null;
599
+ if (!st && !NAV.replace && NAV.last && typeof NAV.last.pos === 'number' && pos === NAV.last.pos + 1) back = NAV.last.hash;
600
+ NAV.replace = null;
601
+ // 7.0.30, Miles's pick B: the steps before this one ride in the same mark, the last of them being where Back goes.
602
+ var trail = back ? (NAV.last.trail || []).concat([{ h: NAV.last.hash, t: NAV.last.label || '' }]).slice(-NAV_TRAIL_MAX) : null;
603
+ var mark = { doc: NAV.doc, pos: pos, back: back };
604
+ if (trail) mark.trail = trail;
605
+ try { history.replaceState(Object.assign({}, st || {}, { cosNav: mark }), ''); } catch (_e) { /* no history API: the parent chain */ }
606
+ NAV.last = { hash: hash, pos: pos, back: back, trail: trail, label: '' };
607
+ }
608
+ var NAV_TRAIL_MAX = 12;
609
+ // The page's own name, kept in its mark, so the next page's trail can name it. setCrumb calls this.
610
+ function navLabel(text) {
611
+ var st = navState();
612
+ if (navOverlay(st) || !st || !st.cosNav || st.cosNav.doc !== NAV.doc || (st.cosNav.label || '') === text) return;
613
+ try { history.replaceState(Object.assign({}, st, { cosNav: Object.assign({}, st.cosNav, { label: text }) }), ''); } catch (_e) { return; }
614
+ if (NAV.last && NAV.last.pos === st.cosNav.pos) NAV.last.label = text;
615
+ }
616
+ // The way he came, oldest first, from this entry's mark: [{ label, hash, back }], back being how many steps back that
617
+ // one is. Null when Back has no path (a deep link, a reload, the first page, an overlay).
618
+ function navTrail() {
619
+ var st = navState();
620
+ if (navOverlay(st) || !st || !st.cosNav || !st.cosNav.back || !st.cosNav.trail || !st.cosNav.trail.length) return null;
621
+ var t = st.cosNav.trail;
622
+ return t.map(function (s, i) { return { label: trailName(s.h, s.t), hash: s.h, back: t.length - i }; });
623
+ }
624
+ // A step is named by what kind of page it is, not its title (the title is the heading under the row): "Meeting",
625
+ // "Session", "Naming voices". A list is its pane. A message, a day and a chat keep their own short name.
626
+ function trailName(hash, crumb) {
627
+ var a = String(hash || '').replace(/^#\/?/, '').split('/').filter(function (x) { return x !== ''; }), p = ROOT_PANES.indexOf(a[0]) >= 0 ? a[0] : 'today';
628
+ if (a.length < 2 || isTabRoute(a.slice(1))) return TITLES[p] || 'Today';
629
+ if (p === 'meetings') return 'Meeting';
630
+ if (p === 'sessions') return 'Session';
631
+ if (p === 'speakers' && a[1] === 'review') return 'Naming voices';
632
+ return crumb || TITLES[p] || 'Today';
633
+ }
634
+ // One line that never wraps. When it is too wide, steps leave from the middle (the one after the first, then the next),
635
+ // never the first, the one before this page, or this page; "…" stands where they were. widths: each step's, then this
636
+ // page's; more: the width "…" adds. Returns the indexes to leave out.
637
+ function trailCut(widths, more, avail) {
638
+ var n = widths.length - 1, total = widths.reduce(function (a, b) { return a + b; }, 0), cut = [];
639
+ if (total <= avail) return cut;
640
+ total += more;
641
+ for (var i = 1; i < n - 1 && total > avail; i++) { cut.push(i); total -= widths[i]; }
642
+ return cut;
643
+ }
644
+ // The row under the top bar, on a phone (below 600 px; wider screens have the navigation bar's crumbs). Only on a page
645
+ // you opened (a reader, a meeting, a session), and only when there is a path.
646
+ function paintTrail(r) {
647
+ var el = typeof document !== 'undefined' && document.getElementById('nav-trail');
648
+ if (!el) return;
649
+ r = r || parseRoute();
650
+ var steps = r.args.length && !isTabRoute(r.args) ? navTrail() : null;
651
+ if (!steps) { el.hidden = true; el.innerHTML = ''; return; }
652
+ var here = trailName(location.hash, crumbDetail);
653
+ el.innerHTML = '<ol>' + steps.map(function (s, i) {
654
+ return '<li class="trail-step"><a href="' + esc(s.hash) + '" data-trail-back="' + s.back + '">' + esc(s.label) + '</a></li>' +
655
+ (i === 0 ? '<li class="trail-more" aria-hidden="true" hidden>…</li>' : '');
656
+ }).join('') + '<li class="trail-here" aria-current="page"><span>' + esc(here) + '</span></li></ol>';
657
+ el.hidden = false;
658
+ var items = Array.prototype.slice.call(el.querySelectorAll('.trail-step,.trail-here')), more = el.querySelector('.trail-more');
659
+ if (!more || !el.clientWidth) return;
660
+ el.classList.add('trail-fit'); // each step at its full width, to measure
661
+ more.hidden = false;
662
+ var moreW = more.offsetWidth, widths = items.map(function (li) { return li.offsetWidth; });
663
+ more.hidden = true;
664
+ el.classList.remove('trail-fit');
665
+ var cut = trailCut(widths, moreW, el.clientWidth);
666
+ // A left-out step stays for a screen reader but takes no keyboard focus (Q1 L1): it is not on screen.
667
+ cut.forEach(function (i) { items[i].classList.add('trail-cut'); var a = items[i].querySelector('a'); if (a) a.setAttribute('tabindex', '-1'); });
668
+ more.hidden = !cut.length;
669
+ }
670
+ // Where Back goes from here: { pane, path } (path true: the way he came; false: the parent chain).
671
+ function navBackTo() {
672
+ var st = navState();
673
+ // navStamp has run on this entry (every landing routes), so its mark is this load's: an earlier load's was re-marked
674
+ // with no path there.
675
+ if (!navOverlay(st) && st && st.cosNav && st.cosNav.back) {
676
+ var p = String(st.cosNav.back).replace(/^#\/?/, '').split('/')[0];
677
+ return { pane: ROOT_PANES.indexOf(p) >= 0 ? p : 'today', path: true };
678
+ }
679
+ var r = parseRoute(), up = PANES[r.pane] && PANES[r.pane].up ? PANES[r.pane].up(r.args) : null;
680
+ return { pane: up || (r.args.length && !isTabRoute(r.args)) ? r.pane : 'today', path: false };
681
+ }
682
+ // The one way this page replaces an address (W.replace for the panes; Q1 7.0.30 H1). It tells navStamp the entry was
683
+ // replaced, never pushed, so a replaced entry never gets a path: a pane switching a tab with location.replace after a
684
+ // Back (a forward entry left) was stamped as a push at history.length - 1, and Back then called history.back() from
685
+ // the first entry, out of the app. Its position is this entry's mark, or one above the last mark when an overlay's
686
+ // entry (Today's sheet, the image viewer) is the one replaced. Returns false, doing nothing, when the address is the
687
+ // same (location.replace would not route, and the flag would wrongly mark the next address).
688
+ function navReplace(hash) {
689
+ if (location.hash === hash) return false;
690
+ var st = navState(), pos;
691
+ if (navOverlay(st)) pos = NAV.last && typeof NAV.last.pos === 'number' ? NAV.last.pos + 1 : history.length - 1;
692
+ else pos = st && st.cosNav && typeof st.cosNav.pos === 'number' ? st.cosNav.pos : history.length - 1;
693
+ NAV.replace = { pos: pos };
694
+ try { location.replace(location.pathname + location.search + hash); } catch (_e) { NAV.replace = null; location.hash = hash; }
695
+ return true;
696
+ }
697
+ // The parent chain, replacing this entry (never adding one).
698
+ function goReplace(pane, args) {
699
+ var hash = '#/' + [pane].concat(args || []).map(encodeURIComponent).join('/');
700
+ if (!navReplace(hash)) route();
701
+ }
702
+ // ── end back by path
528
703
  // 7.0.2 (A8): a phone is under 600 px wide, or a short touch screen at any width (the unfolded Duo is 951x539 in
529
704
  // Comet): height is its scarce axis, so it gets the phone Ask sheet and the 44 px tap floor (app.css), as folded does.
530
705
  var SHORT_TOUCH_MQ = '(pointer: coarse) and (max-height: 720px)';
531
706
  function isPhone() { return !window.matchMedia('(min-width: 600px)').matches || window.matchMedia(SHORT_TOUCH_MQ).matches; }
707
+ // 7.0.29: the folded Duo's side rail (app.css "the folded Duo's side rail"). theme.js decides from the screen's shape
708
+ // (html.short-screen); this media query is app.css's own, so the two never disagree. When it is on, the rail is the one
709
+ // way to switch panes: the top bar's section button keeps the name and loses its menu (inert), and its Back moves to
710
+ // the rail's foot (#rail-back, the same goBack).
711
+ var SIDE_RAIL_MQ = '(max-width: 599px) and (pointer: coarse)';
712
+ function sideRail() {
713
+ var de = document.documentElement;
714
+ return !!(de && de.classList && de.classList.contains('short-screen') && window.matchMedia && window.matchMedia(SIDE_RAIL_MQ).matches);
715
+ }
716
+ // 44 points on the phone. Safari's Page Zoom lays the page out wider and draws it at visualViewport.scale (his Duo: 449 px
717
+ // at 0.85), so 44 px would be 37 points: the rail's buttons are ceil(44 / scale) px: 44 at 100 percent, 52 at 85, 88 at 50 (the lowest Page Zoom). Read at boot and when the
718
+ // layout width changes (rotation), never on a pinch: a pinch must not resize the rail.
719
+ var RAIL = { hit: 0, cw: -1, hooked: false, on: null };
720
+ function railHitSync() {
721
+ var de = document.documentElement, cw = de ? de.clientWidth : 0;
722
+ if (RAIL.hit && cw === RAIL.cw) return RAIL.hit;
723
+ var vv = window.visualViewport, s = vv && typeof vv.scale === 'number' ? vv.scale : 1;
724
+ if (!(s >= 0.5 && s <= 1)) s = 1;
725
+ RAIL.hit = Math.ceil(44 / s - 0.01); // s is 0.5 to 1 here, so 44 to 88
726
+ RAIL.cw = cw;
727
+ if (de && de.style) de.style.setProperty('--rail-hit', RAIL.hit + 'px');
728
+ return RAIL.hit;
729
+ }
730
+ function railSync() {
731
+ var on = false;
732
+ try {
733
+ // theme.js's one screen test first (its own resize listener ran too; this keeps the order certain), then the page.
734
+ if (typeof window.CosShortScreen === 'function') window.CosShortScreen();
735
+ on = sideRail();
736
+ var btn = typeof $ === 'function' ? $('section-btn') : null;
737
+ if (btn) {
738
+ btn.inert = on;
739
+ if (on) btn.removeAttribute('aria-haspopup'); else btn.setAttribute('aria-haspopup', 'dialog');
740
+ }
741
+ if (on) { railHitSync(); closeMenu(); }
742
+ // The rail changes the reply box's width: an empty box re-fits its placeholder (autoGrow, F2).
743
+ var si = typeof $ === 'function' ? $('session-input') : null; if (si && typeof sxPlaceholderFit === 'function' && si.phLong) sxPlaceholderFit(si); else if (si && !si.value && typeof autoGrow === 'function') autoGrow(si);
744
+ // A change after boot (a fold, a rotation, a new screen) is reported once, with the screen's own numbers.
745
+ if (RAIL.on !== null && RAIL.on !== on && typeof layoutCrumb === 'function') { var sc = window.screen || {}; layoutCrumb('rail', { sw: sc.width, sh: sc.height, rail: on, railHit: RAIL.hit }); }
746
+ RAIL.on = on;
747
+ if (!RAIL.hooked && typeof window.matchMedia === 'function' && typeof window.addEventListener === 'function') {
748
+ RAIL.hooked = true;
749
+ var mq = window.matchMedia(SIDE_RAIL_MQ);
750
+ if (mq && mq.addEventListener) mq.addEventListener('change', railSync); else if (mq && mq.addListener) mq.addListener(railSync);
751
+ window.addEventListener('resize', function () { railSync(); });
752
+ window.addEventListener('orientationchange', function () { railSync(); });
753
+ }
754
+ } catch (_e) { /* a harness without a page: no rail */ }
755
+ return on;
756
+ }
532
757
  /** A keyboard and a fine pointer are the way in (a Mac, a PC): Return sends. Touch screens type a new line. */
533
758
  function keyboardFirst() { return !!(window.matchMedia && window.matchMedia('(hover: hover) and (pointer: fine)').matches); }
534
759
  // Return in a text box sends only where a keyboard and a pointer are the way in (cos-chat.js keyboardFirst): never on
@@ -541,6 +766,7 @@
541
766
  return !!(e.metaKey || e.ctrlKey || keyboardFirst());
542
767
  }
543
768
  function route(forceChanged) {
769
+ if (typeof navStamp === 'function') navStamp();
544
770
  var r = parseRoute();
545
771
  var changedPane = forceChanged === true || r.pane !== state.pane || !document.querySelector('#pane-' + r.pane + ':not(.hidden)');
546
772
  if (state.pane !== r.pane && PANES[state.pane] && PANES[state.pane].leave) { try { PANES[state.pane].leave(); } catch (err) { console.error(err); } }
@@ -563,9 +789,21 @@
563
789
 
564
790
  // Top bar (phone): the section switcher at a list, a back button on a reader.
565
791
  $('topbar-back').classList.toggle('hidden', !reading);
792
+ var railBack = $('rail-back');
793
+ if (railBack) railBack.hidden = !reading;
566
794
  $('section-btn').classList.toggle('hidden', reading);
567
- $('topbar-back-label').textContent = TITLES[r.pane] || 'Back';
795
+ // 7.0.30: Back names where it goes, the way he came or the parent (navBackTo).
796
+ var backTo = typeof navBackTo === 'function' ? navBackTo() : { pane: r.pane };
797
+ $('topbar-back-label').textContent = TITLES[backTo.pane] || 'Back';
798
+ var topBack = $('topbar-back');
799
+ if (topBack && topBack.setAttribute) topBack.setAttribute('aria-label', 'Back to ' + (TITLES[backTo.pane] || 'the list'));
800
+ if (railBack && railBack.setAttribute) railBack.setAttribute('aria-label', 'Back to ' + (TITLES[backTo.pane] || 'the list'));
801
+ // From 600 px Back is the navigation bar's (shell.js): its word stays "Back", as on the Mac's bar, and its name and
802
+ // tooltip say where it goes, as the rail's do (7.0.30 counted run, items_check B1 and B2 at the open Duo).
803
+ var navBack = $('nav-back');
804
+ if (navBack && navBack.setAttribute) { navBack.setAttribute('aria-label', 'Back to ' + (TITLES[backTo.pane] || 'the list')); navBack.setAttribute('title', 'Back to ' + (TITLES[backTo.pane] || 'the list')); }
568
805
  $('section-name').textContent = TITLES[r.pane] || 'Today';
806
+ if (typeof paintTrail === 'function') paintTrail(r);
569
807
  closeMenu();
570
808
  if (changedPane) window.scrollTo(0, 0);
571
809
  $('topbar').classList.remove('tucked');
@@ -601,30 +839,36 @@
601
839
  runHooks(routeHooks, r);
602
840
  }
603
841
  function runHooks(list, arg) { list.forEach(function (fn) { try { fn(arg); } catch (err) { console.error(err); } }); }
604
- // Back unwinds one step, the way Control's Back does: a pane module may name the step above a
605
- // nested reader (an archive chat goes back to its day, not to the list).
842
+ // Back (7.0.30): the way he came, when this page made the entry behind (navBackTo). Otherwise one step up, the way
843
+ // Control's Back does: a pane module may name the step above a nested reader (an archive chat goes back to its day,
844
+ // not to the list), replacing this entry.
606
845
  function goBack() {
846
+ if (typeof navBackTo === 'function' && navBackTo().path) { history.back(); return; }
607
847
  var r = parseRoute();
608
848
  var up = PANES[r.pane] && PANES[r.pane].up ? PANES[r.pane].up(r.args) : null;
609
- if (up) go(r.pane, up);
610
- else if (r.args.length && !isTabRoute(r.args)) go(r.pane);
611
- else go('today');
849
+ var step = typeof goReplace === 'function' ? goReplace : go;
850
+ if (up) step(r.pane, up);
851
+ else if (r.args.length && !isTabRoute(r.args)) step(r.pane);
852
+ else step('today');
612
853
  }
613
854
  // '#/<pane>/t/<tab>' is the pane's list on one of its tabs, not a reader: the phone keeps the section switcher and Back
614
855
  // leaves the pane. Memories keeps its tab in the address this way, so a reload or a new browser lands on the same tab.
615
856
  function isTabRoute(args) { return args.length === 2 && args[0] === 't'; }
616
- // On the closed Duo every vertical pixel counts: the top bar slides away while you scroll
617
- // down and comes back the moment you scroll up.
857
+ // On a phone every vertical pixel counts: the top bar slides away while you scroll down a list and comes back the
858
+ // moment you scroll up. 7.0.30 (item 1): never on a reader, where the top bar holds Back, so Back stays in reach on a
859
+ // tall phone however far down he reads.
618
860
  var lastY = 0;
619
861
  function onScroll() {
620
862
  var y = Math.max(0, window.scrollY || 0);
621
- var bar = $('topbar');
863
+ // On a reader the bar stays (its Back is there). The folded Duo's Back is on the side rail (the bar's is hidden by CSS,
864
+ // not the class), so there the bar tucks as before (Q1 7.0.30 M1).
865
+ var bar = $('topbar'), back = $('topbar-back'), onReader = !!(back && !back.classList.contains('hidden')) && !(typeof sideRail === 'function' && sideRail());
622
866
  bar.classList.toggle('scrolled', y > 8);
623
- if (y < 60 || y < lastY - 4) bar.classList.remove('tucked');
867
+ if (y < 60 || y < lastY - 4 || onReader) bar.classList.remove('tucked');
624
868
  else if (y > lastY + 4 && isPhone()) bar.classList.add('tucked');
625
869
  lastY = y;
626
870
  }
627
- function openMenu() { $('section-menu').classList.remove('hidden'); var cur = document.querySelector('#section-menu [aria-current="page"]') || document.querySelector('#section-menu [data-goto]'); if (cur) cur.focus(); }
871
+ function openMenu() { if (sideRail()) return; $('section-menu').classList.remove('hidden'); var cur = document.querySelector('#section-menu [aria-current="page"]') || document.querySelector('#section-menu [data-goto]'); if (cur) cur.focus(); }
628
872
  function closeMenu() { $('section-menu').classList.add('hidden'); }
629
873
 
630
874
  // ── auth ────────────────────────────────────────────────
@@ -660,7 +904,7 @@
660
904
  // 7.0.23 regression (lapse_check, 08:2x): a frame class left on keeps html and body at one screen with overflow
661
905
  // hidden, so the gate could not scroll and Connect sat below a keyboard-height screen. The gate is a normal page;
662
906
  // showShell's route() puts the frame back (sxFrameSync) when the shell returns.
663
- document.documentElement.classList.remove('sx-frame', 'mx-frame');
907
+ document.documentElement.classList.remove('sx-frame');
664
908
  // A later 401 from a pane still polling (W.api passes no message) must not blank what the gate already says:
665
909
  // the reason it opened, or "That code did not match" while the person retypes.
666
910
  if (!message && alreadyOpen) return;
@@ -2068,7 +2312,11 @@
2068
2312
  var qs1 = sessionQs(), open = (!!html && !te) || !!(qs1 && ((qs1.itemsFor && qs1.itemsFor(r.id).length) || (qs1.atMacFor && qs1.atMacFor(r.id).length)));
2069
2313
  var box = $('session-input');
2070
2314
  // 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…');
2315
+ if (box && box.setAttribute) {
2316
+ box.phLong = te ? 'Or write your own answer' : open ? 'Message this session (not an answer)' : 'Continue this session…';
2317
+ box.phShort = open && !te ? 'Message (not an answer)' : '';
2318
+ if (typeof sxPlaceholderFit === 'function') sxPlaceholderFit(box); else box.setAttribute('placeholder', box.phLong);
2319
+ }
2072
2320
  }
2073
2321
  // ── end at the Mac's prompt
2074
2322
 
@@ -2484,7 +2732,7 @@
2484
2732
  var dest = session ? ['sessions', s[0], s.slice(1).join('|')] : kind === 'pet' ? ['sessions'] : ['work'].concat(kind === 'need' ? [] : ['sort']);
2485
2733
  console.error('[today] the overlay did not open (' + kind + '); opening ' + dest.join('/') + ' instead.', err);
2486
2734
  toast(session ? 'Could not open that here. Opening the session.' : kind === 'pet' ? 'Could not open that here. Opening Sessions.' : 'Could not open that here. Opening it in Work.');
2487
- if (history.state && history.state.cosTodayOverlay) location.replace('#/' + dest.map(encodeURIComponent).join('/'));
2735
+ if (history.state && history.state.cosTodayOverlay) navReplace('#/' + dest.map(encodeURIComponent).join('/'));
2488
2736
  else go(dest[0], dest.slice(1));
2489
2737
  });
2490
2738
  }
@@ -2759,6 +3007,8 @@
2759
3007
  var touched = function () { SX_PIN.userAt = Date.now(); };
2760
3008
  ['wheel', 'touchstart', 'pointerdown', 'keydown'].forEach(function (t) { body.addEventListener(t, touched, { passive: true }); });
2761
3009
  body.addEventListener('scroll', sxGoQuestionPaint, { passive: true });
3010
+ // 7.0.29 ADD 3: reaching the end shows him what is there.
3011
+ body.addEventListener('scroll', function () { if (typeof sxMarkShown === 'function' && state.reading) sxMarkShown(state.reading); }, { passive: true });
2762
3012
  body.addEventListener('scroll', function () {
2763
3013
  if (SX_PIN.body !== body || Date.now() - SX_PIN.userAt > SX_PIN_USER_MS) return;
2764
3014
  if (Math.abs(body.scrollTop - SX_PIN.put) > 4 && body.scrollTop < body.scrollHeight - body.clientHeight - 4) sxUnpin();
@@ -2907,12 +3157,25 @@
2907
3157
  // Earlier this month as compact rows, folded shut with a count. Days are counted back from today (2 to 6 days ago is
2908
3158
  // Earlier this week, 7 to 13 Last week, 14 to 30 Earlier this month), so no group is empty only because of the
2909
3159
  // calendar.
2910
- var SX_GROUPS = [['needs', 'Needs you', 'state'], ['working', 'Working now', 'state'], ['pinned', 'Pinned', ''], ['today', 'Today', ''],
3160
+ var SX_GROUPS = [['needs', 'Needs you', 'state'], ['working', 'Working now', 'state'], ['pinned', 'Pinned', 'pinned'], ['today', 'Today', ''],
2911
3161
  ['yesterday', 'Yesterday', ''], ['week', 'Earlier this week', ''], ['lastweek', 'Last week', 'compact'], ['month', 'Earlier this month', 'compact']];
2912
3162
  var SX_DAY_MS = 86400000, SX_REST_MS = 2000, SX_EMPTY_HOLD_MS = 5000;
2913
3163
  // place: each row's group as last drawn at rest; seen: when Needs you and Working now last had rows; open: unfolded
2914
3164
  // compact groups; older: /older's short rows (olderOk false: an older server, 404).
2915
3165
  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 };
3166
+ // 7.0.29 ADD 1 (Miles 23:53: thirteen pinned rows sat between Working now and Today): Pinned folds like a folder, with
3167
+ // its count, and this browser remembers the choice (localStorage). Open by default, so no list changes until he folds
3168
+ // it. Placement is unchanged: sxGroupFor tests state before pin, so a pinned session that is running is drawn in
3169
+ // Working now, one that waits or failed in Needs you, folded or not, and it goes back under Pinned when idle.
3170
+ var SX_PIN_FOLD_KEY = 'cos_sx_pinned_folded';
3171
+ function sxPinnedOpen() {
3172
+ if (typeof SXL.pinOpen !== 'boolean') { var v = null; try { v = typeof localStorage === 'object' && localStorage ? localStorage.getItem(SX_PIN_FOLD_KEY) : null; } catch (_e) { v = null; } SXL.pinOpen = v !== '1'; }
3173
+ return SXL.pinOpen;
3174
+ }
3175
+ function sxPinnedSet(open) {
3176
+ SXL.pinOpen = !!open;
3177
+ try { if (typeof localStorage === 'object' && localStorage) { if (open) localStorage.removeItem(SX_PIN_FOLD_KEY); else localStorage.setItem(SX_PIN_FOLD_KEY, '1'); } } catch (_e) { /* storage off: this visit only */ }
3178
+ }
2916
3179
  function sxRowKey(s) { var k = splitSessionId(s.provider, s.session_id || s.id); return k.provider + '|' + String(k.id).toLowerCase(); }
2917
3180
  function sxDayStart(t) { var d = new Date(t); d.setHours(0, 0, 0, 0); return d.getTime(); }
2918
3181
  function sxSortTime(s) { return SX.clock === 'opened' ? (sxTime(s.created) || sxTime(s.modified)) : sxTime(s.modified); }
@@ -3017,6 +3280,13 @@
3017
3280
  return;
3018
3281
  }
3019
3282
  if (!n) return;
3283
+ if (g[2] === 'pinned') {
3284
+ var popen = sxPinnedOpen();
3285
+ html += '<section class="sx-g" data-g="pinned"><button type="button" class="sx-gh" data-sx="fold" data-g="pinned" aria-expanded="' + popen + '">' +
3286
+ '<span class="fold"><span class="chev" aria-hidden="true"></span>' + g[1] + '</span><span class="n">' + n + '</span></button>' +
3287
+ (popen ? '<div class="sx-rows">' + rows.map(function (s) { return sxRow(s, null, amb, s.form === 'short'); }).join('') + '</div>' : '') + '</section>';
3288
+ return;
3289
+ }
3020
3290
  if (g[2] === 'compact') {
3021
3291
  var open = !!SXL.open[id];
3022
3292
  html += '<section class="sx-g" data-g="' + id + '"><button type="button" class="sx-gh" data-sx="fold" data-g="' + id + '" aria-expanded="' + open + '">' +
@@ -3188,6 +3458,9 @@
3188
3458
  (s.cos_pinned ? '<button type="button" role="menuitem" data-sx="org-pin" data-key="' + esc(key) + '">Unpin in COS</button>' : '')
3189
3459
  : '<button type="button" role="menuitem" data-sx="org-pin" data-key="' + esc(key) + '">' + (s.cos_pinned ? 'Unpin' : 'Pin') + '</button>') +
3190
3460
  '<button type="button" role="menuitem" data-sx="org-move-menu" data-key="' + esc(key) + '">Move to folder</button>' +
3461
+ // 7.0.29 ADD 2 (Miles 23:53: he could not find how to take a session out of a folder; "No folder" sat at the foot
3462
+ // of Move to folder): a row in a folder gets Remove from folder, the same write as No folder (folder_id null).
3463
+ (s.folder_id && SXF.byId[s.folder_id] ? '<button type="button" role="menuitem" data-sx="org-move" data-key="' + esc(key) + '" data-folder="">Remove from folder</button>' : '') +
3191
3464
  '<button type="button" role="menuitem" data-sx="org-rename" data-key="' + esc(key) + '">Rename</button>';
3192
3465
  }
3193
3466
  function sxFolderMenuHtml(id) {
@@ -3491,6 +3764,7 @@
3491
3764
  if (act === 'view') { sxViewMenu(false); return setSessionsView(t.getAttribute('data-view')); }
3492
3765
  if (act === 'view-menu') return sxViewMenu();
3493
3766
  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; }
3767
+ if (act === 'fold' && t.getAttribute('data-g') === 'pinned') { sxPinnedSet(!sxPinnedOpen()); return renderSessionGroups(); }
3494
3768
  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
3769
  if (/^(row-menu|org-|folder-|new-folder|rename-clear|inline-cancel)/.test(act)) { e.stopPropagation(); return sxOrgClick(act, t); }
3496
3770
  if (act === 'older-more') return sxLoadOlder(false, true);
@@ -3507,7 +3781,6 @@
3507
3781
  if (act === 'clear') { var input = $('sessions-q'); input.value = ''; searchSessions(); input.focus(); return; }
3508
3782
  if (act === 'copy-session') return copySessionBrief();
3509
3783
  if (act === 'full') { if (SX_SPLIT) SX_SPLIT.toggleFull(); return; }
3510
- if (act === 'refresh-turns' && rd) { rd.refusal = null; renderVerdict(rd); refreshTurns(rd.provider, rd.id); return; }
3511
3784
  if (act === 'retry' && rd) { rd.refusal = null; renderVerdict(rd); return sendSessionTurn(!!rd.retryFork); }
3512
3785
  if (act === 'caution-send' && rd) { rd.cautionAck = true; rd.cautionAsk = false; renderVerdict(rd); return sendSessionTurn(false); }
3513
3786
  if (act === 'caution-cancel' && rd) { rd.cautionAsk = false; renderVerdict(rd); return; }
@@ -3803,7 +4076,7 @@
3803
4076
  var timer = ctrl && o.timeoutMs ? setTimeout(function () { ctrl.abort(); }, o.timeoutMs) : null;
3804
4077
  var headers = { 'X-COS-Web': '1' };
3805
4078
  if (o.body !== undefined) headers['Content-Type'] = 'application/json';
3806
- var sentAt = Date.now();
4079
+ var sentAt = Date.now(), writeDone = typeof writeStart === 'function' ? writeStart(o.method || 'GET') : function () {};
3807
4080
  return fetch(path, { method: o.method || 'GET', headers: headers, credentials: 'same-origin', body: o.body !== undefined ? JSON.stringify(o.body) : undefined, signal: ctrl ? ctrl.signal : undefined })
3808
4081
  .then(function (res) {
3809
4082
  sxNoteClock(res, sentAt);
@@ -3816,7 +4089,7 @@
3816
4089
  noteServer(false);
3817
4090
  throw unreachableError(err);
3818
4091
  })
3819
- .finally(function () { if (timer) clearTimeout(timer); });
4092
+ .finally(function () { if (timer) clearTimeout(timer); writeDone(); });
3820
4093
  }
3821
4094
 
3822
4095
  // Per-browser memory for one session: messages held until a reply ends, and the queue keys
@@ -3925,6 +4198,8 @@
3925
4198
  // edge says one waits. It sits in the reader, not the dock, because the dock scrolls by itself as a last resort.
3926
4199
  '<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>';
3927
4200
  readerImagesMount($('session-turns'), r);
4201
+ if (typeof unsentRestore === 'function') unsentRestore(r);
4202
+ var si0 = $('session-input'); if (si0 && !si0.value && typeof autoGrow === 'function') autoGrow(si0);
3928
4203
  sxNoteList(r, d);
3929
4204
  setLive({});
3930
4205
  if (typeof readerMacPaint === 'function') readerMacPaint(r, sxState(d) === 'waiting');
@@ -3942,6 +4217,8 @@
3942
4217
  startHeldTimer(state.reading);
3943
4218
  }
3944
4219
  if (turns.length) requestAnimationFrame(function () { if (typeof sxToNewest === 'function') sxToNewest(); else window.scrollTo(0, document.documentElement.scrollHeight); });
4220
+ // 7.0.29 ADD 3: the newest turns he was just taken to count as shown (this frame runs after the one above).
4221
+ if (turns.length) requestAnimationFrame(function () { if (typeof sxMarkShown === 'function') sxMarkShown(r); });
3945
4222
  requestAnimationFrame(function () { if (typeof sxFrameCrumb === 'function') sxFrameCrumb('open'); });
3946
4223
  watchSession(provider, id);
3947
4224
  }).catch(function (err) {
@@ -4230,6 +4507,53 @@
4230
4507
  paintActivity(r);
4231
4508
  if (moved) renderVerdict(r);
4232
4509
  }
4510
+ // ── 7.0.29 ADD 3 (Miles 00:01 "we have to hit continue to steer the session"; 00:03 "Refresh doesn't seem to do
4511
+ // anything"; the lead's ruling 00:02 and A1 to A3 00:47): the changed-thread stop. The server guard is unchanged; the
4512
+ // page does the acknowledging. Shown, not just drawn (A1): a turn counts as shown only while the reader is at the
4513
+ // newest turn (following it, or scrolled to the end since). Read from the reader's own pin and scroll, no observer per
4514
+ // turn. Only turns with words count; tool steps are not turns here.
4515
+ function sxTextTurns(turns) { return (turns || []).filter(function (t) { return t && String(t.text || t.content || '').trim(); }); }
4516
+ function sxAtEnd() {
4517
+ try {
4518
+ var body = $('session-body');
4519
+ if (body && document.documentElement.classList.contains('sx-frame')) return SX_PIN.body === body || body.scrollTop >= body.scrollHeight - body.clientHeight - 4;
4520
+ var de = document.documentElement;
4521
+ return (window.scrollY || 0) + window.innerHeight >= de.scrollHeight - 4;
4522
+ } catch (_e) { return false; } // a page without these (a test harness): nothing counts as shown, so nothing resends
4523
+ }
4524
+ function sxMarkShown(r) {
4525
+ if (!r || state.reading !== r || !sxAtEnd()) return;
4526
+ r.shown = r.shown || {};
4527
+ sxTextTurns(r.turns).forEach(function (t) { r.shown[sxTurnSig(t)] = 1; });
4528
+ }
4529
+ // At Send: what he had been shown, and whether the session was already waiting on him.
4530
+ function sxPressSeen(r) {
4531
+ sxMarkShown(r);
4532
+ return { shown: Object.assign({}, r.shown || {}), waiting: !!r.waitingNow };
4533
+ }
4534
+ // After a changed-thread refusal: fetch the turns (the refreshTurns request), draw them, and say what is new since Send:
4535
+ // turns with words he had not been shown, or a question or permission now waiting that was not (A2). null: the fetch
4536
+ // failed, so nothing can be said and the box shows.
4537
+ function sxChangedLook(r, seen) {
4538
+ return api(sessionPath(r.provider, r.id, '?turns=' + SX_TURNS + '&cards=1')).then(function (d) {
4539
+ if (state.reading !== r) return null;
4540
+ var turns = Array.isArray(d && d.recent_turns) ? d.recent_turns : [];
4541
+ var box = $('session-turns'); if (box) { sxMergeTurns(box, r.turns || [], turns, d); readerImagesMount(box, r); }
4542
+ r.detail = d; r.turns = turns; r.more = d.recent_turns_more === true;
4543
+ var fresh = sxTextTurns(turns).filter(function (t) { return !seen.shown[sxTurnSig(t)]; });
4544
+ // Waiting by the reader's own rule, or by the detail's own word (a session read as stale may still say it waits):
4545
+ // either way stopping is the safe side.
4546
+ return { fresh: fresh, newWait: (sxState(d) === 'waiting' || (d && d.agent_state === 'waiting')) && !seen.waiting };
4547
+ }, function () { return null; });
4548
+ }
4549
+ // Take him to the first new turn: it is drawn, and the stop is about it.
4550
+ function sxShowFresh(fresh) {
4551
+ if (!fresh || !fresh.length) return;
4552
+ var sig = sxTurnSig(fresh[0]), el = null;
4553
+ document.querySelectorAll('#session-turns .turn[data-sig]').forEach(function (n) { if (!el && n.getAttribute('data-sig') === sig) el = n; });
4554
+ sxUnpin();
4555
+ if (el && el.scrollIntoView) el.scrollIntoView({ block: 'start' }); else sxToNewest();
4556
+ }
4233
4557
  function refreshTurns(provider, id) {
4234
4558
  var reading = state.reading;
4235
4559
  api(sessionPath(provider, id, '?turns=' + SX_TURNS + '&cards=1')).then(function (d) {
@@ -4239,6 +4563,7 @@
4239
4563
  var box = $('session-turns'); if (box) { sxMergeTurns(box, r.turns || [], turns, d); readerImagesMount(box, r); }
4240
4564
  // The header keeps the subtitle it opened with: a rewrap above the turns would move them.
4241
4565
  r.detail = d; r.turns = turns; r.more = d.recent_turns_more === true;
4566
+ sxMarkShown(r);
4242
4567
  renderHeld(r);
4243
4568
  loadQueue(provider, id);
4244
4569
  sxProbeAttach(r);
@@ -4362,9 +4687,10 @@
4362
4687
  } else if (r.refusal) {
4363
4688
  var f = r.refusal;
4364
4689
  html = refusalBox(f.copy, f.supplement,
4365
- (f.changed ? '<button type="button" class="cx-btn quiet" data-sx="refresh-turns">Refresh</button><button type="button" class="cx-btn quiet" data-action="send-anyway">Continue anyway</button>' : '') +
4690
+ // 7.0.29 ADD 3: a changed thread says what each button does. The page has already refreshed, so no Refresh.
4691
+ (f.changed ? '<button type="button" class="cx-btn quiet" data-action="send-anyway">Send it anyway</button>' : '') +
4366
4692
  (f.retry ? '<button type="button" class="cx-btn quiet" data-sx="retry">Retry</button>' : '') +
4367
- (f.fork ? forkBtn : ''));
4693
+ (f.fork ? (f.changed ? '<button type="button" class="cx-btn quiet" data-action="fork-session" title="Runs your message in a copy of this thread. The original is untouched.">Send to a copy</button>' : forkBtn) : ''));
4368
4694
  } else if (v && v.attachable === false && /^(native_thread_working|native_target_busy)$/.test(v.reason)) {
4369
4695
  // 6.99.29: "Working right now" only while the one state says so.
4370
4696
  if (sxParkNoteShows(r)) html = '<div class="sx-park"><p>' + esc(sxParkLine(r)) + '</p>' + (/fork/i.test(v.reasonCopy) ? '<div class="sx-box-actions">' + forkBtn + '</div>' : '') + '</div>';
@@ -4399,18 +4725,51 @@
4399
4725
  if (!r.draft || r.draft.text !== text || (r.draft.ids || []).join(',') !== files.join(',')) r.draft = { text: text, key: newTurnKey(), ids: files };
4400
4726
  return r.draft;
4401
4727
  }
4402
- // The box is cleared only of the very words that were sent; anything typed since stays.
4403
- function clearBoxOf(text) {
4728
+ // 7.0.29 (lead F3: the rail's Back sits level with Send, so a mis-tap is likely): words typed in a session's reply
4729
+ // box and not sent come back when that session opens again in this tab, after Back, the rail or another pane. Kept in
4730
+ // memory, per session, as he types; dropped when the box is empty or when those words are sent.
4731
+ var SX_UNSENT = {};
4732
+ function unsentKey(provider, id) { return String(provider || '') + '|' + String(id || '').toLowerCase(); }
4733
+ function unsentSave() {
4734
+ var r = state.reading, input = $('session-input');
4735
+ if (!r || !input) return;
4736
+ var k = unsentKey(r.provider, r.id);
4737
+ if (input.value.trim()) SX_UNSENT[k] = input.value; else delete SX_UNSENT[k];
4738
+ }
4739
+ function unsentRestore(r) {
4740
+ var input = $('session-input'), v = r ? SX_UNSENT[unsentKey(r.provider, r.id)] : '';
4741
+ if (input && v && !input.value) { input.value = v; autoGrow(input); }
4742
+ }
4743
+ // 7.0.29 R4 (Q1 Note 1): the saved words of the session the message was sent FROM only, never another session's
4744
+ // words that happen to match. No session named, nothing is dropped.
4745
+ function unsentDrop(text, r) {
4746
+ if (!r || !r.provider || !r.id) return;
4747
+ var k = unsentKey(r.provider, r.id), v = SX_UNSENT[k];
4748
+ if (v == null) return;
4749
+ var t = String(text).trim(), bare = typeof turnEndBare === 'function' ? turnEndBare(text) : t;
4750
+ v = String(v).trim();
4751
+ if (v === t || v === bare) delete SX_UNSENT[k];
4752
+ }
4753
+ // The box is cleared only of the very words that were sent; anything typed since stays. `r` is the session they were
4754
+ // sent from: another session's box is never touched (R4).
4755
+ function clearBoxOf(text, r) {
4756
+ if (typeof unsentDrop === 'function') unsentDrop(text, r || state.reading);
4757
+ if (r && state.reading !== r) return;
4404
4758
  var input = $('session-input');
4405
4759
  // 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
4760
  if (input && (input.value.trim() === String(text).trim() || input.value.trim() === turnEndBare(text))) { input.value = ''; autoGrow(input); }
4407
4761
  if (state.reading && state.reading.draft && state.reading.draft.text === text) state.reading.draft = null;
4408
4762
  }
4409
4763
  // Words go back in the box only if the box is empty; otherwise the status line keeps them. `files` says what became
4410
- // of the message's files (heldFilesBack).
4411
- function returnToBox(text, why, files) {
4764
+ // of the message's files (heldFilesBack). 7.0.29 R4: when `r`, the session they came from, is not the one open, they
4765
+ // go to that session's saved words (back in its box when it opens), never into the open session's box.
4766
+ function returnToBox(text, why, files, r) {
4767
+ if (r && r.provider && r.id && state.reading !== r) {
4768
+ if (typeof unsentKey === 'function') { var k = unsentKey(r.provider, r.id), had = SX_UNSENT[k]; SX_UNSENT[k] = had && String(had).trim() ? had + '\n\n' + text : text; }
4769
+ return;
4770
+ }
4412
4771
  var input = $('session-input');
4413
- if (input && !input.value.trim()) { input.value = text; autoGrow(input); sessionStatus(why + ' Your message is back in the box.' + (files || ''), 'error'); }
4772
+ if (input && !input.value.trim()) { input.value = text; autoGrow(input); if (typeof unsentSave === 'function') unsentSave(); sessionStatus(why + ' Your message is back in the box.' + (files || ''), 'error'); }
4414
4773
  else sessionStatus(why + ' Not sent: “' + String(text).slice(0, 80) + (text.length > 80 ? '…' : '') + '”' + (files || ''), 'error');
4415
4774
  }
4416
4775
  // Your words show in the conversation the moment the Mac accepts them; the next refresh of
@@ -4437,7 +4796,7 @@
4437
4796
  var READER_VIEW = {
4438
4797
  status: function (r, text, kind, actionHtml) { sessionStatus(text, kind, actionHtml); },
4439
4798
  pending: function (r, text, note) { showPendingTurn(text, note); },
4440
- clear: function (r, text) { clearBoxOf(text); },
4799
+ clear: function (r, text) { clearBoxOf(text, r); },
4441
4800
  verdict: function (r) { renderVerdict(r); },
4442
4801
  hold: function (r, draft) { holdUntilReplyEnds(r, draft); },
4443
4802
  watch: function (r, bindingId, key) { watchTurn(bindingId, key, r.provider, r.id); },
@@ -4552,7 +4911,11 @@
4552
4911
  if (r.refusal || r.cautionAsk) { r.refusal = null; r.cautionAsk = false; renderVerdict(r); }
4553
4912
  // 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
4913
  // 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);
4914
+ var answering = !!(!fork && r.turnEnd && turnEndOf(readerRowOf(r)));
4915
+ if (answering) text = turnEndPrompt(text);
4916
+ // 7.0.29 ADD 3: what he had been shown when he pressed Send. A turn-end answer never resends by itself; R6 (Q1's nit):
4917
+ // the same test as the answer's prefix (r.turnEnd AND the ask still open), so a message after a closed ask is a message.
4918
+ if (!fork && typeof sxPressSeen === 'function') opts = Object.assign({}, opts || {}, { seen: answering ? null : sxPressSeen(r) });
4556
4919
  r.sending = true;
4557
4920
  var btns = form.querySelectorAll('button'); btns.forEach(function (b) { b.disabled = true; });
4558
4921
  var done = function () { r.sending = false; btns.forEach(function (b) { b.disabled = false; }); paintReaderFiles(); };
@@ -4633,14 +4996,25 @@
4633
4996
  // it under the same key and sends it the moment the reply ends.
4634
4997
  if (reason === 'native_turn_in_progress') { if (!opts.held) view.hold(r, draft); return 'held'; }
4635
4998
  if (reason === 'native_thread_changed' && b.revision) {
4636
- if (opts.held) settleHeld(r, opts.held, 'refused', 'This session changed on your Mac while your message waited.');
4637
- else {
4638
- // Control's refusal box for a changed thread: Refresh, Continue anyway, Fork with this message.
4639
- r.sendAnyway = b.revision;
4640
- view.status(r, '');
4641
- r.refusal = { copy: b.reasonCopy || 'This session changed on your Mac since you opened it.', changed: true, fork: true };
4642
- view.verdict(r);
4999
+ // The box. Send it anyway always carries the revision of THIS refusal, the newest (A3).
5000
+ var changedBox = function (copy) { r.sendAnyway = b.revision; view.status(r, ''); r.refusal = { copy: copy, changed: true, fork: true }; view.verdict(r); };
5001
+ if (opts.held) { settleHeld(r, opts.held, 'refused', 'This session changed on your Mac while your message waited.'); return 'changed'; }
5002
+ // 7.0.29 ADD 3, the session reader only (opts.seen; Today's panel keeps its own path): never for a held or
5003
+ // queued message, a turn-end answer (seen is null), or a send that already carries a revision, which the one
5004
+ // automatic resend always does. So at most one resend, and a second refusal shows the box.
5005
+ if (opts.seen && !opts.acknowledgedRevision && state.reading === r) {
5006
+ return sxChangedLook(r, opts.seen).then(function (look) {
5007
+ if (look && !look.fresh.length && !look.newWait && state.reading === r) return continueSession(r, text, Object.assign({}, opts, { acknowledgedRevision: b.revision }));
5008
+ changedBox(!look ? 'This session changed on your Mac, and COS could not load what changed. Your message is not sent yet.'
5009
+ : look.fresh.length ? 'A new reply arrived while you were writing. Your message is not sent yet.'
5010
+ : 'The session is now waiting on a question. Your message is not sent yet.');
5011
+ if (look) sxShowFresh(look.fresh);
5012
+ return 'changed';
5013
+ });
4643
5014
  }
5015
+ changedBox(opts.seen === undefined ? (b.reasonCopy || 'This session changed on your Mac since you opened it.')
5016
+ : opts.seen === null ? 'This session changed on your Mac. Your answer is not sent yet.'
5017
+ : 'This session changed on your Mac again. Your message is not sent yet.');
4644
5018
  return 'changed';
4645
5019
  }
4646
5020
  if (/^(stale_epoch|binding_expired|binding_not_active|binding_unusable|unknown_binding)$/.test(reason) && !opts.reattached) {
@@ -4861,7 +5235,7 @@
4861
5235
  showPendingTurn(draft.text, 'sends after this reply', draft.key);
4862
5236
  saveHeld(r);
4863
5237
  }
4864
- clearBoxOf(draft.text);
5238
+ clearBoxOf(draft.text, r);
4865
5239
  sessionStatus('COS is still answering. ' + (r.held.length > 1 ? r.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>');
4866
5240
  startHeldTimer(r);
4867
5241
  }
@@ -4894,7 +5268,7 @@
4894
5268
  } else {
4895
5269
  if (bubble) bubble.remove();
4896
5270
  // The whole message comes back: its words, and its files where the Mac still keeps them.
4897
- returnToBox(h.text, how === 'cancelled' ? 'Not sent.' : copy || 'Not sent.', heldFilesBack(r, h));
5271
+ returnToBox(h.text, how === 'cancelled' ? 'Not sent.' : copy || 'Not sent.', heldFilesBack(r, h), r);
4898
5272
  }
4899
5273
  if (!r.held.some(function (x) { return !x.cancelled; })) clearInterval(r.heldTimer);
4900
5274
  }
@@ -4966,7 +5340,7 @@
4966
5340
  sessionStatus('Copying the session. This can take up to a minute.');
4967
5341
  var settle = function (b) {
4968
5342
  if (b.forked === true) {
4969
- r.forkDraft = null; clearBoxOf(text); readerFilesSent(readerFilesOn() ? readerBox(r) : null, ids);
5343
+ r.forkDraft = null; clearBoxOf(text, r); readerFilesSent(readerFilesOn() ? readerBox(r) : null, ids);
4970
5344
  sessionStatus('Forked. The copy has your message and shows in Sessions in a moment.');
4971
5345
  setTimeout(function () { if (state.pane === 'sessions') loadSessions(true); }, 4000);
4972
5346
  return true;
@@ -5361,7 +5735,7 @@
5361
5735
  // estimates into them, and its rows keep Control's category titles.
5362
5736
  function loadUsage() {
5363
5737
  SX.view = 'usage'; sxSave();
5364
- location.replace('#/sessions');
5738
+ navReplace('#/sessions');
5365
5739
  }
5366
5740
  function renderUsageView(force) {
5367
5741
  var box = $('sessions-usage'); if (!box) return;
@@ -7136,10 +7510,13 @@
7136
7510
 
7137
7511
  // ── events (one delegated listener) ─────────────────────
7138
7512
  document.addEventListener('click', function (e) {
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');
7513
+ 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],[data-trail-back],#topbar-back,#rail-back,#section-btn,#sign-out,#copy-link,#ask-close,#auth-switch,#auth-token-show,#auth-retry,#device-code-btn');
7140
7514
  if (!t) { if (e.target.id === 'ask-sheet') closeAsk(); if (e.target.id === 'section-menu') closeMenu(); return; }
7141
7515
  var inSheet = !!t.closest('#ask-sheet');
7142
7516
  if (t.id === 'topbar-back') return goBack();
7517
+ if (t.id === 'rail-back') return goBack();
7518
+ // 7.0.30 (pick B): a trail step is the browser's own Back, that many steps.
7519
+ if (t.hasAttribute('data-trail-back')) { e.preventDefault(); history.go(-Number(t.getAttribute('data-trail-back'))); return; }
7143
7520
  if (t.id === 'section-btn') return openMenu();
7144
7521
  if (t.id === 'auth-retry') { t.disabled = true; t.textContent = 'Checking'; bootStatus().then(function () { t.disabled = false; t.textContent = 'Try again'; }); return; }
7145
7522
  if (t.id === 'auth-switch') {
@@ -7284,6 +7661,7 @@
7284
7661
  });
7285
7662
  document.addEventListener('input', function (e) {
7286
7663
  if (e.target.tagName === 'TEXTAREA') autoGrow(e.target);
7664
+ if (e.target.id === 'session-input' && typeof unsentSave === 'function') unsentSave();
7287
7665
  if (e.target.id === 'ask-q') renderAsk();
7288
7666
  if (e.target.hasAttribute && e.target.hasAttribute('data-next-prep-steer')) nextPrepInput(e.target);
7289
7667
  if (e.target.id === 'sessions-q' && !e.target.value.trim()) renderSessionGroups(state.sessions);
@@ -7507,13 +7885,54 @@
7507
7885
  } catch (_e) { return false; }
7508
7886
  }
7509
7887
 
7888
+ // Page errors (lead 08:31, 11/11): Control Web reported none, so "no errors in his rows" proved nothing. A script error
7889
+ // or an unhandled rejection becomes one bounded row, event web.error: what kind it was, the error's class (TypeError),
7890
+ // its code and HTTP status when it has them, the script file's own name, line and column, and the pane. Never the
7891
+ // message, a stack or any text: those can carry what a person typed. At most WEB_ERR_MAX rows a page load, one per
7892
+ // kind, class, code and place.
7893
+ var WEB_ERR_MAX = 10, WEB_ERR = { n: 0, seen: {} };
7894
+ function webErrorRow(kind, err, file, line, col) {
7895
+ try {
7896
+ var name = err && typeof err.name === 'string' ? err.name : '';
7897
+ var cls = /^[A-Za-z][A-Za-z0-9]{0,39}$/.test(name) ? name : (err === null || err === undefined ? 'none' : typeof err === 'object' ? 'object' : typeof err);
7898
+ var code = err && (typeof err.code === 'string' || typeof err.code === 'number') && /^[A-Za-z0-9_.-]{1,40}$/.test(String(err.code)) ? String(err.code) : '';
7899
+ var status = err && typeof err.status === 'number' && isFinite(err.status) ? err.status : null;
7900
+ var src = String(file || '').split(/[?#]/)[0].split('/').pop();
7901
+ src = /^[A-Za-z0-9_.-]{1,40}$/.test(src) ? src : '';
7902
+ var ln = Number(line) > 0 ? Math.floor(Number(line)) : 0, cn = Number(col) > 0 ? Math.floor(Number(col)) : 0;
7903
+ var key = [kind, cls, code, status, src, ln].join('|');
7904
+ if (WEB_ERR.seen[key] || WEB_ERR.n >= WEB_ERR_MAX) return false;
7905
+ WEB_ERR.seen[key] = 1; WEB_ERR.n++;
7906
+ var pane = typeof state === 'object' && state && typeof state.pane === 'string' && /^[a-z-]{1,24}$/.test(state.pane) ? state.pane : '';
7907
+ api('/api/diag/client', { method: 'POST', body: { ts: Date.now(), level: 'error', event: 'web.error',
7908
+ data: { kind: kind, cls: cls, code: code, status: status, src: src, line: ln, col: cn, pane: pane, n: WEB_ERR.n } }, timeoutMs: 5000 }).catch(function () {});
7909
+ return true;
7910
+ } catch (_e) { return false; }
7911
+ }
7912
+ if (window.addEventListener) {
7913
+ // Only script errors: a failed image or file load fires 'error' on its element, which does not reach the window.
7914
+ window.addEventListener('error', function (e) { if (e && (e.error || e.filename)) webErrorRow('error', e.error || null, e.filename, e.lineno, e.colno); });
7915
+ window.addEventListener('unhandledrejection', function (e) { webErrorRow('rejection', e ? e.reason : null, '', 0, 0); });
7916
+ }
7917
+
7918
+ // 7.0.28: when theme.js had to bring back a stylesheet or a script (the page opened before it could reach the Mac), one
7919
+ // numbers-only row says so: how ('sheets', 'reload' or 'gave-up'), how many sheets and scripts had failed, how many
7920
+ // checks it took and how long. A reload's report comes back from sessionStorage once.
7921
+ (function restyleRow() {
7922
+ var R = window.CosRestyle;
7923
+ if (!R) return;
7924
+ var row = function (d) { if (d && typeof d === 'object') layoutCrumb('restyle', { how: String(d.how || '').slice(0, 24), sheets: +d.sheets || 0, scripts: +d.scripts || 0, tries: +d.tries || 0, ms: +d.ms || 0 }); };
7925
+ row(R.takeSaved && R.takeSaved());
7926
+ window.addEventListener('cos-restyle', function (e) { row(e && e.detail); });
7927
+ })();
7928
+
7510
7929
  // ── the shared kit for pane modules (panes/*.js) ───────
7511
7930
  window.COSWeb = {
7512
7931
  $: $, esc: esc, md: md, plain: plain, structureLensAnswer: structureLensAnswer, tableFit: tableFit, api: api, toast: toast, ago: ago, clock: clock, toDate: toDate,
7513
7932
  plural: plural, countWord: countWord, dayWord: dayWord, plainDay: plainDay, providerLabel: providerLabel, domainLabel: domainLabel,
7514
7933
  skeletonRows: skeletonRows, emptyBox: emptyBox, setStatus: setStatus, autoGrow: autoGrow,
7515
7934
  readStore: readStore, writeStore: writeStore, copyText: copyText, isPhone: isPhone,
7516
- state: state, titles: TITLES, go: go, parseRoute: parseRoute, goBack: goBack,
7935
+ state: state, titles: TITLES, go: go, replace: navReplace, parseRoute: parseRoute, goBack: goBack,
7517
7936
  registerPane: function (name, def) { PANES[name] = def; },
7518
7937
  sessionList: sessionList,
7519
7938
  petPrefs: petPrefs,
@@ -7523,7 +7942,7 @@
7523
7942
  onShell: function (fn) { shellHooks.push(fn); },
7524
7943
  // The detail title after "COS Control > Section" (Control's breadcrumb). Pane modules set it
7525
7944
  // once a detail has loaded; route() clears it on every navigation.
7526
- setCrumb: function (text) { crumbDetail = String(text || ''); runHooks(routeHooks, parseRoute()); },
7945
+ setCrumb: function (text) { crumbDetail = String(text || ''); navLabel(crumbDetail); paintTrail(); runHooks(routeHooks, parseRoute()); },
7527
7946
  crumb: function () { return crumbDetail; },
7528
7947
  // Panes that read with their own fetch call this on a 401 so the sign-in screen shows.
7529
7948
  showAuth: function (message) { showAuth(message); },
@@ -7679,7 +8098,7 @@
7679
8098
  var sc = window.screen || {}, w = lrWide();
7680
8099
  layoutCrumb('device', { sw: sc.width, sh: sc.height, sah: sc.availHeight, oh: window.outerHeight, ow: window.outerWidth, dpr: window.devicePixelRatio,
7681
8100
  sat: lrProbe('padding-top:env(safe-area-inset-top)', 'paddingTop'), sab: lrProbe('padding-bottom:env(safe-area-inset-bottom)', 'paddingBottom'),
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 });
8101
+ anyCoarse: lrMatch('(any-pointer: coarse)'), shortTouch: lrMatch(SHORT_TOUCH_MQ), rail: typeof sideRail === 'function' ? sideRail() : false, railHit: typeof RAIL === 'object' ? RAIL.hit : 0, phone: isPhone(), cw: w.cw, dw: w.dw, scw: w.scw, wide: w.wide });
7683
8102
  }
7684
8103
  // The focus line is for touch screens only (QA W3): on a Mac it would only spend the budget.
7685
8104
  function lrFocus() {
@@ -7774,6 +8193,7 @@
7774
8193
 
7775
8194
  // Every pane module is a plain script after this one; boot waits for all of them.
7776
8195
  function boot() {
8196
+ if (typeof railSync === 'function') railSync(); // 7.0.29: before the shell shows, so the rail never moves the page
7777
8197
  // The open-time report's clock (ms since navigation start, -1 where there is none) and the two numbers boot gives it:
7778
8198
  // when boot started (every script had run) and the sign-in check's round trip. Kept inside boot, and FIRST_PAINT
7779
8199
  // looked up, never assumed, because the harnesses that run boot alone have neither.
@@ -7822,20 +8242,27 @@
7822
8242
  return api('/api/web-session/status').then(function (st) { return bootAnswer(st); }, function (err) { return bootFail(err); });
7823
8243
  }
7824
8244
  function bootAnswer(st) {
8245
+ AUTH_RETRY.n = 0; // the Mac answered: a later not-answering card starts its quick checks again
7825
8246
  if (st && st.authenticated) return showShell(st);
7826
8247
  // 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
8248
  showAuth(typeof signinKnown === 'function' && signinKnown() ? signinLapsedCopy() : undefined);
7828
8249
  }
7829
8250
  function bootFail(err) {
7830
8251
  // 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 };
8252
+ // showAuth turns the card off first (authOffline(false)); that must not restart the 1, 2, 4, 8 s steps, so only a Mac
8253
+ // that answered (bootAnswer, or an error with a status here) resets them.
8254
+ if (err && err.status) { AUTH_RETRY.n = 0; showAuth(err.message); } else { showAuth(); authOffline(true); }
8255
+ }
8256
+ // Q1 M2: the not-answering card checks again while the page is in front, and stops while it is hidden. 7.0.28 (Miles
8257
+ // 19:28): the first checks come after 1, 2, 4 and 8 s, because a phone that opened the page before its tunnel was up
8258
+ // usually reaches the Mac within seconds; then every 30 s. The steps start over once the Mac has answered.
8259
+ var AUTH_RETRY = { timer: null, ms: 30000, steps: [1000, 2000, 4000, 8000], n: 0 };
7835
8260
  function authRetrySchedule(on) {
7836
8261
  if (AUTH_RETRY.timer) { clearTimeout(AUTH_RETRY.timer); AUTH_RETRY.timer = null; }
7837
8262
  if (!on || document.hidden) return;
7838
- AUTH_RETRY.timer = setTimeout(function () { AUTH_RETRY.timer = null; if (!document.hidden) bootStatus(); }, AUTH_RETRY.ms);
8263
+ var ms = AUTH_RETRY.n < AUTH_RETRY.steps.length ? AUTH_RETRY.steps[AUTH_RETRY.n] : AUTH_RETRY.ms;
8264
+ AUTH_RETRY.n++;
8265
+ AUTH_RETRY.timer = setTimeout(function () { AUTH_RETRY.timer = null; if (!document.hidden) bootStatus(); }, ms);
7839
8266
  }
7840
8267
  document.addEventListener('visibilitychange', function () {
7841
8268
  var box = $('auth-offline');
@@ -7853,8 +8280,8 @@
7853
8280
  if (title) title.textContent = on ? (offline ? 'This device is offline' : 'COS on your Mac is not answering') : 'Connect this browser';
7854
8281
  if (intro) intro.classList.toggle('hidden', !!on);
7855
8282
  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.';
8283
+ if (text && on) text.textContent = offline ? 'Connect to the internet and Tailscale. This page checks again in a few seconds, then every 30 seconds.'
8284
+ : 'Your Mac may be asleep, or this device may be offline or off Tailscale. This page checks again in a few seconds, then every 30 seconds.';
7858
8285
  authRetrySchedule(!!on);
7859
8286
  if (on) { var err = $('auth-error'); if (err) { setStatus(err, '', true); err.classList.add('hidden'); } }
7860
8287
  }