@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.
- package/.env.example +18 -0
- package/CHANGELOG.md +417 -0
- package/README.md +3 -1
- package/managed-runtime-contract.json +3 -0
- package/package.json +1 -1
- package/server/index.ts +29 -7
- package/server/lib/agent-session-output-images.ts +627 -49
- package/server/lib/agent-session-store.ts +24 -11
- package/server/lib/agent-session-tool-images.ts +224 -0
- package/server/lib/agent-session-turns.ts +19 -2
- package/server/lib/api-auth.ts +6 -0
- package/server/lib/ask-work-plan.ts +3 -1
- package/server/lib/calendar-source.ts +491 -0
- package/server/lib/codex-bridge.ts +4 -1
- package/server/lib/codex-run-ledger.ts +29 -0
- package/server/lib/context-builder.ts +2 -1
- package/server/lib/drop-p2-runtime.ts +1 -1
- package/server/lib/drop-store.ts +10 -4
- package/server/lib/learning-needs-you.ts +24 -7
- package/server/lib/live-events.ts +533 -0
- package/server/lib/local-path-redact.ts +272 -0
- package/server/lib/meeting-ask-orientation.ts +40 -6
- package/server/lib/meeting-ask-runner.ts +30 -10
- package/server/lib/morning-brief-runtime.ts +4 -2
- package/server/lib/native-mac-caller.ts +29 -0
- package/server/lib/ping-engine.ts +1 -1
- package/server/lib/ping-sources.ts +11 -6
- package/server/lib/preview-content.ts +172 -0
- package/server/lib/preview-error-code.ts +10 -0
- package/server/lib/preview-folders-setting.ts +133 -0
- package/server/lib/preview-log-throttle.ts +18 -0
- package/server/lib/preview-thumbs.ts +139 -0
- package/server/lib/secret-path-guard.ts +80 -0
- package/server/lib/session-files-setting.ts +88 -0
- package/server/lib/session-links.ts +280 -0
- package/server/lib/thread-turn-queue.ts +10 -0
- package/server/lib/today-brief.ts +119 -28
- package/server/lib/web-memories-bridge.ts +3 -1
- package/server/lib/work-activity.ts +56 -6
- package/server/lib/work-session-activity.ts +260 -0
- package/server/lib/your-move-count.ts +9 -3
- package/server/routes/agent-session-bindings.ts +13 -0
- package/server/routes/agent-session-stream.ts +16 -0
- package/server/routes/agent-sessions.ts +146 -11
- package/server/routes/ask-work-plan.ts +4 -1
- package/server/routes/claude-sessions.ts +5 -1
- package/server/routes/health.ts +35 -0
- package/server/routes/live-events.ts +329 -0
- package/server/routes/permission-broker.ts +69 -5
- package/server/routes/session-preview.ts +200 -0
- package/server/routes/thread-turn-queue.ts +207 -3
- package/server/routes/today-brief.ts +44 -9
- package/server/routes/welcome-context.ts +23 -3
- package/server/routes/work-board.ts +9 -2
- package/server/web-control/app.css +40 -1
- package/server/web-control/app.js +1309 -56
- package/server/web-control/control.css +25 -0
- package/server/web-control/index.html +7 -2
- package/server/web-control/memory-review.js +100 -0
- package/server/web-control/panes/cos-chat.css +42 -3
- package/server/web-control/panes/cos-chat.js +267 -15
- package/server/web-control/panes/meetings.css +18 -2
- package/server/web-control/panes/meetings.js +24 -8
- package/server/web-control/panes/memories.css +17 -2
- package/server/web-control/panes/memories.js +127 -42
- package/server/web-control/panes/messages.js +21 -2
- package/server/web-control/panes/preview.js +318 -0
- package/server/web-control/panes/questions.js +28 -8
- package/server/web-control/panes/reader-media.js +409 -23
- package/server/web-control/panes/sessions.css +32 -3
- package/server/web-control/panes/settings.js +96 -5
- package/server/web-control/panes/today-actions.css +11 -0
- package/server/web-control/panes/today-actions.js +45 -12
- package/server/web-control/panes/work.css +10 -0
- package/server/web-control/panes/work.js +10 -2
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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:
|
|
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">' +
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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">
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
2730
|
-
|
|
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*>/.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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4912
|
-
|
|
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
|
-
|
|
4939
|
-
crumbs.
|
|
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);
|