@gotcos/glasses-server 6.99.33 → 7.0.7

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.
Files changed (76) hide show
  1. package/.env.example +18 -0
  2. package/CHANGELOG.md +417 -0
  3. package/README.md +3 -1
  4. package/managed-runtime-contract.json +3 -0
  5. package/package.json +1 -1
  6. package/server/index.ts +29 -7
  7. package/server/lib/agent-session-output-images.ts +627 -49
  8. package/server/lib/agent-session-store.ts +24 -11
  9. package/server/lib/agent-session-tool-images.ts +224 -0
  10. package/server/lib/agent-session-turns.ts +19 -2
  11. package/server/lib/api-auth.ts +6 -0
  12. package/server/lib/ask-work-plan.ts +3 -1
  13. package/server/lib/calendar-source.ts +491 -0
  14. package/server/lib/codex-bridge.ts +4 -1
  15. package/server/lib/codex-run-ledger.ts +29 -0
  16. package/server/lib/context-builder.ts +2 -1
  17. package/server/lib/drop-p2-runtime.ts +1 -1
  18. package/server/lib/drop-store.ts +10 -4
  19. package/server/lib/learning-needs-you.ts +24 -7
  20. package/server/lib/live-events.ts +533 -0
  21. package/server/lib/local-path-redact.ts +272 -0
  22. package/server/lib/meeting-ask-orientation.ts +40 -6
  23. package/server/lib/meeting-ask-runner.ts +30 -10
  24. package/server/lib/morning-brief-runtime.ts +4 -2
  25. package/server/lib/native-mac-caller.ts +29 -0
  26. package/server/lib/ping-engine.ts +1 -1
  27. package/server/lib/ping-sources.ts +11 -6
  28. package/server/lib/preview-content.ts +172 -0
  29. package/server/lib/preview-error-code.ts +10 -0
  30. package/server/lib/preview-folders-setting.ts +133 -0
  31. package/server/lib/preview-log-throttle.ts +18 -0
  32. package/server/lib/preview-thumbs.ts +139 -0
  33. package/server/lib/secret-path-guard.ts +80 -0
  34. package/server/lib/session-files-setting.ts +88 -0
  35. package/server/lib/session-links.ts +280 -0
  36. package/server/lib/thread-turn-queue.ts +10 -0
  37. package/server/lib/today-brief.ts +119 -28
  38. package/server/lib/web-memories-bridge.ts +3 -1
  39. package/server/lib/work-activity.ts +56 -6
  40. package/server/lib/work-session-activity.ts +260 -0
  41. package/server/lib/your-move-count.ts +9 -3
  42. package/server/routes/agent-session-bindings.ts +13 -0
  43. package/server/routes/agent-session-stream.ts +16 -0
  44. package/server/routes/agent-sessions.ts +146 -11
  45. package/server/routes/ask-work-plan.ts +4 -1
  46. package/server/routes/claude-sessions.ts +5 -1
  47. package/server/routes/health.ts +35 -0
  48. package/server/routes/live-events.ts +329 -0
  49. package/server/routes/permission-broker.ts +69 -5
  50. package/server/routes/session-preview.ts +200 -0
  51. package/server/routes/thread-turn-queue.ts +207 -3
  52. package/server/routes/today-brief.ts +44 -9
  53. package/server/routes/welcome-context.ts +23 -3
  54. package/server/routes/work-board.ts +9 -2
  55. package/server/web-control/app.css +40 -1
  56. package/server/web-control/app.js +1309 -56
  57. package/server/web-control/control.css +25 -0
  58. package/server/web-control/index.html +7 -2
  59. package/server/web-control/memory-review.js +100 -0
  60. package/server/web-control/panes/cos-chat.css +42 -3
  61. package/server/web-control/panes/cos-chat.js +267 -15
  62. package/server/web-control/panes/meetings.css +18 -2
  63. package/server/web-control/panes/meetings.js +24 -8
  64. package/server/web-control/panes/memories.css +17 -2
  65. package/server/web-control/panes/memories.js +127 -42
  66. package/server/web-control/panes/messages.js +21 -2
  67. package/server/web-control/panes/preview.js +318 -0
  68. package/server/web-control/panes/questions.js +28 -8
  69. package/server/web-control/panes/reader-media.js +409 -23
  70. package/server/web-control/panes/sessions.css +32 -3
  71. package/server/web-control/panes/settings.js +96 -5
  72. package/server/web-control/panes/today-actions.css +11 -0
  73. package/server/web-control/panes/today-actions.js +45 -12
  74. package/server/web-control/panes/work.css +10 -0
  75. package/server/web-control/panes/work.js +10 -2
  76. package/server/web-control/shell.js +17 -14
@@ -26,6 +26,153 @@
26
26
  }).join('\n');
27
27
  }
28
28
  function plain(text) { return String(text == null ? '' : text).replace(/\*\*|`/g, '').replace(/^#{1,4}\s+/, ''); }
29
+ // 7.0.5: a COS answer is written for the G2 lens as plain text, so a numbered list arrives inline ("...for me: 1. Prep
30
+ // before each meeting. 2. Catching conflicts early. 3. ..."), one long paragraph on the phone. Before md() draws a COS
31
+ // answer (the chat card, the Messages reader), each run of inline items 1, 2, 3 in order goes on its own line under
32
+ // its lead-in. Only whitespace changes: every character of the answer stays. The lens and the stored text never see it.
33
+ // Conservative: text with any Markdown structure is left alone; a marker counts only after a sentence end (". " ": "
34
+ // "? " "! ") and never inside a code, bold or italic span; fewer than two items, or numbers out of order, are not a list;
35
+ // words after the last item stay in it, except a short "Label:" sentence followed by a new 1, which starts the next list.
36
+ // 7.0.7 (final QA W1): a new line never starts with what a renderer reads as a block. A "Label:" that starts with one
37
+ // stays on the line before it, and a list whose item starts with one is left as it is (never half a list).
38
+ var LA_STRUCTURED = /^[ \t]*(?:[-*+][ \t]|\d{1,3}[.)][ \t]|#|>|\|)|\n[ \t]*\n|```|~~~/m;
39
+ var LA_MARK = /([.:?!]["'”’)]?)([ \t]+)([1-9]\d?)\.(?=[ \t]+\S)/g;
40
+ var LA_END = /[.?!]["'”’)]?([ \t]+)/g;
41
+ // Spans no cut may enter. Each kind is found on its own, as its renderer finds it, and a cut is refused inside any of
42
+ // them: md()'s own `code` and **bold** (never empty, no star inside bold), and for the Mac's inline Markdown *italic*,
43
+ // __bold__ and _italic_, each opening on a non-space and closing after one, on one line ("2 * 3" and a lone "*" are
44
+ // not spans). A word's own underscores (max_tokens, top_p) are matched first, so they never open a span. A link holds
45
+ // no whitespace, and only whitespace is ever replaced, so a link is never cut.
46
+ var LA_GUARD = /`[^`\n]+`/g;
47
+ var LA_BOLD = /\*\*[^*\n]+\*\*/g;
48
+ var LA_STAR = /\*(?!\*)\S(?:[^*\n]*(?!\*)\S)?\*/g;
49
+ var LA_UNDER = /[A-Za-z0-9]_+|__(?!_)\S(?:[^_\n]*(?!_)\S)?__(?![A-Za-z0-9])|_(?!_)\S(?:[^_\n]*(?!_)\S)?_(?![A-Za-z0-9])/g;
50
+ var LA_SPANS = [LA_GUARD, LA_BOLD, LA_STAR, LA_UNDER];
51
+ // What a line may not start with when a cut makes it start (md() here; the Mac's COSMarkdownParser also reads
52
+ // "[Speaker]:" lines and "<details>"): a heading, quote, bullet or table row, a "[" or "<", or a number list marker.
53
+ var LA_BLOCK_START = /[ \t]*(?:[\-#>*+|<\[]|\d+[.)])/y;
54
+ // What an item's own words may not start with: a heading, a quote, or a Mac task box ("1. [x] done").
55
+ var LA_ITEM_START = /[ \t]*(?:[#>]|\[[ xX]\])/y;
56
+ var LA_LABEL_MAX = 100;
57
+ // Does `re` (sticky) match at `at` in `line`?
58
+ function laAt(re, line, at) { re.lastIndex = at; return re.test(line); }
59
+ function structureLensAnswer(text) {
60
+ var s = text == null ? '' : String(text);
61
+ if (s.indexOf('. ') < 0 || LA_STRUCTURED.test(s)) return s;
62
+ var lines = s.split('\n'), out = [], changed = false;
63
+ for (var i = 0; i < lines.length; i++) {
64
+ var r = laLine(lines[i]);
65
+ if (r !== lines[i]) changed = true;
66
+ // A line that became a list ends with a blank line before whatever follows it.
67
+ out.push(r !== lines[i] && i < lines.length - 1 ? r + '\n' : r);
68
+ }
69
+ return changed ? out.join('\n') : s;
70
+ }
71
+ function laLine(line) {
72
+ var kinds = [], m;
73
+ for (var g = 0; g < LA_SPANS.length; g++) {
74
+ var re = LA_SPANS[g], starts = [], ends = [];
75
+ re.lastIndex = 0;
76
+ while ((m = re.exec(line))) { starts.push(m.index); ends.push(m.index + m[0].length); }
77
+ if (starts.length) kinds.push([starts, ends]);
78
+ }
79
+ // Inside a span of any kind? One kind's spans are in order and never overlap: binary search each.
80
+ var guarded = function (at) {
81
+ for (var k = 0; k < kinds.length; k++) {
82
+ var st = kinds[k][0], en = kinds[k][1], lo = 0, hi = st.length - 1;
83
+ while (lo <= hi) { var mid = (lo + hi) >> 1; if (st[mid] > at) hi = mid - 1; else if (en[mid] <= at) lo = mid + 1; else return true; }
84
+ }
85
+ return false;
86
+ };
87
+ var marks = [];
88
+ LA_MARK.lastIndex = 0;
89
+ while ((m = LA_MARK.exec(line))) {
90
+ var ws = m.index + m[1].length, at = ws + m[2].length;
91
+ if (guarded(m.index) || guarded(at)) continue;
92
+ marks.push({ colon: m[1].charAt(0) === ':', ws: ws, at: at, body: at + m[3].length + 1, n: Number(m[3]) });
93
+ }
94
+ // Runs of 1, 2, 3 in order; two items or more make a list.
95
+ var runs = [], cur = [];
96
+ for (var k = 0; k < marks.length; k++) {
97
+ var c = marks[k];
98
+ if (cur.length && c.n === cur[cur.length - 1].n + 1) { cur.push(c); continue; }
99
+ if (cur.length >= 2) runs.push(cur);
100
+ cur = c.n === 1 ? [c] : [];
101
+ }
102
+ if (cur.length >= 2) runs.push(cur);
103
+ var cuts = [], prevEnd = 0, lists = 0;
104
+ for (var q = 0; q < runs.length; q++) {
105
+ var run = runs[q], first = run[0];
106
+ // After a list, only a "Label:" lead-in starts another; anything else stays in the last item.
107
+ if (lists && !first.colon) continue;
108
+ // An item whose words start a block leaves its whole list as it is; it still counts as a list, so the words after
109
+ // it stay in its last item.
110
+ var clean = true;
111
+ for (var j = 0; j < run.length; j++) if (laAt(LA_ITEM_START, line, run[j].body)) { clean = false; break; }
112
+ if (clean) {
113
+ if (first.colon) {
114
+ // The lead-in is the "Label:" sentence: it gets its own line when it is short and does not start a block.
115
+ var from = -1, to = -1, e;
116
+ LA_END.lastIndex = prevEnd;
117
+ while ((e = LA_END.exec(line)) && e.index + e[0].length < first.ws) {
118
+ if (!guarded(e.index)) { to = e.index + e[0].length; from = to - e[1].length; }
119
+ }
120
+ if (from >= 0 && first.ws - to <= LA_LABEL_MAX && !laAt(LA_BLOCK_START, line, to)) cuts.push([from, to, '\n\n']);
121
+ }
122
+ cuts.push([first.ws, first.at, '\n\n']);
123
+ for (var r2 = 1; r2 < run.length; r2++) cuts.push([run[r2].ws, run[r2].at, '\n']);
124
+ }
125
+ prevEnd = run[run.length - 1].body; // after the last item's own "5.", which is not a sentence end
126
+ lists++;
127
+ }
128
+ if (!cuts.length) return line;
129
+ var res = '', pos = 0;
130
+ for (var x = 0; x < cuts.length; x++) { res += line.slice(pos, cuts[x][0]) + cuts[x][2]; pos = cuts[x][1]; }
131
+ return res + line.slice(pos);
132
+ }
133
+ // Markdown pipe tables (Duo tables). Every pane that draws one asks tableFit how far each column may shrink. Cells
134
+ // wrap only at spaces (CSS: overflow-wrap break-word, so a column is never narrower than its longest word), a column
135
+ // whose text is longer than TF_MIN_CH keeps at least that many characters, a first column of short labels stays on
136
+ // one line, and a long first column gets a fixed width and wraps at words. Only a token of TF_LONG or more characters
137
+ // (a URL, a hash) may break inside. A table wider than its box scrolls inside it (CSS .tf-wrap), first column sticky.
138
+ var TF_LONG = 25, TF_MIN_CH = 10, TF_KEY_WORDS = 3, TF_KEY_CHARS = 28, TF_FIRST_MIN = 14, TF_FIRST_MAX = 26;
139
+ var TF_LONG_RE = new RegExp('[^\\s<>]{' + TF_LONG + ',}', 'g');
140
+ function tfText(cell) {
141
+ return String(cell == null ? '' : cell).replace(/<[^>]*>/g, '').replace(/&(?:amp|lt|gt|quot|#39);/g, '_')
142
+ .replace(/\[([^\]\n]*)\]\([^)\s]*\)/g, '$1').replace(/\*\*|__|~~|`/g, '').replace(/\s+/g, ' ').trim();
143
+ }
144
+ function tfLong(html) {
145
+ return String(html).split(/(<[^>]*>)/).map(function (part) {
146
+ return part.charAt(0) === '<' ? part : part.replace(TF_LONG_RE, function (t) { return '<span class="tf-long">' + t + '</span>'; });
147
+ }).join('');
148
+ }
149
+ // rows: every row of raw cell text, the header row first when there is one.
150
+ function tableFit(rows) {
151
+ rows = (rows || []).filter(Array.isArray);
152
+ var n = rows.reduce(function (m, r) { return Math.max(m, r.length); }, 0);
153
+ var key = n > 1 && rows.length > 0 && rows.every(function (r) {
154
+ var t = tfText(r[0]);
155
+ return t.length <= TF_KEY_CHARS && (t ? t.split(' ').length : 0) <= TF_KEY_WORDS;
156
+ });
157
+ var cols = [];
158
+ for (var k = 0; k < n; k++) {
159
+ var len = rows.reduce(function (m, r) { return Math.max(m, tfText(r[k]).length); }, 0);
160
+ if (k === 0 && key) cols.push({ min: 0, width: 0 });
161
+ else if (k === 0 && n > 1) cols.push({ min: Math.min(len, TF_FIRST_MIN), width: Math.min(len, TF_FIRST_MAX) });
162
+ else cols.push({ min: Math.min(len, TF_MIN_CH), width: 0 });
163
+ }
164
+ return {
165
+ key: key, cols: cols,
166
+ tableClass: 'tf' + (key ? ' tf-key' : ''),
167
+ // The first row's cells carry each column's floor (an inner block's min-width counts in every engine's table
168
+ // sizing) and the first column's width; later rows only get their long tokens marked.
169
+ width: function (k, first) { var c = cols[k]; return first && c && c.width ? 'width:' + c.width + 'ch' : ''; },
170
+ cell: function (k, html, first) {
171
+ var c = cols[k], body = tfLong(html);
172
+ return first && c && c.min >= 3 ? '<span class="tf-min" style="min-width:' + c.min + 'ch">' + body + '</span>' : body;
173
+ }
174
+ };
175
+ }
29
176
  var WORDS = ['No', 'One', 'Two', 'Three', 'Four', 'Five', 'Six', 'Seven', 'Eight', 'Nine', 'Ten'];
30
177
  function countWord(n, lower) { var w = n >= 0 && n <= 10 ? WORDS[n] : String(n); return lower ? w.toLowerCase() : w; }
31
178
  function plural(n, one, many) { return n === 1 ? one : many; }
@@ -278,7 +425,10 @@
278
425
  var hash = '#/' + [pane].concat(args || []).map(encodeURIComponent).join('/');
279
426
  if (location.hash === hash) route(); else location.hash = hash;
280
427
  }
281
- function isPhone() { return !window.matchMedia('(min-width: 600px)').matches; }
428
+ // 7.0.2 (A8): a phone is under 600 px wide, or a short touch screen at any width (the unfolded Duo is 951x539 in
429
+ // Comet): height is its scarce axis, so it gets the phone Ask sheet and the 44 px tap floor (app.css), as folded does.
430
+ var SHORT_TOUCH_MQ = '(pointer: coarse) and (max-height: 720px)';
431
+ function isPhone() { return !window.matchMedia('(min-width: 600px)').matches || window.matchMedia(SHORT_TOUCH_MQ).matches; }
282
432
  /** A keyboard and a fine pointer are the way in (a Mac, a PC): Return sends. Touch screens type a new line. */
283
433
  function keyboardFirst() { return !!(window.matchMedia && window.matchMedia('(hover: hover) and (pointer: fine)').matches); }
284
434
  // Return in a text box sends only where a keyboard and a pointer are the way in (cos-chat.js keyboardFirst): never on
@@ -379,6 +529,7 @@
379
529
  // ── auth ────────────────────────────────────────────────
380
530
  function showAuth(message) {
381
531
  readerImagesStop($('session-turns')); readerImagesStop($('ta-last'));
532
+ if (typeof liveHide === 'function') { liveHide(); liveStopWatch(); LIVE.mode = 'idle'; }
382
533
  var alreadyOpen = !$('auth-gate').classList.contains('hidden');
383
534
  $('app-shell').classList.add('hidden');
384
535
  $('auth-gate').classList.remove('hidden');
@@ -396,6 +547,7 @@
396
547
  loadTailnetUrl();
397
548
  runHooks(shellHooks, info);
398
549
  route();
550
+ if (typeof liveStart === 'function') liveStart(); // 7.0.1: the one live stream for this page (a no-op if it is already open)
399
551
  // Reloaded with Today's overlay open: its history entry is still current. The overlay opens the same thing again
400
552
  // (panes/today-actions.js restore), so Back closes it; if it cannot load, the entry is unmarked.
401
553
  var held = history.state;
@@ -419,15 +571,24 @@
419
571
  // stories all asked for it separately. Now there is one copy: a request younger than maxAgeMs is
420
572
  // answered from it, a request already on the wire is shared, and panes can paint the last copy at
421
573
  // once while a fresh one loads (sessionListCached).
422
- var SL = { data: null, at: 0, inflight: null, waiting: null };
574
+ var SL = { data: null, at: 0, inflight: null, waiting: null, failed: false };
423
575
  function sessionList(maxAgeMs) {
424
576
  if (SL.data && Date.now() - SL.at < (maxAgeMs || 0)) return Promise.resolve(SL.data);
425
577
  if (SL.inflight) return SL.inflight;
426
578
  SL.inflight = api('/api/agent-sessions?limit=80&sort=updated').then(function (d) {
579
+ // 7.0.5: a sessions read (Sessions pane or Today) that follows a failed one is a reconnect: Today's next summary
580
+ // ask says so (requestBrief), once.
581
+ if (SL.failed) { SL.failed = false; if (typeof BRIEF === 'object' && BRIEF) BRIEF.reconnect = true; }
427
582
  SL.data = d; SL.at = Date.now(); SL.inflight = null;
583
+ // 7.0.1: a fresh list is corrected by the live model at once, so it never paints a state the stream already moved.
584
+ // Only while a stream is open and live (review W1): reconnecting, busy or hidden, the list is the newest truth.
585
+ if (typeof liveCanOverlay === 'function' && d && Array.isArray(d.sessions)) {
586
+ if (liveCanOverlay()) liveOverlay(d.sessions, { placeholders: true, unknown: true });
587
+ else if (liveOn()) LIVE.freshAt = SL.at;
588
+ }
428
589
  noteWaiting(d);
429
590
  return d;
430
- }, function (err) { SL.inflight = null; throw err; });
591
+ }, function (err) { SL.inflight = null; SL.failed = true; throw err; });
431
592
  return SL.inflight;
432
593
  }
