@gotcos/glasses-server 6.99.29 → 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 +475 -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 +79 -9
- package/server/web-control/app.js +1543 -65
- package/server/web-control/control.css +37 -6
- package/server/web-control/index.html +8 -2
- package/server/web-control/memory-review.js +100 -0
- package/server/web-control/panes/cos-chat.css +51 -6
- package/server/web-control/panes/cos-chat.js +268 -16
- package/server/web-control/panes/meetings.css +23 -4
- package/server/web-control/panes/meetings.js +25 -9
- package/server/web-control/panes/memories.css +18 -3
- package/server/web-control/panes/memories.js +130 -50
- package/server/web-control/panes/messages.js +21 -2
- package/server/web-control/panes/preview.js +318 -0
- package/server/web-control/panes/questions.css +20 -4
- package/server/web-control/panes/questions.js +127 -26
- package/server/web-control/panes/reader-media.js +409 -23
- package/server/web-control/panes/sessions.css +50 -10
- package/server/web-control/panes/settings.js +96 -5
- package/server/web-control/panes/speakers.css +3 -5
- package/server/web-control/panes/speakers.js +7 -4
- package/server/web-control/panes/threads.js +3 -2
- package/server/web-control/panes/today-actions.css +49 -18
- package/server/web-control/panes/today-actions.js +217 -50
- package/server/web-control/panes/work.css +11 -1
- package/server/web-control/panes/work.js +10 -2
- package/server/web-control/shell.js +20 -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; }
|
|
@@ -58,6 +205,19 @@
|
|
|
58
205
|
if (diff === 1) return 'tomorrow';
|
|
59
206
|
return d.toLocaleDateString(undefined, { weekday: 'long' });
|
|
60
207
|
}
|
|
208
|
+
// 6.99.30 (design QA): a day in a row, in plain words: "Today", else "Nov 7" ("Nov 7, 2025" in another year). Takes
|
|
209
|
+
// a YYYY-MM-DD (a meeting's date, read as that calendar day, never shifted by time zone) or a Date. Anything else comes
|
|
210
|
+
// back as it was.
|
|
211
|
+
var MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
212
|
+
function plainDay(v, now) {
|
|
213
|
+
var y, m, d;
|
|
214
|
+
if (v instanceof Date && isFinite(v.getTime())) { y = v.getFullYear(); m = v.getMonth(); d = v.getDate(); }
|
|
215
|
+
else { var p = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(v == null ? '' : v)); if (!p) return v == null ? '' : String(v); y = +p[1]; m = +p[2] - 1; d = +p[3]; }
|
|
216
|
+
if (m < 0 || m > 11 || d < 1 || d > 31) return String(v);
|
|
217
|
+
var n = now || new Date();
|
|
218
|
+
if (y === n.getFullYear() && m === n.getMonth() && d === n.getDate()) return 'Today';
|
|
219
|
+
return MONTHS[m] + ' ' + d + (y === n.getFullYear() ? '' : ', ' + y);
|
|
220
|
+
}
|
|
61
221
|
var PROVIDERS = { claude: 'Claude Code', codex: 'Codex', cursor: 'Cursor', glasses: 'Glasses', cos: 'COS' };
|
|
62
222
|
function providerLabel(p) { return PROVIDERS[p] || (p ? p.charAt(0).toUpperCase() + p.slice(1) : 'Session'); }
|
|
63
223
|
function badge(p) { var cls = PROVIDERS[p] ? (p === 'cos' ? 'glasses' : p) : 'work'; return '<span class="badge ' + esc(cls) + '">' + esc(providerLabel(p)) + '</span>'; }
|
|
@@ -100,9 +260,12 @@
|
|
|
100
260
|
var limit = o.timeoutMs != null ? o.timeoutMs : (method === 'GET' ? API_READ_TIMEOUT_MS : 0);
|
|
101
261
|
var ctrl = limit && window.AbortController ? new AbortController() : null;
|
|
102
262
|
var timer = ctrl ? setTimeout(function () { ctrl.abort(); }, limit) : null;
|
|
263
|
+
var sentAt = Date.now();
|
|
103
264
|
return fetch(path, { method: method, headers: headers, credentials: 'same-origin', body: body, signal: ctrl ? ctrl.signal : undefined }).then(function (res) {
|
|
104
265
|
// Blob bodies keep their deadline after headers: a stalled download must release its queue slot.
|
|
105
266
|
if (timer && o.responseType !== 'blob') clearTimeout(timer);
|
|
267
|
+
sxNoteClock(res, sentAt);
|
|
268
|
+
noteServer(true);
|
|
106
269
|
if (res.status === 401 && path.indexOf('/api/web-session/') !== 0) { showAuth(); var gone = new Error('Signed out. Connect this browser again.'); gone.status = 401; throw gone; }
|
|
107
270
|
if (o.responseType === 'blob' && res.ok) return res.blob();
|
|
108
271
|
return res.text().then(function (text) {
|
|
@@ -126,14 +289,68 @@
|
|
|
126
289
|
}, function (err) {
|
|
127
290
|
if (timer) clearTimeout(timer);
|
|
128
291
|
if (err && err.name === 'AbortError' && ctrl && ctrl.signal.aborted) {
|
|
292
|
+
// 6.99.32 (QA 6.99.30 canary W1): a read the Mac never answered in time means it is not answering. A write has
|
|
293
|
+
// no deadline unless its caller set one, and a slow save is not a Mac that went away, so only reads say so.
|
|
294
|
+
if (method === 'GET' && !(SERVER.lastUpAt >= sentAt)) noteServer(false);
|
|
129
295
|
var late = new Error('Your Mac did not answer in ' + Math.round(limit / 1000) + ' seconds. Check that it is awake, then try again.');
|
|
130
296
|
late.code = 'timeout'; throw late;
|
|
131
297
|
}
|
|
298
|
+
// 6.99.30: the browser's own words for a request that never reached the Mac ("Failed to fetch" in Chrome and
|
|
299
|
+
// Comet, "Load failed" in Safari, "NetworkError..." in Firefox) never reach the page. Anything with no status
|
|
300
|
+
// that is not a timeout or an abort the caller asked for is the Mac out of reach, said once, here.
|
|
301
|
+
if (err && err.status == null && !err.code && err.name !== 'AbortError') { noteServer(false); throw unreachableError(err); }
|
|
132
302
|
throw err;
|
|
133
303
|
});
|
|
134
304
|
}
|
|
135
305
|
var API_READ_TIMEOUT_MS = 20000;
|
|
136
306
|
|
|
307
|
+
// ── what every request learns about the Mac (6.99.30) ──
|
|
308
|
+
// api() above and apiAnswer() (the Sessions reader's transport) share these; they sit here, between api() and the
|
|
309
|
+
// state, so a test that loads api() alone has them.
|
|
310
|
+
// 6.99.30: the one sentence for a request that never reached the Mac (api() and apiAnswer() below, and the panes with
|
|
311
|
+
// their own fetch: COSWeb.unreachableCopy). Never the browser's own "Failed to fetch" or "Load failed".
|
|
312
|
+
var UNREACHABLE_COPY = 'Could not reach COS on your Mac. Check that it is awake and on Tailscale, then try again.';
|
|
313
|
+
function unreachableError(cause) { var e = new Error(UNREACHABLE_COPY); e.code = 'unreachable'; e.cause = cause; return e; }
|
|
314
|
+
|
|
315
|
+
// 6.99.30 (QA: clock skew): the Mac's clock, as this page's offset from it. The session card's one state (sxTruth)
|
|
316
|
+
// weighs the Mac's own times (state_since, a row's activity) against moments this page stamped with its own clock (a
|
|
317
|
+
// gate answer, a Stop). A phone a minute behind made every Mac time look a minute newer, so a stale Idle beat a
|
|
318
|
+
// Working the page had just been told about. Each answer's Date header (whole seconds, read at the midpoint of the
|
|
319
|
+
// request) gives the offset; the answer with the shortest round trip in the last ten minutes wins, so one slow answer
|
|
320
|
+
// never moves it. Zero until the first answer. A missing or unreadable header changes nothing.
|
|
321
|
+
var SX_CLOCK = { skew: 0, rtt: Infinity, at: 0 };
|
|
322
|
+
var SX_CLOCK_KEEP_MS = 600000;
|
|
323
|
+
function sxNoteClock(res, sentAt) {
|
|
324
|
+
var got = Date.now(), h = null;
|
|
325
|
+
try { h = res && res.headers && typeof res.headers.get === 'function' ? res.headers.get('date') : null; } catch (_e) { h = null; }
|
|
326
|
+
var server = h ? Date.parse(h) : NaN;
|
|
327
|
+
if (!isFinite(server) || typeof sentAt !== 'number') return;
|
|
328
|
+
var rtt = Math.max(0, got - sentAt);
|
|
329
|
+
// The header drops the milliseconds: on average the Mac's moment is half a second after the stamped second.
|
|
330
|
+
var skew = Math.round(server + 500 - (sentAt + rtt / 2));
|
|
331
|
+
if (rtt <= SX_CLOCK.rtt || got - SX_CLOCK.at > SX_CLOCK_KEEP_MS) { SX_CLOCK.skew = skew; SX_CLOCK.rtt = rtt; SX_CLOCK.at = got; }
|
|
332
|
+
}
|
|
333
|
+
// 6.99.30 (QA: "Server connected" stayed green for five minutes after the Mac went away): every request says whether
|
|
334
|
+
// the Mac answered. A request that never reached it marks it down; any HTTP answer, whatever its status, marks it up.
|
|
335
|
+
// QA 6.99.30 (Skeptic): never a 502. Nothing sits in front of the Mac (direct HTTPS over Tailscale) and COS sends its
|
|
336
|
+
// own 502s (an unsupported Memories workspace, a bad Meetings Ask answer), so a 502 flapped the rail, the question
|
|
337
|
+
// cards and an open sheet down and up again. The rail's server line, Today's sheet and the question cards hear each change at once
|
|
338
|
+
// (COSWeb.onServer). null until the first answer.
|
|
339
|
+
// lastUpAt (6.99.32, QA Skeptic W-2): when the Mac last answered anything, on this page's clock. A read that timed out
|
|
340
|
+
// marks it down only if nothing has answered since that read went out (a slow image whose headers already came, a busy
|
|
341
|
+
// Mac whose question poll keeps answering), so a slow but alive Mac never flaps.
|
|
342
|
+
var SERVER = { up: null, hooks: [], lastUpAt: 0 };
|
|
343
|
+
function noteServer(up) {
|
|
344
|
+
up = !!up;
|
|
345
|
+
if (up) SERVER.lastUpAt = Date.now();
|
|
346
|
+
if (SERVER.up === up) return;
|
|
347
|
+
SERVER.up = up;
|
|
348
|
+
SERVER.hooks.slice().forEach(function (fn) { try { fn(up); } catch (err) { console.error(err); } });
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** This page's moment `t` (its own Date.now()) on the Mac's clock. */
|
|
352
|
+
function sxServerTime(t) { return t == null ? t : t + SX_CLOCK.skew; }
|
|
353
|
+
|
|
137
354
|
// ── state ───────────────────────────────────────────────
|
|
138
355
|
var state = {
|
|
139
356
|
pane: 'today', serverName: '', tailnetUrl: '',
|
|
@@ -208,7 +425,21 @@
|
|
|
208
425
|
var hash = '#/' + [pane].concat(args || []).map(encodeURIComponent).join('/');
|
|
209
426
|
if (location.hash === hash) route(); else location.hash = hash;
|
|
210
427
|
}
|
|
211
|
-
|
|
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; }
|
|
432
|
+
/** A keyboard and a fine pointer are the way in (a Mac, a PC): Return sends. Touch screens type a new line. */
|
|
433
|
+
function keyboardFirst() { return !!(window.matchMedia && window.matchMedia('(hover: hover) and (pointer: fine)').matches); }
|
|
434
|
+
// Return in a text box sends only where a keyboard and a pointer are the way in (cos-chat.js keyboardFirst): never on
|
|
435
|
+
// a touch screen, whatever its width (6.99.30: the unfolded Duo's on-screen keyboard sent on Return), and never
|
|
436
|
+
// mid-composition (an IME's Return picks the word; keyCode 229 is Safari's composing key). Shift-Return is a new line.
|
|
437
|
+
// QA 6.99.30 (Skeptic): Cmd-Return or Ctrl-Return sends on every device, so a Bluetooth keyboard on the Duo or an iPad
|
|
438
|
+
// (a coarse pointer, so plain Return is a new line there) still has a send key.
|
|
439
|
+
function returnSends(e) {
|
|
440
|
+
if (!e || e.key !== 'Enter' || e.shiftKey || e.isComposing || e.keyCode === 229 || !e.target || e.target.tagName !== 'TEXTAREA') return false;
|
|
441
|
+
return !!(e.metaKey || e.ctrlKey || keyboardFirst());
|
|
442
|
+
}
|
|
212
443
|
function route(forceChanged) {
|
|
213
444
|
var r = parseRoute();
|
|
214
445
|
var changedPane = forceChanged === true || r.pane !== state.pane || !document.querySelector('#pane-' + r.pane + ':not(.hidden)');
|
|
@@ -298,6 +529,7 @@
|
|
|
298
529
|
// ── auth ────────────────────────────────────────────────
|
|
299
530
|
function showAuth(message) {
|
|
300
531
|
readerImagesStop($('session-turns')); readerImagesStop($('ta-last'));
|
|
532
|
+
if (typeof liveHide === 'function') { liveHide(); liveStopWatch(); LIVE.mode = 'idle'; }
|
|
301
533
|
var alreadyOpen = !$('auth-gate').classList.contains('hidden');
|
|
302
534
|
$('app-shell').classList.add('hidden');
|
|
303
535
|
$('auth-gate').classList.remove('hidden');
|
|
@@ -315,6 +547,7 @@
|
|
|
315
547
|
loadTailnetUrl();
|
|
316
548
|
runHooks(shellHooks, info);
|
|
317
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)
|
|
318
551
|
// Reloaded with Today's overlay open: its history entry is still current. The overlay opens the same thing again
|
|
319
552
|
// (panes/today-actions.js restore), so Back closes it; if it cannot load, the entry is unmarked.
|
|
320
553
|
var held = history.state;
|
|
@@ -338,15 +571,24 @@
|
|
|
338
571
|
// stories all asked for it separately. Now there is one copy: a request younger than maxAgeMs is
|
|
339
572
|
// answered from it, a request already on the wire is shared, and panes can paint the last copy at
|
|
340
573
|
// once while a fresh one loads (sessionListCached).
|
|
341
|
-
var SL = { data: null, at: 0, inflight: null, waiting: null };
|
|
574
|
+
var SL = { data: null, at: 0, inflight: null, waiting: null, failed: false };
|
|
342
575
|
function sessionList(maxAgeMs) {
|
|
343
576
|
if (SL.data && Date.now() - SL.at < (maxAgeMs || 0)) return Promise.resolve(SL.data);
|
|
344
577
|
if (SL.inflight) return SL.inflight;
|
|
345
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; }
|
|
346
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
|
+
}
|
|
347
589
|
noteWaiting(d);
|
|
348
590
|
return d;
|
|
349
|
-
}, function (err) { SL.inflight = null; throw err; });
|
|
591
|
+
}, function (err) { SL.inflight = null; SL.failed = true; throw err; });
|
|
350
592
|
return SL.inflight;
|
|
351
593
|
}
|
|
352
594
|
function sessionListCached() { return SL.data ? { data: SL.data, at: SL.at } : null; }
|
|
@@ -368,6 +610,438 @@
|
|
|
368
610
|
if (fresh) questionsNudge();
|
|
369
611
|
}
|
|
370
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
|
+
}
|
|
371
1045
|
// ── the session pet (Today) ─────────────────────────────
|
|
372
1046
|
// Same pet and the same pose rule as COS Control (PetSpritePose.resolve): waiting with nothing running
|
|
373
1047
|
// is "waiting", then one, two, three, four or more sessions in play are working, duel, trio, swarm, an open
|
|
@@ -584,25 +1258,28 @@
|
|
|
584
1258
|
}
|
|
585
1259
|
|
|
586
1260
|
// ── Today ───────────────────────────────────────────────
|
|
587
|
-
|
|
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) {
|
|
588
1264
|
var now = new Date();
|
|
589
1265
|
$('today-date').textContent = now.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' }) + ', ' + clock(now);
|
|
590
1266
|
var gen = ++TODAY.gen;
|
|
591
1267
|
// Voices and memories answer on their own time (the memories status runs a CLI on the Mac): Today paints what it
|
|
592
1268
|
// has and says it is still reading, then repaints as each one lands.
|
|
593
|
-
|
|
1269
|
+
var extras = [];
|
|
1270
|
+
if (force === true || Date.now() - TODAY.extraAt > 60000 || TODAY.voices.state === 'failed' || TODAY.memories.state === 'failed') {
|
|
594
1271
|
TODAY.extraAt = Date.now();
|
|
595
1272
|
TODAY.voices = { state: 'pending', groups: [] }; TODAY.memories = { state: 'pending', n: 0 };
|
|
596
|
-
api('/api/voice/held-groups').then(function (d) {
|
|
1273
|
+
extras.push(api('/api/voice/held-groups').then(function (d) {
|
|
597
1274
|
TODAY.voices = { state: 'answered', groups: (d && Array.isArray(d.groups)) ? d.groups : [] };
|
|
598
|
-
}, function () { TODAY.voices = { state: 'failed', groups: [] }; }).then(function () { paintNeeds(); });
|
|
599
|
-
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) {
|
|
600
1277
|
var n = d && d.details ? d.details.learningToReview : null;
|
|
601
1278
|
TODAY.memories = typeof n === 'number' ? { state: 'answered', n: Math.max(0, n) } : { state: 'failed', n: 0 };
|
|
602
|
-
}, function () { TODAY.memories = { state: 'failed', n: 0 }; }).then(function () { paintNeeds(); });
|
|
1279
|
+
}, function () { TODAY.memories = { state: 'failed', n: 0 }; }).then(function () { paintNeeds(); }));
|
|
603
1280
|
}
|
|
604
|
-
Promise.all([
|
|
605
|
-
sessionList(15000).catch(function () { return null; }),
|
|
1281
|
+
var main = Promise.all([
|
|
1282
|
+
sessionList(force === true ? 0 : 15000).catch(function () { return null; }),
|
|
606
1283
|
api('/api/work-board/your-move').catch(function () { return null; }),
|
|
607
1284
|
api('/api/welcome-context').catch(function () { return null; }),
|
|
608
1285
|
api('/api/meetings/live-chunks').catch(function () { return null; })
|
|
@@ -617,6 +1294,9 @@
|
|
|
617
1294
|
var open = sessions.filter(function (s) { return s.running && !s.running_active; });
|
|
618
1295
|
var recent = live.filter(function (s) { return Date.now() - (s.mtimeMs || 0) < LIVE_FRESH_MS; });
|
|
619
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;
|
|
620
1300
|
TODAY.needs = needs; TODAY.working = working; TODAY.open = open; TODAY.welcome = welcome; TODAY.offline = offline; TODAY.at = Date.now();
|
|
621
1301
|
// your-move answers {available:false} when the board cannot be read: that is a failed read, not "nothing".
|
|
622
1302
|
TODAY.sessionsState = res[0] ? 'answered' : 'failed';
|
|
@@ -627,16 +1307,22 @@
|
|
|
627
1307
|
renderRunning(working, open);
|
|
628
1308
|
if (!offline) renderPet(sessions);
|
|
629
1309
|
paintNeeds();
|
|
1310
|
+
renderWeather(welcome);
|
|
630
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));
|
|
631
1315
|
loadTodayIntake();
|
|
632
1316
|
setBadge('sessions', working.length > 0);
|
|
633
|
-
liveSession(recent).then(function (rec) {
|
|
1317
|
+
return liveSession(recent).then(function (rec) {
|
|
634
1318
|
if (gen !== TODAY.gen) return;
|
|
635
1319
|
renderRecording(rec);
|
|
636
1320
|
setBadge('meetings', !!rec);
|
|
637
1321
|
if (rec) { TODAY.recording = true; paintNeeds(); }
|
|
638
1322
|
});
|
|
639
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;
|
|
640
1326
|
}
|
|
641
1327
|
// A capture counts as live only while it is still being written: an abandoned
|
|
642
1328
|
// active-sessions file must never read as "Recording now".
|
|
@@ -660,15 +1346,15 @@
|
|
|
660
1346
|
'<span class="btn small">Live view</span></button>';
|
|
661
1347
|
}
|
|
662
1348
|
// ── The Today summary (6.99.26 candidate) ───────────────
|
|
663
|
-
// "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
|
|
664
1350
|
// the server (lib/today-brief.ts). Today sends what it already read: session titles, states, how long, each session's
|
|
665
1351
|
// latest line and what needs you. The server decides whether a new one is written (off unless switched on, only when
|
|
666
|
-
// 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
|
|
667
1353
|
// and the summary replaces it when one arrives. Never shown over a session list that was not read.
|
|
668
|
-
var BRIEF = { view: null, inflight: false, sentKey: '', sentAt: 0 };
|
|
1354
|
+
var BRIEF = { view: null, inflight: false, sentKey: '', sentAt: 0, reconnect: false };
|
|
669
1355
|
// 6.99.26 QA: the same rows are asked about again only after five minutes (a Today that sits open still learns what
|
|
670
1356
|
// the server reads itself: sub-agents and Work cards); changed rows are asked about at once, one ask at a time.
|
|
671
|
-
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;
|
|
672
1358
|
function briefMinutes(v) { var d = toDate(v); return d ? Math.max(0, Math.round((Date.now() - d.getTime()) / 60000)) : 0; }
|
|
673
1359
|
function briefInputs(m) {
|
|
674
1360
|
var sessions = [];
|
|
@@ -701,9 +1387,25 @@
|
|
|
701
1387
|
var body = briefInputs(m);
|
|
702
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]);
|
|
703
1389
|
var since = Date.now() - BRIEF.sentAt;
|
|
704
|
-
|
|
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';
|
|
705
1400
|
BRIEF.inflight = true; BRIEF.sentKey = key; BRIEF.sentAt = Date.now();
|
|
706
|
-
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) {
|
|
707
1409
|
BRIEF.view = v && typeof v === 'object' ? v : null;
|
|
708
1410
|
}, function () { /* keep what was shown: the counts line or the last summary */ }).then(function () {
|
|
709
1411
|
BRIEF.inflight = false;
|
|
@@ -774,15 +1476,21 @@
|
|
|
774
1476
|
function listWords(names) { return names.length < 2 ? names.join('') : names.slice(0, -1).join(', ') + ' and ' + names[names.length - 1]; }
|
|
775
1477
|
function runRow(s, activeNow) {
|
|
776
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;
|
|
777
1482
|
var meta = activeNow
|
|
778
|
-
? 'Working for ' + span(s.state_since || s.last_activity_at)
|
|
1483
|
+
? 'Working for ' + (since != null ? '\u0000' : span(s.state_since || s.last_activity_at))
|
|
779
1484
|
: 'Open, quiet for ' + span(s.last_activity_at || s.state_since);
|
|
780
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);
|
|
781
1489
|
// A tap opens the peek on Today (panes/today-actions.js); it falls back to the reader if the peek cannot load.
|
|
782
1490
|
return '<button type="button" class="run-row" data-today="run" data-key="' + esc(key.provider) + '|' + esc(key.id) + '">' +
|
|
783
1491
|
'<span class="run-who">' + badge(key.provider) + (activeNow ? '<span class="pulse" aria-label="working"></span>' : '') + '</span>' +
|
|
784
1492
|
'<span class="run-title">' + esc(sessionTitle(s)) + '</span>' +
|
|
785
|
-
'<span class="run-meta">' +
|
|
1493
|
+
'<span class="run-meta">' + metaHtml + '</span></button>';
|
|
786
1494
|
}
|
|
787
1495
|
function renderRunning(working, open) {
|
|
788
1496
|
$('run-count').textContent = working.length ? String(working.length) : '';
|
|
@@ -798,6 +1506,150 @@
|
|
|
798
1506
|
var more = (working.length - shown.length) + (shown.length ? open.length : Math.max(0, open.length - 2));
|
|
799
1507
|
if (more > 0) html += '<button type="button" class="run-more" data-goto="sessions">' + countWord(more) + ' more open. All sessions</button>';
|
|
800
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(' · ');
|
|
801
1653
|
}
|
|
802
1654
|
function needWhy(item) {
|
|
803
1655
|
var p = item.progress || {};
|
|
@@ -833,7 +1685,12 @@
|
|
|
833
1685
|
if (s.waiting_kind === 'plan') return 'Has a plan for you to approve';
|
|
834
1686
|
var d = String(s.waiting_detail || '').trim();
|
|
835
1687
|
if (TOOL_NAMES.indexOf(d) >= 0) d = '';
|
|
836
|
-
|
|
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
|
+
}
|
|
837
1694
|
if (s.waiting_kind === 'question' && d) return d.length > 160 ? d.slice(0, 159) + '…' : d;
|
|
838
1695
|
return 'Waiting for you' + (sxProvider(s.provider) === 'claude' ? '. Paused until you answer.' : '');
|
|
839
1696
|
}
|
|
@@ -884,6 +1741,11 @@
|
|
|
884
1741
|
(TODAY.needs || []).forEach(function (it) {
|
|
885
1742
|
var k = splitSessionId(it.provider, it.sessionId);
|
|
886
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;
|
|
887
1749
|
rows.push({ kind: 'work', tier: 3, item: it, key: k.id ? k.provider + '|' + k.id : '' });
|
|
888
1750
|
});
|
|
889
1751
|
var groups = TODAY.voices.groups || [];
|
|
@@ -923,7 +1785,8 @@
|
|
|
923
1785
|
return bits.join(', ');
|
|
924
1786
|
}
|
|
925
1787
|
function needRowHtml(r, i) {
|
|
926
|
-
|
|
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) : '';
|
|
927
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>'; };
|
|
928
1791
|
if (r.kind === 'work') {
|
|
929
1792
|
var it = r.item, k = splitSessionId(it.provider, it.sessionId);
|
|
@@ -955,18 +1818,32 @@
|
|
|
955
1818
|
var html = visible.map(needRowHtml).join('');
|
|
956
1819
|
if (shown.length > visible.length) html += '<button type="button" class="need-more" data-today-needs="all">Show ' + (shown.length - visible.length) + ' more</button>';
|
|
957
1820
|
if (m.later.length && !TODAY.showLater) html += '<button type="button" class="need-more" data-today-needs="later">' + esc(m.later.length + (m.later.length === 1 ? ' thing can' : ' things can') + ' wait until tomorrow. Show') + '</button>';
|
|
958
|
-
|
|
959
|
-
|
|
1821
|
+
// 6.99.30 (design QA): no empty line above a failed read (it left a 45 px gap), and one retry row, never one per
|
|
1822
|
+
// source: with the Mac not answering (sessions and Work both unread, or more than two sources) it says so once.
|
|
1823
|
+
if (!m.rows.length) html = m.failed.length ? '' : '<p class="card-empty">' + esc(m.settled ? 'Nothing is waiting on you.' : 'Reading sessions, Work, voices and memories.') + '</p>';
|
|
1824
|
+
if (m.failed.length) {
|
|
1825
|
+
var silent = TODAY.offline || m.failed.length > 2;
|
|
1826
|
+
html += '<button type="button" class="need-more need-failed" data-today-needs="retry">' + esc(silent ? 'Your Mac is not answering. Try again.' : 'Couldn’t read ' + listWords(m.failed) + '. Try again') + '</button>';
|
|
1827
|
+
}
|
|
960
1828
|
list.innerHTML = html;
|
|
961
1829
|
}
|
|
1830
|
+
// Weather sits under the date at the top left (Miles, 11/07), where the eye starts, not inside Next up.
|
|
1831
|
+
// It shows whether or not a meeting is next.
|
|
1832
|
+
function renderWeather(w) {
|
|
1833
|
+
var el = $('today-weather'); if (!el) return;
|
|
1834
|
+
var x = w && w.weather;
|
|
1835
|
+
if (!x || !x.temp) { el.classList.add('hidden'); el.textContent = ''; return; }
|
|
1836
|
+
var desc = String(x.desc || '').toLowerCase(), place = x.location ? String(x.location).split(',')[0] : '';
|
|
1837
|
+
el.textContent = x.temp + (desc ? ' and ' + desc : '') + (place ? ' in ' + place : '');
|
|
1838
|
+
el.classList.remove('hidden');
|
|
1839
|
+
}
|
|
962
1840
|
function renderNext(w) {
|
|
963
1841
|
var card = $('next-card');
|
|
964
1842
|
var n = w && w.nextEvent; var d = n && toDate(n.time);
|
|
965
1843
|
if (!d || !n.title) { card.classList.add('hidden'); return; }
|
|
966
|
-
var weather = w.weather && w.weather.temp ? w.weather.temp + ' and ' + String(w.weather.desc || '').toLowerCase() + (w.weather.location ? ' in ' + w.weather.location.split(',')[0] : '') : '';
|
|
967
1844
|
var dw = dayWord(d);
|
|
968
1845
|
$('next-body').innerHTML = '<span class="when">' + esc(clock(d)) + '</span><span><span class="what">' + esc(n.title) + '</span>' +
|
|
969
|
-
'<span class="sub" style="display:block">' + esc(
|
|
1846
|
+
'<span class="sub" style="display:block">' + esc(dw.charAt(0).toUpperCase() + dw.slice(1)) + '</span></span>';
|
|
970
1847
|
// Control's Next up (todayPrepButtons): Prep me opens the Prep composer in the COS chat (panes/cos-chat.js prepFor),
|
|
971
1848
|
// Open meeting opens Meetings. Without a server to answer, Prep me is not offered.
|
|
972
1849
|
var prep = !TODAY.offline && window.COSWeb && window.COSWeb.cosChat && window.COSWeb.cosChat.prepFor;
|
|
@@ -974,6 +1851,27 @@
|
|
|
974
1851
|
'<button type="button" class="link-btn" data-goto="meetings">Open meeting</button>';
|
|
975
1852
|
card.classList.remove('hidden');
|
|
976
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
|
+
}
|
|
977
1875
|
function prepNext() {
|
|
978
1876
|
var n = TODAY.welcome && TODAY.welcome.nextEvent, d = n && toDate(n.time), chat = window.COSWeb && window.COSWeb.cosChat;
|
|
979
1877
|
if (!n || !n.title || !chat || !chat.prepFor) return;
|
|
@@ -1195,7 +2093,7 @@
|
|
|
1195
2093
|
var stats = view === 'sessions' ? sxStats(vis) : [];
|
|
1196
2094
|
var busy = view === 'sessions' ? SX.loading : SX.usageLoading;
|
|
1197
2095
|
hero.innerHTML = '<span class="cx-glyph"><svg class="icon"><use href="#i-sessions"/></svg></span>' +
|
|
1198
|
-
'<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>' +
|
|
1199
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>' +
|
|
1200
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>' : '');
|
|
1201
2099
|
var clockOn = view === 'sessions' && !sxQueryActive();
|
|
@@ -1210,6 +2108,12 @@
|
|
|
1210
2108
|
if (usage) usage.classList.toggle('hidden', view === 'sessions');
|
|
1211
2109
|
}
|
|
1212
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
|
+
}
|
|
1213
2117
|
function loadSessions(force) {
|
|
1214
2118
|
var r = parseRoute();
|
|
1215
2119
|
var reading = r.pane === 'sessions' && r.args.length === 2;
|
|
@@ -1275,6 +2179,7 @@
|
|
|
1275
2179
|
var meta = sxBadge(key.provider) +
|
|
1276
2180
|
(s.pinned ? '<span class="sx-pinned">PINNED</span>' : '') +
|
|
1277
2181
|
(label ? '<span class="sx-chip ' + st + '">' + esc(label) + '</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>' : '') +
|
|
1278
2183
|
(upd ? '<span class="sx-when">Updated ' + esc(sxShortDate(upd)) + '</span>' : '') +
|
|
1279
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>' : '') +
|
|
1280
2185
|
(s.project && String(s.project) !== title ? '<span class="sx-ws">' + esc(s.project) + '</span>' : '');
|
|
@@ -1480,6 +2385,7 @@
|
|
|
1480
2385
|
}
|
|
1481
2386
|
if (act === 'clear') { var input = $('sessions-q'); input.value = ''; searchSessions(); input.focus(); return; }
|
|
1482
2387
|
if (act === 'copy-session') return copySessionBrief();
|
|
2388
|
+
if (act === 'full') { if (SX_SPLIT) SX_SPLIT.toggleFull(); return; }
|
|
1483
2389
|
if (act === 'refresh-turns' && rd) { rd.refusal = null; renderVerdict(rd); refreshTurns(rd.provider, rd.id); return; }
|
|
1484
2390
|
if (act === 'retry' && rd) { rd.refusal = null; renderVerdict(rd); return sendSessionTurn(!!rd.retryFork); }
|
|
1485
2391
|
if (act === 'caution-send' && rd) { rd.cautionAck = true; rd.cautionAsk = false; renderVerdict(rd); return sendSessionTurn(false); }
|
|
@@ -1508,6 +2414,153 @@
|
|
|
1508
2414
|
});
|
|
1509
2415
|
}
|
|
1510
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
|
+
|
|
1511
2564
|
// ── Sessions: read, send, queue, fork, stop (the glasses' Continue, from a browser) ──
|
|
1512
2565
|
// The server owns every safety rule (agent-session-bindings.ts): COS writes into a desktop
|
|
1513
2566
|
// thread only through a binding, attaches only when no live process owns the thread, and
|
|
@@ -1551,18 +2604,54 @@
|
|
|
1551
2604
|
}
|
|
1552
2605
|
function sessionPath(provider, id, tail) { return '/api/agent-sessions/' + encodeURIComponent(provider) + '/' + encodeURIComponent(id) + (tail || ''); }
|
|
1553
2606
|
|
|
2607
|
+
// 6.99.30 (QA, held from Codex for .30): a queued message the server refused or let expire is said where the person
|
|
2608
|
+
// looks, not only in the Sessions reader: a "Not sent" line in Today's sheet for that session and a chip on its row in
|
|
2609
|
+
// the Sessions list. What the server's queue said last, per session, in this browser for a day; each read of the
|
|
2610
|
+
// queue replaces it, so a row the server drops clears the mark.
|
|
2611
|
+
var NOT_SENT_KEY = 'cos_web_not_sent', NOT_SENT_KEEP_MS = 86400000;
|
|
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'; }
|
|
2617
|
+
function notSentLine(t) {
|
|
2618
|
+
if (maybeSent(t)) return String(t.reasonCopy || 'May not have been sent. Read it, then say it again.').trim();
|
|
2619
|
+
var why = String((t && t.reasonCopy) || (t && t.status === 'expired' ? 'the thread stayed busy too long.' : 'the session refused it.')).trim();
|
|
2620
|
+
return /^not sent\b/i.test(why) ? why : 'Not sent: ' + why.charAt(0).toLowerCase() + why.slice(1);
|
|
2621
|
+
}
|
|
2622
|
+
function noteNotSent(provider, id, rows) {
|
|
2623
|
+
var all = readStore(NOT_SENT_KEY), k = provider + '|' + id, now = Date.now(), list = notSentRows(rows);
|
|
2624
|
+
Object.keys(all).forEach(function (x) { if (!all[x] || now - (Number(all[x].at) || 0) > NOT_SENT_KEEP_MS) delete all[x]; });
|
|
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];
|
|
2626
|
+
writeStore(NOT_SENT_KEY, all);
|
|
2627
|
+
}
|
|
2628
|
+
function notSentFor(provider, id) {
|
|
2629
|
+
var v = readStore(NOT_SENT_KEY)[provider + '|' + id];
|
|
2630
|
+
return v && Date.now() - (Number(v.at) || 0) <= NOT_SENT_KEEP_MS ? v : null;
|
|
2631
|
+
}
|
|
2632
|
+
|
|
1554
2633
|
// Like api(), but a 4xx refusal comes back as { status, body } instead of throwing, because
|
|
1555
2634
|
// these routes answer "busy", "changed" or "not now" with a body the person should read.
|
|
2635
|
+
// A timeout here stays the caller's (an abort: "may have saved", a probe that gives up), never a down mark; api()
|
|
2636
|
+
// reads and the question poll are what say a hung Mac is not answering (6.99.32).
|
|
1556
2637
|
function apiAnswer(path, opts) {
|
|
1557
2638
|
var o = opts || {};
|
|
1558
2639
|
var ctrl = typeof AbortController === 'function' ? new AbortController() : null;
|
|
1559
2640
|
var timer = ctrl && o.timeoutMs ? setTimeout(function () { ctrl.abort(); }, o.timeoutMs) : null;
|
|
1560
2641
|
var headers = { 'X-COS-Web': '1' };
|
|
1561
2642
|
if (o.body !== undefined) headers['Content-Type'] = 'application/json';
|
|
2643
|
+
var sentAt = Date.now();
|
|
1562
2644
|
return fetch(path, { method: o.method || 'GET', headers: headers, credentials: 'same-origin', body: o.body !== undefined ? JSON.stringify(o.body) : undefined, signal: ctrl ? ctrl.signal : undefined })
|
|
1563
2645
|
.then(function (res) {
|
|
2646
|
+
sxNoteClock(res, sentAt);
|
|
2647
|
+
noteServer(true);
|
|
1564
2648
|
if (res.status === 401) { showAuth(); throw new Error('Signed out. Connect this browser again.'); }
|
|
1565
2649
|
return res.text().then(function (t) { var b = {}; try { b = t ? JSON.parse(t) : {}; } catch (_e) { b = {}; } return { status: res.status, body: b || {} }; });
|
|
2650
|
+
}, function (err) {
|
|
2651
|
+
// A timeout this call asked for stays an abort (its callers say "may have saved"); the rest never reached the Mac.
|
|
2652
|
+
if (err && err.name === 'AbortError') throw err;
|
|
2653
|
+
noteServer(false);
|
|
2654
|
+
throw unreachableError(err);
|
|
1566
2655
|
})
|
|
1567
2656
|
.finally(function () { if (timer) clearTimeout(timer); });
|
|
1568
2657
|
}
|
|
@@ -1610,6 +2699,11 @@
|
|
|
1610
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.';
|
|
1611
2700
|
}
|
|
1612
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
|
+
}
|
|
1613
2707
|
function openSession(provider, id) {
|
|
1614
2708
|
var reader = $('session-reader');
|
|
1615
2709
|
if (!provider || !id) return;
|
|
@@ -1624,7 +2718,7 @@
|
|
|
1624
2718
|
var listed = (SX.rows || []).filter(function (s) { return sxSame(sxKey(s), provider + ':' + String(id).toLowerCase()); })[0];
|
|
1625
2719
|
if (listed) window.COSWeb.setCrumb(sxTitle(listed));
|
|
1626
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);
|
|
1627
|
-
api(sessionPath(provider, id, '?turns=' + SX_TURNS)).then(function (d) {
|
|
2721
|
+
api(sessionPath(provider, id, '?turns=' + SX_TURNS + '&cards=1')).then(function (d) {
|
|
1628
2722
|
if (state.reading !== r) return;
|
|
1629
2723
|
var turns = Array.isArray(d && d.recent_turns) ? d.recent_turns : ((d && d.recent_turns && d.recent_turns.turns) || (d && d.turns) || []);
|
|
1630
2724
|
r.detail = d; r.turns = turns; r.more = d.recent_turns_more === true;
|
|
@@ -1633,7 +2727,7 @@
|
|
|
1633
2727
|
window.COSWeb.setCrumb(title);
|
|
1634
2728
|
var trunc = sxTruncNote(d, turns, r.more);
|
|
1635
2729
|
reader.innerHTML =
|
|
1636
|
-
'<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>' +
|
|
1637
2731
|
'<p class="sx-subtitle">' + esc(sxSubtitle(d, provider, turns, r.more)) + '</p></div>' +
|
|
1638
2732
|
'<div class="sx-activity" id="session-live" aria-live="polite"></div>' +
|
|
1639
2733
|
(trunc ? '<p class="sx-note">' + esc(trunc) + '</p>' : '') +
|
|
@@ -1737,7 +2831,7 @@
|
|
|
1737
2831
|
// the same time are ordered gate, stream, list, hook, stop: the hook's word wins a tie. Idle yields
|
|
1738
2832
|
// only to working seen more than SX_IDLE_GRACE_MS after it, the server's rule for the flush that
|
|
1739
2833
|
// trails a Stop (TRANSCRIPT_AFTER_IDLE_GRACE_MS).
|
|
1740
|
-
var SX_IDLE_GRACE_MS = 5000, SX_STOP_ECHO_MS = 8000, SX_GATE_RECHECK_MS = 20000;
|
|
2834
|
+
var SX_IDLE_GRACE_MS = 5000, SX_STOP_ECHO_MS = 8000, SX_GATE_RECHECK_MS = 20000, SX_PROBE_TIMEOUT_MS = 15000;
|
|
1741
2835
|
var SX_TIE_RANK = { gate: 0, stream: 1, list: 2, hook: 3, stop: 4 };
|
|
1742
2836
|
function sxMs(v) {
|
|
1743
2837
|
var t = v instanceof Date ? v.getTime() : typeof v === 'number' ? v : typeof v === 'string' && v ? Date.parse(v) : NaN;
|
|
@@ -1750,9 +2844,11 @@
|
|
|
1750
2844
|
if (f.agentState && SX_HOOK_WORD[f.agentState]) out.push({ word: SX_HOOK_WORD[f.agentState], at: sxMs(f.stateSince) || sxMs(f.statusAt) || 0, src: 'hook', seen: sxMs(f.stateSeenAt) });
|
|
1751
2845
|
else if (f.statusAt) out.push({ word: f.transport === 'working' ? 'Working' : f.transport === 'done' ? 'Done' : 'Idle', at: sxMs(f.statusAt), src: 'stream', seen: sxMs(f.transportSeenAt) });
|
|
1752
2846
|
if (r.list && r.list.at != null) out.push({ word: r.list.word, at: r.list.at, src: 'list', seen: sxMs(r.list.seen) });
|
|
1753
|
-
|
|
2847
|
+
// A gate answer and a Stop are this page's own moments: ranked on the Mac's clock (sxServerTime) against the Mac's
|
|
2848
|
+
// own times, and kept on this page's clock as `seen` for the echo window (6.99.30, QA: clock skew).
|
|
2849
|
+
if (r.gate && r.gate.busy && r.gate.at != null) out.push({ word: 'Working', at: sxServerTime(r.gate.at), src: 'gate', seen: r.gate.at });
|
|
1754
2850
|
var stop = sxMs(r.stoppedAt);
|
|
1755
|
-
if (stop !== null) out.push({ word: 'Idle', at: stop, src: 'stop', seen: stop });
|
|
2851
|
+
if (stop !== null) out.push({ word: 'Idle', at: sxServerTime(stop), src: 'stop', seen: stop });
|
|
1756
2852
|
return out;
|
|
1757
2853
|
}
|
|
1758
2854
|
/** { word, at, src }: the one state the whole card shows. Null before any signal. */
|
|
@@ -1796,9 +2892,12 @@
|
|
|
1796
2892
|
// Arrival is the first time this page saw THIS word at THIS time: a re-read of the same row is not news.
|
|
1797
2893
|
r.list = { word: word, at: at != null ? at : 0, seen: was && was.word === word && was.at === (at != null ? at : 0) && was.seen != null ? was.seen : Date.now() };
|
|
1798
2894
|
}
|
|
1799
|
-
/** A gate busy answer that decides the card is re-asked
|
|
1800
|
-
*
|
|
1801
|
-
*
|
|
2895
|
+
/** A gate busy answer that decides the card is re-asked while the card shows it: 20 s after the last attempt, and
|
|
2896
|
+
* each failed attempt doubles the wait, up to 16 times (QA 6.99.29: a failing probe was re-asked every second).
|
|
2897
|
+
* Every attempt is stamped (gate.triedAt) and ends within SX_PROBE_TIMEOUT_MS (6.99.30: before that a probe that
|
|
2898
|
+
* never answered left r.probing set, and the gate was never asked again). So a busy gate stands only while the
|
|
2899
|
+
* server keeps answering busy, or keeps not answering, and a fresher signal (a Stop, the hook going idle more than
|
|
2900
|
+
* the grace after it) still outranks it. `at` stays the time of the last ANSWER: a failure is no evidence of busy. */
|
|
1802
2901
|
function sxGateStale(r) {
|
|
1803
2902
|
var t = sxTruth(r);
|
|
1804
2903
|
if (!(t && t.src === 'gate' && r.gate) || r.probing) return false;
|
|
@@ -1830,7 +2929,9 @@
|
|
|
1830
2929
|
var t = r ? sxTruth(r) : null;
|
|
1831
2930
|
var since = t && t.at ? new Date(t.at) : f.stateSince || f.lastEventAt;
|
|
1832
2931
|
if (!since) return '';
|
|
1833
|
-
|
|
2932
|
+
// QA 6.99.30 (Skeptic): every moment here is on the Mac's clock (the truth's own time, or the Mac's state_since), so
|
|
2933
|
+
// now is too; a phone a minute behind read "0s" for a minute.
|
|
2934
|
+
var s = Math.max(0, Math.floor((sxServerTime(Date.now()) - since.getTime()) / 1000));
|
|
1834
2935
|
if (s < 60) return s + 's';
|
|
1835
2936
|
if (s < 3600) return Math.floor(s / 60) + 'm ' + (s % 60) + 's';
|
|
1836
2937
|
return Math.floor(s / 3600) + 'h ' + Math.floor((s % 3600) / 60) + 'm';
|
|
@@ -1840,6 +2941,7 @@
|
|
|
1840
2941
|
if (reason === 'http_503') return 'The server has no room for another live feed right now; showing the stored turns.';
|
|
1841
2942
|
if (reason === 'reconnecting') return 'Reconnecting to the live feed…';
|
|
1842
2943
|
if (reason === 'stream_lost') return 'The live feed stopped answering; showing the stored turns. Reopen the session to try again.';
|
|
2944
|
+
if (reason === 'unreachable') return 'Your Mac is not answering. This is the last state it sent.';
|
|
1843
2945
|
return 'Live feed unavailable (' + reason + '); showing the stored turns.';
|
|
1844
2946
|
}
|
|
1845
2947
|
// The block keeps one size whatever it holds (/qa: the turns below moved while Miles read, as tool
|
|
@@ -1946,7 +3048,7 @@
|
|
|
1946
3048
|
}
|
|
1947
3049
|
function refreshTurns(provider, id) {
|
|
1948
3050
|
var reading = state.reading;
|
|
1949
|
-
api(sessionPath(provider, id, '?turns=' + SX_TURNS)).then(function (d) {
|
|
3051
|
+
api(sessionPath(provider, id, '?turns=' + SX_TURNS + '&cards=1')).then(function (d) {
|
|
1950
3052
|
var r = state.reading;
|
|
1951
3053
|
if (!r || r !== reading || r.id !== id || r.provider !== provider) return;
|
|
1952
3054
|
var turns = Array.isArray(d && d.recent_turns) ? d.recent_turns : [];
|
|
@@ -1975,7 +3077,8 @@
|
|
|
1975
3077
|
var waiting = rows.filter(function (t) { return t.status === 'waiting' || t.status === 'queued' || t.status === 'pending'; });
|
|
1976
3078
|
// QA 6.99.29: a queued message the server refused (or let expire) stays in view with the server's reason,
|
|
1977
3079
|
// instead of vanishing from the line.
|
|
1978
|
-
var notSent = rows
|
|
3080
|
+
var notSent = notSentRows(rows);
|
|
3081
|
+
if (q.status === 200) noteNotSent(provider, id, rows);
|
|
1979
3082
|
if (q.status === 200 && r.queued !== waiting.length) { r.queued = waiting.length; paintActivity(r); }
|
|
1980
3083
|
if (!waiting.length && !notSent.length) { box.innerHTML = ''; return; }
|
|
1981
3084
|
box.innerHTML = '<div class="sx-queue">' + (waiting.length ? '<p class="sx-queue-head">' + (waiting.length === 1 ? '1 follow-up queued. It sends when this session is available.' : waiting.length + ' follow-ups queued. They send in order when this session is available.') + '</p>' : '') +
|
|
@@ -1985,8 +3088,8 @@
|
|
|
1985
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>';
|
|
1986
3089
|
}).join('') +
|
|
1987
3090
|
notSent.map(function (t) {
|
|
1988
|
-
return '<div class="sx-queue-row sx-queue-refused"><span class="sx-queue-state">
|
|
1989
|
-
'<span class="sx-queue-reason">' + esc(
|
|
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') +
|
|
3092
|
+
'<span class="sx-queue-reason">' + esc(notSentLine(t)) + '</span></span></div>';
|
|
1990
3093
|
}).join('') + '</div>';
|
|
1991
3094
|
}).catch(function () {});
|
|
1992
3095
|
}
|
|
@@ -2000,13 +3103,15 @@
|
|
|
2000
3103
|
// Every attempt is stamped; a failed one backs the next off (sxGateStale). A gate answer resets both.
|
|
2001
3104
|
if (r.gate) r.gate.triedAt = Date.now();
|
|
2002
3105
|
var failed = function () { if (r.gate) r.gate.failures = (r.gate.failures || 0) + 1; };
|
|
2003
|
-
return apiAnswer(sessionPath(r.provider, r.id, '/attachability')).then(function (a) {
|
|
3106
|
+
return apiAnswer(sessionPath(r.provider, r.id, '/attachability'), { timeoutMs: SX_PROBE_TIMEOUT_MS }).then(function (a) {
|
|
2004
3107
|
if (state.reading !== r && !r.view) return;
|
|
2005
3108
|
if (a.status === 404) { failed(); r.verdict = { attachable: false, reason: 'route_absent', reasonCopy: 'Update the COS server to continue a thread from here.', ownerCount: 0 }; viewOf(r).verdict(r); return; }
|
|
2006
3109
|
var b = a.body || {};
|
|
2007
3110
|
if (a.status !== 200 || typeof b.attachable !== 'boolean') { failed(); return; }
|
|
2008
3111
|
var owners = Number(b.ownerCount) || 0;
|
|
2009
|
-
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) };
|
|
2010
3115
|
// 6.99.29: the gate's answer is a signal of the one state, as of now.
|
|
2011
3116
|
sxNoteGate(r, b.attachable === false && b.reason === 'native_thread_working');
|
|
2012
3117
|
sxRepaint(r);
|
|
@@ -2017,6 +3122,17 @@
|
|
|
2017
3122
|
codex: 'Working right now. A message you send joins the Codex app’s queue for this thread.',
|
|
2018
3123
|
claude: 'Working right now. A message you send waits and lands when this turn ends.'
|
|
2019
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
|
+
}
|
|
2020
3136
|
/** The composer's "Working right now" note: a busy gate verdict, and the one state agrees. */
|
|
2021
3137
|
function sxParkNoteShows(r) {
|
|
2022
3138
|
var v = r.verdict;
|
|
@@ -2049,7 +3165,7 @@
|
|
|
2049
3165
|
(f.fork ? forkBtn : ''));
|
|
2050
3166
|
} else if (v && v.attachable === false && /^(native_thread_working|native_target_busy)$/.test(v.reason)) {
|
|
2051
3167
|
// 6.99.29: "Working right now" only while the one state says so.
|
|
2052
|
-
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>';
|
|
2053
3169
|
} else if (v && v.attachable === false && v.reasonCopy) {
|
|
2054
3170
|
html = refusalBox(v.reasonCopy, sxSupplement(v.reason), /fork/i.test(v.reasonCopy) ? forkBtn : '');
|
|
2055
3171
|
}
|
|
@@ -2145,6 +3261,12 @@
|
|
|
2145
3261
|
if (!READER_FILES[key].anyBytes && !READER_FILES[key].atts.length && kit.turnFileBytes()) READER_FILES[key].anyBytes = kit.turnFileBytes();
|
|
2146
3262
|
return READER_FILES[key];
|
|
2147
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
|
+
}
|
|
2148
3270
|
function readerFilesState(box) {
|
|
2149
3271
|
var kit = readerKit();
|
|
2150
3272
|
if (!kit || !box || !box.atts.length) return 'none';
|
|
@@ -2160,6 +3282,8 @@
|
|
|
2160
3282
|
Array.prototype.forEach.call(slot.querySelectorAll('[data-sx-file]'), function (i) { i.disabled = full || !!r.sending; });
|
|
2161
3283
|
list.innerHTML = kit.chipsHtml(box, 'data-action') + (box.atts.length ? '<p class="att-limit">' + esc(kit.limitLine(box)) + '</p>' : '');
|
|
2162
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);
|
|
2163
3287
|
// Send and Fork wait while a file uploads. A failed file never greys them out (QA 6.99.27): a tap says why.
|
|
2164
3288
|
var st = readerFilesState(box), form = $('session-form');
|
|
2165
3289
|
if (!form || r.sending) return;
|
|
@@ -2389,7 +3513,7 @@
|
|
|
2389
3513
|
rememberParked(r, draft.key);
|
|
2390
3514
|
if (opts.held) settleHeld(r, opts.held, 'parked');
|
|
2391
3515
|
else { view.pending(r, draft.text, 'waiting in line'); view.clear(r, draft.text); }
|
|
2392
|
-
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.'));
|
|
2393
3517
|
view.queue(r);
|
|
2394
3518
|
// 6.99.29: "The session is busy" is the gate's word as of now; the card shows the same. Last, and
|
|
2395
3519
|
// never allowed to cost the parked message its settlement.
|
|
@@ -2485,11 +3609,50 @@
|
|
|
2485
3609
|
}
|
|
2486
3610
|
|
|
2487
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.
|
|
2488
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);
|
|
2489
3621
|
var images = turn && Array.isArray(turn.outputImages) ? turn.outputImages.filter(function (x) {
|
|
2490
3622
|
return x && /^ai_[a-f0-9]{64}$/.test(x.id || '') && (x.state === 'available' || x.state === 'unavailable');
|
|
2491
3623
|
}).map(function (x) { return { id: x.id, label: String(x.label || 'Output image').slice(0, 180), state: x.state, reason: x.reason }; }) : [];
|
|
2492
|
-
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>' : '';
|
|
2493
3656
|
}
|
|
2494
3657
|
function readerImagesStop(host) {
|
|
2495
3658
|
if (!host) return;
|
|
@@ -2498,15 +3661,17 @@
|
|
|
2498
3661
|
}
|
|
2499
3662
|
function readerImagesMount(host, r) {
|
|
2500
3663
|
if (!host || !r) return;
|
|
2501
|
-
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; }
|
|
2502
3665
|
var owner = host.__cosImageOwner = {};
|
|
2503
|
-
|
|
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) {
|
|
2504
3669
|
if (host.__cosImageOwner !== owner || host.isConnected === false) return;
|
|
2505
3670
|
if (!kit) throw new Error('Image previews could not load.');
|
|
2506
3671
|
kit.mount(host, r.provider, r.id);
|
|
2507
3672
|
}).catch(function () {
|
|
2508
3673
|
if (host.__cosImageOwner !== owner || host.isConnected === false) return;
|
|
2509
|
-
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) {
|
|
2510
3675
|
slot.textContent = 'Image previews could not load. ';
|
|
2511
3676
|
var retry = document.createElement('button'); retry.type = 'button'; retry.className = 'link-btn'; retry.textContent = 'Retry images';
|
|
2512
3677
|
retry.addEventListener('click', function () { readerImagesMount(host, r); }); slot.appendChild(retry);
|
|
@@ -2532,7 +3697,7 @@
|
|
|
2532
3697
|
out += original ? '## Original request\n\n' + original + '\n\n## Conversation\n\n' : '## Conversation\n\n';
|
|
2533
3698
|
if (r.more) out += '[Older turns omitted. COS serves the newest ' + turns.length + '. Original request kept.]\n\n';
|
|
2534
3699
|
turns.forEach(function (t) {
|
|
2535
|
-
var text = String(t.text).trim();
|
|
3700
|
+
var text = String(t.text).trim().replace(/\[([^\]\n]*)\]\(cos-file:\d{1,2}\)/g, '$1');
|
|
2536
3701
|
if (original && t.role === 'user' && text === original) return;
|
|
2537
3702
|
out += '**' + (t.role === 'user' ? 'You' : 'Assistant') + '**\n\n' + text + '\n\n';
|
|
2538
3703
|
});
|
|
@@ -2550,9 +3715,16 @@
|
|
|
2550
3715
|
// Markdown for an assistant turn (Control renders replies with COSMarkdownView): headings, lists,
|
|
2551
3716
|
// code, quotes, tables, rules, links. Everything is escaped first, so a reply can never add
|
|
2552
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.
|
|
2553
3721
|
function sxInline(s) {
|
|
3722
|
+
var links = sxInline.links;
|
|
2554
3723
|
var codes = [];
|
|
2555
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
|
+
});
|
|
2556
3728
|
s = s.replace(/\[([^\]\n]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
|
|
2557
3729
|
s = s.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>').replace(/__([^_\n]+)__/g, '<strong>$1</strong>');
|
|
2558
3730
|
s = s.replace(/(^|[^*\w])\*([^*\s][^*\n]*?)\*(?!\w)/g, '$1<em>$2</em>');
|
|
@@ -2563,7 +3735,13 @@
|
|
|
2563
3735
|
var SX_RULE = /^\s*([-*_])(\s*\1){2,}\s*$/;
|
|
2564
3736
|
var SX_TABLE_SEP = /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$/;
|
|
2565
3737
|
function sxCells(row) { return row.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map(function (c) { return c.trim(); }); }
|
|
2566
|
-
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) {
|
|
2567
3745
|
var lines = esc(String(text || '')).replace(/\r\n?/g, '\n').split('\n');
|
|
2568
3746
|
var out = [], para = [], i = 0;
|
|
2569
3747
|
function flush() { if (para.length) { out.push('<p>' + para.map(sxInline).join('<br>') + '</p>'); para = []; } }
|
|
@@ -2585,12 +3763,16 @@
|
|
|
2585
3763
|
if (line.indexOf('|') >= 0 && i + 1 < lines.length && SX_TABLE_SEP.test(lines[i + 1])) {
|
|
2586
3764
|
flush();
|
|
2587
3765
|
var head = sxCells(line), aligns = sxCells(lines[i + 1]).map(function (a) { return /^:-+:$/.test(a) ? 'center' : /-:$/.test(a) ? 'right' : ''; });
|
|
2588
|
-
var cell = function (tag, c, k) { return '<' + tag + (aligns[k] ? ' style="text-align:' + aligns[k] + '"' : '') + '>' + sxInline(c) + '</' + tag + '>'; };
|
|
2589
3766
|
i += 2;
|
|
2590
3767
|
var body = [];
|
|
2591
3768
|
while (i < lines.length && lines[i].indexOf('|') >= 0 && lines[i].trim()) { body.push(sxCells(lines[i])); i += 1; }
|
|
2592
|
-
|
|
2593
|
-
|
|
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>');
|
|
2594
3776
|
continue;
|
|
2595
3777
|
}
|
|
2596
3778
|
if (/^\s*>/.test(line)) {
|
|
@@ -2611,6 +3793,17 @@
|
|
|
2611
3793
|
sxMd.unescaped = function (lines) {
|
|
2612
3794
|
return lines.join('\n').split(/\n\s*\n/).map(function (p) { return '<p>' + p.split('\n').map(sxInline).join('<br>') + '</p>'; }).join('');
|
|
2613
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
|
+
};
|
|
2614
3807
|
// Nested lists by indent: a deeper item opens a list inside the open item, a shallower one closes back.
|
|
2615
3808
|
function sxListBlock(lines, i) {
|
|
2616
3809
|
var html = '', stack = [];
|
|
@@ -2646,7 +3839,7 @@
|
|
|
2646
3839
|
// One turn's identity, for telling which turns a refresh brought that the page already shows.
|
|
2647
3840
|
function sxTurnSig(t) {
|
|
2648
3841
|
var text = String(t.text || t.content || '').trim();
|
|
2649
|
-
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) : '');
|
|
2650
3843
|
}
|
|
2651
3844
|
function sxTurnHtml(t) {
|
|
2652
3845
|
var role = String(t.role || '').toLowerCase() === 'user' ? 'user' : 'assistant';
|
|
@@ -2655,7 +3848,9 @@
|
|
|
2655
3848
|
var when = at ? (Date.now() - at.getTime() < 86400000 ? clock(at) : sxShortDate(at)) : '';
|
|
2656
3849
|
return '<div class="turn ' + role + ' sx-turn" data-sig="' + esc(sxTurnSig(t)) + '"><span class="who"><span>' + (role === 'user' ? 'YOU' : 'ASSISTANT') + '</span>' +
|
|
2657
3850
|
(when ? '<span class="sx-turn-when">' + esc(when) + '</span>' : '') + '</span>' +
|
|
2658
|
-
|
|
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>';
|
|
2659
3854
|
}
|
|
2660
3855
|
// A refresh never rebuilds what you are reading (Miles, /qa: "start to scroll and then it reloads
|
|
2661
3856
|
// the section"). The server's window slides as a session grows, so the turns the page already
|
|
@@ -4624,7 +5819,7 @@
|
|
|
4624
5819
|
.catch(function (err) {
|
|
4625
5820
|
// The server names what failed (wrong token, wrong code, the glasses code, lockout).
|
|
4626
5821
|
// No status means the request never reached COS (the browser's own "Load failed").
|
|
4627
|
-
showAuth(err && err.status ? err.message :
|
|
5822
|
+
showAuth(err && err.status ? err.message : UNREACHABLE_COPY);
|
|
4628
5823
|
});
|
|
4629
5824
|
} else if (f.id === 'session-form') sendSessionTurn(false);
|
|
4630
5825
|
else if (f.id === 'sessions-search-form') { $('sessions-q').blur(); searchSessions(); }
|
|
@@ -4673,7 +5868,7 @@
|
|
|
4673
5868
|
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { e.preventDefault(); openAsk(); return; }
|
|
4674
5869
|
if (e.key === 'Escape' && !$('ask-sheet').classList.contains('hidden')) { closeAsk(); return; }
|
|
4675
5870
|
if (e.key === 'Escape' && !$('section-menu').classList.contains('hidden')) { closeMenu(); $('section-btn').focus(); return; }
|
|
4676
|
-
if (e
|
|
5871
|
+
if (returnSends(e)) { e.preventDefault(); var form = e.target.closest('form'); if (form) form.requestSubmit(); }
|
|
4677
5872
|
});
|
|
4678
5873
|
document.querySelectorAll('[data-mode]').forEach(function (b) {
|
|
4679
5874
|
b.addEventListener('click', function () {
|
|
@@ -4684,14 +5879,37 @@
|
|
|
4684
5879
|
});
|
|
4685
5880
|
window.addEventListener('hashchange', route);
|
|
4686
5881
|
window.addEventListener('scroll', onScroll, { passive: true });
|
|
5882
|
+
// 6.99.30: back from the browser's back-forward cache (a swipe back, the app switcher) the open reader's live stream
|
|
5883
|
+
// is closed and the page's last reading is however old the visit was. Open the stream again and read the turns.
|
|
5884
|
+
window.addEventListener('pageshow', function (e) {
|
|
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
|
|
5887
|
+
var r = state.reading;
|
|
5888
|
+
if (r && r.provider && r.id) {
|
|
5889
|
+
if (state.stream) { try { state.stream.close(); } catch (_e) {} state.stream = null; }
|
|
5890
|
+
watchSession(r.provider, r.id); refreshTurns(r.provider, r.id);
|
|
5891
|
+
}
|
|
5892
|
+
if (state.pane === 'today' && !$('app-shell').classList.contains('hidden')) loadToday();
|
|
5893
|
+
});
|
|
4687
5894
|
document.addEventListener('visibilitychange', function () {
|
|
4688
|
-
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
|
|
4689
5898
|
// Coming back to the page refreshes Today in place. Other panes keep what is on screen:
|
|
4690
5899
|
// rebuilding them here moved the page under Miles's finger ("it reloads the section").
|
|
4691
5900
|
if (state.pane === 'today' && Date.now() - (state.lastLoad.today || 0) > 60000) { state.lastLoad.today = Date.now(); loadToday(); }
|
|
4692
5901
|
// Back within the minute: Today keeps its data, but the pet's loop stopped while hidden, so restart it.
|
|
4693
5902
|
else if (state.pane === 'today' && PET.pose && !PET.timer) renderPet(state.sessions || []);
|
|
4694
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
|
+
});
|
|
4695
5913
|
window.addEventListener('message', function (e) {
|
|
4696
5914
|
if (e.origin !== location.origin) return;
|
|
4697
5915
|
var d = e.data;
|
|
@@ -4724,10 +5942,133 @@
|
|
|
4724
5942
|
});
|
|
4725
5943
|
}
|
|
4726
5944
|
|
|
5945
|
+
// ── the visible viewport, read one way (6.99.30) ──────
|
|
5946
|
+
// Today's session sheet (panes/today-actions.js) and the question card's footer on Today (panes/questions.js) both
|
|
5947
|
+
// place things above whatever covers the bottom of the page: an on-screen keyboard, a hardware keyboard's strip, or a
|
|
5948
|
+
// browser's own bottom bar (Comet, Chrome, Safari's, whichever: nothing here knows any browser's bar). Only what the
|
|
5949
|
+
// browser reports is used: window.visualViewport's height, offsetTop and scale against the layout viewport.
|
|
5950
|
+
// layoutH the layout viewport's height. Read whenever the page is not pinch-zoomed (then innerHeight is the layout
|
|
5951
|
+
// viewport everywhere) and kept while it is, since some browsers report the zoomed size there.
|
|
5952
|
+
// inset what covers the bottom, in unzoomed CSS px: layoutH less the visible height times the zoom. A pinch
|
|
5953
|
+
// zoom alone is no inset (half the height at 2x is all of the page).
|
|
5954
|
+
// top, height the box to fill, in layout px. Unzoomed it is the visible viewport itself (offsetTop is where a
|
|
5955
|
+
// browser panned the page to keep a focused field in view). Zoomed it is the layout viewport less the
|
|
5956
|
+
// inset, so a pinch zoom magnifies the sheet instead of shrinking its layout into the zoomed window.
|
|
5957
|
+
// below what of the layout viewport lies under that box: the backdrop alone reaches down over it.
|
|
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.
|
|
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
|
+
}
|
|
5983
|
+
function viewportFit() {
|
|
5984
|
+
var vv = window.visualViewport, de = document.documentElement;
|
|
5985
|
+
var inner = window.innerHeight || (de && de.clientHeight) || 0;
|
|
5986
|
+
var scale = vv && vv.scale > 0 ? vv.scale : 1, zoomed = scale > 1.01;
|
|
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);
|
|
6008
|
+
var H = VP.layoutH;
|
|
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 };
|
|
6010
|
+
var inset = Math.max(0, Math.round(H - vv.height * scale));
|
|
6011
|
+
var top, height;
|
|
6012
|
+
if (!zoomed) { top = Math.max(0, Math.round(vv.offsetTop || 0)); height = Math.min(Math.round(vv.height), H); }
|
|
6013
|
+
else { height = H - inset; top = Math.min(Math.max(0, Math.round(vv.offsetTop || 0)), inset); }
|
|
6014
|
+
if (top + height > H) top = Math.max(0, H - height);
|
|
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 };
|
|
6017
|
+
}
|
|
6018
|
+
|
|
6019
|
+
// ── layout breadcrumbs (6.99.30) ──────────────────────
|
|
6020
|
+
// When a sheet lands wrong on a phone nobody here holds, the numbers have to come back on their own. A breadcrumb
|
|
6021
|
+
// goes to the server's existing client diagnostics log (POST /api/diag/client, routes/diag.ts: 10 MB with one
|
|
6022
|
+
// rotation, 30 lines per 10 s per client) when an overlay opens and each time its keyboard state changes. Numbers and
|
|
6023
|
+
// a browser family only: no address, no text, no ids, never the user agent string itself. At most one identical
|
|
6024
|
+
// line per 2 s and LAYOUT_CRUMB_MAX a page load, fire and forget.
|
|
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: [] };
|
|
6031
|
+
/** The browser family, from the UA string, for the breadcrumb. Comet first: it says Chrome or Safari too. */
|
|
6032
|
+
function uaClass(ua) {
|
|
6033
|
+
ua = String(ua == null ? (navigator && navigator.userAgent) || '' : ua);
|
|
6034
|
+
var ios = /iPhone|iPad|iPod/.test(ua) || (/Macintosh/.test(ua) && typeof navigator !== 'undefined' && navigator.maxTouchPoints > 1);
|
|
6035
|
+
var android = /Android/.test(ua);
|
|
6036
|
+
var os = ios ? 'ios' : android ? 'android' : /Mac OS X|Macintosh/.test(ua) ? 'mac' : /Windows/.test(ua) ? 'windows' : /Linux|CrOS/.test(ua) ? 'linux' : 'other';
|
|
6037
|
+
var hints = typeof navigator !== 'undefined' && navigator.userAgentData && navigator.userAgentData.brands ? navigator.userAgentData.brands.map(function (b) { return b && b.brand; }).join(' ') : '';
|
|
6038
|
+
var brand = /Comet\//i.test(ua) || /\bComet\b/i.test(ua) || /\bComet\b/i.test(hints) ? 'comet'
|
|
6039
|
+
: /CriOS\//.test(ua) ? 'crios' : /FxiOS\//.test(ua) ? 'firefox' : /EdgiOS\/|Edg\//.test(ua) ? 'edge'
|
|
6040
|
+
: /Firefox\//.test(ua) ? 'firefox' : /Chrome\/|Chromium\//.test(ua) ? 'chrome'
|
|
6041
|
+
: /Safari\//.test(ua) ? 'safari' : 'other';
|
|
6042
|
+
return { os: os, brand: brand };
|
|
6043
|
+
}
|
|
6044
|
+
/** One breadcrumb: `event` (open, kb), the viewport as the browser reports it, and the overlay's own state. */
|
|
6045
|
+
function layoutCrumb(event, extra) {
|
|
6046
|
+
try {
|
|
6047
|
+
var vv = window.visualViewport, de = document.documentElement, ua = uaClass();
|
|
6048
|
+
var r = function (n) { return typeof n === 'number' && isFinite(n) ? Math.round(n * 100) / 100 : null; };
|
|
6049
|
+
var o = (screen && screen.orientation && screen.orientation.type) || (typeof window.orientation === 'number' ? 'deg' + window.orientation : '');
|
|
6050
|
+
var data = { what: String(event || ''), ua: ua.brand, os: ua.os, iw: r(window.innerWidth), ih: r(window.innerHeight), ch: r(de && de.clientHeight),
|
|
6051
|
+
vvh: vv ? r(vv.height) : null, vvw: vv ? r(vv.width) : null, vvt: vv ? r(vv.offsetTop) : null, vvs: vv ? r(vv.scale) : null,
|
|
6052
|
+
orient: String(o).slice(0, 24), coarse: !!(window.matchMedia && window.matchMedia('(pointer: coarse)').matches) };
|
|
6053
|
+
var x = extra || {};
|
|
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; });
|
|
6055
|
+
var key = JSON.stringify(data), now = Date.now();
|
|
6056
|
+
var focusLine = data.what === 'focus';
|
|
6057
|
+
if (focusLine ? crumbs.focus >= LAYOUT_FOCUS_MAX : crumbs.sent - crumbs.focus >= LAYOUT_CRUMB_MAX - LAYOUT_FOCUS_MAX) return false;
|
|
6058
|
+
if (key === crumbs.last && now - crumbs.lastAt < LAYOUT_CRUMB_GAP_MS) return false;
|
|
6059
|
+
crumbs.recent = crumbs.recent.filter(function (t) { return now - t < LAYOUT_CRUMB_WINDOW_MS; });
|
|
6060
|
+
if (crumbs.recent.length >= (focusLine ? LAYOUT_CRUMB_RATE / 2 : LAYOUT_CRUMB_RATE)) return false;
|
|
6061
|
+
crumbs.sent++; if (focusLine) crumbs.focus++;
|
|
6062
|
+
crumbs.last = key; crumbs.lastAt = now; crumbs.recent.push(now);
|
|
6063
|
+
api('/api/diag/client', { method: 'POST', body: { ts: now, level: 'info', event: 'web.layout', data: data }, timeoutMs: 5000 }).catch(function () {});
|
|
6064
|
+
return true;
|
|
6065
|
+
} catch (_e) { return false; }
|
|
6066
|
+
}
|
|
6067
|
+
|
|
4727
6068
|
// ── the shared kit for pane modules (panes/*.js) ───────
|
|
4728
6069
|
window.COSWeb = {
|
|
4729
|
-
$: $, esc: esc, md: md, plain: plain, api: api, toast: toast, ago: ago, clock: clock, toDate: toDate,
|
|
4730
|
-
plural: plural, countWord: countWord, dayWord: dayWord, providerLabel: providerLabel, domainLabel: domainLabel,
|
|
6070
|
+
$: $, esc: esc, md: md, plain: plain, structureLensAnswer: structureLensAnswer, tableFit: tableFit, api: api, toast: toast, ago: ago, clock: clock, toDate: toDate,
|
|
6071
|
+
plural: plural, countWord: countWord, dayWord: dayWord, plainDay: plainDay, providerLabel: providerLabel, domainLabel: domainLabel,
|
|
4731
6072
|
skeletonRows: skeletonRows, emptyBox: emptyBox, setStatus: setStatus, autoGrow: autoGrow,
|
|
4732
6073
|
readStore: readStore, writeStore: writeStore, copyText: copyText, isPhone: isPhone,
|
|
4733
6074
|
state: state, titles: TITLES, go: go, parseRoute: parseRoute, goBack: goBack,
|
|
@@ -4747,7 +6088,31 @@
|
|
|
4747
6088
|
// Control's session state (the Sessions pane's sxState) and warm-up test, so the rail's
|
|
4748
6089
|
// "N waiting on you, N working" counts exactly what the pane lists.
|
|
4749
6090
|
sessionState: function (s) { return sxState(s); },
|
|
4750
|
-
isKeepWarm: function (s) { return sxKeepWarm(s); }
|
|
6091
|
+
isKeepWarm: function (s) { return sxKeepWarm(s); },
|
|
6092
|
+
// 6.99.30: the visible viewport read one way, layout breadcrumbs, the one unreachable sentence, Return-sends.
|
|
6093
|
+
viewportFit: viewportFit, layoutCrumb: layoutCrumb, uaClass: uaClass, unreachableCopy: UNREACHABLE_COPY, keyboardFirst: keyboardFirst,
|
|
6094
|
+
serverUp: function () { return SERVER.up; },
|
|
6095
|
+
// Panes with their own fetch (the questions poll) say whether the Mac answered, so a "down" mark never outlives it.
|
|
6096
|
+
noteServer: function (up) { noteServer(up); },
|
|
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
|
+
}
|
|
4751
6116
|
};
|
|
4752
6117
|
// Today as a place to act (panes/today-actions.js, Miles 11/06): what the Today cards show, a repaint after a
|
|
4753
6118
|
// decision, and the Sessions reader's own pieces, so the overlay sends and reads through the reader's code.
|
|
@@ -4784,13 +6149,126 @@
|
|
|
4784
6149
|
newKey: newTurnKey,
|
|
4785
6150
|
probe: function (r) { return sxProbeAttach(r); },
|
|
4786
6151
|
path: sessionPath, answer: apiAnswer,
|
|
4787
|
-
|
|
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); },
|
|
4788
6154
|
feed: { fresh: newFeed, apply: applyFeed, word: sxFeedWord, detail: sxFeedDetail, tool: sxToolLine, elapsed: sxElapsed, fallback: sxFallbackLine,
|
|
4789
6155
|
truth: sxTruth, settle: sxSettle, noteList: sxNoteList, noteGate: sxNoteGate, gateStale: sxGateStale, promptLine: sxPromptLine, parkNoteShows: sxParkNoteShows },
|
|
4790
6156
|
title: sxTitle, stateOf: sxState, stateLabel: function (s) { return sxStateLabel(s, sxState(s)); },
|
|
4791
|
-
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
|
|
4792
6158
|
};
|
|
4793
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
|
+
|
|
4794
6272
|
// Every pane module is a plain script after this one; boot waits for all of them.
|
|
4795
6273
|
function boot() {
|
|
4796
6274
|
var params = new URLSearchParams(location.search);
|
|
@@ -4799,7 +6277,7 @@
|
|
|
4799
6277
|
history.replaceState({}, document.title, location.pathname + location.hash);
|
|
4800
6278
|
api('/api/web-session/login', { method: 'POST', body: { code: linkCode } }).then(showShell)
|
|
4801
6279
|
.catch(function (err) {
|
|
4802
|
-
showAuth(err && err.status ? err.message :
|
|
6280
|
+
showAuth(err && err.status ? err.message : UNREACHABLE_COPY);
|
|
4803
6281
|
var box = $('auth-code'); if (box) box.value = linkCode;
|
|
4804
6282
|
});
|
|
4805
6283
|
return;
|
|
@@ -4811,7 +6289,7 @@
|
|
|
4811
6289
|
.catch(function (err) { showAuth(err && err.status ? err.message : 'That sign-in link did not work. Get a new one from COS Control on your Mac or the COS Glasses app, or use a code or the token.'); });
|
|
4812
6290
|
return;
|
|
4813
6291
|
}
|
|
4814
|
-
api('/api/web-session/status').then(function (st) { if (st && st.authenticated) showShell(st); else showAuth(); }).catch(function () { showAuth(
|
|
6292
|
+
api('/api/web-session/status').then(function (st) { if (st && st.authenticated) showShell(st); else showAuth(); }).catch(function () { showAuth(UNREACHABLE_COPY); });
|
|
4815
6293
|
}
|
|
4816
6294
|
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); else boot();
|
|
4817
6295
|
})();
|