@gotcos/glasses-server 7.0.18 → 7.0.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +135 -0
- package/package.json +1 -1
- package/server/index.ts +3 -0
- 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/thread-turn-queue.ts +24 -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/web-control/.manifest.json +7 -7
- package/server/web-control/app.css +9 -0
- package/server/web-control/app.js +732 -56
- package/server/web-control/index.html +40 -16
- 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 +33 -0
|
@@ -555,6 +555,7 @@
|
|
|
555
555
|
if (r.pane !== 'meetings' && state.livePoll) { clearInterval(state.livePoll); state.livePoll = null; }
|
|
556
556
|
|
|
557
557
|
var reading = r.args.length > 0 && !isTabRoute(r.args);
|
|
558
|
+
if (typeof sxFrameSync === 'function') sxFrameSync();
|
|
558
559
|
var paneEl = $('pane-' + r.pane);
|
|
559
560
|
if (paneEl) paneEl.classList.toggle('reading', reading);
|
|
560
561
|
var split = paneEl && paneEl.querySelector('.split');
|
|
@@ -627,7 +628,28 @@
|
|
|
627
628
|
function closeMenu() { $('section-menu').classList.add('hidden'); }
|
|
628
629
|
|
|
629
630
|
// ── auth ────────────────────────────────────────────────
|
|
631
|
+
// 7.0.19: the first paint (index.html #first-paint) ends the moment the sign-in card or the app shows, whichever
|
|
632
|
+
// comes first, once. One layout report says how long it took, in the phone's own numbers: ms from navigation start to
|
|
633
|
+
// the browser's first contentful paint (-1 when none was recorded before this point) and to this moment.
|
|
634
|
+
var FIRST_PAINT = { done: false };
|
|
635
|
+
function firstPaintDone(via) {
|
|
636
|
+
if (FIRST_PAINT.done) return false;
|
|
637
|
+
FIRST_PAINT.done = true;
|
|
638
|
+
document.documentElement.classList.remove('fp-wait');
|
|
639
|
+
try {
|
|
640
|
+
var perf = window.performance, fcp = perf && perf.getEntriesByName ? perf.getEntriesByName('first-contentful-paint')[0] : null;
|
|
641
|
+
var shown = perf && perf.now ? Math.round(perf.now()) : -1;
|
|
642
|
+
// dcl: the browser's DOMContentLoaded; boot: when every script had run and boot started; rtt: the sign-in check's
|
|
643
|
+
// round trip (-1 for a sign-in link, which checks with a login instead).
|
|
644
|
+
var nav = perf && perf.getEntriesByType ? perf.getEntriesByType('navigation')[0] : null;
|
|
645
|
+
var dcl = nav && typeof nav.domContentLoadedEventStart === 'number' ? Math.round(nav.domContentLoadedEventStart) : -1;
|
|
646
|
+
if (typeof layoutCrumb === 'function') layoutCrumb('open-time', { fcp: fcp ? Math.round(fcp.startTime) : -1, shown: shown, via: via,
|
|
647
|
+
dcl: dcl, boot: FIRST_PAINT.boot == null ? -1 : FIRST_PAINT.boot, rtt: FIRST_PAINT.rtt == null ? -1 : FIRST_PAINT.rtt });
|
|
648
|
+
} catch (_e) { /* timing is a report only */ }
|
|
649
|
+
return true;
|
|
650
|
+
}
|
|
630
651
|
function showAuth(message) {
|
|
652
|
+
if (typeof firstPaintDone === 'function') firstPaintDone('auth'); // harnesses that run this alone have none
|
|
631
653
|
readerImagesStop($('session-turns')); readerImagesStop($('ta-last'));
|
|
632
654
|
if (typeof liveHide === 'function') { liveHide(); liveStopWatch(); LIVE.mode = 'idle'; }
|
|
633
655
|
var alreadyOpen = !$('auth-gate').classList.contains('hidden');
|
|
@@ -635,6 +657,10 @@
|
|
|
635
657
|
if (typeof authOffline === 'function') authOffline(false);
|
|
636
658
|
$('app-shell').classList.add('hidden');
|
|
637
659
|
$('auth-gate').classList.remove('hidden');
|
|
660
|
+
// 7.0.23 regression (lapse_check, 08:2x): a frame class left on keeps html and body at one screen with overflow
|
|
661
|
+
// hidden, so the gate could not scroll and Connect sat below a keyboard-height screen. The gate is a normal page;
|
|
662
|
+
// showShell's route() puts the frame back (sxFrameSync) when the shell returns.
|
|
663
|
+
document.documentElement.classList.remove('sx-frame', 'mx-frame');
|
|
638
664
|
// A later 401 from a pane still polling (W.api passes no message) must not blank what the gate already says:
|
|
639
665
|
// the reason it opened, or "That code did not match" while the person retypes.
|
|
640
666
|
if (!message && alreadyOpen) return;
|
|
@@ -643,6 +669,7 @@
|
|
|
643
669
|
}
|
|
644
670
|
function signedInOnce() { var shell = typeof $ === 'function' ? $('app-shell') : null; return !!(shell && !shell.classList.contains('hidden')) || CONN.lapsed; }
|
|
645
671
|
function showShell(info) {
|
|
672
|
+
if (typeof firstPaintDone === 'function') firstPaintDone('shell'); // harnesses that run this alone have none
|
|
646
673
|
if (typeof signinRemember === 'function') signinRemember(info);
|
|
647
674
|
if (typeof authOffline === 'function') authOffline(false);
|
|
648
675
|
$('auth-gate').classList.add('hidden');
|
|
@@ -681,7 +708,9 @@
|
|
|
681
708
|
function sessionList(maxAgeMs) {
|
|
682
709
|
if (SL.data && Date.now() - SL.at < (maxAgeMs || 0)) return Promise.resolve(SL.data);
|
|
683
710
|
if (SL.inflight) return SL.inflight;
|
|
684
|
-
|
|
711
|
+
// kept=1 (server 7.0.25, lead 10:29): only a client that asks gets the kept rows (COS pins, folders, COS-started
|
|
712
|
+
// sessions past the cap); the Mac app and the lens, which do not ask, get 7.0.18's list unchanged.
|
|
713
|
+
SL.inflight = api('/api/agent-sessions?limit=80&sort=updated&kept=1').then(function (d) {
|
|
685
714
|
// 7.0.5: a sessions read (Sessions pane or Today) that follows a failed one is a reconnect: Today's next summary
|
|
686
715
|
// ask says so (requestBrief), once.
|
|
687
716
|
if (SL.failed) { SL.failed = false; if (typeof BRIEF === 'object' && BRIEF) BRIEF.reconnect = true; }
|
|
@@ -1732,14 +1761,17 @@
|
|
|
1732
1761
|
if (!list) return;
|
|
1733
1762
|
if (sxQueryActive()) { paintSessionsHero(); return; }
|
|
1734
1763
|
if (structural) { renderSessionGroups(); return; }
|
|
1735
|
-
var amb = sxAmbiguous(sxVisible());
|
|
1764
|
+
var amb = sxAmbiguous(sxVisible()), moved = false;
|
|
1736
1765
|
(rows || []).forEach(function (s) {
|
|
1737
1766
|
if (!s || sxKeepWarm(s)) return;
|
|
1767
|
+
// Checked before the row is looked for: a row in a folded group is not drawn, and its move still counts.
|
|
1768
|
+
var live = sxLiveRow(s, amb);
|
|
1769
|
+
if (live.moved) moved = true;
|
|
1738
1770
|
var k = splitSessionId(s.provider, s.session_id || s.id), want = k.provider + '|' + k.id;
|
|
1739
1771
|
var els = list.querySelectorAll('[data-session]');
|
|
1740
1772
|
for (var i = 0; i < els.length; i++) {
|
|
1741
1773
|
if (els[i].getAttribute('data-session') !== want) continue;
|
|
1742
|
-
var html =
|
|
1774
|
+
var html = live.html;
|
|
1743
1775
|
if (els[i].__liveHtml !== html) {
|
|
1744
1776
|
var tmp = document.createElement('div'); tmp.innerHTML = html;
|
|
1745
1777
|
var next = tmp.firstElementChild || tmp.firstChild;
|
|
@@ -1748,6 +1780,7 @@
|
|
|
1748
1780
|
break;
|
|
1749
1781
|
}
|
|
1750
1782
|
});
|
|
1783
|
+
if (moved) sxRegroupLater();
|
|
1751
1784
|
paintSessionsHero();
|
|
1752
1785
|
}
|
|
1753
1786
|
// ── Today's header line and Refresh ──
|
|
@@ -2476,7 +2509,8 @@
|
|
|
2476
2509
|
}
|
|
2477
2510
|
var SX = {
|
|
2478
2511
|
rows: null, dropped: null, enabled: true, loading: false, error: '', loadedAt: 0,
|
|
2479
|
-
|
|
2512
|
+
// 7.0.23 step 2b: Pinned is a group now, not a clock; a saved 'pinned' reads as Updated.
|
|
2513
|
+
clock: sxPref('clock', 'updated', ['updated', 'opened']),
|
|
2480
2514
|
view: sxPref('view', 'sessions', ['sessions', 'usage', 'cost']),
|
|
2481
2515
|
recency: sxPref('recency', 'newest', ['newest', 'oldest', 'match']),
|
|
2482
2516
|
q: '', instant: [], remote: [], searching: false, searchError: '', ranFor: '', semantic: null, semanticReason: '', searchSeq: 0, searchTimer: null,
|
|
@@ -2506,7 +2540,9 @@
|
|
|
2506
2540
|
|
|
2507
2541
|
// ClaudeSession.title: the job label, else the name, else the workspace, else the id.
|
|
2508
2542
|
function sxTitle(s) {
|
|
2509
|
-
|
|
2543
|
+
// 7.0.23 step 2b-2: a title renamed in COS (S1's title_override) is the title everywhere; the agent's own stays
|
|
2544
|
+
// in display_label, shown under it in the list and still searchable.
|
|
2545
|
+
var t = plain(s.title_override || s.display_label || s.custom_title || '').replace(/\s+/g, ' ').trim();
|
|
2510
2546
|
return t || String(s.project || '').trim() || String(s.session_id || s.id || 'Session');
|
|
2511
2547
|
}
|
|
2512
2548
|
function sxWarmWords(t) { t = String(t || '').trim().toLowerCase(); return t === 'ready' || t.indexOf('this is an automated local readiness check') === 0; }
|
|
@@ -2619,7 +2655,8 @@
|
|
|
2619
2655
|
'<div class="cx-hero-text"><h1 class="cx-title">Sessions</h1><p class="cx-detail-line">' + esc(detail) + liveHeroNote(view) + '</p></div>' +
|
|
2620
2656
|
'<div class="cx-actions"><button type="button" class="cx-btn quiet" data-sx="refresh"' + (busy ? ' disabled' : '') + '><svg class="icon"><use href="#i-refresh"/></svg>Refresh</button></div>' +
|
|
2621
2657
|
(stats.length ? '<div class="cx-stats">' + stats.map(function (s) { return '<span class="cx-stat"><b>' + esc(typeof s[0] === 'number' ? s[0].toLocaleString() : s[0]) + '</b><span>' + s[1] + '</span></span>'; }).join('') + '</div>' : '');
|
|
2622
|
-
|
|
2658
|
+
// 7.0.23 step 2b: Updated and Opened are the sort menu now, and Pinned a group; no clock switches here.
|
|
2659
|
+
var clockOn = false;
|
|
2623
2660
|
views.innerHTML = '<div class="cx-switch" role="group" aria-label="Sessions view">' +
|
|
2624
2661
|
Object.keys(SX_VIEWS).map(function (v) { return '<button type="button" data-sx="view" data-view="' + v + '" aria-pressed="' + (v === view) + '">' + SX_VIEWS[v] + '</button>'; }).join('') +
|
|
2625
2662
|
'</div>' +
|
|
@@ -2629,6 +2666,115 @@
|
|
|
2629
2666
|
var split = $('sessions-split'), usage = $('sessions-usage');
|
|
2630
2667
|
if (split) split.classList.toggle('hidden', view !== 'sessions');
|
|
2631
2668
|
if (usage) usage.classList.toggle('hidden', view === 'sessions');
|
|
2669
|
+
// 7.0.23 step 2b: in Sessions the list's own head (the Sessions menu, Search, the quiet line, the sort menu) takes
|
|
2670
|
+
// the place of this header and the switches; Usage and COS cost keep them, so there is a way back.
|
|
2671
|
+
hero.classList.toggle('hidden', view === 'sessions');
|
|
2672
|
+
// Under 600 px the list's own Sessions button steps aside for the top bar's (sessions.css), and this switch stays
|
|
2673
|
+
// the way to Usage and COS cost there; from 600 px the Sessions menu is that way.
|
|
2674
|
+
views.classList.toggle('sx-wide-hide', view === 'sessions');
|
|
2675
|
+
sxFrameSync();
|
|
2676
|
+
}
|
|
2677
|
+
// 7.0.23 (step 1 of the chat-style Sessions, DESIGN_PLAN v2 "The frame"): while Sessions shows its list and reader,
|
|
2678
|
+
// html.sx-frame makes the page one screen, never taller: the app shell is the visible box (--vv-top, --vv-h from
|
|
2679
|
+
// viewportFit, the same kit kb-chat uses), and the rail, the list and the reader's body each scroll by themselves, with
|
|
2680
|
+
// the reply area fixed at the bottom of the reader (panes/sessions.css). Usage and COS cost keep the page's own scroll.
|
|
2681
|
+
// Layout, not a proven fix for the 20:16 report: the frame reports where the rail, the list and the reply box sit
|
|
2682
|
+
// against the visible area (sxFrameCrumb).
|
|
2683
|
+
function sxFrameSync() {
|
|
2684
|
+
// Never while the sign-in gate shows (the shell is hidden): the gate must scroll like a page.
|
|
2685
|
+
var de = document.documentElement, shell = $('app-shell'), on = state.pane === 'sessions' && SX.view === 'sessions' && !(shell && shell.classList.contains('hidden'));
|
|
2686
|
+
if (de && de.classList) de.classList.toggle('sx-frame', on);
|
|
2687
|
+
if (on) sxFrameHook();
|
|
2688
|
+
return on;
|
|
2689
|
+
}
|
|
2690
|
+
// Where the frame's parts sit against the VISIBLE area (step 1's position report, lead 23:00): on a reader's open, and
|
|
2691
|
+
// KB_AFTER_MS after an on-screen keyboard closes while the frame is on. The visible area is viewportFit's box (a
|
|
2692
|
+
// visible height under 80 px is no reading, so it is innerHeight from 0, and fb says so) and, across, visualViewport's
|
|
2693
|
+
// offsetLeft and width. For the rail, the list, the reader and the reply box: their top, bottom, left and right in
|
|
2694
|
+
// the same coordinates, and whether each lies inside the visible area (railIn, listIn, replyIn: within 1 px on every
|
|
2695
|
+
// side; a part that is not shown reads -1 and false). Numbers and fixed labels only, at most SX_FRAME_MAX a page load.
|
|
2696
|
+
var SX_FRAME_MAX = 8, SX_FRAME = { n: 0, hooked: false };
|
|
2697
|
+
function sxFrameCrumb(why) {
|
|
2698
|
+
if (SX_FRAME.n >= SX_FRAME_MAX || !document.documentElement.classList.contains('sx-frame') || typeof layoutCrumb !== 'function') return false;
|
|
2699
|
+
try {
|
|
2700
|
+
var v = viewportFit(), vv = window.visualViewport, iw = window.innerWidth || 0;
|
|
2701
|
+
var left = vv && !v.bad && typeof vv.offsetLeft === 'number' ? vv.offsetLeft : 0;
|
|
2702
|
+
var width = vv && !v.bad && vv.width > 0 ? vv.width : iw;
|
|
2703
|
+
var box = { t: v.top, b: v.top + v.height, l: left, r: left + width };
|
|
2704
|
+
var out = { why: String(why || '').slice(0, 24), fb: !!v.bad, boxT: box.t, boxB: box.b, boxL: box.l, boxR: box.r };
|
|
2705
|
+
var part = function (name, el) {
|
|
2706
|
+
var r = el && el.getBoundingClientRect && el.offsetParent !== null ? el.getBoundingClientRect() : null;
|
|
2707
|
+
var shown = !!(r && r.width > 0 && r.height > 0);
|
|
2708
|
+
out[name + 'T'] = shown ? r.top : -1; out[name + 'B'] = shown ? r.bottom : -1; out[name + 'L'] = shown ? r.left : -1; out[name + 'R'] = shown ? r.right : -1;
|
|
2709
|
+
out[name + 'In'] = shown && r.top >= box.t - 1 && r.bottom <= box.b + 1 && r.left >= box.l - 1 && r.right <= box.r + 1;
|
|
2710
|
+
};
|
|
2711
|
+
part('rail', document.querySelector('.rail'));
|
|
2712
|
+
part('list', $('sessions-list-col'));
|
|
2713
|
+
part('rd', $('session-reader'));
|
|
2714
|
+
part('reply', $('session-form'));
|
|
2715
|
+
if (layoutCrumb('sx-frame', out)) { SX_FRAME.n++; return true; }
|
|
2716
|
+
} catch (_e) { /* a report never stops the page */ }
|
|
2717
|
+
return false;
|
|
2718
|
+
}
|
|
2719
|
+
// The keyboard's after-close moment (app.js lrAfter, KB_AFTER_MS), heard once, reported while the frame is on.
|
|
2720
|
+
function sxFrameHook() {
|
|
2721
|
+
if (SX_FRAME.hooked || !window.COSWeb || typeof window.COSWeb.onKeyboard !== 'function') return;
|
|
2722
|
+
SX_FRAME.hooked = true;
|
|
2723
|
+
window.COSWeb.onKeyboard(function (what) { if (what === 'after') sxFrameCrumb('kb-after'); });
|
|
2724
|
+
}
|
|
2725
|
+
// "Go to the question" (Q1 23:40): shown in the dock while the session's question cards have something and the end of
|
|
2726
|
+
// the conversation, where they are, is out of view (the person scrolled up). Read on the body's scroll, its size
|
|
2727
|
+
// changes and the cards' redraws.
|
|
2728
|
+
function sxGoQuestionPaint() {
|
|
2729
|
+
var line = $('session-goq'), body = $('session-body'), q = $('session-questions');
|
|
2730
|
+
if (!line) return false;
|
|
2731
|
+
var has = !!(q && q.children && q.children.length && String(q.textContent || '').trim());
|
|
2732
|
+
var hidden = false;
|
|
2733
|
+
if (has && body && body.getBoundingClientRect && q.getBoundingClientRect) hidden = q.getBoundingClientRect().top >= body.getBoundingClientRect().bottom - 8;
|
|
2734
|
+
var show = has && hidden && document.documentElement.classList.contains('sx-frame');
|
|
2735
|
+
// Its place: just above the dock, whatever the dock's height is now (the reader holds it in --sx-dock-h).
|
|
2736
|
+
var dock = $('session-dock'), rd = line.parentElement;
|
|
2737
|
+
if (show && dock && rd && rd.style && dock.getBoundingClientRect) rd.style.setProperty('--sx-dock-h', Math.round(dock.getBoundingClientRect().height) + 'px');
|
|
2738
|
+
line.hidden = !show;
|
|
2739
|
+
return show;
|
|
2740
|
+
}
|
|
2741
|
+
// The reader's own scroll (#session-body); the window's only where the frame is off.
|
|
2742
|
+
// What mounts after the first paint (the session's question cards, the queue, the activity, image previews) changes
|
|
2743
|
+
// the body's height and would leave it short of the end. So the body stays at the newest turn while its size or its
|
|
2744
|
+
// content changes, until the person scrolls it themself: a wheel, touch, pointer or key on the body within the last
|
|
2745
|
+
// SX_PIN_USER_MS, then a scroll that leaves the end by more than 4 px. A scroll the browser makes on its own (scroll
|
|
2746
|
+
// anchoring while turns and previews land) never counts. Two more passes (250 and 900 ms) cover a browser without
|
|
2747
|
+
// ResizeObserver.
|
|
2748
|
+
var SX_NEWEST_AGAIN_MS = [250, 900], SX_PIN_USER_MS = 1000;
|
|
2749
|
+
var SX_PIN = { body: null, ro: null, put: -1, userAt: 0 };
|
|
2750
|
+
function sxUnpin() { if (SX_PIN.ro) { try { SX_PIN.ro.disconnect(); } catch (_e) {} } SX_PIN.ro = null; SX_PIN.body = null; }
|
|
2751
|
+
function sxToNewest() {
|
|
2752
|
+
var body = $('session-body');
|
|
2753
|
+
if (!(body && document.documentElement.classList.contains('sx-frame'))) { window.scrollTo(0, document.documentElement.scrollHeight); return; }
|
|
2754
|
+
var bottom = function () { body.scrollTop = body.scrollHeight; SX_PIN.put = body.scrollTop; };
|
|
2755
|
+
bottom();
|
|
2756
|
+
if (SX_PIN.body !== body) {
|
|
2757
|
+
sxUnpin();
|
|
2758
|
+
SX_PIN.body = body;
|
|
2759
|
+
var touched = function () { SX_PIN.userAt = Date.now(); };
|
|
2760
|
+
['wheel', 'touchstart', 'pointerdown', 'keydown'].forEach(function (t) { body.addEventListener(t, touched, { passive: true }); });
|
|
2761
|
+
body.addEventListener('scroll', sxGoQuestionPaint, { passive: true });
|
|
2762
|
+
body.addEventListener('scroll', function () {
|
|
2763
|
+
if (SX_PIN.body !== body || Date.now() - SX_PIN.userAt > SX_PIN_USER_MS) return;
|
|
2764
|
+
if (Math.abs(body.scrollTop - SX_PIN.put) > 4 && body.scrollTop < body.scrollHeight - body.clientHeight - 4) sxUnpin();
|
|
2765
|
+
}, { passive: true });
|
|
2766
|
+
if (typeof ResizeObserver === 'function') {
|
|
2767
|
+
// The body and everything in it (the note, the turns, Copy session, its note): any of them can grow.
|
|
2768
|
+
SX_PIN.ro = new ResizeObserver(function () { if (SX_PIN.body === body && body.isConnected) bottom(); sxGoQuestionPaint(); });
|
|
2769
|
+
SX_PIN.ro.observe(body);
|
|
2770
|
+
[].forEach.call(body.children, function (c) { SX_PIN.ro.observe(c); });
|
|
2771
|
+
// The dock too: files added to the reply box change its height, and Go to the question sits just above it.
|
|
2772
|
+
if ($('session-dock')) SX_PIN.ro.observe($('session-dock'));
|
|
2773
|
+
}
|
|
2774
|
+
}
|
|
2775
|
+
SX_NEWEST_AGAIN_MS.forEach(function (ms) {
|
|
2776
|
+
setTimeout(function () { if (body.isConnected && SX_PIN.body === body) bottom(); }, ms);
|
|
2777
|
+
});
|
|
2632
2778
|
}
|
|
2633
2779
|
|
|
2634
2780
|
// 7.0.1: the hero's counts say when they are not live ("Reconnecting", "as of 2 min ago"); nothing without a stream.
|
|
@@ -2668,6 +2814,8 @@
|
|
|
2668
2814
|
state.sessions = SX.rows;
|
|
2669
2815
|
if (sxQueryActive()) SX.instant = sxInstant(SX.q);
|
|
2670
2816
|
renderSessionGroups();
|
|
2817
|
+
sxLoadOlder(force === true);
|
|
2818
|
+
if (typeof sxLoadFolders === 'function') sxLoadFolders(force === true);
|
|
2671
2819
|
}).catch(function (err) {
|
|
2672
2820
|
SX.loading = false;
|
|
2673
2821
|
SX.error = (err && err.message) || 'Sessions did not load';
|
|
@@ -2693,36 +2841,409 @@
|
|
|
2693
2841
|
}
|
|
2694
2842
|
// Control's row: glyph, title, a snippet on search hits, then badge · PINNED · state · Updated ·
|
|
2695
2843
|
// Opened (twins only, amber) · workspace; a match chip and a chevron on the right.
|
|
2696
|
-
|
|
2844
|
+
// ── the grouped list (7.0.23, step 2b-1 of the chat-style Sessions; DESIGN_PLAN v2 "The list", the 23:33 mock) ──
|
|
2845
|
+
// A row, full or compact. sxRow stays the one renderer: live updates (liveSessionRows) replace a row's root, the
|
|
2846
|
+
// element with data-session, with this; search draws its hits with it (a snippet and the Keyword or Meaning tag).
|
|
2847
|
+
// Line two is the product's own state words, then the project, in plain words with commas: Running; Waiting and its
|
|
2848
|
+
// kind; Stale; Failed and its reason; "2 queued"; Not sent; "Pinned in Claude" for an agent's own pin; "Opened
|
|
2849
|
+
// Nov 9" when two rows on screen share a title. The left edge is green running, accent waiting, red failed, and the
|
|
2850
|
+
// state is always in the words too. A compact row (days 8 to 30, the short form from /older) is the title and date.
|
|
2851
|
+
function sxRowWords(s, st, key) {
|
|
2852
|
+
var w = [];
|
|
2853
|
+
if (st === 'running') w.push('Running');
|
|
2854
|
+
else if (st === 'waiting') { var k = sxWaitingKind(s.waiting_kind, s.waiting_detail); w.push(k ? 'Waiting, ' + k.charAt(0).toLowerCase() + k.slice(1) : 'Waiting'); }
|
|
2855
|
+
else if (st === 'stale') w.push('Stale');
|
|
2856
|
+
else if (st === 'error') w.push(s.failure ? 'Failed, ' + String(s.failure).replace(/_/g, ' ') : 'Failed');
|
|
2857
|
+
var queued = Number(s.queued_turns) || 0;
|
|
2858
|
+
if (queued > 0) w.push(queued + ' queued');
|
|
2859
|
+
var ns = notSentFor(key.provider, key.id);
|
|
2860
|
+
if (ns) w.push(ns.label || 'Not sent');
|
|
2861
|
+
if (s.pinned) w.push('Pinned in ' + sxProviderLabel(key.provider));
|
|
2862
|
+
return w;
|
|
2863
|
+
}
|
|
2864
|
+
function sxRow(s, hit, ambiguous, compact) {
|
|
2697
2865
|
var key = splitSessionId(s.provider, s.session_id || s.id);
|
|
2698
2866
|
var r = parseRoute();
|
|
2699
2867
|
var selected = r.pane === 'sessions' && r.args[0] === key.provider && r.args[1] === key.id;
|
|
2700
|
-
var
|
|
2701
|
-
var
|
|
2702
|
-
var
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
2868
|
+
var short = s.form === 'short', st = short ? '' : sxState(s), title = sxTitle(s);
|
|
2869
|
+
var when = toDate(SX.clock === 'opened' ? (s.created || s.modified) : s.modified);
|
|
2870
|
+
var edge = st === 'running' ? ' running' : st === 'waiting' ? ' waiting' : st === 'error' ? ' failed' : '';
|
|
2871
|
+
var id = esc(key.provider) + '|' + esc(key.id), rowKey = key.provider + '|' + key.id;
|
|
2872
|
+
var org = typeof SXF === 'object' && SXF.ok === true;
|
|
2873
|
+
if (org && SXF.renaming && SXF.renaming.toLowerCase() === rowKey.toLowerCase()) {
|
|
2874
|
+
// data-edit, not data-session, while the form is open: a form's Save (or Enter, which the browser turns into a
|
|
2875
|
+
// click on Save) must not reach the page's row handler and open the session.
|
|
2876
|
+
return '<div class="sx-r editing" data-edit="' + id + '">' + sxInlineHtml('rename', rowKey, 'Rename this session', s.title_override || title,
|
|
2877
|
+
s.title_override ? 'The agent\u2019s own title stays underneath and in search.' : '') + '</div>';
|
|
2878
|
+
}
|
|
2879
|
+
var agent = s.title_override && s.display_label && String(s.display_label).trim() !== title ? plain(String(s.display_label)).replace(/\s+/g, ' ').trim() : '';
|
|
2880
|
+
var head = '<span class="sx-r-title">' + esc(title) + '</span>' + (when ? '<span class="sx-r-time">' + esc(sxShortDate(when)) + '</span>' : '') +
|
|
2881
|
+
(agent ? '<span class="sx-r-orig">' + esc(agent) + '</span>' : '');
|
|
2882
|
+
var more = org && !short && !compact ? '<button type="button" class="sx-r-more" data-sx="row-menu" data-key="' + id + '" aria-haspopup="menu" aria-expanded="false" aria-label="' + esc('More for ' + title) + '"><span aria-hidden="true">\u2026</span></button>' : '';
|
|
2883
|
+
if (compact || short) {
|
|
2884
|
+
return '<div class="sx-r compact' + edge + '" data-session="' + id + '"' + (selected ? ' aria-selected="true"' : '') + '>' +
|
|
2885
|
+
'<button type="button" class="sx-r-open" data-session="' + id + '"' + (selected ? ' aria-selected="true"' : '') + '>' + head + '</button></div>';
|
|
2886
|
+
}
|
|
2887
|
+
var words = sxRowWords(s, st, key);
|
|
2888
|
+
var opened = toDate(s.created);
|
|
2889
|
+
if (ambiguous && ambiguous[title] && opened) words.push('Opened ' + sxShortDate(opened));
|
|
2890
|
+
var proj = s.project && String(s.project) !== title ? String(s.project) : '';
|
|
2891
|
+
var line = words.concat(proj ? [proj] : []).join(', ');
|
|
2892
|
+
var failedWord = st === 'error' ? words[0] : '';
|
|
2893
|
+
var lineHtml = failedWord ? '<b class="failed">' + esc(failedWord) + '</b>' + esc(line.slice(failedWord.length)) : esc(line);
|
|
2894
|
+
var match = hit ? (hit.match === 'both' ? 'Keyword and meaning' : hit.match === 'semantic' ? 'Meaning' : 'Keyword') : '';
|
|
2710
2895
|
// An instant title hit's snippet is the title itself; it says nothing twice.
|
|
2711
2896
|
var snippet = hit && hit.snippet ? plain(hit.snippet).replace(/\s+/g, ' ').trim() : '';
|
|
2712
|
-
return '<
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
'
|
|
2717
|
-
|
|
2897
|
+
return '<div class="sx-r full' + edge + '" data-session="' + id + '"' + (selected ? ' aria-selected="true"' : '') + '><span class="sx-r-edge" aria-hidden="true"></span>' +
|
|
2898
|
+
'<button type="button" class="sx-r-open" data-session="' + id + '"' + (selected ? ' aria-selected="true"' : '') + '>' + head +
|
|
2899
|
+
'<span class="sx-r-sub">' + sxGlyph(key.provider) + (line ? '<span class="t">' + lineHtml + '</span>' : '') + (match ? '<span class="sx-tag">' + match + '</span>' : '') + '</span>' +
|
|
2900
|
+
(snippet && snippet !== title ? '<span class="sx-r-snip">' + esc(snippet) + '</span>' : '') +
|
|
2901
|
+
'</button>' + more + '</div>';
|
|
2902
|
+
}
|
|
2903
|
+
|
|
2904
|
+
// Groups, top to bottom; a session shows once. Needs you is what Today's needsModel counts for a session: waiting, or
|
|
2905
|
+
// a question this page holds for it; plus failed (DESIGN_PLAN v2). Working now is running. Pinned is an agent's own
|
|
2906
|
+
// pin or a COS pin. Then by day of the sort's time: Today, Yesterday, Earlier this week as full rows; Last week and
|
|
2907
|
+
// Earlier this month as compact rows, folded shut with a count. Days are counted back from today (2 to 6 days ago is
|
|
2908
|
+
// Earlier this week, 7 to 13 Last week, 14 to 30 Earlier this month), so no group is empty only because of the
|
|
2909
|
+
// calendar.
|
|
2910
|
+
var SX_GROUPS = [['needs', 'Needs you', 'state'], ['working', 'Working now', 'state'], ['pinned', 'Pinned', ''], ['today', 'Today', ''],
|
|
2911
|
+
['yesterday', 'Yesterday', ''], ['week', 'Earlier this week', ''], ['lastweek', 'Last week', 'compact'], ['month', 'Earlier this month', 'compact']];
|
|
2912
|
+
var SX_DAY_MS = 86400000, SX_REST_MS = 2000, SX_EMPTY_HOLD_MS = 5000;
|
|
2913
|
+
// place: each row's group as last drawn at rest; seen: when Needs you and Working now last had rows; open: unfolded
|
|
2914
|
+
// compact groups; older: /older's short rows (olderOk false: an older server, 404).
|
|
2915
|
+
var SXL = { place: null, down: false, touchAt: 0, scrollAt: 0, timer: 0, seen: {}, open: {}, older: [], olderN: null, olderOk: null, olderNext: null, olderAt: 0, olderBusy: false };
|
|
2916
|
+
function sxRowKey(s) { var k = splitSessionId(s.provider, s.session_id || s.id); return k.provider + '|' + String(k.id).toLowerCase(); }
|
|
2917
|
+
function sxDayStart(t) { var d = new Date(t); d.setHours(0, 0, 0, 0); return d.getTime(); }
|
|
2918
|
+
function sxSortTime(s) { return SX.clock === 'opened' ? (sxTime(s.created) || sxTime(s.modified)) : sxTime(s.modified); }
|
|
2919
|
+
function sxHeldKeys() {
|
|
2920
|
+
var qs = typeof sessionQs === 'function' ? sessionQs() : null, out = {};
|
|
2921
|
+
(qs && qs.itemsFor ? qs.itemsFor('') : []).forEach(function (it) { var k = splitSessionId(it.provider, it.sessionId); if (k.id) out[k.provider + '|' + String(k.id).toLowerCase()] = 1; });
|
|
2922
|
+
return out;
|
|
2923
|
+
}
|
|
2924
|
+
function sxGroupFor(s, now, held) {
|
|
2925
|
+
if (s.form !== 'short') {
|
|
2926
|
+
var st = sxState(s);
|
|
2927
|
+
if (st === 'waiting' || st === 'error' || held[sxRowKey(s)]) return 'needs';
|
|
2928
|
+
if (st === 'running') return 'working';
|
|
2929
|
+
}
|
|
2930
|
+
if (s.pinned || s.cos_pinned) return 'pinned';
|
|
2931
|
+
if (s.folder_id && typeof SXF === 'object' && SXF.byId[s.folder_id]) return 'f:' + s.folder_id;
|
|
2932
|
+
var t = sxSortTime(s), today = sxDayStart(now);
|
|
2933
|
+
if (t >= today) return 'today';
|
|
2934
|
+
if (t >= today - SX_DAY_MS) return 'yesterday';
|
|
2935
|
+
if (t >= today - 6 * SX_DAY_MS) return 'week';
|
|
2936
|
+
if (t >= today - 13 * SX_DAY_MS) return 'lastweek';
|
|
2937
|
+
return 'month';
|
|
2938
|
+
}
|
|
2939
|
+
// The main list's rows, then /older's short rows it does not have (the main row wins on a repeat).
|
|
2940
|
+
function sxAllRows() {
|
|
2941
|
+
var main = sxReal(), have = {};
|
|
2942
|
+
main.forEach(function (s) { have[sxRowKey(s)] = 1; });
|
|
2943
|
+
return main.concat((SXL.older || []).filter(function (s) { return !sxKeepWarm(s) && !have[sxRowKey(s)]; }));
|
|
2944
|
+
}
|
|
2945
|
+
// At rest: nothing pressed, no touch or scroll in the last SX_REST_MS, and no row holding keyboard focus.
|
|
2946
|
+
function sxListAtRest() {
|
|
2947
|
+
if (SXL.down) return false;
|
|
2948
|
+
var now = Date.now();
|
|
2949
|
+
if (now - SXL.touchAt < SX_REST_MS || now - SXL.scrollAt < SX_REST_MS) return false;
|
|
2950
|
+
var a = document.activeElement;
|
|
2951
|
+
return !(a && a.closest && a.closest('#sessions-list [data-session]'));
|
|
2952
|
+
}
|
|
2953
|
+
function sxRegroupLater(ms) {
|
|
2954
|
+
if (SXL.timer) return;
|
|
2955
|
+
SXL.timer = setTimeout(function () {
|
|
2956
|
+
SXL.timer = 0;
|
|
2957
|
+
if (state.pane !== 'sessions' || SX.view !== 'sessions' || sxQueryActive()) return;
|
|
2958
|
+
if (sxListAtRest()) renderSessionGroups(); else sxRegroupLater();
|
|
2959
|
+
}, ms || SX_REST_MS + 50);
|
|
2960
|
+
}
|
|
2961
|
+
// A group's row form: compact in Last week and Earlier this month, full under Needs you and Working now, and elsewhere
|
|
2962
|
+
// short when the row is one of /older's short rows (the same rule sxListHtml draws with).
|
|
2963
|
+
function sxGroupCompact(g, s) {
|
|
2964
|
+
for (var i = 0; i < SX_GROUPS.length; i++) if (SX_GROUPS[i][0] === g) return SX_GROUPS[i][2] === 'compact' ? true : SX_GROUPS[i][2] === 'state' ? false : s.form === 'short';
|
|
2965
|
+
return s.form === 'short';
|
|
2966
|
+
}
|
|
2967
|
+
// A live state change (liveSessionRows' paint without a structural change, Q1 07:22 FIX 1): the row is redrawn where
|
|
2968
|
+
// it was placed, in that group's form, and when its group would now differ (Working now to Needs you, say) the list
|
|
2969
|
+
// asks for a regroup, which the rest rule applies (at once at rest, held while the list is pressed, scrolled or focused).
|
|
2970
|
+
function sxLiveRow(s, amb) {
|
|
2971
|
+
var k = sxRowKey(s), placed = SXL.place ? SXL.place[k] : null, want = sxGroupFor(s, Date.now(), sxHeldKeys());
|
|
2972
|
+
return { html: sxRow(s, null, amb, sxGroupCompact(placed || want, s)), moved: !!placed && placed !== want };
|
|
2718
2973
|
}
|
|
2719
2974
|
function sxListHtml() {
|
|
2720
2975
|
if (!SX.rows) return SX.error ? sxEmpty(SX.error) : skeletonRows(6);
|
|
2721
2976
|
if (!SX.enabled && !SX.rows.length) return sxEmpty('AI sessions are off. Turn on Show AI sessions in Settings, Work and agents.');
|
|
2722
|
-
var
|
|
2723
|
-
if (!
|
|
2724
|
-
var
|
|
2725
|
-
|
|
2977
|
+
var all = sxAllRows();
|
|
2978
|
+
if (!all.length) return sxEmpty(SX.error || sxEmptyCopy());
|
|
2979
|
+
var now = Date.now(), held = sxHeldKeys(), want = {}, by = {}, moves = 0;
|
|
2980
|
+
all.forEach(function (s) { want[sxRowKey(s)] = sxGroupFor(s, now, held); });
|
|
2981
|
+
// A row changes group only at rest; held moves apply together on the next rest (sxRegroupLater).
|
|
2982
|
+
if (!SXL.place || sxListAtRest()) SXL.place = want;
|
|
2983
|
+
else Object.keys(want).forEach(function (k) { if (!(k in SXL.place)) SXL.place[k] = want[k]; else if (SXL.place[k] !== want[k]) moves++; });
|
|
2984
|
+
if (moves) sxRegroupLater();
|
|
2985
|
+
all.forEach(function (s) { var k = sxRowKey(s), g = SXL.place[k] || want[k]; (by[g] = by[g] || []).push(s); });
|
|
2986
|
+
var amb = sxAmbiguous(all.filter(function (s) { return s.form !== 'short'; }));
|
|
2987
|
+
var html = '', holdUntil = 0;
|
|
2988
|
+
// Folders sit after Pinned, each a heading that folds (open by default), with its own menu; New folder's field
|
|
2989
|
+
// opens at the top of this area.
|
|
2990
|
+
var groups = [];
|
|
2991
|
+
SX_GROUPS.forEach(function (g) {
|
|
2992
|
+
groups.push(g);
|
|
2993
|
+
if (g[0] === 'pinned' && typeof SXF === 'object' && SXF.ok) {
|
|
2994
|
+
if (SXF.newOpen) groups.push(['newfolder', '', 'form']);
|
|
2995
|
+
SXF.folders.forEach(function (f) { groups.push(['f:' + f.id, f.name, 'folder']); });
|
|
2996
|
+
}
|
|
2997
|
+
});
|
|
2998
|
+
groups.forEach(function (g) {
|
|
2999
|
+
if (g[2] === 'form') { html += '<section class="sx-g sx-g-form">' + sxInlineHtml('new-folder', '', 'New folder', '', '') + '</section>'; return; }
|
|
3000
|
+
if (g[2] === 'folder') {
|
|
3001
|
+
var fid = g[0].slice(2), frows = (by[g[0]] || []).sort(function (a, b) { return sxSortTime(b) - sxSortTime(a); }), fopen = SXL.open[g[0]] !== false;
|
|
3002
|
+
if (SXF.folderRenaming === fid) { html += '<section class="sx-g" data-g="' + esc(g[0]) + '">' + sxInlineHtml('folder-rename', fid, 'Rename folder', g[1], '') + '</section>'; return; }
|
|
3003
|
+
html += '<section class="sx-g sx-folder" data-g="' + esc(g[0]) + '"><div class="sx-gh-row"><button type="button" class="sx-gh" data-sx="fold" data-g="' + esc(g[0]) + '" aria-expanded="' + fopen + '">' +
|
|
3004
|
+
'<span class="fold"><span class="chev" aria-hidden="true"></span><span class="nm">' + esc(g[1]) + '</span></span><span class="n">' + frows.length + '</span></button>' +
|
|
3005
|
+
'<button type="button" class="sx-r-more" data-sx="folder-menu" data-id="' + esc(fid) + '" aria-haspopup="menu" aria-expanded="false" aria-label="' + esc('Folder menu for ' + g[1]) + '"><span aria-hidden="true">\u2026</span></button></div>' +
|
|
3006
|
+
(fopen ? (frows.length ? '<div class="sx-rows">' + frows.map(function (s) { return sxRow(s, null, amb, s.form === 'short'); }).join('') + '</div>'
|
|
3007
|
+
: '<p class="sx-empty-folder">Nothing here yet. Move a session in from its menu.</p>') : '') + '</section>';
|
|
3008
|
+
return;
|
|
3009
|
+
}
|
|
3010
|
+
var id = g[0], rows = (by[id] || []).sort(function (a, b) { return sxSortTime(b) - sxSortTime(a); }), n = rows.length;
|
|
3011
|
+
if (g[2] === 'state') {
|
|
3012
|
+
if (n) SXL.seen[id] = now;
|
|
3013
|
+
else if (SXL.seen[id] && now - SXL.seen[id] < SX_EMPTY_HOLD_MS) holdUntil = Math.max(holdUntil, SXL.seen[id] + SX_EMPTY_HOLD_MS);
|
|
3014
|
+
else return;
|
|
3015
|
+
html += '<section class="sx-g" data-g="' + id + '"><h3 class="sx-gh state ' + id + '"><span>' + g[1] + '</span><span class="n">' + n + '</span></h3>' +
|
|
3016
|
+
'<div class="sx-rows">' + rows.map(function (s) { return sxRow(s, null, amb); }).join('') + '</div></section>';
|
|
3017
|
+
return;
|
|
3018
|
+
}
|
|
3019
|
+
if (!n) return;
|
|
3020
|
+
if (g[2] === 'compact') {
|
|
3021
|
+
var open = !!SXL.open[id];
|
|
3022
|
+
html += '<section class="sx-g" data-g="' + id + '"><button type="button" class="sx-gh" data-sx="fold" data-g="' + id + '" aria-expanded="' + open + '">' +
|
|
3023
|
+
'<span class="fold"><span class="chev" aria-hidden="true"></span>' + g[1] + '</span><span class="n">' + n + '</span></button>' +
|
|
3024
|
+
(open ? '<div class="sx-rows">' + rows.map(function (s) { return sxRow(s, null, amb, true); }).join('') + '</div>' +
|
|
3025
|
+
(id === 'month' && SXL.olderNext ? '<p class="sx-more-older"><button type="button" class="cx-btn quiet" data-sx="older-more">Show older</button></p>' : '') : '') + '</section>';
|
|
3026
|
+
return;
|
|
3027
|
+
}
|
|
3028
|
+
html += '<section class="sx-g" data-g="' + id + '"><h3 class="sx-gh"><span>' + g[1] + '</span><span class="n">' + n + '</span></h3>' +
|
|
3029
|
+
'<div class="sx-rows">' + rows.map(function (s) { return sxRow(s, null, amb, s.form === 'short'); }).join('') + '</div></section>';
|
|
3030
|
+
});
|
|
3031
|
+
// A Needs you or Working now heading at 0 stays for SX_EMPTY_HOLD_MS, then the list draws again without it.
|
|
3032
|
+
if (holdUntil) sxRegroupLater(holdUntil - now + 20);
|
|
3033
|
+
return '<div class="sx-list sx-groups">' + html + '</div>';
|
|
3034
|
+
}
|
|
3035
|
+
// The list keeps its place across a redraw: the top visible row stays where it was in the list's own scroll.
|
|
3036
|
+
function sxAnchor(col) {
|
|
3037
|
+
if (!col || !col.getBoundingClientRect) return null;
|
|
3038
|
+
var top = col.getBoundingClientRect().top, rows = col.querySelectorAll('.sx-r[data-session]');
|
|
3039
|
+
for (var i = 0; i < rows.length; i++) { var b = rows[i].getBoundingClientRect(); if (b.bottom > top) return { key: rows[i].getAttribute('data-session'), top: b.top }; }
|
|
3040
|
+
return null;
|
|
3041
|
+
}
|
|
3042
|
+
function sxRestoreAnchor(col, a) {
|
|
3043
|
+
if (!col || !a) return;
|
|
3044
|
+
var rows = col.querySelectorAll('.sx-r[data-session]');
|
|
3045
|
+
for (var i = 0; i < rows.length; i++) if (rows[i].getAttribute('data-session') === a.key) { col.scrollTop += rows[i].getBoundingClientRect().top - a.top; return; }
|
|
3046
|
+
}
|
|
3047
|
+
// GET /api/agent-sessions/older (server step 2a): the short rows for days 8 to 30 and the kept ones, newest first,
|
|
3048
|
+
// paged; read when the list loads (at most once a minute) and for Show older, never on the poll. An older server
|
|
3049
|
+
// answers 404: the list stays at 7 days and says so.
|
|
3050
|
+
var SX_OLDER_MS = 60000;
|
|
3051
|
+
function sxLoadOlder(force, more) {
|
|
3052
|
+
if (SXL.olderBusy || SXL.olderOk === false) return Promise.resolve();
|
|
3053
|
+
if (!more && !force && SXL.olderAt && Date.now() - SXL.olderAt < SX_OLDER_MS) return Promise.resolve();
|
|
3054
|
+
SXL.olderBusy = true;
|
|
3055
|
+
var q = '?days=30&limit=200' + (more && SXL.olderNext ? '&before=' + encodeURIComponent(SXL.olderNext) : '');
|
|
3056
|
+
return api('/api/agent-sessions/older' + q).then(function (d) {
|
|
3057
|
+
var rows = (d && Array.isArray(d.sessions) ? d.sessions : []).map(function (x) { return Object.assign({}, x, { form: 'short' }); });
|
|
3058
|
+
SXL.older = more ? (SXL.older || []).concat(rows) : rows;
|
|
3059
|
+
if (!more && typeof d.older_than_window === 'number') SXL.olderN = d.older_than_window;
|
|
3060
|
+
SXL.olderNext = d && d.next_before ? d.next_before : null;
|
|
3061
|
+
SXL.olderOk = true; SXL.olderAt = Date.now();
|
|
3062
|
+
}, function (err) {
|
|
3063
|
+
if (err && (err.status === 404 || err.code === 'not_found')) SXL.olderOk = false;
|
|
3064
|
+
}).then(function () { SXL.olderBusy = false; if (state.pane === 'sessions' && SX.view === 'sessions' && !sxQueryActive()) renderSessionGroups(); });
|
|
3065
|
+
}
|
|
3066
|
+
// The one quiet line at the top of the list: how far back it reaches and how many more are found by search.
|
|
3067
|
+
function sxQuietLine() {
|
|
3068
|
+
if (SXL.olderOk) return 'Last 30 days. ' + (SXL.olderN ? SXL.olderN + ' older through search.' : 'Older ones through search.');
|
|
3069
|
+
var age = sxDropped().age;
|
|
3070
|
+
return 'Last 7 days. ' + (age ? age + ' older through search.' : 'Older ones through search.');
|
|
3071
|
+
}
|
|
3072
|
+
// The sort menu: Updated or Opened; while searching, Best match or Updated.
|
|
3073
|
+
function sxPaintSort() {
|
|
3074
|
+
var sel = $('sessions-sort'); if (!sel) return;
|
|
3075
|
+
var searching = sxQueryActive();
|
|
3076
|
+
var opts = searching ? [['match', 'Best match'], ['newest', 'Updated']] : [['updated', 'Updated'], ['opened', 'Opened']];
|
|
3077
|
+
var cur = searching ? (SX.recency === 'match' ? 'match' : 'newest') : (SX.clock === 'opened' ? 'opened' : 'updated');
|
|
3078
|
+
var html = opts.map(function (o) { return '<option value="' + o[0] + '">' + o[1] + '</option>'; }).join('');
|
|
3079
|
+
if (sel.__html !== html) { sel.innerHTML = html; sel.__html = html; }
|
|
3080
|
+
sel.value = cur;
|
|
3081
|
+
}
|
|
3082
|
+
function sxPaintListHead() {
|
|
3083
|
+
var nf = $('sessions-new-folder'); if (nf) nf.classList.toggle('hidden', !(typeof SXF === 'object' && SXF.ok === true));
|
|
3084
|
+
var on = $('sessions-org-note'); if (on) { var nt = typeof SXF === 'object' ? SXF.note : ''; on.textContent = nt; on.classList.toggle('hidden', !nt); }
|
|
3085
|
+
var line = $('sessions-quiet');
|
|
3086
|
+
if (line) { var t = sxQueryActive() ? '' : sxQuietLine(); line.textContent = t; line.classList.toggle('hidden', !t); }
|
|
3087
|
+
sxPaintSort();
|
|
3088
|
+
}
|
|
3089
|
+
// The Sessions menu: Sessions, Usage, COS cost, and Refresh.
|
|
3090
|
+
function sxViewMenu(open) {
|
|
3091
|
+
var btn = $('sessions-view-btn'), menu = $('sessions-view-menu');
|
|
3092
|
+
if (!btn || !menu) return;
|
|
3093
|
+
if (open == null) open = menu.classList.contains('hidden');
|
|
3094
|
+
menu.classList.toggle('hidden', !open);
|
|
3095
|
+
btn.setAttribute('aria-expanded', String(open));
|
|
3096
|
+
if (open) { var first = menu.querySelector('[role="menuitem"]'); if (first && first.focus) first.focus(); }
|
|
3097
|
+
}
|
|
3098
|
+
function sxListWatch() {
|
|
3099
|
+
var col = $('sessions-list-col');
|
|
3100
|
+
if (!col || col.__sxWatch) return;
|
|
3101
|
+
col.__sxWatch = true;
|
|
3102
|
+
var down = function () { SXL.down = true; SXL.touchAt = Date.now(); };
|
|
3103
|
+
var up = function () { SXL.down = false; SXL.touchAt = Date.now(); sxRegroupLater(); };
|
|
3104
|
+
var moved = function () { SXL.scrollAt = Date.now(); };
|
|
3105
|
+
col.addEventListener('pointerdown', down, { passive: true });
|
|
3106
|
+
col.addEventListener('touchstart', down, { passive: true });
|
|
3107
|
+
['pointerup', 'pointercancel', 'touchend', 'touchcancel'].forEach(function (t) { col.addEventListener(t, up, { passive: true }); });
|
|
3108
|
+
col.addEventListener('scroll', moved, { passive: true });
|
|
3109
|
+
col.addEventListener('wheel', moved, { passive: true });
|
|
3110
|
+
document.addEventListener('click', function (e) {
|
|
3111
|
+
var menu = $('sessions-view-menu');
|
|
3112
|
+
if (menu && !menu.classList.contains('hidden') && !e.target.closest('#sessions-view-menu') && !e.target.closest('#sessions-view-btn')) sxViewMenu(false);
|
|
3113
|
+
});
|
|
3114
|
+
// The row and folder menu lives on <body> (outside this pane): its items are handled here; a tap outside closes it.
|
|
3115
|
+
document.addEventListener('click', function (e) {
|
|
3116
|
+
var item = e.target.closest && e.target.closest('#sessions-pop [data-sx]');
|
|
3117
|
+
if (item) { e.stopPropagation(); return sxOrgClick(item.getAttribute('data-sx'), item); }
|
|
3118
|
+
if (SXF.pop && !(e.target.closest && (e.target.closest('#sessions-pop') || e.target.closest('[data-sx="row-menu"],[data-sx="folder-menu"]')))) sxPopClose();
|
|
3119
|
+
}, true);
|
|
3120
|
+
document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && SXF.pop) { var b = SXF.pop; sxPopClose(); if (b && b.focus) b.focus(); } });
|
|
3121
|
+
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') { var menu = $('sessions-view-menu'); if (menu && !menu.classList.contains('hidden')) { sxViewMenu(false); var b = $('sessions-view-btn'); if (b) b.focus(); } } });
|
|
3122
|
+
}
|
|
3123
|
+
// ── folders, COS pins and rename (7.0.23, step 2b-2; S1's organizer, contract at 469a334) ──
|
|
3124
|
+
// One store on the Mac (GET/POST/PATCH/DELETE /api/session-folders, PATCH /api/agent-sessions/:p/:id/organizer),
|
|
3125
|
+
// shared by the phone and the Mac app. Every change is one small request; after it the folders and the list are
|
|
3126
|
+
// read again. Folder names and renamed titles are untrusted text: drawn with esc() only, never as markup, never in a
|
|
3127
|
+
// URL (S1 rule 6). An older server answers 404 to GET /api/session-folders: no More button and no New folder, and
|
|
3128
|
+
// the list is as before.
|
|
3129
|
+
var SXF = { ok: null, folders: [], byId: {}, at: 0, busy: false, note: '', newOpen: false, renaming: '', folderRenaming: '', armDelete: '', pop: null };
|
|
3130
|
+
var SXF_REASONS = {
|
|
3131
|
+
folder_name_taken: 'A folder already has that name.', invalid_name: 'A folder name is 1 to 60 characters.', too_many_folders: 'That is the most folders this Mac keeps (200).',
|
|
3132
|
+
invalid_title: 'A title is 1 to 120 characters.', alias_conflict: 'Claude is still settling which session this is. Try again in a moment.',
|
|
3133
|
+
session_not_found: 'This session is no longer on the Mac.', folder_not_found: 'That folder is gone. The list has been read again.',
|
|
3134
|
+
too_many_entries: 'This Mac keeps folders, pins and titles for 5,000 sessions at most.', organizer_unavailable: 'Folders, pins and titles are not available on the Mac right now.'
|
|
3135
|
+
};
|
|
3136
|
+
function sxOrgWords(err) { var c = err && (err.code || ''); return SXF_REASONS[c] || 'That did not save. Check that the Mac is awake, then try again.'; }
|
|
3137
|
+
function sxLoadFolders(force) {
|
|
3138
|
+
if (SXF.ok === false || SXF.busy) return Promise.resolve();
|
|
3139
|
+
if (!force && SXF.at && Date.now() - SXF.at < SX_OLDER_MS) return Promise.resolve();
|
|
3140
|
+
SXF.busy = true;
|
|
3141
|
+
return api('/api/session-folders').then(function (d) {
|
|
3142
|
+
SXF.folders = (d && Array.isArray(d.folders) ? d.folders : []).filter(function (f) { return f && typeof f.id === 'string'; });
|
|
3143
|
+
SXF.byId = {}; SXF.folders.forEach(function (f) { SXF.byId[f.id] = f; });
|
|
3144
|
+
SXF.ok = true; SXF.at = Date.now();
|
|
3145
|
+
}, function (err) { if (err && err.status === 404) SXF.ok = false; }).then(function () {
|
|
3146
|
+
SXF.busy = false;
|
|
3147
|
+
if (state.pane === 'sessions' && SX.view === 'sessions' && !sxQueryActive()) renderSessionGroups();
|
|
3148
|
+
});
|
|
3149
|
+
}
|
|
3150
|
+
// A move the person asked for (pin, folder, rename, a deleted folder) applies at once, like a sort change: the rest
|
|
3151
|
+
// rule holds only moves the person did not make.
|
|
3152
|
+
function sxOrgDone(note) {
|
|
3153
|
+
SXF.note = note || ''; SXF.at = 0; SXL.place = null;
|
|
3154
|
+
// A list read already in flight started before this change: wait for it, then read again, so the list shows it.
|
|
3155
|
+
var settled = function () { return SL.inflight ? SL.inflight.then(function () {}, function () {}) : Promise.resolve(); };
|
|
3156
|
+
// The placement is reset once the new list is in (a redraw in between, with the old rows, would use it up).
|
|
3157
|
+
return sxLoadFolders(true).then(settled).then(function () { return loadSessions(true); }).then(function () { SXL.place = null; renderSessionGroups(); });
|
|
3158
|
+
}
|
|
3159
|
+
function sxOrgFail(err) { SXF.note = sxOrgWords(err); sxPaintListHead(); }
|
|
3160
|
+
// A text field inside the list keeps the list from redrawing over what is being typed.
|
|
3161
|
+
function sxEditing() { var a = document.activeElement; return !!(a && a.closest && a.closest('#sessions-list form.sx-inline')); }
|
|
3162
|
+
function sxRowByKey(key) { return sxAllRows().filter(function (s) { return sxRowKey(s) === String(key).toLowerCase(); })[0] || null; }
|
|
3163
|
+
function sxOrgPatch(key, body) {
|
|
3164
|
+
var s = sxRowByKey(key); if (!s) return Promise.resolve();
|
|
3165
|
+
var k = splitSessionId(s.provider, s.session_id || s.id);
|
|
3166
|
+
return api('/api/agent-sessions/' + encodeURIComponent(k.provider) + '/' + encodeURIComponent(k.id) + '/organizer', { method: 'PATCH', body: body });
|
|
3167
|
+
}
|
|
3168
|
+
function sxInlineHtml(kind, id, label, value, hint) {
|
|
3169
|
+
return '<form class="sx-inline" data-sx-form="' + kind + '" data-id="' + esc(id) + '"><label class="sx-inline-label" for="sx-inline-' + kind + '">' + esc(label) + '</label>' +
|
|
3170
|
+
'<input class="field" id="sx-inline-' + kind + '" maxlength="' + (kind === 'rename' ? 120 : 60) + '" autocomplete="off" enterkeyhint="done" value="' + esc(value || '') + '">' +
|
|
3171
|
+
(hint ? '<p class="sx-inline-hint">' + esc(hint) + '</p>' : '') +
|
|
3172
|
+
'<div class="sx-inline-acts"><button type="submit" class="cx-btn primary">' + (kind === 'new-folder' ? 'Create' : 'Save') + '</button>' +
|
|
3173
|
+
(kind === 'rename' && value ? '<button type="button" class="cx-btn quiet" data-sx="rename-clear" data-id="' + esc(id) + '">Use the agent’s title</button>' : '') +
|
|
3174
|
+
'<button type="button" class="cx-btn quiet" data-sx="inline-cancel">Cancel</button></div></form>';
|
|
3175
|
+
}
|
|
3176
|
+
// The row's menu: Pin or Unpin (a COS pin); an agent's own pin is said and has no Unpin; Move to folder; Rename.
|
|
3177
|
+
function sxRowMenuHtml(s, key, moving) {
|
|
3178
|
+
var k = splitSessionId(s.provider, s.session_id || s.id);
|
|
3179
|
+
if (moving) {
|
|
3180
|
+
var cur = s.folder_id || '';
|
|
3181
|
+
return (SXF.folders.length ? SXF.folders.map(function (f) {
|
|
3182
|
+
return '<button type="button" role="menuitemradio" aria-checked="' + (cur === f.id) + '" data-sx="org-move" data-key="' + esc(key) + '" data-folder="' + esc(f.id) + '">' + esc(f.name) + '</button>';
|
|
3183
|
+
}).join('') : '<p class="sx-pop-note">No folders yet. Make one with New folder at the top of the list.</p>') +
|
|
3184
|
+
'<button type="button" role="menuitemradio" aria-checked="' + (!cur) + '" data-sx="org-move" data-key="' + esc(key) + '" data-folder="">No folder</button>';
|
|
3185
|
+
}
|
|
3186
|
+
// An agent's own pin is not COS's to undo; a COS pin under it still is (Q1 07:22 a).
|
|
3187
|
+
return (s.pinned ? '<p class="sx-pop-note">Pinned in ' + sxProviderLabel(k.provider) + '</p>' +
|
|
3188
|
+
(s.cos_pinned ? '<button type="button" role="menuitem" data-sx="org-pin" data-key="' + esc(key) + '">Unpin in COS</button>' : '')
|
|
3189
|
+
: '<button type="button" role="menuitem" data-sx="org-pin" data-key="' + esc(key) + '">' + (s.cos_pinned ? 'Unpin' : 'Pin') + '</button>') +
|
|
3190
|
+
'<button type="button" role="menuitem" data-sx="org-move-menu" data-key="' + esc(key) + '">Move to folder</button>' +
|
|
3191
|
+
'<button type="button" role="menuitem" data-sx="org-rename" data-key="' + esc(key) + '">Rename</button>';
|
|
3192
|
+
}
|
|
3193
|
+
function sxFolderMenuHtml(id) {
|
|
3194
|
+
return '<button type="button" role="menuitem" data-sx="folder-rename" data-id="' + esc(id) + '">Rename</button>' +
|
|
3195
|
+
'<button type="button" role="menuitem" data-sx="folder-delete" data-id="' + esc(id) + '">' + (SXF.armDelete === id ? 'Tap again to delete the folder. Its sessions go back to their day group.' : 'Delete folder') + '</button>';
|
|
3196
|
+
}
|
|
3197
|
+
// One small menu at a time, under its button, inside the visible frame; Escape or a tap outside closes it.
|
|
3198
|
+
function sxPopClose() {
|
|
3199
|
+
var pop = $('sessions-pop'); if (pop) { pop.classList.add('hidden'); pop.innerHTML = ''; }
|
|
3200
|
+
if (SXF.pop && SXF.pop.setAttribute) SXF.pop.setAttribute('aria-expanded', 'false');
|
|
3201
|
+
SXF.pop = null;
|
|
3202
|
+
}
|
|
3203
|
+
function sxPopOpen(btn, html, label) {
|
|
3204
|
+
var pop = $('sessions-pop');
|
|
3205
|
+
if (!pop) { pop = document.createElement('div'); pop.id = 'sessions-pop'; pop.className = 'sx-menu sx-pop hidden'; pop.setAttribute('role', 'menu'); document.body.appendChild(pop); }
|
|
3206
|
+
if (SXF.pop && SXF.pop !== btn && SXF.pop.setAttribute) SXF.pop.setAttribute('aria-expanded', 'false');
|
|
3207
|
+
pop.setAttribute('aria-label', label || 'Menu');
|
|
3208
|
+
pop.innerHTML = html; pop.classList.remove('hidden');
|
|
3209
|
+
SXF.pop = btn; btn.setAttribute('aria-expanded', 'true');
|
|
3210
|
+
var r = btn.getBoundingClientRect(), w = pop.offsetWidth || 220, h = pop.offsetHeight || 160, vw = window.innerWidth || 0, vh = window.innerHeight || 0;
|
|
3211
|
+
pop.style.left = Math.max(8, Math.min(r.right - w, vw - w - 8)) + 'px';
|
|
3212
|
+
pop.style.top = (r.bottom + 4 + h > vh - 8 ? Math.max(8, r.top - 4 - h) : r.bottom + 4) + 'px';
|
|
3213
|
+
var first = pop.querySelector('[role^="menuitem"]'); if (first && first.focus) first.focus();
|
|
3214
|
+
}
|
|
3215
|
+
function sxOrgClick(act, t) {
|
|
3216
|
+
var key = t.getAttribute('data-key') || '', id = t.getAttribute('data-id') || '', s = key ? sxRowByKey(key) : null;
|
|
3217
|
+
if (act === 'row-menu') { if (SXF.pop === t) return sxPopClose(); return s ? sxPopOpen(t, sxRowMenuHtml(s, key, false), 'Session menu') : null; }
|
|
3218
|
+
if (act === 'org-move-menu') { var b = SXF.pop; return s && b ? sxPopOpen(b, sxRowMenuHtml(s, key, true), 'Move to folder') : null; }
|
|
3219
|
+
if (act === 'folder-menu') { SXF.armDelete = ''; if (SXF.pop === t) return sxPopClose(); return sxPopOpen(t, sxFolderMenuHtml(id), 'Folder menu'); }
|
|
3220
|
+
if (act === 'org-pin' && s) { sxPopClose(); return sxOrgPatch(key, { cos_pinned: !s.cos_pinned }).then(function () { return sxOrgDone(); }, sxOrgFail); }
|
|
3221
|
+
if (act === 'org-move' && s) { sxPopClose(); var f = t.getAttribute('data-folder') || null; return sxOrgPatch(key, { folder_id: f }).then(function () { return sxOrgDone(); }, sxOrgFail); }
|
|
3222
|
+
if (act === 'org-rename') { sxPopClose(); SXF.renaming = key; SXF.note = ''; renderSessionGroups(); var i = $('sx-inline-rename'); if (i) { i.focus(); i.select && i.select(); } return; }
|
|
3223
|
+
if (act === 'rename-clear') { SXF.renaming = ''; return sxOrgPatch(id, { title: null }).then(function () { return sxOrgDone(); }, sxOrgFail); }
|
|
3224
|
+
if (act === 'new-folder') { SXF.newOpen = true; SXF.note = ''; renderSessionGroups(); var n = $('sx-inline-new-folder'); if (n) n.focus(); return; }
|
|
3225
|
+
if (act === 'folder-rename') { sxPopClose(); SXF.folderRenaming = id; SXF.note = ''; renderSessionGroups(); var fr = $('sx-inline-folder-rename'); if (fr) { fr.focus(); fr.select && fr.select(); } return; }
|
|
3226
|
+
if (act === 'folder-delete') {
|
|
3227
|
+
if (SXF.armDelete !== id) { SXF.armDelete = id; if (SXF.pop) sxPopOpen(SXF.pop, sxFolderMenuHtml(id), 'Folder menu'); return; }
|
|
3228
|
+
SXF.armDelete = ''; sxPopClose();
|
|
3229
|
+
return api('/api/session-folders/' + encodeURIComponent(id), { method: 'DELETE' }).then(function () { return sxOrgDone(); }, sxOrgFail);
|
|
3230
|
+
}
|
|
3231
|
+
if (act === 'inline-cancel') { SXF.newOpen = false; SXF.renaming = ''; SXF.folderRenaming = ''; var a = document.activeElement; if (a && a.blur) a.blur(); return renderSessionGroups(); }
|
|
3232
|
+
}
|
|
3233
|
+
function sxOrgSubmit(form) {
|
|
3234
|
+
var kind = form.getAttribute('data-sx-form'), id = form.getAttribute('data-id') || '', input = form.querySelector('input');
|
|
3235
|
+
var v = input ? String(input.value || '').trim() : '';
|
|
3236
|
+
if (kind === 'new-folder') {
|
|
3237
|
+
if (!v) { SXF.note = SXF_REASONS.invalid_name; return sxPaintListHead(); }
|
|
3238
|
+
return api('/api/session-folders', { method: 'POST', body: { name: v } }).then(function () { SXF.newOpen = false; if (input) input.blur(); return sxOrgDone(); }, sxOrgFail);
|
|
3239
|
+
}
|
|
3240
|
+
if (kind === 'folder-rename') {
|
|
3241
|
+
if (!v) { SXF.note = SXF_REASONS.invalid_name; return sxPaintListHead(); }
|
|
3242
|
+
return api('/api/session-folders/' + encodeURIComponent(id), { method: 'PATCH', body: { name: v } }).then(function () { SXF.folderRenaming = ''; if (input) input.blur(); return sxOrgDone(); }, sxOrgFail);
|
|
3243
|
+
}
|
|
3244
|
+
if (kind === 'rename') {
|
|
3245
|
+
return sxOrgPatch(id, { title: v || null }).then(function () { SXF.renaming = ''; if (input) input.blur(); return sxOrgDone(); }, sxOrgFail);
|
|
3246
|
+
}
|
|
2726
3247
|
}
|
|
2727
3248
|
// The list, under the hero. The name stays: the shared input handler calls it when the search clears.
|
|
2728
3249
|
function renderSessionGroups() {
|
|
@@ -2737,8 +3258,14 @@
|
|
|
2737
3258
|
var note = sxQueryActive() && SX.ranFor === SX.q && SX.semantic === false ? sxSemanticHint(SX.semanticReason) : '';
|
|
2738
3259
|
hint.textContent = note; hint.classList.toggle('hidden', !note);
|
|
2739
3260
|
}
|
|
3261
|
+
sxListWatch();
|
|
3262
|
+
sxPaintListHead();
|
|
2740
3263
|
var list = $('sessions-list'); if (!list) return;
|
|
3264
|
+
// A field in the list being typed in (New folder, a rename) is never drawn over; the list waits for it.
|
|
3265
|
+
if (!sxQueryActive() && typeof sxEditing === 'function' && sxEditing()) { sxRegroupLater(); return; }
|
|
3266
|
+
var col = $('sessions-list-col'), at = sxQueryActive() ? null : sxAnchor(col);
|
|
2741
3267
|
list.innerHTML = sxQueryActive() ? sxSearchHtml() : sxListHtml();
|
|
3268
|
+
sxRestoreAnchor(col, at);
|
|
2742
3269
|
}
|
|
2743
3270
|
|
|
2744
3271
|
// Search (ActivityWindow.swift sessionsSearchBar 2652-2712, ControllerModel scheduleSessionSearch):
|
|
@@ -2956,15 +3483,22 @@
|
|
|
2956
3483
|
// keep their data-action names, which the shared listener handles.
|
|
2957
3484
|
var sxPane = $('pane-sessions');
|
|
2958
3485
|
if (sxPane) {
|
|
2959
|
-
|
|
3486
|
+
sxPaintSort();
|
|
2960
3487
|
sxPane.addEventListener('click', function (e) {
|
|
2961
3488
|
var t = e.target.closest('[data-sx]');
|
|
2962
3489
|
if (!t || !sxPane.contains(t)) return;
|
|
2963
3490
|
var act = t.getAttribute('data-sx'), rd = state.reading;
|
|
2964
|
-
if (act === 'view') return setSessionsView(t.getAttribute('data-view'));
|
|
3491
|
+
if (act === 'view') { sxViewMenu(false); return setSessionsView(t.getAttribute('data-view')); }
|
|
3492
|
+
if (act === 'view-menu') return sxViewMenu();
|
|
3493
|
+
if (act === 'goto-question') { sxToNewest(); sxGoQuestionPaint(); var gq = $('session-questions'), first = gq && gq.querySelector('button, input, [tabindex]'); if (first && first.focus) first.focus({ preventScroll: true }); return; }
|
|
3494
|
+
if (act === 'fold') { var g = t.getAttribute('data-g'), isF = g.indexOf('f:') === 0; SXL.open[g] = isF ? SXL.open[g] === false : !SXL.open[g]; return renderSessionGroups(); }
|
|
3495
|
+
if (/^(row-menu|org-|folder-|new-folder|rename-clear|inline-cancel)/.test(act)) { e.stopPropagation(); return sxOrgClick(act, t); }
|
|
3496
|
+
if (act === 'older-more') return sxLoadOlder(false, true);
|
|
2965
3497
|
if (act === 'clock') { SX.clock = t.getAttribute('data-clock'); sxSave(); return renderSessionGroups(); }
|
|
2966
3498
|
if (act === 'refresh') {
|
|
3499
|
+
sxViewMenu(false);
|
|
2967
3500
|
if (SX.view !== 'sessions') return renderUsageView(true);
|
|
3501
|
+
sxLoadOlder(true);
|
|
2968
3502
|
SX.hooksAt = 0;
|
|
2969
3503
|
loadSessions(true);
|
|
2970
3504
|
if (sxQueryActive()) searchSessions();
|
|
@@ -2992,9 +3526,23 @@
|
|
|
2992
3526
|
renderSessionGroups();
|
|
2993
3527
|
SX.searchTimer = setTimeout(searchSessions, 350);
|
|
2994
3528
|
});
|
|
3529
|
+
// 7.0.23 step 2b-2: New folder and the renames are inline forms in the list.
|
|
3530
|
+
sxPane.addEventListener('submit', function (e) {
|
|
3531
|
+
var f = e.target;
|
|
3532
|
+
if (!f || !f.matches || !f.matches('form.sx-inline')) return;
|
|
3533
|
+
e.preventDefault();
|
|
3534
|
+
sxOrgSubmit(f);
|
|
3535
|
+
});
|
|
2995
3536
|
sxPane.addEventListener('change', function (e) {
|
|
2996
3537
|
var id = e.target.id;
|
|
2997
|
-
|
|
3538
|
+
// 7.0.23 step 2b: one sort menu. Updated or Opened orders the groups (the rows move together: the list redraws
|
|
3539
|
+
// at once, not on rest, because the person chose it); while searching it is Best match or Updated.
|
|
3540
|
+
if (id === 'sessions-sort') {
|
|
3541
|
+
var v = e.target.value;
|
|
3542
|
+
if (sxQueryActive()) SX.recency = v === 'match' ? 'match' : 'newest';
|
|
3543
|
+
else { SX.clock = v === 'opened' ? 'opened' : 'updated'; SXL.place = null; }
|
|
3544
|
+
sxSave(); return renderSessionGroups();
|
|
3545
|
+
}
|
|
2998
3546
|
// Custom dates opens the date sheet; the menu goes back to the range in force if it is cancelled.
|
|
2999
3547
|
if (id === 'usage-period') { if (e.target.value === U_CUSTOM) return uOpenRange(e.target, true); SX.uPeriod = e.target.value; uPeriodChanged(); return paintUsage(); }
|
|
3000
3548
|
if (id === 'usage-tool') { SX.uTool = e.target.value; SX.uModel = 'All models'; SX.uToolBefore = null; uSliceChanged(); return paintUsage(); }
|
|
@@ -3013,6 +3561,12 @@
|
|
|
3013
3561
|
// window) only means the choice lasts this visit. Below a pane's own breakpoint the divider is hidden by CSS and the
|
|
3014
3562
|
// kit does nothing (o.media), so a phone's layout never changes.
|
|
3015
3563
|
var SX_SPLIT_KEY = 'cos_web_sessions_split', SX_LIST_MIN = 240, SX_READER_MIN = 380, SX_HANDLE_W = 18, SX_KEY_STEP = 16, SX_DOUBLE_MS = 350;
|
|
3564
|
+
// 11/10 (lead 20:39): Miles squeezes the reader on his Duo's Home Screen web app and the page breaks; WebKit at scale 1
|
|
3565
|
+
// does not (cw = scw, reader floor 380). One layout report when a drag ends, so his phone says what the page did:
|
|
3566
|
+
// the split, list and reader widths, the share, clientWidth and scrollWidth, visualViewport.pageTop, pageLeft and
|
|
3567
|
+
// offsetLeft, scrollX, and the top, left and right of the rail, the list and the other column (layoutCrumb adds the
|
|
3568
|
+
// scale and offsetTop itself). Numbers only, at most SPLIT_DRAG_MAX a page load.
|
|
3569
|
+
var SPLIT_DRAG = { n: 0 }, SPLIT_DRAG_MAX = 6;
|
|
3016
3570
|
// o: key (storage), prefix (class names: <prefix>-sized, -full and -dragging), cssVar (the share, default
|
|
3017
3571
|
// --<prefix>-list-share), label (what the divider sizes, for screen readers), sized (the column the share sizes,
|
|
3018
3572
|
// default the list), side ('end' when that column is on the right: a side panel grows as the line moves left),
|
|
@@ -3048,7 +3602,7 @@
|
|
|
3048
3602
|
split.classList.toggle(PRE + '-sized', share != null);
|
|
3049
3603
|
if (share != null) split.style.setProperty(VAR, share.toFixed(2) + '%'); else split.style.removeProperty(VAR);
|
|
3050
3604
|
split.classList.toggle(PRE + '-full', full);
|
|
3051
|
-
document.querySelectorAll(FULL_SEL).forEach(function (b) { b.setAttribute('aria-pressed', String(full)); b.textContent = full ? 'Show list' : 'Full width'; });
|
|
3605
|
+
document.querySelectorAll(FULL_SEL).forEach(function (b) { b.setAttribute('aria-pressed', String(full)); b.textContent = full ? 'Show list' : (o.hideText || 'Full width'); });
|
|
3052
3606
|
aria();
|
|
3053
3607
|
}
|
|
3054
3608
|
// px: the sized column's width asked for; kept inside both columns' minimums for the split's width now.
|
|
@@ -3061,6 +3615,26 @@
|
|
|
3061
3615
|
if (persist) save();
|
|
3062
3616
|
}
|
|
3063
3617
|
function reset() { share = null; paint(); save(); }
|
|
3618
|
+
function dragReport() {
|
|
3619
|
+
if (SPLIT_DRAG.n >= SPLIT_DRAG_MAX || typeof layoutCrumb !== 'function') return;
|
|
3620
|
+
try {
|
|
3621
|
+
var de = document.documentElement, vv = window.visualViewport, t = total(), lw = listWidth(),
|
|
3622
|
+
hw = handle.getBoundingClientRect ? handle.getBoundingClientRect().width : HANDLE_W,
|
|
3623
|
+
rail = document.querySelector('.rail'), list = split.querySelector(LIST_SEL), other = null;
|
|
3624
|
+
// The other column: the split's child that is neither the sized column nor the divider.
|
|
3625
|
+
for (var c = split.firstElementChild; c; c = c.nextElementSibling) if (c !== list && c !== handle) other = c;
|
|
3626
|
+
var vn = function (k) { return vv && typeof vv[k] === 'number' ? vv[k] : -1; };
|
|
3627
|
+
var rr = rail ? rail.getBoundingClientRect() : null, lr = list ? list.getBoundingClientRect() : null, or = other ? other.getBoundingClientRect() : null;
|
|
3628
|
+
// Up and down (pt, the tops) and sideways (pl, ol, sx, the left and right edges): a rail or list that left the
|
|
3629
|
+
// screen to the side shows here too (Q1 20:44).
|
|
3630
|
+
if (layoutCrumb('split-drag', { key: String(KEY).slice(0, 24), t: t, sz: lw, rw: t - lw - hw, share: share == null ? -1 : share,
|
|
3631
|
+
cw: de.clientWidth, scw: de.scrollWidth, pt: vn('pageTop'), pl: vn('pageLeft'), ol: vn('offsetLeft'),
|
|
3632
|
+
sx: typeof window.scrollX === 'number' ? window.scrollX : -1,
|
|
3633
|
+
railTop: rr ? rr.top : -1, railL: rr ? rr.left : -1, railR: rr ? rr.right : -1,
|
|
3634
|
+
listTop: lr ? lr.top : -1, listL: lr ? lr.left : -1, listR: lr ? lr.right : -1,
|
|
3635
|
+
rdL: or ? or.left : -1, rdR: or ? or.right : -1 })) SPLIT_DRAG.n++;
|
|
3636
|
+
} catch (_e) { /* a report never stops a drag */ }
|
|
3637
|
+
}
|
|
3064
3638
|
handle.addEventListener('pointerdown', function (e) {
|
|
3065
3639
|
if (e.pointerType === 'mouse' && e.button !== 0) return;
|
|
3066
3640
|
if (narrow()) return;
|
|
@@ -3081,7 +3655,7 @@
|
|
|
3081
3655
|
handle.removeEventListener('pointermove', move); handle.removeEventListener('pointerup', end); handle.removeEventListener('pointercancel', end);
|
|
3082
3656
|
split.classList.remove(PRE + '-dragging');
|
|
3083
3657
|
// The lift's own position counts: a browser may fold the last moves into it.
|
|
3084
|
-
if (moved) { if (ev.type === 'pointerup') setWidth(start + dx(ev), false); save(); lastTap = 0; return; }
|
|
3658
|
+
if (moved) { if (ev.type === 'pointerup') setWidth(start + dx(ev), false); save(); lastTap = 0; dragReport(); return; }
|
|
3085
3659
|
if (ev.type !== 'pointerup') return;
|
|
3086
3660
|
var now = Date.now();
|
|
3087
3661
|
if (now - lastTap < SX_DOUBLE_MS) { lastTap = 0; reset(); } else lastTap = now;
|
|
@@ -3107,7 +3681,8 @@
|
|
|
3107
3681
|
};
|
|
3108
3682
|
}
|
|
3109
3683
|
// Sessions: the first pane with the divider, unchanged (its markup is in index.html, its CSS in panes/sessions.css).
|
|
3110
|
-
|
|
3684
|
+
// 7.0.23: in Sessions the button says what it does, Hide list (DESIGN_PLAN v2); Memories and Messages keep Full width.
|
|
3685
|
+
var SX_SPLIT_OPTS = { key: SX_SPLIT_KEY, prefix: 'sx', label: 'Session list', fullSel: '[data-sx="full"]', media: '(min-width: 760px)', hideText: 'Hide list',
|
|
3111
3686
|
listMin: SX_LIST_MIN, readerMin: SX_READER_MIN, handleW: SX_HANDLE_W };
|
|
3112
3687
|
function sxSplitKit(split, handle, storage) { return splitKit(split, handle, storage, SX_SPLIT_OPTS); }
|
|
3113
3688
|
// The other panes (W.splitKit): the divider's markup, a Full width button, and mount, which binds the kit to a split
|
|
@@ -3290,7 +3865,7 @@
|
|
|
3290
3865
|
// Wide layouts: give the reader the whole width, or bring the list back (sxSplitKit). Hidden under 760 px by CSS.
|
|
3291
3866
|
function sxFullButton() {
|
|
3292
3867
|
var full = !!(SX_SPLIT && SX_SPLIT.state().full);
|
|
3293
|
-
return '<button type="button" class="cx-btn quiet sx-full-btn" data-sx="full" aria-controls="sessions-list-col" aria-pressed="' + full + '">' + (full ? 'Show list' : '
|
|
3868
|
+
return '<button type="button" class="cx-btn quiet sx-full-btn" data-sx="full" aria-controls="sessions-list-col" aria-pressed="' + full + '">' + (full ? 'Show list' : 'Hide list') + '</button>';
|
|
3294
3869
|
}
|
|
3295
3870
|
function openSession(provider, id) {
|
|
3296
3871
|
var reader = $('session-reader');
|
|
@@ -3317,17 +3892,25 @@
|
|
|
3317
3892
|
reader.innerHTML =
|
|
3318
3893
|
'<div class="sx-reader-head"><div class="sx-head-line">' + sxBadge(provider) + '<h2 class="sx-reader-title">' + esc(title) + '</h2>' + sxFullButton() + '</div>' +
|
|
3319
3894
|
'<p class="sx-subtitle">' + esc(sxSubtitle(d, provider, turns, r.more)) + '</p></div>' +
|
|
3895
|
+
// 7.0.23 frame: the conversation scrolls by itself between the header and the dock.
|
|
3896
|
+
'<div class="sx-body" id="session-body">' +
|
|
3897
|
+
(trunc ? '<p class="sx-note">' + esc(trunc) + '</p>' : '') +
|
|
3898
|
+
'<div class="turns sx-turns" id="session-turns">' + renderTurns(turns, d) + '</div>' +
|
|
3899
|
+
'<div class="sx-foot"><button type="button" class="cx-btn quiet" data-sx="copy-session" title="A kickstart brief for another agent, built from the turns COS serves. Not a Claude Code resume: it does not reopen this session.">Copy session</button>' +
|
|
3900
|
+
'<span class="sx-foot-note">Kickstart brief for another agent. Not a Claude Code resume.</span></div>' +
|
|
3901
|
+
'<p class="sx-copy-note hidden" id="session-copy-note" role="status"></p>' +
|
|
3902
|
+
// DESIGN.md step 4 #3: this session's question cards (held and answerable, or at the Mac with its words), the
|
|
3903
|
+
// same component Today's sheet mounts. 7.0.23: at the end of the conversation, the newest thing in it, right
|
|
3904
|
+
// above the reply box: a card is too tall for the dock on the Duo (it hid the choices or the reply box), and
|
|
3905
|
+
// here it opens in view with the reader and scrolls with it.
|
|
3906
|
+
'<div class="sx-questions" id="session-questions"></div>' +
|
|
3907
|
+
'</div>' +
|
|
3908
|
+
// The dock, fixed at the bottom of the reader: what it is doing now (with Stop), the Mac's question, then the
|
|
3909
|
+
// verdict, the reply box, its status and the queue, all as before.
|
|
3910
|
+
'<div class="sx-dock" id="session-dock">' +
|
|
3320
3911
|
'<div class="sx-activity" id="session-live" aria-live="polite"></div>' +
|
|
3321
3912
|
'<div class="sx-mac-q" id="session-mac-q" role="status" hidden></div>' +
|
|
3322
|
-
(trunc ? '<p class="sx-note">' + esc(trunc) + '</p>' : '') +
|
|
3323
|
-
'<div class="turns sx-turns" id="session-turns">' + renderTurns(turns, d) + '</div>' +
|
|
3324
|
-
'<div class="sx-foot"><button type="button" class="cx-btn quiet" data-sx="copy-session" title="A kickstart brief for another agent, built from the turns COS serves. Not a Claude Code resume: it does not reopen this session.">Copy session</button>' +
|
|
3325
|
-
'<span class="sx-foot-note">Kickstart brief for another agent. Not a Claude Code resume.</span></div>' +
|
|
3326
|
-
'<p class="sx-copy-note hidden" id="session-copy-note" role="status"></p>' +
|
|
3327
3913
|
'<div class="sx-chat">' +
|
|
3328
|
-
// DESIGN.md step 4 #3: this session's question cards (held and answerable, or at the Mac with its words), above
|
|
3329
|
-
// the verdict and the Send box, the same component Today's sheet mounts, so the full hold lights up here.
|
|
3330
|
-
'<div class="sx-questions" id="session-questions"></div>' +
|
|
3331
3914
|
'<div id="session-verdict"></div>' +
|
|
3332
3915
|
'<form class="composer att-composer" id="session-form" data-provider="' + esc(provider) + '" data-id="' + esc(id) + '">' +
|
|
3333
3916
|
'<div class="cc-chips att-files hidden" id="session-files"></div>' +
|
|
@@ -3337,13 +3920,16 @@
|
|
|
3337
3920
|
'</form><div class="status-line" id="session-status"></div>' +
|
|
3338
3921
|
'<p class="sx-caution hidden" id="session-caution"></p>' +
|
|
3339
3922
|
'<div id="session-queue"></div>' +
|
|
3340
|
-
'</div>'
|
|
3923
|
+
'</div></div>' +
|
|
3924
|
+
// Q1 (23:40): the question cards end the conversation; scrolled up away from them, a button over the dock's top
|
|
3925
|
+
// edge says one waits. It sits in the reader, not the dock, because the dock scrolls by itself as a last resort.
|
|
3926
|
+
'<p class="sx-goq" id="session-goq" hidden><span>A question is waiting at the end.</span><button type="button" class="cx-btn quiet" data-sx="goto-question">Go to the question</button></p>';
|
|
3341
3927
|
readerImagesMount($('session-turns'), r);
|
|
3342
3928
|
sxNoteList(r, d);
|
|
3343
3929
|
setLive({});
|
|
3344
3930
|
if (typeof readerMacPaint === 'function') readerMacPaint(r, sxState(d) === 'waiting');
|
|
3345
3931
|
var qk = window.COSWeb.sessionQuestions, qhost = $('session-questions');
|
|
3346
|
-
if (qk && qk.mount && qhost) r.qMount = qk.mount(qhost, id, function () { if (state.reading === r && typeof readerMacPaint === 'function') readerMacPaint(r, r.waitingNow); });
|
|
3932
|
+
if (qk && qk.mount && qhost) r.qMount = qk.mount(qhost, id, function () { if (state.reading === r && typeof readerMacPaint === 'function') readerMacPaint(r, r.waitingNow); if (typeof sxGoQuestionPaint === 'function') sxGoQuestionPaint(); });
|
|
3347
3933
|
paintReaderFiles();
|
|
3348
3934
|
paintWebKeyOffer(r);
|
|
3349
3935
|
// The Mac's answer about files may not be in yet: ask once, then draw the clip.
|
|
@@ -3355,7 +3941,8 @@
|
|
|
3355
3941
|
sessionStatus('Waiting for the current reply. ' + (state.reading.held.length > 1 ? state.reading.held.length + ' messages send' : 'Yours sends') + ' as soon as it finishes. ', null, '<button type="button" class="link-btn" data-action="drop-after">Don’t send</button>');
|
|
3356
3942
|
startHeldTimer(state.reading);
|
|
3357
3943
|
}
|
|
3358
|
-
if (turns.length) requestAnimationFrame(function () { window.scrollTo(0, document.documentElement.scrollHeight); });
|
|
3944
|
+
if (turns.length) requestAnimationFrame(function () { if (typeof sxToNewest === 'function') sxToNewest(); else window.scrollTo(0, document.documentElement.scrollHeight); });
|
|
3945
|
+
requestAnimationFrame(function () { if (typeof sxFrameCrumb === 'function') sxFrameCrumb('open'); });
|
|
3359
3946
|
watchSession(provider, id);
|
|
3360
3947
|
}).catch(function (err) {
|
|
3361
3948
|
if (state.reading !== r) return;
|
|
@@ -3666,6 +4253,22 @@
|
|
|
3666
4253
|
if (p === 2) return '3rd in line';
|
|
3667
4254
|
return (p + 1) + 'th in line';
|
|
3668
4255
|
}
|
|
4256
|
+
// Why a waiting follow-up is held (server 7.0.25, S1: waiting_reason on the row, a short code the drain decided).
|
|
4257
|
+
// Plain words for the codes we know; any other well-formed code reads as a plain wait; a malformed value is not shown.
|
|
4258
|
+
var SX_HOLD_WORDS = {
|
|
4259
|
+
files_wait_for_turn_end: 'It has files, so it waits for the current turn to end.',
|
|
4260
|
+
turn_open: 'Waiting for the current turn to end.', turn_not_ended: 'Waiting for the current turn to end.',
|
|
4261
|
+
native_thread_working: 'Waiting for the current turn to end.',
|
|
4262
|
+
live_desktop_process: 'Waiting while another app has this session open.',
|
|
4263
|
+
native_target_busy: 'Waiting: another COS turn holds this session.',
|
|
4264
|
+
native_target_fenced: 'Waiting for this session to be released.',
|
|
4265
|
+
cancel_hold: 'Held for 2 minutes after a stop.'
|
|
4266
|
+
};
|
|
4267
|
+
function sxQueueWhy(t) {
|
|
4268
|
+
var c = t && t.waiting_reason;
|
|
4269
|
+
if (typeof c !== 'string' || !/^[a-z0-9_]{1,48}$/.test(c)) return '';
|
|
4270
|
+
return Object.prototype.hasOwnProperty.call(SX_HOLD_WORDS, c) ? SX_HOLD_WORDS[c] : 'Waiting for this session to be free.';
|
|
4271
|
+
}
|
|
3669
4272
|
function loadQueue(provider, id) {
|
|
3670
4273
|
apiAnswer(sessionPath(provider, id, '/queued-turns')).then(function (q) {
|
|
3671
4274
|
var r = state.reading;
|
|
@@ -3681,7 +4284,9 @@
|
|
|
3681
4284
|
box.innerHTML = '<div class="sx-queue">' + (waiting.length ? '<p class="sx-queue-head">' + (waiting.length === 1 ? '1 follow-up queued. It sends when this session is available.' : waiting.length + ' follow-ups queued. They send in order when this session is available.') + '</p>' : '') +
|
|
3682
4285
|
waiting.map(function (t) {
|
|
3683
4286
|
var mine = t.owner !== 'codex' && parkedByMe(r, t.clientTurnId);
|
|
3684
|
-
|
|
4287
|
+
var why = sxQueueWhy(t);
|
|
4288
|
+
return '<div class="sx-queue-row"><span class="sx-queue-state">' + esc(sxQueueLine(t)) + '</span><span class="sx-queue-text">' + esc(t.preview || t.prompt || 'Message') +
|
|
4289
|
+
(why ? '<span class="sx-queue-reason">' + esc(why) + '</span>' : '') + '</span>' +
|
|
3685
4290
|
(mine ? '<button type="button" class="cx-btn quiet" data-action="unqueue" data-turn-key="' + esc(t.clientTurnId) + '" title="Cancel this queued message. It will not be sent.">Cancel</button>' : '') + '</div>';
|
|
3686
4291
|
}).join('') +
|
|
3687
4292
|
notSent.map(function (t) {
|
|
@@ -3818,7 +4423,7 @@
|
|
|
3818
4423
|
if (heldKey) el.setAttribute('data-held-key', heldKey);
|
|
3819
4424
|
el.innerHTML = '<span class="who">You, ' + esc(note) + '</span>' + md(text);
|
|
3820
4425
|
box.appendChild(el);
|
|
3821
|
-
requestAnimationFrame(function () { window.scrollTo(0, document.documentElement.scrollHeight); });
|
|
4426
|
+
requestAnimationFrame(function () { if (typeof sxToNewest === 'function') sxToNewest(); else window.scrollTo(0, document.documentElement.scrollHeight); });
|
|
3822
4427
|
}
|
|
3823
4428
|
function heldBubble(key) { return document.querySelector('#session-turns .turn.pending[data-held-key="' + key.replace(/[^A-Za-z0-9_-]/g, '') + '"]'); }
|
|
3824
4429
|
function renderHeld(r) {
|
|
@@ -4671,15 +5276,23 @@
|
|
|
4671
5276
|
box.insertAdjacentHTML('beforeend', fresh.map(sxTurnHtml).join(''));
|
|
4672
5277
|
return;
|
|
4673
5278
|
}
|
|
5279
|
+
// In the frame the conversation scrolls inside #session-body: the card kept is the first one showing in it, and the
|
|
5280
|
+
// body (not the window) moves back by what the redraw moved it.
|
|
5281
|
+
var body = document.documentElement.classList.contains('sx-frame') ? $('session-body') : null;
|
|
5282
|
+
var edge = body && body.getBoundingClientRect ? body.getBoundingClientRect().top : 0;
|
|
4674
5283
|
var anchor = null, top = 0, cards = box.querySelectorAll('.turn[data-sig]');
|
|
4675
5284
|
for (var i = 0; i < cards.length; i++) {
|
|
4676
5285
|
var rect = cards[i].getBoundingClientRect();
|
|
4677
|
-
if (rect.bottom >
|
|
5286
|
+
if (rect.bottom > edge) { anchor = cards[i].getAttribute('data-sig'); top = rect.top; break; }
|
|
4678
5287
|
}
|
|
4679
5288
|
box.innerHTML = renderTurns(next, d);
|
|
5289
|
+
// Still at the newest turn since it opened (sxToNewest's pin): stay there, rather than keep a card in place.
|
|
5290
|
+
if (body && typeof SX_PIN === 'object' && SX_PIN.body === body) { body.scrollTop = body.scrollHeight; SX_PIN.put = body.scrollTop; return; }
|
|
4680
5291
|
if (!anchor) return;
|
|
4681
5292
|
var same = [].filter.call(box.querySelectorAll('.turn[data-sig]'), function (el) { return el.getAttribute('data-sig') === anchor; })[0];
|
|
4682
|
-
if (same)
|
|
5293
|
+
if (!same) return;
|
|
5294
|
+
if (body) body.scrollTop += same.getBoundingClientRect().top - top;
|
|
5295
|
+
else window.scrollBy(0, same.getBoundingClientRect().top - top);
|
|
4683
5296
|
}
|
|
4684
5297
|
|
|
4685
5298
|
// ── Ask COS (the Ask sheet's first row) ─────────────────
|
|
@@ -6784,6 +7397,15 @@
|
|
|
6784
7397
|
// fixed composer clears to sit above the keyboard. 0 when the web view itself shrank.
|
|
6785
7398
|
var VP = { layoutH: 0 };
|
|
6786
7399
|
VP.maxH = 0; VP.w = -1; VP.free = 0; VP.typing = false; VP.blurAt = 0;
|
|
7400
|
+
// 7.0.23 (lead 23:02, from Miles's phone rows 11/10): on the unfolded Duo, after a keyboard closed, visualViewport
|
|
7401
|
+
// reported a height of 0 (with an offset anywhere from 0 to 787) for minutes at a time, while innerHeight was back at
|
|
7402
|
+
// 787 within 0.3 s. A visible height under VV_MIN_PX is never a real reading, whatever causes it: it is treated as no
|
|
7403
|
+
// reading, the box is innerHeight from offset 0, and it is counted (VP.badN runs of it, VP.badReads reads), which every
|
|
7404
|
+
// layout report carries as vz and vzr. Why 80: the smallest real visible height in his 99 rows that day is 359.98
|
|
7405
|
+
// (folded, keyboard up), a phone on its side with the keyboard up still shows more than 80, and under 80 nothing fits
|
|
7406
|
+
// a reply box and a header anyway. Without a keyboard innerHeight is the visible height, so the fallback costs nothing.
|
|
7407
|
+
var VV_MIN_PX = 80;
|
|
7408
|
+
VP.bad = false; VP.badN = 0; VP.badReads = 0;
|
|
6787
7409
|
var VP_SLACK_PX = 20, VP_SETTLE_MS = 1000;
|
|
6788
7410
|
function vpTyping() {
|
|
6789
7411
|
var a = document.activeElement;
|
|
@@ -6819,7 +7441,13 @@
|
|
|
6819
7441
|
}
|
|
6820
7442
|
else if (!VP.layoutH) VP.layoutH = Math.max(inner, (de && de.clientHeight) || 0);
|
|
6821
7443
|
var H = VP.layoutH;
|
|
6822
|
-
if (!vv
|
|
7444
|
+
if (!vv) return { layoutH: H, top: 0, height: H, inset: 0, below: 0, dock: 0, scale: 1, zoomed: false, vv: false };
|
|
7445
|
+
if (!(vv.height >= VV_MIN_PX)) {
|
|
7446
|
+
VP.badReads++;
|
|
7447
|
+
if (!VP.bad) { VP.bad = true; VP.badN++; }
|
|
7448
|
+
return { layoutH: H, top: 0, height: Math.round(inner) || H, inset: 0, below: 0, dock: 0, scale: 1, zoomed: false, vv: false, bad: true };
|
|
7449
|
+
}
|
|
7450
|
+
VP.bad = false;
|
|
6823
7451
|
var inset = Math.max(0, Math.round(H - vv.height * scale));
|
|
6824
7452
|
var top, height;
|
|
6825
7453
|
if (!zoomed) { top = Math.max(0, Math.round(vv.offsetTop || 0)); height = Math.min(Math.round(vv.height), H); }
|
|
@@ -6862,7 +7490,8 @@
|
|
|
6862
7490
|
var o = (screen && screen.orientation && screen.orientation.type) || (typeof window.orientation === 'number' ? 'deg' + window.orientation : '');
|
|
6863
7491
|
var data = { what: String(event || ''), ua: ua.brand, os: ua.os, iw: r(window.innerWidth), ih: r(window.innerHeight), ch: r(de && de.clientHeight),
|
|
6864
7492
|
vvh: vv ? r(vv.height) : null, vvw: vv ? r(vv.width) : null, vvt: vv ? r(vv.offsetTop) : null, vvs: vv ? r(vv.scale) : null,
|
|
6865
|
-
orient: String(o).slice(0, 24), coarse: !!(window.matchMedia && window.matchMedia('(pointer: coarse)').matches)
|
|
7493
|
+
orient: String(o).slice(0, 24), coarse: !!(window.matchMedia && window.matchMedia('(pointer: coarse)').matches),
|
|
7494
|
+
vz: VP.badN, vzr: VP.badReads };
|
|
6866
7495
|
var x = extra || {};
|
|
6867
7496
|
Object.keys(x).forEach(function (k) { var v = x[k]; if (/^[a-z][a-zA-Z0-9]{0,15}$/.test(k) && (typeof v === 'number' || typeof v === 'boolean' || (typeof v === 'string' && v.length <= 24))) data[k] = typeof v === 'number' ? r(v) : v; });
|
|
6868
7497
|
var key = JSON.stringify(data), now = Date.now();
|
|
@@ -7013,13 +7642,44 @@
|
|
|
7013
7642
|
return typeof v === 'number' && isFinite(v) ? v : null;
|
|
7014
7643
|
}
|
|
7015
7644
|
function lrMatch(q) { try { return !!window.matchMedia(q).matches; } catch (_e) { return false; } }
|
|
7645
|
+
// The page wider than the screen (the Duo, 11/10: scale 0.85, scroll width 449 for a 382 screen). Two causes look the
|
|
7646
|
+
// same from outside; these numbers narrow it: cw, the layout viewport; dw, the width this device shows at scale 1;
|
|
7647
|
+
// scw, the document's scroll width. cw = scw, both above dw, says only that the layout viewport itself is wider: a
|
|
7648
|
+
// browser or phone setting, or iOS widening the layout to fit content (Q1 15:27); nothing is named or scanned then, so
|
|
7649
|
+
// '' is not proof of a setting. Our page overflowing a layout that stayed at the device width shows as scw above cw
|
|
7650
|
+
// (the Duo's 11/10 rows: scroll width 449 while the width was 382 at scale 1): then `wide` names the
|
|
7651
|
+
// element that sticks out, the first in page order whose right edge passes cw. Page order puts a parent before its
|
|
7652
|
+
// children, so that element's parent ends within cw: it is the one that sticks out of its container, never a
|
|
7653
|
+
// stretched container (those end at cw; Q1 15:20). One scan per layout (cached by cw and scw), at most 4000 elements.
|
|
7654
|
+
var LR_WIDE = { key: '', out: null };
|
|
7655
|
+
function lrWide() {
|
|
7656
|
+
var num = function (v) { return typeof v === 'number' && isFinite(v) ? Math.round(v) : null; };
|
|
7657
|
+
var de = document.documentElement, vv = window.visualViewport, out = { cw: num(de && de.clientWidth), dw: null, scw: null, wide: '' };
|
|
7658
|
+
try {
|
|
7659
|
+
var se = document.scrollingElement || de;
|
|
7660
|
+
out.scw = num(se && se.scrollWidth);
|
|
7661
|
+
out.dw = vv ? num(vv.width * vv.scale) : num(window.innerWidth);
|
|
7662
|
+
if (!(out.cw && out.scw && out.scw > out.cw + 1)) return out;
|
|
7663
|
+
var key = out.cw + 'x' + out.scw;
|
|
7664
|
+
if (LR_WIDE.key === key && LR_WIDE.out) { out.wide = LR_WIDE.out.wide; return out; }
|
|
7665
|
+
var all = document.body ? document.body.getElementsByTagName('*') : [], edge = out.cw + 1, sx = window.scrollX || 0;
|
|
7666
|
+
for (var i = 0; i < all.length && i < 4000; i++) {
|
|
7667
|
+
var el = all[i], r = el.getBoundingClientRect();
|
|
7668
|
+
if (!(r.width > 0 && r.right + sx > edge)) continue;
|
|
7669
|
+
out.wide = (el.tagName.toLowerCase() + (el.id ? '#' + el.id : (typeof el.className === 'string' && el.className.trim() ? '.' + el.className.trim().split(/\s+/)[0] : ''))).slice(0, 24);
|
|
7670
|
+
break;
|
|
7671
|
+
}
|
|
7672
|
+
LR_WIDE.key = key; LR_WIDE.out = { wide: out.wide };
|
|
7673
|
+
} catch (_e) { /* a report only */ }
|
|
7674
|
+
return out;
|
|
7675
|
+
}
|
|
7016
7676
|
function lrDevice() {
|
|
7017
7677
|
if (LR.device) return;
|
|
7018
7678
|
LR.device = true;
|
|
7019
|
-
var sc = window.screen || {};
|
|
7679
|
+
var sc = window.screen || {}, w = lrWide();
|
|
7020
7680
|
layoutCrumb('device', { sw: sc.width, sh: sc.height, sah: sc.availHeight, oh: window.outerHeight, ow: window.outerWidth, dpr: window.devicePixelRatio,
|
|
7021
7681
|
sat: lrProbe('padding-top:env(safe-area-inset-top)', 'paddingTop'), sab: lrProbe('padding-bottom:env(safe-area-inset-bottom)', 'paddingBottom'),
|
|
7022
|
-
anyCoarse: lrMatch('(any-pointer: coarse)'), shortTouch: lrMatch(SHORT_TOUCH_MQ), phone: isPhone() });
|
|
7682
|
+
anyCoarse: lrMatch('(any-pointer: coarse)'), shortTouch: lrMatch(SHORT_TOUCH_MQ), phone: isPhone(), cw: w.cw, dw: w.dw, scw: w.scw, wide: w.wide });
|
|
7023
7683
|
}
|
|
7024
7684
|
// The focus line is for touch screens only (QA W3): on a Mac it would only spend the budget.
|
|
7025
7685
|
function lrFocus() {
|
|
@@ -7029,7 +7689,9 @@
|
|
|
7029
7689
|
if (!lrMatch('(pointer: coarse)')) return;
|
|
7030
7690
|
try { fs = parseFloat(getComputedStyle(a).fontSize); } catch (_e) { fs = null; }
|
|
7031
7691
|
var s = lrScroll();
|
|
7032
|
-
|
|
7692
|
+
// The width numbers only when this focus line will be sent (its own budget), so a capped page never scans.
|
|
7693
|
+
var w = typeof crumbs === 'object' && crumbs && crumbs.focus >= LAYOUT_FOCUS_MAX ? { cw: null, dw: null, wide: '' } : lrWide();
|
|
7694
|
+
layoutCrumb('focus', { pane: lrPane(), field: lrKind(a), fs: fs, sy: s.sy, smax: s.smax, scw: s.sw, kb: LR.up, n: ++LR.focusN, cw: w.cw, dw: w.dw, wide: w.wide });
|
|
7033
7695
|
}
|
|
7034
7696
|
function lrHooks(what, v) { LR.hooks.forEach(function (fn) { try { fn(what, v); } catch (err) { console.error(err); } }); }
|
|
7035
7697
|
function lrGap(v) {
|
|
@@ -7112,6 +7774,13 @@
|
|
|
7112
7774
|
|
|
7113
7775
|
// Every pane module is a plain script after this one; boot waits for all of them.
|
|
7114
7776
|
function boot() {
|
|
7777
|
+
// The open-time report's clock (ms since navigation start, -1 where there is none) and the two numbers boot gives it:
|
|
7778
|
+
// when boot started (every script had run) and the sign-in check's round trip. Kept inside boot, and FIRST_PAINT
|
|
7779
|
+
// looked up, never assumed, because the harnesses that run boot alone have neither.
|
|
7780
|
+
var openClock = function () { try { return typeof performance === 'object' && performance && performance.now ? performance.now() : -1; } catch (_e) { return -1; } };
|
|
7781
|
+
var openTimes = typeof FIRST_PAINT === 'object' && FIRST_PAINT ? FIRST_PAINT : null;
|
|
7782
|
+
var openRtt = function (asked) { if (openTimes && asked >= 0 && openTimes.rtt == null) openTimes.rtt = Math.round(openClock() - asked); };
|
|
7783
|
+
if (openTimes && openTimes.boot == null) openTimes.boot = Math.round(openClock());
|
|
7115
7784
|
var params = new URLSearchParams(location.search);
|
|
7116
7785
|
var linkCode = params.get('code');
|
|
7117
7786
|
if (linkCode && !params.get('handoff')) {
|
|
@@ -7134,7 +7803,9 @@
|
|
|
7134
7803
|
return;
|
|
7135
7804
|
}
|
|
7136
7805
|
// The status read stays here in boot() itself (routes/web-phone-link.test.ts runs boot alone and pins it).
|
|
7137
|
-
|
|
7806
|
+
// The open-time report carries its round trip (firstPaintDone).
|
|
7807
|
+
var asked = openClock();
|
|
7808
|
+
api('/api/web-session/status').then(function (st) { openRtt(asked); return bootAnswer(st); }, function (err) { openRtt(asked); return bootFail(err); });
|
|
7138
7809
|
}
|
|
7139
7810
|
// S1 item 7 (lead go, 20:11): a link or code the server refused may be old while this browser is still signed in (an
|
|
7140
7811
|
// old QR, a reopened tab). Ask status first: signed in opens the app and says the link was old; signed out shows the
|
|
@@ -7187,5 +7858,10 @@
|
|
|
7187
7858
|
authRetrySchedule(!!on);
|
|
7188
7859
|
if (on) { var err = $('auth-error'); if (err) { setStatus(err, '', true); err.classList.add('hidden'); } }
|
|
7189
7860
|
}
|
|
7190
|
-
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
|
|
7861
|
+
if (document.readyState === 'loading' && !cosBootAfter()) document.addEventListener('DOMContentLoaded', boot);
|
|
7862
|
+
else if (cosBootAfter()) cosBootAfter()(boot);
|
|
7863
|
+
else boot();
|
|
7864
|
+
// The scripts come from theme.js's loader (index.html #boot-scripts), which runs boot once every one of them has run
|
|
7865
|
+
// (window.CosBoot.after). Without it (a page from before the loader), as before.
|
|
7866
|
+
function cosBootAfter() { return window.CosBoot && typeof window.CosBoot.after === 'function' ? window.CosBoot.after : null; }
|
|
7191
7867
|
})();
|