433
594
  function sessionListCached() { return SL.data ? { data: SL.data, at: SL.at } : null; }
@@ -449,6 +610,438 @@
449
610
  if (fresh) questionsNudge();
450
611
  }
451
612
 
613
+ // ── live state (7.0.1) ──────────────────────────────────
614
+ // Miles 11/07: the pet, the session rows, Today's counts and the running clocks showed the last fetch, not now. One
615
+ // EventSource('/api/events') per page (docs/live-events-contract.md) tells the page when the server's own session
616
+ // state moves: ids, states, counts and times only. The list (/api/agent-sessions) still carries every word; the
617
+ // stream corrects its rows in place and asks for a fresh list when a session appears, leaves or replies.
618
+ // mode 'idle' not started (signed out)
619
+ // 'connecting' a stream is opening; nothing painted differently yet
620
+ // 'live' a frame arrived inside the last 30 s: counts are live
621
+ // 'reconnecting' the stream dropped or went quiet: "Reconnecting", the list read every 5 s until it is back
622
+ // 'busy' the server has no room for another stream (503): retried in 30 s, no fast polling
623
+ // 'absent' no /api/events (an older server, or COS_WEB_LIVE_EVENTS=0): exactly what 7.0.0 did
624
+ // A hidden page holds no stream and runs no timer; coming back re-reads the list at once and resumes from the last
625
+ // id (`?after=<epoch>.<seq>`, the contract's manual resume, since new EventSource cannot set Last-Event-ID).
626
+ var LIVE_SILENT_MS = 30000, LIVE_POLL_MS = 5000, LIVE_WATCH_MS = 5000, LIVE_RETRY_MS = 3000, LIVE_RETRY_MAX_MS = 30000;
627
+ var LIVE_BUSY_MS = 30000, LIVE_ABSENT_RECHECK_MS = 10 * 60000, LIVE_LIST_DEBOUNCE_MS = 1000, LIVE_STALE_MS = 30000, LIVE_REPLY_LIST_GAP_MS = 12000;
628
+ var LIVE = {
629
+ mode: 'idle', es: null, lastId: '', epoch: null, seq: -1, frameAt: 0, skew: 0, gotFrame: false,
630
+ sessions: {}, // key (provider:sessionid, lowercase) -> LiveSession
631
+ questions: null, queue: null, degraded: {},
632
+ freshAt: 0, // when the counts on screen were last confirmed: a frame on a live stream, or a list read
633
+ retryMs: LIVE_RETRY_MS, retry: null, poll: null, watch: null, listTimer: null, paintTimer: null,
634
+ absentAt: 0, hooks: [], gen: 0, pendingRows: [], pendingStructural: false, listAt: 0, listDue: 0, noteSaid: null
635
+ };
636
+ function liveOnMode(m) { return m === 'live' || m === 'reconnecting' || m === 'busy'; }
637
+ function liveOn() { return liveOnMode(LIVE.mode); }
638
+ /** The live model corrects list rows only while a stream is open and live. Reconnecting, busy or hidden, the list
639
+ * read is newer than the model (review W1). The model is kept only as the base a resume applies to. */
640
+ function liveCanOverlay() { return LIVE.mode === 'live' && !!LIVE.es && LIVE.gotFrame; }
641
+ function liveKey(provider, id) { return String(provider || 'claude').toLowerCase() + ':' + String(id || '').toLowerCase(); }
642
+ function liveRowKey(s) { var k = splitSessionId(s.provider, s.session_id || s.id); return liveKey(k.provider, k.id); }
643
+ // The contract's join (review B1): the full id, lowercase. The 8-character prefix joins only when one side is
644
+ // exactly 8 characters (the list may carry a Claude tab under the registry's short id until it finds the
645
+ // transcript) and only one candidate matches, the rule countFor uses on the server. Codex ids are UUIDv7: their
646
+ // first 8 characters are a timestamp and stay the same for about 65 s, so a prefix alone joins parallel sessions.
647
+ function liveSame(a, b) {
648
+ if (a === b) return true;
649
+ var ia = a.slice(a.indexOf(':') + 1), ib = b.slice(b.indexOf(':') + 1);
650
+ if (a.slice(0, a.length - ia.length) !== b.slice(0, b.length - ib.length)) return false;
651
+ // One side must BE the other's first 8 characters, so it is exactly 8 long: two full ids never join here.
652
+ return ia.length === 8 ? ib.slice(0, 8) === ia : ia.slice(0, 8) === ib;
653
+ }
654
+ /** The one key in `keys` this key joins, or '' (none, or more than one by prefix). */
655
+ function liveMatch(key, keys) {
656
+ if (keys.indexOf(key) >= 0) return key;
657
+ var hits = keys.filter(function (k) { return liveSame(key, k); });
658
+ return hits.length === 1 ? hits[0] : '';
659
+ }
660
+ function liveIso(ms) { return typeof ms === 'number' && isFinite(ms) ? new Date(ms).toISOString() : null; }
661
+ /** The list arrays on this page (usually one array, shared): every row a live frame must correct. */
662
+ function liveLists() {
663
+ var out = [];
664
+ [SL.data && SL.data.sessions, state.sessions, typeof SX !== 'undefined' && SX ? SX.rows : null].forEach(function (a) { if (Array.isArray(a) && out.indexOf(a) < 0) out.push(a); });
665
+ return out;
666
+ }
667
+ /** Write one LiveSession onto a list row, in the list's own fields (sxState, running_active, running). */
668
+ // Claude: the registry and the hooks know whether the tab is open, so the stream decides open or closed too. Codex
669
+ // and Cursor (review W2): the stream's "open" is only a 30-minute hook window, while the list reads the process
670
+ // itself, so the list decides open or closed and alive; the stream moves working, waiting or idle and the times.
671
+ function liveOwnsOpen(provider) { return String(provider || 'claude').toLowerCase() === 'claude'; }
672
+ function liveWrite(row, ls) {
673
+ var st = ls.state;
674
+ row._live = true;
675
+ row._liveTurnAt = (st === 'working' || st === 'waiting') && typeof ls.turnStartedAt === 'number' ? ls.turnStartedAt : null;
676
+ row.state_source = ls.source === 'registry' ? 'registry' : 'hook';
677
+ row.running_active = st === 'working';
678
+ if (liveOwnsOpen(ls.provider)) {
679
+ row.running = st !== 'ended';
680
+ row.alive = st !== 'ended';
681
+ row.agent_state = st === 'working' ? 'running' : st === 'waiting' ? 'waiting' : st === 'ended' ? 'ended' : ls.failed ? 'failed' : 'idle';
682
+ } else if (st === 'working' || st === 'waiting') {
683
+ row.agent_state = st === 'working' ? 'running' : 'waiting';
684
+ } else if (row.agent_state === 'running' || row.agent_state === 'waiting' || row.agent_state === 'idle' || row.agent_state === 'failed' || !row.agent_state) {
685
+ row.agent_state = row.running === false ? 'ended' : ls.failed && st !== 'ended' ? 'failed' : 'idle';
686
+ }
687
+ if (row._liveTurnAt != null) row.state_since = liveIso(row._liveTurnAt);
688
+ else if (typeof ls.updatedAt === 'number' && row._liveState !== st) row.state_since = liveIso(ls.updatedAt);
689
+ if (typeof ls.updatedAt === 'number') row.last_activity_at = liveIso(ls.updatedAt);
690
+ if (typeof ls.queued === 'number') row.queued_turns = ls.queued;
691
+ row._liveState = st;
692
+ }
693
+ /** A session the stream says left the live model: no longer working or open (it may stay in the list as recent). */
694
+ function liveForget(row) {
695
+ row._live = false; row._liveTurnAt = null; row._liveState = '';
696
+ row.running_active = false;
697
+ if (!liveOwnsOpen(splitSessionId(row.provider, row.session_id || row.id).provider)) {
698
+ // Codex and Cursor: still open or closed as the list says; only no longer working or waiting.
699
+ if (row.agent_state === 'running' || row.agent_state === 'waiting') row.agent_state = row.running === false ? 'ended' : 'idle';
700
+ return;
701
+ }
702
+ row.running = false;
703
+ if (row.agent_state === 'running' || row.agent_state === 'waiting' || row.agent_state === 'idle') row.agent_state = 'ended';
704
+ }
705
+ /** Overlay the live model on the list rows. Returns what moved: { changed: [rows], added: n, unknown: n }. */
706
+ function liveOverlay(rows, opts) {
707
+ var o = opts || {}, out = { changed: [], added: 0, unknown: [] }, byJoin = {};
708
+ Object.keys(LIVE.sessions).forEach(function (k) { var ls = LIVE.sessions[k]; byJoin[liveKey(ls.provider, ls.sessionId)] = ls; });
709
+ var keys = Object.keys(byJoin), seen = {};
710
+ (rows || []).forEach(function (row) {
711
+ if (!row) return;
712
+ var j = liveMatch(liveRowKey(row), keys), ls = j ? byJoin[j] : null;
713
+ if (ls) { seen[j] = 1; var before = row._liveState + '|' + row._liveTurnAt + '|' + row.queued_turns; liveWrite(row, ls); if (before !== row._liveState + '|' + row._liveTurnAt + '|' + row.queued_turns) out.changed.push(row); }
714
+ // A row this page corrected before, now missing from a whole snapshot: it left. Never a row the stream never
715
+ // spoke for (a Codex or Cursor session whose hooks never reach the server stays as the list says), and never
716
+ // while the server says it could not read its registry (degraded: absence is not "nothing running").
717
+ else if (o.whole && row._live && !LIVE.degraded.registry) { liveForget(row); out.changed.push(row); }
718
+ });
719
+ // A session the list has not caught up with yet: a placeholder row while it works or waits, so the pet and the
720
+ // Running count move now, and a fresh list (debounced) brings its title.
721
+ if (o.placeholders || o.unknown) {
722
+ Object.keys(byJoin).forEach(function (j) {
723
+ if (seen[j]) return;
724
+ var ls = byJoin[j];
725
+ out.unknown.push(j);
726
+ if (!o.placeholders || (ls.state !== 'working' && ls.state !== 'waiting')) return;
727
+ var row = { provider: ls.provider, session_id: ls.sessionId, id: ls.sessionId, title: 'New ' + providerLabel(ls.provider) + ' session', _placeholder: true, running: true, alive: true };
728
+ liveWrite(row, ls);
729
+ rows.unshift(row); out.added++; out.changed.push(row);
730
+ });
731
+ }
732
+ return out;
733
+ }
734
+ /** Apply the live model to every list on the page, then repaint what shows. */
735
+ function liveApply(whole) {
736
+ if (!liveCanOverlay()) return { changed: 0, added: 0, unknown: 0 };
737
+ var lists = liveLists(), changed = [], added = 0, unknown = [];
738
+ lists.forEach(function (rows, i) {
739
+ var r = liveOverlay(rows, { whole: whole, placeholders: i === 0, unknown: i === 0 });
740
+ changed = changed.concat(r.changed); added += r.added; if (i === 0) unknown = r.unknown;
741
+ });
742
+ // A session the list has not shown yet: the snapshot and each new session already ask for one fresh list
743
+ // (liveListSoon in liveFrame), so nothing more here.
744
+ if (changed.length || added) livePaint(changed, added > 0);
745
+ return { changed: changed.length, added: added, unknown: unknown.length };
746
+ }
747
+ function liveRunHooks(ev) { LIVE.hooks.slice().forEach(function (fn) { try { fn(ev); } catch (err) { console.error(err); } }); }
748
+ // One frame. Returns false when the stream must be reopened (a gap or a server restart).
749
+ function liveFrame(f) {
750
+ if (!f || typeof f !== 'object' || typeof f.kind !== 'string') return true;
751
+ var now = Date.now(), couldOverlay = liveCanOverlay();
752
+ LIVE.frameAt = now;
753
+ if (typeof f.at === 'number' && isFinite(f.at)) LIVE.skew = now - f.at;
754
+ var stateFrame = f.kind === 'snapshot' || f.kind === 'session' || f.kind === 'session.removed' || f.kind === 'questions' || f.kind === 'queue';
755
+ if (f.kind === 'snapshot') {
756
+ LIVE.epoch = f.epoch; LIVE.seq = typeof f.seq === 'number' ? f.seq : -1;
757
+ LIVE.sessions = {};
758
+ (Array.isArray(f.sessions) ? f.sessions : []).forEach(function (ls) { if (ls && ls.sessionId) LIVE.sessions[liveKey(ls.provider, ls.sessionId)] = ls; });
759
+ LIVE.questions = f.questions || null; LIVE.queue = f.queue || null;
760
+ LIVE.degraded = f.degraded && typeof f.degraded === 'object' ? f.degraded : {};
761
+ } else if (typeof f.seq === 'number') {
762
+ if (LIVE.epoch !== null && f.epoch != null && f.epoch !== LIVE.epoch) return false; // a new server life
763
+ if (stateFrame ? f.seq > LIVE.seq + 1 : f.seq > LIVE.seq) return false; // missed a frame
764
+ if (stateFrame && f.seq <= LIVE.seq) return true; // already applied
765
+ }
766
+ if (stateFrame && typeof f.seq === 'number') { LIVE.seq = f.seq; if (f.epoch != null) LIVE.epoch = f.epoch; LIVE.lastId = LIVE.epoch + '.' + LIVE.seq; }
767
+ if (!LIVE.gotFrame) { LIVE.gotFrame = true; LIVE.retryMs = LIVE_RETRY_MS; }
768
+ var wasMode = LIVE.mode;
769
+ LIVE.mode = 'live';
770
+ if (!LIVE.degraded.registry) LIVE.freshAt = now;
771
+ liveStopPoll();
772
+ // The first frame on a (re)opened stream, after a list read won (Reconnecting, busy, or hidden and back): the
773
+ // resumed model corrects the rows again, including a resume that replayed nothing.
774
+ if (!couldOverlay && f.kind !== 'snapshot') liveApply(false);
775
+ if (f.kind === 'snapshot') {
776
+ liveApply(true);
777
+ liveListSoon();
778
+ questionsNudge();
779
+ liveRunHooks({ kind: 'snapshot' });
780
+ } else if (f.kind === 'session' && f.session && f.session.sessionId) {
781
+ var k = liveKey(f.session.provider, f.session.sessionId), was = LIVE.sessions[k];
782
+ LIVE.sessions[k] = f.session;
783
+ var r = liveApply(false);
784
+ // New: the words come from the list now. A reply landed (`replies` went up): the words come from the list, at
785
+ // most one read per LIVE_REPLY_LIST_GAP_MS (ghost W7: dozens of sessions ending turns would poll the list).
786
+ if (f.change === 'added' || !was) liveListSoon();
787
+ else if (typeof f.session.replies === 'number' && f.session.replies > (was.replies || 0)) liveListSoon(LIVE_REPLY_LIST_GAP_MS);
788
+ if (f.session.state === 'waiting' && (!was || was.state !== 'waiting')) questionsNudge();
789
+ liveRunHooks({ kind: 'session', key: k, session: f.session, previous: was || null, moved: r.changed });
790
+ } else if (f.kind === 'session.removed') {
791
+ var j = liveKey(f.provider, f.sessionId), gone = [];
792
+ delete LIVE.sessions[j];
793
+ var others = Object.keys(LIVE.sessions).map(function (k) { return liveKey(LIVE.sessions[k].provider, LIVE.sessions[k].sessionId); });
794
+ liveLists().forEach(function (rows) {
795
+ for (var i = rows.length - 1; i >= 0; i--) {
796
+ var row = rows[i]; if (!row) continue;
797
+ var rk = liveRowKey(row);
798
+ // This row is the removed session's (by the same join), and no remaining live session claims it.
799
+ if (liveMatch(rk, others.concat([j])) !== j) continue;
800
+ if (row._placeholder) { rows.splice(i, 1); gone.push(row); } else if (row._live) { liveForget(row); gone.push(row); }
801
+ }
802
+ });
803
+ if (gone.length) livePaint(gone, true);
804
+ liveListSoon();
805
+ liveRunHooks({ kind: 'session.removed', key: liveKey(f.provider, f.sessionId) });
806
+ } else if (f.kind === 'questions') {
807
+ LIVE.questions = { total: f.total, approvals: f.approvals, bySession: f.bySession };
808
+ questionsNudge(); // the cards come from /api/session-questions (questions.js keeps its own poll as the fallback)
809
+ liveRunHooks({ kind: 'questions' });
810
+ } else if (f.kind === 'queue') {
811
+ LIVE.queue = { total: f.total, bySession: f.bySession };
812
+ liveRunHooks({ kind: 'queue' });
813
+ }
814
+ if (wasMode !== 'live') livePaint([], true);
815
+ return true;
816
+ }
817
+ function liveUrl() { return '/api/events' + (LIVE.lastId ? '?after=' + encodeURIComponent(LIVE.lastId) : ''); }
818
+ function liveVisible() { return !document.hidden; }
819
+ function liveSignedIn() { var sh = $('app-shell'); return !!(sh && !sh.classList.contains('hidden')); }
820
+ /** Open the one stream (never a second while one is open). */
821
+ function liveOpen() {
822
+ clearTimeout(LIVE.retry); LIVE.retry = null;
823
+ if (LIVE.es || !liveVisible() || !liveSignedIn()) return;
824
+ if (typeof EventSource !== 'function') { liveSetMode('absent'); return; }
825
+ var es, gen = ++LIVE.gen;
826
+ try { es = new EventSource(liveUrl()); } catch (_e) { liveSetMode('absent'); return; }
827
+ LIVE.es = es; LIVE.gotFrame = false;
828
+ if (LIVE.mode !== 'live' && LIVE.mode !== 'reconnecting' && LIVE.mode !== 'busy') LIVE.mode = 'connecting';
829
+ LIVE.frameAt = Date.now();
830
+ es.onmessage = function (ev) {
831
+ if (LIVE.es !== es || gen !== LIVE.gen) return;
832
+ var f; try { f = JSON.parse(ev.data); } catch (_e) { return; }
833
+ if (liveFrame(f) === false) {
834
+ // A gap or a new server life: reopen. A gap resumes from the last applied id; a new life starts fresh.
835
+ if (f && f.epoch != null && LIVE.epoch !== null && f.epoch !== LIVE.epoch) { LIVE.lastId = ''; LIVE.epoch = null; LIVE.seq = -1; }
836
+ liveClose(); liveOpen();
837
+ }
838
+ };
839
+ es.onerror = function () {
840
+ if (LIVE.es !== es || gen !== LIVE.gen) return;
841
+ if (!LIVE.gotFrame) {
842
+ // Never got a frame: EventSource hides the status, so one GET reads it (the readers' fallback-status pattern).
843
+ liveClose();
844
+ liveProbe(gen);
845
+ return;
846
+ }
847
+ // The stream had been live. The browser reconnects on its own with Last-Event-ID while readyState is 0; a closed
848
+ // stream (2) is reopened here with ?after. Either way the page says so and reads the list until a frame lands.
849
+ LIVE.gotFrame = false;
850
+ if (es.readyState === 2) { liveClose(); liveReconnect(); }
851
+ else liveSetMode('reconnecting');
852
+ };
853
+ liveStartWatch();
854
+ }
855
+ function liveClose() {
856
+ if (LIVE.es) { try { LIVE.es.close(); } catch (_e) {} }
857
+ LIVE.es = null; LIVE.gen++;
858
+ }
859
+ // What the route answered, read once without signing the page out: 404 (no route, or the switch off) and 401 or 403
860
+ // mean no live stream here, so the page does exactly what 7.0.0 did. 503 is a full server. Anything else (a
861
+ // network failure, a 200 that dropped) is a stream to reopen.
862
+ function liveProbe(gen) {
863
+ var ctrl = typeof AbortController === 'function' ? new AbortController() : null;
864
+ var timer = ctrl ? setTimeout(function () { ctrl.abort(); }, 10000) : null;
865
+ fetch(liveUrl(), { headers: { 'X-COS-Web': '1', Accept: 'text/event-stream' }, credentials: 'same-origin', signal: ctrl ? ctrl.signal : undefined })
866
+ .then(function (res) {
867
+ var code = res.status, type = '';
868
+ try { type = String((res.headers && res.headers.get && res.headers.get('content-type')) || ''); } catch (_e) { type = ''; }
869
+ if (ctrl) ctrl.abort();
870
+ return { code: code, stream: type.indexOf('text/event-stream') === 0 };
871
+ }, function () { return { code: 0, stream: false }; })
872
+ .then(function (r) {
873
+ if (timer) clearTimeout(timer);
874
+ if (LIVE.es || LIVE.mode === 'idle') return;
875
+ if (r.code === 503) { liveSetMode('busy'); LIVE.retry = setTimeout(liveOpen, LIVE_BUSY_MS); return; }
876
+ if (r.code === 0 || (r.code === 200 && r.stream)) { liveReconnect(); return; }
877
+ // 404, 401, 403, any other status, or a 200 that is not a stream: polling as today.
878
+ liveSetMode('absent');
879
+ });
880
+ }
881
+ function liveReconnect() {
882
+ liveSetMode('reconnecting');
883
+ clearTimeout(LIVE.retry);
884
+ var wait = LIVE.retryMs;
885
+ LIVE.retryMs = Math.min(LIVE.retryMs * 2, LIVE_RETRY_MAX_MS);
886
+ LIVE.retry = setTimeout(liveOpen, wait);
887
+ }
888
+ function liveSetMode(m) {
889
+ var was = LIVE.mode;
890
+ LIVE.mode = m;
891
+ if (m === 'absent') {
892
+ // Exactly 7.0.0: no stream, no extra timer, no label. Rows this page corrected keep their last values until the
893
+ // next list read replaces them, as 7.0.0's rows did.
894
+ liveClose(); liveStopPoll(); liveStopWatch(); clearTimeout(LIVE.retry); LIVE.retry = null;
895
+ LIVE.absentAt = Date.now(); LIVE.sessions = {};
896
+ }
897
+ if (m === 'reconnecting') liveStartPoll(); else if (m !== 'live') liveStopPoll();
898
+ if (was === 'live' && m !== 'live') liveLetGo();
899
+ if (was !== m && (liveOnMode(was) || liveOnMode(m))) livePaint([], true);
900
+ }
901
+ /** Leaving live (review W1): placeholder rows go, and no row keeps a live mark the next list read would not
902
+ * replace. The model stays only as the base a resume applies to; nothing overlays it until a frame lands. */
903
+ function liveLetGo() {
904
+ liveLists().forEach(function (rows) {
905
+ for (var i = rows.length - 1; i >= 0; i--) {
906
+ var row = rows[i]; if (!row) continue;
907
+ if (row._placeholder) rows.splice(i, 1); else if (row._live) { row._live = false; row._liveTurnAt = null; row._liveState = ''; }
908
+ }
909
+ });
910
+ }
911
+ // ── the fallbacks: a watchdog for a quiet stream, and the 5 s list read while reconnecting ──
912
+ function liveStartWatch() {
913
+ if (LIVE.watch) return;
914
+ LIVE.watch = setInterval(function () {
915
+ if (!liveVisible()) return;
916
+ if ((LIVE.mode === 'live' || LIVE.mode === 'connecting' || LIVE.mode === 'reconnecting') && LIVE.es && Date.now() - LIVE.frameAt > LIVE_SILENT_MS) {
917
+ // No frame of any kind for 30 s (the heartbeat is every 15 s): the stream is dead whatever readyState says.
918
+ liveClose(); liveReconnect();
919
+ }
920
+ if (liveOn() || LIVE.mode === 'connecting') liveNoteTick();
921
+ }, LIVE_WATCH_MS);
922
+ }
923
+ function liveStopWatch() { clearInterval(LIVE.watch); LIVE.watch = null; }
924
+ function liveStartPoll() {
925
+ if (LIVE.poll) return;
926
+ LIVE.poll = setInterval(function () { if (liveVisible() && LIVE.mode === 'reconnecting') livePollOnce(); }, LIVE_POLL_MS);
927
+ }
928
+ function liveStopPoll() { clearInterval(LIVE.poll); LIVE.poll = null; }
929
+ /** Read the list now, and swap it in on every pane that shows it. Resolves when it landed (or failed). */
930
+ function livePollOnce() {
931
+ var before = SL.data && SL.data.sessions;
932
+ LIVE.listAt = Date.now();
933
+ var keysOf = function (a) { return (a || []).map(function (s) { return s ? liveRowKey(s) : ''; }).join(','); };
934
+ return sessionList(0).then(function (d) {
935
+ var rows = (d && d.sessions) || [];
936
+ var moved = keysOf(before) !== keysOf(rows);
937
+ if (state.sessions === before || !state.sessions || !state.sessions.length) state.sessions = rows;
938
+ if (typeof SX !== 'undefined' && SX && SX.rows === before) { SX.rows = rows; SX.loadedAt = Date.now(); }
939
+ LIVE.freshAt = Date.now();
940
+ if (liveOn()) livePaint(rows, moved);
941
+ return true;
942
+ }, function () { if (liveOn()) livePaint([], false); return false; });
943
+ }
944
+ /** A fresh list soon (debounced), for the words of a session that appeared, left or replied. With `gapMs` (a reply),
945
+ * no sooner than gapMs after the last list read, and never later than a read already due. */
946
+ function liveListSoon(gapMs) {
947
+ var now = Date.now(), due = now + LIVE_LIST_DEBOUNCE_MS;
948
+ if (gapMs) {
949
+ if (LIVE.listTimer) return; // a read is already due; it brings this reply's words too
950
+ due = Math.max(due, (LIVE.listAt || 0) + gapMs);
951
+ }
952
+ clearTimeout(LIVE.listTimer);
953
+ LIVE.listDue = due;
954
+ LIVE.listTimer = setTimeout(function () { LIVE.listTimer = null; if (liveVisible()) livePollOnce(); }, due - now);
955
+ }
956
+ // ── coming back: visible, back from the back-forward cache, back online ──
957
+ function liveResume() {
958
+ if (!liveSignedIn()) return;
959
+ var wasOn = liveOn();
960
+ if (LIVE.mode === 'absent') {
961
+ // 7.0.0 behaviour; look again only after a long while (a server updated or switched on since). That look is one
962
+ // stream attempt, nothing else: the panes read what 7.0.0 read.
963
+ if (Date.now() - LIVE.absentAt < LIVE_ABSENT_RECHECK_MS) return;
964
+ LIVE.mode = 'connecting';
965
+ }
966
+ if (LIVE.mode === 'idle') return;
967
+ if (wasOn) livePollOnce();
968
+ if (!LIVE.es) { clearTimeout(LIVE.retry); LIVE.retry = null; LIVE.retryMs = LIVE_RETRY_MS; liveOpen(); }
969
+ liveStartWatch();
970
+ liveTickStart(); // stops itself at once when nothing on screen ticks
971
+ liveNoteTick();
972
+ }
973
+ function liveHide() {
974
+ // A hidden page holds no stream (a locked phone drops it anyway) and runs no live timer: not the stream, the list
975
+ // read, the retry, the watchdog or the 1 s clock (ghost note 1).
976
+ liveClose(); liveStopPoll(); liveStopWatch(); liveTickStop(); clearTimeout(LIVE.retry); LIVE.retry = null; clearTimeout(LIVE.listTimer); LIVE.listTimer = null;
977
+ }
978
+ function liveStart() {
979
+ if (LIVE.mode !== 'idle') return;
980
+ LIVE.mode = 'connecting';
981
+ liveOpen();
982
+ }
983
+ // ── honesty: how old the counts on screen are ──
984
+ /** "As of 2 min ago" when the counts are older than 30 s and the page is not live; '' when live, or with no stream
985
+ * at all (7.0.0 showed none). */
986
+ function liveAge(now) {
987
+ if (LIVE.mode === 'idle' || LIVE.mode === 'absent' || LIVE.mode === 'connecting') return '';
988
+ var at = Math.max(LIVE.freshAt || 0, LIVE.mode === 'live' && !LIVE.degraded.registry ? LIVE.frameAt : 0);
989
+ if (!at) return '';
990
+ var s = Math.max(0, ((now || Date.now()) - at) / 1000);
991
+ if (s * 1000 < LIVE_STALE_MS) return '';
992
+ if (s < 60) return 'As of ' + Math.floor(s) + ' s ago';
993
+ if (s < 3600) return 'As of ' + Math.floor(s / 60) + ' min ago';
994
+ return 'As of ' + Math.floor(s / 3600) + ' h ago';
995
+ }
996
+ /** The running clock: "12s", "4m 12s", "1h 5m". */
997
+ function liveClock(sec) {
998
+ var s = Math.max(0, Math.floor(sec));
999
+ if (s < 60) return s + 's';
1000
+ if (s < 3600) return Math.floor(s / 60) + 'm ' + (s % 60) + 's';
1001
+ return Math.floor(s / 3600) + 'h ' + Math.floor((s % 3600) / 60) + 'm';
1002
+ }
1003
+ /** Seconds a working row has run: from the stream's turnStartedAt (skew from the frame's `at`), else the row's own
1004
+ * state_since on the Mac's clock. */
1005
+ function liveElapsed(row, now) {
1006
+ var t = now || Date.now();
1007
+ if (row && typeof row._liveTurnAt === 'number') return (t - LIVE.skew - row._liveTurnAt) / 1000;
1008
+ var d = row ? toDate(row.state_since || row.last_activity_at) : null;
1009
+ return d ? (sxServerTime(t) - d.getTime()) / 1000 : 0;
1010
+ }
1011
+ /** Server ms a row's clock counts from, for the data attribute the 1 s tick reads. */
1012
+ function liveSince(row) {
1013
+ if (row && typeof row._liveTurnAt === 'number') return row._liveTurnAt;
1014
+ var d = row ? toDate(row.state_since || row.last_activity_at) : null;
1015
+ return d ? d.getTime() - SX_CLOCK.skew - LIVE.skew : null;
1016
+ }
1017
+ // ── the 1 s tick: every running clock on screen, never on a hidden page ──
1018
+ var LIVE_TICK = { timer: null };
1019
+ function liveTickStart() {
1020
+ if (LIVE_TICK.timer) return;
1021
+ LIVE_TICK.timer = setInterval(liveTickOnce, 1000);
1022
+ }
1023
+ function liveTickStop() { clearInterval(LIVE_TICK.timer); LIVE_TICK.timer = null; }
1024
+ function liveTickOnce() {
1025
+ if (document.hidden) return; // paused while hidden; visible again paints at once (liveResume, liveNoteTick)
1026
+ var els = document.querySelectorAll('[data-live-since]'), now = Date.now();
1027
+ if (!els.length && !liveNoteBusy()) { liveTickStop(); return; }
1028
+ for (var i = 0; i < els.length; i++) {
1029
+ var since = Number(els[i].getAttribute('data-live-since'));
1030
+ if (isFinite(since)) els[i].textContent = liveClock((now - LIVE.skew - since) / 1000);
1031
+ }
1032
+ liveNoteTick();
1033
+ }
1034
+ // Repaints are batched: a burst of frames paints once.
1035
+ function livePaint(rows, structural) {
1036
+ LIVE.pendingRows = (LIVE.pendingRows || []).concat(rows || []);
1037
+ LIVE.pendingStructural = LIVE.pendingStructural || !!structural;
1038
+ if (LIVE.paintTimer) return;
1039
+ LIVE.paintTimer = setTimeout(function () {
1040
+ var r = LIVE.pendingRows || [], st = LIVE.pendingStructural;
1041
+ LIVE.paintTimer = null; LIVE.pendingRows = []; LIVE.pendingStructural = false;
1042
+ try { liveRepaint(r, st); } catch (err) { console.error(err); }
1043
+ }, 30);
1044
+ }
452
1045
  // ── the session pet (Today) ─────────────────────────────
