@gotcos/glasses-server 7.0.27 → 7.0.29

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.
@@ -529,6 +577,56 @@
529
577
  // 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
578
  var SHORT_TOUCH_MQ = '(pointer: coarse) and (max-height: 720px)';
531
579
  function isPhone() { return !window.matchMedia('(min-width: 600px)').matches || window.matchMedia(SHORT_TOUCH_MQ).matches; }
580
+ // 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
581
+ // (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
582
+ // way to switch panes: the top bar's section button keeps the name and loses its menu (inert), and its Back moves to
583
+ // the rail's foot (#rail-back, the same goBack).
584
+ var SIDE_RAIL_MQ = '(max-width: 599px) and (pointer: coarse)';
585
+ function sideRail() {
586
+ var de = document.documentElement;
587
+ return !!(de && de.classList && de.classList.contains('short-screen') && window.matchMedia && window.matchMedia(SIDE_RAIL_MQ).matches);
588
+ }
589
+ // 44 points on the phone. Safari's Page Zoom lays the page out wider and draws it at visualViewport.scale (his Duo: 449 px
590
+ // 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
591
+ // layout width changes (rotation), never on a pinch: a pinch must not resize the rail.
592
+ var RAIL = { hit: 0, cw: -1, hooked: false, on: null };
593
+ function railHitSync() {
594
+ var de = document.documentElement, cw = de ? de.clientWidth : 0;
595
+ if (RAIL.hit && cw === RAIL.cw) return RAIL.hit;
596
+ var vv = window.visualViewport, s = vv && typeof vv.scale === 'number' ? vv.scale : 1;
597
+ if (!(s >= 0.5 && s <= 1)) s = 1;
598
+ RAIL.hit = Math.ceil(44 / s - 0.01); // s is 0.5 to 1 here, so 44 to 88
599
+ RAIL.cw = cw;
600
+ if (de && de.style) de.style.setProperty('--rail-hit', RAIL.hit + 'px');
601
+ return RAIL.hit;
602
+ }
603
+ function railSync() {
604
+ var on = false;
605
+ try {
606
+ // theme.js's one screen test first (its own resize listener ran too; this keeps the order certain), then the page.
607
+ if (typeof window.CosShortScreen === 'function') window.CosShortScreen();
608
+ on = sideRail();
609
+ var btn = typeof $ === 'function' ? $('section-btn') : null;
610
+ if (btn) {
611
+ btn.inert = on;
612
+ if (on) btn.removeAttribute('aria-haspopup'); else btn.setAttribute('aria-haspopup', 'dialog');
613
+ }
614
+ if (on) { railHitSync(); closeMenu(); }
615
+ // The rail changes the reply box's width: an empty box re-fits its placeholder (autoGrow, F2).
616
+ 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);
617
+ // A change after boot (a fold, a rotation, a new screen) is reported once, with the screen's own numbers.
618
+ 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 }); }
619
+ RAIL.on = on;
620
+ if (!RAIL.hooked && typeof window.matchMedia === 'function' && typeof window.addEventListener === 'function') {
621
+ RAIL.hooked = true;
622
+ var mq = window.matchMedia(SIDE_RAIL_MQ);
623
+ if (mq && mq.addEventListener) mq.addEventListener('change', railSync); else if (mq && mq.addListener) mq.addListener(railSync);
624
+ window.addEventListener('resize', function () { railSync(); });
625
+ window.addEventListener('orientationchange', function () { railSync(); });
626
+ }
627
+ } catch (_e) { /* a harness without a page: no rail */ }
628
+ return on;
629
+ }
532
630
  /** A keyboard and a fine pointer are the way in (a Mac, a PC): Return sends. Touch screens type a new line. */
533
631
  function keyboardFirst() { return !!(window.matchMedia && window.matchMedia('(hover: hover) and (pointer: fine)').matches); }
534
632
  // Return in a text box sends only where a keyboard and a pointer are the way in (cos-chat.js keyboardFirst): never on
@@ -563,6 +661,8 @@
563
661
 
564
662
  // Top bar (phone): the section switcher at a list, a back button on a reader.
565
663
  $('topbar-back').classList.toggle('hidden', !reading);
664
+ var railBack = $('rail-back');
665
+ if (railBack) railBack.hidden = !reading;
566
666
  $('section-btn').classList.toggle('hidden', reading);
567
667
  $('topbar-back-label').textContent = TITLES[r.pane] || 'Back';
568
668
  $('section-name').textContent = TITLES[r.pane] || 'Today';
@@ -624,7 +724,7 @@
624
724
  else if (y > lastY + 4 && isPhone()) bar.classList.add('tucked');
625
725
  lastY = y;
626
726
  }
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(); }
727
+ 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
728
  function closeMenu() { $('section-menu').classList.add('hidden'); }
629
729
 
630
730
  // ── auth ────────────────────────────────────────────────
