@gotcos/glasses-server 7.0.18 → 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.
- package/CHANGELOG.md +198 -0
- package/docs/web-key-verifier-contract.md +1 -1
- package/package.json +1 -1
- package/server/index.ts +32 -6
- package/server/lib/agent-session-older.ts +218 -0
- package/server/lib/agent-session-store.ts +99 -17
- package/server/lib/cos-started-sessions.ts +101 -0
- package/server/lib/list-model-memo.ts +85 -0
- package/server/lib/model-efforts.ts +40 -0
- package/server/lib/session-organizer.ts +305 -0
- package/server/lib/session-stream-events.ts +14 -2
- package/server/lib/tailnet-self-name.ts +57 -10
- package/server/lib/thread-turn-queue.ts +24 -0
- package/server/lib/web-key-signer-app.ts +25 -6
- package/server/lib/web-key-signer-tls.ts +790 -0
- package/server/routes/agent-sessions.ts +106 -2
- package/server/routes/health.ts +3 -0
- package/server/routes/session-organizer.ts +149 -0
- package/server/routes/thread-turn-queue.ts +34 -5
- package/server/routes/web-key.ts +31 -2
- package/server/web-control/.manifest.json +7 -7
- package/server/web-control/app.css +63 -0
- package/server/web-control/app.js +1047 -90
- package/server/web-control/index.html +83 -20
- package/server/web-control/panes/sessions.css +280 -0
- package/server/web-control/panes/today-actions.js +46 -0
- package/server/web-control/panes/work.js +7 -3
- package/server/web-control/theme.js +200 -0
|
@@ -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
|
-
|
|
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
|
|
@@ -555,6 +653,7 @@
|
|
|
555
653
|
if (r.pane !== 'meetings' && state.livePoll) { clearInterval(state.livePoll); state.livePoll = null; }
|
|
556
654
|
|
|
557
655
|
var reading = r.args.length > 0 && !isTabRoute(r.args);
|
|
656
|
+
if (typeof sxFrameSync === 'function') sxFrameSync();
|
|
558
657
|
var paneEl = $('pane-' + r.pane);
|
|
559
658
|
if (paneEl) paneEl.classList.toggle('reading', reading);
|
|
560
659
|
var split = paneEl && paneEl.querySelector('.split');
|
|
@@ -562,6 +661,8 @@
|
|
|
562
661
|
|
|
563
662
|
// Top bar (phone): the section switcher at a list, a back button on a reader.
|
|
564
663
|
$('topbar-back').classList.toggle('hidden', !reading);
|
|
664
|
+
var railBack = $('rail-back');
|
|
665
|
+
if (railBack) railBack.hidden = !reading;
|
|
565
666
|
$('section-btn').classList.toggle('hidden', reading);
|
|
566
667
|
$('topbar-back-label').textContent = TITLES[r.pane] || 'Back';
|
|
567
668
|
$('section-name').textContent = TITLES[r.pane] || 'Today';
|
|
@@ -623,11 +724,32 @@
|
|
|
623
724
|
else if (y > lastY + 4 && isPhone()) bar.classList.add('tucked');
|
|
624
725
|
lastY = y;
|
|
625
726
|
}
|
|
626
|
-
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(); }
|
|
627
728
|
function closeMenu() { $('section-menu').classList.add('hidden'); }
|
|
628
729
|
|
|
629
730
|
// ── auth ────────────────────────────────────────────────
|
|
731
|
+
// 7.0.19: the first paint (index.html #first-paint) ends the moment the sign-in card or the app shows, whichever
|
|
732
|
+
// comes first, once. One layout report says how long it took, in the phone's own numbers: ms from navigation start to
|
|
733
|
+
// the browser's first contentful paint (-1 when none was recorded before this point) and to this moment.
|
|
734
|
+
var FIRST_PAINT = { done: false };
|
|
735
|
+
function firstPaintDone(via) {
|
|
736
|
+
if (FIRST_PAINT.done) return false;
|
|
737
|
+
FIRST_PAINT.done = true;
|
|
738
|
+
document.documentElement.classList.remove('fp-wait');
|
|
739
|
+
try {
|
|
740
|
+
var perf = window.performance, fcp = perf && perf.getEntriesByName ? perf.getEntriesByName('first-contentful-paint')[0] : null;
|
|
741
|
+
var shown = perf && perf.now ? Math.round(perf.now()) : -1;
|
|
742
|
+
// dcl: the browser's DOMContentLoaded; boot: when every script had run and boot started; rtt: the sign-in check's
|
|
743
|
+
// round trip (-1 for a sign-in link, which checks with a login instead).
|
|
744
|
+
var nav = perf && perf.getEntriesByType ? perf.getEntriesByType('navigation')[0] : null;
|
|
745
|
+
var dcl = nav && typeof nav.domContentLoadedEventStart === 'number' ? Math.round(nav.domContentLoadedEventStart) : -1;
|
|
746
|
+
if (typeof layoutCrumb === 'function') layoutCrumb('open-time', { fcp: fcp ? Math.round(fcp.startTime) : -1, shown: shown, via: via,
|
|
747
|
+
dcl: dcl, boot: FIRST_PAINT.boot == null ? -1 : FIRST_PAINT.boot, rtt: FIRST_PAINT.rtt == null ? -1 : FIRST_PAINT.rtt });
|
|
748
|
+
} catch (_e) { /* timing is a report only */ }
|
|
749
|
+
return true;
|
|
750
|
+
}
|
|
630
751
|
function showAuth(message) {
|
|
752
|
+
if (typeof firstPaintDone === 'function') firstPaintDone('auth'); // harnesses that run this alone have none
|
|
631
753
|
readerImagesStop($('session-turns')); readerImagesStop($('ta-last'));
|
|
632
754
|
if (typeof liveHide === 'function') { liveHide(); liveStopWatch(); LIVE.mode = 'idle'; }
|
|
633
755
|
var alreadyOpen = !$('auth-gate').classList.contains('hidden');
|
|
@@ -635,6 +757,10 @@
|
|
|
635
757
|
if (typeof authOffline === 'function') authOffline(false);
|
|
636
758
|
$('app-shell').classList.add('hidden');
|
|
637
759
|
$('auth-gate').classList.remove('hidden');
|
|
760
|
+
// 7.0.23 regression (lapse_check, 08:2x): a frame class left on keeps html and body at one screen with overflow
|
|
761
|
+
// hidden, so the gate could not scroll and Connect sat below a keyboard-height screen. The gate is a normal page;
|
|
762
|
+
// showShell's route() puts the frame back (sxFrameSync) when the shell returns.
|
|
763
|
+
document.documentElement.classList.remove('sx-frame', 'mx-frame');
|
|
638
764
|
// A later 401 from a pane still polling (W.api passes no message) must not blank what the gate already says:
|
|
639
765
|
// the reason it opened, or "That code did not match" while the person retypes.
|
|
640
766
|
if (!message && alreadyOpen) return;
|
|
@@ -643,6 +769,7 @@
|
|
|
643
769
|
}
|
|
644
770
|
function signedInOnce() { var shell = typeof $ === 'function' ? $('app-shell') : null; return !!(shell && !shell.classList.contains('hidden')) || CONN.lapsed; }
|
|
645
771
|
function showShell(info) {
|
|
772
|
+
if (typeof firstPaintDone === 'function') firstPaintDone('shell'); // harnesses that run this alone have none
|
|
646
773
|
if (typeof signinRemember === 'function') signinRemember(info);
|
|
647
774
|
if (typeof authOffline === 'function') authOffline(false);
|
|
648
775
|
$('auth-gate').classList.add('hidden');
|
|
@@ -681,7 +808,9 @@
|
|
|
681
808
|
function sessionList(maxAgeMs) {
|
|
682
809
|
if (SL.data && Date.now() - SL.at < (maxAgeMs || 0)) return Promise.resolve(SL.data);
|
|
683
810
|
if (SL.inflight) return SL.inflight;
|
|
684
|
-
|
|
811
|
+
// kept=1 (server 7.0.25, lead 10:29): only a client that asks gets the kept rows (COS pins, folders, COS-started
|
|
812
|
+
// sessions past the cap); the Mac app and the lens, which do not ask, get 7.0.18's list unchanged.
|
|
813
|
+
SL.inflight = api('/api/agent-sessions?limit=80&sort=updated&kept=1').then(function (d) {
|
|
685
814
|
// 7.0.5: a sessions read (Sessions pane or Today) that follows a failed one is a reconnect: Today's next summary
|
|
686
815
|
// ask says so (requestBrief), once.
|
|
687
816
|
if (SL.failed) { SL.failed = false; if (typeof BRIEF === 'object' && BRIEF) BRIEF.reconnect = true; }
|
|
@@ -1732,14 +1861,17 @@
|
|
|
1732
1861
|
if (!list) return;
|
|
1733
1862
|
if (sxQueryActive()) { paintSessionsHero(); return; }
|
|
1734
1863
|
if (structural) { renderSessionGroups(); return; }
|
|
1735
|
-
var amb = sxAmbiguous(sxVisible());
|
|
1864
|
+
var amb = sxAmbiguous(sxVisible()), moved = false;
|
|
1736
1865
|
(rows || []).forEach(function (s) {
|
|
1737
1866
|
if (!s || sxKeepWarm(s)) return;
|
|
1867
|
+
// Checked before the row is looked for: a row in a folded group is not drawn, and its move still counts.
|
|
1868
|
+
var live = sxLiveRow(s, amb);
|
|
1869
|
+
if (live.moved) moved = true;
|
|
1738
1870
|
var k = splitSessionId(s.provider, s.session_id || s.id), want = k.provider + '|' + k.id;
|
|
1739
1871
|
var els = list.querySelectorAll('[data-session]');
|
|
1740
1872
|
for (var i = 0; i < els.length; i++) {
|
|
1741
1873
|
if (els[i].getAttribute('data-session') !== want) continue;
|
|
1742
|
-
var html =
|
|
1874
|
+
var html = live.html;
|
|
1743
1875
|
if (els[i].__liveHtml !== html) {
|
|
1744
1876
|
var tmp = document.createElement('div'); tmp.innerHTML = html;
|
|
1745
1877
|
var next = tmp.firstElementChild || tmp.firstChild;
|
|
@@ -1748,6 +1880,7 @@
|
|
|
1748
1880
|
break;
|
|
1749
1881
|
}
|
|
1750
1882
|
});
|
|
1883
|
+
if (moved) sxRegroupLater();
|
|
1751
1884
|
paintSessionsHero();
|
|
1752
1885
|
}
|
|
1753
1886
|
// ── Today's header line and Refresh ──
|
|
@@ -2035,7 +2168,11 @@
|
|
|
2035
2168
|
var qs1 = sessionQs(), open = (!!html && !te) || !!(qs1 && ((qs1.itemsFor && qs1.itemsFor(r.id).length) || (qs1.atMacFor && qs1.atMacFor(r.id).length)));
|
|
2036
2169
|
var box = $('session-input');
|
|
2037
2170
|
// 7.0.18: here a reply IS the answer (sent with the contract's attribution), so the box says so.
|
|
2038
|
-
if (box && box.setAttribute)
|
|
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
|
+
}
|
|
2039
2176
|
}
|
|
2040
2177
|
// ── end at the Mac's prompt
|
|
2041
2178
|
|
|
@@ -2476,7 +2613,8 @@
|
|
|
2476
2613
|
}
|
|
2477
2614
|
var SX = {
|
|
2478
2615
|
rows: null, dropped: null, enabled: true, loading: false, error: '', loadedAt: 0,
|
|
2479
|
-
|
|
2616
|
+
// 7.0.23 step 2b: Pinned is a group now, not a clock; a saved 'pinned' reads as Updated.
|
|
2617
|
+
clock: sxPref('clock', 'updated', ['updated', 'opened']),
|
|
2480
2618
|
view: sxPref('view', 'sessions', ['sessions', 'usage', 'cost']),
|
|
2481
2619
|
recency: sxPref('recency', 'newest', ['newest', 'oldest', 'match']),
|
|
2482
2620
|
q: '', instant: [], remote: [], searching: false, searchError: '', ranFor: '', semantic: null, semanticReason: '', searchSeq: 0, searchTimer: null,
|
|
@@ -2506,7 +2644,9 @@
|
|
|
2506
2644
|
|
|
2507
2645
|
// ClaudeSession.title: the job label, else the name, else the workspace, else the id.
|
|
2508
2646
|
function sxTitle(s) {
|
|
2509
|
-
|
|
2647
|
+
// 7.0.23 step 2b-2: a title renamed in COS (S1's title_override) is the title everywhere; the agent's own stays
|
|
2648
|
+
// in display_label, shown under it in the list and still searchable.
|
|
2649
|
+
var t = plain(s.title_override || s.display_label || s.custom_title || '').replace(/\s+/g, ' ').trim();
|
|
2510
2650
|
return t || String(s.project || '').trim() || String(s.session_id || s.id || 'Session');
|
|
2511
2651
|
}
|
|
2512
2652
|
function sxWarmWords(t) { t = String(t || '').trim().toLowerCase(); return t === 'ready' || t.indexOf('this is an automated local readiness check') === 0; }
|
|
@@ -2619,7 +2759,8 @@
|
|
|
2619
2759
|
'<div class="cx-hero-text"><h1 class="cx-title">Sessions</h1><p class="cx-detail-line">' + esc(detail) + liveHeroNote(view) + '</p></div>' +
|
|
2620
2760
|
'<div class="cx-actions"><button type="button" class="cx-btn quiet" data-sx="refresh"' + (busy ? ' disabled' : '') + '><svg class="icon"><use href="#i-refresh"/></svg>Refresh</button></div>' +
|
|
2621
2761
|
(stats.length ? '<div class="cx-stats">' + stats.map(function (s) { return '<span class="cx-stat"><b>' + esc(typeof s[0] === 'number' ? s[0].toLocaleString() : s[0]) + '</b><span>' + s[1] + '</span></span>'; }).join('') + '</div>' : '');
|
|
2622
|
-
|
|
2762
|
+
// 7.0.23 step 2b: Updated and Opened are the sort menu now, and Pinned a group; no clock switches here.
|
|
2763
|
+
var clockOn = false;
|
|
2623
2764
|
views.innerHTML = '<div class="cx-switch" role="group" aria-label="Sessions view">' +
|
|
2624
2765
|
Object.keys(SX_VIEWS).map(function (v) { return '<button type="button" data-sx="view" data-view="' + v + '" aria-pressed="' + (v === view) + '">' + SX_VIEWS[v] + '</button>'; }).join('') +
|
|
2625
2766
|
'</div>' +
|
|
@@ -2629,6 +2770,117 @@
|
|
|
2629
2770
|
var split = $('sessions-split'), usage = $('sessions-usage');
|
|
2630
2771
|
if (split) split.classList.toggle('hidden', view !== 'sessions');
|
|
2631
2772
|
if (usage) usage.classList.toggle('hidden', view === 'sessions');
|
|
2773
|
+
// 7.0.23 step 2b: in Sessions the list's own head (the Sessions menu, Search, the quiet line, the sort menu) takes
|
|
2774
|
+
// the place of this header and the switches; Usage and COS cost keep them, so there is a way back.
|
|
2775
|
+
hero.classList.toggle('hidden', view === 'sessions');
|
|
2776
|
+
// Under 600 px the list's own Sessions button steps aside for the top bar's (sessions.css), and this switch stays
|
|
2777
|
+
// the way to Usage and COS cost there; from 600 px the Sessions menu is that way.
|
|
2778
|
+
views.classList.toggle('sx-wide-hide', view === 'sessions');
|
|
2779
|
+
sxFrameSync();
|
|
2780
|
+
}
|
|
2781
|
+
// 7.0.23 (step 1 of the chat-style Sessions, DESIGN_PLAN v2 "The frame"): while Sessions shows its list and reader,
|
|
2782
|
+
// html.sx-frame makes the page one screen, never taller: the app shell is the visible box (--vv-top, --vv-h from
|
|
2783
|
+
// viewportFit, the same kit kb-chat uses), and the rail, the list and the reader's body each scroll by themselves, with
|
|
2784
|
+
// the reply area fixed at the bottom of the reader (panes/sessions.css). Usage and COS cost keep the page's own scroll.
|
|
2785
|
+
// Layout, not a proven fix for the 20:16 report: the frame reports where the rail, the list and the reply box sit
|
|
2786
|
+
// against the visible area (sxFrameCrumb).
|
|
2787
|
+
function sxFrameSync() {
|
|
2788
|
+
// Never while the sign-in gate shows (the shell is hidden): the gate must scroll like a page.
|
|
2789
|
+
var de = document.documentElement, shell = $('app-shell'), on = state.pane === 'sessions' && SX.view === 'sessions' && !(shell && shell.classList.contains('hidden'));
|
|
2790
|
+
if (de && de.classList) de.classList.toggle('sx-frame', on);
|
|
2791
|
+
if (on) sxFrameHook();
|
|
2792
|
+
return on;
|
|
2793
|
+
}
|
|
2794
|
+
// Where the frame's parts sit against the VISIBLE area (step 1's position report, lead 23:00): on a reader's open, and
|
|
2795
|
+
// KB_AFTER_MS after an on-screen keyboard closes while the frame is on. The visible area is viewportFit's box (a
|
|
2796
|
+
// visible height under 80 px is no reading, so it is innerHeight from 0, and fb says so) and, across, visualViewport's
|
|
2797
|
+
// offsetLeft and width. For the rail, the list, the reader and the reply box: their top, bottom, left and right in
|
|
2798
|
+
// the same coordinates, and whether each lies inside the visible area (railIn, listIn, replyIn: within 1 px on every
|
|
2799
|
+
// side; a part that is not shown reads -1 and false). Numbers and fixed labels only, at most SX_FRAME_MAX a page load.
|
|
2800
|
+
var SX_FRAME_MAX = 8, SX_FRAME = { n: 0, hooked: false };
|
|
2801
|
+
function sxFrameCrumb(why) {
|
|
2802
|
+
if (SX_FRAME.n >= SX_FRAME_MAX || !document.documentElement.classList.contains('sx-frame') || typeof layoutCrumb !== 'function') return false;
|
|
2803
|
+
try {
|
|
2804
|
+
var v = viewportFit(), vv = window.visualViewport, iw = window.innerWidth || 0;
|
|
2805
|
+
var left = vv && !v.bad && typeof vv.offsetLeft === 'number' ? vv.offsetLeft : 0;
|
|
2806
|
+
var width = vv && !v.bad && vv.width > 0 ? vv.width : iw;
|
|
2807
|
+
var box = { t: v.top, b: v.top + v.height, l: left, r: left + width };
|
|
2808
|
+
var out = { why: String(why || '').slice(0, 24), fb: !!v.bad, boxT: box.t, boxB: box.b, boxL: box.l, boxR: box.r };
|
|
2809
|
+
var part = function (name, el) {
|
|
2810
|
+
var r = el && el.getBoundingClientRect && el.offsetParent !== null ? el.getBoundingClientRect() : null;
|
|
2811
|
+
var shown = !!(r && r.width > 0 && r.height > 0);
|
|
2812
|
+
out[name + 'T'] = shown ? r.top : -1; out[name + 'B'] = shown ? r.bottom : -1; out[name + 'L'] = shown ? r.left : -1; out[name + 'R'] = shown ? r.right : -1;
|
|
2813
|
+
out[name + 'In'] = shown && r.top >= box.t - 1 && r.bottom <= box.b + 1 && r.left >= box.l - 1 && r.right <= box.r + 1;
|
|
2814
|
+
};
|
|
2815
|
+
part('rail', document.querySelector('.rail'));
|
|
2816
|
+
part('list', $('sessions-list-col'));
|
|
2817
|
+
part('rd', $('session-reader'));
|
|
2818
|
+
part('reply', $('session-form'));
|
|
2819
|
+
if (layoutCrumb('sx-frame', out)) { SX_FRAME.n++; return true; }
|
|
2820
|
+
} catch (_e) { /* a report never stops the page */ }
|
|
2821
|
+
return false;
|
|
2822
|
+
}
|
|
2823
|
+
// The keyboard's after-close moment (app.js lrAfter, KB_AFTER_MS), heard once, reported while the frame is on.
|
|
2824
|
+
function sxFrameHook() {
|
|
2825
|
+
if (SX_FRAME.hooked || !window.COSWeb || typeof window.COSWeb.onKeyboard !== 'function') return;
|
|
2826
|
+
SX_FRAME.hooked = true;
|
|
2827
|
+
window.COSWeb.onKeyboard(function (what) { if (what === 'after') sxFrameCrumb('kb-after'); });
|
|
2828
|
+
}
|
|
2829
|
+
// "Go to the question" (Q1 23:40): shown in the dock while the session's question cards have something and the end of
|
|
2830
|
+
// the conversation, where they are, is out of view (the person scrolled up). Read on the body's scroll, its size
|
|
2831
|
+
// changes and the cards' redraws.
|
|
2832
|
+
function sxGoQuestionPaint() {
|
|
2833
|
+
var line = $('session-goq'), body = $('session-body'), q = $('session-questions');
|
|
2834
|
+
if (!line) return false;
|
|
2835
|
+
var has = !!(q && q.children && q.children.length && String(q.textContent || '').trim());
|
|
2836
|
+
var hidden = false;
|
|
2837
|
+
if (has && body && body.getBoundingClientRect && q.getBoundingClientRect) hidden = q.getBoundingClientRect().top >= body.getBoundingClientRect().bottom - 8;
|
|
2838
|
+
var show = has && hidden && document.documentElement.classList.contains('sx-frame');
|
|
2839
|
+
// Its place: just above the dock, whatever the dock's height is now (the reader holds it in --sx-dock-h).
|
|
2840
|
+
var dock = $('session-dock'), rd = line.parentElement;
|
|
2841
|
+
if (show && dock && rd && rd.style && dock.getBoundingClientRect) rd.style.setProperty('--sx-dock-h', Math.round(dock.getBoundingClientRect().height) + 'px');
|
|
2842
|
+
line.hidden = !show;
|
|
2843
|
+
return show;
|
|
2844
|
+
}
|
|
2845
|
+
// The reader's own scroll (#session-body); the window's only where the frame is off.
|
|
2846
|
+
// What mounts after the first paint (the session's question cards, the queue, the activity, image previews) changes
|
|
2847
|
+
// the body's height and would leave it short of the end. So the body stays at the newest turn while its size or its
|
|
2848
|
+
// content changes, until the person scrolls it themself: a wheel, touch, pointer or key on the body within the last
|
|
2849
|
+
// SX_PIN_USER_MS, then a scroll that leaves the end by more than 4 px. A scroll the browser makes on its own (scroll
|
|
2850
|
+
// anchoring while turns and previews land) never counts. Two more passes (250 and 900 ms) cover a browser without
|
|
2851
|
+
// ResizeObserver.
|
|
2852
|
+
var SX_NEWEST_AGAIN_MS = [250, 900], SX_PIN_USER_MS = 1000;
|
|
2853
|
+
var SX_PIN = { body: null, ro: null, put: -1, userAt: 0 };
|
|
2854
|
+
function sxUnpin() { if (SX_PIN.ro) { try { SX_PIN.ro.disconnect(); } catch (_e) {} } SX_PIN.ro = null; SX_PIN.body = null; }
|
|
2855
|
+
function sxToNewest() {
|
|
2856
|
+
var body = $('session-body');
|
|
2857
|
+
if (!(body && document.documentElement.classList.contains('sx-frame'))) { window.scrollTo(0, document.documentElement.scrollHeight); return; }
|
|
2858
|
+
var bottom = function () { body.scrollTop = body.scrollHeight; SX_PIN.put = body.scrollTop; };
|
|
2859
|
+
bottom();
|
|
2860
|
+
if (SX_PIN.body !== body) {
|
|
2861
|
+
sxUnpin();
|
|
2862
|
+
SX_PIN.body = body;
|
|
2863
|
+
var touched = function () { SX_PIN.userAt = Date.now(); };
|
|
2864
|
+
['wheel', 'touchstart', 'pointerdown', 'keydown'].forEach(function (t) { body.addEventListener(t, touched, { passive: true }); });
|
|
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 });
|
|
2868
|
+
body.addEventListener('scroll', function () {
|
|
2869
|
+
if (SX_PIN.body !== body || Date.now() - SX_PIN.userAt > SX_PIN_USER_MS) return;
|
|
2870
|
+
if (Math.abs(body.scrollTop - SX_PIN.put) > 4 && body.scrollTop < body.scrollHeight - body.clientHeight - 4) sxUnpin();
|
|
2871
|
+
}, { passive: true });
|
|
2872
|
+
if (typeof ResizeObserver === 'function') {
|
|
2873
|
+
// The body and everything in it (the note, the turns, Copy session, its note): any of them can grow.
|
|
2874
|
+
SX_PIN.ro = new ResizeObserver(function () { if (SX_PIN.body === body && body.isConnected) bottom(); sxGoQuestionPaint(); });
|
|
2875
|
+
SX_PIN.ro.observe(body);
|
|
2876
|
+
[].forEach.call(body.children, function (c) { SX_PIN.ro.observe(c); });
|
|
2877
|
+
// The dock too: files added to the reply box change its height, and Go to the question sits just above it.
|
|
2878
|
+
if ($('session-dock')) SX_PIN.ro.observe($('session-dock'));
|
|
2879
|
+
}
|
|
2880
|
+
}
|
|
2881
|
+
SX_NEWEST_AGAIN_MS.forEach(function (ms) {
|
|
2882
|
+
setTimeout(function () { if (body.isConnected && SX_PIN.body === body) bottom(); }, ms);
|
|
2883
|
+
});
|
|
2632
2884
|
}
|
|
2633
2885
|
|
|
2634
2886
|
// 7.0.1: the hero's counts say when they are not live ("Reconnecting", "as of 2 min ago"); nothing without a stream.
|
|
@@ -2668,6 +2920,8 @@
|
|
|
2668
2920
|
state.sessions = SX.rows;
|
|
2669
2921
|
if (sxQueryActive()) SX.instant = sxInstant(SX.q);
|
|
2670
2922
|
renderSessionGroups();
|
|
2923
|
+
sxLoadOlder(force === true);
|
|
2924
|
+
if (typeof sxLoadFolders === 'function') sxLoadFolders(force === true);
|
|
2671
2925
|
}).catch(function (err) {
|
|
2672
2926
|
SX.loading = false;
|
|
2673
2927
|
SX.error = (err && err.message) || 'Sessions did not load';
|
|
@@ -2693,36 +2947,432 @@
|
|
|
2693
2947
|
}
|
|
2694
2948
|
// Control's row: glyph, title, a snippet on search hits, then badge · PINNED · state · Updated ·
|
|
2695
2949
|
// Opened (twins only, amber) · workspace; a match chip and a chevron on the right.
|
|
2696
|
-
|
|
2950
|
+
// ── the grouped list (7.0.23, step 2b-1 of the chat-style Sessions; DESIGN_PLAN v2 "The list", the 23:33 mock) ──
|
|
2951
|
+
// A row, full or compact. sxRow stays the one renderer: live updates (liveSessionRows) replace a row's root, the
|
|
2952
|
+
// element with data-session, with this; search draws its hits with it (a snippet and the Keyword or Meaning tag).
|
|
2953
|
+
// Line two is the product's own state words, then the project, in plain words with commas: Running; Waiting and its
|
|
2954
|
+
// kind; Stale; Failed and its reason; "2 queued"; Not sent; "Pinned in Claude" for an agent's own pin; "Opened
|
|
2955
|
+
// Nov 9" when two rows on screen share a title. The left edge is green running, accent waiting, red failed, and the
|
|
2956
|
+
// state is always in the words too. A compact row (days 8 to 30, the short form from /older) is the title and date.
|
|
2957
|
+
function sxRowWords(s, st, key) {
|
|
2958
|
+
var w = [];
|
|
2959
|
+
if (st === 'running') w.push('Running');
|
|
2960
|
+
else if (st === 'waiting') { var k = sxWaitingKind(s.waiting_kind, s.waiting_detail); w.push(k ? 'Waiting, ' + k.charAt(0).toLowerCase() + k.slice(1) : 'Waiting'); }
|
|
2961
|
+
else if (st === 'stale') w.push('Stale');
|
|
2962
|
+
else if (st === 'error') w.push(s.failure ? 'Failed, ' + String(s.failure).replace(/_/g, ' ') : 'Failed');
|
|
2963
|
+
var queued = Number(s.queued_turns) || 0;
|
|
2964
|
+
if (queued > 0) w.push(queued + ' queued');
|
|
2965
|
+
var ns = notSentFor(key.provider, key.id);
|
|
2966
|
+
if (ns) w.push(ns.label || 'Not sent');
|
|
2967
|
+
if (s.pinned) w.push('Pinned in ' + sxProviderLabel(key.provider));
|
|
2968
|
+
return w;
|
|
2969
|
+
}
|
|
2970
|
+
function sxRow(s, hit, ambiguous, compact) {
|
|
2697
2971
|
var key = splitSessionId(s.provider, s.session_id || s.id);
|
|
2698
2972
|
var r = parseRoute();
|
|
2699
2973
|
var selected = r.pane === 'sessions' && r.args[0] === key.provider && r.args[1] === key.id;
|
|
2700
|
-
var
|
|
2701
|
-
var
|
|
2702
|
-
var
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
2974
|
+
var short = s.form === 'short', st = short ? '' : sxState(s), title = sxTitle(s);
|
|
2975
|
+
var when = toDate(SX.clock === 'opened' ? (s.created || s.modified) : s.modified);
|
|
2976
|
+
var edge = st === 'running' ? ' running' : st === 'waiting' ? ' waiting' : st === 'error' ? ' failed' : '';
|
|
2977
|
+
var id = esc(key.provider) + '|' + esc(key.id), rowKey = key.provider + '|' + key.id;
|
|
2978
|
+
var org = typeof SXF === 'object' && SXF.ok === true;
|
|
2979
|
+
if (org && SXF.renaming && SXF.renaming.toLowerCase() === rowKey.toLowerCase()) {
|
|
2980
|
+
// data-edit, not data-session, while the form is open: a form's Save (or Enter, which the browser turns into a
|
|
2981
|
+
// click on Save) must not reach the page's row handler and open the session.
|
|
2982
|
+
return '<div class="sx-r editing" data-edit="' + id + '">' + sxInlineHtml('rename', rowKey, 'Rename this session', s.title_override || title,
|
|
2983
|
+
s.title_override ? 'The agent\u2019s own title stays underneath and in search.' : '') + '</div>';
|
|
2984
|
+
}
|
|
2985
|
+
var agent = s.title_override && s.display_label && String(s.display_label).trim() !== title ? plain(String(s.display_label)).replace(/\s+/g, ' ').trim() : '';
|
|
2986
|
+
var head = '<span class="sx-r-title">' + esc(title) + '</span>' + (when ? '<span class="sx-r-time">' + esc(sxShortDate(when)) + '</span>' : '') +
|
|
2987
|
+
(agent ? '<span class="sx-r-orig">' + esc(agent) + '</span>' : '');
|
|
2988
|
+
var more = org && !short && !compact ? '<button type="button" class="sx-r-more" data-sx="row-menu" data-key="' + id + '" aria-haspopup="menu" aria-expanded="false" aria-label="' + esc('More for ' + title) + '"><span aria-hidden="true">\u2026</span></button>' : '';
|
|
2989
|
+
if (compact || short) {
|
|
2990
|
+
return '<div class="sx-r compact' + edge + '" data-session="' + id + '"' + (selected ? ' aria-selected="true"' : '') + '>' +
|
|
2991
|
+
'<button type="button" class="sx-r-open" data-session="' + id + '"' + (selected ? ' aria-selected="true"' : '') + '>' + head + '</button></div>';
|
|
2992
|
+
}
|
|
2993
|
+
var words = sxRowWords(s, st, key);
|
|
2994
|
+
var opened = toDate(s.created);
|
|
2995
|
+
if (ambiguous && ambiguous[title] && opened) words.push('Opened ' + sxShortDate(opened));
|
|
2996
|
+
var proj = s.project && String(s.project) !== title ? String(s.project) : '';
|
|
2997
|
+
var line = words.concat(proj ? [proj] : []).join(', ');
|
|
2998
|
+
var failedWord = st === 'error' ? words[0] : '';
|
|
2999
|
+
var lineHtml = failedWord ? '<b class="failed">' + esc(failedWord) + '</b>' + esc(line.slice(failedWord.length)) : esc(line);
|
|
3000
|
+
var match = hit ? (hit.match === 'both' ? 'Keyword and meaning' : hit.match === 'semantic' ? 'Meaning' : 'Keyword') : '';
|
|
2710
3001
|
// An instant title hit's snippet is the title itself; it says nothing twice.
|
|
2711
3002
|
var snippet = hit && hit.snippet ? plain(hit.snippet).replace(/\s+/g, ' ').trim() : '';
|
|
2712
|
-
return '<
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
'
|
|
2717
|
-
|
|
3003
|
+
return '<div class="sx-r full' + edge + '" data-session="' + id + '"' + (selected ? ' aria-selected="true"' : '') + '><span class="sx-r-edge" aria-hidden="true"></span>' +
|
|
3004
|
+
'<button type="button" class="sx-r-open" data-session="' + id + '"' + (selected ? ' aria-selected="true"' : '') + '>' + head +
|
|
3005
|
+
'<span class="sx-r-sub">' + sxGlyph(key.provider) + (line ? '<span class="t">' + lineHtml + '</span>' : '') + (match ? '<span class="sx-tag">' + match + '</span>' : '') + '</span>' +
|
|
3006
|
+
(snippet && snippet !== title ? '<span class="sx-r-snip">' + esc(snippet) + '</span>' : '') +
|
|
3007
|
+
'</button>' + more + '</div>';
|
|
3008
|
+
}
|
|
3009
|
+
|
|
3010
|
+
// Groups, top to bottom; a session shows once. Needs you is what Today's needsModel counts for a session: waiting, or
|
|
3011
|
+
// a question this page holds for it; plus failed (DESIGN_PLAN v2). Working now is running. Pinned is an agent's own
|
|
3012
|
+
// pin or a COS pin. Then by day of the sort's time: Today, Yesterday, Earlier this week as full rows; Last week and
|
|
3013
|
+
// Earlier this month as compact rows, folded shut with a count. Days are counted back from today (2 to 6 days ago is
|
|
3014
|
+
// Earlier this week, 7 to 13 Last week, 14 to 30 Earlier this month), so no group is empty only because of the
|
|
3015
|
+
// calendar.
|
|
3016
|
+
var SX_GROUPS = [['needs', 'Needs you', 'state'], ['working', 'Working now', 'state'], ['pinned', 'Pinned', 'pinned'], ['today', 'Today', ''],
|
|
3017
|
+
['yesterday', 'Yesterday', ''], ['week', 'Earlier this week', ''], ['lastweek', 'Last week', 'compact'], ['month', 'Earlier this month', 'compact']];
|
|
3018
|
+
var SX_DAY_MS = 86400000, SX_REST_MS = 2000, SX_EMPTY_HOLD_MS = 5000;
|
|
3019
|
+
// place: each row's group as last drawn at rest; seen: when Needs you and Working now last had rows; open: unfolded
|
|
3020
|
+
// compact groups; older: /older's short rows (olderOk false: an older server, 404).
|
|
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
|
+
}
|
|
3035
|
+
function sxRowKey(s) { var k = splitSessionId(s.provider, s.session_id || s.id); return k.provider + '|' + String(k.id).toLowerCase(); }
|
|
3036
|
+
function sxDayStart(t) { var d = new Date(t); d.setHours(0, 0, 0, 0); return d.getTime(); }
|
|
3037
|
+
function sxSortTime(s) { return SX.clock === 'opened' ? (sxTime(s.created) || sxTime(s.modified)) : sxTime(s.modified); }
|
|
3038
|
+
function sxHeldKeys() {
|
|
3039
|
+
var qs = typeof sessionQs === 'function' ? sessionQs() : null, out = {};
|
|
3040
|
+
(qs && qs.itemsFor ? qs.itemsFor('') : []).forEach(function (it) { var k = splitSessionId(it.provider, it.sessionId); if (k.id) out[k.provider + '|' + String(k.id).toLowerCase()] = 1; });
|
|
3041
|
+
return out;
|
|
3042
|
+
}
|
|
3043
|
+
function sxGroupFor(s, now, held) {
|
|
3044
|
+
if (s.form !== 'short') {
|
|
3045
|
+
var st = sxState(s);
|
|
3046
|
+
if (st === 'waiting' || st === 'error' || held[sxRowKey(s)]) return 'needs';
|
|
3047
|
+
if (st === 'running') return 'working';
|
|
3048
|
+
}
|
|
3049
|
+
if (s.pinned || s.cos_pinned) return 'pinned';
|
|
3050
|
+
if (s.folder_id && typeof SXF === 'object' && SXF.byId[s.folder_id]) return 'f:' + s.folder_id;
|
|
3051
|
+
var t = sxSortTime(s), today = sxDayStart(now);
|
|
3052
|
+
if (t >= today) return 'today';
|
|
3053
|
+
if (t >= today - SX_DAY_MS) return 'yesterday';
|
|
3054
|
+
if (t >= today - 6 * SX_DAY_MS) return 'week';
|
|
3055
|
+
if (t >= today - 13 * SX_DAY_MS) return 'lastweek';
|
|
3056
|
+
return 'month';
|
|
3057
|
+
}
|
|
3058
|
+
// The main list's rows, then /older's short rows it does not have (the main row wins on a repeat).
|
|
3059
|
+
function sxAllRows() {
|
|
3060
|
+
var main = sxReal(), have = {};
|
|
3061
|
+
main.forEach(function (s) { have[sxRowKey(s)] = 1; });
|
|
3062
|
+
return main.concat((SXL.older || []).filter(function (s) { return !sxKeepWarm(s) && !have[sxRowKey(s)]; }));
|
|
3063
|
+
}
|
|
3064
|
+
// At rest: nothing pressed, no touch or scroll in the last SX_REST_MS, and no row holding keyboard focus.
|
|
3065
|
+
function sxListAtRest() {
|
|
3066
|
+
if (SXL.down) return false;
|
|
3067
|
+
var now = Date.now();
|
|
3068
|
+
if (now - SXL.touchAt < SX_REST_MS || now - SXL.scrollAt < SX_REST_MS) return false;
|
|
3069
|
+
var a = document.activeElement;
|
|
3070
|
+
return !(a && a.closest && a.closest('#sessions-list [data-session]'));
|
|
3071
|
+
}
|
|
3072
|
+
function sxRegroupLater(ms) {
|
|
3073
|
+
if (SXL.timer) return;
|
|
3074
|
+
SXL.timer = setTimeout(function () {
|
|
3075
|
+
SXL.timer = 0;
|
|
3076
|
+
if (state.pane !== 'sessions' || SX.view !== 'sessions' || sxQueryActive()) return;
|
|
3077
|
+
if (sxListAtRest()) renderSessionGroups(); else sxRegroupLater();
|
|
3078
|
+
}, ms || SX_REST_MS + 50);
|
|
3079
|
+
}
|
|
3080
|
+
// A group's row form: compact in Last week and Earlier this month, full under Needs you and Working now, and elsewhere
|
|
3081
|
+
// short when the row is one of /older's short rows (the same rule sxListHtml draws with).
|
|
3082
|
+
function sxGroupCompact(g, s) {
|
|
3083
|
+
for (var i = 0; i < SX_GROUPS.length; i++) if (SX_GROUPS[i][0] === g) return SX_GROUPS[i][2] === 'compact' ? true : SX_GROUPS[i][2] === 'state' ? false : s.form === 'short';
|
|
3084
|
+
return s.form === 'short';
|
|
3085
|
+
}
|
|
3086
|
+
// A live state change (liveSessionRows' paint without a structural change, Q1 07:22 FIX 1): the row is redrawn where
|
|
3087
|
+
// it was placed, in that group's form, and when its group would now differ (Working now to Needs you, say) the list
|
|
3088
|
+
// asks for a regroup, which the rest rule applies (at once at rest, held while the list is pressed, scrolled or focused).
|
|
3089
|
+
function sxLiveRow(s, amb) {
|
|
3090
|
+
var k = sxRowKey(s), placed = SXL.place ? SXL.place[k] : null, want = sxGroupFor(s, Date.now(), sxHeldKeys());
|
|
3091
|
+
return { html: sxRow(s, null, amb, sxGroupCompact(placed || want, s)), moved: !!placed && placed !== want };
|
|
2718
3092
|
}
|
|
2719
3093
|
function sxListHtml() {
|
|
2720
3094
|
if (!SX.rows) return SX.error ? sxEmpty(SX.error) : skeletonRows(6);
|
|
2721
3095
|
if (!SX.enabled && !SX.rows.length) return sxEmpty('AI sessions are off. Turn on Show AI sessions in Settings, Work and agents.');
|
|
2722
|
-
var
|
|
2723
|
-
if (!
|
|
2724
|
-
var
|
|
2725
|
-
|
|
3096
|
+
var all = sxAllRows();
|
|
3097
|
+
if (!all.length) return sxEmpty(SX.error || sxEmptyCopy());
|
|
3098
|
+
var now = Date.now(), held = sxHeldKeys(), want = {}, by = {}, moves = 0;
|
|
3099
|
+
all.forEach(function (s) { want[sxRowKey(s)] = sxGroupFor(s, now, held); });
|
|
3100
|
+
// A row changes group only at rest; held moves apply together on the next rest (sxRegroupLater).
|
|
3101
|
+
if (!SXL.place || sxListAtRest()) SXL.place = want;
|
|
3102
|
+
else Object.keys(want).forEach(function (k) { if (!(k in SXL.place)) SXL.place[k] = want[k]; else if (SXL.place[k] !== want[k]) moves++; });
|
|
3103
|
+
if (moves) sxRegroupLater();
|
|
3104
|
+
all.forEach(function (s) { var k = sxRowKey(s), g = SXL.place[k] || want[k]; (by[g] = by[g] || []).push(s); });
|
|
3105
|
+
var amb = sxAmbiguous(all.filter(function (s) { return s.form !== 'short'; }));
|
|
3106
|
+
var html = '', holdUntil = 0;
|
|
3107
|
+
// Folders sit after Pinned, each a heading that folds (open by default), with its own menu; New folder's field
|
|
3108
|
+
// opens at the top of this area.
|
|
3109
|
+
var groups = [];
|
|
3110
|
+
SX_GROUPS.forEach(function (g) {
|
|
3111
|
+
groups.push(g);
|
|
3112
|
+
if (g[0] === 'pinned' && typeof SXF === 'object' && SXF.ok) {
|
|
3113
|
+
if (SXF.newOpen) groups.push(['newfolder', '', 'form']);
|
|
3114
|
+
SXF.folders.forEach(function (f) { groups.push(['f:' + f.id, f.name, 'folder']); });
|
|
3115
|
+
}
|
|
3116
|
+
});
|
|
3117
|
+
groups.forEach(function (g) {
|
|
3118
|
+
if (g[2] === 'form') { html += '<section class="sx-g sx-g-form">' + sxInlineHtml('new-folder', '', 'New folder', '', '') + '</section>'; return; }
|
|
3119
|
+
if (g[2] === 'folder') {
|
|
3120
|
+
var fid = g[0].slice(2), frows = (by[g[0]] || []).sort(function (a, b) { return sxSortTime(b) - sxSortTime(a); }), fopen = SXL.open[g[0]] !== false;
|
|
3121
|
+
if (SXF.folderRenaming === fid) { html += '<section class="sx-g" data-g="' + esc(g[0]) + '">' + sxInlineHtml('folder-rename', fid, 'Rename folder', g[1], '') + '</section>'; return; }
|
|
3122
|
+
html += '<section class="sx-g sx-folder" data-g="' + esc(g[0]) + '"><div class="sx-gh-row"><button type="button" class="sx-gh" data-sx="fold" data-g="' + esc(g[0]) + '" aria-expanded="' + fopen + '">' +
|
|
3123
|
+
'<span class="fold"><span class="chev" aria-hidden="true"></span><span class="nm">' + esc(g[1]) + '</span></span><span class="n">' + frows.length + '</span></button>' +
|
|
3124
|
+
'<button type="button" class="sx-r-more" data-sx="folder-menu" data-id="' + esc(fid) + '" aria-haspopup="menu" aria-expanded="false" aria-label="' + esc('Folder menu for ' + g[1]) + '"><span aria-hidden="true">\u2026</span></button></div>' +
|
|
3125
|
+
(fopen ? (frows.length ? '<div class="sx-rows">' + frows.map(function (s) { return sxRow(s, null, amb, s.form === 'short'); }).join('') + '</div>'
|
|
3126
|
+
: '<p class="sx-empty-folder">Nothing here yet. Move a session in from its menu.</p>') : '') + '</section>';
|
|
3127
|
+
return;
|
|
3128
|
+
}
|
|
3129
|
+
var id = g[0], rows = (by[id] || []).sort(function (a, b) { return sxSortTime(b) - sxSortTime(a); }), n = rows.length;
|
|
3130
|
+
if (g[2] === 'state') {
|
|
3131
|
+
if (n) SXL.seen[id] = now;
|
|
3132
|
+
else if (SXL.seen[id] && now - SXL.seen[id] < SX_EMPTY_HOLD_MS) holdUntil = Math.max(holdUntil, SXL.seen[id] + SX_EMPTY_HOLD_MS);
|
|
3133
|
+
else return;
|
|
3134
|
+
html += '<section class="sx-g" data-g="' + id + '"><h3 class="sx-gh state ' + id + '"><span>' + g[1] + '</span><span class="n">' + n + '</span></h3>' +
|
|
3135
|
+
'<div class="sx-rows">' + rows.map(function (s) { return sxRow(s, null, amb); }).join('') + '</div></section>';
|
|
3136
|
+
return;
|
|
3137
|
+
}
|
|
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
|
+
}
|
|
3146
|
+
if (g[2] === 'compact') {
|
|
3147
|
+
var open = !!SXL.open[id];
|
|
3148
|
+
html += '<section class="sx-g" data-g="' + id + '"><button type="button" class="sx-gh" data-sx="fold" data-g="' + id + '" aria-expanded="' + open + '">' +
|
|
3149
|
+
'<span class="fold"><span class="chev" aria-hidden="true"></span>' + g[1] + '</span><span class="n">' + n + '</span></button>' +
|
|
3150
|
+
(open ? '<div class="sx-rows">' + rows.map(function (s) { return sxRow(s, null, amb, true); }).join('') + '</div>' +
|
|
3151
|
+
(id === 'month' && SXL.olderNext ? '<p class="sx-more-older"><button type="button" class="cx-btn quiet" data-sx="older-more">Show older</button></p>' : '') : '') + '</section>';
|
|
3152
|
+
return;
|
|
3153
|
+
}
|
|
3154
|
+
html += '<section class="sx-g" data-g="' + id + '"><h3 class="sx-gh"><span>' + g[1] + '</span><span class="n">' + n + '</span></h3>' +
|
|
3155
|
+
'<div class="sx-rows">' + rows.map(function (s) { return sxRow(s, null, amb, s.form === 'short'); }).join('') + '</div></section>';
|
|
3156
|
+
});
|
|
3157
|
+
// A Needs you or Working now heading at 0 stays for SX_EMPTY_HOLD_MS, then the list draws again without it.
|
|
3158
|
+
if (holdUntil) sxRegroupLater(holdUntil - now + 20);
|
|
3159
|
+
return '<div class="sx-list sx-groups">' + html + '</div>';
|
|
3160
|
+
}
|
|
3161
|
+
// The list keeps its place across a redraw: the top visible row stays where it was in the list's own scroll.
|
|
3162
|
+
function sxAnchor(col) {
|
|
3163
|
+
if (!col || !col.getBoundingClientRect) return null;
|
|
3164
|
+
var top = col.getBoundingClientRect().top, rows = col.querySelectorAll('.sx-r[data-session]');
|
|
3165
|
+
for (var i = 0; i < rows.length; i++) { var b = rows[i].getBoundingClientRect(); if (b.bottom > top) return { key: rows[i].getAttribute('data-session'), top: b.top }; }
|
|
3166
|
+
return null;
|
|
3167
|
+
}
|
|
3168
|
+
function sxRestoreAnchor(col, a) {
|
|
3169
|
+
if (!col || !a) return;
|
|
3170
|
+
var rows = col.querySelectorAll('.sx-r[data-session]');
|
|
3171
|
+
for (var i = 0; i < rows.length; i++) if (rows[i].getAttribute('data-session') === a.key) { col.scrollTop += rows[i].getBoundingClientRect().top - a.top; return; }
|
|
3172
|
+
}
|
|
3173
|
+
// GET /api/agent-sessions/older (server step 2a): the short rows for days 8 to 30 and the kept ones, newest first,
|
|
3174
|
+
// paged; read when the list loads (at most once a minute) and for Show older, never on the poll. An older server
|
|
3175
|
+
// answers 404: the list stays at 7 days and says so.
|
|
3176
|
+
var SX_OLDER_MS = 60000;
|
|
3177
|
+
function sxLoadOlder(force, more) {
|
|
3178
|
+
if (SXL.olderBusy || SXL.olderOk === false) return Promise.resolve();
|
|
3179
|
+
if (!more && !force && SXL.olderAt && Date.now() - SXL.olderAt < SX_OLDER_MS) return Promise.resolve();
|
|
3180
|
+
SXL.olderBusy = true;
|
|
3181
|
+
var q = '?days=30&limit=200' + (more && SXL.olderNext ? '&before=' + encodeURIComponent(SXL.olderNext) : '');
|
|
3182
|
+
return api('/api/agent-sessions/older' + q).then(function (d) {
|
|
3183
|
+
var rows = (d && Array.isArray(d.sessions) ? d.sessions : []).map(function (x) { return Object.assign({}, x, { form: 'short' }); });
|
|
3184
|
+
SXL.older = more ? (SXL.older || []).concat(rows) : rows;
|
|
3185
|
+
if (!more && typeof d.older_than_window === 'number') SXL.olderN = d.older_than_window;
|
|
3186
|
+
SXL.olderNext = d && d.next_before ? d.next_before : null;
|
|
3187
|
+
SXL.olderOk = true; SXL.olderAt = Date.now();
|
|
3188
|
+
}, function (err) {
|
|
3189
|
+
if (err && (err.status === 404 || err.code === 'not_found')) SXL.olderOk = false;
|
|
3190
|
+
}).then(function () { SXL.olderBusy = false; if (state.pane === 'sessions' && SX.view === 'sessions' && !sxQueryActive()) renderSessionGroups(); });
|
|
3191
|
+
}
|
|
3192
|
+
// The one quiet line at the top of the list: how far back it reaches and how many more are found by search.
|
|
3193
|
+
function sxQuietLine() {
|
|
3194
|
+
if (SXL.olderOk) return 'Last 30 days. ' + (SXL.olderN ? SXL.olderN + ' older through search.' : 'Older ones through search.');
|
|
3195
|
+
var age = sxDropped().age;
|
|
3196
|
+
return 'Last 7 days. ' + (age ? age + ' older through search.' : 'Older ones through search.');
|
|
3197
|
+
}
|
|
3198
|
+
// The sort menu: Updated or Opened; while searching, Best match or Updated.
|
|
3199
|
+
function sxPaintSort() {
|
|
3200
|
+
var sel = $('sessions-sort'); if (!sel) return;
|
|
3201
|
+
var searching = sxQueryActive();
|
|
3202
|
+
var opts = searching ? [['match', 'Best match'], ['newest', 'Updated']] : [['updated', 'Updated'], ['opened', 'Opened']];
|
|
3203
|
+
var cur = searching ? (SX.recency === 'match' ? 'match' : 'newest') : (SX.clock === 'opened' ? 'opened' : 'updated');
|
|
3204
|
+
var html = opts.map(function (o) { return '<option value="' + o[0] + '">' + o[1] + '</option>'; }).join('');
|
|
3205
|
+
if (sel.__html !== html) { sel.innerHTML = html; sel.__html = html; }
|
|
3206
|
+
sel.value = cur;
|
|
3207
|
+
}
|
|
3208
|
+
function sxPaintListHead() {
|
|
3209
|
+
var nf = $('sessions-new-folder'); if (nf) nf.classList.toggle('hidden', !(typeof SXF === 'object' && SXF.ok === true));
|
|
3210
|
+
var on = $('sessions-org-note'); if (on) { var nt = typeof SXF === 'object' ? SXF.note : ''; on.textContent = nt; on.classList.toggle('hidden', !nt); }
|
|
3211
|
+
var line = $('sessions-quiet');
|
|
3212
|
+
if (line) { var t = sxQueryActive() ? '' : sxQuietLine(); line.textContent = t; line.classList.toggle('hidden', !t); }
|
|
3213
|
+
sxPaintSort();
|
|
3214
|
+
}
|
|
3215
|
+
// The Sessions menu: Sessions, Usage, COS cost, and Refresh.
|
|
3216
|
+
function sxViewMenu(open) {
|
|
3217
|
+
var btn = $('sessions-view-btn'), menu = $('sessions-view-menu');
|
|
3218
|
+
if (!btn || !menu) return;
|
|
3219
|
+
if (open == null) open = menu.classList.contains('hidden');
|
|
3220
|
+
menu.classList.toggle('hidden', !open);
|
|
3221
|
+
btn.setAttribute('aria-expanded', String(open));
|
|
3222
|
+
if (open) { var first = menu.querySelector('[role="menuitem"]'); if (first && first.focus) first.focus(); }
|
|
3223
|
+
}
|
|
3224
|
+
function sxListWatch() {
|
|
3225
|
+
var col = $('sessions-list-col');
|
|
3226
|
+
if (!col || col.__sxWatch) return;
|
|
3227
|
+
col.__sxWatch = true;
|
|
3228
|
+
var down = function () { SXL.down = true; SXL.touchAt = Date.now(); };
|
|
3229
|
+
var up = function () { SXL.down = false; SXL.touchAt = Date.now(); sxRegroupLater(); };
|
|
3230
|
+
var moved = function () { SXL.scrollAt = Date.now(); };
|
|
3231
|
+
col.addEventListener('pointerdown', down, { passive: true });
|
|
3232
|
+
col.addEventListener('touchstart', down, { passive: true });
|
|
3233
|
+
['pointerup', 'pointercancel', 'touchend', 'touchcancel'].forEach(function (t) { col.addEventListener(t, up, { passive: true }); });
|
|
3234
|
+
col.addEventListener('scroll', moved, { passive: true });
|
|
3235
|
+
col.addEventListener('wheel', moved, { passive: true });
|
|
3236
|
+
document.addEventListener('click', function (e) {
|
|
3237
|
+
var menu = $('sessions-view-menu');
|
|
3238
|
+
if (menu && !menu.classList.contains('hidden') && !e.target.closest('#sessions-view-menu') && !e.target.closest('#sessions-view-btn')) sxViewMenu(false);
|
|
3239
|
+
});
|
|
3240
|
+
// The row and folder menu lives on <body> (outside this pane): its items are handled here; a tap outside closes it.
|
|
3241
|
+
document.addEventListener('click', function (e) {
|
|
3242
|
+
var item = e.target.closest && e.target.closest('#sessions-pop [data-sx]');
|
|
3243
|
+
if (item) { e.stopPropagation(); return sxOrgClick(item.getAttribute('data-sx'), item); }
|
|
3244
|
+
if (SXF.pop && !(e.target.closest && (e.target.closest('#sessions-pop') || e.target.closest('[data-sx="row-menu"],[data-sx="folder-menu"]')))) sxPopClose();
|
|
3245
|
+
}, true);
|
|
3246
|
+
document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && SXF.pop) { var b = SXF.pop; sxPopClose(); if (b && b.focus) b.focus(); } });
|
|
3247
|
+
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') { var menu = $('sessions-view-menu'); if (menu && !menu.classList.contains('hidden')) { sxViewMenu(false); var b = $('sessions-view-btn'); if (b) b.focus(); } } });
|
|
3248
|
+
}
|
|
3249
|
+
// ── folders, COS pins and rename (7.0.23, step 2b-2; S1's organizer, contract at 469a334) ──
|
|
3250
|
+
// One store on the Mac (GET/POST/PATCH/DELETE /api/session-folders, PATCH /api/agent-sessions/:p/:id/organizer),
|
|
3251
|
+
// shared by the phone and the Mac app. Every change is one small request; after it the folders and the list are
|
|
3252
|
+
// read again. Folder names and renamed titles are untrusted text: drawn with esc() only, never as markup, never in a
|
|
3253
|
+
// URL (S1 rule 6). An older server answers 404 to GET /api/session-folders: no More button and no New folder, and
|
|
3254
|
+
// the list is as before.
|
|
3255
|
+
var SXF = { ok: null, folders: [], byId: {}, at: 0, busy: false, note: '', newOpen: false, renaming: '', folderRenaming: '', armDelete: '', pop: null };
|
|
3256
|
+
var SXF_REASONS = {
|
|
3257
|
+
folder_name_taken: 'A folder already has that name.', invalid_name: 'A folder name is 1 to 60 characters.', too_many_folders: 'That is the most folders this Mac keeps (200).',
|
|
3258
|
+
invalid_title: 'A title is 1 to 120 characters.', alias_conflict: 'Claude is still settling which session this is. Try again in a moment.',
|
|
3259
|
+
session_not_found: 'This session is no longer on the Mac.', folder_not_found: 'That folder is gone. The list has been read again.',
|
|
3260
|
+
too_many_entries: 'This Mac keeps folders, pins and titles for 5,000 sessions at most.', organizer_unavailable: 'Folders, pins and titles are not available on the Mac right now.'
|
|
3261
|
+
};
|
|
3262
|
+
function sxOrgWords(err) { var c = err && (err.code || ''); return SXF_REASONS[c] || 'That did not save. Check that the Mac is awake, then try again.'; }
|
|
3263
|
+
function sxLoadFolders(force) {
|
|
3264
|
+
if (SXF.ok === false || SXF.busy) return Promise.resolve();
|
|
3265
|
+
if (!force && SXF.at && Date.now() - SXF.at < SX_OLDER_MS) return Promise.resolve();
|
|
3266
|
+
SXF.busy = true;
|
|
3267
|
+
return api('/api/session-folders').then(function (d) {
|
|
3268
|
+
SXF.folders = (d && Array.isArray(d.folders) ? d.folders : []).filter(function (f) { return f && typeof f.id === 'string'; });
|
|
3269
|
+
SXF.byId = {}; SXF.folders.forEach(function (f) { SXF.byId[f.id] = f; });
|
|
3270
|
+
SXF.ok = true; SXF.at = Date.now();
|
|
3271
|
+
}, function (err) { if (err && err.status === 404) SXF.ok = false; }).then(function () {
|
|
3272
|
+
SXF.busy = false;
|
|
3273
|
+
if (state.pane === 'sessions' && SX.view === 'sessions' && !sxQueryActive()) renderSessionGroups();
|
|
3274
|
+
});
|
|
3275
|
+
}
|
|
3276
|
+
// A move the person asked for (pin, folder, rename, a deleted folder) applies at once, like a sort change: the rest
|
|
3277
|
+
// rule holds only moves the person did not make.
|
|
3278
|
+
function sxOrgDone(note) {
|
|
3279
|
+
SXF.note = note || ''; SXF.at = 0; SXL.place = null;
|
|
3280
|
+
// A list read already in flight started before this change: wait for it, then read again, so the list shows it.
|
|
3281
|
+
var settled = function () { return SL.inflight ? SL.inflight.then(function () {}, function () {}) : Promise.resolve(); };
|
|
3282
|
+
// The placement is reset once the new list is in (a redraw in between, with the old rows, would use it up).
|
|
3283
|
+
return sxLoadFolders(true).then(settled).then(function () { return loadSessions(true); }).then(function () { SXL.place = null; renderSessionGroups(); });
|
|
3284
|
+
}
|
|
3285
|
+
function sxOrgFail(err) { SXF.note = sxOrgWords(err); sxPaintListHead(); }
|
|
3286
|
+
// A text field inside the list keeps the list from redrawing over what is being typed.
|
|
3287
|
+
function sxEditing() { var a = document.activeElement; return !!(a && a.closest && a.closest('#sessions-list form.sx-inline')); }
|
|
3288
|
+
function sxRowByKey(key) { return sxAllRows().filter(function (s) { return sxRowKey(s) === String(key).toLowerCase(); })[0] || null; }
|
|
3289
|
+
function sxOrgPatch(key, body) {
|
|
3290
|
+
var s = sxRowByKey(key); if (!s) return Promise.resolve();
|
|
3291
|
+
var k = splitSessionId(s.provider, s.session_id || s.id);
|
|
3292
|
+
return api('/api/agent-sessions/' + encodeURIComponent(k.provider) + '/' + encodeURIComponent(k.id) + '/organizer', { method: 'PATCH', body: body });
|
|
3293
|
+
}
|
|
3294
|
+
function sxInlineHtml(kind, id, label, value, hint) {
|
|
3295
|
+
return '<form class="sx-inline" data-sx-form="' + kind + '" data-id="' + esc(id) + '"><label class="sx-inline-label" for="sx-inline-' + kind + '">' + esc(label) + '</label>' +
|
|
3296
|
+
'<input class="field" id="sx-inline-' + kind + '" maxlength="' + (kind === 'rename' ? 120 : 60) + '" autocomplete="off" enterkeyhint="done" value="' + esc(value || '') + '">' +
|
|
3297
|
+
(hint ? '<p class="sx-inline-hint">' + esc(hint) + '</p>' : '') +
|
|
3298
|
+
'<div class="sx-inline-acts"><button type="submit" class="cx-btn primary">' + (kind === 'new-folder' ? 'Create' : 'Save') + '</button>' +
|
|
3299
|
+
(kind === 'rename' && value ? '<button type="button" class="cx-btn quiet" data-sx="rename-clear" data-id="' + esc(id) + '">Use the agent’s title</button>' : '') +
|
|
3300
|
+
'<button type="button" class="cx-btn quiet" data-sx="inline-cancel">Cancel</button></div></form>';
|
|
3301
|
+
}
|
|
3302
|
+
// The row's menu: Pin or Unpin (a COS pin); an agent's own pin is said and has no Unpin; Move to folder; Rename.
|
|
3303
|
+
function sxRowMenuHtml(s, key, moving) {
|
|
3304
|
+
var k = splitSessionId(s.provider, s.session_id || s.id);
|
|
3305
|
+
if (moving) {
|
|
3306
|
+
var cur = s.folder_id || '';
|
|
3307
|
+
return (SXF.folders.length ? SXF.folders.map(function (f) {
|
|
3308
|
+
return '<button type="button" role="menuitemradio" aria-checked="' + (cur === f.id) + '" data-sx="org-move" data-key="' + esc(key) + '" data-folder="' + esc(f.id) + '">' + esc(f.name) + '</button>';
|
|
3309
|
+
}).join('') : '<p class="sx-pop-note">No folders yet. Make one with New folder at the top of the list.</p>') +
|
|
3310
|
+
'<button type="button" role="menuitemradio" aria-checked="' + (!cur) + '" data-sx="org-move" data-key="' + esc(key) + '" data-folder="">No folder</button>';
|
|
3311
|
+
}
|
|
3312
|
+
// An agent's own pin is not COS's to undo; a COS pin under it still is (Q1 07:22 a).
|
|
3313
|
+
return (s.pinned ? '<p class="sx-pop-note">Pinned in ' + sxProviderLabel(k.provider) + '</p>' +
|
|
3314
|
+
(s.cos_pinned ? '<button type="button" role="menuitem" data-sx="org-pin" data-key="' + esc(key) + '">Unpin in COS</button>' : '')
|
|
3315
|
+
: '<button type="button" role="menuitem" data-sx="org-pin" data-key="' + esc(key) + '">' + (s.cos_pinned ? 'Unpin' : 'Pin') + '</button>') +
|
|
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>' : '') +
|
|
3320
|
+
'<button type="button" role="menuitem" data-sx="org-rename" data-key="' + esc(key) + '">Rename</button>';
|
|
3321
|
+
}
|
|
3322
|
+
function sxFolderMenuHtml(id) {
|
|
3323
|
+
return '<button type="button" role="menuitem" data-sx="folder-rename" data-id="' + esc(id) + '">Rename</button>' +
|
|
3324
|
+
'<button type="button" role="menuitem" data-sx="folder-delete" data-id="' + esc(id) + '">' + (SXF.armDelete === id ? 'Tap again to delete the folder. Its sessions go back to their day group.' : 'Delete folder') + '</button>';
|
|
3325
|
+
}
|
|
3326
|
+
// One small menu at a time, under its button, inside the visible frame; Escape or a tap outside closes it.
|
|
3327
|
+
function sxPopClose() {
|
|
3328
|
+
var pop = $('sessions-pop'); if (pop) { pop.classList.add('hidden'); pop.innerHTML = ''; }
|
|
3329
|
+
if (SXF.pop && SXF.pop.setAttribute) SXF.pop.setAttribute('aria-expanded', 'false');
|
|
3330
|
+
SXF.pop = null;
|
|
3331
|
+
}
|
|
3332
|
+
function sxPopOpen(btn, html, label) {
|
|
3333
|
+
var pop = $('sessions-pop');
|
|
3334
|
+
if (!pop) { pop = document.createElement('div'); pop.id = 'sessions-pop'; pop.className = 'sx-menu sx-pop hidden'; pop.setAttribute('role', 'menu'); document.body.appendChild(pop); }
|
|
3335
|
+
if (SXF.pop && SXF.pop !== btn && SXF.pop.setAttribute) SXF.pop.setAttribute('aria-expanded', 'false');
|
|
3336
|
+
pop.setAttribute('aria-label', label || 'Menu');
|
|
3337
|
+
pop.innerHTML = html; pop.classList.remove('hidden');
|
|
3338
|
+
SXF.pop = btn; btn.setAttribute('aria-expanded', 'true');
|
|
3339
|
+
var r = btn.getBoundingClientRect(), w = pop.offsetWidth || 220, h = pop.offsetHeight || 160, vw = window.innerWidth || 0, vh = window.innerHeight || 0;
|
|
3340
|
+
pop.style.left = Math.max(8, Math.min(r.right - w, vw - w - 8)) + 'px';
|
|
3341
|
+
pop.style.top = (r.bottom + 4 + h > vh - 8 ? Math.max(8, r.top - 4 - h) : r.bottom + 4) + 'px';
|
|
3342
|
+
var first = pop.querySelector('[role^="menuitem"]'); if (first && first.focus) first.focus();
|
|
3343
|
+
}
|
|
3344
|
+
function sxOrgClick(act, t) {
|
|
3345
|
+
var key = t.getAttribute('data-key') || '', id = t.getAttribute('data-id') || '', s = key ? sxRowByKey(key) : null;
|
|
3346
|
+
if (act === 'row-menu') { if (SXF.pop === t) return sxPopClose(); return s ? sxPopOpen(t, sxRowMenuHtml(s, key, false), 'Session menu') : null; }
|
|
3347
|
+
if (act === 'org-move-menu') { var b = SXF.pop; return s && b ? sxPopOpen(b, sxRowMenuHtml(s, key, true), 'Move to folder') : null; }
|
|
3348
|
+
if (act === 'folder-menu') { SXF.armDelete = ''; if (SXF.pop === t) return sxPopClose(); return sxPopOpen(t, sxFolderMenuHtml(id), 'Folder menu'); }
|
|
3349
|
+
if (act === 'org-pin' && s) { sxPopClose(); return sxOrgPatch(key, { cos_pinned: !s.cos_pinned }).then(function () { return sxOrgDone(); }, sxOrgFail); }
|
|
3350
|
+
if (act === 'org-move' && s) { sxPopClose(); var f = t.getAttribute('data-folder') || null; return sxOrgPatch(key, { folder_id: f }).then(function () { return sxOrgDone(); }, sxOrgFail); }
|
|
3351
|
+
if (act === 'org-rename') { sxPopClose(); SXF.renaming = key; SXF.note = ''; renderSessionGroups(); var i = $('sx-inline-rename'); if (i) { i.focus(); i.select && i.select(); } return; }
|
|
3352
|
+
if (act === 'rename-clear') { SXF.renaming = ''; return sxOrgPatch(id, { title: null }).then(function () { return sxOrgDone(); }, sxOrgFail); }
|
|
3353
|
+
if (act === 'new-folder') { SXF.newOpen = true; SXF.note = ''; renderSessionGroups(); var n = $('sx-inline-new-folder'); if (n) n.focus(); return; }
|
|
3354
|
+
if (act === 'folder-rename') { sxPopClose(); SXF.folderRenaming = id; SXF.note = ''; renderSessionGroups(); var fr = $('sx-inline-folder-rename'); if (fr) { fr.focus(); fr.select && fr.select(); } return; }
|
|
3355
|
+
if (act === 'folder-delete') {
|
|
3356
|
+
if (SXF.armDelete !== id) { SXF.armDelete = id; if (SXF.pop) sxPopOpen(SXF.pop, sxFolderMenuHtml(id), 'Folder menu'); return; }
|
|
3357
|
+
SXF.armDelete = ''; sxPopClose();
|
|
3358
|
+
return api('/api/session-folders/' + encodeURIComponent(id), { method: 'DELETE' }).then(function () { return sxOrgDone(); }, sxOrgFail);
|
|
3359
|
+
}
|
|
3360
|
+
if (act === 'inline-cancel') { SXF.newOpen = false; SXF.renaming = ''; SXF.folderRenaming = ''; var a = document.activeElement; if (a && a.blur) a.blur(); return renderSessionGroups(); }
|
|
3361
|
+
}
|
|
3362
|
+
function sxOrgSubmit(form) {
|
|
3363
|
+
var kind = form.getAttribute('data-sx-form'), id = form.getAttribute('data-id') || '', input = form.querySelector('input');
|
|
3364
|
+
var v = input ? String(input.value || '').trim() : '';
|
|
3365
|
+
if (kind === 'new-folder') {
|
|
3366
|
+
if (!v) { SXF.note = SXF_REASONS.invalid_name; return sxPaintListHead(); }
|
|
3367
|
+
return api('/api/session-folders', { method: 'POST', body: { name: v } }).then(function () { SXF.newOpen = false; if (input) input.blur(); return sxOrgDone(); }, sxOrgFail);
|
|
3368
|
+
}
|
|
3369
|
+
if (kind === 'folder-rename') {
|
|
3370
|
+
if (!v) { SXF.note = SXF_REASONS.invalid_name; return sxPaintListHead(); }
|
|
3371
|
+
return api('/api/session-folders/' + encodeURIComponent(id), { method: 'PATCH', body: { name: v } }).then(function () { SXF.folderRenaming = ''; if (input) input.blur(); return sxOrgDone(); }, sxOrgFail);
|
|
3372
|
+
}
|
|
3373
|
+
if (kind === 'rename') {
|
|
3374
|
+
return sxOrgPatch(id, { title: v || null }).then(function () { SXF.renaming = ''; if (input) input.blur(); return sxOrgDone(); }, sxOrgFail);
|
|
3375
|
+
}
|
|
2726
3376
|
}
|
|
2727
3377
|
// The list, under the hero. The name stays: the shared input handler calls it when the search clears.
|
|
2728
3378
|
function renderSessionGroups() {
|
|
@@ -2737,8 +3387,14 @@
|
|
|
2737
3387
|
var note = sxQueryActive() && SX.ranFor === SX.q && SX.semantic === false ? sxSemanticHint(SX.semanticReason) : '';
|
|
2738
3388
|
hint.textContent = note; hint.classList.toggle('hidden', !note);
|
|
2739
3389
|
}
|
|
3390
|
+
sxListWatch();
|
|
3391
|
+
sxPaintListHead();
|
|
2740
3392
|
var list = $('sessions-list'); if (!list) return;
|
|
3393
|
+
// A field in the list being typed in (New folder, a rename) is never drawn over; the list waits for it.
|
|
3394
|
+
if (!sxQueryActive() && typeof sxEditing === 'function' && sxEditing()) { sxRegroupLater(); return; }
|
|
3395
|
+
var col = $('sessions-list-col'), at = sxQueryActive() ? null : sxAnchor(col);
|
|
2741
3396
|
list.innerHTML = sxQueryActive() ? sxSearchHtml() : sxListHtml();
|
|
3397
|
+
sxRestoreAnchor(col, at);
|
|
2742
3398
|
}
|
|
2743
3399
|
|
|
2744
3400
|
// Search (ActivityWindow.swift sessionsSearchBar 2652-2712, ControllerModel scheduleSessionSearch):
|
|
@@ -2956,15 +3612,23 @@
|
|
|
2956
3612
|
// keep their data-action names, which the shared listener handles.
|
|
2957
3613
|
var sxPane = $('pane-sessions');
|
|
2958
3614
|
if (sxPane) {
|
|
2959
|
-
|
|
3615
|
+
sxPaintSort();
|
|
2960
3616
|
sxPane.addEventListener('click', function (e) {
|
|
2961
3617
|
var t = e.target.closest('[data-sx]');
|
|
2962
3618
|
if (!t || !sxPane.contains(t)) return;
|
|
2963
3619
|
var act = t.getAttribute('data-sx'), rd = state.reading;
|
|
2964
|
-
if (act === 'view') return setSessionsView(t.getAttribute('data-view'));
|
|
3620
|
+
if (act === 'view') { sxViewMenu(false); return setSessionsView(t.getAttribute('data-view')); }
|
|
3621
|
+
if (act === 'view-menu') return sxViewMenu();
|
|
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(); }
|
|
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(); }
|
|
3625
|
+
if (/^(row-menu|org-|folder-|new-folder|rename-clear|inline-cancel)/.test(act)) { e.stopPropagation(); return sxOrgClick(act, t); }
|
|
3626
|
+
if (act === 'older-more') return sxLoadOlder(false, true);
|
|
2965
3627
|
if (act === 'clock') { SX.clock = t.getAttribute('data-clock'); sxSave(); return renderSessionGroups(); }
|
|
2966
3628
|
if (act === 'refresh') {
|
|
3629
|
+
sxViewMenu(false);
|
|
2967
3630
|
if (SX.view !== 'sessions') return renderUsageView(true);
|
|
3631
|
+
sxLoadOlder(true);
|
|
2968
3632
|
SX.hooksAt = 0;
|
|
2969
3633
|
loadSessions(true);
|
|
2970
3634
|
if (sxQueryActive()) searchSessions();
|
|
@@ -2973,7 +3637,6 @@
|
|
|
2973
3637
|
if (act === 'clear') { var input = $('sessions-q'); input.value = ''; searchSessions(); input.focus(); return; }
|
|
2974
3638
|
if (act === 'copy-session') return copySessionBrief();
|
|
2975
3639
|
if (act === 'full') { if (SX_SPLIT) SX_SPLIT.toggleFull(); return; }
|
|
2976
|
-
if (act === 'refresh-turns' && rd) { rd.refusal = null; renderVerdict(rd); refreshTurns(rd.provider, rd.id); return; }
|
|
2977
3640
|
if (act === 'retry' && rd) { rd.refusal = null; renderVerdict(rd); return sendSessionTurn(!!rd.retryFork); }
|
|
2978
3641
|
if (act === 'caution-send' && rd) { rd.cautionAck = true; rd.cautionAsk = false; renderVerdict(rd); return sendSessionTurn(false); }
|
|
2979
3642
|
if (act === 'caution-cancel' && rd) { rd.cautionAsk = false; renderVerdict(rd); return; }
|
|
@@ -2992,9 +3655,23 @@
|
|
|
2992
3655
|
renderSessionGroups();
|
|
2993
3656
|
SX.searchTimer = setTimeout(searchSessions, 350);
|
|
2994
3657
|
});
|
|
3658
|
+
// 7.0.23 step 2b-2: New folder and the renames are inline forms in the list.
|
|
3659
|
+
sxPane.addEventListener('submit', function (e) {
|
|
3660
|
+
var f = e.target;
|
|
3661
|
+
if (!f || !f.matches || !f.matches('form.sx-inline')) return;
|
|
3662
|
+
e.preventDefault();
|
|
3663
|
+
sxOrgSubmit(f);
|
|
3664
|
+
});
|
|
2995
3665
|
sxPane.addEventListener('change', function (e) {
|
|
2996
3666
|
var id = e.target.id;
|
|
2997
|
-
|
|
3667
|
+
// 7.0.23 step 2b: one sort menu. Updated or Opened orders the groups (the rows move together: the list redraws
|
|
3668
|
+
// at once, not on rest, because the person chose it); while searching it is Best match or Updated.
|
|
3669
|
+
if (id === 'sessions-sort') {
|
|
3670
|
+
var v = e.target.value;
|
|
3671
|
+
if (sxQueryActive()) SX.recency = v === 'match' ? 'match' : 'newest';
|
|
3672
|
+
else { SX.clock = v === 'opened' ? 'opened' : 'updated'; SXL.place = null; }
|
|
3673
|
+
sxSave(); return renderSessionGroups();
|
|
3674
|
+
}
|
|
2998
3675
|
// Custom dates opens the date sheet; the menu goes back to the range in force if it is cancelled.
|
|
2999
3676
|
if (id === 'usage-period') { if (e.target.value === U_CUSTOM) return uOpenRange(e.target, true); SX.uPeriod = e.target.value; uPeriodChanged(); return paintUsage(); }
|
|
3000
3677
|
if (id === 'usage-tool') { SX.uTool = e.target.value; SX.uModel = 'All models'; SX.uToolBefore = null; uSliceChanged(); return paintUsage(); }
|
|
@@ -3013,6 +3690,12 @@
|
|
|
3013
3690
|
// window) only means the choice lasts this visit. Below a pane's own breakpoint the divider is hidden by CSS and the
|
|
3014
3691
|
// kit does nothing (o.media), so a phone's layout never changes.
|
|
3015
3692
|
var SX_SPLIT_KEY = 'cos_web_sessions_split', SX_LIST_MIN = 240, SX_READER_MIN = 380, SX_HANDLE_W = 18, SX_KEY_STEP = 16, SX_DOUBLE_MS = 350;
|
|
3693
|
+
// 11/10 (lead 20:39): Miles squeezes the reader on his Duo's Home Screen web app and the page breaks; WebKit at scale 1
|
|
3694
|
+
// does not (cw = scw, reader floor 380). One layout report when a drag ends, so his phone says what the page did:
|
|
3695
|
+
// the split, list and reader widths, the share, clientWidth and scrollWidth, visualViewport.pageTop, pageLeft and
|
|
3696
|
+
// offsetLeft, scrollX, and the top, left and right of the rail, the list and the other column (layoutCrumb adds the
|
|
3697
|
+
// scale and offsetTop itself). Numbers only, at most SPLIT_DRAG_MAX a page load.
|
|
3698
|
+
var SPLIT_DRAG = { n: 0 }, SPLIT_DRAG_MAX = 6;
|
|
3016
3699
|
// o: key (storage), prefix (class names: <prefix>-sized, -full and -dragging), cssVar (the share, default
|
|
3017
3700
|
// --<prefix>-list-share), label (what the divider sizes, for screen readers), sized (the column the share sizes,
|
|
3018
3701
|
// default the list), side ('end' when that column is on the right: a side panel grows as the line moves left),
|
|
@@ -3048,7 +3731,7 @@
|
|
|
3048
3731
|
split.classList.toggle(PRE + '-sized', share != null);
|
|
3049
3732
|
if (share != null) split.style.setProperty(VAR, share.toFixed(2) + '%'); else split.style.removeProperty(VAR);
|
|
3050
3733
|
split.classList.toggle(PRE + '-full', full);
|
|
3051
|
-
document.querySelectorAll(FULL_SEL).forEach(function (b) { b.setAttribute('aria-pressed', String(full)); b.textContent = full ? 'Show list' : 'Full width'; });
|
|
3734
|
+
document.querySelectorAll(FULL_SEL).forEach(function (b) { b.setAttribute('aria-pressed', String(full)); b.textContent = full ? 'Show list' : (o.hideText || 'Full width'); });
|
|
3052
3735
|
aria();
|
|
3053
3736
|
}
|
|
3054
3737
|
// px: the sized column's width asked for; kept inside both columns' minimums for the split's width now.
|
|
@@ -3061,6 +3744,26 @@
|
|
|
3061
3744
|
if (persist) save();
|
|
3062
3745
|
}
|
|
3063
3746
|
function reset() { share = null; paint(); save(); }
|
|
3747
|
+
function dragReport() {
|
|
3748
|
+
if (SPLIT_DRAG.n >= SPLIT_DRAG_MAX || typeof layoutCrumb !== 'function') return;
|
|
3749
|
+
try {
|
|
3750
|
+
var de = document.documentElement, vv = window.visualViewport, t = total(), lw = listWidth(),
|
|
3751
|
+
hw = handle.getBoundingClientRect ? handle.getBoundingClientRect().width : HANDLE_W,
|
|
3752
|
+
rail = document.querySelector('.rail'), list = split.querySelector(LIST_SEL), other = null;
|
|
3753
|
+
// The other column: the split's child that is neither the sized column nor the divider.
|
|
3754
|
+
for (var c = split.firstElementChild; c; c = c.nextElementSibling) if (c !== list && c !== handle) other = c;
|
|
3755
|
+
var vn = function (k) { return vv && typeof vv[k] === 'number' ? vv[k] : -1; };
|
|
3756
|
+
var rr = rail ? rail.getBoundingClientRect() : null, lr = list ? list.getBoundingClientRect() : null, or = other ? other.getBoundingClientRect() : null;
|
|
3757
|
+
// Up and down (pt, the tops) and sideways (pl, ol, sx, the left and right edges): a rail or list that left the
|
|
3758
|
+
// screen to the side shows here too (Q1 20:44).
|
|
3759
|
+
if (layoutCrumb('split-drag', { key: String(KEY).slice(0, 24), t: t, sz: lw, rw: t - lw - hw, share: share == null ? -1 : share,
|
|
3760
|
+
cw: de.clientWidth, scw: de.scrollWidth, pt: vn('pageTop'), pl: vn('pageLeft'), ol: vn('offsetLeft'),
|
|
3761
|
+
sx: typeof window.scrollX === 'number' ? window.scrollX : -1,
|
|
3762
|
+
railTop: rr ? rr.top : -1, railL: rr ? rr.left : -1, railR: rr ? rr.right : -1,
|
|
3763
|
+
listTop: lr ? lr.top : -1, listL: lr ? lr.left : -1, listR: lr ? lr.right : -1,
|
|
3764
|
+
rdL: or ? or.left : -1, rdR: or ? or.right : -1 })) SPLIT_DRAG.n++;
|
|
3765
|
+
} catch (_e) { /* a report never stops a drag */ }
|
|
3766
|
+
}
|
|
3064
3767
|
handle.addEventListener('pointerdown', function (e) {
|
|
3065
3768
|
if (e.pointerType === 'mouse' && e.button !== 0) return;
|
|
3066
3769
|
if (narrow()) return;
|
|
@@ -3081,7 +3784,7 @@
|
|
|
3081
3784
|
handle.removeEventListener('pointermove', move); handle.removeEventListener('pointerup', end); handle.removeEventListener('pointercancel', end);
|
|
3082
3785
|
split.classList.remove(PRE + '-dragging');
|
|
3083
3786
|
// The lift's own position counts: a browser may fold the last moves into it.
|
|
3084
|
-
if (moved) { if (ev.type === 'pointerup') setWidth(start + dx(ev), false); save(); lastTap = 0; return; }
|
|
3787
|
+
if (moved) { if (ev.type === 'pointerup') setWidth(start + dx(ev), false); save(); lastTap = 0; dragReport(); return; }
|
|
3085
3788
|
if (ev.type !== 'pointerup') return;
|
|
3086
3789
|
var now = Date.now();
|
|
3087
3790
|
if (now - lastTap < SX_DOUBLE_MS) { lastTap = 0; reset(); } else lastTap = now;
|
|
@@ -3107,7 +3810,8 @@
|
|
|
3107
3810
|
};
|
|
3108
3811
|
}
|
|
3109
3812
|
// Sessions: the first pane with the divider, unchanged (its markup is in index.html, its CSS in panes/sessions.css).
|
|
3110
|
-
|
|
3813
|
+
// 7.0.23: in Sessions the button says what it does, Hide list (DESIGN_PLAN v2); Memories and Messages keep Full width.
|
|
3814
|
+
var SX_SPLIT_OPTS = { key: SX_SPLIT_KEY, prefix: 'sx', label: 'Session list', fullSel: '[data-sx="full"]', media: '(min-width: 760px)', hideText: 'Hide list',
|
|
3111
3815
|
listMin: SX_LIST_MIN, readerMin: SX_READER_MIN, handleW: SX_HANDLE_W };
|
|
3112
3816
|
function sxSplitKit(split, handle, storage) { return splitKit(split, handle, storage, SX_SPLIT_OPTS); }
|
|
3113
3817
|
// The other panes (W.splitKit): the divider's markup, a Full width button, and mount, which binds the kit to a split
|
|
@@ -3228,7 +3932,7 @@
|
|
|
3228
3932
|
var timer = ctrl && o.timeoutMs ? setTimeout(function () { ctrl.abort(); }, o.timeoutMs) : null;
|
|
3229
3933
|
var headers = { 'X-COS-Web': '1' };
|
|
3230
3934
|
if (o.body !== undefined) headers['Content-Type'] = 'application/json';
|
|
3231
|
-
var sentAt = Date.now();
|
|
3935
|
+
var sentAt = Date.now(), writeDone = typeof writeStart === 'function' ? writeStart(o.method || 'GET') : function () {};
|
|
3232
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 })
|
|
3233
3937
|
.then(function (res) {
|
|
3234
3938
|
sxNoteClock(res, sentAt);
|
|
@@ -3241,7 +3945,7 @@
|
|
|
3241
3945
|
noteServer(false);
|
|
3242
3946
|
throw unreachableError(err);
|
|
3243
3947
|
})
|
|
3244
|
-
.finally(function () { if (timer) clearTimeout(timer); });
|
|
3948
|
+
.finally(function () { if (timer) clearTimeout(timer); writeDone(); });
|
|
3245
3949
|
}
|
|
3246
3950
|
|
|
3247
3951
|
// Per-browser memory for one session: messages held until a reply ends, and the queue keys
|
|
@@ -3290,7 +3994,7 @@
|
|
|
3290
3994
|
// Wide layouts: give the reader the whole width, or bring the list back (sxSplitKit). Hidden under 760 px by CSS.
|
|
3291
3995
|
function sxFullButton() {
|
|
3292
3996
|
var full = !!(SX_SPLIT && SX_SPLIT.state().full);
|
|
3293
|
-
return '<button type="button" class="cx-btn quiet sx-full-btn" data-sx="full" aria-controls="sessions-list-col" aria-pressed="' + full + '">' + (full ? 'Show list' : '
|
|
3997
|
+
return '<button type="button" class="cx-btn quiet sx-full-btn" data-sx="full" aria-controls="sessions-list-col" aria-pressed="' + full + '">' + (full ? 'Show list' : 'Hide list') + '</button>';
|
|
3294
3998
|
}
|
|
3295
3999
|
function openSession(provider, id) {
|
|
3296
4000
|
var reader = $('session-reader');
|
|
@@ -3317,17 +4021,25 @@
|
|
|
3317
4021
|
reader.innerHTML =
|
|
3318
4022
|
'<div class="sx-reader-head"><div class="sx-head-line">' + sxBadge(provider) + '<h2 class="sx-reader-title">' + esc(title) + '</h2>' + sxFullButton() + '</div>' +
|
|
3319
4023
|
'<p class="sx-subtitle">' + esc(sxSubtitle(d, provider, turns, r.more)) + '</p></div>' +
|
|
4024
|
+
// 7.0.23 frame: the conversation scrolls by itself between the header and the dock.
|
|
4025
|
+
'<div class="sx-body" id="session-body">' +
|
|
4026
|
+
(trunc ? '<p class="sx-note">' + esc(trunc) + '</p>' : '') +
|
|
4027
|
+
'<div class="turns sx-turns" id="session-turns">' + renderTurns(turns, d) + '</div>' +
|
|
4028
|
+
'<div class="sx-foot"><button type="button" class="cx-btn quiet" data-sx="copy-session" title="A kickstart brief for another agent, built from the turns COS serves. Not a Claude Code resume: it does not reopen this session.">Copy session</button>' +
|
|
4029
|
+
'<span class="sx-foot-note">Kickstart brief for another agent. Not a Claude Code resume.</span></div>' +
|
|
4030
|
+
'<p class="sx-copy-note hidden" id="session-copy-note" role="status"></p>' +
|
|
4031
|
+
// DESIGN.md step 4 #3: this session's question cards (held and answerable, or at the Mac with its words), the
|
|
4032
|
+
// same component Today's sheet mounts. 7.0.23: at the end of the conversation, the newest thing in it, right
|
|
4033
|
+
// above the reply box: a card is too tall for the dock on the Duo (it hid the choices or the reply box), and
|
|
4034
|
+
// here it opens in view with the reader and scrolls with it.
|
|
4035
|
+
'<div class="sx-questions" id="session-questions"></div>' +
|
|
4036
|
+
'</div>' +
|
|
4037
|
+
// The dock, fixed at the bottom of the reader: what it is doing now (with Stop), the Mac's question, then the
|
|
4038
|
+
// verdict, the reply box, its status and the queue, all as before.
|
|
4039
|
+
'<div class="sx-dock" id="session-dock">' +
|
|
3320
4040
|
'<div class="sx-activity" id="session-live" aria-live="polite"></div>' +
|
|
3321
4041
|
'<div class="sx-mac-q" id="session-mac-q" role="status" hidden></div>' +
|
|
3322
|
-
(trunc ? '<p class="sx-note">' + esc(trunc) + '</p>' : '') +
|
|
3323
|
-
'<div class="turns sx-turns" id="session-turns">' + renderTurns(turns, d) + '</div>' +
|
|
3324
|
-
'<div class="sx-foot"><button type="button" class="cx-btn quiet" data-sx="copy-session" title="A kickstart brief for another agent, built from the turns COS serves. Not a Claude Code resume: it does not reopen this session.">Copy session</button>' +
|
|
3325
|
-
'<span class="sx-foot-note">Kickstart brief for another agent. Not a Claude Code resume.</span></div>' +
|
|
3326
|
-
'<p class="sx-copy-note hidden" id="session-copy-note" role="status"></p>' +
|
|
3327
4042
|
'<div class="sx-chat">' +
|
|
3328
|
-
// DESIGN.md step 4 #3: this session's question cards (held and answerable, or at the Mac with its words), above
|
|
3329
|
-
// the verdict and the Send box, the same component Today's sheet mounts, so the full hold lights up here.
|
|
3330
|
-
'<div class="sx-questions" id="session-questions"></div>' +
|
|
3331
4043
|
'<div id="session-verdict"></div>' +
|
|
3332
4044
|
'<form class="composer att-composer" id="session-form" data-provider="' + esc(provider) + '" data-id="' + esc(id) + '">' +
|
|
3333
4045
|
'<div class="cc-chips att-files hidden" id="session-files"></div>' +
|
|
@@ -3337,13 +4049,18 @@
|
|
|
3337
4049
|
'</form><div class="status-line" id="session-status"></div>' +
|
|
3338
4050
|
'<p class="sx-caution hidden" id="session-caution"></p>' +
|
|
3339
4051
|
'<div id="session-queue"></div>' +
|
|
3340
|
-
'</div>'
|
|
4052
|
+
'</div></div>' +
|
|
4053
|
+
// Q1 (23:40): the question cards end the conversation; scrolled up away from them, a button over the dock's top
|
|
4054
|
+
// edge says one waits. It sits in the reader, not the dock, because the dock scrolls by itself as a last resort.
|
|
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>';
|
|
3341
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);
|
|
3342
4059
|
sxNoteList(r, d);
|
|
3343
4060
|
setLive({});
|
|
3344
4061
|
if (typeof readerMacPaint === 'function') readerMacPaint(r, sxState(d) === 'waiting');
|
|
3345
4062
|
var qk = window.COSWeb.sessionQuestions, qhost = $('session-questions');
|
|
3346
|
-
if (qk && qk.mount && qhost) r.qMount = qk.mount(qhost, id, function () { if (state.reading === r && typeof readerMacPaint === 'function') readerMacPaint(r, r.waitingNow); });
|
|
4063
|
+
if (qk && qk.mount && qhost) r.qMount = qk.mount(qhost, id, function () { if (state.reading === r && typeof readerMacPaint === 'function') readerMacPaint(r, r.waitingNow); if (typeof sxGoQuestionPaint === 'function') sxGoQuestionPaint(); });
|
|
3347
4064
|
paintReaderFiles();
|
|
3348
4065
|
paintWebKeyOffer(r);
|
|
3349
4066
|
// The Mac's answer about files may not be in yet: ask once, then draw the clip.
|
|
@@ -3355,7 +4072,10 @@
|
|
|
3355
4072
|
sessionStatus('Waiting for the current reply. ' + (state.reading.held.length > 1 ? state.reading.held.length + ' messages send' : 'Yours sends') + ' as soon as it finishes. ', null, '<button type="button" class="link-btn" data-action="drop-after">Don’t send</button>');
|
|
3356
4073
|
startHeldTimer(state.reading);
|
|
3357
4074
|
}
|
|
3358
|
-
if (turns.length) requestAnimationFrame(function () { window.scrollTo(0, document.documentElement.scrollHeight); });
|
|
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); });
|
|
4078
|
+
requestAnimationFrame(function () { if (typeof sxFrameCrumb === 'function') sxFrameCrumb('open'); });
|
|
3359
4079
|
watchSession(provider, id);
|
|
3360
4080
|
}).catch(function (err) {
|
|
3361
4081
|
if (state.reading !== r) return;
|
|
@@ -3643,6 +4363,53 @@
|
|
|
3643
4363
|
paintActivity(r);
|
|
3644
4364
|
if (moved) renderVerdict(r);
|
|
3645
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
|
+
}
|
|
3646
4413
|
function refreshTurns(provider, id) {
|
|
3647
4414
|
var reading = state.reading;
|
|
3648
4415
|
api(sessionPath(provider, id, '?turns=' + SX_TURNS + '&cards=1')).then(function (d) {
|
|
@@ -3652,6 +4419,7 @@
|
|
|
3652
4419
|
var box = $('session-turns'); if (box) { sxMergeTurns(box, r.turns || [], turns, d); readerImagesMount(box, r); }
|
|
3653
4420
|
// The header keeps the subtitle it opened with: a rewrap above the turns would move them.
|
|
3654
4421
|
r.detail = d; r.turns = turns; r.more = d.recent_turns_more === true;
|
|
4422
|
+
sxMarkShown(r);
|
|
3655
4423
|
renderHeld(r);
|
|
3656
4424
|
loadQueue(provider, id);
|
|
3657
4425
|
sxProbeAttach(r);
|
|
@@ -3666,6 +4434,22 @@
|
|
|
3666
4434
|
if (p === 2) return '3rd in line';
|
|
3667
4435
|
return (p + 1) + 'th in line';
|
|
3668
4436
|
}
|
|
4437
|
+
// Why a waiting follow-up is held (server 7.0.25, S1: waiting_reason on the row, a short code the drain decided).
|
|
4438
|
+
// Plain words for the codes we know; any other well-formed code reads as a plain wait; a malformed value is not shown.
|
|
4439
|
+
var SX_HOLD_WORDS = {
|
|
4440
|
+
files_wait_for_turn_end: 'It has files, so it waits for the current turn to end.',
|
|
4441
|
+
turn_open: 'Waiting for the current turn to end.', turn_not_ended: 'Waiting for the current turn to end.',
|
|
4442
|
+
native_thread_working: 'Waiting for the current turn to end.',
|
|
4443
|
+
live_desktop_process: 'Waiting while another app has this session open.',
|
|
4444
|
+
native_target_busy: 'Waiting: another COS turn holds this session.',
|
|
4445
|
+
native_target_fenced: 'Waiting for this session to be released.',
|
|
4446
|
+
cancel_hold: 'Held for 2 minutes after a stop.'
|
|
4447
|
+
};
|
|
4448
|
+
function sxQueueWhy(t) {
|
|
4449
|
+
var c = t && t.waiting_reason;
|
|
4450
|
+
if (typeof c !== 'string' || !/^[a-z0-9_]{1,48}$/.test(c)) return '';
|
|
4451
|
+
return Object.prototype.hasOwnProperty.call(SX_HOLD_WORDS, c) ? SX_HOLD_WORDS[c] : 'Waiting for this session to be free.';
|
|
4452
|
+
}
|
|
3669
4453
|
function loadQueue(provider, id) {
|
|
3670
4454
|
apiAnswer(sessionPath(provider, id, '/queued-turns')).then(function (q) {
|
|
3671
4455
|
var r = state.reading;
|
|
@@ -3681,7 +4465,9 @@
|
|
|
3681
4465
|
box.innerHTML = '<div class="sx-queue">' + (waiting.length ? '<p class="sx-queue-head">' + (waiting.length === 1 ? '1 follow-up queued. It sends when this session is available.' : waiting.length + ' follow-ups queued. They send in order when this session is available.') + '</p>' : '') +
|
|
3682
4466
|
waiting.map(function (t) {
|
|
3683
4467
|
var mine = t.owner !== 'codex' && parkedByMe(r, t.clientTurnId);
|
|
3684
|
-
|
|
4468
|
+
var why = sxQueueWhy(t);
|
|
4469
|
+
return '<div class="sx-queue-row"><span class="sx-queue-state">' + esc(sxQueueLine(t)) + '</span><span class="sx-queue-text">' + esc(t.preview || t.prompt || 'Message') +
|
|
4470
|
+
(why ? '<span class="sx-queue-reason">' + esc(why) + '</span>' : '') + '</span>' +
|
|
3685
4471
|
(mine ? '<button type="button" class="cx-btn quiet" data-action="unqueue" data-turn-key="' + esc(t.clientTurnId) + '" title="Cancel this queued message. It will not be sent.">Cancel</button>' : '') + '</div>';
|
|
3686
4472
|
}).join('') +
|
|
3687
4473
|
notSent.map(function (t) {
|
|
@@ -3757,9 +4543,10 @@
|
|
|
3757
4543
|
} else if (r.refusal) {
|
|
3758
4544
|
var f = r.refusal;
|
|
3759
4545
|
html = refusalBox(f.copy, f.supplement,
|
|
3760
|
-
|
|
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>' : '') +
|
|
3761
4548
|
(f.retry ? '<button type="button" class="cx-btn quiet" data-sx="retry">Retry</button>' : '') +
|
|
3762
|
-
(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) : ''));
|
|
3763
4550
|
} else if (v && v.attachable === false && /^(native_thread_working|native_target_busy)$/.test(v.reason)) {
|
|
3764
4551
|
// 6.99.29: "Working right now" only while the one state says so.
|
|
3765
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>';
|
|
@@ -3794,18 +4581,51 @@
|
|
|
3794
4581
|
if (!r.draft || r.draft.text !== text || (r.draft.ids || []).join(',') !== files.join(',')) r.draft = { text: text, key: newTurnKey(), ids: files };
|
|
3795
4582
|
return r.draft;
|
|
3796
4583
|
}
|
|
3797
|
-
//
|
|
3798
|
-
|
|
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;
|
|
3799
4614
|
var input = $('session-input');
|
|
3800
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.
|
|
3801
4616
|
if (input && (input.value.trim() === String(text).trim() || input.value.trim() === turnEndBare(text))) { input.value = ''; autoGrow(input); }
|
|
3802
4617
|
if (state.reading && state.reading.draft && state.reading.draft.text === text) state.reading.draft = null;
|
|
3803
4618
|
}
|
|
3804
4619
|
// Words go back in the box only if the box is empty; otherwise the status line keeps them. `files` says what became
|
|
3805
|
-
// of the message's files (heldFilesBack).
|
|
3806
|
-
|
|
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
|
+
}
|
|
3807
4627
|
var input = $('session-input');
|
|
3808
|
-
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'); }
|
|
3809
4629
|
else sessionStatus(why + ' Not sent: “' + String(text).slice(0, 80) + (text.length > 80 ? '…' : '') + '”' + (files || ''), 'error');
|
|
3810
4630
|
}
|
|
3811
4631
|
// Your words show in the conversation the moment the Mac accepts them; the next refresh of
|
|
@@ -3818,7 +4638,7 @@
|
|
|
3818
4638
|
if (heldKey) el.setAttribute('data-held-key', heldKey);
|
|
3819
4639
|
el.innerHTML = '<span class="who">You, ' + esc(note) + '</span>' + md(text);
|
|
3820
4640
|
box.appendChild(el);
|
|
3821
|
-
requestAnimationFrame(function () { window.scrollTo(0, document.documentElement.scrollHeight); });
|
|
4641
|
+
requestAnimationFrame(function () { if (typeof sxToNewest === 'function') sxToNewest(); else window.scrollTo(0, document.documentElement.scrollHeight); });
|
|
3822
4642
|
}
|
|
3823
4643
|
function heldBubble(key) { return document.querySelector('#session-turns .turn.pending[data-held-key="' + key.replace(/[^A-Za-z0-9_-]/g, '') + '"]'); }
|
|
3824
4644
|
function renderHeld(r) {
|
|
@@ -3832,7 +4652,7 @@
|
|
|
3832
4652
|
var READER_VIEW = {
|
|
3833
4653
|
status: function (r, text, kind, actionHtml) { sessionStatus(text, kind, actionHtml); },
|
|
3834
4654
|
pending: function (r, text, note) { showPendingTurn(text, note); },
|
|
3835
|
-
clear: function (r, text) { clearBoxOf(text); },
|
|
4655
|
+
clear: function (r, text) { clearBoxOf(text, r); },
|
|
3836
4656
|
verdict: function (r) { renderVerdict(r); },
|
|
3837
4657
|
hold: function (r, draft) { holdUntilReplyEnds(r, draft); },
|
|
3838
4658
|
watch: function (r, bindingId, key) { watchTurn(bindingId, key, r.provider, r.id); },
|
|
@@ -3947,7 +4767,11 @@
|
|
|
3947
4767
|
if (r.refusal || r.cautionAsk) { r.refusal = null; r.cautionAsk = false; renderVerdict(r); }
|
|
3948
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
|
|
3949
4769
|
// same attribution as a choice. A fork goes to a copy and is not an answer, so it is sent as written.
|
|
3950
|
-
|
|
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) });
|
|
3951
4775
|
r.sending = true;
|
|
3952
4776
|
var btns = form.querySelectorAll('button'); btns.forEach(function (b) { b.disabled = true; });
|
|
3953
4777
|
var done = function () { r.sending = false; btns.forEach(function (b) { b.disabled = false; }); paintReaderFiles(); };
|
|
@@ -4028,14 +4852,25 @@
|
|
|
4028
4852
|
// it under the same key and sends it the moment the reply ends.
|
|
4029
4853
|
if (reason === 'native_turn_in_progress') { if (!opts.held) view.hold(r, draft); return 'held'; }
|
|
4030
4854
|
if (reason === 'native_thread_changed' && b.revision) {
|
|
4031
|
-
|
|
4032
|
-
|
|
4033
|
-
|
|
4034
|
-
|
|
4035
|
-
|
|
4036
|
-
|
|
4037
|
-
|
|
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
|
+
});
|
|
4038
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.');
|
|
4039
4874
|
return 'changed';
|
|
4040
4875
|
}
|
|
4041
4876
|
if (/^(stale_epoch|binding_expired|binding_not_active|binding_unusable|unknown_binding)$/.test(reason) && !opts.reattached) {
|
|
@@ -4256,7 +5091,7 @@
|
|
|
4256
5091
|
showPendingTurn(draft.text, 'sends after this reply', draft.key);
|
|
4257
5092
|
saveHeld(r);
|
|
4258
5093
|
}
|
|
4259
|
-
clearBoxOf(draft.text);
|
|
5094
|
+
clearBoxOf(draft.text, r);
|
|
4260
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>');
|
|
4261
5096
|
startHeldTimer(r);
|
|
4262
5097
|
}
|
|
@@ -4289,7 +5124,7 @@
|
|
|
4289
5124
|
} else {
|
|
4290
5125
|
if (bubble) bubble.remove();
|
|
4291
5126
|
// The whole message comes back: its words, and its files where the Mac still keeps them.
|
|
4292
|
-
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);
|
|
4293
5128
|
}
|
|
4294
5129
|
if (!r.held.some(function (x) { return !x.cancelled; })) clearInterval(r.heldTimer);
|
|
4295
5130
|
}
|
|
@@ -4361,7 +5196,7 @@
|
|
|
4361
5196
|
sessionStatus('Copying the session. This can take up to a minute.');
|
|
4362
5197
|
var settle = function (b) {
|
|
4363
5198
|
if (b.forked === true) {
|
|
4364
|
-
r.forkDraft = null; clearBoxOf(text); readerFilesSent(readerFilesOn() ? readerBox(r) : null, ids);
|
|
5199
|
+
r.forkDraft = null; clearBoxOf(text, r); readerFilesSent(readerFilesOn() ? readerBox(r) : null, ids);
|
|
4365
5200
|
sessionStatus('Forked. The copy has your message and shows in Sessions in a moment.');
|
|
4366
5201
|
setTimeout(function () { if (state.pane === 'sessions') loadSessions(true); }, 4000);
|
|
4367
5202
|
return true;
|
|
@@ -4671,15 +5506,23 @@
|
|
|
4671
5506
|
box.insertAdjacentHTML('beforeend', fresh.map(sxTurnHtml).join(''));
|
|
4672
5507
|
return;
|
|
4673
5508
|
}
|
|
5509
|
+
// In the frame the conversation scrolls inside #session-body: the card kept is the first one showing in it, and the
|
|
5510
|
+
// body (not the window) moves back by what the redraw moved it.
|
|
5511
|
+
var body = document.documentElement.classList.contains('sx-frame') ? $('session-body') : null;
|
|
5512
|
+
var edge = body && body.getBoundingClientRect ? body.getBoundingClientRect().top : 0;
|
|
4674
5513
|
var anchor = null, top = 0, cards = box.querySelectorAll('.turn[data-sig]');
|
|
4675
5514
|
for (var i = 0; i < cards.length; i++) {
|
|
4676
5515
|
var rect = cards[i].getBoundingClientRect();
|
|
4677
|
-
if (rect.bottom >
|
|
5516
|
+
if (rect.bottom > edge) { anchor = cards[i].getAttribute('data-sig'); top = rect.top; break; }
|
|
4678
5517
|
}
|
|
4679
5518
|
box.innerHTML = renderTurns(next, d);
|
|
5519
|
+
// Still at the newest turn since it opened (sxToNewest's pin): stay there, rather than keep a card in place.
|
|
5520
|
+
if (body && typeof SX_PIN === 'object' && SX_PIN.body === body) { body.scrollTop = body.scrollHeight; SX_PIN.put = body.scrollTop; return; }
|
|
4680
5521
|
if (!anchor) return;
|
|
4681
5522
|
var same = [].filter.call(box.querySelectorAll('.turn[data-sig]'), function (el) { return el.getAttribute('data-sig') === anchor; })[0];
|
|
4682
|
-
if (same)
|
|
5523
|
+
if (!same) return;
|
|
5524
|
+
if (body) body.scrollTop += same.getBoundingClientRect().top - top;
|
|
5525
|
+
else window.scrollBy(0, same.getBoundingClientRect().top - top);
|
|
4683
5526
|
}
|
|
4684
5527
|
|
|
4685
5528
|
// ── Ask COS (the Ask sheet's first row) ─────────────────
|
|
@@ -6523,10 +7366,11 @@
|
|
|
6523
7366
|
|
|
6524
7367
|
// ── events (one delegated listener) ─────────────────────
|
|
6525
7368
|
document.addEventListener('click', function (e) {
|
|
6526
|
-
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');
|
|
6527
7370
|
if (!t) { if (e.target.id === 'ask-sheet') closeAsk(); if (e.target.id === 'section-menu') closeMenu(); return; }
|
|
6528
7371
|
var inSheet = !!t.closest('#ask-sheet');
|
|
6529
7372
|
if (t.id === 'topbar-back') return goBack();
|
|
7373
|
+
if (t.id === 'rail-back') return goBack();
|
|
6530
7374
|
if (t.id === 'section-btn') return openMenu();
|
|
6531
7375
|
if (t.id === 'auth-retry') { t.disabled = true; t.textContent = 'Checking'; bootStatus().then(function () { t.disabled = false; t.textContent = 'Try again'; }); return; }
|
|
6532
7376
|
if (t.id === 'auth-switch') {
|
|
@@ -6671,6 +7515,7 @@
|
|
|
6671
7515
|
});
|
|
6672
7516
|
document.addEventListener('input', function (e) {
|
|
6673
7517
|
if (e.target.tagName === 'TEXTAREA') autoGrow(e.target);
|
|
7518
|
+
if (e.target.id === 'session-input' && typeof unsentSave === 'function') unsentSave();
|
|
6674
7519
|
if (e.target.id === 'ask-q') renderAsk();
|
|
6675
7520
|
if (e.target.hasAttribute && e.target.hasAttribute('data-next-prep-steer')) nextPrepInput(e.target);
|
|
6676
7521
|
if (e.target.id === 'sessions-q' && !e.target.value.trim()) renderSessionGroups(state.sessions);
|
|
@@ -6784,6 +7629,15 @@
|
|
|
6784
7629
|
// fixed composer clears to sit above the keyboard. 0 when the web view itself shrank.
|
|
6785
7630
|
var VP = { layoutH: 0 };
|
|
6786
7631
|
VP.maxH = 0; VP.w = -1; VP.free = 0; VP.typing = false; VP.blurAt = 0;
|
|
7632
|
+
// 7.0.23 (lead 23:02, from Miles's phone rows 11/10): on the unfolded Duo, after a keyboard closed, visualViewport
|
|
7633
|
+
// reported a height of 0 (with an offset anywhere from 0 to 787) for minutes at a time, while innerHeight was back at
|
|
7634
|
+
// 787 within 0.3 s. A visible height under VV_MIN_PX is never a real reading, whatever causes it: it is treated as no
|
|
7635
|
+
// reading, the box is innerHeight from offset 0, and it is counted (VP.badN runs of it, VP.badReads reads), which every
|
|
7636
|
+
// layout report carries as vz and vzr. Why 80: the smallest real visible height in his 99 rows that day is 359.98
|
|
7637
|
+
// (folded, keyboard up), a phone on its side with the keyboard up still shows more than 80, and under 80 nothing fits
|
|
7638
|
+
// a reply box and a header anyway. Without a keyboard innerHeight is the visible height, so the fallback costs nothing.
|
|
7639
|
+
var VV_MIN_PX = 80;
|
|
7640
|
+
VP.bad = false; VP.badN = 0; VP.badReads = 0;
|
|
6787
7641
|
var VP_SLACK_PX = 20, VP_SETTLE_MS = 1000;
|
|
6788
7642
|
function vpTyping() {
|
|
6789
7643
|
var a = document.activeElement;
|
|
@@ -6819,7 +7673,13 @@
|
|
|
6819
7673
|
}
|
|
6820
7674
|
else if (!VP.layoutH) VP.layoutH = Math.max(inner, (de && de.clientHeight) || 0);
|
|
6821
7675
|
var H = VP.layoutH;
|
|
6822
|
-
if (!vv
|
|
7676
|
+
if (!vv) return { layoutH: H, top: 0, height: H, inset: 0, below: 0, dock: 0, scale: 1, zoomed: false, vv: false };
|
|
7677
|
+
if (!(vv.height >= VV_MIN_PX)) {
|
|
7678
|
+
VP.badReads++;
|
|
7679
|
+
if (!VP.bad) { VP.bad = true; VP.badN++; }
|
|
7680
|
+
return { layoutH: H, top: 0, height: Math.round(inner) || H, inset: 0, below: 0, dock: 0, scale: 1, zoomed: false, vv: false, bad: true };
|
|
7681
|
+
}
|
|
7682
|
+
VP.bad = false;
|
|
6823
7683
|
var inset = Math.max(0, Math.round(H - vv.height * scale));
|
|
6824
7684
|
var top, height;
|
|
6825
7685
|
if (!zoomed) { top = Math.max(0, Math.round(vv.offsetTop || 0)); height = Math.min(Math.round(vv.height), H); }
|
|
@@ -6862,7 +7722,8 @@
|
|
|
6862
7722
|
var o = (screen && screen.orientation && screen.orientation.type) || (typeof window.orientation === 'number' ? 'deg' + window.orientation : '');
|
|
6863
7723
|
var data = { what: String(event || ''), ua: ua.brand, os: ua.os, iw: r(window.innerWidth), ih: r(window.innerHeight), ch: r(de && de.clientHeight),
|
|
6864
7724
|
vvh: vv ? r(vv.height) : null, vvw: vv ? r(vv.width) : null, vvt: vv ? r(vv.offsetTop) : null, vvs: vv ? r(vv.scale) : null,
|
|
6865
|
-
orient: String(o).slice(0, 24), coarse: !!(window.matchMedia && window.matchMedia('(pointer: coarse)').matches)
|
|
7725
|
+
orient: String(o).slice(0, 24), coarse: !!(window.matchMedia && window.matchMedia('(pointer: coarse)').matches),
|
|
7726
|
+
vz: VP.badN, vzr: VP.badReads };
|
|
6866
7727
|
var x = extra || {};
|
|
6867
7728
|
Object.keys(x).forEach(function (k) { var v = x[k]; if (/^[a-z][a-zA-Z0-9]{0,15}$/.test(k) && (typeof v === 'number' || typeof v === 'boolean' || (typeof v === 'string' && v.length <= 24))) data[k] = typeof v === 'number' ? r(v) : v; });
|
|
6868
7729
|
var key = JSON.stringify(data), now = Date.now();
|
|
@@ -6878,6 +7739,47 @@
|
|
|
6878
7739
|
} catch (_e) { return false; }
|
|
6879
7740
|
}
|
|
6880
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
|
+
|
|
6881
7783
|
// ── the shared kit for pane modules (panes/*.js) ───────
|
|
6882
7784
|
window.COSWeb = {
|
|
6883
7785
|
$: $, esc: esc, md: md, plain: plain, structureLensAnswer: structureLensAnswer, tableFit: tableFit, api: api, toast: toast, ago: ago, clock: clock, toDate: toDate,
|
|
@@ -7013,13 +7915,44 @@
|
|
|
7013
7915
|
return typeof v === 'number' && isFinite(v) ? v : null;
|
|
7014
7916
|
}
|
|
7015
7917
|
function lrMatch(q) { try { return !!window.matchMedia(q).matches; } catch (_e) { return false; } }
|
|
7918
|
+
// The page wider than the screen (the Duo, 11/10: scale 0.85, scroll width 449 for a 382 screen). Two causes look the
|
|
7919
|
+
// same from outside; these numbers narrow it: cw, the layout viewport; dw, the width this device shows at scale 1;
|
|
7920
|
+
// scw, the document's scroll width. cw = scw, both above dw, says only that the layout viewport itself is wider: a
|
|
7921
|
+
// browser or phone setting, or iOS widening the layout to fit content (Q1 15:27); nothing is named or scanned then, so
|
|
7922
|
+
// '' is not proof of a setting. Our page overflowing a layout that stayed at the device width shows as scw above cw
|
|
7923
|
+
// (the Duo's 11/10 rows: scroll width 449 while the width was 382 at scale 1): then `wide` names the
|
|
7924
|
+
// element that sticks out, the first in page order whose right edge passes cw. Page order puts a parent before its
|
|
7925
|
+
// children, so that element's parent ends within cw: it is the one that sticks out of its container, never a
|
|
7926
|
+
// stretched container (those end at cw; Q1 15:20). One scan per layout (cached by cw and scw), at most 4000 elements.
|
|
7927
|
+
var LR_WIDE = { key: '', out: null };
|
|
7928
|
+
function lrWide() {
|
|
7929
|
+
var num = function (v) { return typeof v === 'number' && isFinite(v) ? Math.round(v) : null; };
|
|
7930
|
+
var de = document.documentElement, vv = window.visualViewport, out = { cw: num(de && de.clientWidth), dw: null, scw: null, wide: '' };
|
|
7931
|
+
try {
|
|
7932
|
+
var se = document.scrollingElement || de;
|
|
7933
|
+
out.scw = num(se && se.scrollWidth);
|
|
7934
|
+
out.dw = vv ? num(vv.width * vv.scale) : num(window.innerWidth);
|
|
7935
|
+
if (!(out.cw && out.scw && out.scw > out.cw + 1)) return out;
|
|
7936
|
+
var key = out.cw + 'x' + out.scw;
|
|
7937
|
+
if (LR_WIDE.key === key && LR_WIDE.out) { out.wide = LR_WIDE.out.wide; return out; }
|
|
7938
|
+
var all = document.body ? document.body.getElementsByTagName('*') : [], edge = out.cw + 1, sx = window.scrollX || 0;
|
|
7939
|
+
for (var i = 0; i < all.length && i < 4000; i++) {
|
|
7940
|
+
var el = all[i], r = el.getBoundingClientRect();
|
|
7941
|
+
if (!(r.width > 0 && r.right + sx > edge)) continue;
|
|
7942
|
+
out.wide = (el.tagName.toLowerCase() + (el.id ? '#' + el.id : (typeof el.className === 'string' && el.className.trim() ? '.' + el.className.trim().split(/\s+/)[0] : ''))).slice(0, 24);
|
|
7943
|
+
break;
|
|
7944
|
+
}
|
|
7945
|
+
LR_WIDE.key = key; LR_WIDE.out = { wide: out.wide };
|
|
7946
|
+
} catch (_e) { /* a report only */ }
|
|
7947
|
+
return out;
|
|
7948
|
+
}
|
|
7016
7949
|
function lrDevice() {
|
|
7017
7950
|
if (LR.device) return;
|
|
7018
7951
|
LR.device = true;
|
|
7019
|
-
var sc = window.screen || {};
|
|
7952
|
+
var sc = window.screen || {}, w = lrWide();
|
|
7020
7953
|
layoutCrumb('device', { sw: sc.width, sh: sc.height, sah: sc.availHeight, oh: window.outerHeight, ow: window.outerWidth, dpr: window.devicePixelRatio,
|
|
7021
7954
|
sat: lrProbe('padding-top:env(safe-area-inset-top)', 'paddingTop'), sab: lrProbe('padding-bottom:env(safe-area-inset-bottom)', 'paddingBottom'),
|
|
7022
|
-
anyCoarse: lrMatch('(any-pointer: coarse)'), shortTouch: lrMatch(SHORT_TOUCH_MQ), phone: isPhone() });
|
|
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 });
|
|
7023
7956
|
}
|
|
7024
7957
|
// The focus line is for touch screens only (QA W3): on a Mac it would only spend the budget.
|
|
7025
7958
|
function lrFocus() {
|
|
@@ -7029,7 +7962,9 @@
|
|
|
7029
7962
|
if (!lrMatch('(pointer: coarse)')) return;
|
|
7030
7963
|
try { fs = parseFloat(getComputedStyle(a).fontSize); } catch (_e) { fs = null; }
|
|
7031
7964
|
var s = lrScroll();
|
|
7032
|
-
|
|
7965
|
+
// The width numbers only when this focus line will be sent (its own budget), so a capped page never scans.
|
|
7966
|
+
var w = typeof crumbs === 'object' && crumbs && crumbs.focus >= LAYOUT_FOCUS_MAX ? { cw: null, dw: null, wide: '' } : lrWide();
|
|
7967
|
+
layoutCrumb('focus', { pane: lrPane(), field: lrKind(a), fs: fs, sy: s.sy, smax: s.smax, scw: s.sw, kb: LR.up, n: ++LR.focusN, cw: w.cw, dw: w.dw, wide: w.wide });
|
|
7033
7968
|
}
|
|
7034
7969
|
function lrHooks(what, v) { LR.hooks.forEach(function (fn) { try { fn(what, v); } catch (err) { console.error(err); } }); }
|
|
7035
7970
|
function lrGap(v) {
|
|
@@ -7112,6 +8047,14 @@
|
|
|
7112
8047
|
|
|
7113
8048
|
// Every pane module is a plain script after this one; boot waits for all of them.
|
|
7114
8049
|
function boot() {
|
|
8050
|
+
if (typeof railSync === 'function') railSync(); // 7.0.29: before the shell shows, so the rail never moves the page
|
|
8051
|
+
// The open-time report's clock (ms since navigation start, -1 where there is none) and the two numbers boot gives it:
|
|
8052
|
+
// when boot started (every script had run) and the sign-in check's round trip. Kept inside boot, and FIRST_PAINT
|
|
8053
|
+
// looked up, never assumed, because the harnesses that run boot alone have neither.
|
|
8054
|
+
var openClock = function () { try { return typeof performance === 'object' && performance && performance.now ? performance.now() : -1; } catch (_e) { return -1; } };
|
|
8055
|
+
var openTimes = typeof FIRST_PAINT === 'object' && FIRST_PAINT ? FIRST_PAINT : null;
|
|
8056
|
+
var openRtt = function (asked) { if (openTimes && asked >= 0 && openTimes.rtt == null) openTimes.rtt = Math.round(openClock() - asked); };
|
|
8057
|
+
if (openTimes && openTimes.boot == null) openTimes.boot = Math.round(openClock());
|
|
7115
8058
|
var params = new URLSearchParams(location.search);
|
|
7116
8059
|
var linkCode = params.get('code');
|
|
7117
8060
|
if (linkCode && !params.get('handoff')) {
|
|
@@ -7134,7 +8077,9 @@
|
|
|
7134
8077
|
return;
|
|
7135
8078
|
}
|
|
7136
8079
|
// The status read stays here in boot() itself (routes/web-phone-link.test.ts runs boot alone and pins it).
|
|
7137
|
-
|
|
8080
|
+
// The open-time report carries its round trip (firstPaintDone).
|
|
8081
|
+
var asked = openClock();
|
|
8082
|
+
api('/api/web-session/status').then(function (st) { openRtt(asked); return bootAnswer(st); }, function (err) { openRtt(asked); return bootFail(err); });
|
|
7138
8083
|
}
|
|
7139
8084
|
// S1 item 7 (lead go, 20:11): a link or code the server refused may be old while this browser is still signed in (an
|
|
7140
8085
|
// old QR, a reopened tab). Ask status first: signed in opens the app and says the link was old; signed out shows the
|
|
@@ -7151,20 +8096,27 @@
|
|
|
7151
8096
|
return api('/api/web-session/status').then(function (st) { return bootAnswer(st); }, function (err) { return bootFail(err); });
|
|
7152
8097
|
}
|
|
7153
8098
|
function bootAnswer(st) {
|
|
8099
|
+
AUTH_RETRY.n = 0; // the Mac answered: a later not-answering card starts its quick checks again
|
|
7154
8100
|
if (st && st.authenticated) return showShell(st);
|
|
7155
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).
|
|
7156
8102
|
showAuth(typeof signinKnown === 'function' && signinKnown() ? signinLapsedCopy() : undefined);
|
|
7157
8103
|
}
|
|
7158
8104
|
function bootFail(err) {
|
|
7159
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.
|
|
7160
|
-
|
|
7161
|
-
|
|
7162
|
-
|
|
7163
|
-
|
|
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 };
|
|
7164
8114
|
function authRetrySchedule(on) {
|
|
7165
8115
|
if (AUTH_RETRY.timer) { clearTimeout(AUTH_RETRY.timer); AUTH_RETRY.timer = null; }
|
|
7166
8116
|
if (!on || document.hidden) return;
|
|
7167
|
-
|
|
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);
|
|
7168
8120
|
}
|
|
7169
8121
|
document.addEventListener('visibilitychange', function () {
|
|
7170
8122
|
var box = $('auth-offline');
|
|
@@ -7182,10 +8134,15 @@
|
|
|
7182
8134
|
if (title) title.textContent = on ? (offline ? 'This device is offline' : 'COS on your Mac is not answering') : 'Connect this browser';
|
|
7183
8135
|
if (intro) intro.classList.toggle('hidden', !!on);
|
|
7184
8136
|
var text = $('auth-offline-text');
|
|
7185
|
-
if (text && on) text.textContent = offline ? 'Connect to the internet and Tailscale. This page checks again every 30 seconds.'
|
|
7186
|
-
: '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.';
|
|
7187
8139
|
authRetrySchedule(!!on);
|
|
7188
8140
|
if (on) { var err = $('auth-error'); if (err) { setStatus(err, '', true); err.classList.add('hidden'); } }
|
|
7189
8141
|
}
|
|
7190
|
-
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
|
|
8142
|
+
if (document.readyState === 'loading' && !cosBootAfter()) document.addEventListener('DOMContentLoaded', boot);
|
|
8143
|
+
else if (cosBootAfter()) cosBootAfter()(boot);
|
|
8144
|
+
else boot();
|
|
8145
|
+
// The scripts come from theme.js's loader (index.html #boot-scripts), which runs boot once every one of them has run
|
|
8146
|
+
// (window.CosBoot.after). Without it (a page from before the loader), as before.
|
|
8147
|
+
function cosBootAfter() { return window.CosBoot && typeof window.CosBoot.after === 'function' ? window.CosBoot.after : null; }
|
|
7191
8148
|
})();
|