453
1046
  // Same pet and the same pose rule as COS Control (PetSpritePose.resolve): waiting with nothing running
454
1047
  // is "waiting", then one, two, three, four or more sessions in play are working, duel, trio, swarm, an open
@@ -665,25 +1258,28 @@
665
1258
  }
666
1259
 
667
1260
  // ── Today ───────────────────────────────────────────────
668
- function loadToday() {
1261
+ // force (7.0.1, Today's Refresh): every source read again now, none from a cache. Resolves when the sessions, Work,
1262
+ // welcome and recording reads have painted.
1263
+ function loadToday(force) {
669
1264
  var now = new Date();
670
1265
  $('today-date').textContent = now.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' }) + ', ' + clock(now);
671
1266
  var gen = ++TODAY.gen;
672
1267
  // Voices and memories answer on their own time (the memories status runs a CLI on the Mac): Today paints what it
673
1268
  // has and says it is still reading, then repaints as each one lands.
674
- if (Date.now() - TODAY.extraAt > 60000 || TODAY.voices.state === 'failed' || TODAY.memories.state === 'failed') {
1269
+ var extras = [];
1270
+ if (force === true || Date.now() - TODAY.extraAt > 60000 || TODAY.voices.state === 'failed' || TODAY.memories.state === 'failed') {
675
1271
  TODAY.extraAt = Date.now();
676
1272
  TODAY.voices = { state: 'pending', groups: [] }; TODAY.memories = { state: 'pending', n: 0 };
677
- api('/api/voice/held-groups').then(function (d) {
1273
+ extras.push(api('/api/voice/held-groups').then(function (d) {
678
1274
  TODAY.voices = { state: 'answered', groups: (d && Array.isArray(d.groups)) ? d.groups : [] };
679
- }, function () { TODAY.voices = { state: 'failed', groups: [] }; }).then(function () { paintNeeds(); });
680
- api('/api/web-bridge/memories', { method: 'POST', body: { id: 'today-status', op: 'status', args: {} } }).then(function (d) {
1275
+ }, function () { TODAY.voices = { state: 'failed', groups: [] }; }).then(function () { paintNeeds(); }));
1276
+ extras.push(api('/api/web-bridge/memories', { method: 'POST', body: { id: 'today-status', op: 'status', args: {} } }).then(function (d) {
681
1277
  var n = d && d.details ? d.details.learningToReview : null;
682
1278
  TODAY.memories = typeof n === 'number' ? { state: 'answered', n: Math.max(0, n) } : { state: 'failed', n: 0 };
683
- }, function () { TODAY.memories = { state: 'failed', n: 0 }; }).then(function () { paintNeeds(); });
1279
+ }, function () { TODAY.memories = { state: 'failed', n: 0 }; }).then(function () { paintNeeds(); }));
684
1280
  }
685
- Promise.all([
686
- sessionList(15000).catch(function () { return null; }),
1281
+ var main = Promise.all([
1282
+ sessionList(force === true ? 0 : 15000).catch(function () { return null; }),
687
1283
  api('/api/work-board/your-move').catch(function () { return null; }),
688
1284
  api('/api/welcome-context').catch(function () { return null; }),
689
1285
  api('/api/meetings/live-chunks').catch(function () { return null; })
@@ -698,6 +1294,9 @@
698
1294
  var open = sessions.filter(function (s) { return s.running && !s.running_active; });
699
1295
  var recent = live.filter(function (s) { return Date.now() - (s.mtimeMs || 0) < LIVE_FRESH_MS; });
700
1296
  var offline = !res[0] && !res[1];
1297
+ // 7.0.5: the sessions or Today read recovered from a failed one, so the next summary ask says "reconnect" (the
1298
+ // server lets that one start skip its 10-minute spacing, at most once per 10 minutes).
1299
+ if ((TODAY.sessionsState === 'failed' || TODAY.offline === true) && res[0] && !offline) BRIEF.reconnect = true;
701
1300
  TODAY.needs = needs; TODAY.working = working; TODAY.open = open; TODAY.welcome = welcome; TODAY.offline = offline; TODAY.at = Date.now();
702
1301
  // your-move answers {available:false} when the board cannot be read: that is a failed read, not "nothing".
703
1302
  TODAY.sessionsState = res[0] ? 'answered' : 'failed';
@@ -710,15 +1309,20 @@
710
1309
  paintNeeds();
711
1310
  renderWeather(welcome);
712
1311
  renderNext(welcome);
1312
+ // PH plan P1: the rest of today under Next up. Kept out of renderNext so that stays a pure render.
1313
+ var nx = welcome && welcome.nextEvent;
1314
+ loadCalendarLater(nx, nx && toDate(nx.time));
713
1315
  loadTodayIntake();
714
1316
  setBadge('sessions', working.length > 0);
715
- liveSession(recent).then(function (rec) {
1317
+ return liveSession(recent).then(function (rec) {
716
1318
  if (gen !== TODAY.gen) return;
717
1319
  renderRecording(rec);
718
1320
  setBadge('meetings', !!rec);
719
1321
  if (rec) { TODAY.recording = true; paintNeeds(); }
720
1322
  });
721
1323
  });
1324
+ // A forced read waits for the voices and memories too, so the summary it asks for sees every source.
1325
+ return force === true ? Promise.all([main].concat(extras)) : main;
722
1326
  }
723
1327
  // A capture counts as live only while it is still being written: an abandoned
724
1328
  // active-sessions file must never read as "Recording now".
@@ -742,15 +1346,15 @@
742
1346
  '<span class="btn small">Live view</span></button>';
743
1347
  }
744
1348
  // ── The Today summary (6.99.26 candidate) ───────────────
745
- // "The orchestrator's eyes" (Miles, 11/07): two or three sentences on what the sessions add up to, written by Haiku on
1349
+ // "The orchestrator's eyes" (Miles, 11/07): two or three sentences on what the sessions add up to, written by Sonnet on
746
1350
  // the server (lib/today-brief.ts). Today sends what it already read: session titles, states, how long, each session's
747
1351
  // latest line and what needs you. The server decides whether a new one is written (off unless switched on, only when
748
- // the inputs changed, 10 minutes apart, 60 a day, a breaker). Today never waits for it: the counts line paints at once
1352
+ // the inputs changed, 10 minutes apart, the Summaries per day cap, a breaker). Today never waits for it: the counts line paints at once
749
1353
  // and the summary replaces it when one arrives. Never shown over a session list that was not read.
750
- var BRIEF = { view: null, inflight: false, sentKey: '', sentAt: 0 };
1354
+ var BRIEF = { view: null, inflight: false, sentKey: '', sentAt: 0, reconnect: false };
751
1355
  // 6.99.26 QA: the same rows are asked about again only after five minutes (a Today that sits open still learns what
752
1356
  // the server reads itself: sub-agents and Work cards); changed rows are asked about at once, one ask at a time.
753
- var BRIEF_RESEND_MS = 5 * 60000, BRIEF_OFF_RECHECK_MS = 5 * 60000, BRIEF_TICK_MS = 60000;
1357
+ var BRIEF_RESEND_MS = 5 * 60000, BRIEF_OFF_RECHECK_MS = 5 * 60000, BRIEF_TICK_MS = 60000, BRIEF_LIVE_GAP_MS = 60000;
754
1358
  function briefMinutes(v) { var d = toDate(v); return d ? Math.max(0, Math.round((Date.now() - d.getTime()) / 60000)) : 0; }
755
1359
  function briefInputs(m) {
756
1360
  var sessions = [];
@@ -783,9 +1387,25 @@
783
1387
  var body = briefInputs(m);
784
1388
  var key = JSON.stringify([body.sessions.map(function (s) { return [s.title, s.state, s.line, s.waiting, s.agents]; }), body.needs, body.later, body.sessionsRead, body.needsComplete, body.unread, body.recording]);
785
1389
  var since = Date.now() - BRIEF.sentAt;
786
- if (BRIEF.view && BRIEF.view.state === 'off' ? since < BRIEF_OFF_RECHECK_MS : key === BRIEF.sentKey && since < BRIEF_RESEND_MS) return;
1390
+ // 7.0.5: right after a read recovered, ask at once (the server still holds its own gates).
1391
+ var reconnect = BRIEF.reconnect && !(BRIEF.view && BRIEF.view.state === 'off');
1392
+ if (!reconnect) {
1393
+ if (BRIEF.view && BRIEF.view.state === 'off' ? since < BRIEF_OFF_RECHECK_MS : key === BRIEF.sentKey && since < BRIEF_RESEND_MS) return;
1394
+ // 7.0.1: with the live stream Today repaints on every session move, so a changed Today asks at most once a minute
1395
+ // (Refresh asks at once). Without it, 7.0.0's rule above is the only one.
1396
+ if (typeof liveOn === 'function' && liveOn() && since < BRIEF_LIVE_GAP_MS) return;
1397
+ }
1398
+ BRIEF.reconnect = false;
1399
+ if (reconnect) body.reason = 'reconnect';
787
1400
  BRIEF.inflight = true; BRIEF.sentKey = key; BRIEF.sentAt = Date.now();
788
- api('/api/today/brief', { method: 'POST', body: body, timeoutMs: 45000 }).then(function (v) {
1401
+ var ask = function (b) { return api('/api/today/brief', { method: 'POST', body: b, timeoutMs: 45000 }); };
1402
+ ask(body).catch(function (err) {
1403
+ // A server before 7.0.5 refuses the unknown `reason` (400 invalid_today_brief): ask it the ordinary way.
1404
+ if (!(reconnect && err && err.status === 400 && err.code === 'invalid_today_brief')) throw err;
1405
+ var again = {};
1406
+ Object.keys(body).forEach(function (k) { if (k !== 'reason') again[k] = body[k]; });
1407
+ return ask(again);
1408
+ }).then(function (v) {
789
1409
  BRIEF.view = v && typeof v === 'object' ? v : null;
790
1410
  }, function () { /* keep what was shown: the counts line or the last summary */ }).then(function () {
791
1411
  BRIEF.inflight = false;
@@ -856,15 +1476,21 @@
856
1476
  function listWords(names) { return names.length < 2 ? names.join('') : names.slice(0, -1).join(', ') + ' and ' + names[names.length - 1]; }
857
1477
  function runRow(s, activeNow) {
858
1478
  var key = splitSessionId(s.provider, s.session_id);
1479
+ // 7.0.1: with the live stream, a working row's clock ticks every second from the turn's start (liveTickOnce).
1480
+ // Without it (an older server, the switch off), the minute line 7.0.0 showed.
1481
+ var since = activeNow && typeof liveOn === 'function' && liveOn() ? liveSince(s) : null;
859
1482
  var meta = activeNow
860
- ? 'Working for ' + span(s.state_since || s.last_activity_at)
1483
+ ? 'Working for ' + (since != null ? '\u0000' : span(s.state_since || s.last_activity_at))
861
1484
  : 'Open, quiet for ' + span(s.last_activity_at || s.state_since);
862
1485
  if (s.project && s.project !== usualProject()) meta += ', in ' + String(s.project).replace(/^-Users-[^-]+-/, '');
1486
+ var metaHtml = since != null
1487
+ ? esc(meta).replace('\u0000', '<span class="run-clock" data-live-since="' + esc(String(since)) + '">' + esc(liveClock(liveElapsed(s))) + '</span>')
1488
+ : esc(meta);
863
1489
  // A tap opens the peek on Today (panes/today-actions.js); it falls back to the reader if the peek cannot load.
864
1490
  return '<button type="button" class="run-row" data-today="run" data-key="' + esc(key.provider) + '|' + esc(key.id) + '">' +
865
1491
  '<span class="run-who">' + badge(key.provider) + (activeNow ? '<span class="pulse" aria-label="working"></span>' : '') + '</span>' +
866
1492
  '<span class="run-title">' + esc(sessionTitle(s)) + '</span>' +
867
- '<span class="run-meta">' + esc(meta) + '</span></button>';
1493
+ '<span class="run-meta">' + metaHtml + '</span></button>';
868
1494
  }
869
1495
  function renderRunning(working, open) {
870
1496
  $('run-count').textContent = working.length ? String(working.length) : '';
@@ -880,6 +1506,150 @@
880
1506
  var more = (working.length - shown.length) + (shown.length ? open.length : Math.max(0, open.length - 2));
881
1507
  if (more > 0) html += '<button type="button" class="run-more" data-goto="sessions">' + countWord(more) + ' more open. All sessions</button>';
882
1508
  list.innerHTML = html;
1509
+ if (typeof liveTickStart === 'function' && list.querySelector && list.querySelector('[data-live-since]')) liveTickStart();
1510
+ }
1511
+ // ── live state on screen (7.0.1): Today's counts and rows, the pet, the Sessions rows, the header line ──
1512
+ // The live model (above) calls this, batched, after a frame or a list read moved something.
1513
+ function liveRepaint(rows, structural) {
1514
+ if (!liveSignedIn()) return;
1515
+ if (state.pane === 'today' && TODAY.at && !TODAY.offline && TODAY.sessionsState === 'answered') {
1516
+ var sessions = state.sessions || [];
1517
+ TODAY.working = sessions.filter(function (s) { return s.running_active; });
1518
+ TODAY.open = sessions.filter(function (s) { return s.running && !s.running_active; });
1519
+ renderRunning(TODAY.working, TODAY.open);
1520
+ renderPet(sessions);
1521
+ paintNeeds();
1522
+ setBadge('sessions', TODAY.working.length > 0);
1523
+ }
1524
+ if (state.pane === 'sessions' && SX.rows && SX.view === 'sessions') liveSessionRows(rows, structural);
1525
+ liveNoteTick();
1526
+ }
1527
+ // Sessions: a row whose state moved is redrawn where it stands (the list never jumps under a finger); a session that
1528
+ // appeared or left redraws the list. A search on screen keeps its hits; only the hero's counts move.
1529
+ function liveSessionRows(rows, structural) {
1530
+ var list = $('sessions-list');
1531
+ if (!list) return;
1532
+ if (sxQueryActive()) { paintSessionsHero(); return; }
1533
+ if (structural) { renderSessionGroups(); return; }
1534
+ var amb = sxAmbiguous(sxVisible());
1535
+ (rows || []).forEach(function (s) {
1536
+ if (!s || sxKeepWarm(s)) return;
1537
+ var k = splitSessionId(s.provider, s.session_id || s.id), want = k.provider + '|' + k.id;
1538
+ var els = list.querySelectorAll('[data-session]');
1539
+ for (var i = 0; i < els.length; i++) {
1540
+ if (els[i].getAttribute('data-session') !== want) continue;
1541
+ var html = sxRow(s, null, amb);
1542
+ if (els[i].__liveHtml !== html) {
1543
+ var tmp = document.createElement('div'); tmp.innerHTML = html;
1544
+ var next = tmp.firstElementChild || tmp.firstChild;
1545
+ if (next) { next.__liveHtml = html; els[i].parentNode.replaceChild(next, els[i]); }
1546
+ }
1547
+ break;
1548
+ }
1549
+ });
1550
+ paintSessionsHero();
1551
+ }
1552
+ // ── Today's header line and Refresh ──
1553
+ // Refresh (Miles 11/07: "a 'refresh' button to the home 'today' tab that allows the user to get the latest state and
1554
+ // to update the summary"): every Today source read again now, then a new summary asked for with force (the server
1555
+ // still holds its switch, its one-per-30-s refresh limit and its daily cap, and says which in `reason`).
1556
+ var TR = { busy: false, doneAt: 0, msg: '', retryAt: 0 };
1557
+ var TR_REASONS = {
1558
+ daily_cap: 'Daily summary limit reached',
1559
+ recording: 'No new summary while a meeting records',
1560
+ maintenance: 'No new summary while COS updates',
1561
+ paused: 'The summary is paused for now. Try again later',
1562
+ failed: 'The summary did not update',
1563
+ interval: 'The summary updated recently. Try again in a few minutes'
1564
+ };
1565
+ /** The words for a refresh answer, or '' when the summary updated (or is switched off). */
1566
+ function refreshWords(v) {
1567
+ if (!v || typeof v !== 'object') return '';
1568
+ if (typeof v.message === 'string' && v.message.trim()) return v.message.trim();
1569
+ if (v.state === 'off') return '';
1570
+ if (v.reason === 'refresh_throttled') return '';
1571
+ return v.state === 'fresh' && !v.reason ? '' : (TR_REASONS[v.reason] || '');
1572
+ }
1573
+ function todayRefresh() {
1574
+ if (TR.busy) return Promise.resolve(false);
1575
+ TR.busy = true; TR.msg = ''; TR.retryAt = 0;
1576
+ var btn = $('today-refresh'); if (btn) btn.disabled = true;
1577
+ liveNoteTick();
1578
+ var done = function (msg) {
1579
+ TR.busy = false; TR.doneAt = Date.now(); TR.msg = msg || '';
1580
+ var b = $('today-refresh'); if (b) b.disabled = false;
1581
+ liveNoteTick(); liveTickStart();
1582
+ return true;
1583
+ };
1584
+ return Promise.resolve(loadToday(true)).then(function () {
1585
+ if (typeof liveOn === 'function' && liveOn()) LIVE.freshAt = Date.now();
1586
+ if (!TODAY.model || TODAY.offline) return done('');
1587
+ // The summary switched off: the state only.
1588
+ if (BRIEF.view && (BRIEF.view.state === 'off' || BRIEF.view.enabled === false)) return done('');
1589
+ var body = briefInputs(TODAY.model);
1590
+ body.force = true;
1591
+ BRIEF.inflight = true;
1592
+ // A 7.0.0 server refuses the unknown `force` (400 invalid_today_brief): ask it the ordinary way instead.
1593
+ var ask = function (b) { return api('/api/today/brief', { method: 'POST', body: b, timeoutMs: 45000 }); };
1594
+ return ask(body).catch(function (err) {
1595
+ if (!(err && err.status === 400 && err.code === 'invalid_today_brief')) throw err;
1596
+ delete body.force;
1597
+ return ask(body);
1598
+ }).then(function (v) {
1599
+ BRIEF.inflight = false;
1600
+ if (v && typeof v === 'object') { BRIEF.view = v; BRIEF.sentAt = Date.now(); }
1601
+ if (v && v.reason === 'refresh_throttled') TR.retryAt = Date.now() + Math.max(1000, Number(v.retryAfterMs) || 30000);
1602
+ if (TODAY.model && state.pane === 'today') renderStory(TODAY.model);
1603
+ return done(refreshWords(v));
1604
+ }, function (err) {
1605
+ BRIEF.inflight = false;
1606
+ return done(err && err.code === 'unreachable' ? err.message : 'The summary did not update');
1607
+ });
1608
+ }, function () { return done('Could not read everything. Try again'); });
1609
+ }
1610
+ function liveNoteBusy() {
1611
+ var now = Date.now();
1612
+ return TR.busy || TR.retryAt > now || (TR.doneAt && now - TR.doneAt < 10 * 60000) || !!liveAge(now) || LIVE.mode === 'reconnecting';
1613
+ }
1614
+ /** The line beside Refresh: Updating, Reconnecting, the counts' age, when Today was refreshed, the server's limit. */
1615
+ function liveNoteText(now) {
1616
+ now = now || Date.now();
1617
+ if (TR.busy) return 'Updating';
1618
+ var parts = [];
1619
+ if (LIVE.mode === 'reconnecting') parts.push('Reconnecting');
1620
+ var age = liveAge(now);
1621
+ if (age) parts.push(age);
1622
+ if (TR.doneAt && !age) {
1623
+ var m = Math.floor((now - TR.doneAt) / 60000);
1624
+ if (m < 10) parts.push(m < 1 ? 'Updated just now' : 'Updated ' + m + ' min ago');
1625
+ }
1626
+ if (TR.retryAt > now) parts.push('Try again in ' + Math.ceil((TR.retryAt - now) / 1000) + ' s');
1627
+ else if (TR.msg && TR.doneAt && now - TR.doneAt < 10 * 60000) parts.push(TR.msg);
1628
+ return parts.join('. ');
1629
+ }
1630
+ /** What the line says, without its numbers: the polite status line speaks only when this changes (review N10). */
1631
+ function liveNoteState(now) {
1632
+ now = now || Date.now();
1633
+ var age = liveAge(now);
1634
+ return [TR.busy ? 'busy' : '', LIVE.mode === 'reconnecting' ? 'reconnecting' : '', age ? 'age' : '',
1635
+ TR.doneAt && !age && now - TR.doneAt < 10 * 60000 ? 'done' : '', TR.retryAt > now ? 'retry' : '',
1636
+ TR.msg && TR.doneAt && now - TR.doneAt < 10 * 60000 ? TR.msg : ''].join('|');
1637
+ }
1638
+ function liveNoteTick() {
1639
+ var el = $('today-live-note'), t = liveNoteText();
1640
+ if (el) { if (el.textContent !== t) el.textContent = t; el.classList.toggle('warn', LIVE.mode === 'reconnecting'); }
1641
+ var said = $('today-live-status'), key = liveNoteState();
1642
+ if (said && LIVE.noteSaid !== key) { LIVE.noteSaid = key; said.textContent = t; }
1643
+ var sx = document.querySelector('#sessions-hero .sx-live-note');
1644
+ if (sx) { var n = liveSessionsNote(); sx.textContent = n ? ' · ' + n : ''; }
1645
+ }
1646
+ /** The Sessions hero's honesty suffix: "Reconnecting" and the counts' age, or ''. */
1647
+ function liveSessionsNote() {
1648
+ var bits = [];
1649
+ if (LIVE.mode === 'reconnecting') bits.push('Reconnecting');
1650
+ var age = liveAge();
1651
+ if (age) bits.push(age.charAt(0).toLowerCase() + age.slice(1));
1652
+ return bits.join(' · ');
883
1653
  }
884
1654
  function needWhy(item) {
885
1655
  var p = item.progress || {};
@@ -915,7 +1685,12 @@
915
1685
  if (s.waiting_kind === 'plan') return 'Has a plan for you to approve';
916
1686
  var d = String(s.waiting_detail || '').trim();
917
1687
  if (TOOL_NAMES.indexOf(d) >= 0) d = '';
918
- if (s.waiting_kind === 'permission' && d) return 'Wants to run ' + (d.length > 80 ? d.slice(0, 79) + '…' : d);
1688
+ // 7.0.4 W5: the server shows the home folder in this command as "[home]" (lib/local-path-redact.ts COMMAND_KEYS), so
1689
+ // the line is a display, labelled as one. The exact command is on the approval card, which is not redacted.
1690
+ if (s.waiting_kind === 'permission' && d) {
1691
+ var shown = d.length > 80 ? d.slice(0, 79) + '…' : d;
1692
+ return d.indexOf('[home]') >= 0 ? 'Wants to run (home folder hidden) ' + shown : 'Wants to run ' + shown;
1693
+ }
919
1694
  if (s.waiting_kind === 'question' && d) return d.length > 160 ? d.slice(0, 159) + '…' : d;
920
1695
  return 'Waiting for you' + (sxProvider(s.provider) === 'claude' ? '. Paused until you answer.' : '');
921
1696
  }
@@ -966,6 +1741,11 @@
966
1741
  (TODAY.needs || []).forEach(function (it) {
967
1742
  var k = splitSessionId(it.provider, it.sessionId);
968
1743
  if (k.id && seen[k.provider + '|' + String(k.id).toLowerCase()]) return; // Control: a Work row for a waiting session is the session's row
1744
+ // 7.0.1 (Miles 11/07): the server saw you start a turn in the session after its needs-input or blocked report
1745
+ // (work-activity.ts supersededBy). That report is history, not something waiting on you. Only the item that
1746
+ // regrouped to running leaves: a superseded completed handoff regroups to "Needs a look", which the Messages
1747
+ // badge counts, so it stays here too (review N6).
1748
+ if (it.supersededAt && it.group === 'running') return;
969
1749
  rows.push({ kind: 'work', tier: 3, item: it, key: k.id ? k.provider + '|' + k.id : '' });
970
1750
  });
971
1751
  var groups = TODAY.voices.groups || [];
@@ -1005,7 +1785,8 @@
1005
1785
  return bits.join(', ');
1006
1786
  }
1007
1787
  function needRowHtml(r, i) {
1008
- var age = r.since ? ago(r.since) : r.item ? ago(r.item.updatedAt || r.item.createdAt) : '';
1788
+ // A Work row is dated by when it started waiting (7.0.1), not by the card's newest event.
1789
+ var age = r.since ? ago(r.since) : r.item ? ago(r.item.waitingSince || r.item.updatedAt || r.item.createdAt) : '';
1009
1790
  var top = function (title) { return '<span class="need-top"><span class="need-title">' + esc(title) + '</span>' + (age ? '<span class="need-age">' + esc(age) + '</span>' : '') + '</span>'; };
1010
1791
  if (r.kind === 'work') {
1011
1792
  var it = r.item, k = splitSessionId(it.provider, it.sessionId);
@@ -1070,6 +1851,27 @@
1070
1851
  '<button type="button" class="link-btn" data-goto="meetings">Open meeting</button>';
1071
1852
  card.classList.remove('hidden');
1072
1853
  }
1854
+ // PH plan P1: the rest of today from GET /api/calendar/today (selected calendars, next 3), under Next up.
1855
+ // An aging snapshot says when it was read. Not connected or nothing more: the block stays hidden.
1856
+ function loadCalendarLater(n, d) {
1857
+ var box = $('next-later'); if (!box) return;
1858
+ if (!n || !n.title || !d) { box.classList.add('hidden'); box.innerHTML = ''; return; }
1859
+ var gen = TODAY.gen;
1860
+ api('/api/calendar/today').then(function (c) {
1861
+ if (gen !== TODAY.gen) return;
1862
+ if (!c || !c.connected) { box.classList.add('hidden'); box.innerHTML = ''; return; }
1863
+ var same = function (r) { var rd = toDate(r.start); return r.title === n.title && rd && d && Math.abs(rd.getTime() - d.getTime()) < 60000; };
1864
+ var rows = (c.next || []).filter(function (r) { return !r.allDay && !same(r); }).slice(0, 2);
1865
+ var asOf = c.stale && c.asOf ? toDate(c.asOf) : null;
1866
+ var html = rows.map(function (r) {
1867
+ var rd = toDate(r.start);
1868
+ return '<div class="later-row"><span class="later-when">' + esc(rd ? clock(rd) : '') + '</span><span class="later-what">' + esc(r.title) + '</span></div>';
1869
+ }).join('');
1870
+ if (asOf) html += '<div class="later-asof">' + esc('Calendar as of ' + clock(asOf)) + '</div>';
1871
+ box.innerHTML = html;
1872
+ box.classList.toggle('hidden', !html);
1873
+ }, function () { box.classList.add('hidden'); box.innerHTML = ''; });
1874
+ }
1073
1875
  function prepNext() {
1074
1876
  var n = TODAY.welcome && TODAY.welcome.nextEvent, d = n && toDate(n.time), chat = window.COSWeb && window.COSWeb.cosChat;
1075
1877
  if (!n || !n.title || !chat || !chat.prepFor) return;
@@ -1291,7 +2093,7 @@
1291
2093
  var stats = view === 'sessions' ? sxStats(vis) : [];
1292
2094
  var busy = view === 'sessions' ? SX.loading : SX.usageLoading;
1293
2095
  hero.innerHTML = '<span class="cx-glyph"><svg class="icon"><use href="#i-sessions"/></svg></span>' +
1294
- '<div class="cx-hero-text"><h1 class="cx-title">Sessions</h1><p class="cx-detail-line">' + esc(detail) + '</p></div>' +
2096
+ '<div class="cx-hero-text"><h1 class="cx-title">Sessions</h1><p class="cx-detail-line">' + esc(detail) + liveHeroNote(view) + '</p></div>' +
1295
2097
  '<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>' +
1296
2098
  (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>' : '');
1297
2099
  var clockOn = view === 'sessions' && !sxQueryActive();
@@ -1306,6 +2108,12 @@
1306
2108
  if (usage) usage.classList.toggle('hidden', view === 'sessions');
1307
2109
  }
1308
2110
 
2111
+ // 7.0.1: the hero's counts say when they are not live ("Reconnecting", "as of 2 min ago"); nothing without a stream.
2112
+ function liveHeroNote(view) {
2113
+ if (view !== 'sessions' || typeof liveSessionsNote !== 'function') return '';
2114
+ var n = liveSessionsNote();
2115
+ return '<span class="sx-live-note">' + (n ? ' · ' + esc(n) : '') + '</span>';
2116
+ }
1309
2117
  function loadSessions(force) {
1310
2118
  var r = parseRoute();
1311
2119
  var reading = r.pane === 'sessions' && r.args.length === 2;
@@ -1371,7 +2179,7 @@
1371
2179
  var meta = sxBadge(key.provider) +
1372
2180
  (s.pinned ? '<span class="sx-pinned">PINNED</span>' : '') +
1373
2181
  (label ? '<span class="sx-chip ' + st + '">' + esc(label) + '</span>' : '') +
1374
- (notSentFor(key.provider, key.id) ? '<span class="sx-chip refused" title="' + esc(notSentFor(key.provider, key.id).line) + '">Not sent</span>' : '') +
2182
+ (notSentFor(key.provider, key.id) ? '<span class="sx-chip refused" title="' + esc(notSentFor(key.provider, key.id).line) + '">' + esc(notSentFor(key.provider, key.id).label || 'Not sent') + '</span>' : '') +
1375
2183
  (upd ? '<span class="sx-when">Updated ' + esc(sxShortDate(upd)) + '</span>' : '') +
1376
2184
  (ambiguous[title] && opened ? '<span class="sx-opened" title="Another session on screen has the same name. This one was opened ' + esc(sxShortDate(opened)) + '.">Opened ' + esc(sxShortDate(opened)) + '</span>' : '') +
1377
2185
  (s.project && String(s.project) !== title ? '<span class="sx-ws">' + esc(s.project) + '</span>' : '');
@@ -1577,6 +2385,7 @@
1577
2385
  }
1578
2386
  if (act === 'clear') { var input = $('sessions-q'); input.value = ''; searchSessions(); input.focus(); return; }
1579
2387
  if (act === 'copy-session') return copySessionBrief();
2388
+ if (act === 'full') { if (SX_SPLIT) SX_SPLIT.toggleFull(); return; }
1580
2389
  if (act === 'refresh-turns' && rd) { rd.refusal = null; renderVerdict(rd); refreshTurns(rd.provider, rd.id); return; }
1581
2390
  if (act === 'retry' && rd) { rd.refusal = null; renderVerdict(rd); return sendSessionTurn(!!rd.retryFork); }
1582
2391
  if (act === 'caution-send' && rd) { rd.cautionAck = true; rd.cautionAsk = false; renderVerdict(rd); return sendSessionTurn(false); }
@@ -1605,6 +2414,153 @@
1605
2414
  });
1606
2415
  }
1607
2416
 
2417
+ // Duo tables: the divider between two columns, where both show side by side. One kit (splitKit) for every pane that
2418
+ // has them: a list beside its reader in Sessions (760 px and up), Memories (760 px and up) and Messages with the COS
2419
+ // chat (860 px and up), and a reader beside its side panel in a meeting (Ask, 860 px and up) and a Work card (900 px).
2420
+ // Drag it with a finger, mouse or pen (pointer events, captured, so a table's own sideways scroll and text selection
2421
+ // in the panes never start a drag), or focus it and use the arrow keys (Shift for bigger steps, Home and End).
2422
+ // A double tap or double click goes back to the default width. The width is a share of the split (CSS clamps it so the
2423
+ // list keeps 240 px and the reader 380 px, also after the screen turns) and is remembered on this device, one key per
2424
+ // pane; "Full width" in the reader hides the list and "Show list" brings it back. Storage that throws (a private
2425
+ // window) only means the choice lasts this visit. Below a pane's own breakpoint the divider is hidden by CSS and the
2426
+ // kit does nothing (o.media), so a phone's layout never changes.
2427
+ 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;
2428
+ // o: key (storage), prefix (class names: <prefix>-sized, -full and -dragging), cssVar (the share, default
2429
+ // --<prefix>-list-share), label (what the divider sizes, for screen readers), sized (the column the share sizes,
2430
+ // default the list), side ('end' when that column is on the right: a side panel grows as the line moves left),
2431
+ // fullSel (the Full width buttons), media (where the divider shows), listMin (the sized column's minimum), readerMin
2432
+ // (the other column's), handleW (px, the same numbers as the pane's CSS clamp), memo (this visit's choice, kept across
2433
+ // a pane's repaint when storage is off).
2434
+ function splitKit(split, handle, storage, o) {
2435
+ var KEY = o.key, PRE = o.prefix || 'split', LIST_MIN = o.listMin || SX_LIST_MIN, READER_MIN = o.readerMin || SX_READER_MIN,
2436
+ HANDLE_W = o.handleW || SX_HANDLE_W, LIST_SEL = o.sized || '.split-list', FULL_SEL = o.fullSel || '[data-split-full="' + KEY + '"]', memo = o.memo || null,
2437
+ VAR = o.cssVar || '--' + PRE + '-list-share', END = o.side === 'end';
2438
+ var saved = {};
2439
+ try { var raw = JSON.parse(storage.getItem(KEY) || 'null'); if (raw && typeof raw === 'object') saved = raw; } catch (_e) { saved = {}; }
2440
+ if (memo && memo.at) saved = memo;
2441
+ var share = typeof saved.share === 'number' && saved.share > 0 && saved.share < 100 ? saved.share : null, full = saved.full === true, lastTap = 0;
2442
+ function save() {
2443
+ if (memo) { memo.share = share; memo.full = full; memo.at = Date.now(); }
2444
+ try { storage.setItem(KEY, JSON.stringify({ share: share, full: full })); } catch (_e) { /* this visit only */ }
2445
+ }
2446
+ // Narrow (a phone, the folded Duo): the divider is not shown, and nothing here may move the layout.
2447
+ function narrow() { return !!o.media && typeof matchMedia === 'function' && !matchMedia(o.media).matches; }
2448
+ function total() { return split.getBoundingClientRect().width; }
2449
+ function listWidth() { var l = split.querySelector(LIST_SEL); return l ? l.getBoundingClientRect().width : 0; }
2450
+ function bounds(t) { return { min: LIST_MIN, max: Math.max(LIST_MIN, t - HANDLE_W - READER_MIN) }; }
2451
+ function aria() {
2452
+ var t = total(); if (!(t > 0)) return;
2453
+ var b = bounds(t), pct = function (px) { return Math.round(px / t * 100); }, now = pct(listWidth());
2454
+ handle.setAttribute('aria-valuemin', String(pct(b.min)));
2455
+ handle.setAttribute('aria-valuemax', String(pct(b.max)));
2456
+ handle.setAttribute('aria-valuenow', String(now));
2457
+ handle.setAttribute('aria-valuetext', (o.label || 'List') + ' ' + now + '% of the width');
2458
+ }
2459
+ function paint() {
2460
+ split.classList.toggle(PRE + '-sized', share != null);
2461
+ if (share != null) split.style.setProperty(VAR, share.toFixed(2) + '%'); else split.style.removeProperty(VAR);
2462
+ split.classList.toggle(PRE + '-full', full);
2463
+ document.querySelectorAll(FULL_SEL).forEach(function (b) { b.setAttribute('aria-pressed', String(full)); b.textContent = full ? 'Show list' : 'Full width'; });
2464
+ aria();
2465
+ }
2466
+ // px: the sized column's width asked for; kept inside both columns' minimums for the split's width now.
2467
+ function setWidth(px, persist) {
2468
+ if (narrow()) return;
2469
+ var t = total(); if (!(t > 0)) return;
2470
+ var b = bounds(t);
2471
+ share = Math.min(b.max, Math.max(b.min, px)) / t * 100;
2472
+ paint();
2473
+ if (persist) save();
2474
+ }
2475
+ function reset() { share = null; paint(); save(); }
2476
+ handle.addEventListener('pointerdown', function (e) {
2477
+ if (e.pointerType === 'mouse' && e.button !== 0) return;
2478
+ if (narrow()) return;
2479
+ e.preventDefault();
2480
+ var x0 = e.clientX, start = listWidth(), moved = false, id = e.pointerId;
2481
+ try { handle.setPointerCapture(id); } catch (_e) {}
2482
+ split.classList.add(PRE + '-dragging');
2483
+ // A column on the right grows as the pointer moves left.
2484
+ function dx(ev) { return END ? x0 - ev.clientX : ev.clientX - x0; }
2485
+ function move(ev) {
2486
+ if (ev.pointerId !== id) return;
2487
+ if (!moved && Math.abs(ev.clientX - x0) < 4) return;
2488
+ moved = true;
2489
+ setWidth(start + dx(ev), false);
2490
+ }
2491
+ function end(ev) {
2492
+ if (ev.pointerId !== id) return;
2493
+ handle.removeEventListener('pointermove', move); handle.removeEventListener('pointerup', end); handle.removeEventListener('pointercancel', end);
2494
+ split.classList.remove(PRE + '-dragging');
2495
+ // The lift's own position counts: a browser may fold the last moves into it.
2496
+ if (moved) { if (ev.type === 'pointerup') setWidth(start + dx(ev), false); save(); lastTap = 0; return; }
2497
+ if (ev.type !== 'pointerup') return;
2498
+ var now = Date.now();
2499
+ if (now - lastTap < SX_DOUBLE_MS) { lastTap = 0; reset(); } else lastTap = now;
2500
+ }
2501
+ handle.addEventListener('pointermove', move); handle.addEventListener('pointerup', end); handle.addEventListener('pointercancel', end);
2502
+ });
2503
+ handle.addEventListener('dblclick', function (e) { if (narrow()) return; e.preventDefault(); reset(); });
2504
+ handle.addEventListener('keydown', function (e) {
2505
+ if (narrow()) return;
2506
+ var t = total(), b = bounds(t), w = listWidth(), step = e.shiftKey ? SX_KEY_STEP * 4 : SX_KEY_STEP;
2507
+ // The arrows move the line the way they point; Home and End take it to the left and right limits.
2508
+ var left = END ? 1 : -1;
2509
+ var to = e.key === 'ArrowLeft' ? w + left * step : e.key === 'ArrowRight' ? w - left * step : e.key === 'Home' ? (END ? b.max : b.min) : e.key === 'End' ? (END ? b.min : b.max) : null;
2510
+ if (to == null) return;
2511
+ e.preventDefault();
2512
+ setWidth(to, true);
2513
+ });
2514
+ paint();
2515
+ return {
2516
+ setWidth: setWidth, reset: reset, paint: paint,
2517
+ toggleFull: function () { full = !full; paint(); save(); },
2518
+ state: function () { return { share: share, full: full }; }
2519
+ };
2520
+ }
2521
+ // Sessions: the first pane with the divider, unchanged (its markup is in index.html, its CSS in panes/sessions.css).
2522
+ var SX_SPLIT_OPTS = { key: SX_SPLIT_KEY, prefix: 'sx', label: 'Session list', fullSel: '[data-sx="full"]', media: '(min-width: 760px)',
2523
+ listMin: SX_LIST_MIN, readerMin: SX_READER_MIN, handleW: SX_HANDLE_W };
2524
+ function sxSplitKit(split, handle, storage) { return splitKit(split, handle, storage, SX_SPLIT_OPTS); }
2525
+ // The other panes (W.splitKit): the divider's markup, a Full width button, and mount, which binds the kit to a split
2526
+ // the pane just drew. One live kit per key; a pane that draws its split again gets a fresh kit with this visit's choice.
2527
+ var SPLITS = {}, SPLIT_MEMO = {}, SPLIT_NO_STORE = { getItem: function () { return null; }, setItem: function () {} };
2528
+ function splitStorage() { try { return window.localStorage || null; } catch (_e) { return null; } }
2529
+ function splitHandleHtml(o) {
2530
+ return '<div class="split-handle" role="separator" aria-orientation="vertical" aria-controls="' + esc(o.controls) + '" aria-label="' + esc(o.label + ' width') +
2531
+ '" tabindex="0" title="Drag to resize. Double-tap or double-click to reset."' + (o.id ? ' id="' + esc(o.id) + '"' : '') + '></div>';
2532
+ }
2533
+ function splitFullButton(key, controls) {
2534
+ var live = SPLITS[key], full = !!(live && live.kit.state().full);
2535
+ return '<button type="button" class="cx-btn quiet split-full-btn" data-split-full="' + esc(key) + '" aria-controls="' + esc(controls || '') + '" aria-pressed="' + full + '">' + (full ? 'Show list' : 'Full width') + '</button>';
2536
+ }
2537
+ function mountSplit(split, handle, o) {
2538
+ if (!split || !handle || !o || !o.key) return null;
2539
+ var live = SPLITS[o.key];
2540
+ if (live && live.split === split && live.handle === handle) return live.kit;
2541
+ var memo = SPLIT_MEMO[o.key] || (SPLIT_MEMO[o.key] = {});
2542
+ var kit = splitKit(split, handle, splitStorage() || SPLIT_NO_STORE, Object.assign({}, o, { memo: memo }));
2543
+ SPLITS[o.key] = { kit: kit, split: split, handle: handle };
2544
+ return kit;
2545
+ }
2546
+ var SX_SPLIT = null;
2547
+ (function () {
2548
+ var split = $('sessions-split'), handle = $('sessions-handle');
2549
+ if (!split || !handle) return;
2550
+ var store = splitStorage();
2551
+ SX_SPLIT = sxSplitKit(split, handle, store || SPLIT_NO_STORE);
2552
+ })();
2553
+ // A turned screen or a fold changes the split's width: every divider on the page repaints its limits.
2554
+ window.addEventListener('resize', function () {
2555
+ if (SX_SPLIT) SX_SPLIT.paint();
2556
+ Object.keys(SPLITS).forEach(function (k) { var s = SPLITS[k]; if (s.split.isConnected) s.kit.paint(); });
2557
+ });
2558
+ document.addEventListener('click', function (e) {
2559
+ var b = e.target && e.target.closest ? e.target.closest('[data-split-full]') : null;
2560
+ var live = b ? SPLITS[b.getAttribute('data-split-full')] : null;
2561
+ if (live) live.kit.toggleFull();
2562
+ });
2563
+
1608
2564
  // ── Sessions: read, send, queue, fork, stop (the glasses' Continue, from a browser) ──
1609
2565
  // The server owns every safety rule (agent-session-bindings.ts): COS writes into a desktop
1610
2566
  // thread only through a binding, attaches only when no live process owns the thread, and
@@ -1654,14 +2610,19 @@
1654
2610
  // queue replaces it, so a row the server drops clears the mark.
1655
2611
  var NOT_SENT_KEY = 'cos_web_not_sent', NOT_SENT_KEEP_MS = 86400000;
1656
2612
  function notSentRows(rows) { return (rows || []).filter(function (t) { return t && (t.status === 'refused' || t.status === 'expired'); }); }
2613
+ // A row COS cannot tell went in or not (deliveryState unknown, e.g. a steer it could not confirm) is not "Not sent":
2614
+ // it may be in the session already, so it has its own label and its own line, never a "Not sent:" prefix.
2615
+ function maybeSent(t) { return !!(t && t.deliveryState === 'unknown'); }
2616
+ function notSentLabel(t) { return maybeSent(t) ? 'May have been sent' : 'Not sent'; }
1657
2617
  function notSentLine(t) {
2618
+ if (maybeSent(t)) return String(t.reasonCopy || 'May not have been sent. Read it, then say it again.').trim();
1658
2619
  var why = String((t && t.reasonCopy) || (t && t.status === 'expired' ? 'the thread stayed busy too long.' : 'the session refused it.')).trim();
1659
2620
  return /^not sent\b/i.test(why) ? why : 'Not sent: ' + why.charAt(0).toLowerCase() + why.slice(1);
1660
2621
  }
1661
2622
  function noteNotSent(provider, id, rows) {
1662
2623
  var all = readStore(NOT_SENT_KEY), k = provider + '|' + id, now = Date.now(), list = notSentRows(rows);
1663
2624
  Object.keys(all).forEach(function (x) { if (!all[x] || now - (Number(all[x].at) || 0) > NOT_SENT_KEEP_MS) delete all[x]; });
1664
- if (list.length) all[k] = { n: list.length, line: notSentLine(list[list.length - 1]).slice(0, 240), at: now }; else delete all[k];
2625
+ if (list.length) all[k] = { n: list.length, line: notSentLine(list[list.length - 1]).slice(0, 240), label: notSentLabel(list[list.length - 1]), at: now }; else delete all[k];
1665
2626
  writeStore(NOT_SENT_KEY, all);
1666
2627
  }
1667
2628
  function notSentFor(provider, id) {
@@ -1738,6 +2699,11 @@
1738
2699
  return 'Showing the last ' + turns.length + (total > turns.length && !d.truncated ? ' of ' + total : '') + ' turns. Copy session keeps the original request plus the newest context.';
1739
2700
  }
1740
2701
 
2702
+ // Wide layouts: give the reader the whole width, or bring the list back (sxSplitKit). Hidden under 760 px by CSS.
2703
+ function sxFullButton() {
2704
+ var full = !!(SX_SPLIT && SX_SPLIT.state().full);
2705
+ return '<button type="button" class="cx-btn quiet sx-full-btn" data-sx="full" aria-controls="sessions-list-col" aria-pressed="' + full + '">' + (full ? 'Show list' : 'Full width') + '</button>';
2706
+ }
1741
2707
  function openSession(provider, id) {
1742
2708
  var reader = $('session-reader');
1743
2709
  if (!provider || !id) return;
@@ -1752,7 +2718,7 @@
1752
2718
  var listed = (SX.rows || []).filter(function (s) { return sxSame(sxKey(s), provider + ':' + String(id).toLowerCase()); })[0];
1753
2719
  if (listed) window.COSWeb.setCrumb(sxTitle(listed));
1754
2720
  reader.innerHTML = '<div class="sx-reader-head"><div class="skeleton" style="height:26px;width:70%;margin:6px 0 10px"></div><div class="skeleton" style="width:45%"></div></div>' + skeletonRows(3);
1755
- api(sessionPath(provider, id, '?turns=' + SX_TURNS)).then(function (d) {
2721
+ api(sessionPath(provider, id, '?turns=' + SX_TURNS + '&cards=1')).then(function (d) {
1756
2722
  if (state.reading !== r) return;
1757
2723
  var turns = Array.isArray(d && d.recent_turns) ? d.recent_turns : ((d && d.recent_turns && d.recent_turns.turns) || (d && d.turns) || []);
1758
2724
  r.detail = d; r.turns = turns; r.more = d.recent_turns_more === true;
@@ -1761,7 +2727,7 @@
1761
2727
  window.COSWeb.setCrumb(title);
1762
2728
  var trunc = sxTruncNote(d, turns, r.more);
1763
2729
  reader.innerHTML =
1764
- '<div class="sx-reader-head"><div class="sx-head-line">' + sxBadge(provider) + '<h2 class="sx-reader-title">' + esc(title) + '</h2></div>' +
2730
+ '<div class="sx-reader-head"><div class="sx-head-line">' + sxBadge(provider) + '<h2 class="sx-reader-title">' + esc(title) + '</h2>' + sxFullButton() + '</div>' +
1765
2731
  '<p class="sx-subtitle">' + esc(sxSubtitle(d, provider, turns, r.more)) + '</p></div>' +
1766
2732
  '<div class="sx-activity" id="session-live" aria-live="polite"></div>' +
1767
2733
  (trunc ? '<p class="sx-note">' + esc(trunc) + '</p>' : '') +
@@ -2082,7 +3048,7 @@
2082
3048
  }
2083
3049
  function refreshTurns(provider, id) {
2084
3050
  var reading = state.reading;
2085
- api(sessionPath(provider, id, '?turns=' + SX_TURNS)).then(function (d) {
3051
+ api(sessionPath(provider, id, '?turns=' + SX_TURNS + '&cards=1')).then(function (d) {
2086
3052
  var r = state.reading;
2087
3053
  if (!r || r !== reading || r.id !== id || r.provider !== provider) return;
2088
3054
  var turns = Array.isArray(d && d.recent_turns) ? d.recent_turns : [];
@@ -2122,7 +3088,7 @@
2122
3088
  (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>';
2123
3089
  }).join('') +
2124
3090
  notSent.map(function (t) {
2125
- return '<div class="sx-queue-row sx-queue-refused"><span class="sx-queue-state">Not sent</span><span class="sx-queue-text">' + esc(t.preview || t.prompt || 'Message') +
3091
+ return '<div class="sx-queue-row sx-queue-refused"><span class="sx-queue-state">' + esc(notSentLabel(t)) + '</span><span class="sx-queue-text">' + esc(t.preview || t.prompt || 'Message') +
2126
3092
  '<span class="sx-queue-reason">' + esc(notSentLine(t)) + '</span></span></div>';
2127
3093
  }).join('') + '</div>';
2128
3094
  }).catch(function () {});
@@ -2143,7 +3109,9 @@
2143
3109
  var b = a.body || {};
2144
3110
  if (a.status !== 200 || typeof b.attachable !== 'boolean') { failed(); return; }
2145
3111
  var owners = Number(b.ownerCount) || 0;
2146
- r.verdict = { attachable: b.attachable, reason: String(b.reason || ''), reasonCopy: String(b.reasonCopy || ''), ownerCount: owners, caution: b.attachable === true && owners > 0 };
3112
+ r.verdict = { attachable: b.attachable, reason: String(b.reason || ''), reasonCopy: String(b.reasonCopy || ''), ownerCount: owners, caution: b.attachable === true && owners > 0,
3113
+ // Steer mid-turn: the server says whether a message sent now goes into the running turn.
3114
+ midTurn: !!(b.midTurn && b.midTurn.available === true) };
2147
3115
  // 6.99.29: the gate's answer is a signal of the one state, as of now.
2148
3116
  sxNoteGate(r, b.attachable === false && b.reason === 'native_thread_working');
2149
3117
  sxRepaint(r);
@@ -2154,6 +3122,17 @@
2154
3122
  codex: 'Working right now. A message you send joins the Codex app’s queue for this thread.',
2155
3123
  claude: 'Working right now. A message you send waits and lands when this turn ends.'
2156
3124
  };
3125
+ // Steer mid-turn: only when the server says this session takes a message inside its running turn.
3126
+ var SX_PARK_MIDTURN = 'Working right now. Your message lands at its next step.';
3127
+ // QA 7.0.0: a message with files never goes in mid-turn (the drain steers words only; a row with files waits for the
3128
+ // turn end, and the park answer says landsAt turn_end). With files in the box the note says so.
3129
+ var SX_PARK_FILES = 'Working right now. A message with files waits and lands when this turn ends.';
3130
+ // `hasFiles`: a view (Today's sheet) says whether its own box holds files; the reader's box is read here.
3131
+ function sxParkLine(r, hasFiles) {
3132
+ if (!(r.verdict && r.verdict.midTurn === true)) return SX_PARK[sxProvider(r.provider)];
3133
+ if (hasFiles === undefined) hasFiles = sxReaderHasFiles(r);
3134
+ return hasFiles ? SX_PARK_FILES : SX_PARK_MIDTURN;
3135
+ }
2157
3136
  /** The composer's "Working right now" note: a busy gate verdict, and the one state agrees. */
2158
3137
  function sxParkNoteShows(r) {
2159
3138
  var v = r.verdict;
@@ -2186,7 +3165,7 @@
2186
3165
  (f.fork ? forkBtn : ''));
2187
3166
  } else if (v && v.attachable === false && /^(native_thread_working|native_target_busy)$/.test(v.reason)) {
2188
3167
  // 6.99.29: "Working right now" only while the one state says so.
2189
- if (sxParkNoteShows(r)) html = '<div class="sx-park"><p>' + esc(SX_PARK[sxProvider(r.provider)]) + '</p>' + (/fork/i.test(v.reasonCopy) ? '<div class="sx-box-actions">' + forkBtn + '</div>' : '') + '</div>';
3168
+ 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>';
2190
3169
  } else if (v && v.attachable === false && v.reasonCopy) {
2191
3170
  html = refusalBox(v.reasonCopy, sxSupplement(v.reason), /fork/i.test(v.reasonCopy) ? forkBtn : '');
2192
3171
  }
@@ -2282,6 +3261,12 @@
2282
3261
  if (!READER_FILES[key].anyBytes && !READER_FILES[key].atts.length && kit.turnFileBytes()) READER_FILES[key].anyBytes = kit.turnFileBytes();
2283
3262
  return READER_FILES[key];
2284
3263
  }
3264
+ // Whether the reader's own box holds a file for this session (any state: a file still uploading goes with the message).
3265
+ function sxReaderHasFiles(r) {
3266
+ if (!r || r.view || state.reading !== r || !readerFilesOn()) return false;
3267
+ var box = readerBox(r);
3268
+ return !!(box && box.atts.length);
3269
+ }
2285
3270
  function readerFilesState(box) {
2286
3271
  var kit = readerKit();
2287
3272
  if (!kit || !box || !box.atts.length) return 'none';
@@ -2297,6 +3282,8 @@
2297
3282
  Array.prototype.forEach.call(slot.querySelectorAll('[data-sx-file]'), function (i) { i.disabled = full || !!r.sending; });
2298
3283
  list.innerHTML = kit.chipsHtml(box, 'data-action') + (box.atts.length ? '<p class="att-limit">' + esc(kit.limitLine(box)) + '</p>' : '');
2299
3284
  list.classList.toggle('hidden', !box.atts.length);
3285
+ // QA 7.0.0: the "Working right now" note depends on whether files go with the message (sxParkLine).
3286
+ if (r && r.verdict) renderVerdict(r);
2300
3287
  // Send and Fork wait while a file uploads. A failed file never greys them out (QA 6.99.27): a tap says why.
2301
3288
  var st = readerFilesState(box), form = $('session-form');
2302
3289
  if (!form || r.sending) return;
@@ -2526,7 +3513,7 @@
2526
3513
  rememberParked(r, draft.key);
2527
3514
  if (opts.held) settleHeld(r, opts.held, 'parked');
2528
3515
  else { view.pending(r, draft.text, 'waiting in line'); view.clear(r, draft.text); }
2529
- view.status(r, b.error === 'duplicate_turn' ? 'Already waiting to send.' : (b.position ? 'The session is busy. Yours is #' + (b.position + 1) + ' in line and sends on its own.' : 'The session is busy. Yours sends next, on its own.'));
3516
+ view.status(r, b.error === 'duplicate_turn' ? 'Already waiting to send.' : (b.landsAt === 'next_step' ? 'The session is busy. Yours lands at its next step.' : b.position ? 'The session is busy. Yours is #' + (b.position + 1) + ' in line and sends on its own.' : 'The session is busy. Yours sends next, on its own.'));
2530
3517
  view.queue(r);
2531
3518
  // 6.99.29: "The session is busy" is the gate's word as of now; the card shows the same. Last, and
2532
3519
  // never allowed to cost the parked message its settlement.
@@ -2622,11 +3609,50 @@
2622
3609
  }
2623
3610
 
2624
3611
  // Output previews are separate from composer attachments. Only the DTO fields reach the lazy viewer.
3612
+ // opts.part (sxTurnHtml): 'before' is only the step that came before a reply's words, drawn above them; 'after' is
3613
+ // everything else, drawn below; 'user' is a YOU turn's attached images and never a reply's outputs. No opts: all of it.
2625
3614
  function readerImagesHtml(turn) {
3615
+ var opts = arguments[1], part = opts && opts.part;
3616
+ // 6.99.35: "Show files and images your sessions make" is off (Settings, Privacy). The server sent no names, only this mark.
3617
+ if (turn && turn.previewsOff === true) return part === 'before' ? '' : '<p class="rm-status rm-off">Previews are off. Turn them on in Settings, Privacy.</p>';
3618
+ if (part === 'before') return readerToolsHtml(turn, true);
3619
+ if (part === 'user') return readerToolsHtml(turn, false);
3620
+ var head = part === 'after' ? '' : readerToolsHtml(turn, true);
2626
3621
  var images = turn && Array.isArray(turn.outputImages) ? turn.outputImages.filter(function (x) {
2627
3622
  return x && /^ai_[a-f0-9]{64}$/.test(x.id || '') && (x.state === 'available' || x.state === 'unavailable');
2628
3623
  }).map(function (x) { return { id: x.id, label: String(x.label || 'Output image').slice(0, 180), state: x.state, reason: x.reason }; }) : [];
2629
- return images.length ? '<div data-reader-images="' + esc(JSON.stringify(images)) + '"><p class="rm-status" role="status">Loading image previews…</p></div>' : '';
3624
+ return head + (images.length ? '<div data-reader-images="' + esc(JSON.stringify(images)) + '"><p class="rm-status" role="status">Loading image previews…</p></div>' : '') + readerFilesHtml(turn) + readerToolsHtml(turn, false);
3625
+ }
3626
+ // 11/08: images the session looked at (a Read of a PNG, a screenshot) and a prompt's attached images, as the Claude
3627
+ // app shows them under a step: "Read", "1 of 2". Only the checked DTO fields travel; the viewer loads them lazily by
3628
+ // opaque id. `before`: the step came before the turn's words. The count of more rides on the last strip shown.
3629
+ var READER_TOOLS = ['Read', 'Screenshot', 'Image', 'Attached'];
3630
+ function readerToolsHtml(turn, before) {
3631
+ var all = turn && Array.isArray(turn.toolImages) ? turn.toolImages.filter(function (x) {
3632
+ return x && /^ti_[a-f0-9]{64}$/.test(x.id || '') && (x.state === 'available' || x.state === 'unavailable');
3633
+ }).slice(0, 12) : [];
3634
+ var mine = all.filter(function (x) { return (x.before === true) === before; }).map(function (x) {
3635
+ return { id: x.id, tool: READER_TOOLS.indexOf(x.tool) >= 0 ? x.tool : 'Image', index: Math.floor(Number(x.index)) || 1, total: Math.floor(Number(x.total)) || 1, state: x.state,
3636
+ reason: ['outside_workspace', 'unavailable', 'too_large', 'missing', 'secret_file'].indexOf(x.reason) >= 0 ? x.reason : undefined };
3637
+ });
3638
+ var hasAfter = all.some(function (x) { return x.before !== true; });
3639
+ var more = Math.max(0, Math.min(9999, Math.floor(Number(turn && turn.toolImagesMore)) || 0));
3640
+ var hasBefore = all.some(function (x) { return x.before === true; });
3641
+ var mineMore = more && (before ? hasBefore && !hasAfter : !(hasBefore && !hasAfter)) ? more : 0;
3642
+ if (!mine.length && !mineMore) return '';
3643
+ return '<div data-reader-tools="' + esc(JSON.stringify({ items: mine, more: mineMore, before: before })) + '"><p class="rm-status" role="status">Loading images…</p></div>';
3644
+ }
3645
+ // 6.99.35: files a Claude session sent with SendUserFile that are not images. Names, types and sizes only; the
3646
+ // lazy viewer opens them through the authenticated output-files route by opaque id.
3647
+ var READER_FILE_KINDS = ['pdf', 'text', 'html', 'image', 'file'];
3648
+ function readerFilesHtml(turn) {
3649
+ var files = turn && Array.isArray(turn.outputFiles) ? turn.outputFiles.filter(function (x) {
3650
+ return x && /^af_[a-f0-9]{64}$/.test(x.id || '') && (x.state === 'available' || x.state === 'unavailable');
3651
+ }).map(function (x) {
3652
+ return { id: x.id, name: String(x.name || 'File').slice(0, 120), kind: READER_FILE_KINDS.indexOf(x.kind) >= 0 ? x.kind : 'file',
3653
+ type: String(x.type || 'File').slice(0, 40), size: typeof x.size === 'number' && x.size >= 0 ? x.size : null, state: x.state, reason: x.reason };
3654
+ }) : [];
3655
+ return files.length ? '<div data-reader-files="' + esc(JSON.stringify(files)) + '"><p class="rm-status" role="status">Loading files…</p></div>' : '';
2630
3656
  }
2631
3657
  function readerImagesStop(host) {
2632
3658
  if (!host) return;
@@ -2635,15 +3661,17 @@
2635
3661
  }
2636
3662
  function readerImagesMount(host, r) {
2637
3663
  if (!host || !r) return;
2638
- if (!host.querySelectorAll('[data-reader-images]').length) { readerImagesStop(host); return; }
3664
+ if (!host.querySelectorAll('[data-reader-images]').length && !host.querySelectorAll('[data-reader-files]').length && !host.querySelectorAll('[data-reader-tools]').length && !host.querySelectorAll('[data-reader-links]').length) { readerImagesStop(host); return; }
2639
3665
  var owner = host.__cosImageOwner = {};
2640
- ensurePane('readerMedia', 4000).then(function (kit) {
3666
+ // Preview engine phase 1: the shared viewer loads first, so link cards and sent files open in it. If it cannot load,
3667
+ // links stay their words and sent files keep the 6.99.35 viewer.
3668
+ ensurePane('preview', 4000).catch(function () { return null; }).then(function () { return ensurePane('readerMedia', 4000); }).then(function (kit) {
2641
3669
  if (host.__cosImageOwner !== owner || host.isConnected === false) return;
2642
3670
  if (!kit) throw new Error('Image previews could not load.');
2643
3671
  kit.mount(host, r.provider, r.id);
2644
3672
  }).catch(function () {
2645
3673
  if (host.__cosImageOwner !== owner || host.isConnected === false) return;
2646
- host.querySelectorAll('[data-reader-images]').forEach(function (slot) {
3674
+ Array.prototype.slice.call(host.querySelectorAll('[data-reader-images]')).concat(Array.prototype.slice.call(host.querySelectorAll('[data-reader-files]')), Array.prototype.slice.call(host.querySelectorAll('[data-reader-tools]'))).forEach(function (slot) {
2647
3675
  slot.textContent = 'Image previews could not load. ';
2648
3676
  var retry = document.createElement('button'); retry.type = 'button'; retry.className = 'link-btn'; retry.textContent = 'Retry images';
2649
3677
  retry.addEventListener('click', function () { readerImagesMount(host, r); }); slot.appendChild(retry);
@@ -2669,7 +3697,7 @@
2669
3697
  out += original ? '## Original request\n\n' + original + '\n\n## Conversation\n\n' : '## Conversation\n\n';
2670
3698
  if (r.more) out += '[Older turns omitted. COS serves the newest ' + turns.length + '. Original request kept.]\n\n';
2671
3699
  turns.forEach(function (t) {
2672
- var text = String(t.text).trim();
3700
+ var text = String(t.text).trim().replace(/\[([^\]\n]*)\]\(cos-file:\d{1,2}\)/g, '$1');
2673
3701
  if (original && t.role === 'user' && text === original) return;
2674
3702
  out += '**' + (t.role === 'user' ? 'You' : 'Assistant') + '**\n\n' + text + '\n\n';
2675
3703
  });
@@ -2687,9 +3715,16 @@
2687
3715
  // Markdown for an assistant turn (Control renders replies with COSMarkdownView): headings, lists,
2688
3716
  // code, quotes, tables, rules, links. Everything is escaped first, so a reply can never add
2689
3717
  // markup of its own; a link keeps only an http(s) address.
3718
+ // Preview engine phase 1: the server turns a reply's local link into `[words](cos-file:N)`, N its card in the turn's
3719
+ // outputLinks. While a turn with cards renders (sxInline.links), that becomes a slot panes/reader-media.js fills with the
3720
+ // card; anywhere else (a file shown in the viewer, previews off, an older turn) it is just the words.
2690
3721
  function sxInline(s) {
3722
+ var links = sxInline.links;
2691
3723
  var codes = [];
2692
3724
  s = s.replace(/`([^`\n]+)`/g, function (_m, c) { codes.push(c); return '\u0000' + (codes.length - 1) + '\u0000'; });
3725
+ s = s.replace(/\[([^\]\n]*)\]\(cos-file:(\d{1,2})\)/g, function (_m, words, n) {
3726
+ return links && Number(n) < links ? '<span class="rm-link-slot" data-reader-link="' + Number(n) + '">' + words + '</span>' : words;
3727
+ });
2693
3728
  s = s.replace(/\[([^\]\n]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
2694
3729
  s = s.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>').replace(/__([^_\n]+)__/g, '<strong>$1</strong>');
2695
3730
  s = s.replace(/(^|[^*\w])\*([^*\s][^*\n]*?)\*(?!\w)/g, '$1<em>$2</em>');
@@ -2700,7 +3735,13 @@
2700
3735
  var SX_RULE = /^\s*([-*_])(\s*\1){2,}\s*$/;
2701
3736
  var SX_TABLE_SEP = /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$/;
2702
3737
  function sxCells(row) { return row.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map(function (c) { return c.trim(); }); }
2703
- function sxMd(text) {
3738
+ function sxMd(text, links) {
3739
+ // links: the turn's card list. Only a real list turns markers into slots (the count bounds the indexes).
3740
+ var held = sxInline.links;
3741
+ sxInline.links = Array.isArray(links) && links.length ? Math.min(20, links.length) : null;
3742
+ try { return sxMdBody(text); } finally { sxInline.links = held; }
3743
+ }
3744
+ function sxMdBody(text) {
2704
3745
  var lines = esc(String(text || '')).replace(/\r\n?/g, '\n').split('\n');
2705
3746
  var out = [], para = [], i = 0;
2706
3747
  function flush() { if (para.length) { out.push('<p>' + para.map(sxInline).join('<br>') + '</p>'); para = []; } }
@@ -2722,12 +3763,16 @@
2722
3763
  if (line.indexOf('|') >= 0 && i + 1 < lines.length && SX_TABLE_SEP.test(lines[i + 1])) {
2723
3764
  flush();
2724
3765
  var head = sxCells(line), aligns = sxCells(lines[i + 1]).map(function (a) { return /^:-+:$/.test(a) ? 'center' : /-:$/.test(a) ? 'right' : ''; });
2725
- var cell = function (tag, c, k) { return '<' + tag + (aligns[k] ? ' style="text-align:' + aligns[k] + '"' : '') + '>' + sxInline(c) + '</' + tag + '>'; };
2726
3766
  i += 2;
2727
3767
  var body = [];
2728
3768
  while (i < lines.length && lines[i].indexOf('|') >= 0 && lines[i].trim()) { body.push(sxCells(lines[i])); i += 1; }
2729
- out.push('<div class="sx-table"><table><thead><tr>' + head.map(function (c, k) { return cell('th', c, k); }).join('') + '</tr></thead><tbody>' +
2730
- body.map(function (row) { return '<tr>' + head.map(function (_c, k) { return cell('td', row[k] || '', k); }).join('') + '</tr>'; }).join('') + '</tbody></table></div>');
3769
+ var fit = tableFit([head].concat(body));
3770
+ var cell = function (tag, c, k, first) {
3771
+ var st = [aligns[k] ? 'text-align:' + aligns[k] : '', fit.width(k, first)].filter(Boolean).join(';');
3772
+ return '<' + tag + (st ? ' style="' + st + '"' : '') + '>' + fit.cell(k, sxInline(c), first) + '</' + tag + '>';
3773
+ };
3774
+ out.push('<div class="sx-table tf-wrap"><table class="' + fit.tableClass + ' tf-grid"><thead><tr>' + head.map(function (c, k) { return cell('th', c, k, true); }).join('') + '</tr></thead><tbody>' +
3775
+ body.map(function (row) { return '<tr>' + head.map(function (_c, k) { return cell('td', row[k] || '', k, false); }).join('') + '</tr>'; }).join('') + '</tbody></table></div>');
2731
3776
  continue;
2732
3777
  }
2733
3778
  if (/^\s*&gt;/.test(line)) {
@@ -2748,6 +3793,17 @@
2748
3793
  sxMd.unescaped = function (lines) {
2749
3794
  return lines.join('\n').split(/\n\s*\n/).map(function (p) { return '<p>' + p.split('\n').map(sxInline).join('<br>') + '</p>'; }).join('');
2750
3795
  };
3796
+ // The card list a turn's Markdown box carries for panes/reader-media.js, checked here: ids and words only.
3797
+ sxMd.linksAttr = function (turn) {
3798
+ var list = turn && Array.isArray(turn.outputLinks) ? turn.outputLinks.slice(0, 20).map(function (x) {
3799
+ return x && /^al_[a-f0-9]{64}$/.test(x.id || '') && (x.state === 'available' || x.state === 'unavailable') ? {
3800
+ id: x.id, name: String(x.name || 'File').slice(0, 120), label: String(x.label || '').slice(0, 120), kind: String(x.kind || 'file').slice(0, 20),
3801
+ type: String(x.type || 'File').slice(0, 40), size: typeof x.size === 'number' && x.size >= 0 ? x.size : undefined,
3802
+ line: typeof x.line === 'number' && x.line > 0 ? Math.floor(x.line) : undefined, state: x.state, reason: typeof x.reason === 'string' ? x.reason.slice(0, 30) : undefined
3803
+ } : { id: '', state: 'invalid' };
3804
+ }) : [];
3805
+ return list.length ? ' data-reader-links="' + esc(JSON.stringify(list)) + '"' : '';
3806
+ };
2751
3807
  // Nested lists by indent: a deeper item opens a list inside the open item, a shallower one closes back.
2752
3808
  function sxListBlock(lines, i) {
2753
3809
  var html = '', stack = [];
@@ -2783,7 +3839,7 @@
2783
3839
  // One turn's identity, for telling which turns a refresh brought that the page already shows.
2784
3840
  function sxTurnSig(t) {
2785
3841
  var text = String(t.text || t.content || '').trim();
2786
- return (String(t.role || '').toLowerCase() === 'user' ? 'u' : 'a') + '|' + (t.at || '') + '|' + text.length + '|' + text.slice(0, 48) + '|' + readerImagesHtml(t);
3842
+ return (String(t.role || '').toLowerCase() === 'user' ? 'u' : 'a') + '|' + (t.at || '') + '|' + text.length + '|' + text.slice(0, 48) + '|' + readerImagesHtml(t) + (sxMd.linksAttr ? sxMd.linksAttr(t) : '');
2787
3843
  }
2788
3844
  function sxTurnHtml(t) {
2789
3845
  var role = String(t.role || '').toLowerCase() === 'user' ? 'user' : 'assistant';
@@ -2792,7 +3848,9 @@
2792
3848
  var when = at ? (Date.now() - at.getTime() < 86400000 ? clock(at) : sxShortDate(at)) : '';
2793
3849
  return '<div class="turn ' + role + ' sx-turn" data-sig="' + esc(sxTurnSig(t)) + '"><span class="who"><span>' + (role === 'user' ? 'YOU' : 'ASSISTANT') + '</span>' +
2794
3850
  (when ? '<span class="sx-turn-when">' + esc(when) + '</span>' : '') + '</span>' +
2795
- (role === 'user' ? '<div class="sx-plain">' + esc(text) + '</div>' : '<div class="sx-md">' + sxMd(text) + '</div>' + readerImagesHtml(t)) + '</div>';
3851
+ // A YOU turn shows only its attached images (and the previews-off mark); never a reply's outputs.
3852
+ (role === 'user' ? '<div class="sx-plain">' + esc(text) + '</div>' + readerImagesHtml(t, { part: 'user' })
3853
+ : readerImagesHtml(t, { part: 'before' }) + '<div class="sx-md"' + (sxMd.linksAttr ? sxMd.linksAttr(t) : '') + '>' + sxMd(text, t.outputLinks) + '</div>' + readerImagesHtml(t, { part: 'after' })) + '</div>';
2796
3854
  }
2797
3855
  // A refresh never rebuilds what you are reading (Miles, /qa: "start to scroll and then it reloads
2798
3856
  // the section"). The server's window slides as a session grows, so the turns the page already
@@ -4825,6 +5883,7 @@
4825
5883
  // is closed and the page's last reading is however old the visit was. Open the stream again and read the turns.
4826
5884
  window.addEventListener('pageshow', function (e) {
4827
5885
  if (!e || !e.persisted) return;
5886
+ liveResume(); // 7.0.1: re-read the list now, then resume the live stream from its last id
4828
5887
  var r = state.reading;
4829
5888
  if (r && r.provider && r.id) {
4830
5889
  if (state.stream) { try { state.stream.close(); } catch (_e) {} state.stream = null; }
@@ -4833,13 +5892,24 @@
4833
5892
  if (state.pane === 'today' && !$('app-shell').classList.contains('hidden')) loadToday();
4834
5893
  });
4835
5894
  document.addEventListener('visibilitychange', function () {
4836
- if (document.hidden || $('app-shell').classList.contains('hidden')) return;
5895
+ if (document.hidden) { liveHide(); return; }
5896
+ if ($('app-shell').classList.contains('hidden')) return;
5897
+ liveResume(); // 7.0.1: re-read the list now, then resume the live stream from its last id
4837
5898
  // Coming back to the page refreshes Today in place. Other panes keep what is on screen:
4838
5899
  // rebuilding them here moved the page under Miles's finger ("it reloads the section").
4839
5900
  if (state.pane === 'today' && Date.now() - (state.lastLoad.today || 0) > 60000) { state.lastLoad.today = Date.now(); loadToday(); }
4840
5901
  // Back within the minute: Today keeps its data, but the pet's loop stopped while hidden, so restart it.
4841
5902
  else if (state.pane === 'today' && PET.pose && !PET.timer) renderPet(state.sessions || []);
4842
5903
  });
5904
+ // 7.0.1: back on the network, the same as coming back to the page.
5905
+ window.addEventListener('online', function () { if (!document.hidden) liveResume(); });
5906
+ // 7.0.1: Today's Refresh (todayRefresh).
5907
+ document.addEventListener('click', function (e) {
5908
+ var b = e.target && e.target.closest && e.target.closest('[data-today-refresh]');
5909
+ if (!b) return;
5910
+ e.preventDefault();
5911
+ todayRefresh();
5912
+ });
4843
5913
  window.addEventListener('message', function (e) {
4844
5914
  if (e.origin !== location.origin) return;
4845
5915
  var d = e.data;
@@ -4886,20 +5956,64 @@
4886
5956
  // inset, so a pinch zoom magnifies the sheet instead of shrinking its layout into the zoomed window.
4887
5957
  // below what of the layout viewport lies under that box: the backdrop alone reaches down over it.
4888
5958
  // No visualViewport (old browsers): the whole layout viewport, nothing covered.
5959
+ // 7.0.2 (A3, the Duo audit): some web views shrink innerHeight itself while the keyboard is up (WKWebView in Comet or
5960
+ // Chrome on iOS may), and re-reading it then measured a covered area of 0, so no keyboard was ever seen. So on a touch
5961
+ // screen, while a field that types has focus, layoutH is the tallest unzoomed innerHeight seen at this width (so this
5962
+ // orientation): the keyboard is the same inset whether the browser shrinks visualViewport only or the whole web view.
5963
+ // - It never goes past the last height read with no field focused (free) plus VP_SLACK_PX, so a browser bar that
5964
+ // comes back while typing is never an 80 px keyboard (QA W1). It never goes under innerHeight itself.
5965
+ // - It starts, on focus, from free: the full height, never a height read while a keyboard was still closing. For
5966
+ // VP_SETTLE_MS after a blur, free only grows (QA W2).
5967
+ // - With no field focused, or on a fine pointer (a Mac window resized or DevTools docked while typing, QA N3), it
5968
+ // follows innerHeight exactly as before 7.0.2. A select is not typing: its picker never counts as a keyboard.
5969
+ // - A new width (a turn) starts over. Pinch zoom keeps the height read before the zoom.
5970
+ // dock the CURRENT layout viewport under the visible box (innerHeight less top and height): what a sticky or
5971
+ // fixed composer clears to sit above the keyboard. 0 when the web view itself shrank.
4889
5972
  var VP = { layoutH: 0 };
5973
+ VP.maxH = 0; VP.w = -1; VP.free = 0; VP.typing = false; VP.blurAt = 0;
5974
+ var VP_SLACK_PX = 20, VP_SETTLE_MS = 1000;
5975
+ function vpTyping() {
5976
+ var a = document.activeElement;
5977
+ if (!a || !a.tagName) return false;
5978
+ var tag = String(a.tagName).toUpperCase();
5979
+ if (tag === 'TEXTAREA') return true;
5980
+ if (tag === 'INPUT') return !/^(checkbox|radio|button|submit|reset|range|color|file|image|hidden)$/i.test(String(a.type || 'text'));
5981
+ return !!a.isContentEditable;
5982
+ }
4890
5983
  function viewportFit() {
4891
5984
  var vv = window.visualViewport, de = document.documentElement;
4892
5985
  var inner = window.innerHeight || (de && de.clientHeight) || 0;
4893
5986
  var scale = vv && vv.scale > 0 ? vv.scale : 1, zoomed = scale > 1.01;
4894
- if (!zoomed || !VP.layoutH) VP.layoutH = zoomed ? Math.max(inner, (de && de.clientHeight) || 0) : inner;
5987
+ // A turn always changes the width. The orientation type is not read: an engine that derives it from the viewport's
5988
+ // aspect flips it when the keyboard shrinks a web view wider than it is then tall (WebKit, 382x266), which would
5989
+ // throw away the very height the keyboard is measured against.
5990
+ var w = window.innerWidth || 0;
5991
+ if (w !== VP.w) { VP.w = w; VP.maxH = 0; VP.free = 0; }
5992
+ if (!zoomed) {
5993
+ var coarse = !window.matchMedia || !!window.matchMedia('(any-pointer: coarse)').matches;
5994
+ var typing = coarse && vpTyping(), now = Date.now();
5995
+ if (!typing) {
5996
+ if (VP.typing) VP.blurAt = now;
5997
+ VP.free = VP.free && now - VP.blurAt < VP_SETTLE_MS ? Math.max(VP.free, inner) : inner;
5998
+ VP.maxH = inner;
5999
+ VP.layoutH = inner;
6000
+ } else {
6001
+ if (!VP.free) VP.free = inner;
6002
+ VP.maxH = Math.max(VP.typing ? VP.maxH : VP.free, inner);
6003
+ VP.layoutH = Math.max(inner, Math.min(VP.maxH, VP.free + VP_SLACK_PX));
6004
+ }
6005
+ VP.typing = typing;
6006
+ }
6007
+ else if (!VP.layoutH) VP.layoutH = Math.max(inner, (de && de.clientHeight) || 0);
4895
6008
  var H = VP.layoutH;
4896
- if (!vv || !(vv.height > 0)) return { layoutH: H, top: 0, height: H, inset: 0, below: 0, scale: 1, zoomed: false, vv: false };
6009
+ if (!vv || !(vv.height > 0)) return { layoutH: H, top: 0, height: H, inset: 0, below: 0, dock: 0, scale: 1, zoomed: false, vv: false };
4897
6010
  var inset = Math.max(0, Math.round(H - vv.height * scale));
4898
6011
  var top, height;
4899
6012
  if (!zoomed) { top = Math.max(0, Math.round(vv.offsetTop || 0)); height = Math.min(Math.round(vv.height), H); }
4900
6013
  else { height = H - inset; top = Math.min(Math.max(0, Math.round(vv.offsetTop || 0)), inset); }
4901
6014
  if (top + height > H) top = Math.max(0, H - height);
4902
- return { layoutH: H, top: top, height: height, inset: inset, below: Math.max(0, H - top - height), scale: scale, zoomed: zoomed, vv: true };
6015
+ var below = Math.max(0, H - top - height);
6016
+ return { layoutH: H, top: top, height: height, inset: inset, below: below, dock: zoomed ? below : Math.max(0, Math.round(inner) - top - height), scale: scale, zoomed: zoomed, vv: true };
4903
6017
  }
4904
6018
 
4905
6019
  // ── layout breadcrumbs (6.99.30) ──────────────────────
@@ -4908,8 +6022,12 @@
4908
6022
  // rotation, 30 lines per 10 s per client) when an overlay opens and each time its keyboard state changes. Numbers and
4909
6023
  // a browser family only: no address, no text, no ids, never the user agent string itself. At most one identical
4910
6024
  // line per 2 s and LAYOUT_CRUMB_MAX a page load, fire and forget.
4911
- var LAYOUT_CRUMB_MAX = 40, LAYOUT_CRUMB_GAP_MS = 2000;
4912
- var crumbs = { sent: 0, last: '', lastAt: 0 };
6025
+ // 7.0.2 (A1): every pane reports now (focus, keyboard open and close, the gap after it closes), so the cap is 80 a
6026
+ // page load, in two budgets: focus lines have LAYOUT_FOCUS_MAX of them, everything else the rest, so a page of typing
6027
+ // never starves the keyboard reports (QA W3). At most LAYOUT_CRUMB_RATE leave in any 10 s, under the server's 30
6028
+ // (routes/diag.ts), and a focus line only while that window is under half full.
6029
+ var LAYOUT_CRUMB_MAX = 80, LAYOUT_FOCUS_MAX = 20, LAYOUT_CRUMB_GAP_MS = 2000, LAYOUT_CRUMB_RATE = 20, LAYOUT_CRUMB_WINDOW_MS = 10000;
6030
+ var crumbs = { sent: 0, focus: 0, last: '', lastAt: 0, recent: [] };
4913
6031
  /** The browser family, from the UA string, for the breadcrumb. Comet first: it says Chrome or Safari too. */
4914
6032
  function uaClass(ua) {
4915
6033
  ua = String(ua == null ? (navigator && navigator.userAgent) || '' : ua);
@@ -4935,8 +6053,13 @@
4935
6053
  var x = extra || {};
4936
6054
  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; });
4937
6055
  var key = JSON.stringify(data), now = Date.now();
4938
- if (crumbs.sent >= LAYOUT_CRUMB_MAX || (key === crumbs.last && now - crumbs.lastAt < LAYOUT_CRUMB_GAP_MS)) return false;
4939
- crumbs.sent++; crumbs.last = key; crumbs.lastAt = now;
6056
+ var focusLine = data.what === 'focus';
6057
+ if (focusLine ? crumbs.focus >= LAYOUT_FOCUS_MAX : crumbs.sent - crumbs.focus >= LAYOUT_CRUMB_MAX - LAYOUT_FOCUS_MAX) return false;
6058
+ if (key === crumbs.last && now - crumbs.lastAt < LAYOUT_CRUMB_GAP_MS) return false;
6059
+ crumbs.recent = crumbs.recent.filter(function (t) { return now - t < LAYOUT_CRUMB_WINDOW_MS; });
6060
+ if (crumbs.recent.length >= (focusLine ? LAYOUT_CRUMB_RATE / 2 : LAYOUT_CRUMB_RATE)) return false;
6061
+ crumbs.sent++; if (focusLine) crumbs.focus++;
6062
+ crumbs.last = key; crumbs.lastAt = now; crumbs.recent.push(now);
4940
6063
  api('/api/diag/client', { method: 'POST', body: { ts: now, level: 'info', event: 'web.layout', data: data }, timeoutMs: 5000 }).catch(function () {});
4941
6064
  return true;
4942
6065
  } catch (_e) { return false; }
@@ -4944,7 +6067,7 @@
4944
6067
 
4945
6068
  // ── the shared kit for pane modules (panes/*.js) ───────
4946
6069
  window.COSWeb = {
4947
- $: $, esc: esc, md: md, plain: plain, api: api, toast: toast, ago: ago, clock: clock, toDate: toDate,
6070
+ $: $, esc: esc, md: md, plain: plain, structureLensAnswer: structureLensAnswer, tableFit: tableFit, api: api, toast: toast, ago: ago, clock: clock, toDate: toDate,
4948
6071
  plural: plural, countWord: countWord, dayWord: dayWord, plainDay: plainDay, providerLabel: providerLabel, domainLabel: domainLabel,
4949
6072
  skeletonRows: skeletonRows, emptyBox: emptyBox, setStatus: setStatus, autoGrow: autoGrow,
4950
6073
  readStore: readStore, writeStore: writeStore, copyText: copyText, isPhone: isPhone,
@@ -4971,7 +6094,25 @@
4971
6094
  serverUp: function () { return SERVER.up; },
4972
6095
  // Panes with their own fetch (the questions poll) say whether the Mac answered, so a "down" mark never outlives it.
4973
6096
  noteServer: function (up) { noteServer(up); },
4974
- onServer: function (fn) { SERVER.hooks.push(fn); }
6097
+ onServer: function (fn) { SERVER.hooks.push(fn); },
6098
+ // Duo tables: the resizable divider between a list and its reader (splitKit above), for panes/*.js.
6099
+ splitKit: { mount: mountSplit, handle: splitHandleHtml, fullButton: splitFullButton },
6100
+ // 7.0.1: the one live stream (docs/live-events-contract.md). mode(): idle, connecting, live, reconnecting, busy or
6101
+ // absent. on(fn) hears each applied frame ({ kind, key, session, previous }). session(provider, id): the live
6102
+ // record, or null. age(): "As of 2 min ago" when the counts are not live, else ''.
6103
+ live: {
6104
+ mode: function () { return LIVE.mode; },
6105
+ on: function (fn) { if (typeof fn === 'function') LIVE.hooks.push(fn); },
6106
+ session: function (provider, id) {
6107
+ if (!liveCanOverlay()) return null; // outside live the list is the truth (review W1)
6108
+ var byKey = {};
6109
+ Object.keys(LIVE.sessions).forEach(function (k) { var ls = LIVE.sessions[k]; byKey[liveKey(ls.provider, ls.sessionId)] = ls; });
6110
+ var j = liveMatch(liveKey(provider, id), Object.keys(byKey));
6111
+ return j ? byKey[j] : null;
6112
+ },
6113
+ age: function () { return liveAge(); },
6114
+ clock: liveClock
6115
+ }
4975
6116
  };
4976
6117
  // Today as a place to act (panes/today-actions.js, Miles 11/06): what the Today cards show, a repaint after a
4977
6118
  // decision, and the Sessions reader's own pieces, so the overlay sends and reads through the reader's code.
@@ -5008,14 +6149,126 @@
5008
6149
  newKey: newTurnKey,
5009
6150
  probe: function (r) { return sxProbeAttach(r); },
5010
6151
  path: sessionPath, answer: apiAnswer,
5011
- notSent: { rows: notSentRows, line: notSentLine, note: noteNotSent, get: notSentFor },
5012
- imageHtml: readerImagesHtml, imagesMount: readerImagesMount, imagesStop: readerImagesStop,
6152
+ notSent: { rows: notSentRows, line: notSentLine, label: notSentLabel, note: noteNotSent, get: notSentFor },
6153
+ imageHtml: readerImagesHtml, imagesMount: readerImagesMount, imagesStop: readerImagesStop, linksAttr: function (t) { return sxMd.linksAttr(t); },
5013
6154
  feed: { fresh: newFeed, apply: applyFeed, word: sxFeedWord, detail: sxFeedDetail, tool: sxToolLine, elapsed: sxElapsed, fallback: sxFallbackLine,
5014
6155
  truth: sxTruth, settle: sxSettle, noteList: sxNoteList, noteGate: sxNoteGate, gateStale: sxGateStale, promptLine: sxPromptLine, parkNoteShows: sxParkNoteShows },
5015
6156
  title: sxTitle, stateOf: sxState, stateLabel: function (s) { return sxStateLabel(s, sxState(s)); },
5016
- parkCopy: function (p) { return SX_PARK[sxProvider(p)]; }, providerLabel: sxProviderLabel, markdown: sxMd
6157
+ parkCopy: function (p, r, hasFiles) { return r ? sxParkLine(r, hasFiles) : SX_PARK[sxProvider(p)]; }, providerLabel: sxProviderLabel, markdown: sxMd
5017
6158
  };
5018
6159
 
6160
+ // ── layout reports and the visible box, every pane (7.0.2) ──
6161
+ // The Duo audit (11/07): only Today's session sheet ever sent a layout report, and only when its classes flipped, so
6162
+ // the COS chat composer, the front screen Miles types on most, never sent one, and a keyboard the code failed to see
6163
+ // sent nothing at all. Now, through layoutCrumb (numbers and fixed labels only, never text or ids):
6164
+ // device once per page load, after sign-in: the screen, the pixel ratio, safe areas, pointer kind
6165
+ // focus a field that types gets focus, on any pane, on a touch screen: its computed font size, the scroll
6166
+ // position and range, and innerWidth against scrollWidth, as scw (zoom vs pan vs a wider page)
6167
+ // kb-open, kb-close EVERY keyboard open and close, numbered, so a repeat is never dropped as a duplicate; with
6168
+ // innerHeight against the layout height (shrank: the web view itself got shorter)
6169
+ // kb-after KB_AFTER_MS after a close: the gap below the composer that had the keyboard, scrollY against its
6170
+ // range, and 100dvh, 100svh and 100lvh in px; measured before and after the panes settle
6171
+ // The same pass publishes the visible box for CSS on <html>: --vv-top, --vv-h, --vv-below (the layout viewport under
6172
+ // the visible box, viewportFit's dock) and html.kb-up while an on-screen keyboard is up (KB_UP_PX or more covered
6173
+ // with a typing field focused). Panes hear open, close, move (the visible box changed while it is up) and after
6174
+ // through COSWeb.onKeyboard; COSWeb.keyboardUp says whether it is up now. Which composer the keyboard is for is the
6175
+ // pane's to mark (panes/cos-chat.js sets html.kb-chat and .cc-kb).
6176
+ var KB_UP_PX = 120, KB_AFTER_MS = 300;
6177
+ var LR = { up: false, frame: 0, after: 0, cycle: 0, focusN: 0, device: false, field: null, hooks: [], box: '' };
6178
+ function lrPane() { var r = parseRoute(); return String(r.pane + (r.args[0] === 'cos' ? '.cos' : '')).slice(0, 24); }
6179
+ function lrKind(a) {
6180
+ var tag = String((a && a.tagName) || '').toLowerCase();
6181
+ return (tag === 'input' ? 'input.' + String(a.type || 'text').toLowerCase() : tag || 'other').slice(0, 24);
6182
+ }
6183
+ function lrScroll() {
6184
+ var se = document.scrollingElement || document.documentElement;
6185
+ var sy = Math.round(window.scrollY || window.pageYOffset || 0);
6186
+ var smax = se ? Math.max(0, Math.round((se.scrollHeight || 0) - (window.innerHeight || se.clientHeight || 0))) : 0;
6187
+ return { sy: sy, smax: smax, sw: se ? Math.round(se.scrollWidth || 0) : 0 };
6188
+ }
6189
+ // One hidden element, measured and removed: its height (h) or a computed padding in px. Null where it cannot be read.
6190
+ function lrProbe(css, prop) {
6191
+ var b = document.body;
6192
+ if (!b || !document.createElement) return null;
6193
+ var el = document.createElement('div'), v = null;
6194
+ el.setAttribute('aria-hidden', 'true');
6195
+ el.style.cssText = 'position:fixed;left:0;top:0;width:1px;visibility:hidden;pointer-events:none;' + css;
6196
+ b.appendChild(el);
6197
+ try { v = prop === 'h' ? el.offsetHeight : parseFloat(getComputedStyle(el)[prop]); } catch (_e) { v = null; }
6198
+ b.removeChild(el);
6199
+ return typeof v === 'number' && isFinite(v) ? v : null;
6200
+ }
6201
+ function lrMatch(q) { try { return !!window.matchMedia(q).matches; } catch (_e) { return false; } }
6202
+ function lrDevice() {
6203
+ if (LR.device) return;
6204
+ LR.device = true;
6205
+ var sc = window.screen || {};
6206
+ layoutCrumb('device', { sw: sc.width, sh: sc.height, sah: sc.availHeight, oh: window.outerHeight, ow: window.outerWidth, dpr: window.devicePixelRatio,
6207
+ sat: lrProbe('padding-top:env(safe-area-inset-top)', 'paddingTop'), sab: lrProbe('padding-bottom:env(safe-area-inset-bottom)', 'paddingBottom'),
6208
+ anyCoarse: lrMatch('(any-pointer: coarse)'), shortTouch: lrMatch(SHORT_TOUCH_MQ), phone: isPhone() });
6209
+ }
6210
+ // The focus line is for touch screens only (QA W3): on a Mac it would only spend the budget.
6211
+ function lrFocus() {
6212
+ if (!vpTyping()) return;
6213
+ var a = document.activeElement, fs = null;
6214
+ LR.field = a;
6215
+ if (!lrMatch('(pointer: coarse)')) return;
6216
+ try { fs = parseFloat(getComputedStyle(a).fontSize); } catch (_e) { fs = null; }
6217
+ var s = lrScroll();
6218
+ layoutCrumb('focus', { pane: lrPane(), field: lrKind(a), fs: fs, sy: s.sy, smax: s.smax, scw: s.sw, kb: LR.up, n: ++LR.focusN });
6219
+ }
6220
+ function lrHooks(what, v) { LR.hooks.forEach(function (fn) { try { fn(what, v); } catch (err) { console.error(err); } }); }
6221
+ function lrGap(v) {
6222
+ var f = LR.field, box = f && f.closest ? (f.closest('form') || f) : f;
6223
+ if (!box || box.isConnected === false || !box.getBoundingClientRect) return null;
6224
+ return Math.round(v.top + v.height - box.getBoundingClientRect().bottom);
6225
+ }
6226
+ function lrAfter() {
6227
+ LR.after = 0;
6228
+ if (LR.up) return;
6229
+ var v = viewportFit(), s = lrScroll(), gap = lrGap(v);
6230
+ lrHooks('after', v);
6231
+ var s2 = lrScroll();
6232
+ layoutCrumb('kb-after', { pane: lrPane(), n: LR.cycle, gap: gap, sy: s.sy, smax: s.smax, over: s.sy > s.smax, gapNow: lrGap(viewportFit()), syNow: s2.sy,
6233
+ dvh: lrProbe('height:100dvh', 'h'), svh: lrProbe('height:100svh', 'h'), lvh: lrProbe('height:100lvh', 'h'), lh: v.layoutH });
6234
+ }
6235
+ function lrApply() {
6236
+ LR.frame = 0;
6237
+ var v = viewportFit(), de = document.documentElement;
6238
+ // Written only when they change: a pinch or pan fires this every frame, and each write restyles the page.
6239
+ var box = v.top + '|' + v.height + '|' + v.dock, moved = box !== LR.box;
6240
+ if (moved && de && de.style && de.style.setProperty) {
6241
+ LR.box = box;
6242
+ de.style.setProperty('--vv-top', v.top + 'px'); de.style.setProperty('--vv-h', v.height + 'px'); de.style.setProperty('--vv-below', v.dock + 'px');
6243
+ }
6244
+ var up = !!(v.vv && v.inset >= KB_UP_PX && vpTyping());
6245
+ if (de && de.classList) de.classList.toggle('kb-up', up);
6246
+ if (up === LR.up) { if (up && moved) lrHooks('move', v); return v; }
6247
+ LR.up = up; LR.cycle++;
6248
+ layoutCrumb(up ? 'kb-open' : 'kb-close', { pane: lrPane(), n: LR.cycle, inset: v.inset, below: v.below, dock: v.dock, lh: v.layoutH, top: v.top, h: v.height,
6249
+ zoomed: v.zoomed, shrank: Math.round(window.innerHeight || 0) < v.layoutH });
6250
+ if (LR.after) { clearTimeout(LR.after); LR.after = 0; }
6251
+ if (!up) LR.after = setTimeout(lrAfter, KB_AFTER_MS);
6252
+ lrHooks(up ? 'open' : 'close', v);
6253
+ return v;
6254
+ }
6255
+ function lrFit() {
6256
+ if (LR.frame) return;
6257
+ if (!window.requestAnimationFrame) { lrApply(); return; }
6258
+ LR.frame = window.requestAnimationFrame(lrApply);
6259
+ }
6260
+ (function lrListen() {
6261
+ var vv = window.visualViewport;
6262
+ if (vv && vv.addEventListener) { vv.addEventListener('resize', lrFit); vv.addEventListener('scroll', lrFit); }
6263
+ window.addEventListener('resize', lrFit); window.addEventListener('orientationchange', lrFit); window.addEventListener('pageshow', lrFit);
6264
+ document.addEventListener('focusin', function () { lrFocus(); lrFit(); });
6265
+ document.addEventListener('focusout', function () { setTimeout(lrFit, 0); });
6266
+ shellHooks.push(function () { lrDevice(); lrFit(); });
6267
+ })();
6268
+ window.COSWeb.keyboardUp = function () { return LR.up; };
6269
+ window.COSWeb.onKeyboard = function (fn) { if (typeof fn === 'function') LR.hooks.push(fn); };
6270
+ // ── end layout reports
6271
+
5019
6272
  // Every pane module is a plain script after this one; boot waits for all of them.
5020
6273
  function boot() {
5021
6274
  var params = new URLSearchParams(location.search);