@@ -2068,7 +2168,11 @@
2068
2168
  var qs1 = sessionQs(), open = (!!html && !te) || !!(qs1 && ((qs1.itemsFor && qs1.itemsFor(r.id).length) || (qs1.atMacFor && qs1.atMacFor(r.id).length)));
2069
2169
  var box = $('session-input');
2070
2170
  // 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…');
2171
+ if (box && box.setAttribute) {
2172
+ box.phLong = te ? 'Or write your own answer' : open ? 'Message this session (not an answer)' : 'Continue this session…';
2173
+ box.phShort = open && !te ? 'Message (not an answer)' : '';
2174
+ if (typeof sxPlaceholderFit === 'function') sxPlaceholderFit(box); else box.setAttribute('placeholder', box.phLong);
2175
+ }
2072
2176
  }
2073
2177
  // ── end at the Mac's prompt
2074
2178
 
@@ -2759,6 +2863,8 @@
2759
2863
  var touched = function () { SX_PIN.userAt = Date.now(); };
2760
2864
  ['wheel', 'touchstart', 'pointerdown', 'keydown'].forEach(function (t) { body.addEventListener(t, touched, { passive: true }); });
2761
2865
  body.addEventListener('scroll', sxGoQuestionPaint, { passive: true });
2866
+ // 7.0.29 ADD 3: reaching the end shows him what is there.
2867
+ body.addEventListener('scroll', function () { if (typeof sxMarkShown === 'function' && state.reading) sxMarkShown(state.reading); }, { passive: true });
2762
2868
  body.addEventListener('scroll', function () {
2763
2869
  if (SX_PIN.body !== body || Date.now() - SX_PIN.userAt > SX_PIN_USER_MS) return;
2764
2870
  if (Math.abs(body.scrollTop - SX_PIN.put) > 4 && body.scrollTop < body.scrollHeight - body.clientHeight - 4) sxUnpin();
@@ -2907,12 +3013,25 @@
2907
3013
  // Earlier this month as compact rows, folded shut with a count. Days are counted back from today (2 to 6 days ago is
2908
3014
  // Earlier this week, 7 to 13 Last week, 14 to 30 Earlier this month), so no group is empty only because of the
2909
3015
  // calendar.
2910
- var SX_GROUPS = [['needs', 'Needs you', 'state'], ['working', 'Working now', 'state'], ['pinned', 'Pinned', ''], ['today', 'Today', ''],
3016
+ var SX_GROUPS = [['needs', 'Needs you', 'state'], ['working', 'Working now', 'state'], ['pinned', 'Pinned', 'pinned'], ['today', 'Today', ''],
2911
3017
  ['yesterday', 'Yesterday', ''], ['week', 'Earlier this week', ''], ['lastweek', 'Last week', 'compact'], ['month', 'Earlier this month', 'compact']];
2912
3018
  var SX_DAY_MS = 86400000, SX_REST_MS = 2000, SX_EMPTY_HOLD_MS = 5000;
2913
3019
  // place: each row's group as last drawn at rest; seen: when Needs you and Working now last had rows; open: unfolded
2914
3020
  // compact groups; older: /older's short rows (olderOk false: an older server, 404).
2915
3021
  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 };
3022
+ // 7.0.29 ADD 1 (Miles 23:53: thirteen pinned rows sat between Working now and Today): Pinned folds like a folder, with
3023
+ // its count, and this browser remembers the choice (localStorage). Open by default, so no list changes until he folds
3024
+ // it. Placement is unchanged: sxGroupFor tests state before pin, so a pinned session that is running is drawn in
3025
+ // Working now, one that waits or failed in Needs you, folded or not, and it goes back under Pinned when idle.
3026
+ var SX_PIN_FOLD_KEY = 'cos_sx_pinned_folded';
3027
+ function sxPinnedOpen() {
3028
+ 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'; }
3029
+ return SXL.pinOpen;
3030
+ }
3031
+ function sxPinnedSet(open) {
3032
+ SXL.pinOpen = !!open;
3033
+ 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 */ }
3034
+ }
2916
3035
  function sxRowKey(s) { var k = splitSessionId(s.provider, s.session_id || s.id); return k.provider + '|' + String(k.id).toLowerCase(); }
2917
3036
  function sxDayStart(t) { var d = new Date(t); d.setHours(0, 0, 0, 0); return d.getTime(); }
2918
3037
  function sxSortTime(s) { return SX.clock === 'opened' ? (sxTime(s.created) || sxTime(s.modified)) : sxTime(s.modified); }
@@ -3017,6 +3136,13 @@
3017
3136
  return;
3018
3137
  }
3019
3138
  if (!n) return;
3139
+ if (g[2] === 'pinned') {
3140
+ var popen = sxPinnedOpen();
3141
+ html += '<section class="sx-g" data-g="pinned"><button type="button" class="sx-gh" data-sx="fold" data-g="pinned" aria-expanded="' + popen + '">' +
3142
+ '<span class="fold"><span class="chev" aria-hidden="true"></span>' + g[1] + '</span><span class="n">' + n + '</span></button>' +
3143
+ (popen ? '<div class="sx-rows">' + rows.map(function (s) { return sxRow(s, null, amb, s.form === 'short'); }).join('') + '</div>' : '') + '</section>';
3144
+ return;
3145
+ }
3020
3146
  if (g[2] === 'compact') {
3021
3147
  var open = !!SXL.open[id];
3022
3148
  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 +3314,9 @@
3188
3314
  (s.cos_pinned ? '<button type="button" role="menuitem" data-sx="org-pin" data-key="' + esc(key) + '">Unpin in COS</button>' : '')
3189
3315
  : '<button type="button" role="menuitem" data-sx="org-pin" data-key="' + esc(key) + '">' + (s.cos_pinned ? 'Unpin' : 'Pin') + '</button>') +
3190
3316
  '<button type="button" role="menuitem" data-sx="org-move-menu" data-key="' + esc(key) + '">Move to folder</button>' +
3317
+ // 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
3318
+ // of Move to folder): a row in a folder gets Remove from folder, the same write as No folder (folder_id null).
3319
+ (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
3320
  '<button type="button" role="menuitem" data-sx="org-rename" data-key="' + esc(key) + '">Rename</button>';
3192
3321
  }
3193
3322
  function sxFolderMenuHtml(id) {
@@ -3491,6 +3620,7 @@
3491
3620
  if (act === 'view') { sxViewMenu(false); return setSessionsView(t.getAttribute('data-view')); }
3492
3621
  if (act === 'view-menu') return sxViewMenu();
3493
3622
  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; }
3623
+ if (act === 'fold' && t.getAttribute('data-g') === 'pinned') { sxPinnedSet(!sxPinnedOpen()); return renderSessionGroups(); }
3494
3624
  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
3625
  if (/^(row-menu|org-|folder-|new-folder|rename-clear|inline-cancel)/.test(act)) { e.stopPropagation(); return sxOrgClick(act, t); }
3496
3626
  if (act === 'older-more') return sxLoadOlder(false, true);
@@ -3507,7 +3637,6 @@
3507
3637
  if (act === 'clear') { var input = $('sessions-q'); input.value = ''; searchSessions(); input.focus(); return; }
3508
3638
  if (act === 'copy-session') return copySessionBrief();
3509
3639
  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
3640
  if (act === 'retry' && rd) { rd.refusal = null; renderVerdict(rd); return sendSessionTurn(!!rd.retryFork); }
3512
3641
  if (act === 'caution-send' && rd) { rd.cautionAck = true; rd.cautionAsk = false; renderVerdict(rd); return sendSessionTurn(false); }
3513
3642
  if (act === 'caution-cancel' && rd) { rd.cautionAsk = false; renderVerdict(rd); return; }
@@ -3803,7 +3932,7 @@
3803
3932
  var timer = ctrl && o.timeoutMs ? setTimeout(function () { ctrl.abort(); }, o.timeoutMs) : null;
3804
3933
  var headers = { 'X-COS-Web': '1' };
3805
3934
  if (o.body !== undefined) headers['Content-Type'] = 'application/json';
3806
- var sentAt = Date.now();
3935
+ var sentAt = Date.now(), writeDone = typeof writeStart === 'function' ? writeStart(o.method || 'GET') : function () {};
3807
3936
  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
3937
  .then(function (res) {
3809
3938
  sxNoteClock(res, sentAt);
@@ -3816,7 +3945,7 @@
3816
3945
  noteServer(false);
3817
3946
  throw unreachableError(err);
3818
3947
  })
3819
- .finally(function () { if (timer) clearTimeout(timer); });
3948
+ .finally(function () { if (timer) clearTimeout(timer); writeDone(); });
3820
3949
  }
3821
3950
 
3822
3951
  // Per-browser memory for one session: messages held until a reply ends, and the queue keys
@@ -3925,6 +4054,8 @@
3925
4054
  // edge says one waits. It sits in the reader, not the dock, because the dock scrolls by itself as a last resort.
3926
4055
  '<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
4056
  readerImagesMount($('session-turns'), r);
4057
+ if (typeof unsentRestore === 'function') unsentRestore(r);
4058
+ var si0 = $('session-input'); if (si0 && !si0.value && typeof autoGrow === 'function') autoGrow(si0);
3928
4059
  sxNoteList(r, d);
3929
4060
  setLive({});
3930
4061
  if (typeof readerMacPaint === 'function') readerMacPaint(r, sxState(d) === 'waiting');
@@ -3942,6 +4073,8 @@
3942
4073
  startHeldTimer(state.reading);
3943
4074
  }
3944
4075
  if (turns.length) requestAnimationFrame(function () { if (typeof sxToNewest === 'function') sxToNewest(); else window.scrollTo(0, document.documentElement.scrollHeight); });
4076
+ // 7.0.29 ADD 3: the newest turns he was just taken to count as shown (this frame runs after the one above).
4077
+ if (turns.length) requestAnimationFrame(function () { if (typeof sxMarkShown === 'function') sxMarkShown(r); });
3945
4078
  requestAnimationFrame(function () { if (typeof sxFrameCrumb === 'function') sxFrameCrumb('open'); });
3946
4079
  watchSession(provider, id);
3947
4080
  }).catch(function (err) {
@@ -4230,6 +4363,53 @@
4230
4363
  paintActivity(r);
4231
4364
  if (moved) renderVerdict(r);
4232
4365
  }
4366
+ // ── 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
4367
+ // anything"; the lead's ruling 00:02 and A1 to A3 00:47): the changed-thread stop. The server guard is unchanged; the
4368
+ // page does the acknowledging. Shown, not just drawn (A1): a turn counts as shown only while the reader is at the
4369
+ // newest turn (following it, or scrolled to the end since). Read from the reader's own pin and scroll, no observer per
4370
+ // turn. Only turns with words count; tool steps are not turns here.
4371
+ function sxTextTurns(turns) { return (turns || []).filter(function (t) { return t && String(t.text || t.content || '').trim(); }); }
4372
+ function sxAtEnd() {
4373
+ try {
4374
+ var body = $('session-body');
4375
+ if (body && document.documentElement.classList.contains('sx-frame')) return SX_PIN.body === body || body.scrollTop >= body.scrollHeight - body.clientHeight - 4;
4376
+ var de = document.documentElement;
4377
+ return (window.scrollY || 0) + window.innerHeight >= de.scrollHeight - 4;
4378
+ } catch (_e) { return false; } // a page without these (a test harness): nothing counts as shown, so nothing resends
4379
+ }
4380
+ function sxMarkShown(r) {
4381
+ if (!r || state.reading !== r || !sxAtEnd()) return;
4382
+ r.shown = r.shown || {};
4383
+ sxTextTurns(r.turns).forEach(function (t) { r.shown[sxTurnSig(t)] = 1; });
4384
+ }
4385
+ // At Send: what he had been shown, and whether the session was already waiting on him.
4386
+ function sxPressSeen(r) {
4387
+ sxMarkShown(r);
4388
+ return { shown: Object.assign({}, r.shown || {}), waiting: !!r.waitingNow };
4389
+ }
4390
+ // After a changed-thread refusal: fetch the turns (the refreshTurns request), draw them, and say what is new since Send:
4391
+ // turns with words he had not been shown, or a question or permission now waiting that was not (A2). null: the fetch
4392
+ // failed, so nothing can be said and the box shows.
4393
+ function sxChangedLook(r, seen) {
4394
+ return api(sessionPath(r.provider, r.id, '?turns=' + SX_TURNS + '&cards=1')).then(function (d) {
4395
+ if (state.reading !== r) return null;
4396
+ var turns = Array.isArray(d && d.recent_turns) ? d.recent_turns : [];
4397
+ var box = $('session-turns'); if (box) { sxMergeTurns(box, r.turns || [], turns, d); readerImagesMount(box, r); }
4398
+ r.detail = d; r.turns = turns; r.more = d.recent_turns_more === true;
4399
+ var fresh = sxTextTurns(turns).filter(function (t) { return !seen.shown[sxTurnSig(t)]; });
4400
+ // Waiting by the reader's own rule, or by the detail's own word (a session read as stale may still say it waits):
4401
+ // either way stopping is the safe side.
4402
+ return { fresh: fresh, newWait: (sxState(d) === 'waiting' || (d && d.agent_state === 'waiting')) && !seen.waiting };
4403
+ }, function () { return null; });
4404
+ }
4405
+ // Take him to the first new turn: it is drawn, and the stop is about it.
4406
+ function sxShowFresh(fresh) {
4407
+ if (!fresh || !fresh.length) return;
4408
+ var sig = sxTurnSig(fresh[0]), el = null;
4409
+ document.querySelectorAll('#session-turns .turn[data-sig]').forEach(function (n) { if (!el && n.getAttribute('data-sig') === sig) el = n; });
4410
+ sxUnpin();
4411
+ if (el && el.scrollIntoView) el.scrollIntoView({ block: 'start' }); else sxToNewest();
4412
+ }
4233
4413
  function refreshTurns(provider, id) {
4234
4414
  var reading = state.reading;
4235
4415
  api(sessionPath(provider, id, '?turns=' + SX_TURNS + '&cards=1')).then(function (d) {
@@ -4239,6 +4419,7 @@
4239
4419
  var box = $('session-turns'); if (box) { sxMergeTurns(box, r.turns || [], turns, d); readerImagesMount(box, r); }
4240
4420
  // The header keeps the subtitle it opened with: a rewrap above the turns would move them.
4241
4421
  r.detail = d; r.turns = turns; r.more = d.recent_turns_more === true;
4422
+ sxMarkShown(r);
4242
4423
  renderHeld(r);
4243
4424
  loadQueue(provider, id);
4244
4425
  sxProbeAttach(r);
@@ -4362,9 +4543,10 @@
4362
4543
  } else if (r.refusal) {
4363
4544
  var f = r.refusal;
4364
4545
  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>' : '') +
4546
+ // 7.0.29 ADD 3: a changed thread says what each button does. The page has already refreshed, so no Refresh.
4547
+ (f.changed ? '<button type="button" class="cx-btn quiet" data-action="send-anyway">Send it anyway</button>' : '') +
4366
4548
  (f.retry ? '<button type="button" class="cx-btn quiet" data-sx="retry">Retry</button>' : '') +
4367
- (f.fork ? forkBtn : ''));
4549
+ (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
4550
  } else if (v && v.attachable === false && /^(native_thread_working|native_target_busy)$/.test(v.reason)) {
4369
4551
  // 6.99.29: "Working right now" only while the one state says so.
4370
4552
  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 +4581,51 @@
4399
4581
  if (!r.draft || r.draft.text !== text || (r.draft.ids || []).join(',') !== files.join(',')) r.draft = { text: text, key: newTurnKey(), ids: files };
4400
4582
  return r.draft;
4401
4583
  }
4402
- // The box is cleared only of the very words that were sent; anything typed since stays.
4403
- function clearBoxOf(text) {
4584
+ // 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
4585
+ // box and not sent come back when that session opens again in this tab, after Back, the rail or another pane. Kept in
4586
+ // memory, per session, as he types; dropped when the box is empty or when those words are sent.
4587
+ var SX_UNSENT = {};
4588
+ function unsentKey(provider, id) { return String(provider || '') + '|' + String(id || '').toLowerCase(); }
4589
+ function unsentSave() {
4590
+ var r = state.reading, input = $('session-input');
4591
+ if (!r || !input) return;
4592
+ var k = unsentKey(r.provider, r.id);
4593
+ if (input.value.trim()) SX_UNSENT[k] = input.value; else delete SX_UNSENT[k];
4594
+ }
4595
+ function unsentRestore(r) {
4596
+ var input = $('session-input'), v = r ? SX_UNSENT[unsentKey(r.provider, r.id)] : '';
4597
+ if (input && v && !input.value) { input.value = v; autoGrow(input); }
4598
+ }
4599
+ // 7.0.29 R4 (Q1 Note 1): the saved words of the session the message was sent FROM only, never another session's
4600
+ // words that happen to match. No session named, nothing is dropped.
4601
+ function unsentDrop(text, r) {
4602
+ if (!r || !r.provider || !r.id) return;
4603
+ var k = unsentKey(r.provider, r.id), v = SX_UNSENT[k];
4604
+ if (v == null) return;
4605
+ var t = String(text).trim(), bare = typeof turnEndBare === 'function' ? turnEndBare(text) : t;
4606
+ v = String(v).trim();
4607
+ if (v === t || v === bare) delete SX_UNSENT[k];
4608
+ }
4609
+ // The box is cleared only of the very words that were sent; anything typed since stays. `r` is the session they were
4610
+ // sent from: another session's box is never touched (R4).
4611
+ function clearBoxOf(text, r) {
4612
+ if (typeof unsentDrop === 'function') unsentDrop(text, r || state.reading);
4613
+ if (r && state.reading !== r) return;
4404
4614
  var input = $('session-input');
4405
4615
  // 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
4616
  if (input && (input.value.trim() === String(text).trim() || input.value.trim() === turnEndBare(text))) { input.value = ''; autoGrow(input); }
4407
4617
  if (state.reading && state.reading.draft && state.reading.draft.text === text) state.reading.draft = null;
4408
4618
  }
4409
4619
  // 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) {
4620
+ // of the message's files (heldFilesBack). 7.0.29 R4: when `r`, the session they came from, is not the one open, they
4621
+ // go to that session's saved words (back in its box when it opens), never into the open session's box.
4622
+ function returnToBox(text, why, files, r) {
4623
+ if (r && r.provider && r.id && state.reading !== r) {
4624
+ 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; }
4625
+ return;
4626
+ }
4412
4627
  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'); }
4628
+ 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
4629
  else sessionStatus(why + ' Not sent: “' + String(text).slice(0, 80) + (text.length > 80 ? '…' : '') + '”' + (files || ''), 'error');
4415
4630
  }
4416
4631
  // Your words show in the conversation the moment the Mac accepts them; the next refresh of
@@ -4437,7 +4652,7 @@
4437
4652
  var READER_VIEW = {
4438
4653
  status: function (r, text, kind, actionHtml) { sessionStatus(text, kind, actionHtml); },
4439
4654
  pending: function (r, text, note) { showPendingTurn(text, note); },
4440
- clear: function (r, text) { clearBoxOf(text); },
4655
+ clear: function (r, text) { clearBoxOf(text, r); },
4441
4656
  verdict: function (r) { renderVerdict(r); },
4442
4657
  hold: function (r, draft) { holdUntilReplyEnds(r, draft); },
4443
4658
  watch: function (r, bindingId, key) { watchTurn(bindingId, key, r.provider, r.id); },
@@ -4552,7 +4767,11 @@
4552
4767
  if (r.refusal || r.cautionAsk) { r.refusal = null; r.cautionAsk = false; renderVerdict(r); }
4553
4768
  // 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
4769
  // 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);
4770
+ var answering = !!(!fork && r.turnEnd && turnEndOf(readerRowOf(r)));
4771
+ if (answering) text = turnEndPrompt(text);
4772
+ // 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):
4773
+ // 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.
4774
+ if (!fork && typeof sxPressSeen === 'function') opts = Object.assign({}, opts || {}, { seen: answering ? null : sxPressSeen(r) });
4556
4775
  r.sending = true;
4557
4776
  var btns = form.querySelectorAll('button'); btns.forEach(function (b) { b.disabled = true; });
4558
4777
  var done = function () { r.sending = false; btns.forEach(function (b) { b.disabled = false; }); paintReaderFiles(); };
@@ -4633,14 +4852,25 @@
4633
4852
  // it under the same key and sends it the moment the reply ends.
4634
4853
  if (reason === 'native_turn_in_progress') { if (!opts.held) view.hold(r, draft); return 'held'; }
4635
4854
  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);
4855
+ // The box. Send it anyway always carries the revision of THIS refusal, the newest (A3).
4856
+ var changedBox = function (copy) { r.sendAnyway = b.revision; view.status(r, ''); r.refusal = { copy: copy, changed: true, fork: true }; view.verdict(r); };
4857
+ if (opts.held) { settleHeld(r, opts.held, 'refused', 'This session changed on your Mac while your message waited.'); return 'changed'; }
4858
+ // 7.0.29 ADD 3, the session reader only (opts.seen; Today's panel keeps its own path): never for a held or
4859
+ // queued message, a turn-end answer (seen is null), or a send that already carries a revision, which the one
4860
+ // automatic resend always does. So at most one resend, and a second refusal shows the box.
4861
+ if (opts.seen && !opts.acknowledgedRevision && state.reading === r) {
4862
+ return sxChangedLook(r, opts.seen).then(function (look) {
4863
+ if (look && !look.fresh.length && !look.newWait && state.reading === r) return continueSession(r, text, Object.assign({}, opts, { acknowledgedRevision: b.revision }));
4864
+ changedBox(!look ? 'This session changed on your Mac, and COS could not load what changed. Your message is not sent yet.'
4865
+ : look.fresh.length ? 'A new reply arrived while you were writing. Your message is not sent yet.'
4866
+ : 'The session is now waiting on a question. Your message is not sent yet.');
4867
+ if (look) sxShowFresh(look.fresh);
4868
+ return 'changed';
4869
+ });
4643
4870
  }
4871
+ changedBox(opts.seen === undefined ? (b.reasonCopy || 'This session changed on your Mac since you opened it.')
4872
+ : opts.seen === null ? 'This session changed on your Mac. Your answer is not sent yet.'
4873
+ : 'This session changed on your Mac again. Your message is not sent yet.');
4644
4874
  return 'changed';
4645
4875
  }
4646
4876
  if (/^(stale_epoch|binding_expired|binding_not_active|binding_unusable|unknown_binding)$/.test(reason) && !opts.reattached) {
@@ -4861,7 +5091,7 @@
4861
5091
  showPendingTurn(draft.text, 'sends after this reply', draft.key);
4862
5092
  saveHeld(r);
4863
5093
  }
4864
- clearBoxOf(draft.text);
5094
+ clearBoxOf(draft.text, r);
4865
5095
  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
5096
  startHeldTimer(r);
4867
5097
  }
@@ -4894,7 +5124,7 @@
4894
5124
  } else {
4895
5125
  if (bubble) bubble.remove();
4896
5126
  // 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));
5127
+ returnToBox(h.text, how === 'cancelled' ? 'Not sent.' : copy || 'Not sent.', heldFilesBack(r, h), r);
4898
5128
  }
4899
5129
  if (!r.held.some(function (x) { return !x.cancelled; })) clearInterval(r.heldTimer);
4900
5130
  }
@@ -4966,7 +5196,7 @@
4966
5196
  sessionStatus('Copying the session. This can take up to a minute.');
4967
5197
  var settle = function (b) {
4968
5198
  if (b.forked === true) {
4969
- r.forkDraft = null; clearBoxOf(text); readerFilesSent(readerFilesOn() ? readerBox(r) : null, ids);
5199
+ r.forkDraft = null; clearBoxOf(text, r); readerFilesSent(readerFilesOn() ? readerBox(r) : null, ids);
4970
5200
  sessionStatus('Forked. The copy has your message and shows in Sessions in a moment.');
4971
5201
  setTimeout(function () { if (state.pane === 'sessions') loadSessions(true); }, 4000);
4972
5202
  return true;
@@ -7136,10 +7366,11 @@
7136
7366
 
7137
7367
  // ── events (one delegated listener) ─────────────────────
7138
7368
  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');
7369
+ 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,#rail-back,#section-btn,#sign-out,#copy-link,#ask-close,#auth-switch,#auth-token-show,#auth-retry,#device-code-btn');
7140
7370
  if (!t) { if (e.target.id === 'ask-sheet') closeAsk(); if (e.target.id === 'section-menu') closeMenu(); return; }
7141
7371
  var inSheet = !!t.closest('#ask-sheet');
7142
7372
  if (t.id === 'topbar-back') return goBack();
7373
+ if (t.id === 'rail-back') return goBack();
7143
7374
  if (t.id === 'section-btn') return openMenu();
7144
7375
  if (t.id === 'auth-retry') { t.disabled = true; t.textContent = 'Checking'; bootStatus().then(function () { t.disabled = false; t.textContent = 'Try again'; }); return; }
7145
7376
  if (t.id === 'auth-switch') {
@@ -7284,6 +7515,7 @@
7284
7515
  });
7285
7516
  document.addEventListener('input', function (e) {
7286
7517
  if (e.target.tagName === 'TEXTAREA') autoGrow(e.target);
7518
+ if (e.target.id === 'session-input' && typeof unsentSave === 'function') unsentSave();
7287
7519
  if (e.target.id === 'ask-q') renderAsk();
7288
7520
  if (e.target.hasAttribute && e.target.hasAttribute('data-next-prep-steer')) nextPrepInput(e.target);
7289
7521
  if (e.target.id === 'sessions-q' && !e.target.value.trim()) renderSessionGroups(state.sessions);
@@ -7507,6 +7739,47 @@
7507
7739
  } catch (_e) { return false; }
7508
7740
  }
7509
7741
 
7742
+ // Page errors (lead 08:31, 11/11): Control Web reported none, so "no errors in his rows" proved nothing. A script error
7743
+ // or an unhandled rejection becomes one bounded row, event web.error: what kind it was, the error's class (TypeError),
7744
+ // its code and HTTP status when it has them, the script file's own name, line and column, and the pane. Never the
7745
+ // message, a stack or any text: those can carry what a person typed. At most WEB_ERR_MAX rows a page load, one per
7746
+ // kind, class, code and place.
7747
+ var WEB_ERR_MAX = 10, WEB_ERR = { n: 0, seen: {} };
7748
+ function webErrorRow(kind, err, file, line, col) {
7749
+ try {
7750
+ var name = err && typeof err.name === 'string' ? err.name : '';
7751
+ 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);
7752
+ var code = err && (typeof err.code === 'string' || typeof err.code === 'number') && /^[A-Za-z0-9_.-]{1,40}$/.test(String(err.code)) ? String(err.code) : '';
7753
+ var status = err && typeof err.status === 'number' && isFinite(err.status) ? err.status : null;
7754
+ var src = String(file || '').split(/[?#]/)[0].split('/').pop();
7755
+ src = /^[A-Za-z0-9_.-]{1,40}$/.test(src) ? src : '';
7756
+ var ln = Number(line) > 0 ? Math.floor(Number(line)) : 0, cn = Number(col) > 0 ? Math.floor(Number(col)) : 0;
7757
+ var key = [kind, cls, code, status, src, ln].join('|');
7758
+ if (WEB_ERR.seen[key] || WEB_ERR.n >= WEB_ERR_MAX) return false;
7759
+ WEB_ERR.seen[key] = 1; WEB_ERR.n++;
7760
+ var pane = typeof state === 'object' && state && typeof state.pane === 'string' && /^[a-z-]{1,24}$/.test(state.pane) ? state.pane : '';
7761
+ api('/api/diag/client', { method: 'POST', body: { ts: Date.now(), level: 'error', event: 'web.error',
7762
+ 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 () {});
7763
+ return true;
7764
+ } catch (_e) { return false; }
7765
+ }
7766
+ if (window.addEventListener) {
7767
+ // Only script errors: a failed image or file load fires 'error' on its element, which does not reach the window.
7768
+ window.addEventListener('error', function (e) { if (e && (e.error || e.filename)) webErrorRow('error', e.error || null, e.filename, e.lineno, e.colno); });
7769
+ window.addEventListener('unhandledrejection', function (e) { webErrorRow('rejection', e ? e.reason : null, '', 0, 0); });
7770
+ }
7771
+
7772
+ // 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
7773
+ // numbers-only row says so: how ('sheets', 'reload' or 'gave-up'), how many sheets and scripts had failed, how many
7774
+ // checks it took and how long. A reload's report comes back from sessionStorage once.
7775
+ (function restyleRow() {
7776
+ var R = window.CosRestyle;
7777
+ if (!R) return;
7778
+ 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 }); };
7779
+ row(R.takeSaved && R.takeSaved());
7780
+ window.addEventListener('cos-restyle', function (e) { row(e && e.detail); });
7781
+ })();
7782
+
7510
7783
  // ── the shared kit for pane modules (panes/*.js) ───────
7511
7784
  window.COSWeb = {
7512
7785
  $: $, esc: esc, md: md, plain: plain, structureLensAnswer: structureLensAnswer, tableFit: tableFit, api: api, toast: toast, ago: ago, clock: clock, toDate: toDate,
@@ -7679,7 +7952,7 @@
7679
7952
  var sc = window.screen || {}, w = lrWide();
7680
7953
  layoutCrumb('device', { sw: sc.width, sh: sc.height, sah: sc.availHeight, oh: window.outerHeight, ow: window.outerWidth, dpr: window.devicePixelRatio,
7681
7954
  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 });
7955
+ 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
7956
  }
7684
7957
  // The focus line is for touch screens only (QA W3): on a Mac it would only spend the budget.
7685
7958
  function lrFocus() {
@@ -7774,6 +8047,7 @@
7774
8047
 
7775
8048
  // Every pane module is a plain script after this one; boot waits for all of them.
7776
8049
  function boot() {
8050
+ if (typeof railSync === 'function') railSync(); // 7.0.29: before the shell shows, so the rail never moves the page
7777
8051
  // The open-time report's clock (ms since navigation start, -1 where there is none) and the two numbers boot gives it:
7778
8052
  // when boot started (every script had run) and the sign-in check's round trip. Kept inside boot, and FIRST_PAINT
7779
8053
  // looked up, never assumed, because the harnesses that run boot alone have neither.
@@ -7822,20 +8096,27 @@
7822
8096
  return api('/api/web-session/status').then(function (st) { return bootAnswer(st); }, function (err) { return bootFail(err); });
7823
8097
  }
7824
8098
  function bootAnswer(st) {
8099
+ AUTH_RETRY.n = 0; // the Mac answered: a later not-answering card starts its quick checks again
7825
8100
  if (st && st.authenticated) return showShell(st);
7826
8101
  // 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
8102
  showAuth(typeof signinKnown === 'function' && signinKnown() ? signinLapsedCopy() : undefined);
7828
8103
  }
7829
8104
  function bootFail(err) {
7830
8105
  // 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 };
8106
+ // showAuth turns the card off first (authOffline(false)); that must not restart the 1, 2, 4, 8 s steps, so only a Mac
8107
+ // that answered (bootAnswer, or an error with a status here) resets them.
8108
+ if (err && err.status) { AUTH_RETRY.n = 0; showAuth(err.message); } else { showAuth(); authOffline(true); }
8109
+ }
8110
+ // Q1 M2: the not-answering card checks again while the page is in front, and stops while it is hidden. 7.0.28 (Miles
8111
+ // 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
8112
+ // usually reaches the Mac within seconds; then every 30 s. The steps start over once the Mac has answered.
8113
+ var AUTH_RETRY = { timer: null, ms: 30000, steps: [1000, 2000, 4000, 8000], n: 0 };
7835
8114
  function authRetrySchedule(on) {
7836
8115
  if (AUTH_RETRY.timer) { clearTimeout(AUTH_RETRY.timer); AUTH_RETRY.timer = null; }
7837
8116
  if (!on || document.hidden) return;
7838
- AUTH_RETRY.timer = setTimeout(function () { AUTH_RETRY.timer = null; if (!document.hidden) bootStatus(); }, AUTH_RETRY.ms);
8117
+ var ms = AUTH_RETRY.n < AUTH_RETRY.steps.length ? AUTH_RETRY.steps[AUTH_RETRY.n] : AUTH_RETRY.ms;
8118
+ AUTH_RETRY.n++;
8119
+ AUTH_RETRY.timer = setTimeout(function () { AUTH_RETRY.timer = null; if (!document.hidden) bootStatus(); }, ms);
7839
8120
  }
7840
8121
  document.addEventListener('visibilitychange', function () {
7841
8122
  var box = $('auth-offline');
@@ -7853,8 +8134,8 @@
7853
8134
  if (title) title.textContent = on ? (offline ? 'This device is offline' : 'COS on your Mac is not answering') : 'Connect this browser';
7854
8135
  if (intro) intro.classList.toggle('hidden', !!on);
7855
8136
  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.';
8137
+ if (text && on) text.textContent = offline ? 'Connect to the internet and Tailscale. This page checks again in a few seconds, then every 30 seconds.'
8138
+ : '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
8139
  authRetrySchedule(!!on);
7859
8140
  if (on) { var err = $('auth-error'); if (err) { setStatus(err, '', true); err.classList.add('hidden'); } }
7860
8141
  }