@gotcos/glasses-server 6.98.1 → 6.99.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +227 -0
- package/package.json +2 -1
- package/server/index.ts +170 -11
- package/server/lib/api-auth.ts +31 -14
- package/server/lib/claude-bridge.ts +21 -2
- package/server/lib/codex-bridge.ts +21 -2
- package/server/lib/cos-thread.ts +582 -0
- package/server/lib/display-bus.ts +3 -0
- package/server/lib/drop-attachments.ts +55 -0
- package/server/lib/drop-destinations.ts +134 -0
- package/server/lib/drop-history-search.ts +97 -0
- package/server/lib/drop-p2-boot.ts +60 -0
- package/server/lib/drop-p2-runtime.ts +237 -0
- package/server/lib/drop-p2-settings.ts +174 -0
- package/server/lib/drop-resolution.ts +168 -0
- package/server/lib/drop-router.ts +325 -0
- package/server/lib/drop-service.ts +830 -0
- package/server/lib/drop-settings.ts +56 -0
- package/server/lib/drop-store.ts +450 -0
- package/server/lib/drop-when.ts +265 -0
- package/server/lib/glasses-pairing.ts +15 -0
- package/server/lib/learning-needs-you.ts +111 -0
- package/server/lib/live-cues-v2-settings.ts +2 -1
- package/server/lib/live-cues-v2.ts +6 -4
- package/server/lib/media-store.ts +80 -0
- package/server/lib/meeting-batch-transcribe.ts +15 -2
- package/server/lib/meeting-consolidation-run.ts +169 -0
- package/server/lib/meeting-store.ts +61 -0
- package/server/lib/meeting-title-steer.ts +200 -0
- package/server/lib/network-policy.ts +84 -6
- package/server/lib/permission-broker.ts +13 -0
- package/server/lib/phone-recording.ts +1 -1
- package/server/lib/ping-budget.ts +149 -0
- package/server/lib/ping-engine.ts +245 -0
- package/server/lib/ping-feed-sink.ts +33 -0
- package/server/lib/ping-sources.ts +192 -0
- package/server/lib/query-job-coordinator.ts +5 -2
- package/server/lib/query-job-runtime.ts +45 -4
- package/server/lib/query-job-types.ts +10 -1
- package/server/lib/reminder-scheduler.ts +153 -0
- package/server/lib/tailnet-self-name.ts +103 -0
- package/server/lib/tailscale-nudge.ts +40 -2
- package/server/lib/task-store.ts +32 -0
- package/server/lib/web-ask-admission.ts +56 -0
- package/server/lib/web-live-chunks.ts +273 -0
- package/server/lib/web-memories-bridge.ts +323 -0
- package/server/lib/web-session-auth.ts +492 -0
- package/server/lib/web-usage-bridge.ts +154 -0
- package/server/lib/work-search.ts +14 -1
- package/server/lib/your-move-count.ts +58 -0
- package/server/routes/cos-thread.ts +79 -0
- package/server/routes/display.ts +11 -1
- package/server/routes/drops.ts +262 -0
- package/server/routes/health.ts +16 -0
- package/server/routes/live-cues.ts +5 -4
- package/server/routes/meeting.ts +61 -5
- package/server/routes/permission-broker.ts +23 -3
- package/server/routes/transcribe-stream.ts +43 -4
- package/server/routes/web-control.ts +569 -0
- package/server/routes/welcome-context.ts +2 -1
- package/server/scripts/web-live-cls/cdp.mjs +57 -0
- package/server/scripts/web-live-cls/measure.mjs +80 -0
- package/server/scripts/web-live-cls/mock.mjs +97 -0
- package/server/scripts/web-live-cls/render.mjs +26 -0
- package/server/web-control/app.css +519 -0
- package/server/web-control/app.js +3643 -0
- package/server/web-control/control.css +325 -0
- package/server/web-control/fonts/DMSans.ttf +0 -0
- package/server/web-control/fonts/Fraunces.ttf +0 -0
- package/server/web-control/fonts/JetBrainsMono.ttf +0 -0
- package/server/web-control/index.html +275 -0
- package/server/web-control/mark.svg +1 -0
- package/server/web-control/memories/d3.min.js +2 -0
- package/server/web-control/memories/graph-explorer.css +200 -0
- package/server/web-control/memories/graph-explorer.js +748 -0
- package/server/web-control/memories/memories-app.js +1561 -0
- package/server/web-control/memories/memories-theme.css +73 -0
- package/server/web-control/memories/memories.html +72 -0
- package/server/web-control/memories/memory-stewardship.js +63 -0
- package/server/web-control/memories/memory-workspace.js +216 -0
- package/server/web-control/panes/cos-chat.css +174 -0
- package/server/web-control/panes/cos-chat.js +1118 -0
- package/server/web-control/panes/dictation.js +173 -0
- package/server/web-control/panes/meetings.css +314 -0
- package/server/web-control/panes/meetings.js +2568 -0
- package/server/web-control/panes/messages.js +718 -0
- package/server/web-control/panes/questions.css +32 -0
- package/server/web-control/panes/questions.js +275 -0
- package/server/web-control/panes/sessions.css +452 -0
- package/server/web-control/panes/settings.js +292 -0
- package/server/web-control/panes/speakers.css +269 -0
- package/server/web-control/panes/speakers.js +2755 -0
- package/server/web-control/panes/threads.js +200 -0
- package/server/web-control/panes/work.css +261 -0
- package/server/web-control/panes/work.js +1667 -0
- package/server/web-control/shell.js +294 -0
- package/server/web-control/theme.js +25 -0
- package/server/web-control/vendor/qrcode-encoder.js +10 -0
|
@@ -0,0 +1,3643 @@
|
|
|
1
|
+
/* COS Control Web. One script, no framework, no inline handlers (the page CSP is script-src 'self').
|
|
2
|
+
Auth is the HttpOnly cos_web_session cookie only: this page never holds the pairing token.
|
|
3
|
+
Every server string reaches the DOM through esc(). Routes live in location.hash so the
|
|
4
|
+
phone's back gesture and the browser's back button both work. */
|
|
5
|
+
(function () {
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
// ── utilities ───────────────────────────────────────────
|
|
9
|
+
var $ = function (id) { return document.getElementById(id); };
|
|
10
|
+
function esc(s) {
|
|
11
|
+
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
|
|
12
|
+
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
// Agent turns and task text are markdown. Escape first, then style a small subset, so the
|
|
16
|
+
// result can never contain markup the server sent.
|
|
17
|
+
function md(text) {
|
|
18
|
+
return esc(text).split('\n').map(function (line) {
|
|
19
|
+
var l = line
|
|
20
|
+
.replace(/`([^`\n]+)`/g, '<code>$1</code>')
|
|
21
|
+
.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>');
|
|
22
|
+
if (/^#{1,4}\s+/.test(l)) return '<strong>' + l.replace(/^#{1,4}\s+/, '') + '</strong>';
|
|
23
|
+
if (/^>\s?/.test(l)) return '<span class="quote">' + l.replace(/^>\s?/, '') + '</span>';
|
|
24
|
+
if (/^\s*[-*]\s+/.test(l)) return l.replace(/^(\s*)[-*]\s+/, '$1• ');
|
|
25
|
+
return l;
|
|
26
|
+
}).join('\n');
|
|
27
|
+
}
|
|
28
|
+
function plain(text) { return String(text == null ? '' : text).replace(/\*\*|`/g, '').replace(/^#{1,4}\s+/, ''); }
|
|
29
|
+
var WORDS = ['No', 'One', 'Two', 'Three', 'Four', 'Five', 'Six', 'Seven', 'Eight', 'Nine', 'Ten'];
|
|
30
|
+
function countWord(n, lower) { var w = n >= 0 && n <= 10 ? WORDS[n] : String(n); return lower ? w.toLowerCase() : w; }
|
|
31
|
+
function plural(n, one, many) { return n === 1 ? one : many; }
|
|
32
|
+
function toDate(v) {
|
|
33
|
+
if (v == null || v === '') return null;
|
|
34
|
+
var d = typeof v === 'number' || /^\d{10,}$/.test(String(v)) ? new Date(Number(v)) : new Date(v);
|
|
35
|
+
return isNaN(d.getTime()) ? null : d;
|
|
36
|
+
}
|
|
37
|
+
function ago(v) {
|
|
38
|
+
var d = toDate(v); if (!d) return '';
|
|
39
|
+
var s = Math.max(0, (Date.now() - d.getTime()) / 1000);
|
|
40
|
+
if (s < 60) return 'now';
|
|
41
|
+
if (s < 3600) return Math.round(s / 60) + ' min';
|
|
42
|
+
if (s < 86400) return Math.round(s / 3600) + ' h';
|
|
43
|
+
if (s < 172800) return 'Yesterday';
|
|
44
|
+
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
|
45
|
+
}
|
|
46
|
+
function span(v) {
|
|
47
|
+
var d = toDate(v); if (!d) return '';
|
|
48
|
+
var m = Math.max(1, Math.round((Date.now() - d.getTime()) / 60000));
|
|
49
|
+
if (m < 60) return m + ' min';
|
|
50
|
+
var h = Math.floor(m / 60);
|
|
51
|
+
return h < 48 ? h + ' h' : Math.round(h / 24) + ' days';
|
|
52
|
+
}
|
|
53
|
+
function clock(d) { return d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }); }
|
|
54
|
+
function dayWord(d) {
|
|
55
|
+
var today = new Date(); var t0 = new Date(today.getFullYear(), today.getMonth(), today.getDate()).getTime();
|
|
56
|
+
var diff = Math.round((new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime() - t0) / 86400000);
|
|
57
|
+
if (diff === 0) return 'today';
|
|
58
|
+
if (diff === 1) return 'tomorrow';
|
|
59
|
+
return d.toLocaleDateString(undefined, { weekday: 'long' });
|
|
60
|
+
}
|
|
61
|
+
var PROVIDERS = { claude: 'Claude Code', codex: 'Codex', cursor: 'Cursor', glasses: 'Glasses', cos: 'COS' };
|
|
62
|
+
function providerLabel(p) { return PROVIDERS[p] || (p ? p.charAt(0).toUpperCase() + p.slice(1) : 'Session'); }
|
|
63
|
+
function badge(p) { var cls = PROVIDERS[p] ? (p === 'cos' ? 'glasses' : p) : 'work'; return '<span class="badge ' + esc(cls) + '">' + esc(providerLabel(p)) + '</span>'; }
|
|
64
|
+
var DOMAINS = { quilt: 'Quilt', sprocket_rocket: 'Sprocket Rocket', hermit_crabs: 'Hermit Crabs', personal: 'Personal' };
|
|
65
|
+
function domainLabel(d) { return DOMAINS[d] || d || ''; }
|
|
66
|
+
function sessionTitle(s) {
|
|
67
|
+
var t = s.display_label || s.custom_title || s.title || s.first_prompt || '';
|
|
68
|
+
t = plain(t).replace(/\s+/g, ' ').trim();
|
|
69
|
+
return t || 'Untitled session';
|
|
70
|
+
}
|
|
71
|
+
function splitSessionId(provider, id) {
|
|
72
|
+
var raw = String(id || '');
|
|
73
|
+
var m = /^(claude|codex|cursor):(.+)$/.exec(raw);
|
|
74
|
+
return m ? { provider: m[1], id: m[2] } : { provider: provider || 'claude', id: raw };
|
|
75
|
+
}
|
|
76
|
+
function skeletonRows(n) {
|
|
77
|
+
var out = '';
|
|
78
|
+
for (var i = 0; i < n; i++) out += '<div class="skel-row"><div class="skeleton" style="width:' + (62 + (i * 13) % 30) + '%"></div><div class="skeleton" style="width:' + (30 + (i * 7) % 25) + '%"></div></div>';
|
|
79
|
+
return '<div class="list">' + out + '</div>';
|
|
80
|
+
}
|
|
81
|
+
function emptyBox(title, body) { return '<div class="list"><p class="empty"><strong>' + esc(title) + '</strong>' + esc(body || '') + '</p></div>'; }
|
|
82
|
+
var toastTimer = null;
|
|
83
|
+
function toast(text) {
|
|
84
|
+
var el = $('toast'); el.textContent = text; el.classList.remove('hidden');
|
|
85
|
+
clearTimeout(toastTimer); toastTimer = setTimeout(function () { el.classList.add('hidden'); }, 3200);
|
|
86
|
+
}
|
|
87
|
+
function setStatus(el, text, isError) { if (!el) return; el.textContent = text || ''; el.classList.toggle('error', !!isError); }
|
|
88
|
+
function autoGrow(ta) { ta.style.height = 'auto'; ta.style.height = Math.min(ta.scrollHeight, 140) + 'px'; }
|
|
89
|
+
|
|
90
|
+
// ── API ─────────────────────────────────────────────────
|
|
91
|
+
function api(path, opts) {
|
|
92
|
+
var o = opts || {};
|
|
93
|
+
var headers = { 'X-COS-Web': '1' };
|
|
94
|
+
var body;
|
|
95
|
+
if (o.body !== undefined) { headers['Content-Type'] = 'application/json'; body = JSON.stringify(o.body); }
|
|
96
|
+
// Reads give up after 20 s (a sleeping or busy Mac must not leave a reader on its skeleton
|
|
97
|
+
// forever); a caller can pass timeoutMs. Writes wait unless they ask, so a slow save is not
|
|
98
|
+
// reported as failed while it may still land.
|
|
99
|
+
var method = o.method || 'GET';
|
|
100
|
+
var limit = o.timeoutMs != null ? o.timeoutMs : (method === 'GET' ? API_READ_TIMEOUT_MS : 0);
|
|
101
|
+
var ctrl = limit && window.AbortController ? new AbortController() : null;
|
|
102
|
+
var timer = ctrl ? setTimeout(function () { ctrl.abort(); }, limit) : null;
|
|
103
|
+
return fetch(path, { method: method, headers: headers, credentials: 'same-origin', body: body, signal: ctrl ? ctrl.signal : undefined }).then(function (res) {
|
|
104
|
+
if (timer) clearTimeout(timer);
|
|
105
|
+
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; }
|
|
106
|
+
return res.text().then(function (text) {
|
|
107
|
+
var data = {};
|
|
108
|
+
try { data = text ? JSON.parse(text) : {}; } catch (_e) { data = {}; }
|
|
109
|
+
if (!res.ok) {
|
|
110
|
+
var msg = (data && (data.message || (data.error && data.error.message) || data.error)) || ('The server answered ' + res.status + '.');
|
|
111
|
+
var err = new Error(typeof msg === 'string' ? msg : 'The server answered ' + res.status + '.');
|
|
112
|
+
err.status = res.status;
|
|
113
|
+
// The machine-readable reason, so a pane can say the right thing for a refusal whose
|
|
114
|
+
// body carries a code and no sentence (for example work-intake's {error: {code}}).
|
|
115
|
+
var code = data && ((data.error && typeof data.error === 'object' && data.error.code) || data.code || data.reason || (typeof data.error === 'string' ? data.error : ''));
|
|
116
|
+
if (code) err.code = String(code);
|
|
117
|
+
throw err;
|
|
118
|
+
}
|
|
119
|
+
return data;
|
|
120
|
+
});
|
|
121
|
+
}, function (err) {
|
|
122
|
+
if (timer) clearTimeout(timer);
|
|
123
|
+
if (err && err.name === 'AbortError' && ctrl && ctrl.signal.aborted) {
|
|
124
|
+
var late = new Error('Your Mac did not answer in ' + Math.round(limit / 1000) + ' seconds. Check that it is awake, then try again.');
|
|
125
|
+
late.code = 'timeout'; throw late;
|
|
126
|
+
}
|
|
127
|
+
throw err;
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
var API_READ_TIMEOUT_MS = 20000;
|
|
131
|
+
|
|
132
|
+
// ── state ───────────────────────────────────────────────
|
|
133
|
+
var state = {
|
|
134
|
+
pane: 'today', serverName: '', tailnetUrl: '',
|
|
135
|
+
sessions: [], sessionsMode: 'words', workTasks: [], workOpenStages: { complete: false },
|
|
136
|
+
stream: null, memoriesLoaded: false, lastLoad: {}, livePoll: null
|
|
137
|
+
};
|
|
138
|
+
// Control's order (ActivitySection.visibleSections): Today, then Messages to Dictation. Usage is a
|
|
139
|
+
// view inside Sessions on the Mac; its route stays so old links work, and the rail marks Sessions.
|
|
140
|
+
var ROOT_PANES = ['today', 'messages', 'speakers', 'meetings', 'memories', 'threads', 'sessions', 'work', 'dictation', 'usage', 'settings'];
|
|
141
|
+
var TITLES = { today: 'Today', messages: 'Messages', speakers: 'Speakers', meetings: 'Meetings', memories: 'Memories', threads: 'Threads', sessions: 'Sessions', work: 'Work', dictation: 'Dictation', usage: 'Usage', settings: 'Settings' };
|
|
142
|
+
// Pane modules (panes/*.js) register here: { load(args, ctx), leave(), up(args) }. A registered
|
|
143
|
+
// pane owns its section; route() only shows it and hands it the hash arguments.
|
|
144
|
+
var PANES = {};
|
|
145
|
+
var routeHooks = [];
|
|
146
|
+
var shellHooks = [];
|
|
147
|
+
var crumbDetail = '';
|
|
148
|
+
|
|
149
|
+
// ── routing ─────────────────────────────────────────────
|
|
150
|
+
function parseRoute() {
|
|
151
|
+
var parts = (location.hash || '').replace(/^#\/?/, '').split('/').map(function (p) { try { return decodeURIComponent(p); } catch (_e) { return p; } });
|
|
152
|
+
var pane = ROOT_PANES.indexOf(parts[0]) >= 0 ? parts[0] : 'today';
|
|
153
|
+
return { pane: pane, args: parts.slice(1).filter(function (p) { return p !== ''; }) };
|
|
154
|
+
}
|
|
155
|
+
function go(pane, args) {
|
|
156
|
+
var hash = '#/' + [pane].concat(args || []).map(encodeURIComponent).join('/');
|
|
157
|
+
if (location.hash === hash) route(); else location.hash = hash;
|
|
158
|
+
}
|
|
159
|
+
function isPhone() { return !window.matchMedia('(min-width: 600px)').matches; }
|
|
160
|
+
function route() {
|
|
161
|
+
var r = parseRoute();
|
|
162
|
+
var changedPane = r.pane !== state.pane || !document.querySelector('#pane-' + r.pane + ':not(.hidden)');
|
|
163
|
+
if (state.pane !== r.pane && PANES[state.pane] && PANES[state.pane].leave) { try { PANES[state.pane].leave(); } catch (err) { console.error(err); } }
|
|
164
|
+
state.pane = r.pane;
|
|
165
|
+
crumbDetail = '';
|
|
166
|
+
document.querySelectorAll('.pane').forEach(function (sec) { sec.classList.toggle('hidden', sec.id !== 'pane-' + r.pane); });
|
|
167
|
+
document.querySelectorAll('#section-menu [data-goto]').forEach(function (b) { if (b.getAttribute('data-goto') === r.pane) b.setAttribute('aria-current', 'page'); else b.removeAttribute('aria-current'); });
|
|
168
|
+
var railPane = r.pane === 'usage' ? 'sessions' : r.pane;
|
|
169
|
+
document.querySelectorAll('.rail-btn,.rail-settings').forEach(function (b) { if (b.getAttribute('data-pane') === railPane) b.setAttribute('aria-current', 'page'); else b.removeAttribute('aria-current'); });
|
|
170
|
+
if (state.stream && !(r.pane === 'sessions' && r.args.length === 2)) { state.stream.close(); state.stream = null; }
|
|
171
|
+
if (!(r.pane === 'sessions' && r.args.length === 2)) { clearInterval(state.turnPoll); if (state.reading) clearInterval(state.reading.heldTimer); state.reading = null; }
|
|
172
|
+
if (r.pane !== 'meetings' && state.livePoll) { clearInterval(state.livePoll); state.livePoll = null; }
|
|
173
|
+
|
|
174
|
+
var reading = r.args.length > 0;
|
|
175
|
+
var paneEl = $('pane-' + r.pane);
|
|
176
|
+
if (paneEl) paneEl.classList.toggle('reading', reading);
|
|
177
|
+
var split = paneEl && paneEl.querySelector('.split');
|
|
178
|
+
if (split) split.classList.toggle('reading', reading);
|
|
179
|
+
|
|
180
|
+
// Top bar (phone): the section switcher at a list, a back button on a reader.
|
|
181
|
+
$('topbar-back').classList.toggle('hidden', !reading);
|
|
182
|
+
$('section-btn').classList.toggle('hidden', reading);
|
|
183
|
+
$('topbar-back-label').textContent = TITLES[r.pane] || 'Back';
|
|
184
|
+
$('section-name').textContent = TITLES[r.pane] || 'Today';
|
|
185
|
+
closeMenu();
|
|
186
|
+
if (changedPane) window.scrollTo(0, 0);
|
|
187
|
+
$('topbar').classList.remove('tucked');
|
|
188
|
+
onScroll();
|
|
189
|
+
|
|
190
|
+
if (PANES[r.pane]) { try { PANES[r.pane].load(r.args, { changed: changedPane }); } catch (err) { console.error(err); } }
|
|
191
|
+
else if (r.pane === 'today') loadToday();
|
|
192
|
+
else if (r.pane === 'sessions') { if (!reading || !state.sessions.length || changedPane) loadSessions(); if (reading) openSession(r.args[0], r.args[1]); }
|
|
193
|
+
else if (r.pane === 'memories') loadMemories();
|
|
194
|
+
else if (r.pane === 'usage') loadUsage(false);
|
|
195
|
+
state.lastLoad[r.pane] = Date.now();
|
|
196
|
+
runHooks(routeHooks, r);
|
|
197
|
+
}
|
|
198
|
+
function runHooks(list, arg) { list.forEach(function (fn) { try { fn(arg); } catch (err) { console.error(err); } }); }
|
|
199
|
+
// Back unwinds one step, the way Control's Back does: a pane module may name the step above a
|
|
200
|
+
// nested reader (an archive chat goes back to its day, not to the list).
|
|
201
|
+
function goBack() {
|
|
202
|
+
var r = parseRoute();
|
|
203
|
+
var up = PANES[r.pane] && PANES[r.pane].up ? PANES[r.pane].up(r.args) : null;
|
|
204
|
+
if (up) go(r.pane, up);
|
|
205
|
+
else if (r.args.length) go(r.pane);
|
|
206
|
+
else go('today');
|
|
207
|
+
}
|
|
208
|
+
// On the closed Duo every vertical pixel counts: the top bar slides away while you scroll
|
|
209
|
+
// down and comes back the moment you scroll up.
|
|
210
|
+
var lastY = 0;
|
|
211
|
+
function onScroll() {
|
|
212
|
+
var y = Math.max(0, window.scrollY || 0);
|
|
213
|
+
var bar = $('topbar');
|
|
214
|
+
bar.classList.toggle('scrolled', y > 8);
|
|
215
|
+
if (y < 60 || y < lastY - 4) bar.classList.remove('tucked');
|
|
216
|
+
else if (y > lastY + 4 && isPhone()) bar.classList.add('tucked');
|
|
217
|
+
lastY = y;
|
|
218
|
+
}
|
|
219
|
+
function openMenu() { $('section-menu').classList.remove('hidden'); var cur = document.querySelector('#section-menu [aria-current="page"]') || document.querySelector('#section-menu [data-goto]'); if (cur) cur.focus(); }
|
|
220
|
+
function closeMenu() { $('section-menu').classList.add('hidden'); }
|
|
221
|
+
|
|
222
|
+
// ── auth ────────────────────────────────────────────────
|
|
223
|
+
function showAuth(message) {
|
|
224
|
+
var alreadyOpen = !$('auth-gate').classList.contains('hidden');
|
|
225
|
+
$('app-shell').classList.add('hidden');
|
|
226
|
+
$('auth-gate').classList.remove('hidden');
|
|
227
|
+
// A later 401 from a pane still polling (W.api passes no message) must not blank what the gate already says:
|
|
228
|
+
// the reason it opened, or "That code did not match" while the person retypes.
|
|
229
|
+
if (!message && alreadyOpen) return;
|
|
230
|
+
setStatus($('auth-error'), message || '', true);
|
|
231
|
+
$('auth-error').classList.toggle('hidden', !message);
|
|
232
|
+
}
|
|
233
|
+
function showShell(info) {
|
|
234
|
+
$('auth-gate').classList.add('hidden');
|
|
235
|
+
$('app-shell').classList.remove('hidden');
|
|
236
|
+
state.serverName = (info && info.serverName) || 'Your Mac';
|
|
237
|
+
$('host-name').textContent = state.serverName.replace(/-/g, ' ');
|
|
238
|
+
loadTailnetUrl();
|
|
239
|
+
runHooks(shellHooks, info);
|
|
240
|
+
route();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ── Today ───────────────────────────────────────────────
|
|
244
|
+
function loadToday() {
|
|
245
|
+
var now = new Date();
|
|
246
|
+
$('today-date').textContent = now.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' }) + ', ' + clock(now);
|
|
247
|
+
Promise.all([
|
|
248
|
+
api('/api/agent-sessions?limit=40&sort=updated').catch(function () { return null; }),
|
|
249
|
+
api('/api/work-board/your-move').catch(function () { return null; }),
|
|
250
|
+
api('/api/welcome-context').catch(function () { return null; }),
|
|
251
|
+
api('/api/meetings/live-chunks').catch(function () { return null; })
|
|
252
|
+
]).then(function (res) {
|
|
253
|
+
var sessions = (res[0] && res[0].sessions) || [];
|
|
254
|
+
state.sessions = sessions;
|
|
255
|
+
var needs = (res[1] && res[1].items) || [];
|
|
256
|
+
var welcome = res[2] || {};
|
|
257
|
+
var live = (res[3] && res[3].sessions) || [];
|
|
258
|
+
var working = sessions.filter(function (s) { return s.running_active; });
|
|
259
|
+
var open = sessions.filter(function (s) { return s.running && !s.running_active; });
|
|
260
|
+
var recent = live.filter(function (s) { return Date.now() - (s.mtimeMs || 0) < LIVE_FRESH_MS; });
|
|
261
|
+
var offline = !res[0] && !res[1];
|
|
262
|
+
|
|
263
|
+
renderStory(needs.length, working.length, open.length, welcome.nextEvent, false, offline);
|
|
264
|
+
renderRunning(working, open);
|
|
265
|
+
renderNeeds(needs);
|
|
266
|
+
renderNext(welcome);
|
|
267
|
+
loadTodayIntake();
|
|
268
|
+
setBadge('today', needs.length > 0);
|
|
269
|
+
setBadge('sessions', working.length > 0);
|
|
270
|
+
liveSession(recent).then(function (rec) {
|
|
271
|
+
renderRecording(rec);
|
|
272
|
+
setBadge('meetings', !!rec);
|
|
273
|
+
if (rec) renderStory(needs.length, working.length, open.length, welcome.nextEvent, true, offline);
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
// A capture counts as live only while it is still being written: an abandoned
|
|
278
|
+
// active-sessions file must never read as "Recording now".
|
|
279
|
+
var LIVE_FRESH_MS = 5 * 60000;
|
|
280
|
+
var SOURCES = { mac: 'Mac recording', phone: 'iPhone recording', meeting: 'Glasses recording' };
|
|
281
|
+
function liveSession(list) {
|
|
282
|
+
if (!list || !list.length) return Promise.resolve(null);
|
|
283
|
+
var top = list[0];
|
|
284
|
+
return api('/api/meetings/live-chunks?sessionId=' + encodeURIComponent(top.sessionId) + '&since=1000000').then(function (r) {
|
|
285
|
+
var read = r && r.read;
|
|
286
|
+
if (!read || read.ended || Date.now() - Number(read.lastActivityAt || 0) > LIVE_FRESH_MS) return null;
|
|
287
|
+
var prefix = String(top.sessionId).split('_')[0];
|
|
288
|
+
return { sessionId: top.sessionId, source: SOURCES[prefix] || 'Recording', startTime: read.startTime, lines: (read.maxIndex || 0) + 1 };
|
|
289
|
+
}).catch(function () { return null; });
|
|
290
|
+
}
|
|
291
|
+
function renderRecording(rec) {
|
|
292
|
+
var box = $('today-rec');
|
|
293
|
+
if (!rec) { box.innerHTML = ''; return; }
|
|
294
|
+
box.innerHTML = '<button type="button" class="rec-banner" data-goto="meetings"><span class="rec-dot"></span>' +
|
|
295
|
+
'<span class="rec-copy"><strong>Recording now</strong><span>' + esc(rec.source + (rec.startTime ? ', ' + span(rec.startTime) + ' so far' : '')) + '</span></span>' +
|
|
296
|
+
'<span class="btn small">Live view</span></button>';
|
|
297
|
+
}
|
|
298
|
+
function renderStory(needs, working, open, next, recording, offline) {
|
|
299
|
+
var el = $('today-story');
|
|
300
|
+
el.className = 'story';
|
|
301
|
+
if (offline) { el.textContent = 'COS on ' + state.serverName + ' is not answering right now. Today fills in when it does.'; return; }
|
|
302
|
+
// Quiet hours (Control 0.5.361, and the rail's line): after 6 PM and at weekends what is
|
|
303
|
+
// waiting can wait until tomorrow, so Today says so instead of "waiting on you".
|
|
304
|
+
var nowD = new Date(), quiet = nowD.getDay() === 0 || nowD.getDay() === 6 || nowD.getHours() >= 18;
|
|
305
|
+
var first = needs > 0
|
|
306
|
+
? (quiet ? countWord(needs) + ' ' + plural(needs, 'handoff', 'handoffs') + ' can wait until tomorrow'
|
|
307
|
+
: countWord(needs) + ' ' + plural(needs, 'handoff is', 'handoffs are') + ' waiting on you')
|
|
308
|
+
: 'Nothing is waiting on you';
|
|
309
|
+
var nextBit = '';
|
|
310
|
+
var nd = next && toDate(next.time);
|
|
311
|
+
if (nd && next.title && (nd.getTime() - Date.now()) < 18 * 3600000 && nd.getTime() > Date.now() - 600000) {
|
|
312
|
+
var dw = dayWord(nd);
|
|
313
|
+
nextBit = ', and ' + next.title + ' is at ' + clock(nd) + (dw === 'today' ? '' : ' ' + dw);
|
|
314
|
+
}
|
|
315
|
+
var second;
|
|
316
|
+
if (working > 0) second = countWord(working) + ' ' + plural(working, 'agent is', 'agents are') + ' working' + (open > 0 ? ', with ' + countWord(open, true) + ' more ' + plural(open, 'session', 'sessions') + ' open' : '');
|
|
317
|
+
else if (open > 0) second = countWord(open) + ' ' + plural(open, 'session is', 'sessions are') + ' open and quiet';
|
|
318
|
+
else second = 'No agents are running';
|
|
319
|
+
if (recording) second += (working > 0 || open > 0 ? ', and ' : '. ') + 'COS is recording a meeting';
|
|
320
|
+
el.textContent = first + nextBit + '. ' + second + '.';
|
|
321
|
+
}
|
|
322
|
+
function runRow(s, activeNow) {
|
|
323
|
+
var key = splitSessionId(s.provider, s.session_id);
|
|
324
|
+
var meta = activeNow
|
|
325
|
+
? 'Working for ' + span(s.state_since || s.last_activity_at)
|
|
326
|
+
: 'Open, quiet for ' + span(s.last_activity_at || s.state_since);
|
|
327
|
+
if (s.project && s.project !== usualProject()) meta += ', in ' + String(s.project).replace(/^-Users-[^-]+-/, '');
|
|
328
|
+
return '<button type="button" class="run-row" data-session="' + esc(key.provider) + '|' + esc(key.id) + '">' +
|
|
329
|
+
'<span class="run-who">' + badge(key.provider) + (activeNow ? '<span class="pulse" aria-label="working"></span>' : '') + '</span>' +
|
|
330
|
+
'<span class="run-title">' + esc(sessionTitle(s)) + '</span>' +
|
|
331
|
+
'<span class="run-meta">' + esc(meta) + '</span></button>';
|
|
332
|
+
}
|
|
333
|
+
function renderRunning(working, open) {
|
|
334
|
+
$('run-count').textContent = working.length ? String(working.length) : '';
|
|
335
|
+
var list = $('run-list');
|
|
336
|
+
var shown = working.slice(0, 3);
|
|
337
|
+
var html = shown.map(function (s) { return runRow(s, true); }).join('');
|
|
338
|
+
if (!shown.length) {
|
|
339
|
+
html = open.slice(0, 2).map(function (s) { return runRow(s, false); }).join('');
|
|
340
|
+
if (!html) html = '<div class="run-empty">Nothing running. Start a session on your Mac and it shows here.</div>';
|
|
341
|
+
}
|
|
342
|
+
var more = (working.length - shown.length) + (shown.length ? open.length : Math.max(0, open.length - 2));
|
|
343
|
+
if (more > 0) html += '<button type="button" class="run-more" data-goto="sessions">' + countWord(more) + ' more open. All sessions</button>';
|
|
344
|
+
list.innerHTML = html;
|
|
345
|
+
}
|
|
346
|
+
function needWhy(item) {
|
|
347
|
+
var p = item.progress || {};
|
|
348
|
+
if (p.reported === 'needsInput' || item.waitingInApp) return 'Waiting for your reply';
|
|
349
|
+
if (p.reported === 'blocked') return 'Blocked';
|
|
350
|
+
if (item.status === 'delivered') return 'Handed to you';
|
|
351
|
+
return 'Needs a look';
|
|
352
|
+
}
|
|
353
|
+
function renderNeeds(items) {
|
|
354
|
+
$('needs-count').textContent = items.length ? String(items.length) : '';
|
|
355
|
+
var list = $('needs-list');
|
|
356
|
+
if (!items.length) { list.innerHTML = '<p class="card-empty">You are clear. New handoffs from your agents land here.</p>'; return; }
|
|
357
|
+
// One primary action, on the item that has waited longest for a reply; the rest stay quiet.
|
|
358
|
+
list.innerHTML = items.slice(0, 6).map(function (it, i) {
|
|
359
|
+
var key = splitSessionId(it.provider, it.sessionId);
|
|
360
|
+
var title = it.sessionTitle || it.title || 'Handoff';
|
|
361
|
+
var why = needWhy(it) + (it.domain ? ' in ' + domainLabel(it.domain) : '');
|
|
362
|
+
return '<div class="need-row">' +
|
|
363
|
+
'<div class="need-top"><span class="need-title">' + esc(title) + '</span><span class="need-age">' + esc(ago(it.updatedAt || it.createdAt)) + '</span></div>' +
|
|
364
|
+
'<div class="need-foot">' + esc(why) + '</div>' +
|
|
365
|
+
(key.id ? '<div class="need-actions"><button type="button" class="btn small' + (i === 0 ? ' primary' : '') + '" data-session="' + esc(key.provider) + '|' + esc(key.id) + '">Open session</button></div>' : '') +
|
|
366
|
+
'</div>';
|
|
367
|
+
}).join('');
|
|
368
|
+
}
|
|
369
|
+
function renderNext(w) {
|
|
370
|
+
var card = $('next-card');
|
|
371
|
+
var n = w && w.nextEvent; var d = n && toDate(n.time);
|
|
372
|
+
if (!d || !n.title) { card.classList.add('hidden'); return; }
|
|
373
|
+
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] : '') : '';
|
|
374
|
+
var dw = dayWord(d);
|
|
375
|
+
$('next-body').innerHTML = '<span class="when">' + esc(clock(d)) + '</span><span><span class="what">' + esc(n.title) + '</span>' +
|
|
376
|
+
'<span class="sub" style="display:block">' + esc((dw.charAt(0).toUpperCase() + dw.slice(1)) + (weather ? '. Now ' + weather : '')) + '</span></span>';
|
|
377
|
+
card.classList.remove('hidden');
|
|
378
|
+
}
|
|
379
|
+
// From today's meetings: Work's suggestions from meetings held today. They stay here until
|
|
380
|
+
// midnight, then join Sort in Work; Review opens them in Work's Sort.
|
|
381
|
+
function localDay(d) { return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); }
|
|
382
|
+
function loadTodayIntake() {
|
|
383
|
+
var card = $('intake-card'); if (!card) return;
|
|
384
|
+
api('/api/work-intake').then(function (d) {
|
|
385
|
+
var today = localDay(new Date());
|
|
386
|
+
var items = ((d && d.items) || []).filter(function (it) {
|
|
387
|
+
if (['suggested', 'review', 'ask'].indexOf(it.status) < 0) return false;
|
|
388
|
+
var day = String((it.meeting && (it.meeting.filename || it.meeting.date)) || '').match(/\d{4}-\d{2}-\d{2}/);
|
|
389
|
+
return day && day[0] === today;
|
|
390
|
+
});
|
|
391
|
+
if (!items.length) { card.classList.add('hidden'); return; }
|
|
392
|
+
var meetings = {};
|
|
393
|
+
items.forEach(function (it) { var t = (it.meeting && it.meeting.title) || 'A meeting'; meetings[t] = (meetings[t] || 0) + 1; });
|
|
394
|
+
$('intake-count').textContent = String(items.length);
|
|
395
|
+
$('intake-body').innerHTML = Object.keys(meetings).slice(0, 4).map(function (t) {
|
|
396
|
+
return '<div class="intake-row"><span class="intake-meeting">' + esc(t) + '</span><span class="intake-n">' + meetings[t] + '</span></div>';
|
|
397
|
+
}).join('') + '<p class="intake-foot">Moves to Work at midnight.</p>';
|
|
398
|
+
card.classList.remove('hidden');
|
|
399
|
+
}, function () { card.classList.add('hidden'); });
|
|
400
|
+
}
|
|
401
|
+
function setBadge(name, on) { document.querySelectorAll('[data-badge="' + name + '"]').forEach(function (b) { b.classList.toggle('hidden', !on); }); }
|
|
402
|
+
|
|
403
|
+
// ── Sessions ────────────────────────────────────────────
|
|
404
|
+
// Control's Sessions pane (ActivityWindow.swift sessionsList 2288-2366, sessionRow 2773-2865,
|
|
405
|
+
// sessionsStatus 2911-2933; Models.swift ClaudeSession 1762-2150): one flat list on a clock
|
|
406
|
+
// (Updated, Opened, Pinned), the ON DISK / WAITING / PINNED strip, and per row the provider
|
|
407
|
+
// badge, PINNED, the state chip, "Updated <time>" and, for twins, "Opened <date>". The pane holds
|
|
408
|
+
// three views, Sessions | Usage | COS cost, as on the Mac. Data: GET /api/agent-sessions and
|
|
409
|
+
// /api/agent-sessions/search. The Mac's own enrichment (Desktop titles, scheduled jobs, the
|
|
410
|
+
// jobs-today list) has no server route, so it is not here.
|
|
411
|
+
var SX_PREFS = 'cos_web_sessions';
|
|
412
|
+
function sxPref(key, fallback, allowed) { var v = readStore(SX_PREFS)[key]; return allowed.indexOf(v) >= 0 ? v : fallback; }
|
|
413
|
+
// Usage's own choices ride along once they have been read, so an early save never writes defaults over them.
|
|
414
|
+
function sxSave() {
|
|
415
|
+
var v = readStore(SX_PREFS);
|
|
416
|
+
v.clock = SX.clock; v.view = SX.view; v.recency = SX.recency;
|
|
417
|
+
if (SX.uPrefsLoaded) { v.uPeriod = SX.uPeriod; v.uFrom = SX.uFrom; v.uTo = SX.uTo; v.uMeasure = SX.uMeasure; v.uSeries = SX.uSeries; v.uScale = SX.uScale; v.uLines = SX.uLines; }
|
|
418
|
+
writeStore(SX_PREFS, v);
|
|
419
|
+
}
|
|
420
|
+
var SX = {
|
|
421
|
+
rows: null, dropped: null, enabled: true, loading: false, error: '', loadedAt: 0,
|
|
422
|
+
clock: sxPref('clock', 'updated', ['updated', 'opened', 'pinned']),
|
|
423
|
+
view: sxPref('view', 'sessions', ['sessions', 'usage', 'cost']),
|
|
424
|
+
recency: sxPref('recency', 'newest', ['newest', 'oldest', 'match']),
|
|
425
|
+
q: '', instant: [], remote: [], searching: false, searchError: '', ranFor: '', semantic: null, semanticReason: '', searchSeq: 0, searchTimer: null,
|
|
426
|
+
hooks: null, hooksAt: 0,
|
|
427
|
+
usage: null, usageAt: 0, usageLoading: false, usageError: '',
|
|
428
|
+
uPeriod: 'Last 30 days', uTool: 'All tools', uModel: 'All models',
|
|
429
|
+
// Usage on a wide screen (uWideModel): the custom range, the chart sentence, the chosen bar, the table's order.
|
|
430
|
+
uFrom: '', uTo: '', uMeasure: 'all', uSeries: 'tool', uScale: '', uScaleBeforeHistory: '', uDayJump: false, uLines: false,
|
|
431
|
+
uInspect: null, uPinned: null, uSort: 'tokens', uSortAsc: false, uOpen: {}, uCols: null, uToolBefore: null, uM: null
|
|
432
|
+
};
|
|
433
|
+
var SX_VIEWS = { sessions: 'Sessions', usage: 'Usage', cost: 'COS cost' };
|
|
434
|
+
|
|
435
|
+
function sxProvider(p) { return p === 'codex' || p === 'cursor' ? p : 'claude'; }
|
|
436
|
+
function sxProviderLabel(p) { p = sxProvider(p); return p === 'codex' ? 'Codex' : p === 'cursor' ? 'Cursor' : 'Claude'; }
|
|
437
|
+
// Each platform's own mark, the same SVGs COS Control draws (Resources/mark-claude.svg, mark-codex.svg,
|
|
438
|
+
// mark-cursor.svg): Claude, OpenAI and Cursor. One filled path each, tinted by the tile's provider color.
|
|
439
|
+
var SX_GLYPHS = {
|
|
440
|
+
claude: '<path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z"/>',
|
|
441
|
+
codex: '<path d="M9.205 8.658v-2.26c0-.19.072-.333.238-.428l4.543-2.616c.619-.357 1.356-.523 2.117-.523 2.854 0 4.662 2.212 4.662 4.566 0 .167 0 .357-.024.547l-4.71-2.759a.797.797 0 00-.856 0l-5.97 3.473zm10.609 8.8V12.06c0-.333-.143-.57-.429-.737l-5.97-3.473 1.95-1.118a.433.433 0 01.476 0l4.543 2.617c1.309.76 2.189 2.378 2.189 3.948 0 1.808-1.07 3.473-2.76 4.163zM7.802 12.703l-1.95-1.142c-.167-.095-.239-.238-.239-.428V5.899c0-2.545 1.95-4.472 4.591-4.472 1 0 1.927.333 2.712.928L8.23 5.067c-.285.166-.428.404-.428.737v6.898zM12 15.128l-2.795-1.57v-3.33L12 8.658l2.795 1.57v3.33L12 15.128zm1.796 7.23c-1 0-1.927-.332-2.712-.927l4.686-2.712c.285-.166.428-.404.428-.737v-6.898l1.974 1.142c.167.095.238.238.238.428v5.233c0 2.545-1.974 4.472-4.614 4.472zm-5.637-5.303l-4.544-2.617c-1.308-.761-2.188-2.378-2.188-3.948A4.482 4.482 0 014.21 6.327v5.423c0 .333.143.571.428.738l5.947 3.449-1.95 1.118a.432.432 0 01-.476 0zm-.262 3.9c-2.688 0-4.662-2.021-4.662-4.519 0-.19.024-.38.047-.57l4.686 2.71c.286.167.571.167.856 0l5.97-3.448v2.26c0 .19-.07.333-.237.428l-4.543 2.616c-.619.357-1.356.523-2.117.523zm5.899 2.83a5.947 5.947 0 005.827-4.756C22.287 18.339 24 15.84 24 13.296c0-1.665-.713-3.282-1.998-4.448.119-.5.19-.999.19-1.498 0-3.401-2.759-5.947-5.946-5.947-.642 0-1.26.095-1.88.31A5.962 5.962 0 0010.205 0a5.947 5.947 0 00-5.827 4.757C1.713 5.447 0 7.945 0 10.49c0 1.666.713 3.283 1.998 4.448-.119.5-.19 1-.19 1.499 0 3.401 2.759 5.946 5.946 5.946.642 0 1.26-.095 1.88-.309a5.96 5.96 0 004.162 1.713z"/>',
|
|
442
|
+
cursor: '<path d="M22.106 5.68L12.5.135a.998.998 0 00-.998 0L1.893 5.68a.84.84 0 00-.419.726v11.186c0 .3.16.577.42.727l9.607 5.547a.999.999 0 00.998 0l9.608-5.547a.84.84 0 00.42-.727V6.407a.84.84 0 00-.42-.726zm-.603 1.176L12.228 22.92c-.063.108-.228.064-.228-.061V12.34a.59.59 0 00-.295-.51l-9.11-5.26c-.107-.062-.063-.228.062-.228h18.55c.264 0 .428.286.296.514z"/>'
|
|
443
|
+
};
|
|
444
|
+
function sxGlyph(p) {
|
|
445
|
+
p = sxProvider(p);
|
|
446
|
+
return '<span class="sx-glyph ' + p + '" role="img" aria-label="' + (p === 'codex' ? 'ChatGPT' : sxProviderLabel(p)) + '"><svg viewBox="0 0 24 24" aria-hidden="true">' + SX_GLYPHS[p] + '</svg></span>';
|
|
447
|
+
}
|
|
448
|
+
function sxBadge(p) { p = sxProvider(p); return '<span class="sx-badge ' + p + '">' + sxProviderLabel(p).toUpperCase() + '</span>'; }
|
|
449
|
+
|
|
450
|
+
// ClaudeSession.title: the job label, else the name, else the workspace, else the id.
|
|
451
|
+
function sxTitle(s) {
|
|
452
|
+
var t = plain(s.display_label || s.custom_title || '').replace(/\s+/g, ' ').trim();
|
|
453
|
+
return t || String(s.project || '').trim() || String(s.session_id || s.id || 'Session');
|
|
454
|
+
}
|
|
455
|
+
function sxWarmWords(t) { t = String(t || '').trim().toLowerCase(); return t === 'ready' || t.indexOf('this is an automated local readiness check') === 0; }
|
|
456
|
+
// A warm-up is known by its first prompt, never by a title someone gave it (Models.swift isKeepWarm).
|
|
457
|
+
function sxKeepWarm(s) { return sxWarmWords(s.display_label) && (!s.first_prompt || sxWarmWords(s.first_prompt)); }
|
|
458
|
+
function sxTime(v) { var d = toDate(v); return d ? d.getTime() : 0; }
|
|
459
|
+
function sxShortDate(d) {
|
|
460
|
+
if (d.toDateString() === new Date().toDateString()) return clock(d);
|
|
461
|
+
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// The row's state in Control's words (HelperSources/main.swift sessionStateFromServer and
|
|
465
|
+
// claudePeerState): the server's derivation when the hooks or the registry back it; otherwise a
|
|
466
|
+
// Claude row with no live process is stale, and the rest read from the server's own state.
|
|
467
|
+
var SX_FROM_SERVER = { running: 'running', waiting: 'waiting', idle: 'recent', ended: 'stale', failed: 'error' };
|
|
468
|
+
function sxState(s) {
|
|
469
|
+
var a = s.agent_state || '', src = s.state_source || '';
|
|
470
|
+
if ((src === 'hook' || src === 'registry') && SX_FROM_SERVER[a]) return SX_FROM_SERVER[a];
|
|
471
|
+
if (sxProvider(s.provider) === 'claude' && !s.alive) return 'stale';
|
|
472
|
+
if (a === 'waiting') return 'waiting';
|
|
473
|
+
if (a === 'failed') return 'error';
|
|
474
|
+
return s.running_active || a === 'running' ? 'running' : 'recent';
|
|
475
|
+
}
|
|
476
|
+
function sxNeedsPerson(s) { var st = sxState(s); return st === 'waiting' || st === 'error'; }
|
|
477
|
+
// "Permission: Bash git push", "Question", "Plan approval", "Input".
|
|
478
|
+
function sxWaitingKind(kind, detail) {
|
|
479
|
+
detail = String(detail || '').trim();
|
|
480
|
+
if (kind === 'permission') return detail ? 'Permission: ' + detail : 'Permission';
|
|
481
|
+
if (kind === 'question') return 'Question';
|
|
482
|
+
if (kind === 'plan') return 'Plan approval';
|
|
483
|
+
if (kind === 'mcp_input') return 'Input';
|
|
484
|
+
if (!kind) return detail;
|
|
485
|
+
var cap = String(kind).charAt(0).toUpperCase() + String(kind).slice(1);
|
|
486
|
+
return detail ? cap + ': ' + detail : cap;
|
|
487
|
+
}
|
|
488
|
+
function sxStateLabel(s, st) {
|
|
489
|
+
var base = '';
|
|
490
|
+
if (st === 'waiting') { var k = sxWaitingKind(s.waiting_kind, s.waiting_detail); base = k ? 'Waiting · ' + k : 'Waiting'; }
|
|
491
|
+
else if (st === 'stale') base = 'Stale';
|
|
492
|
+
else if (st === 'running') base = 'Running';
|
|
493
|
+
else if (st === 'error') base = s.failure ? 'Failed · ' + String(s.failure).replace(/_/g, ' ') : 'Failed';
|
|
494
|
+
var queued = Number(s.queued_turns) || 0;
|
|
495
|
+
if (queued > 0) return base ? base + ' · ' + queued + ' queued' : queued + ' queued';
|
|
496
|
+
return base;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function sxReal() { return (SX.rows || []).filter(function (s) { return !sxKeepWarm(s); }); }
|
|
500
|
+
function byUpdated(a, b) { return sxTime(b.modified) - sxTime(a.modified); }
|
|
501
|
+
// visibleSessions: Updated is every row by its last update; Opened is what is alive or was opened
|
|
502
|
+
// in the last 7 days, newest first; Pinned is the pins.
|
|
503
|
+
function sxVisible() {
|
|
504
|
+
var real = sxReal();
|
|
505
|
+
if (SX.clock === 'opened') {
|
|
506
|
+
var week = Date.now() - 7 * 86400000;
|
|
507
|
+
return real.filter(function (s) { if (s.alive) return true; var t = sxTime(s.created) || sxTime(s.modified); return t > 0 && t >= week; })
|
|
508
|
+
.sort(function (a, b) { return sxTime(b.created) - sxTime(a.created); });
|
|
509
|
+
}
|
|
510
|
+
if (SX.clock === 'pinned') return real.filter(function (s) { return !!s.pinned; }).sort(byUpdated);
|
|
511
|
+
return real.slice().sort(byUpdated);
|
|
512
|
+
}
|
|
513
|
+
// Two rows with one name are almost always a fork and its parent: those rows say when each opened.
|
|
514
|
+
function sxAmbiguous(rows) {
|
|
515
|
+
var seen = {}, dupes = {};
|
|
516
|
+
rows.forEach(function (s) { var k = sxTitle(s); if (seen[k]) dupes[k] = true; else seen[k] = true; });
|
|
517
|
+
return dupes;
|
|
518
|
+
}
|
|
519
|
+
function sxDropped() {
|
|
520
|
+
var d = SX.dropped || {}, parts = [];
|
|
521
|
+
var age = Math.max(0, Number(d.age) || 0), cap = Math.max(0, Number(d.limit) || 0), big = Math.max(0, Number(d.oversized) || 0);
|
|
522
|
+
if (age) parts.push(age + ' older than 7 days');
|
|
523
|
+
if (cap) parts.push(cap + ' over the cap');
|
|
524
|
+
if (big) parts.push(big + ' too large');
|
|
525
|
+
return { age: age, summary: parts.length ? parts.join(' · ') + ' not shown' : '' };
|
|
526
|
+
}
|
|
527
|
+
function sxQueryActive() { return SX.q.trim().length >= 2; }
|
|
528
|
+
|
|
529
|
+
function sxDetailLine(vis) {
|
|
530
|
+
if (sxQueryActive()) return SX.searching ? 'Looking up…' : 'Lookup across Claude, Codex, and Cursor';
|
|
531
|
+
if (SX.loading) return vis.length ? 'Refreshing…' : 'Claude · Codex · Cursor · reading the last 7 days…';
|
|
532
|
+
if (SX.error) return SX.error;
|
|
533
|
+
if (!vis.length) return 'No sessions';
|
|
534
|
+
if (SX.clock === 'opened') return 'Claude · Codex · Cursor · opened in last 7 days';
|
|
535
|
+
if (SX.clock === 'pinned') return 'Claude · Codex · Cursor · pinned';
|
|
536
|
+
var dropped = sxDropped().summary;
|
|
537
|
+
return dropped ? 'Last 7 days · ' + dropped : 'Claude · Codex · Cursor · updated in last 7 days';
|
|
538
|
+
}
|
|
539
|
+
function sxStats(vis) {
|
|
540
|
+
if (!vis.length && SX.loading && !sxQueryActive()) return [['–', 'ON DISK'], ['–', 'WAITING'], ['–', 'PINNED']];
|
|
541
|
+
if (!vis.length) return [];
|
|
542
|
+
return [[vis.length, 'ON DISK'], [vis.filter(sxNeedsPerson).length, 'WAITING'], [vis.filter(function (s) { return !!s.pinned; }).length, 'PINNED']];
|
|
543
|
+
}
|
|
544
|
+
function sxEmptyCopy() {
|
|
545
|
+
if (SX.clock === 'opened') return 'No sessions opened in the last 7 days. Pins are under Pinned.';
|
|
546
|
+
if (SX.clock === 'pinned') return 'No pinned sessions. Star Claude Desktop chats, pin Cursor chats, or pin Codex/ChatGPT threads to see them here.';
|
|
547
|
+
var d = sxDropped();
|
|
548
|
+
if (d.summary && d.age > 0) return 'No sessions updated on this Mac in the last 7 days. ' + d.summary.replace(/ not shown$/, '') + '. Search to find them.';
|
|
549
|
+
return 'No Claude, Codex, or Cursor sessions updated on this Mac in the last 7 days.';
|
|
550
|
+
}
|
|
551
|
+
function sxEmpty(text) { return '<div class="cx-list"><p class="cx-empty">' + esc(text) + '</p></div>'; }
|
|
552
|
+
|
|
553
|
+
// The hero, the view switch and the clock: painted on every change so the counts never lag the list.
|
|
554
|
+
function paintSessionsHero() {
|
|
555
|
+
var hero = $('sessions-hero'), views = $('sessions-views');
|
|
556
|
+
if (!hero || !views) return;
|
|
557
|
+
var view = SX.view, vis = SX.rows ? sxVisible() : [];
|
|
558
|
+
var detail = view === 'cost' ? 'What COS itself used, on top of your sessions' : view === 'usage' ? 'Token usage and estimated API value' : sxDetailLine(vis);
|
|
559
|
+
var stats = view === 'sessions' ? sxStats(vis) : [];
|
|
560
|
+
var busy = view === 'sessions' ? SX.loading : SX.usageLoading;
|
|
561
|
+
hero.innerHTML = '<span class="cx-glyph"><svg class="icon"><use href="#i-sessions"/></svg></span>' +
|
|
562
|
+
'<div class="cx-hero-text"><h1 class="cx-title">Sessions</h1><p class="cx-detail-line">' + esc(detail) + '</p></div>' +
|
|
563
|
+
'<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>' +
|
|
564
|
+
(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>' : '');
|
|
565
|
+
var clockOn = view === 'sessions' && !sxQueryActive();
|
|
566
|
+
views.innerHTML = '<div class="cx-switch" role="group" aria-label="Sessions view">' +
|
|
567
|
+
Object.keys(SX_VIEWS).map(function (v) { return '<button type="button" data-sx="view" data-view="' + v + '" aria-pressed="' + (v === view) + '">' + SX_VIEWS[v] + '</button>'; }).join('') +
|
|
568
|
+
'</div>' +
|
|
569
|
+
(clockOn ? '<div class="cx-switch sx-clock" role="group" aria-label="Clock">' +
|
|
570
|
+
[['updated', 'Updated'], ['opened', 'Opened'], ['pinned', 'Pinned']].map(function (c) { return '<button type="button" data-sx="clock" data-clock="' + c[0] + '" aria-pressed="' + (c[0] === SX.clock) + '">' + c[1] + '</button>'; }).join('') +
|
|
571
|
+
'</div>' : '');
|
|
572
|
+
var split = $('sessions-split'), usage = $('sessions-usage');
|
|
573
|
+
if (split) split.classList.toggle('hidden', view !== 'sessions');
|
|
574
|
+
if (usage) usage.classList.toggle('hidden', view === 'sessions');
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function loadSessions(force) {
|
|
578
|
+
var r = parseRoute();
|
|
579
|
+
var reading = r.pane === 'sessions' && r.args.length === 2;
|
|
580
|
+
if (reading) SX.view = 'sessions';
|
|
581
|
+
else if (r.pane === 'sessions') window.COSWeb.setCrumb(SX.view === 'sessions' ? '' : SX_VIEWS[SX.view]);
|
|
582
|
+
paintSessionsHero();
|
|
583
|
+
if (SX.view !== 'sessions') { renderUsageView(force === true); return Promise.resolve(); }
|
|
584
|
+
loadSessionHooks();
|
|
585
|
+
if (force !== true && SX.rows && Date.now() - SX.loadedAt < 15000) { renderSessionGroups(); return Promise.resolve(); }
|
|
586
|
+
SX.loading = true;
|
|
587
|
+
renderSessionGroups();
|
|
588
|
+
return api('/api/agent-sessions?limit=80&sort=updated').then(function (d) {
|
|
589
|
+
SX.rows = (d && d.sessions) || [];
|
|
590
|
+
SX.dropped = (d && d.dropped) || null;
|
|
591
|
+
SX.enabled = !(d && d.enabled === false);
|
|
592
|
+
SX.loading = false; SX.error = ''; SX.loadedAt = Date.now();
|
|
593
|
+
state.sessions = SX.rows;
|
|
594
|
+
if (sxQueryActive()) SX.instant = sxInstant(SX.q);
|
|
595
|
+
renderSessionGroups();
|
|
596
|
+
}).catch(function (err) {
|
|
597
|
+
SX.loading = false;
|
|
598
|
+
SX.error = (err && err.message) || 'Sessions did not load';
|
|
599
|
+
renderSessionGroups();
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
// The project most sessions share says nothing; name it only when a session differs (Today's rows).
|
|
603
|
+
function usualProject() {
|
|
604
|
+
var counts = {}, best = '', n = 0;
|
|
605
|
+
state.sessions.forEach(function (s) { if (!s.project) return; counts[s.project] = (counts[s.project] || 0) + 1; if (counts[s.project] > n) { n = counts[s.project]; best = s.project; } });
|
|
606
|
+
return best;
|
|
607
|
+
}
|
|
608
|
+
// The Ask sheet's compact row (⌘K recents and hits).
|
|
609
|
+
function sessionRow(s, sub) {
|
|
610
|
+
var key = splitSessionId(s.provider, s.session_id || s.id);
|
|
611
|
+
var r = parseRoute();
|
|
612
|
+
var selected = r.pane === 'sessions' && r.args[0] === key.provider && r.args[1] === key.id;
|
|
613
|
+
var project = s.project && s.project !== usualProject() ? String(s.project).replace(/^-Users-[^-]+-/, '') : '';
|
|
614
|
+
return '<button type="button" class="row" data-session="' + esc(key.provider) + '|' + esc(key.id) + '"' + (selected ? ' aria-selected="true"' : '') + '>' +
|
|
615
|
+
'<span class="row-title">' + esc(sessionTitle(s)) + '</span>' +
|
|
616
|
+
'<span class="row-side">' + esc(ago(s.last_activity_at || s.modified || s.created)) + '</span>' +
|
|
617
|
+
'<span class="row-sub">' + badge(key.provider) + '<span class="clip">' + esc(sub || project) + '</span></span></button>';
|
|
618
|
+
}
|
|
619
|
+
// Control's row: glyph, title, a snippet on search hits, then badge · PINNED · state · Updated ·
|
|
620
|
+
// Opened (twins only, amber) · workspace; a match chip and a chevron on the right.
|
|
621
|
+
function sxRow(s, hit, ambiguous) {
|
|
622
|
+
var key = splitSessionId(s.provider, s.session_id || s.id);
|
|
623
|
+
var r = parseRoute();
|
|
624
|
+
var selected = r.pane === 'sessions' && r.args[0] === key.provider && r.args[1] === key.id;
|
|
625
|
+
var st = sxState(s), label = sxStateLabel(s, st), title = sxTitle(s);
|
|
626
|
+
var upd = toDate(s.modified), opened = toDate(s.created);
|
|
627
|
+
var meta = sxBadge(key.provider) +
|
|
628
|
+
(s.pinned ? '<span class="sx-pinned">PINNED</span>' : '') +
|
|
629
|
+
(label ? '<span class="sx-chip ' + st + '">' + esc(label) + '</span>' : '') +
|
|
630
|
+
(upd ? '<span class="sx-when">Updated ' + esc(sxShortDate(upd)) + '</span>' : '') +
|
|
631
|
+
(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>' : '') +
|
|
632
|
+
(s.project && String(s.project) !== title ? '<span class="sx-ws">' + esc(s.project) + '</span>' : '');
|
|
633
|
+
var match = hit ? (hit.match === 'both' ? 'Keyword + meaning' : hit.match === 'semantic' ? 'Meaning' : 'Keyword') : '';
|
|
634
|
+
// An instant title hit's snippet is the title itself; it says nothing twice.
|
|
635
|
+
var snippet = hit && hit.snippet ? plain(hit.snippet).replace(/\s+/g, ' ').trim() : '';
|
|
636
|
+
return '<button type="button" class="cx-row sx-row" data-session="' + esc(key.provider) + '|' + esc(key.id) + '"' + (selected ? ' aria-selected="true"' : '') + '>' +
|
|
637
|
+
sxGlyph(key.provider) +
|
|
638
|
+
'<span class="cx-row-body"><span class="sx-title">' + esc(title) + '</span>' +
|
|
639
|
+
(snippet && snippet !== title ? '<span class="sx-snippet">' + esc(snippet) + '</span>' : '') +
|
|
640
|
+
'<span class="sx-meta">' + meta + '</span></span>' +
|
|
641
|
+
'<span class="sx-side">' + (match ? '<span class="sx-match">' + match + '</span>' : '') + '<span class="chev"></span></span></button>';
|
|
642
|
+
}
|
|
643
|
+
function sxListHtml() {
|
|
644
|
+
if (!SX.rows) return SX.error ? sxEmpty(SX.error) : skeletonRows(6);
|
|
645
|
+
if (!SX.enabled && !SX.rows.length) return sxEmpty('AI sessions are off. Turn on Show AI sessions in Settings, Work and agents.');
|
|
646
|
+
var vis = sxVisible();
|
|
647
|
+
if (!vis.length) return sxEmpty(SX.error || sxEmptyCopy());
|
|
648
|
+
var amb = sxAmbiguous(vis);
|
|
649
|
+
return '<div class="cx-list sx-list">' + vis.map(function (s) { return sxRow(s, null, amb); }).join('') + '</div>';
|
|
650
|
+
}
|
|
651
|
+
// The list, under the hero. The name stays: the shared input handler calls it when the search clears.
|
|
652
|
+
function renderSessionGroups() {
|
|
653
|
+
var input = $('sessions-q');
|
|
654
|
+
var q = input ? input.value.trim() : '';
|
|
655
|
+
if (q.length < 2 && SX.q) { SX.q = ''; SX.instant = []; SX.remote = []; SX.ranFor = ''; SX.searchError = ''; SX.semantic = null; SX.searching = false; }
|
|
656
|
+
paintSessionsHero();
|
|
657
|
+
paintHooks();
|
|
658
|
+
var clear = $('sessions-clear'); if (clear) clear.classList.toggle('hidden', !(input && input.value));
|
|
659
|
+
var hint = $('sessions-hint');
|
|
660
|
+
if (hint) {
|
|
661
|
+
var note = sxQueryActive() && SX.ranFor === SX.q && SX.semantic === false ? sxSemanticHint(SX.semanticReason) : '';
|
|
662
|
+
hint.textContent = note; hint.classList.toggle('hidden', !note);
|
|
663
|
+
}
|
|
664
|
+
var list = $('sessions-list'); if (!list) return;
|
|
665
|
+
list.innerHTML = sxQueryActive() ? sxSearchHtml() : sxListHtml();
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// Search (ActivityWindow.swift sessionsSearchBar 2652-2712, ControllerModel scheduleSessionSearch):
|
|
669
|
+
// instant title hits from the loaded list, then the server's keyword and meaning hits, one row
|
|
670
|
+
// per session, ordered by the Recency choice.
|
|
671
|
+
var SX_STOP = ['the', 'a', 'an', 'and', 'or', 'of', 'to', 'in', 'for', 'on', 'at', 'by', 'with', 'from', 'vs', 'is', 'it', 'be', 'as', 'we', 'our'];
|
|
672
|
+
function sxTokens(q) {
|
|
673
|
+
var seen = {}, out = [];
|
|
674
|
+
String(q).toLowerCase().split(/[^\p{L}\p{N}]+/u).forEach(function (t) { if (t.length >= 2 && SX_STOP.indexOf(t) < 0 && !seen[t]) { seen[t] = true; out.push(t); } });
|
|
675
|
+
return out;
|
|
676
|
+
}
|
|
677
|
+
function sxScore(tokens, title, hay) {
|
|
678
|
+
if (!tokens.length) return 0;
|
|
679
|
+
var tl = title.toLowerCase(), hl = hay.toLowerCase(), hits = 0, inTitle = 0;
|
|
680
|
+
tokens.forEach(function (t) { var a = tl.indexOf(t) >= 0, b = hl.indexOf(t) >= 0; if (!a && !b) return; hits += 1; if (a) inTitle += 1; });
|
|
681
|
+
if (!hits) return 0;
|
|
682
|
+
var coverage = hits / tokens.length;
|
|
683
|
+
if (coverage < 0.5 && !inTitle) return 0;
|
|
684
|
+
return Math.min(1, coverage * 0.65 + (inTitle / tokens.length) * 0.35);
|
|
685
|
+
}
|
|
686
|
+
function sxInstant(q) {
|
|
687
|
+
var tokens = sxTokens(q);
|
|
688
|
+
if (!tokens.length) return [];
|
|
689
|
+
return sxReal().map(function (s) {
|
|
690
|
+
var t = sxTitle(s), v = sxScore(tokens, t, [s.display_label || '', s.project || '', t].join('\n'));
|
|
691
|
+
return v > 0 ? { s: s, snippet: t, match: 'keyword', score: v } : null;
|
|
692
|
+
}).filter(Boolean).sort(function (a, b) { return b.score - a.score; }).slice(0, 20);
|
|
693
|
+
}
|
|
694
|
+
function sxKey(s) { var k = splitSessionId(s.provider, s.session_id || s.id); return k.provider + ':' + k.id.toLowerCase(); }
|
|
695
|
+
// One session whether the id is whole or the list's 8-character prefix (ClaudeSession.sameSession).
|
|
696
|
+
function sxSame(a, b) {
|
|
697
|
+
if (a === b) return true;
|
|
698
|
+
var x = a.split(':'), y = b.split(':');
|
|
699
|
+
if (x[0] !== y[0] || !x[1] || !y[1]) return false;
|
|
700
|
+
var short = x[1].length <= y[1].length ? x[1] : y[1], long = short === x[1] ? y[1] : x[1];
|
|
701
|
+
return short.length >= 8 && long.indexOf(short) === 0;
|
|
702
|
+
}
|
|
703
|
+
function sxHits() {
|
|
704
|
+
var out = [];
|
|
705
|
+
SX.instant.concat(SX.remote).forEach(function (h) {
|
|
706
|
+
var k = sxKey(h.s);
|
|
707
|
+
for (var i = 0; i < out.length; i++) if (sxSame(sxKey(out[i].s), k)) { if (out[i].score < h.score) out[i] = h; return; }
|
|
708
|
+
out.push(h);
|
|
709
|
+
});
|
|
710
|
+
var date = function (h) { return sxTime(h.s.modified) || sxTime(h.s.created); };
|
|
711
|
+
return out.sort(function (a, b) {
|
|
712
|
+
if (SX.recency === 'match') return (b.score - a.score) || (date(b) - date(a));
|
|
713
|
+
if (SX.recency === 'oldest') return ((date(a) || Infinity) - (date(b) || Infinity)) || (b.score - a.score);
|
|
714
|
+
return (date(b) - date(a)) || (b.score - a.score);
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
// A hit names the session as the search found it; the list's own row carries the live state.
|
|
718
|
+
function sxListed(s) {
|
|
719
|
+
var k = sxKey(s);
|
|
720
|
+
var row = (SX.rows || []).filter(function (x) { return sxSame(sxKey(x), k); })[0];
|
|
721
|
+
return row || s;
|
|
722
|
+
}
|
|
723
|
+
function sxSearchHtml() {
|
|
724
|
+
var hits = sxHits();
|
|
725
|
+
if (!hits.length) {
|
|
726
|
+
if (SX.searching) return '<p class="cx-note sx-looking">Looking up…</p>';
|
|
727
|
+
if (SX.searchError) return sxEmpty(SX.searchError);
|
|
728
|
+
return sxEmpty('No sessions match that lookup.');
|
|
729
|
+
}
|
|
730
|
+
var rows = hits.map(function (h) { return { s: sxListed(h.s), h: h }; });
|
|
731
|
+
var amb = sxAmbiguous(sxVisible().concat(rows.map(function (x) { return x.s; })));
|
|
732
|
+
return '<div class="cx-list sx-list">' + rows.map(function (x) { return sxRow(x.s, x.h, amb); }).join('') + '</div>';
|
|
733
|
+
}
|
|
734
|
+
// The words Control shows when meaning search is not available (em dashes made periods).
|
|
735
|
+
function sxSemanticHint(reason) {
|
|
736
|
+
reason = String(reason || '');
|
|
737
|
+
if (reason.indexOf('server_error_') === 0) return 'Keyword only. The server returned ' + reason.slice('server_error_'.length);
|
|
738
|
+
if (reason === 'server_too_old') return 'Keyword only. Meaning search needs a server update';
|
|
739
|
+
if (reason === 'server_unreachable') return 'Keyword only. The server did not answer in time';
|
|
740
|
+
if (reason === 'no_server_token') return 'Keyword only. No server token available';
|
|
741
|
+
if (reason === 'no_session_embeddings' || reason === 'embeddings_unreachable') return 'Keyword only. Meaning search needs an OpenAI key';
|
|
742
|
+
return 'Keyword only. Meaning search is unavailable';
|
|
743
|
+
}
|
|
744
|
+
function searchSessions() {
|
|
745
|
+
var input = $('sessions-q');
|
|
746
|
+
var q = input ? input.value.trim() : '';
|
|
747
|
+
clearTimeout(SX.searchTimer);
|
|
748
|
+
SX.q = q;
|
|
749
|
+
if (q.length < 2) { renderSessionGroups(); return; }
|
|
750
|
+
if (q !== SX.ranFor) SX.remote = [];
|
|
751
|
+
SX.instant = sxInstant(q);
|
|
752
|
+
SX.searchError = '';
|
|
753
|
+
SX.searching = true;
|
|
754
|
+
var mine = ++SX.searchSeq;
|
|
755
|
+
renderSessionGroups();
|
|
756
|
+
api('/api/agent-sessions/search?q=' + encodeURIComponent(q) + '&limit=20').then(function (d) {
|
|
757
|
+
if (mine !== SX.searchSeq) return;
|
|
758
|
+
SX.remote = ((d && d.hits) || []).map(function (h) {
|
|
759
|
+
return { s: h, snippet: h.snippet || '', match: h.match || 'keyword', score: Number(h.score) || Math.max(Number(h.keywordScore) || 0, Number(h.semanticScore) || 0) };
|
|
760
|
+
});
|
|
761
|
+
SX.semantic = !!(d && d.semanticAvailable === true);
|
|
762
|
+
SX.semanticReason = (d && d.semanticReason) || '';
|
|
763
|
+
SX.searching = false; SX.ranFor = q;
|
|
764
|
+
renderSessionGroups();
|
|
765
|
+
}, function (err) {
|
|
766
|
+
if (mine !== SX.searchSeq) return;
|
|
767
|
+
SX.searching = false; SX.ranFor = q; SX.remote = [];
|
|
768
|
+
if (!SX.instant.length) SX.searchError = (err && err.message) || 'The lookup failed.';
|
|
769
|
+
renderSessionGroups();
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
// The meaning finder the Ask sheet offers ("Find a session about this").
|
|
773
|
+
function findByMeaning(q) {
|
|
774
|
+
return api('/api/agent-sessions/find', { method: 'POST', body: { query: q } }).then(function (d) {
|
|
775
|
+
if (d && d.available === false) return emptyBox('Meaning search is off', d.message || 'Turn on a decision provider in Settings.');
|
|
776
|
+
var results = (d && d.results) || [];
|
|
777
|
+
if (!results.length) return emptyBox('Nothing matched that meaning', 'Try different words, or search by Words.');
|
|
778
|
+
return '<h2 class="group-title"><span>About “' + esc(q) + '”</span><span>' + results.length + '</span></h2><div class="list">' + results.map(function (r) {
|
|
779
|
+
var s = r.session || r;
|
|
780
|
+
if (!s.provider && r.provider) s.provider = r.provider;
|
|
781
|
+
if (!s.session_id) s.session_id = r.sessionId || r.id;
|
|
782
|
+
return sessionRow(s, r.reason || s.project);
|
|
783
|
+
}).join('') + '</div>';
|
|
784
|
+
}).catch(function (err) { return emptyBox('Meaning search did not run', err.message); });
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// The hooks banner (ActivityWindow.swift 2867-2905). Installing hooks writes to the Mac's Claude
|
|
788
|
+
// settings, which stays a Mac action, so the web names where to do it.
|
|
789
|
+
function loadSessionHooks() {
|
|
790
|
+
if (Date.now() - SX.hooksAt < 60000) { paintHooks(); return; }
|
|
791
|
+
SX.hooksAt = Date.now();
|
|
792
|
+
api('/api/session-hooks/status').then(function (d) {
|
|
793
|
+
SX.hooks = (d && d.sessionHooks && d.sessionHooks.state) || null; paintHooks();
|
|
794
|
+
}, function (err) { SX.hooks = err && err.status === 404 ? 'route_absent' : null; paintHooks(); });
|
|
795
|
+
}
|
|
796
|
+
function paintHooks() {
|
|
797
|
+
var el = $('sessions-hooks'); if (!el) return;
|
|
798
|
+
var st = SX.hooks;
|
|
799
|
+
if (['missing', 'drift', 'script_outdated', 'route_absent'].indexOf(st) < 0) { el.innerHTML = ''; return; }
|
|
800
|
+
var text = st === 'route_absent' ? 'Live session state needs COS server 6.48.0 or later.'
|
|
801
|
+
: st === 'missing' ? 'Claude Code hooks are not installed: sessions read from their transcripts only.'
|
|
802
|
+
: 'Claude Code hooks need a refresh (' + (st === 'drift' ? 'settings changed' : 'script outdated') + ').';
|
|
803
|
+
var where = st === 'route_absent' ? 'Update Server from COS Control on your Mac.' : (st === 'missing' ? 'Install hooks' : 'Reinstall hooks') + ' from COS Control on your Mac.';
|
|
804
|
+
el.innerHTML = '<div class="sx-hooks"><span class="sx-hooks-dot" aria-hidden="true"></span><span>' + esc(text) + ' <span class="sx-hooks-where">' + esc(where) + '</span></span></div>';
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// The view switch: Sessions | Usage | COS cost. A reader belongs to Sessions, so another view
|
|
808
|
+
// closes it; the route repaints the pane and names the view in the breadcrumb.
|
|
809
|
+
function setSessionsView(v) {
|
|
810
|
+
if (!SX_VIEWS[v] || v === SX.view) return;
|
|
811
|
+
SX.view = v; sxSave();
|
|
812
|
+
go('sessions');
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// Pane-level events for this pane's own controls (data-sx). Sends, Stop, Fork and the queue
|
|
816
|
+
// keep their data-action names, which the shared listener handles.
|
|
817
|
+
var sxPane = $('pane-sessions');
|
|
818
|
+
if (sxPane) {
|
|
819
|
+
if ($('sessions-recency')) $('sessions-recency').value = SX.recency;
|
|
820
|
+
sxPane.addEventListener('click', function (e) {
|
|
821
|
+
var t = e.target.closest('[data-sx]');
|
|
822
|
+
if (!t || !sxPane.contains(t)) return;
|
|
823
|
+
var act = t.getAttribute('data-sx'), rd = state.reading;
|
|
824
|
+
if (act === 'view') return setSessionsView(t.getAttribute('data-view'));
|
|
825
|
+
if (act === 'clock') { SX.clock = t.getAttribute('data-clock'); sxSave(); return renderSessionGroups(); }
|
|
826
|
+
if (act === 'refresh') {
|
|
827
|
+
if (SX.view !== 'sessions') return renderUsageView(true);
|
|
828
|
+
SX.hooksAt = 0;
|
|
829
|
+
loadSessions(true);
|
|
830
|
+
if (sxQueryActive()) searchSessions();
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
if (act === 'clear') { var input = $('sessions-q'); input.value = ''; searchSessions(); input.focus(); return; }
|
|
834
|
+
if (act === 'copy-session') return copySessionBrief();
|
|
835
|
+
if (act === 'refresh-turns' && rd) { rd.refusal = null; renderVerdict(rd); refreshTurns(rd.provider, rd.id); return; }
|
|
836
|
+
if (act === 'retry' && rd) { rd.refusal = null; renderVerdict(rd); return sendSessionTurn(!!rd.retryFork); }
|
|
837
|
+
if (act === 'caution-send' && rd) { rd.cautionAck = true; rd.cautionAsk = false; renderVerdict(rd); return sendSessionTurn(false); }
|
|
838
|
+
if (act === 'caution-cancel' && rd) { rd.cautionAsk = false; renderVerdict(rd); return; }
|
|
839
|
+
if (act === 'usage-clear') { SX.uTool = 'All tools'; SX.uModel = 'All models'; SX.uToolBefore = null; uSliceChanged(); return paintUsage(); }
|
|
840
|
+
if (act === 'usage-range') return uOpenRange(t, true);
|
|
841
|
+
});
|
|
842
|
+
sxPane.addEventListener('input', function (e) {
|
|
843
|
+
if (e.target.id !== 'sessions-q') return;
|
|
844
|
+
clearTimeout(SX.searchTimer);
|
|
845
|
+
if (e.target.value.trim().length < 2) { searchSessions(); return; }
|
|
846
|
+
// The last answer belonged to other words: until this one lands, the instant title hits stand alone.
|
|
847
|
+
SX.q = e.target.value.trim();
|
|
848
|
+
if (SX.q !== SX.ranFor) { SX.remote = []; SX.searching = true; }
|
|
849
|
+
SX.instant = sxInstant(SX.q);
|
|
850
|
+
renderSessionGroups();
|
|
851
|
+
SX.searchTimer = setTimeout(searchSessions, 350);
|
|
852
|
+
});
|
|
853
|
+
sxPane.addEventListener('change', function (e) {
|
|
854
|
+
var id = e.target.id;
|
|
855
|
+
if (id === 'sessions-recency') { SX.recency = e.target.value; sxSave(); return renderSessionGroups(); }
|
|
856
|
+
// Custom dates opens the date sheet; the menu goes back to the range in force if it is cancelled.
|
|
857
|
+
if (id === 'usage-period') { if (e.target.value === U_CUSTOM) return uOpenRange(e.target, true); SX.uPeriod = e.target.value; uPeriodChanged(); return paintUsage(); }
|
|
858
|
+
if (id === 'usage-tool') { SX.uTool = e.target.value; SX.uModel = 'All models'; SX.uToolBefore = null; uSliceChanged(); return paintUsage(); }
|
|
859
|
+
if (id === 'usage-model') { SX.uModel = e.target.value; SX.uToolBefore = null; uSliceChanged(); return paintUsage(); }
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// ── Sessions: read, send, queue, fork, stop (the glasses' Continue, from a browser) ──
|
|
864
|
+
// The server owns every safety rule (agent-session-bindings.ts): COS writes into a desktop
|
|
865
|
+
// thread only through a binding, attaches only when no live process owns the thread, and
|
|
866
|
+
// re-checks before delivery. This page follows the same order the glasses do:
|
|
867
|
+
// attach (or reuse this tab's binding) -> send the turn under ONE key per draft ->
|
|
868
|
+
// if the session is busy, park it in the session's queue -> watch the live stream.
|
|
869
|
+
// A refusal is an answer with the server's own words, never an error to retry blindly. The
|
|
870
|
+
// draft key is reused on a resend, so a retry after a lost answer can never post twice.
|
|
871
|
+
// One id for the life of the page, read once. Without storage (a private window) the old code made
|
|
872
|
+
// a new id on every call, so one page sent different ids to /attach, /queued-turns and /fork.
|
|
873
|
+
var WEB_SESSION_KEY = 'cos_web_sid';
|
|
874
|
+
var webSessionId = null;
|
|
875
|
+
function cosSessionId() {
|
|
876
|
+
if (webSessionId) return webSessionId;
|
|
877
|
+
var stored = null;
|
|
878
|
+
try { stored = localStorage.getItem(WEB_SESSION_KEY); } catch (_e) { stored = null; }
|
|
879
|
+
if (stored && /^web-[a-z0-9]{8,24}$/.test(stored)) return (webSessionId = stored);
|
|
880
|
+
webSessionId = 'web-' + Math.random().toString(36).slice(2, 12) + Date.now().toString(36).slice(-4);
|
|
881
|
+
try { localStorage.setItem(WEB_SESSION_KEY, webSessionId); } catch (_e) {}
|
|
882
|
+
return webSessionId;
|
|
883
|
+
}
|
|
884
|
+
function newTurnKey() { return 'ct' + Date.now().toString(36) + Math.random().toString(36).slice(2, 10).padEnd(8, '0'); }
|
|
885
|
+
function targetKey(provider, id) { return provider.length + ':' + provider + ':' + id.length + ':' + id; }
|
|
886
|
+
var BINDING_REUSE_MS = 25 * 60000; // the server keeps a binding 30 min
|
|
887
|
+
var PARKABLE_REFUSALS = /^(native_thread_working|native_target_busy|target_busy)$/;
|
|
888
|
+
// A binding is kept per browser, across reloads: the server allows one live binding per
|
|
889
|
+
// thread, so a reload that forgot it would read its own binding as "target busy".
|
|
890
|
+
var BINDINGS_KEY = 'cos_web_bindings';
|
|
891
|
+
function readBindings() {
|
|
892
|
+
try { var all = JSON.parse(localStorage.getItem(BINDINGS_KEY) || '{}'); return all && typeof all === 'object' ? all : {}; } catch (_e) { return {}; }
|
|
893
|
+
}
|
|
894
|
+
function bindingFor(provider, id) {
|
|
895
|
+
var b = readBindings()[provider + '|' + id];
|
|
896
|
+
return b && typeof b.bindingId === 'string' && typeof b.epoch === 'number' && Date.now() - b.at < BINDING_REUSE_MS ? b : null;
|
|
897
|
+
}
|
|
898
|
+
function rememberBinding(provider, id, b) {
|
|
899
|
+
var all = readBindings(), now = Date.now();
|
|
900
|
+
Object.keys(all).forEach(function (k) { if (!all[k] || now - all[k].at >= BINDING_REUSE_MS) delete all[k]; });
|
|
901
|
+
if (b) all[provider + '|' + id] = b; else delete all[provider + '|' + id];
|
|
902
|
+
try { localStorage.setItem(BINDINGS_KEY, JSON.stringify(all)); } catch (_e) {}
|
|
903
|
+
}
|
|
904
|
+
function sessionPath(provider, id, tail) { return '/api/agent-sessions/' + encodeURIComponent(provider) + '/' + encodeURIComponent(id) + (tail || ''); }
|
|
905
|
+
|
|
906
|
+
// Like api(), but a 4xx refusal comes back as { status, body } instead of throwing, because
|
|
907
|
+
// these routes answer "busy", "changed" or "not now" with a body the person should read.
|
|
908
|
+
function apiAnswer(path, opts) {
|
|
909
|
+
var o = opts || {};
|
|
910
|
+
var ctrl = typeof AbortController === 'function' ? new AbortController() : null;
|
|
911
|
+
var timer = ctrl && o.timeoutMs ? setTimeout(function () { ctrl.abort(); }, o.timeoutMs) : null;
|
|
912
|
+
var headers = { 'X-COS-Web': '1' };
|
|
913
|
+
if (o.body !== undefined) headers['Content-Type'] = 'application/json';
|
|
914
|
+
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 })
|
|
915
|
+
.then(function (res) {
|
|
916
|
+
if (res.status === 401) { showAuth(); throw new Error('Signed out. Connect this browser again.'); }
|
|
917
|
+
return res.text().then(function (t) { var b = {}; try { b = t ? JSON.parse(t) : {}; } catch (_e) { b = {}; } return { status: res.status, body: b || {} }; });
|
|
918
|
+
})
|
|
919
|
+
.finally(function () { if (timer) clearTimeout(timer); });
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// Per-browser memory for one session: messages held until a reply ends, and the queue keys
|
|
923
|
+
// this browser parked (so Remove is offered only on its own rows). Both survive a reload.
|
|
924
|
+
var HELD_KEY = 'cos_web_held', PARKED_KEY = 'cos_web_parked';
|
|
925
|
+
function readStore(key) { try { var v = JSON.parse(localStorage.getItem(key) || '{}'); return v && typeof v === 'object' ? v : {}; } catch (_e) { return {}; } }
|
|
926
|
+
function writeStore(key, v) { try { localStorage.setItem(key, JSON.stringify(v)); } catch (_e) {} }
|
|
927
|
+
function saveHeld(r) {
|
|
928
|
+
var all = readStore(HELD_KEY), k = r.provider + '|' + r.id;
|
|
929
|
+
var keep = r.held.filter(function (h) { return !h.cancelled; }).map(function (h) { return { text: h.text, key: h.key, since: h.since }; });
|
|
930
|
+
if (keep.length) all[k] = keep; else delete all[k];
|
|
931
|
+
writeStore(HELD_KEY, all);
|
|
932
|
+
}
|
|
933
|
+
function rememberParked(r, key) {
|
|
934
|
+
var all = readStore(PARKED_KEY), k = r.provider + '|' + r.id;
|
|
935
|
+
all[k] = (all[k] || []).concat(key).slice(-50);
|
|
936
|
+
writeStore(PARKED_KEY, all);
|
|
937
|
+
}
|
|
938
|
+
function parkedByMe(r, key) { return (readStore(PARKED_KEY)[r.provider + '|' + r.id] || []).indexOf(key) >= 0; }
|
|
939
|
+
|
|
940
|
+
// The server serves at most 40 turns (lib/agent-session-turns.ts SESSION_TURNS_MAX), each
|
|
941
|
+
// shaped to 4,000 characters. Control reads up to 120 from the Mac's own files.
|
|
942
|
+
var SX_TURNS = 40;
|
|
943
|
+
// Control's detail subtitle (Models.swift ClaudeSessionDetail.subtitle).
|
|
944
|
+
function sxSubtitle(d, provider, turns, more) {
|
|
945
|
+
var parts = [sxProviderLabel(d.provider || provider)];
|
|
946
|
+
if (d.project) parts.push(String(d.project).replace(/^-Users-[^-]+-/, ''));
|
|
947
|
+
if (d.git_branch && d.git_branch !== 'unknown') parts.push(String(d.git_branch));
|
|
948
|
+
var total = Number(d.message_count) || 0;
|
|
949
|
+
if (more) parts.push(total > turns.length && !d.truncated ? 'last ' + turns.length + ' of ' + total + ' turns' : 'last ' + turns.length + ' turns');
|
|
950
|
+
else parts.push(turns.length + (turns.length === 1 ? ' turn' : ' turns'));
|
|
951
|
+
if (Number(d.omitted_tools) > 0) parts.push('tools omitted');
|
|
952
|
+
if (d.truncated) parts.push('large session · earlier turns skipped');
|
|
953
|
+
return parts.join(' · ');
|
|
954
|
+
}
|
|
955
|
+
function sxTruncNote(d, turns, more) {
|
|
956
|
+
if (!more) return '';
|
|
957
|
+
var total = Number(d.message_count) || 0;
|
|
958
|
+
return 'Showing the last ' + turns.length + (total > turns.length && !d.truncated ? ' of ' + total : '') + ' turns. Copy session keeps the original request plus the newest context.';
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
function openSession(provider, id) {
|
|
962
|
+
var reader = $('session-reader');
|
|
963
|
+
if (!provider || !id) return;
|
|
964
|
+
if (state.stream) { state.stream.close(); state.stream = null; }
|
|
965
|
+
clearInterval(state.turnPoll);
|
|
966
|
+
if (state.reading) { clearInterval(state.reading.heldTimer); clearInterval(state.reading.feedTimer); }
|
|
967
|
+
var held = (readStore(HELD_KEY)[provider + '|' + id] || []).filter(function (h) { return h && typeof h.text === 'string' && typeof h.key === 'string'; });
|
|
968
|
+
var r = state.reading = { provider: provider, id: id, draft: null, live: null, held: held, flushing: null, feed: newFeed(), verdict: null, cautionAck: false, cautionAsk: false, refusal: null, detail: null, turns: [], more: false, queued: 0 };
|
|
969
|
+
if (SX.view !== 'sessions') { SX.view = 'sessions'; sxSave(); paintSessionsHero(); }
|
|
970
|
+
document.querySelectorAll('#sessions-list [data-session]').forEach(function (b) { b.setAttribute('aria-selected', String(b.getAttribute('data-session') === provider + '|' + id)); });
|
|
971
|
+
var listed = (SX.rows || []).filter(function (s) { return sxSame(sxKey(s), provider + ':' + String(id).toLowerCase()); })[0];
|
|
972
|
+
if (listed) window.COSWeb.setCrumb(sxTitle(listed));
|
|
973
|
+
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);
|
|
974
|
+
api(sessionPath(provider, id, '?turns=' + SX_TURNS)).then(function (d) {
|
|
975
|
+
if (state.reading !== r) return;
|
|
976
|
+
var turns = Array.isArray(d && d.recent_turns) ? d.recent_turns : ((d && d.recent_turns && d.recent_turns.turns) || (d && d.turns) || []);
|
|
977
|
+
r.detail = d; r.turns = turns; r.more = d.recent_turns_more === true;
|
|
978
|
+
r.queued = Number(d.queued_turns) || 0;
|
|
979
|
+
var title = sxTitle(d);
|
|
980
|
+
window.COSWeb.setCrumb(title);
|
|
981
|
+
var trunc = sxTruncNote(d, turns, r.more);
|
|
982
|
+
reader.innerHTML =
|
|
983
|
+
'<div class="sx-reader-head"><div class="sx-head-line">' + sxBadge(provider) + '<h2 class="sx-reader-title">' + esc(title) + '</h2></div>' +
|
|
984
|
+
'<p class="sx-subtitle">' + esc(sxSubtitle(d, provider, turns, r.more)) + '</p></div>' +
|
|
985
|
+
'<div class="sx-activity" id="session-live" aria-live="polite"></div>' +
|
|
986
|
+
(trunc ? '<p class="sx-note">' + esc(trunc) + '</p>' : '') +
|
|
987
|
+
'<div class="turns sx-turns" id="session-turns">' + renderTurns(turns, d) + '</div>' +
|
|
988
|
+
'<div class="sx-foot"><button type="button" class="cx-btn quiet" data-sx="copy-session" title="A kickstart brief for another agent, built from the turns COS serves. Not a Claude Code resume: it does not reopen this session.">Copy session</button>' +
|
|
989
|
+
'<span class="sx-foot-note">Kickstart brief for another agent. Not a Claude Code resume.</span></div>' +
|
|
990
|
+
'<p class="sx-copy-note hidden" id="session-copy-note" role="status"></p>' +
|
|
991
|
+
'<div class="sx-chat">' +
|
|
992
|
+
'<div id="session-verdict"></div>' +
|
|
993
|
+
'<form class="composer" id="session-form" data-provider="' + esc(provider) + '" data-id="' + esc(id) + '">' +
|
|
994
|
+
'<textarea id="session-input" rows="1" placeholder="Continue this session…" enterkeyhint="send" aria-label="Continue this session"></textarea>' +
|
|
995
|
+
'<div class="composer-actions"><button type="button" class="btn small" data-action="fork-session" title="Copy this session and send the message to the copy">Fork</button>' +
|
|
996
|
+
'<button type="submit" class="btn primary small">Send</button></div>' +
|
|
997
|
+
'</form><div class="status-line" id="session-status"></div>' +
|
|
998
|
+
'<p class="sx-caution hidden" id="session-caution"></p>' +
|
|
999
|
+
'<div id="session-queue"></div>' +
|
|
1000
|
+
'</div>';
|
|
1001
|
+
setLive({ working: !!d.running_active });
|
|
1002
|
+
renderHeld(state.reading);
|
|
1003
|
+
loadQueue(provider, id);
|
|
1004
|
+
sxProbeAttach(r);
|
|
1005
|
+
if (state.reading.held.length) {
|
|
1006
|
+
sessionStatus('Waiting for the current reply. ' + (state.reading.held.length > 1 ? state.reading.held.length + ' messages send' : 'Yours sends') + ' as soon as it finishes. ', null, '<button type="button" class="link-btn" data-action="drop-after">Don’t send</button>');
|
|
1007
|
+
startHeldTimer(state.reading);
|
|
1008
|
+
}
|
|
1009
|
+
if (turns.length) requestAnimationFrame(function () { window.scrollTo(0, document.documentElement.scrollHeight); });
|
|
1010
|
+
watchSession(provider, id);
|
|
1011
|
+
}).catch(function (err) {
|
|
1012
|
+
if (state.reading !== r) return;
|
|
1013
|
+
reader.innerHTML = sxEmpty((err && err.message) || 'This session did not open.') + '<p class="sx-retry"><button type="button" class="cx-btn quiet" data-session="' + esc(provider) + '|' + esc(id) + '">Retry</button></p>';
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
// ACTIVITY (ActivityWindow.swift SessionActivityFeed 6800-6890, SessionLiveFeed.swift): the state
|
|
1018
|
+
// word with its clock, the current prompt, the last eight tool lines, and the honest counters.
|
|
1019
|
+
// The reducer follows SessionLiveFeed.applying: a reseed (seq 1 after anything) starts over.
|
|
1020
|
+
function newFeed() { return { transport: 'idle', agentState: null, stateSince: null, waitingKind: null, waitingDetail: null, failure: null, lastReply: null, prompt: '', tools: [], lastSeq: 0, lastEventAt: null, missed: 0, connected: false, fallback: null }; }
|
|
1021
|
+
function applyFeed(r, e) {
|
|
1022
|
+
if (typeof e.seq !== 'number') return;
|
|
1023
|
+
var f = r.feed;
|
|
1024
|
+
if (e.seq === 1 && f.lastSeq > 0) { var keep = f.connected; f = r.feed = newFeed(); f.connected = keep; }
|
|
1025
|
+
else if (f.lastSeq > 0 && e.seq > f.lastSeq + 1) f.missed += e.seq - f.lastSeq - 1;
|
|
1026
|
+
f.lastSeq = e.seq;
|
|
1027
|
+
f.lastEventAt = typeof e.at === 'number' ? new Date(e.at) : new Date();
|
|
1028
|
+
f.connected = true;
|
|
1029
|
+
if (f.fallback === 'reconnecting') f.fallback = null;
|
|
1030
|
+
if (e.kind === 'status') {
|
|
1031
|
+
if (e.state) f.transport = e.state;
|
|
1032
|
+
if (e.agent_state) {
|
|
1033
|
+
f.agentState = e.agent_state; f.stateSince = toDate(e.state_since);
|
|
1034
|
+
f.waitingKind = e.waiting_kind || null; f.waitingDetail = e.waiting_detail || null; f.failure = e.failure || null;
|
|
1035
|
+
if (e.last_reply) f.lastReply = e.last_reply;
|
|
1036
|
+
}
|
|
1037
|
+
} else if (e.kind === 'prompt') {
|
|
1038
|
+
f.prompt = String(e.text || ''); f.tools = [];
|
|
1039
|
+
} else if (e.kind === 'tool') {
|
|
1040
|
+
f.tools.push({ seq: e.seq, verb: e.verb || 'tool', target: e.target || '' });
|
|
1041
|
+
if (f.tools.length > 8) f.tools.splice(0, f.tools.length - 8);
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
var SX_VERBS = { read: 'Read', edit: 'Edited', write: 'Edited', bash: 'Ran', run: 'Ran', search: 'Searched', grep: 'Searched', glob: 'Searched', fetch: 'Fetched', web: 'Fetched', agent: 'Delegated', task: 'Delegated' };
|
|
1045
|
+
function sxToolLine(t) { var v = String(t.verb || 'tool'); var w = SX_VERBS[v] || v.charAt(0).toUpperCase() + v.slice(1); return t.target ? w + ' ' + t.target : w; }
|
|
1046
|
+
// The word follows the page's own working flag, which a Stop or a finished turn settles before
|
|
1047
|
+
// the stream's status catches up; otherwise the server's derived state, as Control reads it.
|
|
1048
|
+
function sxFeedWord(r) {
|
|
1049
|
+
var f = r.feed;
|
|
1050
|
+
if (r.live && r.live.working) return 'Working';
|
|
1051
|
+
var s = f.agentState || f.transport;
|
|
1052
|
+
if (s === 'waiting') return 'Waiting';
|
|
1053
|
+
if (s === 'failed') return 'Failed';
|
|
1054
|
+
if (s === 'ended' || s === 'done') return 'Done';
|
|
1055
|
+
return 'Idle';
|
|
1056
|
+
}
|
|
1057
|
+
function sxFeedDetail(f, word) {
|
|
1058
|
+
if (word === 'Waiting' && f.agentState === 'waiting') {
|
|
1059
|
+
var d = String(f.waitingDetail || '').trim();
|
|
1060
|
+
if (f.waitingKind === 'permission') return d ? 'Permission: ' + d : 'Permission';
|
|
1061
|
+
if (f.waitingKind === 'question') return 'Question';
|
|
1062
|
+
if (f.waitingKind === 'plan') return 'Plan approval';
|
|
1063
|
+
if (f.waitingKind === 'mcp_input') return 'Input';
|
|
1064
|
+
return d;
|
|
1065
|
+
}
|
|
1066
|
+
if (word === 'Failed' && f.agentState === 'failed') return String(f.failure || '').replace(/_/g, ' ');
|
|
1067
|
+
if (word === 'Idle' && f.agentState === 'idle') { var reply = String(f.lastReply || '').trim(); return reply ? 'last reply: ' + reply : ''; }
|
|
1068
|
+
return '';
|
|
1069
|
+
}
|
|
1070
|
+
function sxElapsed(f) {
|
|
1071
|
+
var since = f.stateSince || f.lastEventAt;
|
|
1072
|
+
if (!since) return '';
|
|
1073
|
+
var s = Math.max(0, Math.floor((Date.now() - since.getTime()) / 1000));
|
|
1074
|
+
if (s < 60) return s + 's';
|
|
1075
|
+
if (s < 3600) return Math.floor(s / 60) + 'm ' + (s % 60) + 's';
|
|
1076
|
+
return Math.floor(s / 3600) + 'h ' + Math.floor((s % 3600) / 60) + 'm';
|
|
1077
|
+
}
|
|
1078
|
+
function sxFallbackLine(reason) {
|
|
1079
|
+
if (reason === 'http_404') return 'No live feed for this session (no transcript to follow); showing the stored turns.';
|
|
1080
|
+
if (reason === 'http_503') return 'The server has no room for another live feed right now; showing the stored turns.';
|
|
1081
|
+
if (reason === 'reconnecting') return 'Reconnecting to the live feed…';
|
|
1082
|
+
if (reason === 'stream_lost') return 'The live feed stopped answering; showing the stored turns. Reopen the session to try again.';
|
|
1083
|
+
return 'Live feed unavailable (' + reason + '); showing the stored turns.';
|
|
1084
|
+
}
|
|
1085
|
+
// The block keeps one size whatever it holds (/qa: the turns below moved while Miles read, as tool
|
|
1086
|
+
// lines and prose came and went): a label row with the counters, a state row with the clock and
|
|
1087
|
+
// Stop, one prompt line, and a tail of the newest tool lines and reply text, fixed in height and
|
|
1088
|
+
// anchored to its bottom, so the newest line shows and older lines slide out of view above it.
|
|
1089
|
+
function paintActivity(r) {
|
|
1090
|
+
var box = $('session-live');
|
|
1091
|
+
if (!box || state.reading !== r) return;
|
|
1092
|
+
var f = r.feed, live = r.live || {};
|
|
1093
|
+
var on = f.connected || f.lastSeq > 0 || live.working;
|
|
1094
|
+
var bits = [];
|
|
1095
|
+
if (on && f.missed > 0) bits.push('<span class="warn">missed ' + f.missed + '</span>');
|
|
1096
|
+
if (on && r.queued > 0) bits.push('<span class="accent">' + (r.queued === 1 ? '1 queued' : r.queued + ' queued') + '</span>');
|
|
1097
|
+
if (on && f.fallback === 'reconnecting') bits.push('<span>reconnecting…</span>');
|
|
1098
|
+
var html = '<div class="sx-act-label"><span>ACTIVITY</span>' + (bits.length ? '<span class="sx-act-bits">' + bits.join('') + '</span>' : '') + '</div>';
|
|
1099
|
+
if (on) {
|
|
1100
|
+
var word = sxFeedWord(r), detail = sxFeedDetail(f, word);
|
|
1101
|
+
html += '<div class="sx-act-state"><span class="sx-dot ' + word.toLowerCase() + '" aria-hidden="true"></span><strong>' + word + '</strong>' +
|
|
1102
|
+
'<span class="sx-act-detail">' + esc(detail) + '</span>' +
|
|
1103
|
+
'<span class="sx-act-clock" id="session-elapsed">' + esc(sxElapsed(f)) + '</span>' +
|
|
1104
|
+
(live.working ? '<button type="button" class="cx-btn quiet sx-act-stop" data-action="stop-session">Stop</button>' : '') + '</div>' +
|
|
1105
|
+
'<p class="sx-act-prompt">' + esc(f.prompt) + '</p>' +
|
|
1106
|
+
'<div class="sx-act-tail">' + f.tools.map(function (t) { return '<div class="sx-act-tool">' + esc(sxToolLine(t)) + '</div>'; }).join('') +
|
|
1107
|
+
(live.working && live.prose ? '<div class="sx-act-prose">' + esc(String(live.prose).slice(-600)) + '</div>' : '') + '</div>';
|
|
1108
|
+
} else {
|
|
1109
|
+
html += '<p class="sx-act-fallback">' + esc(f.fallback ? sxFallbackLine(f.fallback) : 'Connecting to the live feed…') + '</p>';
|
|
1110
|
+
}
|
|
1111
|
+
box.innerHTML = html;
|
|
1112
|
+
// The clock ticks only while the session works or waits (Control 0.5.337).
|
|
1113
|
+
clearInterval(r.feedTimer); r.feedTimer = null;
|
|
1114
|
+
var w = sxFeedWord(r);
|
|
1115
|
+
if ((f.connected || f.lastSeq > 0) && (w === 'Working' || w === 'Waiting')) {
|
|
1116
|
+
r.feedTimer = setInterval(function () {
|
|
1117
|
+
if (state.reading !== r) { clearInterval(r.feedTimer); return; }
|
|
1118
|
+
var el = $('session-elapsed'); if (el) el.textContent = sxElapsed(r.feed);
|
|
1119
|
+
}, 1000);
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
// EventSource hides the status of a refused stream, so one GET reads it for the fallback line.
|
|
1123
|
+
function sxProbeStream(provider, id, r) {
|
|
1124
|
+
var ctrl = typeof AbortController === 'function' ? new AbortController() : null;
|
|
1125
|
+
fetch(sessionPath(provider, id, '/stream?seed=turn'), { headers: { 'X-COS-Web': '1' }, credentials: 'same-origin', signal: ctrl ? ctrl.signal : undefined })
|
|
1126
|
+
.then(function (res) { var code = res.status; if (ctrl) ctrl.abort(); return code; }, function () { return 0; })
|
|
1127
|
+
.then(function (code) {
|
|
1128
|
+
if (state.reading !== r || r.feed.lastSeq > 0) return;
|
|
1129
|
+
r.feed.fallback = code === 200 ? 'stream_lost' : code ? 'http_' + code : 'unreachable';
|
|
1130
|
+
paintActivity(r);
|
|
1131
|
+
});
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
// The live stream: one JSON object per data line, `kind` = status | prompt | tool | prose | heartbeat.
|
|
1135
|
+
function watchSession(provider, id) {
|
|
1136
|
+
var r0 = state.reading;
|
|
1137
|
+
if (typeof EventSource !== 'function') { if (r0) { r0.feed.fallback = 'unsupported'; paintActivity(r0); } return; }
|
|
1138
|
+
var es;
|
|
1139
|
+
try { es = new EventSource(sessionPath(provider, id, '/stream?seed=turn')); } catch (_e) { if (r0) { r0.feed.fallback = 'unsupported'; paintActivity(r0); } return; }
|
|
1140
|
+
state.stream = es;
|
|
1141
|
+
var wasWorking = false;
|
|
1142
|
+
es.onerror = function () {
|
|
1143
|
+
var r = state.reading;
|
|
1144
|
+
if (!r || r.id !== id || state.stream !== es) return;
|
|
1145
|
+
if (es.readyState === 2) {
|
|
1146
|
+
if (r.feed.lastSeq > 0) { r.feed.connected = false; r.feed.fallback = 'stream_lost'; paintActivity(r); }
|
|
1147
|
+
else sxProbeStream(provider, id, r);
|
|
1148
|
+
} else { r.feed.fallback = 'reconnecting'; paintActivity(r); }
|
|
1149
|
+
};
|
|
1150
|
+
es.onmessage = function (ev) {
|
|
1151
|
+
var e; try { e = JSON.parse(ev.data); } catch (_e) { return; }
|
|
1152
|
+
var r = state.reading;
|
|
1153
|
+
if (!r || r.id !== id) return;
|
|
1154
|
+
applyFeed(r, e);
|
|
1155
|
+
if (e.kind === 'status') {
|
|
1156
|
+
var working = e.state === 'working' || e.agent_state === 'running';
|
|
1157
|
+
// The stream's status trails a Stop by a few seconds; do not let that echo reopen it.
|
|
1158
|
+
if (working && r.stoppedAt && Date.now() - r.stoppedAt < 8000) working = false;
|
|
1159
|
+
var waiting = e.state === 'waiting' || e.state === 'needs_input' || e.agent_state === 'waiting';
|
|
1160
|
+
var el = $('session-state');
|
|
1161
|
+
if (el) el.textContent = working ? 'Working now' : waiting ? 'Waiting for you' : 'Open';
|
|
1162
|
+
setLive({ working: working });
|
|
1163
|
+
if (wasWorking && !working) { refreshTurns(provider, id); replyEnded(r); }
|
|
1164
|
+
wasWorking = working;
|
|
1165
|
+
} else if (e.kind === 'tool') {
|
|
1166
|
+
setLive({ step: [e.verb, e.target].filter(Boolean).join(' ') });
|
|
1167
|
+
} else if (e.kind === 'prose' && e.text) {
|
|
1168
|
+
setLive({ prose: String(e.text) });
|
|
1169
|
+
} else if (e.kind === 'prompt') {
|
|
1170
|
+
paintActivity(r);
|
|
1171
|
+
}
|
|
1172
|
+
};
|
|
1173
|
+
}
|
|
1174
|
+
function setLive(patch) {
|
|
1175
|
+
var r = state.reading; if (!r) return;
|
|
1176
|
+
r.live = Object.assign(r.live || {}, patch);
|
|
1177
|
+
if (!r.live.working) { r.live.step = null; r.live.prose = null; }
|
|
1178
|
+
paintActivity(r);
|
|
1179
|
+
}
|
|
1180
|
+
function refreshTurns(provider, id) {
|
|
1181
|
+
api(sessionPath(provider, id, '?turns=' + SX_TURNS)).then(function (d) {
|
|
1182
|
+
var r = state.reading;
|
|
1183
|
+
if (!r || r.id !== id) return;
|
|
1184
|
+
var turns = Array.isArray(d && d.recent_turns) ? d.recent_turns : [];
|
|
1185
|
+
var box = $('session-turns'); if (box) sxMergeTurns(box, r.turns || [], turns, d);
|
|
1186
|
+
// The header keeps the subtitle it opened with: a rewrap above the turns would move them.
|
|
1187
|
+
r.detail = d; r.turns = turns; r.more = d.recent_turns_more === true;
|
|
1188
|
+
renderHeld(r);
|
|
1189
|
+
loadQueue(provider, id);
|
|
1190
|
+
sxProbeAttach(r);
|
|
1191
|
+
}).catch(function () {});
|
|
1192
|
+
}
|
|
1193
|
+
// Control's queue line for a parked turn (Models.swift 1655): Next, 2nd in line, 3rd in line.
|
|
1194
|
+
function sxQueueLine(t) {
|
|
1195
|
+
var p = Number(t.position);
|
|
1196
|
+
if (!(p >= 0)) return 'Waiting';
|
|
1197
|
+
if (p === 0) return 'Next';
|
|
1198
|
+
if (p === 1) return '2nd in line';
|
|
1199
|
+
if (p === 2) return '3rd in line';
|
|
1200
|
+
return (p + 1) + 'th in line';
|
|
1201
|
+
}
|
|
1202
|
+
function loadQueue(provider, id) {
|
|
1203
|
+
apiAnswer(sessionPath(provider, id, '/queued-turns')).then(function (q) {
|
|
1204
|
+
var r = state.reading;
|
|
1205
|
+
var box = $('session-queue'); if (!box || !r || r.id !== id) return;
|
|
1206
|
+
var rows = (q.status === 200 && q.body && q.body.turns) || [];
|
|
1207
|
+
var waiting = rows.filter(function (t) { return t.status === 'waiting' || t.status === 'queued' || t.status === 'pending'; });
|
|
1208
|
+
if (q.status === 200 && r.queued !== waiting.length) { r.queued = waiting.length; paintActivity(r); }
|
|
1209
|
+
if (!waiting.length) { box.innerHTML = ''; return; }
|
|
1210
|
+
box.innerHTML = '<div class="sx-queue"><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>' +
|
|
1211
|
+
waiting.map(function (t) {
|
|
1212
|
+
var mine = t.owner !== 'codex' && parkedByMe(r, t.clientTurnId);
|
|
1213
|
+
return '<div class="sx-queue-row"><span class="sx-queue-state">' + esc(sxQueueLine(t)) + '</span><span class="sx-queue-text">' + esc(t.preview || t.prompt || 'Message') + '</span>' +
|
|
1214
|
+
(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>';
|
|
1215
|
+
}).join('') + '</div>';
|
|
1216
|
+
}).catch(function () {});
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
// What the server says about writing into this session before anything is sent (Control's
|
|
1220
|
+
// probeChatAttachability): a busy thread parks a send, another app holding it idle asks first,
|
|
1221
|
+
// and anything else is the server's refusal, word for word.
|
|
1222
|
+
function sxProbeAttach(r) {
|
|
1223
|
+
apiAnswer(sessionPath(r.provider, r.id, '/attachability')).then(function (a) {
|
|
1224
|
+
if (state.reading !== r) return;
|
|
1225
|
+
if (a.status === 404) { r.verdict = { attachable: false, reason: 'route_absent', reasonCopy: 'Update the COS server to continue a thread from here.', ownerCount: 0 }; renderVerdict(r); return; }
|
|
1226
|
+
var b = a.body || {};
|
|
1227
|
+
if (a.status !== 200 || typeof b.attachable !== 'boolean') return;
|
|
1228
|
+
var owners = Number(b.ownerCount) || 0;
|
|
1229
|
+
r.verdict = { attachable: b.attachable, reason: String(b.reason || ''), reasonCopy: String(b.reasonCopy || ''), ownerCount: owners, caution: b.attachable === true && owners > 0 };
|
|
1230
|
+
renderVerdict(r);
|
|
1231
|
+
}).catch(function () {});
|
|
1232
|
+
}
|
|
1233
|
+
var SX_PARK = {
|
|
1234
|
+
cursor: 'Working right now. A message you send goes into this Cursor chat when its turn ends.',
|
|
1235
|
+
codex: 'Working right now. A message you send joins the Codex app’s queue for this thread.',
|
|
1236
|
+
claude: 'Working right now. A message you send waits and lands when this turn ends.'
|
|
1237
|
+
};
|
|
1238
|
+
function sxSupplement(reason) {
|
|
1239
|
+
if (reason === 'native_target_busy') return 'It frees itself within 30 minutes. Detaching is not available in Control yet.';
|
|
1240
|
+
if (reason === 'native_target_fenced') return 'Fences can be released from the Fences card in this panel.';
|
|
1241
|
+
return '';
|
|
1242
|
+
}
|
|
1243
|
+
function renderVerdict(r) {
|
|
1244
|
+
var box = $('session-verdict'), line = $('session-caution');
|
|
1245
|
+
if (!box || state.reading !== r) return;
|
|
1246
|
+
var v = r.verdict, html = '';
|
|
1247
|
+
var forkBtn = '<button type="button" class="cx-btn quiet" data-action="fork-session" title="Runs your message in a copy of this thread. The original is untouched.">Fork with this message</button>';
|
|
1248
|
+
function refusalBox(copy, supplement, buttons) {
|
|
1249
|
+
return '<div class="sx-box refusal" role="status"><p>' + esc(copy) + '</p>' + (supplement ? '<p class="sx-box-sub">' + esc(supplement) + '</p>' : '') +
|
|
1250
|
+
(buttons ? '<div class="sx-box-actions">' + buttons + '</div>' : '') + '</div>';
|
|
1251
|
+
}
|
|
1252
|
+
if (r.cautionAsk) {
|
|
1253
|
+
html = '<div class="sx-box confirm" role="alertdialog" aria-labelledby="sx-caution-title" aria-describedby="sx-caution-body"><strong id="sx-caution-title">Send into an open session?</strong>' +
|
|
1254
|
+
'<p id="sx-caution-body">Another app on this Mac has this session open. It looks idle right now, but a reply will run with that session’s own permissions.</p>' +
|
|
1255
|
+
'<div class="sx-box-actions"><button type="button" class="cx-btn danger" data-sx="caution-send">Send anyway</button><button type="button" class="cx-btn quiet" data-sx="caution-cancel">Cancel</button></div></div>';
|
|
1256
|
+
} else if (r.refusal) {
|
|
1257
|
+
var f = r.refusal;
|
|
1258
|
+
html = refusalBox(f.copy, f.supplement,
|
|
1259
|
+
(f.changed ? '<button type="button" class="cx-btn quiet" data-sx="refresh-turns">Refresh</button><button type="button" class="cx-btn quiet" data-action="send-anyway">Continue anyway</button>' : '') +
|
|
1260
|
+
(f.retry ? '<button type="button" class="cx-btn quiet" data-sx="retry">Retry</button>' : '') +
|
|
1261
|
+
(f.fork ? forkBtn : ''));
|
|
1262
|
+
} else if (v && v.attachable === false && /^(native_thread_working|native_target_busy)$/.test(v.reason)) {
|
|
1263
|
+
html = '<div class="sx-park"><p>' + esc(SX_PARK[sxProvider(r.provider)]) + '</p>' + (/fork/i.test(v.reasonCopy) ? '<div class="sx-box-actions">' + forkBtn + '</div>' : '') + '</div>';
|
|
1264
|
+
} else if (v && v.attachable === false && v.reasonCopy) {
|
|
1265
|
+
html = refusalBox(v.reasonCopy, sxSupplement(v.reason), /fork/i.test(v.reasonCopy) ? forkBtn : '');
|
|
1266
|
+
}
|
|
1267
|
+
box.innerHTML = html;
|
|
1268
|
+
if (line) {
|
|
1269
|
+
var caution = v && v.caution ? (sxProvider(r.provider) === 'codex'
|
|
1270
|
+
? 'The Codex app has this thread open. COS will ask before the first send, then hands your message to the app’s own queue, where it shows.'
|
|
1271
|
+
: 'Another app on this Mac has this session open. COS will ask before the first send. The reply lands in the transcript; the open desk tab will not show it until you resume.') : '';
|
|
1272
|
+
line.textContent = caution; line.classList.toggle('hidden', !caution);
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
function sessionStatus(text, kind, actionHtml) {
|
|
1277
|
+
var el = $('session-status'); if (!el) return;
|
|
1278
|
+
el.className = 'status-line' + (kind === 'error' ? ' error' : '');
|
|
1279
|
+
el.innerHTML = esc(text || '') + (actionHtml || '');
|
|
1280
|
+
}
|
|
1281
|
+
function draftFor(text) {
|
|
1282
|
+
var r = state.reading;
|
|
1283
|
+
if (!r.draft || r.draft.text !== text) r.draft = { text: text, key: newTurnKey() };
|
|
1284
|
+
return r.draft;
|
|
1285
|
+
}
|
|
1286
|
+
// The box is cleared only of the very words that were sent; anything typed since stays.
|
|
1287
|
+
function clearBoxOf(text) {
|
|
1288
|
+
var input = $('session-input');
|
|
1289
|
+
if (input && input.value.trim() === String(text).trim()) { input.value = ''; autoGrow(input); }
|
|
1290
|
+
if (state.reading && state.reading.draft && state.reading.draft.text === text) state.reading.draft = null;
|
|
1291
|
+
}
|
|
1292
|
+
// Words go back in the box only if the box is empty; otherwise the status line keeps them.
|
|
1293
|
+
function returnToBox(text, why) {
|
|
1294
|
+
var input = $('session-input');
|
|
1295
|
+
if (input && !input.value.trim()) { input.value = text; autoGrow(input); sessionStatus(why + ' Your message is back in the box.', 'error'); }
|
|
1296
|
+
else sessionStatus(why + ' Not sent: “' + String(text).slice(0, 80) + (text.length > 80 ? '…' : '') + '”', 'error');
|
|
1297
|
+
}
|
|
1298
|
+
// Your words show in the conversation the moment the Mac accepts them; the next refresh of
|
|
1299
|
+
// the transcript replaces the bubble with the real turn.
|
|
1300
|
+
function showPendingTurn(text, note, heldKey) {
|
|
1301
|
+
var box = $('session-turns'); if (!box) return;
|
|
1302
|
+
var ph = box.querySelector('.reader-placeholder'); if (ph) ph.remove();
|
|
1303
|
+
var el = document.createElement('div');
|
|
1304
|
+
el.className = 'turn user pending';
|
|
1305
|
+
if (heldKey) el.setAttribute('data-held-key', heldKey);
|
|
1306
|
+
el.innerHTML = '<span class="who">You, ' + esc(note) + '</span>' + md(text);
|
|
1307
|
+
box.appendChild(el);
|
|
1308
|
+
requestAnimationFrame(function () { window.scrollTo(0, document.documentElement.scrollHeight); });
|
|
1309
|
+
}
|
|
1310
|
+
function heldBubble(key) { return document.querySelector('#session-turns .turn.pending[data-held-key="' + key.replace(/[^A-Za-z0-9_-]/g, '') + '"]'); }
|
|
1311
|
+
function renderHeld(r) {
|
|
1312
|
+
r.held.forEach(function (h) { if (!h.cancelled && !heldBubble(h.key)) showPendingTurn(h.text, 'sends after this reply', h.key); });
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
function sendSessionTurn(fork, opts) {
|
|
1316
|
+
var r = state.reading; var form = $('session-form');
|
|
1317
|
+
if (!r || !form) return;
|
|
1318
|
+
var input = $('session-input'), text = input.value.trim();
|
|
1319
|
+
if (!text) { sessionStatus(fork ? 'Write what the copy should do first.' : 'Write a message first.', 'error'); return; }
|
|
1320
|
+
// Control's caution (SessionChatVerdict.caution): another app holds this session and looks
|
|
1321
|
+
// idle, so the first send asks before it writes. A fork writes to a copy and never asks.
|
|
1322
|
+
if (!fork && r.verdict && r.verdict.caution && !r.cautionAck) { r.cautionAsk = true; renderVerdict(r); return; }
|
|
1323
|
+
// One send at a time: Enter submits the form even while Send is disabled, and a second send
|
|
1324
|
+
// during the first attach could park the same text in the queue while the first lands.
|
|
1325
|
+
if (r.sending) return;
|
|
1326
|
+
if (r.refusal || r.cautionAsk) { r.refusal = null; r.cautionAsk = false; renderVerdict(r); }
|
|
1327
|
+
r.sending = true;
|
|
1328
|
+
var btns = form.querySelectorAll('button'); btns.forEach(function (b) { b.disabled = true; });
|
|
1329
|
+
var done = function () { r.sending = false; btns.forEach(function (b) { b.disabled = false; }); };
|
|
1330
|
+
(fork ? forkSession(r, text) : continueSession(r, text, opts || {})).then(done, function (err) {
|
|
1331
|
+
done();
|
|
1332
|
+
if (state.reading !== r) return;
|
|
1333
|
+
var why = err && err.name === 'AbortError'
|
|
1334
|
+
? (fork ? 'No answer from your Mac yet. The copy may still be made; tapping Fork again is safe, it will not make a second one.' : 'No answer from your Mac yet. It may still land. Sending again is safe; it will not post twice.')
|
|
1335
|
+
: (err && err.message) || 'Could not reach your Mac.';
|
|
1336
|
+
sessionStatus('');
|
|
1337
|
+
r.retryFork = !!fork;
|
|
1338
|
+
r.refusal = { copy: why, retry: true, fork: !fork };
|
|
1339
|
+
renderVerdict(r);
|
|
1340
|
+
});
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
// One attempt to deliver `text`. `opts.held` is a message waiting for a reply to end: its
|
|
1344
|
+
// attempts never touch the box or the status line unless they settle it.
|
|
1345
|
+
// Resolves to 'sent' | 'parked' | 'held' | 'refused' | 'changed'.
|
|
1346
|
+
function continueSession(r, text, opts) {
|
|
1347
|
+
var provider = r.provider, id = r.id;
|
|
1348
|
+
var draft = opts.held ? { text: opts.held.text, key: opts.held.key } : draftFor(text);
|
|
1349
|
+
if (!opts.held) sessionStatus('Sending');
|
|
1350
|
+
var bound = bindingFor(provider, id);
|
|
1351
|
+
var attach = bound ? Promise.resolve({ ok: true, binding: bound }) : apiAnswer(sessionPath(provider, id, '/attach'), { method: 'POST', body: { cosSessionId: cosSessionId() }, timeoutMs: 20000 }).then(function (a) {
|
|
1352
|
+
// 201 Created on success; any 2xx that says attached counts.
|
|
1353
|
+
if (a.status >= 200 && a.status < 300 && a.body.attached === true && a.body.bindingId && typeof a.body.epoch === 'number') {
|
|
1354
|
+
var b = { bindingId: a.body.bindingId, epoch: a.body.epoch, at: Date.now() };
|
|
1355
|
+
rememberBinding(provider, id, b);
|
|
1356
|
+
return { ok: true, binding: b };
|
|
1357
|
+
}
|
|
1358
|
+
return { ok: false, refusal: a.body };
|
|
1359
|
+
});
|
|
1360
|
+
return attach.then(function (at) {
|
|
1361
|
+
// Busy right now (the Mac is writing to it, or another binding holds it): park the
|
|
1362
|
+
// message in the session's own queue; the server delivers it when the session is free.
|
|
1363
|
+
// Any other refusal is permanent for this thread, and a parked message would never go.
|
|
1364
|
+
if (!at.ok) {
|
|
1365
|
+
var why = (at.refusal && at.refusal.reason) || '';
|
|
1366
|
+
if (PARKABLE_REFUSALS.test(why)) return parkTurn(r, draft, at.refusal, opts);
|
|
1367
|
+
var copy = ((at.refusal && at.refusal.reasonCopy) || 'COS cannot write to this session right now.') + ' Fork sends your message to a copy instead.';
|
|
1368
|
+
if (opts.held) settleHeld(r, opts.held, 'refused', copy);
|
|
1369
|
+
else {
|
|
1370
|
+
// Control's refusal box: the server's words, then Fork with this message.
|
|
1371
|
+
sessionStatus('');
|
|
1372
|
+
r.refusal = { copy: (at.refusal && at.refusal.reasonCopy) || 'COS cannot write to this session right now.', supplement: sxSupplement(why), fork: true };
|
|
1373
|
+
renderVerdict(r);
|
|
1374
|
+
}
|
|
1375
|
+
return 'refused';
|
|
1376
|
+
}
|
|
1377
|
+
return apiAnswer('/api/agent-sessions/bindings/' + encodeURIComponent(at.binding.bindingId) + '/turns', {
|
|
1378
|
+
method: 'POST', timeoutMs: 25000,
|
|
1379
|
+
body: Object.assign({ prompt: draft.text, epoch: at.binding.epoch, targetKey: targetKey(provider, id), clientTurnId: draft.key }, opts.acknowledgedRevision ? { acknowledgedRevision: opts.acknowledgedRevision } : {}),
|
|
1380
|
+
}).then(function (t) {
|
|
1381
|
+
var b = t.body || {};
|
|
1382
|
+
if (t.status >= 200 && t.status < 300 && (b.outcome === 'queued' || b.outcome === 'completed')) {
|
|
1383
|
+
if (opts.held) settleHeld(r, opts.held, 'sent');
|
|
1384
|
+
else { showPendingTurn(draft.text, b.replayed ? 'already sent' : 'sent'); clearBoxOf(draft.text); }
|
|
1385
|
+
sessionStatus(b.replayed ? 'Already sent. Watching for the reply.' : 'Sent. Watching for the reply.');
|
|
1386
|
+
watchTurn(at.binding.bindingId, draft.key, provider, id);
|
|
1387
|
+
return 'sent';
|
|
1388
|
+
}
|
|
1389
|
+
var reason = b.reason || '';
|
|
1390
|
+
if (reason === 'native_thread_working') return parkTurn(r, draft, b, opts);
|
|
1391
|
+
// COS's own reply is still running on this thread. The server's queue counts that
|
|
1392
|
+
// thread as free (the running child is COS's), so it cannot park this; the page holds
|
|
1393
|
+
// it under the same key and sends it the moment the reply ends.
|
|
1394
|
+
if (reason === 'native_turn_in_progress') { if (!opts.held) holdUntilReplyEnds(r, draft); return 'held'; }
|
|
1395
|
+
if (reason === 'native_thread_changed' && b.revision) {
|
|
1396
|
+
if (opts.held) settleHeld(r, opts.held, 'refused', 'This session changed on your Mac while your message waited.');
|
|
1397
|
+
else {
|
|
1398
|
+
// Control's refusal box for a changed thread: Refresh, Continue anyway, Fork with this message.
|
|
1399
|
+
r.sendAnyway = b.revision;
|
|
1400
|
+
sessionStatus('');
|
|
1401
|
+
r.refusal = { copy: b.reasonCopy || 'This session changed on your Mac since you opened it.', changed: true, fork: true };
|
|
1402
|
+
renderVerdict(r);
|
|
1403
|
+
}
|
|
1404
|
+
return 'changed';
|
|
1405
|
+
}
|
|
1406
|
+
if (/^(stale_epoch|binding_expired|binding_not_active|binding_unusable|unknown_binding)$/.test(reason) && !opts.reattached) {
|
|
1407
|
+
rememberBinding(provider, id, null);
|
|
1408
|
+
return continueSession(r, text, Object.assign({}, opts, { reattached: true }));
|
|
1409
|
+
}
|
|
1410
|
+
var refusedCopy = b.reasonCopy || 'The session did not take that message.';
|
|
1411
|
+
if (opts.held) settleHeld(r, opts.held, 'refused', refusedCopy);
|
|
1412
|
+
else sessionStatus(refusedCopy + ' It is still in the box.', 'error');
|
|
1413
|
+
return 'refused';
|
|
1414
|
+
});
|
|
1415
|
+
});
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
var HELD_MAX_MS = 5 * 60000, HELD_RETRY_MS = 8000;
|
|
1419
|
+
function holdUntilReplyEnds(r, draft) {
|
|
1420
|
+
if (!r.held.some(function (h) { return h.key === draft.key; })) {
|
|
1421
|
+
r.held.push({ text: draft.text, key: draft.key, since: Date.now() });
|
|
1422
|
+
showPendingTurn(draft.text, 'sends after this reply', draft.key);
|
|
1423
|
+
saveHeld(r);
|
|
1424
|
+
}
|
|
1425
|
+
clearBoxOf(draft.text);
|
|
1426
|
+
sessionStatus('COS is still answering. ' + (r.held.length > 1 ? r.held.length + ' messages send' : 'Yours sends') + ' as soon as it finishes. ', null, '<button type="button" class="link-btn" data-action="drop-after">Don’t send</button>');
|
|
1427
|
+
startHeldTimer(r);
|
|
1428
|
+
}
|
|
1429
|
+
function startHeldTimer(r) {
|
|
1430
|
+
clearInterval(r.heldTimer);
|
|
1431
|
+
r.heldTimer = setInterval(function () { flushHeld(r); }, HELD_RETRY_MS);
|
|
1432
|
+
}
|
|
1433
|
+
function replyEnded(r) { if (r && r.held && r.held.length) setTimeout(function () { flushHeld(r); }, 800); }
|
|
1434
|
+
// Tries the oldest held message once. A "still answering" answer changes nothing on screen.
|
|
1435
|
+
function flushHeld(r) {
|
|
1436
|
+
if (state.reading !== r) { clearInterval(r.heldTimer); return; }
|
|
1437
|
+
var next = r.held.filter(function (h) { return !h.cancelled; })[0];
|
|
1438
|
+
if (!next) { clearInterval(r.heldTimer); return; }
|
|
1439
|
+
if (r.flushing) return;
|
|
1440
|
+
if (Date.now() - next.since > HELD_MAX_MS) { settleHeld(r, next, 'timeout', 'The session stayed busy for five minutes.'); return; }
|
|
1441
|
+
r.flushing = next;
|
|
1442
|
+
continueSession(r, next.text, { held: next }).then(function () {}, function () { /* no answer: keep it, retry */ })
|
|
1443
|
+
.finally(function () { r.flushing = null; if (next.cancelled && r.held.indexOf(next) >= 0) settleHeld(r, next, 'cancelled'); });
|
|
1444
|
+
}
|
|
1445
|
+
// Ends one held message: sent, refused, timed out or cancelled.
|
|
1446
|
+
function settleHeld(r, h, how, copy) {
|
|
1447
|
+
r.held = r.held.filter(function (x) { return x !== h; });
|
|
1448
|
+
saveHeld(r);
|
|
1449
|
+
var bubble = heldBubble(h.key);
|
|
1450
|
+
if (how === 'sent') {
|
|
1451
|
+
if (bubble) { bubble.removeAttribute('data-held-key'); bubble.querySelector('.who').textContent = 'You, sent'; }
|
|
1452
|
+
if (h.cancelled) sessionStatus('Too late to stop it: that message had already gone.');
|
|
1453
|
+
} else if (how === 'parked') {
|
|
1454
|
+
if (bubble) { bubble.removeAttribute('data-held-key'); bubble.querySelector('.who').textContent = 'You, waiting in line'; }
|
|
1455
|
+
} else {
|
|
1456
|
+
if (bubble) bubble.remove();
|
|
1457
|
+
if (how === 'cancelled') returnToBox(h.text, 'Not sent.');
|
|
1458
|
+
else returnToBox(h.text, copy || 'Not sent.');
|
|
1459
|
+
}
|
|
1460
|
+
if (!r.held.some(function (x) { return !x.cancelled; })) clearInterval(r.heldTimer);
|
|
1461
|
+
}
|
|
1462
|
+
function dropHeld() {
|
|
1463
|
+
var r = state.reading; if (!r || !r.held.length) return;
|
|
1464
|
+
r.held.slice().forEach(function (h) {
|
|
1465
|
+
h.cancelled = true;
|
|
1466
|
+
if (r.flushing !== h) settleHeld(r, h, 'cancelled');
|
|
1467
|
+
});
|
|
1468
|
+
saveHeld(r);
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
function parkTurn(r, draft, refusal, opts) {
|
|
1472
|
+
return apiAnswer(sessionPath(r.provider, r.id, '/queued-turns'), {
|
|
1473
|
+
method: 'POST', timeoutMs: 15000,
|
|
1474
|
+
body: { cosSessionId: cosSessionId(), clientTurnId: draft.key, prompt: draft.text },
|
|
1475
|
+
}).then(function (q) {
|
|
1476
|
+
var b = q.body || {};
|
|
1477
|
+
if (b.queued === true || b.error === 'duplicate_turn') {
|
|
1478
|
+
rememberParked(r, draft.key);
|
|
1479
|
+
if (opts.held) settleHeld(r, opts.held, 'parked');
|
|
1480
|
+
else { showPendingTurn(draft.text, 'waiting in line'); clearBoxOf(draft.text); }
|
|
1481
|
+
sessionStatus(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.'));
|
|
1482
|
+
loadQueue(r.provider, r.id);
|
|
1483
|
+
return 'parked';
|
|
1484
|
+
}
|
|
1485
|
+
if (b.error === 'thread_free' && !opts.freeRetry) return continueSession(r, draft.text, Object.assign({}, opts, { freeRetry: true }));
|
|
1486
|
+
var copy = (refusal && refusal.reasonCopy) || (b.error === 'queue_full' ? 'That session already has a full line of waiting messages.' : 'COS could not send or queue that.');
|
|
1487
|
+
if (opts.held) settleHeld(r, opts.held, 'refused', copy); else sessionStatus(copy + ' It is still in the box.', 'error');
|
|
1488
|
+
return 'refused';
|
|
1489
|
+
});
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
// Follow one sent turn to its end (completed, refused, or ambiguous) for up to 3 minutes.
|
|
1493
|
+
function watchTurn(bindingId, key, provider, id) {
|
|
1494
|
+
clearInterval(state.turnPoll);
|
|
1495
|
+
var started = Date.now();
|
|
1496
|
+
state.turnPoll = setInterval(function () {
|
|
1497
|
+
var r = state.reading;
|
|
1498
|
+
if (!r || r.id !== id || Date.now() - started > 180000) { clearInterval(state.turnPoll); return; }
|
|
1499
|
+
apiAnswer('/api/agent-sessions/bindings/' + encodeURIComponent(bindingId) + '/turns/' + encodeURIComponent(key)).then(function (s) {
|
|
1500
|
+
var o = s.body && s.body.outcome;
|
|
1501
|
+
if (o === 'completed') {
|
|
1502
|
+
clearInterval(state.turnPoll); sessionStatus('Done.'); refreshTurns(provider, id);
|
|
1503
|
+
// The finished turn is the authority; the stream's idle status can trail it.
|
|
1504
|
+
setLive({ working: false }); var st = $('session-state'); if (st) st.textContent = 'Open';
|
|
1505
|
+
replyEnded(r);
|
|
1506
|
+
} else if (o === 'refused') { clearInterval(state.turnPoll); sessionStatus(s.body.reasonCopy || 'The session refused that message.', 'error'); replyEnded(r); }
|
|
1507
|
+
else if (o === 'ambiguous') { clearInterval(state.turnPoll); sessionStatus(s.body.reasonCopy || 'COS cannot tell whether that landed. Look at the session before sending it again.', 'error'); }
|
|
1508
|
+
}).catch(function () {});
|
|
1509
|
+
}, 3000);
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
// Fork copies the session and runs the message there. One key per draft, so a second tap or
|
|
1513
|
+
// a resend after a timeout finds the same job instead of making a second copy.
|
|
1514
|
+
function forkSession(r, text) {
|
|
1515
|
+
if (!r.forkDraft || r.forkDraft.text !== text) r.forkDraft = { text: text, key: 'fk' + newTurnKey().slice(2) };
|
|
1516
|
+
var forkId = r.forkDraft.key;
|
|
1517
|
+
sessionStatus('Copying the session. This can take up to a minute.');
|
|
1518
|
+
var settle = function (b) {
|
|
1519
|
+
if (b.forked === true) {
|
|
1520
|
+
r.forkDraft = null; clearBoxOf(text);
|
|
1521
|
+
sessionStatus('Forked. The copy has your message and shows in Sessions in a moment.');
|
|
1522
|
+
setTimeout(function () { if (state.pane === 'sessions') loadSessions(true); }, 4000);
|
|
1523
|
+
return true;
|
|
1524
|
+
}
|
|
1525
|
+
if (b.forked === false || b.state === 'failed' || b.state === 'refused') { sessionStatus((b.reasonCopy || 'COS could not copy that session.'), 'error'); return true; }
|
|
1526
|
+
return false;
|
|
1527
|
+
};
|
|
1528
|
+
return apiAnswer(sessionPath(r.provider, r.id, '/fork'), { method: 'POST', timeoutMs: 65000, body: { prompt: text, cosSessionId: cosSessionId(), clientForkId: forkId } }).then(function (f) {
|
|
1529
|
+
if (settle(f.body || {})) return;
|
|
1530
|
+
if (f.status !== 202) { sessionStatus('COS could not copy that session.', 'error'); return; }
|
|
1531
|
+
// 202: running in the background. Follow its job for up to three minutes.
|
|
1532
|
+
var started = Date.now();
|
|
1533
|
+
return new Promise(function (resolve) {
|
|
1534
|
+
var poll = setInterval(function () {
|
|
1535
|
+
if (state.reading !== r || Date.now() - started > 180000) { clearInterval(poll); resolve(); return; }
|
|
1536
|
+
apiAnswer('/api/agent-session-forks/' + encodeURIComponent(forkId)).then(function (j) {
|
|
1537
|
+
if (settle(j.body || {})) { clearInterval(poll); resolve(); }
|
|
1538
|
+
}).catch(function () {});
|
|
1539
|
+
}, 3000);
|
|
1540
|
+
});
|
|
1541
|
+
});
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
function stopSession() {
|
|
1545
|
+
var r = state.reading; if (!r) return;
|
|
1546
|
+
sessionStatus('Stopping');
|
|
1547
|
+
apiAnswer(sessionPath(r.provider, r.id, '/cancel'), { method: 'POST', timeoutMs: 15000, body: { clientCancelId: newTurnKey() } }).then(function (c) {
|
|
1548
|
+
var b = c.body || {};
|
|
1549
|
+
sessionStatus(b.reasonCopy || (b.cancelled ? 'Stopped.' : 'COS could not stop it.'), b.cancelled ? null : 'error');
|
|
1550
|
+
if (b.cancelled) {
|
|
1551
|
+
r.stoppedAt = Date.now();
|
|
1552
|
+
clearInterval(state.turnPoll);
|
|
1553
|
+
setLive({ working: false }); var st = $('session-state'); if (st) st.textContent = 'Open';
|
|
1554
|
+
setTimeout(function () { if (state.reading === r) refreshTurns(r.provider, r.id); }, 1500);
|
|
1555
|
+
}
|
|
1556
|
+
}).catch(function (err) { sessionStatus(err.message, 'error'); });
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
function removeQueued(key) {
|
|
1560
|
+
var r = state.reading; if (!r || !key || !parkedByMe(r, key)) return;
|
|
1561
|
+
apiAnswer(sessionPath(r.provider, r.id, '/queued-turns/' + encodeURIComponent(key)), { method: 'DELETE' }).then(function (d) {
|
|
1562
|
+
sessionStatus(d.body && d.body.cancelled ? 'Removed from the line.' : ((d.body && d.body.reasonCopy) || 'It may already be sending.'), d.body && d.body.cancelled ? null : 'error');
|
|
1563
|
+
loadQueue(r.provider, r.id);
|
|
1564
|
+
});
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
// Copy session (Control's Kickstart brief, HelperSources/main.swift claudeKickstartCopy). Control
|
|
1568
|
+
// builds it from the Mac's transcript; the web builds it from the turns the server serves and
|
|
1569
|
+
// the session's first prompt, so it keeps the original request and the newest context.
|
|
1570
|
+
function sxKickstart(r) {
|
|
1571
|
+
var d = r.detail || {}, p = sxProvider(r.provider);
|
|
1572
|
+
var name = p === 'codex' ? 'Codex' : p === 'cursor' ? 'Cursor' : 'Claude Code';
|
|
1573
|
+
var title = sxTitle(d);
|
|
1574
|
+
var branch = d.git_branch && d.git_branch !== 'unknown' ? d.git_branch : '(unknown)';
|
|
1575
|
+
var out = '# Kickstart: ' + (title || name + ' session') + '\n\n' +
|
|
1576
|
+
'Read-only export of a ' + name + ' session. Continue this work here. Do not look for ' + name + ' jsonl or session IDs to resume.\n\n' +
|
|
1577
|
+
'- Workspace: ' + (d.project || '(unknown)') + '\n- Branch: ' + branch + '\n- Session: ' + (d.session_id || r.id) +
|
|
1578
|
+
(Number(d.omitted_tools) > 0 ? '\n- Tool calls omitted: ' + d.omitted_tools : '') + '\n\n';
|
|
1579
|
+
var turns = (r.turns || []).filter(function (t) { return String(t.text || '').trim(); });
|
|
1580
|
+
var firstUser = turns.filter(function (t) { return t.role === 'user'; })[0];
|
|
1581
|
+
var original = String(d.first_prompt || '').trim() || (firstUser ? String(firstUser.text).trim() : '');
|
|
1582
|
+
out += original ? '## Original request\n\n' + original + '\n\n## Conversation\n\n' : '## Conversation\n\n';
|
|
1583
|
+
if (r.more) out += '[Older turns omitted. COS serves the newest ' + turns.length + '. Original request kept.]\n\n';
|
|
1584
|
+
turns.forEach(function (t) {
|
|
1585
|
+
var text = String(t.text).trim();
|
|
1586
|
+
if (original && t.role === 'user' && text === original) return;
|
|
1587
|
+
out += '**' + (t.role === 'user' ? 'You' : 'Assistant') + '**\n\n' + text + '\n\n';
|
|
1588
|
+
});
|
|
1589
|
+
return out.length > 100000 ? out.slice(0, 100000) + '\n[truncated]\n' : out;
|
|
1590
|
+
}
|
|
1591
|
+
function copySessionBrief() {
|
|
1592
|
+
var r = state.reading, note = $('session-copy-note');
|
|
1593
|
+
if (!r || !r.detail) return;
|
|
1594
|
+
var text = sxKickstart(r);
|
|
1595
|
+
var say = function (msg) { if (note) { note.textContent = msg; note.classList.remove('hidden'); } };
|
|
1596
|
+
if (!text.trim()) return say('Nothing to copy');
|
|
1597
|
+
copyText(text).then(function () { say('Copied kickstart for another agent'); }, function (err) { say((err && err.message) || 'This browser would not copy.'); });
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
// Markdown for an assistant turn (Control renders replies with COSMarkdownView): headings, lists,
|
|
1601
|
+
// code, quotes, tables, rules, links. Everything is escaped first, so a reply can never add
|
|
1602
|
+
// markup of its own; a link keeps only an http(s) address.
|
|
1603
|
+
function sxInline(s) {
|
|
1604
|
+
var codes = [];
|
|
1605
|
+
s = s.replace(/`([^`\n]+)`/g, function (_m, c) { codes.push(c); return '\u0000' + (codes.length - 1) + '\u0000'; });
|
|
1606
|
+
s = s.replace(/\[([^\]\n]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
|
|
1607
|
+
s = s.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>').replace(/__([^_\n]+)__/g, '<strong>$1</strong>');
|
|
1608
|
+
s = s.replace(/(^|[^*\w])\*([^*\s][^*\n]*?)\*(?!\w)/g, '$1<em>$2</em>');
|
|
1609
|
+
s = s.replace(/~~([^~\n]+)~~/g, '<del>$1</del>');
|
|
1610
|
+
return s.replace(/\u0000(\d+)\u0000/g, function (_m, i) { return '<code>' + codes[Number(i)] + '</code>'; });
|
|
1611
|
+
}
|
|
1612
|
+
var SX_LIST = /^(\s*)([-*+]|\d{1,3}[.)])\s+(.*)$/;
|
|
1613
|
+
var SX_RULE = /^\s*([-*_])(\s*\1){2,}\s*$/;
|
|
1614
|
+
var SX_TABLE_SEP = /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$/;
|
|
1615
|
+
function sxCells(row) { return row.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map(function (c) { return c.trim(); }); }
|
|
1616
|
+
function sxMd(text) {
|
|
1617
|
+
var lines = esc(String(text || '')).replace(/\r\n?/g, '\n').split('\n');
|
|
1618
|
+
var out = [], para = [], i = 0;
|
|
1619
|
+
function flush() { if (para.length) { out.push('<p>' + para.map(sxInline).join('<br>') + '</p>'); para = []; } }
|
|
1620
|
+
while (i < lines.length) {
|
|
1621
|
+
var line = lines[i];
|
|
1622
|
+
if (/^\s*(```|~~~)/.test(line)) {
|
|
1623
|
+
flush();
|
|
1624
|
+
var code = [];
|
|
1625
|
+
i += 1;
|
|
1626
|
+
while (i < lines.length && !/^\s*(```|~~~)/.test(lines[i])) { code.push(lines[i]); i += 1; }
|
|
1627
|
+
i += 1;
|
|
1628
|
+
out.push('<pre><code>' + code.join('\n') + '</code></pre>');
|
|
1629
|
+
continue;
|
|
1630
|
+
}
|
|
1631
|
+
if (!line.trim()) { flush(); i += 1; continue; }
|
|
1632
|
+
var h = /^\s{0,3}(#{1,6})\s+(.*?)\s*#*\s*$/.exec(line);
|
|
1633
|
+
if (h) { flush(); var lvl = Math.min(6, h[1].length + 2); out.push('<h' + lvl + '>' + sxInline(h[2]) + '</h' + lvl + '>'); i += 1; continue; }
|
|
1634
|
+
if (SX_RULE.test(line)) { flush(); out.push('<hr>'); i += 1; continue; }
|
|
1635
|
+
if (line.indexOf('|') >= 0 && i + 1 < lines.length && SX_TABLE_SEP.test(lines[i + 1])) {
|
|
1636
|
+
flush();
|
|
1637
|
+
var head = sxCells(line), aligns = sxCells(lines[i + 1]).map(function (a) { return /^:-+:$/.test(a) ? 'center' : /-:$/.test(a) ? 'right' : ''; });
|
|
1638
|
+
var cell = function (tag, c, k) { return '<' + tag + (aligns[k] ? ' style="text-align:' + aligns[k] + '"' : '') + '>' + sxInline(c) + '</' + tag + '>'; };
|
|
1639
|
+
i += 2;
|
|
1640
|
+
var body = [];
|
|
1641
|
+
while (i < lines.length && lines[i].indexOf('|') >= 0 && lines[i].trim()) { body.push(sxCells(lines[i])); i += 1; }
|
|
1642
|
+
out.push('<div class="sx-table"><table><thead><tr>' + head.map(function (c, k) { return cell('th', c, k); }).join('') + '</tr></thead><tbody>' +
|
|
1643
|
+
body.map(function (row) { return '<tr>' + head.map(function (_c, k) { return cell('td', row[k] || '', k); }).join('') + '</tr>'; }).join('') + '</tbody></table></div>');
|
|
1644
|
+
continue;
|
|
1645
|
+
}
|
|
1646
|
+
if (/^\s*>/.test(line)) {
|
|
1647
|
+
flush();
|
|
1648
|
+
var quote = [];
|
|
1649
|
+
while (i < lines.length && /^\s*>/.test(lines[i])) { quote.push(lines[i].replace(/^\s*>\s?/, '')); i += 1; }
|
|
1650
|
+
out.push('<blockquote>' + sxMd.unescaped(quote) + '</blockquote>');
|
|
1651
|
+
continue;
|
|
1652
|
+
}
|
|
1653
|
+
if (SX_LIST.test(line)) { flush(); var list = sxListBlock(lines, i); out.push(list.html); i = list.next; continue; }
|
|
1654
|
+
para.push(line.trim());
|
|
1655
|
+
i += 1;
|
|
1656
|
+
}
|
|
1657
|
+
flush();
|
|
1658
|
+
return out.join('');
|
|
1659
|
+
}
|
|
1660
|
+
// A quote's lines are already escaped: paragraphs and line breaks only, inline styles kept.
|
|
1661
|
+
sxMd.unescaped = function (lines) {
|
|
1662
|
+
return lines.join('\n').split(/\n\s*\n/).map(function (p) { return '<p>' + p.split('\n').map(sxInline).join('<br>') + '</p>'; }).join('');
|
|
1663
|
+
};
|
|
1664
|
+
// Nested lists by indent: a deeper item opens a list inside the open item, a shallower one closes back.
|
|
1665
|
+
function sxListBlock(lines, i) {
|
|
1666
|
+
var html = '', stack = [];
|
|
1667
|
+
while (i < lines.length) {
|
|
1668
|
+
var m = SX_LIST.exec(lines[i]);
|
|
1669
|
+
if (!m) {
|
|
1670
|
+
if (!lines[i].trim() && i + 1 < lines.length && SX_LIST.test(lines[i + 1])) { i += 1; continue; }
|
|
1671
|
+
if (stack.length && /^\s{2,}\S/.test(lines[i])) { html += '<br>' + sxInline(lines[i].trim()); i += 1; continue; }
|
|
1672
|
+
break;
|
|
1673
|
+
}
|
|
1674
|
+
var indent = m[1].replace(/\t/g, ' ').length, tag = /\d/.test(m[2]) ? 'ol' : 'ul';
|
|
1675
|
+
var start = tag === 'ol' ? parseInt(m[2], 10) : 1;
|
|
1676
|
+
var open = '<' + tag + (tag === 'ol' && start > 1 ? ' start="' + start + '"' : '') + '>';
|
|
1677
|
+
if (!stack.length || indent > stack[stack.length - 1].indent) { html += open; stack.push({ indent: indent, tag: tag }); }
|
|
1678
|
+
else {
|
|
1679
|
+
while (stack.length > 1 && indent < stack[stack.length - 1].indent) html += '</li></' + stack.pop().tag + '>';
|
|
1680
|
+
html += '</li>';
|
|
1681
|
+
if (stack[stack.length - 1].tag !== tag) { html += '</' + stack.pop().tag + '>' + open; stack.push({ indent: indent, tag: tag }); }
|
|
1682
|
+
}
|
|
1683
|
+
html += '<li>' + sxInline(m[3]);
|
|
1684
|
+
i += 1;
|
|
1685
|
+
}
|
|
1686
|
+
while (stack.length) html += '</li></' + stack.pop().tag + '>';
|
|
1687
|
+
return { html: html, next: i };
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
// YOU and ASSISTANT cards, shown whole (Control: "a reply is Markdown; what you typed is shown as
|
|
1691
|
+
// you typed it"). The server shapes each turn to 4,000 characters with a visible ellipsis.
|
|
1692
|
+
function renderTurns(turns, d) {
|
|
1693
|
+
if (!turns.length) return '<p class="reader-placeholder">This session has no user or assistant prose stored.</p>';
|
|
1694
|
+
return turns.map(sxTurnHtml).join('');
|
|
1695
|
+
}
|
|
1696
|
+
// One turn's identity, for telling which turns a refresh brought that the page already shows.
|
|
1697
|
+
function sxTurnSig(t) {
|
|
1698
|
+
var text = String(t.text || t.content || '').trim();
|
|
1699
|
+
return (String(t.role || '').toLowerCase() === 'user' ? 'u' : 'a') + '|' + (t.at || '') + '|' + text.length + '|' + text.slice(0, 48);
|
|
1700
|
+
}
|
|
1701
|
+
function sxTurnHtml(t) {
|
|
1702
|
+
var role = String(t.role || '').toLowerCase() === 'user' ? 'user' : 'assistant';
|
|
1703
|
+
var text = String(t.text || t.content || '').trim();
|
|
1704
|
+
var at = toDate(t.at);
|
|
1705
|
+
var when = at ? (Date.now() - at.getTime() < 86400000 ? clock(at) : sxShortDate(at)) : '';
|
|
1706
|
+
return '<div class="turn ' + role + ' sx-turn" data-sig="' + esc(sxTurnSig(t)) + '"><span class="who"><span>' + (role === 'user' ? 'YOU' : 'ASSISTANT') + '</span>' +
|
|
1707
|
+
(when ? '<span class="sx-turn-when">' + esc(when) + '</span>' : '') + '</span>' +
|
|
1708
|
+
(role === 'user' ? '<div class="sx-plain">' + esc(text) + '</div>' : '<div class="sx-md">' + sxMd(text) + '</div>') + '</div>';
|
|
1709
|
+
}
|
|
1710
|
+
// A refresh never rebuilds what you are reading (Miles, /qa: "start to scroll and then it reloads
|
|
1711
|
+
// the section"). The server's window slides as a session grows, so the turns the page already
|
|
1712
|
+
// shows are found at the start of the new list and only what follows them is appended; older
|
|
1713
|
+
// cards stay. Your sent and waiting bubbles give way to the real turns only when new turns land.
|
|
1714
|
+
// When the lists share nothing, the card you are reading is kept where it was on screen.
|
|
1715
|
+
function sxMergeTurns(box, old, next, d) {
|
|
1716
|
+
if (!old.length && !next.length) return;
|
|
1717
|
+
var oldSigs = old.map(sxTurnSig), nextSigs = next.map(sxTurnSig), fresh = null;
|
|
1718
|
+
for (var j = 0; j < oldSigs.length && fresh === null; j++) {
|
|
1719
|
+
var tail = oldSigs.slice(j);
|
|
1720
|
+
if (tail.length > nextSigs.length) continue;
|
|
1721
|
+
if (tail.every(function (s, k) { return s === nextSigs[k]; })) fresh = next.slice(tail.length);
|
|
1722
|
+
}
|
|
1723
|
+
if (fresh) {
|
|
1724
|
+
if (!fresh.length) return;
|
|
1725
|
+
box.querySelectorAll('.turn.pending, .reader-placeholder').forEach(function (el) { el.remove(); });
|
|
1726
|
+
box.insertAdjacentHTML('beforeend', fresh.map(sxTurnHtml).join(''));
|
|
1727
|
+
return;
|
|
1728
|
+
}
|
|
1729
|
+
var anchor = null, top = 0, cards = box.querySelectorAll('.turn[data-sig]');
|
|
1730
|
+
for (var i = 0; i < cards.length; i++) {
|
|
1731
|
+
var rect = cards[i].getBoundingClientRect();
|
|
1732
|
+
if (rect.bottom > 0) { anchor = cards[i].getAttribute('data-sig'); top = rect.top; break; }
|
|
1733
|
+
}
|
|
1734
|
+
box.innerHTML = renderTurns(next, d);
|
|
1735
|
+
if (!anchor) return;
|
|
1736
|
+
var same = [].filter.call(box.querySelectorAll('.turn[data-sig]'), function (el) { return el.getAttribute('data-sig') === anchor; })[0];
|
|
1737
|
+
if (same) window.scrollBy(0, same.getBoundingClientRect().top - top);
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
// ── Ask COS (the Ask sheet's first row) ─────────────────
|
|
1741
|
+
// 6.99.10: the same request as the COS chat's Ask (panes/cos-chat.js submitAsk). Before, this sent `prompt` with a
|
|
1742
|
+
// `web_<time>` id and no generation or era, so the server refused every one (409 message_era_mismatch on a Mac with
|
|
1743
|
+
// a message era, else invalid_client_job_id) and a small status line was the only sign.
|
|
1744
|
+
function webJobId() {
|
|
1745
|
+
// crypto.randomUUID needs a secure context; a plain-http tailnet page is not one.
|
|
1746
|
+
var b = new Uint8Array(16);
|
|
1747
|
+
crypto.getRandomValues(b);
|
|
1748
|
+
b[6] = (b[6] & 0x0f) | 0x40; b[8] = (b[8] & 0x3f) | 0x80;
|
|
1749
|
+
var h = Array.prototype.map.call(b, function (x) { return (x + 0x100).toString(16).slice(1); }).join('');
|
|
1750
|
+
return h.slice(0, 8) + '-' + h.slice(8, 12) + '-' + h.slice(12, 16) + '-' + h.slice(16, 20) + '-' + h.slice(20);
|
|
1751
|
+
}
|
|
1752
|
+
function askCos(prompt, statusEl) {
|
|
1753
|
+
// 6.99.12: "it shows Sent but never shows up" (Miles, 11/04). With Ask COS in Messages, the sheet hands its words to
|
|
1754
|
+
// that composer (panes/cos-chat.js submitAsk), so the answer streams on screen and lands as a numbered turn. The
|
|
1755
|
+
// request below is the fallback for a page without it; it resolves to the Messages route to open.
|
|
1756
|
+
var chat = window.COSWeb && window.COSWeb.cosChat;
|
|
1757
|
+
if (chat && chat.askFromSheet) {
|
|
1758
|
+
setStatus(statusEl, 'Sending…');
|
|
1759
|
+
return chat.askFromSheet(prompt).then(function (where) { setStatus(statusEl, ''); return where; }, function (err) {
|
|
1760
|
+
setStatus(statusEl, err.message, true);
|
|
1761
|
+
throw err;
|
|
1762
|
+
});
|
|
1763
|
+
}
|
|
1764
|
+
setStatus(statusEl, 'Sent. COS is answering.');
|
|
1765
|
+
var body = { clientJobId: webJobId(), generation: 1, query: prompt, source: 'web', activityToolMode: 'status' };
|
|
1766
|
+
return api('/api/query-jobs', { method: 'POST', body: body, timeoutMs: 30000 }).then(function () {
|
|
1767
|
+
// The answer lands in Messages; refresh it there if it is open.
|
|
1768
|
+
var again = function () { if (state.pane === 'messages' && PANES.messages && PANES.messages.refresh) PANES.messages.refresh(); };
|
|
1769
|
+
setTimeout(again, 4000);
|
|
1770
|
+
setTimeout(function () { again(); setStatus(statusEl, ''); }, 15000);
|
|
1771
|
+
}).catch(function (err) {
|
|
1772
|
+
setStatus(statusEl, err.message, true);
|
|
1773
|
+
toast('Ask COS did not send: ' + err.message);
|
|
1774
|
+
throw err;
|
|
1775
|
+
});
|
|
1776
|
+
}
|
|
1777
|
+
// ── Memories (the native page, hosted) ──────────────────
|
|
1778
|
+
function syncFrameTheme() {
|
|
1779
|
+
var frame = $('memories-frame'); if (!frame) return;
|
|
1780
|
+
var mode = window.CosTheme ? window.CosTheme.mode() : 'light';
|
|
1781
|
+
frame.style.colorScheme = mode;
|
|
1782
|
+
try {
|
|
1783
|
+
var root = frame.contentDocument && frame.contentDocument.documentElement;
|
|
1784
|
+
if (root) {
|
|
1785
|
+
root.setAttribute('data-theme', mode);
|
|
1786
|
+
if (isPhone()) root.setAttribute('data-host', 'web-phone'); else root.removeAttribute('data-host');
|
|
1787
|
+
}
|
|
1788
|
+
} catch (_e) {}
|
|
1789
|
+
}
|
|
1790
|
+
function loadMemories() {
|
|
1791
|
+
var frame = $('memories-frame');
|
|
1792
|
+
if (state.memoriesLoaded) { syncFrameTheme(); return; }
|
|
1793
|
+
state.memoriesLoaded = true;
|
|
1794
|
+
frame.addEventListener('load', syncFrameTheme);
|
|
1795
|
+
frame.src = '/control/memories/memories.html';
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
// ── Usage ───────────────────────────────────────────────
|
|
1799
|
+
// Usage and COS cost are views inside Sessions, as on the Mac (ActivityWindow.swift 2306-2325);
|
|
1800
|
+
// an old #/usage link opens the Usage view there. Data: GET /api/usage/summary, the helper's
|
|
1801
|
+
// read-only session-usage and cos-usage reports (web-usage-bridge.ts). The figures follow
|
|
1802
|
+
// SessionUsageCore.swift: COS cost counts measured dollars (measuredUSD) and never adds
|
|
1803
|
+
// estimates into them, and its rows keep Control's category titles.
|
|
1804
|
+
function loadUsage() {
|
|
1805
|
+
SX.view = 'usage'; sxSave();
|
|
1806
|
+
location.replace('#/sessions');
|
|
1807
|
+
}
|
|
1808
|
+
function renderUsageView(force) {
|
|
1809
|
+
var box = $('sessions-usage'); if (!box) return;
|
|
1810
|
+
uLoadPrefs(); uWire();
|
|
1811
|
+
var stale = !SX.usage || Date.now() - SX.usageAt > 5 * 60000;
|
|
1812
|
+
if (SX.usage) paintUsage(true);
|
|
1813
|
+
else box.innerHTML = '<p class="cx-note sx-reading">' + (SX.view === 'cost' ? 'Reading what COS used.' : 'Reading local usage…') + '</p>';
|
|
1814
|
+
if ((!force && !stale) || SX.usageLoading) return;
|
|
1815
|
+
SX.usageLoading = true; paintSessionsHero();
|
|
1816
|
+
// The server runs the two Mac helpers side by side, each capped at 15 s (web-usage-bridge.ts), so a
|
|
1817
|
+
// cold read can pass the 20 s default for reads once the network is added; 30 s covers it.
|
|
1818
|
+
api('/api/usage/summary' + (force ? '?refresh=1' : ''), { timeoutMs: 30000 }).then(function (d) {
|
|
1819
|
+
SX.usage = d || {}; SX.usageAt = Date.now(); SX.usageError = ''; SX.usageLoading = false;
|
|
1820
|
+
paintSessionsHero(); paintUsage(true);
|
|
1821
|
+
}, function (err) {
|
|
1822
|
+
SX.usageLoading = false; SX.usageError = (err && err.message) || 'Usage did not load.';
|
|
1823
|
+
paintSessionsHero(); paintUsage(true);
|
|
1824
|
+
});
|
|
1825
|
+
}
|
|
1826
|
+
// background: a refresh or a breakpoint change, which waits while someone is using the page (uHeld). What a person
|
|
1827
|
+
// chose paints at once. From 860 px Usage takes the Mac's desktop layout; under it the vertical view is unchanged.
|
|
1828
|
+
function paintUsage(background) {
|
|
1829
|
+
var box = $('sessions-usage'); if (!box || SX.view === 'sessions') return;
|
|
1830
|
+
if (background === true && SX.view === 'usage' && uHeld()) { SX.uDeferred = true; return; }
|
|
1831
|
+
SX.uDeferred = false; SX.uRedraw = false;
|
|
1832
|
+
var wide = SX.view === 'usage' && !!SX.usage && uWide();
|
|
1833
|
+
SX.uWideShown = wide;
|
|
1834
|
+
box.classList.toggle('u-wide', wide);
|
|
1835
|
+
if (!wide) SX.uM = null;
|
|
1836
|
+
if (!SX.usage) { box.innerHTML = SX.usageError ? '<div class="cx-notice warn">' + esc(SX.usageError) + '</div>' : ''; return; }
|
|
1837
|
+
box.innerHTML = (SX.usageError ? '<div class="cx-notice warn">' + esc(SX.usageError) + '</div>' : '') +
|
|
1838
|
+
(SX.view === 'cost' ? cosCostHtml(SX.usage) : wide ? sessionUsageWideHtml(SX.usage) : sessionUsageHtml(SX.usage));
|
|
1839
|
+
if (wide) uDrawWide();
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
// Numbers the way Control writes them (SessionUsagePresentation, CosCostPresentation).
|
|
1843
|
+
function uCurrency(v, digits) { return (Number(v) || 0).toLocaleString('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: digits, maximumFractionDigits: digits }); }
|
|
1844
|
+
function uMoney(v) { v = Number(v) || 0; return v > 0 && v < 0.01 ? 'under 1¢' : uCurrency(v, v < 10 ? 2 : 0); }
|
|
1845
|
+
function uAtLeast(v) { return v < 0.01 ? 'at least a fraction of 1¢' : 'at least ' + uMoney(v); }
|
|
1846
|
+
function uDollars(v) { v = Number(v) || 0; return v > 0 && v < 1 ? '<$1' : uCurrency(v, 0); }
|
|
1847
|
+
function uNum(n) { return (Number(n) || 0).toLocaleString('en-US'); }
|
|
1848
|
+
function uShort(n) {
|
|
1849
|
+
n = Number(n) || 0;
|
|
1850
|
+
if (n >= 1e9) return (n / 1e9).toFixed(2) + 'B';
|
|
1851
|
+
if (n >= 1e8) return (n / 1e6).toFixed(0) + 'M';
|
|
1852
|
+
if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M';
|
|
1853
|
+
if (n >= 1e5) return (n / 1e3).toFixed(0) + 'K';
|
|
1854
|
+
if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K';
|
|
1855
|
+
return String(n);
|
|
1856
|
+
}
|
|
1857
|
+
function uShare(part, whole) { if (!(whole > 0)) return 'Unavailable'; var p = 100 * part / whole; return p > 0 && p < 0.1 ? '<0.1%' : p.toFixed(1) + '%'; }
|
|
1858
|
+
var U_MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
|
|
1859
|
+
function uDayWords(key) { var p = String(key || '').split('-').map(Number); return p.length === 3 && p[1] ? U_MONTHS[p[1] - 1] + ' ' + p[2] + ', ' + p[0] : String(key || ''); }
|
|
1860
|
+
|
|
1861
|
+
// SessionUsagePricing.tokens, for the chart's hover card: "2.82B", "48.30M", "12.5K".
|
|
1862
|
+
function uTok2(n) { n = Number(n) || 0; return n >= 1e9 ? (n / 1e9).toFixed(2) + 'B' : n >= 1e6 ? (n / 1e6).toFixed(2) + 'M' : n >= 1e3 ? (n / 1e3).toFixed(1) + 'K' : String(n); }
|
|
1863
|
+
// spokenTokens: "78.0 billion", for the summary card's headline.
|
|
1864
|
+
function uSpoken(n) {
|
|
1865
|
+
n = Number(n) || 0;
|
|
1866
|
+
if (n >= 1e9) return (n / 1e9).toFixed(1) + ' billion';
|
|
1867
|
+
if (n >= 1e6) return (n >= 1e8 ? (n / 1e6).toFixed(0) : (n / 1e6).toFixed(1)) + ' million';
|
|
1868
|
+
if (n >= 1e3) return (n / 1e3).toFixed(1) + ' thousand';
|
|
1869
|
+
return uNum(n);
|
|
1870
|
+
}
|
|
1871
|
+
// deltaPercent: "+48.1%", "-3.2%", "0.0%"; no prior is Unavailable, never a made-up change.
|
|
1872
|
+
function uDelta(cur, prior) {
|
|
1873
|
+
if (!(prior > 0)) return 'Unavailable';
|
|
1874
|
+
var p = 100 * (cur - prior) / prior;
|
|
1875
|
+
if (Math.abs(p) < 0.05) return '0.0%';
|
|
1876
|
+
return (p > 0 ? '+' : '') + p.toFixed(1) + '%';
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
// ── Usage range math (pure: no page, no SX; server/lib/web-usage-range.test.ts runs this block) ──
|
|
1880
|
+
// Dates are ledger day keys (yyyy-MM-dd) in the report's time zone. A day key is a civil date, so the arithmetic
|
|
1881
|
+
// runs on UTC midnights and never meets a daylight-saving gap (SessionUsageDay in SessionUsageCore.swift).
|
|
1882
|
+
var U_CUSTOM = 'Custom dates';
|
|
1883
|
+
var U_PRESETS = ['Last 7 days', 'Last 30 days', 'Last 90 days', 'This month', 'Last month', 'This quarter', 'Year to date', 'Last weekend', 'All history'];
|
|
1884
|
+
var U_LAST = { 'Last 7 days': 7, 'Last 30 days': 30, 'Last 90 days': 90 };
|
|
1885
|
+
var U_MONTH_RE = /^\d{4}-\d{2}$/, U_DAY_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
1886
|
+
var U_WEEKDAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
|
1887
|
+
// Today's date where the report was written. A zone the browser does not know falls back to the browser's own day.
|
|
1888
|
+
function uDayKey(tz, now) {
|
|
1889
|
+
var d = now || new Date();
|
|
1890
|
+
try {
|
|
1891
|
+
var parts = {};
|
|
1892
|
+
new Intl.DateTimeFormat('en-US', { timeZone: tz || undefined, year: 'numeric', month: '2-digit', day: '2-digit' }).formatToParts(d)
|
|
1893
|
+
.forEach(function (p) { parts[p.type] = p.value; });
|
|
1894
|
+
if (parts.year && parts.month && parts.day) return parts.year + '-' + parts.month + '-' + parts.day;
|
|
1895
|
+
} catch (_e) { /* an unknown zone */ }
|
|
1896
|
+
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
|
|
1897
|
+
}
|
|
1898
|
+
function uSerial(key) { var p = String(key).split('-').map(Number); return Math.round(Date.UTC(p[0], p[1] - 1, p[2]) / 86400000); }
|
|
1899
|
+
function uFromSerial(n) { return new Date(n * 86400000).toISOString().slice(0, 10); }
|
|
1900
|
+
function uAddDays(key, n) { return uFromSerial(uSerial(key) + n); }
|
|
1901
|
+
// 0 is Monday. 1970-01-01 was a Thursday.
|
|
1902
|
+
function uWeekday(key) { return ((uSerial(key) + 3) % 7 + 7) % 7; }
|
|
1903
|
+
function uValidDay(key) { return U_DAY_RE.test(String(key || '')) && uFromSerial(uSerial(key)) === key; }
|
|
1904
|
+
function uMonthAdd(month, n) { var p = month.split('-').map(Number), m = p[0] * 12 + (p[1] - 1) + n; return Math.floor(m / 12) + '-' + String(m % 12 + 1).padStart(2, '0'); }
|
|
1905
|
+
function uMonthEnd(month) { return uAddDays(uMonthAdd(month, 1) + '-01', -1); }
|
|
1906
|
+
function uKnownPeriod(p) { return U_PRESETS.indexOf(p) >= 0 || U_MONTH_RE.test(String(p || '')) || p === U_CUSTOM; }
|
|
1907
|
+
function uMonthShaped(p) { return p === 'This month' || p === 'Last month' || U_MONTH_RE.test(String(p || '')); }
|
|
1908
|
+
function uMonthOf(p, today) { return p === 'This month' ? today.slice(0, 7) : p === 'Last month' ? uMonthAdd(today.slice(0, 7), -1) : p; }
|
|
1909
|
+
// The words Control uses when a custom range is not one.
|
|
1910
|
+
function uCustomError(from, to) {
|
|
1911
|
+
if (!uValidDay(from) || !uValidDay(to)) return 'Choose a start and an end date.';
|
|
1912
|
+
return from > to ? 'Choose an end date on or after the start.' : '';
|
|
1913
|
+
}
|
|
1914
|
+
// The dates a period covers, [first, last]. Null is All history; false is a custom range that is not one. A month or
|
|
1915
|
+
// quarter covers all its dates, as Control's selectionBounds does; the calendar and the comparison stop at today.
|
|
1916
|
+
function uRange(period, today, from, to) {
|
|
1917
|
+
if (period === 'All history') return null;
|
|
1918
|
+
if (U_LAST[period]) return [uAddDays(today, 1 - U_LAST[period]), today];
|
|
1919
|
+
if (period === 'Last weekend') { var monday = uAddDays(today, -uWeekday(today)); return [uAddDays(monday, -2), uAddDays(monday, -1)]; }
|
|
1920
|
+
if (uMonthShaped(period)) { var m = uMonthOf(period, today); return [m + '-01', uMonthEnd(m)]; }
|
|
1921
|
+
if (period === 'This quarter') {
|
|
1922
|
+
var q = today.slice(0, 4) + '-' + String(Math.floor((Number(today.slice(5, 7)) - 1) / 3) * 3 + 1).padStart(2, '0');
|
|
1923
|
+
return [q + '-01', uMonthEnd(uMonthAdd(q, 2))];
|
|
1924
|
+
}
|
|
1925
|
+
if (period === 'Year to date') return [today.slice(0, 4) + '-01-01', today];
|
|
1926
|
+
if (period === U_CUSTOM) return uCustomError(from, to) ? false : [from, to];
|
|
1927
|
+
return false;
|
|
1928
|
+
}
|
|
1929
|
+
// Like-for-like windows (SessionUsageCore comparisonWindows): a month in progress against the same dates of the month
|
|
1930
|
+
// before, clamped to its length; a finished month against the whole month before; anything else against the same
|
|
1931
|
+
// number of dates just before it, counting only the dates that have happened. All history has nothing before it.
|
|
1932
|
+
function uCompare(period, today, from, to) {
|
|
1933
|
+
if (period === 'All history') return null;
|
|
1934
|
+
var shifted = function (a, b, span) { var n = span || (uSerial(b) - uSerial(a) + 1); return { current: [a, b], prior: [uAddDays(a, -n), uAddDays(b, -n)] }; };
|
|
1935
|
+
if (period === 'Last weekend') { var w = uRange(period, today); return shifted(w[0], w[1], 7); }
|
|
1936
|
+
if (uMonthShaped(period)) {
|
|
1937
|
+
var month = uMonthOf(period, today), first = month + '-01', prev = uMonthAdd(month, -1);
|
|
1938
|
+
if (month === today.slice(0, 7)) {
|
|
1939
|
+
var same = uAddDays(prev + '-01', uSerial(today) - uSerial(first)), end = uMonthEnd(prev);
|
|
1940
|
+
return { current: [first, today], prior: [prev + '-01', same < end ? same : end] };
|
|
1941
|
+
}
|
|
1942
|
+
return { current: [first, uMonthEnd(month)], prior: [prev + '-01', uMonthEnd(prev)] };
|
|
1943
|
+
}
|
|
1944
|
+
var r = uRange(period, today, from, to);
|
|
1945
|
+
if (!r || r[0] > today) return null;
|
|
1946
|
+
return shifted(r[0], r[1] < today ? r[1] : today);
|
|
1947
|
+
}
|
|
1948
|
+
// ‹ › on the summary card: the same length earlier or later. A month steps a month and a calendar quarter steps a
|
|
1949
|
+
// quarter; anything else moves by its number of days, and a step that lands on the last 7, 30 or 90 days says so.
|
|
1950
|
+
// Nothing steps past today, or earlier than the first day of history.
|
|
1951
|
+
function uStep(period, today, from, to, delta, firstDay) {
|
|
1952
|
+
if (period === 'All history') return null;
|
|
1953
|
+
var nb, next;
|
|
1954
|
+
if (uMonthShaped(period)) {
|
|
1955
|
+
var m = uMonthAdd(uMonthOf(period, today), delta);
|
|
1956
|
+
nb = [m + '-01', uMonthEnd(m)]; next = { period: m, from: '', to: '' };
|
|
1957
|
+
} else {
|
|
1958
|
+
var r = uRange(period, today, from, to);
|
|
1959
|
+
if (!r) return null;
|
|
1960
|
+
var quarter = /-(01|04|07|10)-01$/.test(r[0]) && r[1] === uMonthEnd(uMonthAdd(r[0].slice(0, 7), 2));
|
|
1961
|
+
if (quarter) { var qs = uMonthAdd(r[0].slice(0, 7), 3 * delta); nb = [qs + '-01', uMonthEnd(uMonthAdd(qs, 2))]; }
|
|
1962
|
+
else { var n = uSerial(r[1]) - uSerial(r[0]) + 1; nb = [uAddDays(r[0], delta * n), uAddDays(r[1], delta * n)]; }
|
|
1963
|
+
next = { period: U_CUSTOM, from: nb[0], to: nb[1] };
|
|
1964
|
+
Object.keys(U_LAST).forEach(function (p) { if (nb[1] === today && nb[0] === uAddDays(today, 1 - U_LAST[p])) next = { period: p, from: '', to: '' }; });
|
|
1965
|
+
}
|
|
1966
|
+
if (nb[0] > today) return null;
|
|
1967
|
+
if (delta < 0 && firstDay && nb[1] < firstDay) return null;
|
|
1968
|
+
return next;
|
|
1969
|
+
}
|
|
1970
|
+
// The scale a period opens on until someone picks one, so bars stay few enough to read (defaultScale, extended to the
|
|
1971
|
+
// ranges the web adds: a quarter by week, a long custom range or year by week or month).
|
|
1972
|
+
function uDefaultScale(period, today, from, to) {
|
|
1973
|
+
if (period === 'All history') return 'monthly';
|
|
1974
|
+
if (period === 'Last 90 days' || period === 'This quarter') return 'weekly';
|
|
1975
|
+
if (period === 'Year to date' || period === U_CUSTOM) {
|
|
1976
|
+
var r = uRange(period, today, from, to);
|
|
1977
|
+
if (!r) return 'daily';
|
|
1978
|
+
var n = uSerial(r[1] < today ? r[1] : today) - uSerial(r[0]) + 1;
|
|
1979
|
+
return n > 200 ? 'monthly' : n > 62 ? 'weekly' : 'daily';
|
|
1980
|
+
}
|
|
1981
|
+
return 'daily';
|
|
1982
|
+
}
|
|
1983
|
+
function uPeriodKey(day, scale) { return scale === 'monthly' ? day.slice(0, 7) : scale === 'weekly' ? uAddDays(day.slice(0, 10), -uWeekday(day.slice(0, 10))) : day.slice(0, 10); }
|
|
1984
|
+
function uPeriodStart(key) { return key.length === 7 ? key + '-01' : key; }
|
|
1985
|
+
function uShortDay(key) { var p = key.split('-').map(Number); return U_MONTHS[p[1] - 1].slice(0, 3) + ' ' + p[2]; }
|
|
1986
|
+
// dateRangeTitle: "Sep 6 – Oct 5, 2026", "Dec 29, 2025 – Jan 4, 2026", "Oct 19, 2026".
|
|
1987
|
+
function uDateRangeTitle(b) {
|
|
1988
|
+
var a = b[0], z = b[1];
|
|
1989
|
+
if (a === z) return uShortDay(a) + ', ' + a.slice(0, 4);
|
|
1990
|
+
return a.slice(0, 4) === z.slice(0, 4) ? uShortDay(a) + ' – ' + uShortDay(z) + ', ' + z.slice(0, 4) : uShortDay(a) + ', ' + a.slice(0, 4) + ' – ' + uShortDay(z) + ', ' + z.slice(0, 4);
|
|
1991
|
+
}
|
|
1992
|
+
// periodTitle: a bar's name, clamped to the selection, so a boundary week or month says which of its dates are in it.
|
|
1993
|
+
function uPeriodTitle(key, scale, bounds) {
|
|
1994
|
+
var first = uPeriodStart(key);
|
|
1995
|
+
var clamp = function (a, z) { return [bounds && bounds[0] > a ? bounds[0] : a, bounds && bounds[1] < z ? bounds[1] : z]; };
|
|
1996
|
+
if (scale === 'monthly') {
|
|
1997
|
+
var name = U_MONTHS[Number(first.slice(5, 7)) - 1] + ' ' + first.slice(0, 4), end = uMonthEnd(first.slice(0, 7)), c = clamp(first, end);
|
|
1998
|
+
return (c[0] === first && c[1] === end) || c[0] > c[1] ? name : name + ' (' + uShortDay(c[0]) + ' – ' + uShortDay(c[1]) + ')';
|
|
1999
|
+
}
|
|
2000
|
+
if (scale === 'weekly') {
|
|
2001
|
+
var sunday = uAddDays(first, 6), w = clamp(first, sunday);
|
|
2002
|
+
return (w[0] === first && w[1] === sunday) || w[0] > w[1] ? 'Week of ' + uDateRangeTitle([first, sunday]) : uDateRangeTitle(w) + ' (partial week)';
|
|
2003
|
+
}
|
|
2004
|
+
return U_WEEKDAYS[uWeekday(first)] + ', ' + uShortDay(first) + ', ' + first.slice(0, 4);
|
|
2005
|
+
}
|
|
2006
|
+
// The label on the period button: the preset's words, "October 2026", or a custom range's dates.
|
|
2007
|
+
function uRangeTitle(period, from, to) {
|
|
2008
|
+
if (period === U_CUSTOM) return uCustomError(from, to) ? 'Custom dates' : uDateRangeTitle([from, to]);
|
|
2009
|
+
if (U_MONTH_RE.test(period)) return U_MONTHS[Number(period.slice(5)) - 1] + ' ' + period.slice(0, 4);
|
|
2010
|
+
return period;
|
|
2011
|
+
}
|
|
2012
|
+
// partialPeriods: the first and last week or month that the selection or today cuts short.
|
|
2013
|
+
function uPartialPeriods(keys, scale, firstDay, lastDay) {
|
|
2014
|
+
var out = {};
|
|
2015
|
+
if (scale === 'daily' || !firstDay) return out;
|
|
2016
|
+
keys.forEach(function (k) {
|
|
2017
|
+
var s = uPeriodStart(k), e = scale === 'weekly' ? uAddDays(s, 6) : uMonthEnd(s.slice(0, 7));
|
|
2018
|
+
if (s < firstDay || e > lastDay) out[k] = true;
|
|
2019
|
+
});
|
|
2020
|
+
return out;
|
|
2021
|
+
}
|
|
2022
|
+
// axisKeys: at most about seven labels, from the first period, never the last (it would run off the edge).
|
|
2023
|
+
function uAxisKeys(keys) {
|
|
2024
|
+
if (keys.length <= 7) return keys.slice();
|
|
2025
|
+
var step = Math.ceil(keys.length / 7), out = [];
|
|
2026
|
+
for (var i = 0; i < keys.length - 1; i += step) out.push(keys[i]);
|
|
2027
|
+
return out;
|
|
2028
|
+
}
|
|
2029
|
+
function uAxisLabel(key) {
|
|
2030
|
+
var s = uPeriodStart(key), m = U_MONTHS[Number(s.slice(5, 7)) - 1].slice(0, 3);
|
|
2031
|
+
return key.length === 7 ? (s.slice(5, 7) === '01' ? m + ' ' + s.slice(0, 4) : m) : m + ' ' + Number(s.slice(8, 10));
|
|
2032
|
+
}
|
|
2033
|
+
// niceAxisTop: four even steps that land on round numbers.
|
|
2034
|
+
function uNiceTop(peak) {
|
|
2035
|
+
if (!(peak > 0) || !isFinite(peak)) return 1;
|
|
2036
|
+
var target = peak * 1.04, mag = Math.pow(10, Math.floor(Math.log10(target)));
|
|
2037
|
+
var steps = [1, 1.2, 1.6, 2, 2.4, 3.2, 4, 6, 8, 10];
|
|
2038
|
+
for (var i = 0; i < steps.length; i++) if (steps[i] * mag >= target) return steps[i] * mag;
|
|
2039
|
+
return 10 * mag;
|
|
2040
|
+
}
|
|
2041
|
+
// ── end of usage range math ──
|
|
2042
|
+
|
|
2043
|
+
// ── Usage view (SessionUsageView.swift 329-445) ──
|
|
2044
|
+
var U_PERIODS = ['Last 7 days', 'Last 30 days', 'Last 90 days', 'Last weekend', 'All history'];
|
|
2045
|
+
function uToolName(p) { return p === 'Anthropic' ? 'Claude Code' : p === 'OpenAI' ? 'Codex' : p === 'Cursor' ? 'Cursor' : 'Codex (' + p + ')'; }
|
|
2046
|
+
// "claude-opus-5-5" reads "Opus 5.5"; an unknown id stays recognizable.
|
|
2047
|
+
function uModelName(id) {
|
|
2048
|
+
var name = String(id || '').replace(/-\d{8}$/, '');
|
|
2049
|
+
if (name.indexOf('cursor-') === 0) name = name.slice(7);
|
|
2050
|
+
var parts = name.split('-');
|
|
2051
|
+
if (parts.length < 2) return name;
|
|
2052
|
+
var isVersion = function (s) { return /^[\d.]+$/.test(s); };
|
|
2053
|
+
var cap = function (s) { return s ? s.charAt(0).toUpperCase() + s.slice(1) : s; };
|
|
2054
|
+
if (parts[0] === 'claude') {
|
|
2055
|
+
parts.shift();
|
|
2056
|
+
if (parts.length > 1 && isVersion(parts[0])) { var t = parts[0]; parts[0] = parts[1]; parts[1] = t; }
|
|
2057
|
+
var family = parts.shift(), version = [];
|
|
2058
|
+
while (parts.length && isVersion(parts[0])) version.push(parts.shift());
|
|
2059
|
+
return [cap(family)].concat(version.length ? [version.join('.')] : []).concat(parts).join(' ');
|
|
2060
|
+
}
|
|
2061
|
+
if (parts[0] === 'gpt') return ['GPT-' + parts[1]].concat(parts.slice(2).map(function (p, i) { return i === 0 ? cap(p) : p; })).join(' ');
|
|
2062
|
+
return [cap(parts[0])].concat(parts.slice(1)).join(' ');
|
|
2063
|
+
}
|
|
2064
|
+
function uTotals(rows) {
|
|
2065
|
+
var t = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, events: 0, priced: 0, usd: 0 };
|
|
2066
|
+
rows.forEach(function (b) {
|
|
2067
|
+
var c = b.counts || {};
|
|
2068
|
+
t.input += Number(c.input) || 0; t.output += Number(c.output) || 0; t.cacheRead += Number(c.cacheRead) || 0; t.cacheWrite += Number(c.cacheWrite) || 0;
|
|
2069
|
+
t.events += Number(b.events) || 0; t.priced += Number(b.pricedEvents) || 0; t.usd += Number(b.estimatedUSD) || 0;
|
|
2070
|
+
});
|
|
2071
|
+
t.fresh = t.input + t.output + t.cacheWrite;
|
|
2072
|
+
t.all = t.fresh + t.cacheRead;
|
|
2073
|
+
return t;
|
|
2074
|
+
}
|
|
2075
|
+
function uCostCell(t) { return t.priced > 0 ? uDollars(t.usd) + (t.priced < t.events ? '+' : '') : 'No price'; }
|
|
2076
|
+
function uGroup(rows, keyFn) {
|
|
2077
|
+
var by = {}, order = [];
|
|
2078
|
+
rows.forEach(function (b) { var k = keyFn(b); if (!by[k]) { by[k] = []; order.push(k); } by[k].push(b); });
|
|
2079
|
+
return order.map(function (k) { return { key: k, rows: by[k], t: uTotals(by[k]) }; });
|
|
2080
|
+
}
|
|
2081
|
+
function uSelect(id, label, value, options) {
|
|
2082
|
+
return '<select id="' + id + '" class="field sx-select" aria-label="' + label + '">' + options.map(function (o) {
|
|
2083
|
+
return '<option value="' + esc(o[0]) + '"' + (o[0] === value ? ' selected' : '') + '>' + esc(o[1]) + '</option>';
|
|
2084
|
+
}).join('') + '</select>';
|
|
2085
|
+
}
|
|
2086
|
+
function sessionUsageHtml(d) {
|
|
2087
|
+
var su = d.sessionUsage;
|
|
2088
|
+
var how = '<details class="sx-how"><summary>How this is counted</summary>' + uHowLines(su).map(function (l) { return '<p>' + esc(l) + '</p>'; }).join('') + '</details>';
|
|
2089
|
+
if (!su) return '<div class="sx-uempty">' + esc('Usage has not been read yet. Refresh reads Claude Code and Codex logs on this Mac.') + '</div>' + how;
|
|
2090
|
+
var tz = su.timeZone || '';
|
|
2091
|
+
var buckets = (Array.isArray(su.buckets) ? su.buckets : []).filter(function (b) { return b && typeof b.day === 'string'; });
|
|
2092
|
+
var pending = [];
|
|
2093
|
+
if (Number(su.pendingFiles) > 0) pending.push('Reading older history: ' + su.pendingFiles + ' files remaining. Totals will grow until it finishes.');
|
|
2094
|
+
if (su.cursorFeed && su.cursorFeed.pending === true && su.cursorFeed.message) pending.push(su.cursorFeed.message + ' Totals will grow until it finishes.');
|
|
2095
|
+
var pendingHtml = pending.map(function (n) { return '<p class="cx-note sx-unote">' + esc(n) + '</p>'; }).join('');
|
|
2096
|
+
if (!buckets.length) return '<div class="sx-uempty">' + esc('No usage recorded yet. Claude Code and Codex sessions on this Mac appear here once their logs are read. A missing tool is not zero usage.') + pendingHtml + '</div>' + how;
|
|
2097
|
+
|
|
2098
|
+
var months = []; buckets.forEach(function (b) { var m = b.day.slice(0, 7); if (months.indexOf(m) < 0) months.push(m); });
|
|
2099
|
+
months.sort().reverse();
|
|
2100
|
+
var periods = U_PERIODS.concat(months.slice(0, 12));
|
|
2101
|
+
// A range picked on the wide layout (This quarter, a custom range) stays when the phone folds; Custom dates sits
|
|
2102
|
+
// before All history, as on the Mac.
|
|
2103
|
+
if (!uKnownPeriod(SX.uPeriod) || (SX.uPeriod === U_CUSTOM && uCustomError(SX.uFrom, SX.uTo))) SX.uPeriod = 'Last 30 days';
|
|
2104
|
+
if (SX.uPeriod !== U_CUSTOM && periods.indexOf(SX.uPeriod) < 0) periods.splice(4, 0, SX.uPeriod);
|
|
2105
|
+
periods.splice(periods.indexOf('All history'), 0, U_CUSTOM);
|
|
2106
|
+
var bounds = uRange(SX.uPeriod, uDayKey(tz), SX.uFrom, SX.uTo);
|
|
2107
|
+
var inPeriod = bounds === null ? buckets : bounds ? buckets.filter(function (b) { return b.day >= bounds[0] && b.day <= bounds[1]; }) : [];
|
|
2108
|
+
var tools = uGroup(buckets, function (b) { return b.provider; }).sort(function (a, b) { return b.t.all - a.t.all; }).map(function (g) { return g.key; });
|
|
2109
|
+
if (SX.uTool !== 'All tools' && tools.indexOf(SX.uTool) < 0) SX.uTool = 'All tools';
|
|
2110
|
+
var forTool = buckets.filter(function (b) { return SX.uTool === 'All tools' || b.provider === SX.uTool; });
|
|
2111
|
+
var models = uGroup(forTool, function (b) { return b.model; }).sort(function (a, b) { return b.t.all - a.t.all; }).map(function (g) { return g.key; });
|
|
2112
|
+
if (SX.uModel !== 'All models' && models.indexOf(SX.uModel) < 0) SX.uModel = 'All models';
|
|
2113
|
+
var selected = inPeriod.filter(function (b) { return (SX.uTool === 'All tools' || b.provider === SX.uTool) && (SX.uModel === 'All models' || b.model === SX.uModel); });
|
|
2114
|
+
var hasFilter = SX.uTool !== 'All tools' || SX.uModel !== 'All models';
|
|
2115
|
+
var updated = toDate(su.updatedAt);
|
|
2116
|
+
|
|
2117
|
+
var html = '<div class="sx-ufilters">' +
|
|
2118
|
+
uSelect('usage-period', 'Period', SX.uPeriod, periods.map(function (p) { return [p, p === U_CUSTOM ? (SX.uPeriod === U_CUSTOM ? uRangeTitle(p, SX.uFrom, SX.uTo) : p) : /^\d{4}-\d{2}$/.test(p) ? U_MONTHS[Number(p.slice(5)) - 1] + ' ' + p.slice(0, 4) : p]; })) +
|
|
2119
|
+
(SX.uPeriod === U_CUSTOM ? '<button type="button" class="sx-link" data-sx="usage-range">Change dates</button>' : '') +
|
|
2120
|
+
uSelect('usage-tool', 'Tool', SX.uTool, [['All tools', 'All tools']].concat(tools.map(function (p) { return [p, uToolName(p)]; }))) +
|
|
2121
|
+
uSelect('usage-model', 'Model', SX.uModel, [['All models', 'All models']].concat(models.map(function (m) { return [m, uModelName(m)]; }))) +
|
|
2122
|
+
(hasFilter ? '<button type="button" class="sx-link" data-sx="usage-clear">Clear filters</button>' : '') +
|
|
2123
|
+
(updated ? '<span class="sx-fresh">Updated ' + esc(clock(updated)) + '</span>' : '') +
|
|
2124
|
+
'</div>' + pendingHtml;
|
|
2125
|
+
if (!selected.length) return html + '<div class="sx-uempty">No recorded usage for this selection. A missing tool is not zero usage.</div>' + uFooter(su, selected) + how;
|
|
2126
|
+
|
|
2127
|
+
var t = uTotals(selected);
|
|
2128
|
+
var days = uGroup(selected, function (b) { return b.day; });
|
|
2129
|
+
var pricedDays = days.filter(function (g) { return g.t.priced > 0; });
|
|
2130
|
+
var perDay = pricedDays.length ? uDollars(pricedDays.reduce(function (s, g) { return s + g.t.usd; }, 0) / pricedDays.length) + (pricedDays.some(function (g) { return g.t.priced < g.t.events; }) ? '+' : '') : 'No price';
|
|
2131
|
+
var figures = [
|
|
2132
|
+
['API value', uCostCell(t), '', 'What these tokens would cost at standard API rates. Not your subscription bill.'],
|
|
2133
|
+
['New tokens', uShort(t.fresh), '', 'Input, output and cache writes.'],
|
|
2134
|
+
['All tokens', uShort(t.all), uShare(t.cacheRead, t.all) + ' read again from cache', 'New tokens plus context read again from cache.'],
|
|
2135
|
+
['API value a day', perDay, days.length + ' ' + (days.length === 1 ? 'day' : 'days') + ' with usage', 'Average across days with a price. Days without logs or a price are left out, not counted as zero.']
|
|
2136
|
+
];
|
|
2137
|
+
html += '<div class="sx-figures">' + figures.map(function (f, i) {
|
|
2138
|
+
return '<div class="sx-figure' + (i === 0 ? ' lead' : '') + '" title="' + esc(f[3]) + '"><b>' + esc(f[1]) + '</b><span class="sx-figure-title">' + esc(f[0]) + '</span>' + (f[2] ? '<span class="sx-figure-note">' + esc(f[2]) + '</span>' : '') + '</div>';
|
|
2139
|
+
}).join('') + '</div>';
|
|
2140
|
+
|
|
2141
|
+
// By tool and model, largest first.
|
|
2142
|
+
var byModel = uGroup(selected, function (b) { return b.provider + '|' + b.model; }).sort(function (a, b) { return b.t.all - a.t.all; });
|
|
2143
|
+
html += '<h3 class="sx-uhead">By tool and model</h3><div class="sx-table sx-utable"><table><thead><tr><th>Model</th><th>Tool</th><th class="num hp">New tokens</th><th class="num">All tokens</th><th class="num">API value</th></tr></thead><tbody>' +
|
|
2144
|
+
byModel.map(function (g) {
|
|
2145
|
+
var b = g.rows[0];
|
|
2146
|
+
return '<tr><td title="' + esc(b.model) + '">' + esc(uModelName(b.model)) + '</td><td>' + esc(uToolName(b.provider)) + '</td><td class="num hp">' + uShort(g.t.fresh) + '</td><td class="num">' + uShort(g.t.all) + '</td><td class="num">' + esc(uCostCell(g.t)) + '</td></tr>';
|
|
2147
|
+
}).join('') + '</tbody></table></div>';
|
|
2148
|
+
|
|
2149
|
+
// Day by day, newest first, with a bar for the day's new tokens.
|
|
2150
|
+
var dayRows = days.slice().sort(function (a, b) { return a.key < b.key ? 1 : -1; }).slice(0, 45);
|
|
2151
|
+
var peak = dayRows.reduce(function (m, g) { return Math.max(m, g.t.fresh); }, 0) || 1;
|
|
2152
|
+
html += '<h3 class="sx-uhead">Day by day</h3><div class="cx-list sx-days">' + dayRows.map(function (g) {
|
|
2153
|
+
var p = g.key.split('-').map(Number);
|
|
2154
|
+
var label = new Date(Date.UTC(p[0], p[1] - 1, p[2])).toLocaleDateString('en-US', { timeZone: 'UTC', weekday: 'short', month: 'short', day: 'numeric' });
|
|
2155
|
+
return '<div class="sx-day"><span class="sx-day-date">' + esc(label) + '</span><span class="sx-day-bar" aria-hidden="true"><span style="width:' + Math.max(1, Math.round(100 * g.t.fresh / peak)) + '%"></span></span>' +
|
|
2156
|
+
'<span class="sx-day-tok">' + uShort(g.t.fresh) + '</span><span class="sx-day-usd">' + esc(uCostCell(g.t)) + '</span></div>';
|
|
2157
|
+
}).join('') + '</div>' + (days.length > dayRows.length ? '<p class="cx-note">The newest ' + dayRows.length + ' of ' + days.length + ' days.</p>' : '');
|
|
2158
|
+
return html + uFooter(su, selected) + how;
|
|
2159
|
+
}
|
|
2160
|
+
function uFooter(su, selected) {
|
|
2161
|
+
var bits = ['Read from ' + uNum(su.filesIndexed) + ' log files on this Mac.'];
|
|
2162
|
+
if (Number(su.unreadableFiles) > 0) bits.push(uNum(su.unreadableFiles) + ' could not be read.');
|
|
2163
|
+
var missing = {}, unpriced = 0;
|
|
2164
|
+
selected.forEach(function (b) { var gap = (Number(b.events) || 0) - (Number(b.pricedEvents) || 0); if (gap > 0) { unpriced += gap; var n = uModelName(b.model); missing[n] = (missing[n] || 0) + gap; } });
|
|
2165
|
+
if (unpriced > 0) {
|
|
2166
|
+
var names = Object.keys(missing).sort(function (a, b) { return missing[b] - missing[a] || (a < b ? -1 : 1); });
|
|
2167
|
+
bits.push('A + means a figure leaves out ' + uNum(unpriced) + ' records with no verified price: ' + names.slice(0, 2).join(' and ') + (names.length > 2 ? ' and ' + (names.length - 2) + ' more' : '') + '.');
|
|
2168
|
+
}
|
|
2169
|
+
return '<p class="sx-ufoot">' + bits.map(esc).join(' ') + '</p>';
|
|
2170
|
+
}
|
|
2171
|
+
function uHowLines(su) {
|
|
2172
|
+
var lines = ['While COS Control is running it refreshes every five minutes. Pausing keeps existing history. Only counts, dates, model names and file checkpoints are stored. Local logs are never uploaded.'];
|
|
2173
|
+
if (su) {
|
|
2174
|
+
(su.coverage || []).forEach(function (c) { if (typeof c === 'string') lines.push(c); });
|
|
2175
|
+
var gaps = (Number(su.unreadableFiles) || 0) + (Number(su.malformedRows) || 0) + (Number(su.missingBaselineEvents) || 0);
|
|
2176
|
+
if (gaps > 0) lines.push('Gaps: ' + (su.unreadableFiles || 0) + ' unreadable files or oversized rows, ' + (su.malformedRows || 0) + ' malformed rows, ' + (su.missingBaselineEvents || 0) + ' missing usage baselines. Totals reflect what could be read.');
|
|
2177
|
+
lines.push('Calendar dates in ' + (su.timeZone || 'this Mac’s time zone') + '. Days without logs are missing, not zero. Weeks run Monday to Sunday, and the first and last week or month of a period can be partial.');
|
|
2178
|
+
}
|
|
2179
|
+
lines.push('New tokens are input, output and cache writes. Output includes reasoning. Cache reads are repeated context, not cache savings.');
|
|
2180
|
+
var checked = su && /^\d{4}-\d{2}-\d{2}/.test(String(su.pricingVersion || '')) ? uDayWords(String(su.pricingVersion).slice(0, 10)) : 'October 25, 2026';
|
|
2181
|
+
lines.push('API value is the standard API rate, checked ' + checked + ' and applied to all dates. It leaves out discounts, speed tiers, tools and taxes, and it is not your subscription bill. Cursor’s value is the per-request cost Cursor reports.');
|
|
2182
|
+
lines.push('Token share is not your plan allowance. It compares recorded tokens in this selection; providers calculate subscription limits separately.');
|
|
2183
|
+
return lines;
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
|
|
2187
|
+
// ── Usage, wide (860 px and up): the Mac's desktop layout (SessionUsageView body, SessionUsageSplitLayout) ──
|
|
2188
|
+
// Figures, then the chart beside the summary card, then the day-by-day calendar, the Tool and model table and the
|
|
2189
|
+
// daily breakdown. Under 860 px the vertical view above stays as it was. The chart and calendar draw after the HTML
|
|
2190
|
+
// lands, at the width they get, and redraw only on their own: a hover, a tap or a refresh never rebuilds a control
|
|
2191
|
+
// under a finger (uHeld).
|
|
2192
|
+
var U_WIDE_MQ = '(min-width: 860px)';
|
|
2193
|
+
function uWide() { try { return !!(window.matchMedia && window.matchMedia(U_WIDE_MQ).matches); } catch (_e) { return false; } }
|
|
2194
|
+
var U_MEASURES = [['all', 'All tokens'], ['new', 'New tokens'], ['value', 'API value']];
|
|
2195
|
+
var U_SERIES = [['tool', 'tool'], ['model', 'model'], ['type', 'token type']];
|
|
2196
|
+
var U_SCALES = [['daily', 'day'], ['weekly', 'week'], ['monthly', 'month']];
|
|
2197
|
+
var U_COLUMNS = [
|
|
2198
|
+
['records', 'Records', 'Usage records, about one for each model response.'],
|
|
2199
|
+
['cacheReads', 'Cache reads', 'How much of all tokens was context read again from cache.'],
|
|
2200
|
+
['perMillion', 'Per 1M new', 'API value for each million new tokens, counting only rows where every record has a price.'],
|
|
2201
|
+
['lastUsed', 'Last used', 'The last day with a record in this selection.']
|
|
2202
|
+
];
|
|
2203
|
+
var U_COLS_KEY = 'cos_web_usage_columns';
|
|
2204
|
+
// SessionUsageChart.seriesColor and rankedColors. The values live in sessions.css as --u-* tokens.
|
|
2205
|
+
var U_RANKED = ['var(--u-gold)', 'var(--u-codex)', 'var(--u-output)', 'var(--u-input)', 'var(--u-purple)', 'var(--u-brown)'];
|
|
2206
|
+
var U_HASHED = ['var(--u-blue)', 'var(--u-green)', 'var(--u-pink)', 'var(--u-indigo)', 'var(--u-tan)'];
|
|
2207
|
+
function uSeriesColor(name) {
|
|
2208
|
+
switch (name) {
|
|
2209
|
+
case 'Claude Code': return 'var(--u-gold)';
|
|
2210
|
+
case 'Codex': return 'var(--u-codex)';
|
|
2211
|
+
case 'Cursor': return 'var(--u-cursor)';
|
|
2212
|
+
case 'Codex (Ollama)': return 'var(--u-purple)';
|
|
2213
|
+
case 'Input': return 'var(--u-input)';
|
|
2214
|
+
case 'Output': return 'var(--u-output)';
|
|
2215
|
+
case 'Read from cache': return 'var(--u-read)';
|
|
2216
|
+
case 'Written to cache': return 'var(--u-purple)';
|
|
2217
|
+
case 'Other models': return 'var(--u-gray)';
|
|
2218
|
+
}
|
|
2219
|
+
var h = 0, bytes = new TextEncoder().encode(String(name));
|
|
2220
|
+
for (var i = 0; i < bytes.length; i++) h = (h * 31 + bytes[i]) % U_HASHED.length;
|
|
2221
|
+
return U_HASHED[h];
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2224
|
+
// SessionUsagePricing.rate and categoryUSD (SessionUsageCore.swift 98-170, rates checked 2026-10-25): the stored
|
|
2225
|
+
// value of a fully priced row split across its token types, reconciling to it. Partly priced rows and Cursor are not
|
|
2226
|
+
// split, and the card says so instead of inventing the difference.
|
|
2227
|
+
var U_RATES_ANTHROPIC = {
|
|
2228
|
+
'claude-opus-5-5': [4, 20, 0.2], 'claude-sonnet-5-5': [2, 10, 0.1],
|
|
2229
|
+
'claude-fable-5-1': [10, 50, 0.25], 'claude-mythos-5-1': [10, 50, 0.25], 'claude-fable-5': [10, 50, 1], 'claude-mythos-5': [10, 50, 1],
|
|
2230
|
+
'claude-opus-5': [5, 25, 0.5], 'claude-opus-4-8': [5, 25, 0.5], 'claude-opus-4-7': [5, 25, 0.5], 'claude-opus-4-6': [5, 25, 0.5], 'claude-opus-4-5': [5, 25, 0.5],
|
|
2231
|
+
'claude-opus-4-1': [15, 75, 1.5], 'claude-opus-4': [15, 75, 1.5], 'claude-sonnet-5': [2, 10, 0.2],
|
|
2232
|
+
'claude-sonnet-4-6': [3, 15, 0.3], 'claude-sonnet-4-5': [3, 15, 0.3], 'claude-sonnet-4': [3, 15, 0.3],
|
|
2233
|
+
'claude-haiku-4-5': [1, 5, 0.1], 'claude-3-5-haiku': [0.8, 4, 0.08]
|
|
2234
|
+
};
|
|
2235
|
+
var U_RATES_OPENAI = { 'gpt-6.1-sol': [2, 10, 0.1, 2.5, 5], 'gpt-6-astra': [10, 50, 1, 12.5, 25], 'gpt-6-luna': [0.1, 0.5, 0.01, 0.125, 0.25], 'gpt-5.3-codex': [1.75, 14, 0.175, 0, 0] };
|
|
2236
|
+
function uRate(provider, model, prompt) {
|
|
2237
|
+
var name = String(model || '').replace(/-\d{8}$/, ''), t;
|
|
2238
|
+
if (provider === 'Anthropic') {
|
|
2239
|
+
t = name === 'claude-haiku-5-5' ? (prompt > 100000 ? [0.5, 2.5, 0.05] : [0.1, 0.5, 0.01]) : U_RATES_ANTHROPIC[name];
|
|
2240
|
+
return t ? { input: t[0], output: t[1], read: t[2], write5: t[0] * 1.25, write1h: t[0] * 2 } : null;
|
|
2241
|
+
}
|
|
2242
|
+
t = provider === 'OpenAI' ? U_RATES_OPENAI[name] : null;
|
|
2243
|
+
return t ? { input: t[0], output: t[1], read: t[2], write5: t[3], write1h: t[4] } : null;
|
|
2244
|
+
}
|
|
2245
|
+
function uCategoryUSD(b) {
|
|
2246
|
+
var c = b.counts || {}, events = Number(b.events) || 0, usd = Number(b.estimatedUSD) || 0;
|
|
2247
|
+
if (!(events > 0) || Number(b.pricedEvents) !== events || !(usd > 0)) return null;
|
|
2248
|
+
var r = uRate(b.provider, b.model, 0);
|
|
2249
|
+
if (!r) return null;
|
|
2250
|
+
var write = Number(c.cacheWrite) || 0;
|
|
2251
|
+
var inU = (Number(c.input) || 0) * r.input / 1e6, outU = (Number(c.output) || 0) * r.output / 1e6, readU = (Number(c.cacheRead) || 0) * r.read / 1e6;
|
|
2252
|
+
var w5 = write * r.write5 / 1e6, nonWrite = inU + outU + readU;
|
|
2253
|
+
// One-hour cache writes raise only cacheWrite, never past every write at the one-hour rate.
|
|
2254
|
+
if (b.provider === 'Anthropic' && write > 0 && String(b.model).indexOf('claude-haiku-5-5') !== 0) {
|
|
2255
|
+
var rem = usd - nonWrite, cap = write * r.write1h / 1e6;
|
|
2256
|
+
if (rem >= w5 * 0.999 && rem <= cap * 1.001) return { input: inU, output: outU, cacheRead: readU, cacheWrite: Math.max(0, rem) };
|
|
2257
|
+
}
|
|
2258
|
+
var base = nonWrite + w5;
|
|
2259
|
+
if (!(base > 0)) return null;
|
|
2260
|
+
var s = usd / base;
|
|
2261
|
+
return { input: inU * s, output: outU * s, cacheRead: readU * s, cacheWrite: Math.max(0, usd - inU * s - outU * s - readU * s) };
|
|
2262
|
+
}
|
|
2263
|
+
function uCostSplit(rows) {
|
|
2264
|
+
var sp = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, priced: 0, unsplit: 0 };
|
|
2265
|
+
rows.forEach(function (b) {
|
|
2266
|
+
if (!(Number(b.pricedEvents) > 0)) return;
|
|
2267
|
+
sp.priced += Number(b.estimatedUSD) || 0;
|
|
2268
|
+
var c = uCategoryUSD(b);
|
|
2269
|
+
if (!c) { sp.unsplit += Number(b.events) || 0; return; }
|
|
2270
|
+
sp.input += c.input; sp.output += c.output; sp.cacheRead += c.cacheRead; sp.cacheWrite += c.cacheWrite;
|
|
2271
|
+
});
|
|
2272
|
+
sp.split = sp.input + sp.output + sp.cacheRead + sp.cacheWrite;
|
|
2273
|
+
return sp;
|
|
2274
|
+
}
|
|
2275
|
+
// tokenMix: always the four disjoint types, so the strips, the card's rows and the chart describe the same set.
|
|
2276
|
+
function uTokenMix(t, excl, rows) {
|
|
2277
|
+
var c = uCostSplit(rows);
|
|
2278
|
+
return [
|
|
2279
|
+
{ id: 'Input', count: t.input, usd: c.input },
|
|
2280
|
+
{ id: 'Output', count: t.output, usd: c.output },
|
|
2281
|
+
{ id: 'Read from cache', count: t.cacheRead, usd: c.cacheRead, excluded: excl },
|
|
2282
|
+
{ id: 'Written to cache', count: t.cacheWrite, usd: c.cacheWrite }
|
|
2283
|
+
];
|
|
2284
|
+
}
|
|
2285
|
+
function uMeasured(t, excl) { return excl ? t.fresh : t.all; }
|
|
2286
|
+
// summarize: one total per key, largest first by the measured tokens, ties by name.
|
|
2287
|
+
function uSummaries(rows, keyFn, excl) {
|
|
2288
|
+
return uGroup(rows, keyFn).sort(function (a, b) { var x = uMeasured(a.t, excl), y = uMeasured(b.t, excl); return x === y ? (a.key < b.key ? -1 : 1) : y - x; });
|
|
2289
|
+
}
|
|
2290
|
+
function uCostLabel2(t) { return t.priced > 0 ? uCurrency(t.usd, 2) + (t.priced < t.events ? '+' : '') : 'No price'; }
|
|
2291
|
+
function uSumCost(t) { return t.priced > 0 ? uDollars(t.usd) + (t.priced < t.events ? '+' : '') : 'No price'; }
|
|
2292
|
+
// peakDay: the highest day by the chart's measure; API value ranks only days with a price. Ties go to the earlier date.
|
|
2293
|
+
function uPeakDay(rows, cost, excl) {
|
|
2294
|
+
var best = null;
|
|
2295
|
+
uGroup(rows, function (b) { return b.day; }).forEach(function (g) {
|
|
2296
|
+
if (cost && !(g.t.priced > 0)) return;
|
|
2297
|
+
var v = cost ? g.t.usd : uMeasured(g.t, excl);
|
|
2298
|
+
if (!best || v > best.v || (v === best.v && g.key < best.g.key)) best = { g: g, v: v };
|
|
2299
|
+
});
|
|
2300
|
+
return best && best.g;
|
|
2301
|
+
}
|
|
2302
|
+
// comparePeriod: the selection against the window before it, for the same tool and model.
|
|
2303
|
+
function uComparePeriod(M, cost, excl) {
|
|
2304
|
+
var t = uTotals(M.selected), out = { peak: uPeakDay(M.selected, cost, excl), prior: null, has: false, partialPrior: false, costPartial: false, tok: 'Unavailable', usd: 'Unavailable' };
|
|
2305
|
+
var w = uCompare(M.period, M.today, SX.uFrom, SX.uTo);
|
|
2306
|
+
if (!w) return out;
|
|
2307
|
+
out.prior = w.prior;
|
|
2308
|
+
var priorRows = M.matching.filter(function (b) { return b.day >= w.prior[0] && b.day <= w.prior[1]; });
|
|
2309
|
+
// Each tool in view must reach back that far, or a mostly empty window would read as a real baseline.
|
|
2310
|
+
var provs = {}; M.selected.forEach(function (b) { provs[b.provider] = true; });
|
|
2311
|
+
var names = Object.keys(provs);
|
|
2312
|
+
out.partialPrior = !names.length || names.some(function (p) { var first = M.firstByProvider[p]; return !first || first > w.prior[0]; });
|
|
2313
|
+
var pt = uTotals(priorRows);
|
|
2314
|
+
out.has = priorRows.length > 0;
|
|
2315
|
+
out.tok = out.has ? uDelta(uMeasured(t, excl), uMeasured(pt, excl)) : 'Unavailable';
|
|
2316
|
+
out.usd = out.has && t.priced > 0 && pt.priced > 0 ? uDelta(t.usd, pt.usd) : 'Unavailable';
|
|
2317
|
+
out.costPartial = t.priced < t.events || pt.priced < pt.events;
|
|
2318
|
+
return out;
|
|
2319
|
+
}
|
|
2320
|
+
function uPriorNote(delta, c, pricedOnly) {
|
|
2321
|
+
if (!c.has || c.partialPrior || delta === 'Unavailable') return '';
|
|
2322
|
+
return delta + ' on the period before' + (pricedOnly ? ', priced records' : '');
|
|
2323
|
+
}
|
|
2324
|
+
// The selection the wide view draws. Built once per paint; a hover or a tap reads it and never rebuilds it.
|
|
2325
|
+
function uWideModel(d) {
|
|
2326
|
+
var su = d.sessionUsage, tz = su.timeZone || '', today = uDayKey(tz);
|
|
2327
|
+
var buckets = (Array.isArray(su.buckets) ? su.buckets : []).filter(function (b) { return b && typeof b.day === 'string' && uValidDay(b.day.slice(0, 10)); });
|
|
2328
|
+
var M = { su: su, tz: tz, today: today, buckets: buckets, firstByProvider: {} };
|
|
2329
|
+
if (!buckets.length) return M;
|
|
2330
|
+
buckets.forEach(function (b) { var f = M.firstByProvider[b.provider]; if (!f || b.day < f) M.firstByProvider[b.provider] = b.day; if (!M.firstDay || b.day < M.firstDay) M.firstDay = b.day; });
|
|
2331
|
+
var months = []; buckets.forEach(function (b) { var m = b.day.slice(0, 7); if (months.indexOf(m) < 0) months.push(m); });
|
|
2332
|
+
M.months = months.sort().reverse();
|
|
2333
|
+
if (!uKnownPeriod(SX.uPeriod) || (SX.uPeriod === U_CUSTOM && uCustomError(SX.uFrom, SX.uTo))) SX.uPeriod = 'Last 30 days';
|
|
2334
|
+
M.period = SX.uPeriod;
|
|
2335
|
+
M.bounds = uRange(M.period, today, SX.uFrom, SX.uTo);
|
|
2336
|
+
var inPeriod = M.bounds ? buckets.filter(function (b) { return b.day >= M.bounds[0] && b.day <= M.bounds[1]; }) : buckets;
|
|
2337
|
+
M.tools = uGroup(buckets, function (b) { return b.provider; }).sort(function (a, b) { return b.t.all - a.t.all; }).map(function (g) { return g.key; });
|
|
2338
|
+
if (SX.uTool !== 'All tools' && M.tools.indexOf(SX.uTool) < 0) SX.uTool = 'All tools';
|
|
2339
|
+
var forTool = buckets.filter(function (b) { return SX.uTool === 'All tools' || b.provider === SX.uTool; });
|
|
2340
|
+
M.models = uGroup(forTool, function (b) { return b.model; }).sort(function (a, b) { return b.t.all - a.t.all; }).map(function (g) { return g.key; });
|
|
2341
|
+
if (SX.uModel !== 'All models' && M.models.indexOf(SX.uModel) < 0) SX.uModel = 'All models';
|
|
2342
|
+
var inSlice = function (b) { return (SX.uTool === 'All tools' || b.provider === SX.uTool) && (SX.uModel === 'All models' || b.model === SX.uModel); };
|
|
2343
|
+
M.matching = buckets.filter(inSlice);
|
|
2344
|
+
M.selected = inPeriod.filter(inSlice);
|
|
2345
|
+
M.hasFilter = SX.uTool !== 'All tools' || SX.uModel !== 'All models';
|
|
2346
|
+
M.cost = SX.uMeasure === 'value'; M.excl = SX.uMeasure === 'new';
|
|
2347
|
+
M.series = SX.uSeries;
|
|
2348
|
+
M.scale = uScale(M);
|
|
2349
|
+
if (!M.selected.length) return M;
|
|
2350
|
+
|
|
2351
|
+
// Bars: one per period with records. Slots run continuously from the first to the last, so a gap shows as a gap.
|
|
2352
|
+
M.keyOf = {}; M.keySet = {};
|
|
2353
|
+
M.selected.forEach(function (b) { if (!M.keyOf[b.day]) { M.keyOf[b.day] = uPeriodKey(b.day, M.scale); M.keySet[M.keyOf[b.day]] = true; } });
|
|
2354
|
+
M.keys = Object.keys(M.keySet).sort();
|
|
2355
|
+
M.slots = [];
|
|
2356
|
+
for (var k = M.keys[0]; k <= M.keys[M.keys.length - 1];) {
|
|
2357
|
+
M.slots.push(k);
|
|
2358
|
+
k = M.scale === 'monthly' ? uMonthAdd(k, 1) : uAddDays(k, M.scale === 'weekly' ? 7 : 1);
|
|
2359
|
+
}
|
|
2360
|
+
var firstSel = M.selected.reduce(function (m, b) { return !m || b.day < m ? b.day : m; }, '');
|
|
2361
|
+
var lo = M.bounds && M.bounds[0] > firstSel ? M.bounds[0] : firstSel, hi = M.bounds && M.bounds[1] < today ? M.bounds[1] : today;
|
|
2362
|
+
M.partial = uPartialPeriods(M.keys, M.scale, lo, hi);
|
|
2363
|
+
M.inspect = uInspected(M);
|
|
2364
|
+
|
|
2365
|
+
// The six largest models by the measured tokens. The chart and the card name the same six.
|
|
2366
|
+
var top = {}, rank = {};
|
|
2367
|
+
uSummaries(M.selected, function (b) { return b.model; }, M.excl).slice(0, 6).forEach(function (g, i) { top[g.key] = true; rank[uModelName(g.key)] = U_RANKED[i]; });
|
|
2368
|
+
M.rankColor = rank;
|
|
2369
|
+
var groups = {};
|
|
2370
|
+
M.selected.forEach(function (b) { var key = M.keyOf[b.day]; (groups[key] = groups[key] || []).push(b); });
|
|
2371
|
+
M.points = [];
|
|
2372
|
+
Object.keys(groups).forEach(function (key) {
|
|
2373
|
+
var g = groups[key];
|
|
2374
|
+
if (M.series === 'type') {
|
|
2375
|
+
var gt = uTotals(g);
|
|
2376
|
+
uTokenMix(gt, M.excl, g).forEach(function (c) { if (M.cost || !c.excluded) M.points.push({ key: key, series: c.id, tokens: c.count, usd: c.usd, priced: gt.priced, events: gt.events }); });
|
|
2377
|
+
return;
|
|
2378
|
+
}
|
|
2379
|
+
var by = {};
|
|
2380
|
+
g.forEach(function (b) { var s = M.series === 'tool' ? uToolName(b.provider) : top[b.model] ? uModelName(b.model) : 'Other models'; (by[s] = by[s] || []).push(b); });
|
|
2381
|
+
Object.keys(by).forEach(function (s) { var st = uTotals(by[s]); M.points.push({ key: key, series: s, tokens: uMeasured(st, M.excl), usd: st.usd, priced: st.priced, events: st.events }); });
|
|
2382
|
+
});
|
|
2383
|
+
// The largest series sits at the bottom of every stack, and the legend and colours read the same order.
|
|
2384
|
+
var totals = {};
|
|
2385
|
+
M.points.forEach(function (p) { totals[p.series] = (totals[p.series] || 0) + (M.cost ? p.usd : p.tokens); });
|
|
2386
|
+
M.order = Object.keys(totals).sort(function (a, b) { return totals[a] === totals[b] ? (a < b ? -1 : 1) : totals[b] - totals[a]; });
|
|
2387
|
+
M.color = function (name) { return M.series === 'model' ? (rank[name] || 'var(--u-gray)') : uSeriesColor(name); };
|
|
2388
|
+
M.plotted = function (p) { return M.cost ? p.usd : p.tokens / 1e6; };
|
|
2389
|
+
M.figure = function (p) { return !M.cost || p.priced > 0; };
|
|
2390
|
+
M.average = uChartAverage(M);
|
|
2391
|
+
if (M.cost && M.selected.every(function (b) { return !(Number(b.pricedEvents) > 0); })) M.noChart = 'No verified prices for this selection.';
|
|
2392
|
+
else if (M.cost && M.series === 'type' && !(uCostSplit(M.selected).split > 0)) M.noChart = 'This cost cannot be split by token type. Cursor reports one cost per request.';
|
|
2393
|
+
M.compare = uComparePeriod(M, M.cost, M.excl);
|
|
2394
|
+
M.weeks = uCalendarWeeks(M);
|
|
2395
|
+
return M;
|
|
2396
|
+
}
|
|
2397
|
+
// The scale in use: a day jump (Busiest day, a calendar square) shows days until the whole period comes back.
|
|
2398
|
+
function uScale(M) { return SX.uDayJump ? 'daily' : SX.uScale || uDefaultScale(M.period, M.today, SX.uFrom, SX.uTo); }
|
|
2399
|
+
function uInspected(M) { return SX.uInspect && SX.uInspect.scale === M.scale && M.keySet && M.keySet[SX.uInspect.key] ? SX.uInspect.key : null; }
|
|
2400
|
+
// chartAverage: the typical bar, leaving out partial ones and, for value, bars with no price. Lines draw none, and
|
|
2401
|
+
// under four full periods an average says nothing a glance does not.
|
|
2402
|
+
function uChartAverage(M) {
|
|
2403
|
+
if (SX.uLines) return null;
|
|
2404
|
+
var totals = {};
|
|
2405
|
+
M.points.forEach(function (p) { if (M.figure(p)) totals[p.key] = (totals[p.key] || 0) + M.plotted(p); });
|
|
2406
|
+
var full = Object.keys(totals).filter(function (k) { return !M.partial[k]; }).map(function (k) { return totals[k]; });
|
|
2407
|
+
return full.length >= 4 ? full.reduce(function (s, v) { return s + v; }, 0) / full.length : null;
|
|
2408
|
+
}
|
|
2409
|
+
function uDayValue(M, t) { return M.cost ? t.usd : uMeasured(t, M.excl); }
|
|
2410
|
+
function uDayFigure(M, v) { return M.cost ? uDollars(v) : uShort(v); }
|
|
2411
|
+
// calendarWeeks: Monday-to-Sunday columns over the selection, through today. A day outside it stays blank; a day in
|
|
2412
|
+
// it with no records has no logs, which is not zero.
|
|
2413
|
+
function uCalendarWeeks(M) {
|
|
2414
|
+
var lower = M.bounds ? M.bounds[0] : M.firstDay, upper = M.bounds && M.bounds[1] < M.today ? M.bounds[1] : M.today;
|
|
2415
|
+
if (!lower || lower > upper) return [];
|
|
2416
|
+
var days = {}; uGroup(M.selected, function (b) { return b.day; }).forEach(function (g) { days[g.key] = g; });
|
|
2417
|
+
var weeks = [], spans = uSerial(upper) - uSerial(lower) > 366;
|
|
2418
|
+
for (var monday = uAddDays(lower, -uWeekday(lower)); monday <= upper; monday = uAddDays(monday, 7)) {
|
|
2419
|
+
var first = monday < lower ? lower : monday;
|
|
2420
|
+
var label = U_MONTHS[Number(first.slice(5, 7)) - 1].slice(0, 3) + (spans && (!weeks.length || first.slice(5, 7) === '01') ? ' ' + first.slice(0, 4) : '');
|
|
2421
|
+
var prev = weeks.length ? weeks[weeks.length - 1].days.filter(function (x) { return x.inRange; })[0] : null;
|
|
2422
|
+
var cells = [];
|
|
2423
|
+
for (var i = 0; i < 7; i++) { var day = uAddDays(monday, i); cells.push({ id: day, inRange: day >= lower && day <= upper, sum: days[day] || null }); }
|
|
2424
|
+
weeks.push({ id: monday, label: prev && prev.id.slice(5, 7) === first.slice(5, 7) ? null : label, days: cells });
|
|
2425
|
+
}
|
|
2426
|
+
// A stub first column would run its label into the next month's.
|
|
2427
|
+
if (weeks.length > 1 && weeks[1].label && weeks[0].days.filter(function (x) { return x.inRange; }).length < 4) weeks[0].label = null;
|
|
2428
|
+
return weeks;
|
|
2429
|
+
}
|
|
2430
|
+
// calendarFit: the most recent weeks that fit at 4 px a square or more, with month labels that do not touch.
|
|
2431
|
+
function uCalendarFit(all, width) {
|
|
2432
|
+
var spacing = all.length > 52 ? 1 : 3;
|
|
2433
|
+
if (!(width > 0) || !all.length) return { weeks: all, spacing: spacing, numbers: false };
|
|
2434
|
+
var fits = Math.max(1, Math.floor((width + spacing) / (4 + spacing)));
|
|
2435
|
+
var weeks = all.slice(-fits).map(function (w) { return { id: w.id, label: w.label, days: w.days }; });
|
|
2436
|
+
if (weeks.length < all.length) {
|
|
2437
|
+
var f = weeks[0].days.filter(function (x) { return x.inRange; })[0];
|
|
2438
|
+
if (f) weeks[0].label = U_MONTHS[Number(f.id.slice(5, 7)) - 1].slice(0, 3) + ' ' + f.id.slice(0, 4);
|
|
2439
|
+
}
|
|
2440
|
+
var pitch = (width + spacing) / weeks.length, kept = [], labels = {};
|
|
2441
|
+
var labelled = weeks.map(function (w, i) { return [i, w.label]; }).filter(function (x) { return x[1]; });
|
|
2442
|
+
labelled.filter(function (x) { return x[1].indexOf(' ') >= 0; }).concat(labelled.filter(function (x) { return x[1].indexOf(' ') < 0; })).forEach(function (x) {
|
|
2443
|
+
var start = x[0] * pitch, end = start + x[1].length * 6.5 + 2;
|
|
2444
|
+
if (kept.some(function (r) { return start < r[1] + 4 && r[0] < end + 4; }) || end > width + 8) return;
|
|
2445
|
+
kept.push([start, end]); labels[x[0]] = x[1];
|
|
2446
|
+
});
|
|
2447
|
+
weeks.forEach(function (w, i) { w.label = labels[i] || null; });
|
|
2448
|
+
return { weeks: weeks, spacing: spacing, numbers: width / weeks.length - spacing >= 44 };
|
|
2449
|
+
}
|
|
2450
|
+
var U_CHEV = '<svg class="u-chev" viewBox="0 0 10 6" aria-hidden="true"><path d="M1 1l4 4 4-4"/></svg>';
|
|
2451
|
+
// One word of the chart sentence: the word shows, and a real select over it opens the platform's own picker.
|
|
2452
|
+
function uWord(id, label, value, options) {
|
|
2453
|
+
var shown = (options.filter(function (o) { return o[0] === value; })[0] || options[0])[1];
|
|
2454
|
+
return '<label class="u-word"><span class="u-word-text">' + esc(shown) + '</span>' + U_CHEV +
|
|
2455
|
+
'<select id="' + id + '" aria-label="' + esc(label) + '">' + options.map(function (o) { return '<option value="' + esc(o[0]) + '"' + (o[0] === value ? ' selected' : '') + '>' + esc(o[1]) + '</option>'; }).join('') + '</select></label>';
|
|
2456
|
+
}
|
|
2457
|
+
function uWholeTitle(M) {
|
|
2458
|
+
var parts = [M.period === U_CUSTOM ? uDateRangeTitle([SX.uFrom, SX.uTo]) : uRangeTitle(M.period)];
|
|
2459
|
+
if (SX.uTool !== 'All tools') parts.push(uToolName(SX.uTool));
|
|
2460
|
+
if (SX.uModel !== 'All models') parts.push(uModelName(SX.uModel));
|
|
2461
|
+
return parts.join(', ');
|
|
2462
|
+
}
|
|
2463
|
+
function uMeasureWords() { return SX.uMeasure === 'value' ? 'API value' : SX.uMeasure === 'new' ? 'New tokens' : 'All tokens'; }
|
|
2464
|
+
function uSwatch(color) { return color ? '<span class="u-sw" style="background:' + color + '" aria-hidden="true"></span>' : ''; }
|
|
2465
|
+
|
|
2466
|
+
// The answers people open the page for (figures, SessionUsageView 702-750), with the change on the period before.
|
|
2467
|
+
function uFiguresHtml(M) {
|
|
2468
|
+
var all = uComparePeriod(M, false, false), nw = uComparePeriod(M, false, true), t = uTotals(M.selected);
|
|
2469
|
+
var days = uGroup(M.selected, function (b) { return b.day; }), priced = days.filter(function (g) { return g.t.priced > 0; });
|
|
2470
|
+
var perDay = priced.length ? uDollars(priced.reduce(function (s, g) { return s + g.t.usd; }, 0) / priced.length) + (priced.some(function (g) { return g.t.priced < g.t.events; }) ? '+' : '') : 'No price';
|
|
2471
|
+
var list = [
|
|
2472
|
+
['API value', uCostCell(t), uPriorNote(all.usd, all, all.costPartial), 'What these tokens would cost at standard API rates. Not your subscription bill.'],
|
|
2473
|
+
['New tokens', uShort(t.fresh), uPriorNote(nw.tok, nw), 'Input, output and cache writes.'],
|
|
2474
|
+
['All tokens', uShort(t.all), uShare(t.cacheRead, t.all) + ' read again from cache', 'New tokens plus context read again from cache.'],
|
|
2475
|
+
['API value a day', perDay, days.length + ' ' + (days.length === 1 ? 'day' : 'days') + ' with usage', 'Average across days with a price. Days without logs or a price are left out, not counted as zero.']
|
|
2476
|
+
];
|
|
2477
|
+
// monthPace: this calendar month for the tool and model in view, whatever period is selected.
|
|
2478
|
+
var month = M.today.slice(0, 7), mt = uTotals(M.matching.filter(function (b) { return b.day.slice(0, 7) === month; }));
|
|
2479
|
+
if (mt.priced > 0) {
|
|
2480
|
+
var todayN = Number(M.today.slice(8)), last = uMonthEnd(month), total = Number(last.slice(8));
|
|
2481
|
+
var start = M.matching.filter(function (b) { return b.day.slice(0, 7) === month; }).reduce(function (m, b) { return Math.min(m, Number(b.day.slice(8, 10))); }, todayN);
|
|
2482
|
+
var elapsed = todayN - start + 1, projected = elapsed >= 3 && todayN < total ? mt.usd + mt.usd / elapsed * (total - todayN) : null;
|
|
2483
|
+
list.push([U_MONTHS[Number(month.slice(5)) - 1] + ' so far', uDollars(mt.usd) + (mt.priced < mt.events ? '+' : ''), projected === null ? '' : 'About ' + uDollars(projected) + ' by ' + uShortDay(last),
|
|
2484
|
+
'This calendar month for the tool and model in view, whatever period is selected. The estimate is a straight line from the days so far.']);
|
|
2485
|
+
}
|
|
2486
|
+
return '<div class="sx-figures u-figs' + (list.length > 4 ? ' five' : '') + '">' + list.map(function (f, i) {
|
|
2487
|
+
return '<div class="sx-figure' + (i === 0 ? ' lead' : '') + '" title="' + esc(f[3]) + '"><b>' + esc(f[1]) + '</b><span class="sx-figure-title">' + esc(f[0]) + '</span>' + (f[2] ? '<span class="sx-figure-note">' + esc(f[2]) + '</span>' : '') + '</div>';
|
|
2488
|
+
}).join('') + '</div>';
|
|
2489
|
+
}
|
|
2490
|
+
|
|
2491
|
+
function uChartSectionHtml(M) {
|
|
2492
|
+
var sentence = '<div class="u-sentence">' + uWord('u-measure', 'Measure', SX.uMeasure, U_MEASURES) + '<span>by</span>' + uWord('u-series', 'Stack by', SX.uSeries, U_SERIES) +
|
|
2493
|
+
'<span>per</span>' + uWord('u-scale', 'Time scale', M.scale, U_SCALES) + '<span class="u-grow"></span>' +
|
|
2494
|
+
'<button type="button" class="u-style" data-u="lines" data-on="0" aria-pressed="' + !SX.uLines + '" aria-label="Stacked bars" title="Stacked bars"><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M2 14h12M4 12V7M8 12V3M12 12V8"/></svg></button>' +
|
|
2495
|
+
'<button type="button" class="u-style" data-u="lines" data-on="1" aria-pressed="' + !!SX.uLines + '" aria-label="Lines" title="Lines"><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M2 14h12M2.5 11l3.5-4 3 2.5L13.5 4"/></svg></button></div>';
|
|
2496
|
+
var legend = M.order.map(function (n) { return '<span class="u-key">' + uSwatch(M.color(n)) + esc(n) + '</span>'; }).join('');
|
|
2497
|
+
var scaleWord = M.scale === 'weekly' ? 'week' : M.scale === 'monthly' ? 'month' : 'day';
|
|
2498
|
+
if (!SX.uLines && Object.keys(M.partial).length) legend += '<span class="u-key">Lighter bars cover part of a ' + scaleWord + (M.average !== null ? ' and are left out of the average.' : '.') + '</span>';
|
|
2499
|
+
return '<section class="u-chartcol">' + sentence +
|
|
2500
|
+
'<div class="u-chart" id="u-chart" tabindex="0" role="group" aria-label="' + esc(uMeasureWords() + ' by ' + (SX.uSeries === 'type' ? 'token type' : SX.uSeries) + ' per ' + scaleWord + '. Arrow keys step through the bars.') + '">' +
|
|
2501
|
+
'<div class="u-tip hidden" id="u-tip" aria-hidden="true"></div></div>' +
|
|
2502
|
+
'<div class="u-legend">' + legend + '</div></section>';
|
|
2503
|
+
}
|
|
2504
|
+
|
|
2505
|
+
// The summary card (SessionUsageInspector): the whole period until a bar is chosen, then that bar.
|
|
2506
|
+
function uCardHtml(M) {
|
|
2507
|
+
var key = M.inspect, rows = key ? M.selected.filter(function (b) { return M.keyOf[b.day] === key; }) : M.selected;
|
|
2508
|
+
var t = uTotals(rows), measured = uMeasured(t, M.excl);
|
|
2509
|
+
var title = key ? uPeriodTitle(key, M.scale, M.bounds) : uWholeTitle(M), earlier, later;
|
|
2510
|
+
if (key) { var i = M.keys.indexOf(key); earlier = i > 0; later = i < M.keys.length - 1; }
|
|
2511
|
+
else { earlier = !!uStep(M.period, M.today, SX.uFrom, SX.uTo, -1, M.firstDay); later = !!uStep(M.period, M.today, SX.uFrom, SX.uTo, 1, M.firstDay); }
|
|
2512
|
+
var step = function (d, on) {
|
|
2513
|
+
return '<button type="button" class="u-step" data-u="step" data-d="' + d + '"' + (on ? '' : ' disabled') + ' aria-label="' + (d < 0 ? 'Earlier period' : 'Later period') + '" title="' + (d < 0 ? 'Earlier period' : 'Later period') + '">' +
|
|
2514
|
+
'<svg viewBox="0 0 10 10" aria-hidden="true"><path d="' + (d < 0 ? 'M6.5 1.5L3 5l3.5 3.5' : 'M3.5 1.5L7 5 3.5 8.5') + '"/></svg></button>';
|
|
2515
|
+
};
|
|
2516
|
+
var h = '<div class="u-card-top">' + (key ? '<p class="u-card-title">' + esc(title) + '</p>'
|
|
2517
|
+
: '<button type="button" class="u-card-title u-rangelink" data-u="range" aria-haspopup="dialog" title="Choose the dates">' + esc(title) + U_CHEV + '</button>') + step(-1, earlier) + step(1, later) + '</div>';
|
|
2518
|
+
if (key) {
|
|
2519
|
+
h += '<p class="u-card-head">' + esc(M.cost ? uCostCell(t) : uSpoken(measured) + (M.excl ? ' new tokens' : ' tokens')) + '</p>' +
|
|
2520
|
+
'<p class="u-card-sub">' + esc(M.cost ? uSpoken(t.all) + ' tokens' : t.priced > 0 ? uDollars(t.usd) + ' at API rates' + (t.priced < t.events ? ', plus unpriced records' : '') : 'No verified price') + '</p>';
|
|
2521
|
+
}
|
|
2522
|
+
var mix = uTokenMix(t, false, rows).sort(function (a, b) { return b.count - a.count; }), split = uCostSplit(rows);
|
|
2523
|
+
var strip = function (label, parts) {
|
|
2524
|
+
var sum = parts.reduce(function (s, p) { return s + Math.max(0, p[1]); }, 0);
|
|
2525
|
+
return '<div class="u-mix"><span>' + label + '</span><div class="u-strip" aria-hidden="true">' + parts.filter(function (p) { return p[1] > 0 && sum > 0; }).map(function (p) {
|
|
2526
|
+
return '<i style="flex-grow:' + (p[1] / sum).toFixed(5) + ';background:' + uSeriesColor(p[0]) + '"></i>';
|
|
2527
|
+
}).join('') + '</div></div>';
|
|
2528
|
+
};
|
|
2529
|
+
h += '<div class="u-mixes">' + strip('Share of tokens', mix.map(function (c) { return [c.id, c.count]; })) +
|
|
2530
|
+
(split.split > 0 ? strip('Share of API value', mix.map(function (c) { return [c.id, c.usd]; })) : '') + '</div>';
|
|
2531
|
+
var row = function (color, name, a, b, help) {
|
|
2532
|
+
return '<div class="u-crow">' + uSwatch(color) + '<span class="u-cname" title="' + esc(help || name) + '">' + esc(name) + '</span><span class="u-cnum">' + esc(a) + '</span><span class="u-cval">' + esc(b) + '</span></div>';
|
|
2533
|
+
};
|
|
2534
|
+
var tools = uSummaries(rows, function (b) { return b.provider; }, M.excl);
|
|
2535
|
+
if (tools.length > 1) h += '<h4 class="u-ch">Tools</h4>' + tools.map(function (g) { return row(uSeriesColor(uToolName(g.key)), uToolName(g.key), uShort(uMeasured(g.t, M.excl)), uSumCost(g.t)); }).join('');
|
|
2536
|
+
if (key) {
|
|
2537
|
+
var models = uSummaries(rows, function (b) { return b.provider + '|' + b.model; }, M.excl);
|
|
2538
|
+
h += '<h4 class="u-ch">Models</h4>' + models.slice(0, 5).map(function (g) {
|
|
2539
|
+
var id = g.rows[0].model, name = uModelName(id);
|
|
2540
|
+
return row(M.series === 'model' ? M.rankColor[name] : '', name, uShort(uMeasured(g.t, M.excl)), uSumCost(g.t), id);
|
|
2541
|
+
}).join('') + (models.length > 5 ? '<p class="u-cnote">' + (models.length - 5) + ' more not listed here</p>' : '');
|
|
2542
|
+
}
|
|
2543
|
+
h += '<h4 class="u-ch">Token types</h4>' + uTokenMix(t, M.excl, rows).sort(function (a, b) { return b.count - a.count; }).map(function (c) {
|
|
2544
|
+
return row(uSeriesColor(c.id), c.id, c.excluded ? 'Not counted' : uShort(c.count), split.split > 0 ? uDollars(c.usd) : '');
|
|
2545
|
+
}).join('');
|
|
2546
|
+
if (split.unsplit > 0 && split.split > 0) {
|
|
2547
|
+
h += '<p class="u-cnote">Values by type cover ' + esc(uDollars(split.split)) + ' of ' + esc(uDollars(split.priced)) + '. ' +
|
|
2548
|
+
(rows.some(function (b) { return b.provider === 'Cursor'; }) ? 'Cursor and partly priced records are' : 'Partly priced records are') + ' not split.</p>';
|
|
2549
|
+
}
|
|
2550
|
+
h += '<hr class="u-rule-h">';
|
|
2551
|
+
if (key) h += '<p class="u-cline">' + esc(uSpoken(t.fresh) + ' were new. ' + uShare(t.cacheRead, t.all) + ' was context read again from cache.') + '</p>';
|
|
2552
|
+
if (!key) {
|
|
2553
|
+
var c = M.compare;
|
|
2554
|
+
if (c.peak) h += '<button type="button" class="u-peak" data-u="peak" data-day="' + esc(c.peak.key) + '" title="Show this day">Busiest day: ' + esc(uPeriodTitle(c.peak.key, 'daily') + ', ' + (M.cost ? uDollars(c.peak.t.usd) : uShort(uMeasured(c.peak.t, M.excl)))) + '</button>';
|
|
2555
|
+
if (c.prior && (!c.has || c.partialPrior)) h += '<p class="u-cline">' + (c.partialPrior ? 'History does not reach back a full period, so there is no comparison.' : 'No usage in the period before.') + '</p>';
|
|
2556
|
+
if (c.prior && c.has && !c.partialPrior) h += '<p class="u-cline">' + esc('Compared with ' + uDateRangeTitle(c.prior) + ': tokens ' + c.tok + ', API value ' + c.usd + (c.costPartial ? ' (priced records only)' : '') + '.') + '</p>';
|
|
2557
|
+
}
|
|
2558
|
+
if (key) h += '<button type="button" class="sx-link u-whole" data-u="whole">Show the whole period</button>';
|
|
2559
|
+
return h;
|
|
2560
|
+
}
|
|
2561
|
+
|
|
2562
|
+
function uCols() {
|
|
2563
|
+
if (!SX.uCols) {
|
|
2564
|
+
var saved = null;
|
|
2565
|
+
try { saved = JSON.parse(localStorage.getItem(U_COLS_KEY) || 'null'); } catch (_e) { saved = null; }
|
|
2566
|
+
SX.uCols = Array.isArray(saved) ? U_COLUMNS.map(function (c) { return c[0]; }).filter(function (c) { return saved.indexOf(c) >= 0; }) : ['records', 'lastUsed'];
|
|
2567
|
+
}
|
|
2568
|
+
return SX.uCols;
|
|
2569
|
+
}
|
|
2570
|
+
// sortModels: unpriced rows sort last on API value in both directions; ties break on provider and model.
|
|
2571
|
+
function uSortRows(groups, sort, asc) {
|
|
2572
|
+
return groups.slice().sort(function (a, b) {
|
|
2573
|
+
if (sort === 'cost' && (a.t.priced > 0) !== (b.t.priced > 0)) return a.t.priced > 0 ? -1 : 1;
|
|
2574
|
+
var x, y;
|
|
2575
|
+
if (sort === 'model') { x = String(a.rows[0].model).toLowerCase(); y = String(b.rows[0].model).toLowerCase(); if (x !== y) return (x < y) === asc ? -1 : 1; return a.key < b.key ? -1 : 1; }
|
|
2576
|
+
x = sort === 'newTokens' ? a.t.fresh : sort === 'cost' ? a.t.usd : a.t.all;
|
|
2577
|
+
y = sort === 'newTokens' ? b.t.fresh : sort === 'cost' ? b.t.usd : b.t.all;
|
|
2578
|
+
if (x === y) return a.key < b.key ? -1 : 1;
|
|
2579
|
+
return asc ? x - y : y - x;
|
|
2580
|
+
});
|
|
2581
|
+
}
|
|
2582
|
+
// The Tool and model table (SessionUsageView usageTable): tools, each with its models, in the chosen order.
|
|
2583
|
+
function uTableHtml(M) {
|
|
2584
|
+
var cols = uCols(), whole = uTotals(M.selected), wholeV = M.cost ? whole.usd : uMeasured(whole, M.excl), last = {};
|
|
2585
|
+
M.selected.forEach(function (b) { var k = b.provider + '|' + b.model; if (!last[k] || b.day > last[k]) last[k] = b.day; });
|
|
2586
|
+
var year = M.today.slice(0, 4);
|
|
2587
|
+
var share = function (t) {
|
|
2588
|
+
var part = M.cost ? t.usd : uMeasured(t, M.excl);
|
|
2589
|
+
var text = M.cost ? (t.priced > 0 ? uShare(t.usd, whole.usd) : '') : uShare(part, wholeV);
|
|
2590
|
+
if (text === 'Unavailable') text = '';
|
|
2591
|
+
var f = wholeV > 0 ? part / wholeV : 0;
|
|
2592
|
+
return { text: text, f: f };
|
|
2593
|
+
};
|
|
2594
|
+
var extra = function (rows, t) {
|
|
2595
|
+
return cols.map(function (c) {
|
|
2596
|
+
if (c === 'records') return '<td class="num">' + uNum(t.events) + '</td>';
|
|
2597
|
+
if (c === 'cacheReads') return '<td class="num">' + esc(uShare(t.cacheRead, t.all)) + '</td>';
|
|
2598
|
+
if (c === 'perMillion') {
|
|
2599
|
+
var full = rows.filter(function (b) { return Number(b.events) > 0 && Number(b.pricedEvents) === Number(b.events); }), ft = uTotals(full);
|
|
2600
|
+
return '<td class="num">' + (ft.fresh > 0 ? esc(uCurrency(ft.usd / ft.fresh * 1e6, 2)) : 'No price') + '</td>';
|
|
2601
|
+
}
|
|
2602
|
+
var day = rows.map(function (b) { return last[b.provider + '|' + b.model] || ''; }).reduce(function (m, d) { return d > m ? d : m; }, '');
|
|
2603
|
+
return '<td class="num">' + (day ? esc(uShortDay(day) + (day.slice(0, 4) === year ? '' : ', ' + day.slice(0, 4))) : '') + '</td>';
|
|
2604
|
+
}).join('');
|
|
2605
|
+
};
|
|
2606
|
+
var cells = function (rows, t, color) {
|
|
2607
|
+
var s = share(t);
|
|
2608
|
+
return '<td class="num">' + uShort(t.all) + '</td><td class="num">' + uShort(t.fresh) + '</td>' + extra(rows, t) +
|
|
2609
|
+
'<td class="num u-sharecell"><span class="u-sharebar" aria-hidden="true"' + (s.text ? '' : ' data-empty') + '><i style="width:' + (s.f > 0 ? Math.max(2, Math.round(56 * s.f)) : 0) + 'px;background:' + color + '"></i></span>' + esc(s.text) + '</td>' +
|
|
2610
|
+
'<td class="num">' + esc(uSumCost(t)) + '</td>';
|
|
2611
|
+
};
|
|
2612
|
+
var sortTh = function (label, sort, help, lead) {
|
|
2613
|
+
var on = SX.uSort === sort;
|
|
2614
|
+
return '<th class="' + (lead ? 'name' : 'num') + '"' + (on ? ' aria-sort="' + (SX.uSortAsc ? 'ascending' : 'descending') + '"' : '') + '><button type="button" class="u-sort' + (on ? ' on' : '') + '" data-u="sort" data-sort="' + sort + '" title="' + esc(help) + '">' + label +
|
|
2615
|
+
(on ? '<svg class="u-dir" viewBox="0 0 10 6" aria-hidden="true"><path d="' + (SX.uSortAsc ? 'M1 5l4-4 4 4' : 'M1 1l4 4 4-4') + '"/></svg>' : '') + '</button></th>';
|
|
2616
|
+
};
|
|
2617
|
+
var head = '<tr>' + sortTh('Name', 'model', 'Sort models by name', true) + sortTh('All tokens', 'tokens', 'Sort by all tokens') + sortTh('New tokens', 'newTokens', 'Sort by input, output and cache writes') +
|
|
2618
|
+
cols.map(function (c) { var d = U_COLUMNS.filter(function (x) { return x[0] === c; })[0]; return '<th class="num" title="' + esc(d[2]) + '">' + d[1] + '</th>'; }).join('') +
|
|
2619
|
+
'<th class="num" title="Share of the selected ' + (M.cost ? 'API value' : 'tokens') + '. Never your subscription allowance.">Share</th>' + sortTh('API value', 'cost', 'Sort by API value; unpriced models stay last') + '</tr>';
|
|
2620
|
+
var span = 5 + cols.length, body = '';
|
|
2621
|
+
uSummaries(M.selected, function (b) { return b.provider; }, false).forEach(function (g) {
|
|
2622
|
+
var tool = uToolName(g.key), color = uSeriesColor(tool), on = SX.uTool === g.key;
|
|
2623
|
+
body += '<tr class="u-tool"><td><button type="button" class="u-namebtn" data-u="tool" data-p="' + esc(g.key) + '" title="' + (on ? 'Show all tools' : 'Show only ' + esc(tool)) + '">' + uSwatch(color) + esc(tool) + '</button></td>' + cells(g.rows, g.t, color) + '</tr>';
|
|
2624
|
+
var models = uSortRows(uGroup(g.rows, function (b) { return b.model; }), SX.uSort, SX.uSortAsc);
|
|
2625
|
+
var open = SX.uOpen[g.key] || SX.uModel !== 'All models', shown = open ? models : models.slice(0, 5);
|
|
2626
|
+
shown.forEach(function (m) {
|
|
2627
|
+
var mine = SX.uModel === m.key && SX.uTool === g.key;
|
|
2628
|
+
body += '<tr class="u-model"><td><button type="button" class="u-namebtn' + (mine ? ' on' : '') + '" data-u="model" data-p="' + esc(g.key) + '" data-m="' + esc(m.key) + '" title="' + esc(m.key) + '">' + esc(uModelName(m.key)) + '</button></td>' + cells(m.rows, m.t, color) + '</tr>';
|
|
2629
|
+
});
|
|
2630
|
+
if (models.length > shown.length) {
|
|
2631
|
+
var hidden = models.slice(shown.length), hr = [], n = hidden.length;
|
|
2632
|
+
hidden.forEach(function (m) { hr = hr.concat(m.rows); });
|
|
2633
|
+
body += '<tr class="u-model"><td><button type="button" class="u-namebtn u-more" data-u="more" data-p="' + esc(g.key) + '">' + n + ' more ' + (n === 1 ? 'model' : 'models') + '</button></td>' + cells(hr, uTotals(hr), 'var(--sub)') + '</tr>';
|
|
2634
|
+
} else if (models.length > 5 && SX.uModel === 'All models') {
|
|
2635
|
+
body += '<tr class="u-model"><td colspan="' + span + '"><button type="button" class="u-namebtn u-more" data-u="fewer" data-p="' + esc(g.key) + '">Show fewer</button></td></tr>';
|
|
2636
|
+
}
|
|
2637
|
+
});
|
|
2638
|
+
var menu = '<details class="u-cols"' + (SX.uColsOpen ? ' open' : '') + '><summary>Columns' + U_CHEV + '</summary><div class="u-cols-menu" role="group" aria-label="Table columns">' +
|
|
2639
|
+
U_COLUMNS.map(function (c) { return '<label class="u-colopt" title="' + esc(c[2]) + '"><input type="checkbox" data-ucol="' + c[0] + '"' + (cols.indexOf(c[0]) >= 0 ? ' checked' : '') + '>' + c[1] + '</label>'; }).join('') + '</div></details>';
|
|
2640
|
+
return '<section class="u-tablesec"><div class="u-thead"><h3 class="sx-uhead u-h" id="u-table-title">' + esc(M.inspect ? 'Tool and model, ' + uWholeTitle(M) : 'Tool and model') + '</h3>' + menu + '</div>' +
|
|
2641
|
+
'<div class="u-tablewrap"><table class="u-table"><thead>' + head + '</thead><tbody>' + body + '</tbody></table></div></section>';
|
|
2642
|
+
}
|
|
2643
|
+
function uBreakdownHtml(M) {
|
|
2644
|
+
var days = uGroup(M.selected, function (b) { return b.day; }).sort(function (a, b) { return a.key < b.key ? 1 : -1; });
|
|
2645
|
+
var whole = uMeasured(uTotals(M.selected), M.excl), year = M.today.slice(0, 4);
|
|
2646
|
+
return '<details class="u-breakdown"' + (SX.uBreakOpen ? ' open' : '') + '><summary>Daily breakdown, ' + uNum(days.length) + ' recorded ' + (days.length === 1 ? 'day' : 'days') + U_CHEV + '</summary>' +
|
|
2647
|
+
'<div class="u-brow u-bhead"><span>Date</span><span>Tokens</span><span>Share</span><span>Records</span><span>API value</span></div>' +
|
|
2648
|
+
days.map(function (g) {
|
|
2649
|
+
var v = uMeasured(g.t, M.excl);
|
|
2650
|
+
return '<button type="button" class="u-brow' + (M.scale === 'daily' && M.inspect === g.key ? ' on' : '') + '" data-u="day" data-day="' + g.key + '" title="' + g.key + '"><span>' + esc(U_WEEKDAYS[uWeekday(g.key)].slice(0, 3) + ', ' + uShortDay(g.key) + (g.key.slice(0, 4) === year ? '' : ', ' + g.key.slice(0, 4))) + '</span>' +
|
|
2651
|
+
'<span>' + uShort(v) + '</span><span>' + esc(uShare(v, whole)) + '</span><span>' + uNum(g.t.events) + '</span><span>' + esc(uCostLabel2(g.t)) + '</span></button>';
|
|
2652
|
+
}).join('') + '</details>';
|
|
2653
|
+
}
|
|
2654
|
+
function sessionUsageWideHtml(d) {
|
|
2655
|
+
var su = d.sessionUsage;
|
|
2656
|
+
var how = '<details class="sx-how"><summary>How this is counted</summary>' + uHowLines(su).map(function (l) { return '<p>' + esc(l) + '</p>'; }).join('') + '</details>';
|
|
2657
|
+
SX.uM = null;
|
|
2658
|
+
if (!su) return '<div class="sx-uempty">' + esc('Usage has not been read yet. Refresh reads Claude Code and Codex logs on this Mac.') + '</div>' + how;
|
|
2659
|
+
var M = uWideModel(d);
|
|
2660
|
+
var pending = [];
|
|
2661
|
+
if (Number(su.pendingFiles) > 0) pending.push('Reading older history: ' + su.pendingFiles + ' files remaining. Totals will grow until it finishes.');
|
|
2662
|
+
if (su.cursorFeed && su.cursorFeed.pending === true && su.cursorFeed.message) pending.push(su.cursorFeed.message + ' Totals will grow until it finishes.');
|
|
2663
|
+
var pendingHtml = pending.map(function (n) { return '<p class="cx-note sx-unote">' + esc(n) + '</p>'; }).join('');
|
|
2664
|
+
if (!M.buckets.length) return '<div class="sx-uempty">' + esc('No usage recorded yet. Claude Code and Codex sessions on this Mac appear here once their logs are read. A missing tool is not zero usage.') + pendingHtml + '</div>' + how;
|
|
2665
|
+
var updated = toDate(su.updatedAt);
|
|
2666
|
+
var html = '<div class="sx-ufilters u-filters">' +
|
|
2667
|
+
'<button type="button" class="field sx-select u-rangebtn" data-u="range" aria-haspopup="dialog" aria-label="Period: ' + esc(uRangeTitle(M.period, SX.uFrom, SX.uTo)) + '">' + esc(uRangeTitle(M.period, SX.uFrom, SX.uTo)) + '</button>' +
|
|
2668
|
+
uSelect('usage-tool', 'Tool', SX.uTool, [['All tools', 'All tools']].concat(M.tools.map(function (p) { return [p, uToolName(p)]; }))) +
|
|
2669
|
+
uSelect('usage-model', 'Model', SX.uModel, [['All models', 'All models']].concat(M.models.map(function (m) { return [m, uModelName(m)]; }))) +
|
|
2670
|
+
(M.hasFilter ? '<button type="button" class="sx-link" data-sx="usage-clear">Clear filters</button>' : '') +
|
|
2671
|
+
(updated ? '<span class="sx-fresh">Updated ' + esc(clock(updated)) + '</span>' : '') + '</div>' + pendingHtml;
|
|
2672
|
+
if (!M.selected.length) return html + '<div class="sx-uempty">No recorded usage for this selection. A missing tool is not zero usage.</div>' + uFooter(su, M.selected) + how;
|
|
2673
|
+
SX.uM = M;
|
|
2674
|
+
html += uFiguresHtml(M) +
|
|
2675
|
+
'<div class="u-split">' + uChartSectionHtml(M) + '<aside class="u-card" id="u-card" aria-label="Summary" aria-live="polite">' + uCardHtml(M) + '</aside></div>';
|
|
2676
|
+
// Under three columns the bars above already say all of this.
|
|
2677
|
+
if (M.weeks.length >= 3) html += '<section class="u-daysec"><h3 class="sx-uhead u-h">' + esc(uMeasureWords()) + ' day by day</h3><div class="u-heat" id="u-heat"></div></section>';
|
|
2678
|
+
return html + uTableHtml(M) + uBreakdownHtml(M) + uFooter(su, M.selected) + how;
|
|
2679
|
+
}
|
|
2680
|
+
|
|
2681
|
+
// ── Drawing: the chart (SessionUsageChart) and the calendar (SessionUsageCalendar), at the width they get ──
|
|
2682
|
+
var U_CHART_H = 300;
|
|
2683
|
+
function uAxisValue(M, v, top) {
|
|
2684
|
+
if (!(v > 0)) return '0';
|
|
2685
|
+
if (M.cost) return uCurrency(v, top < 10 ? 2 : 0);
|
|
2686
|
+
if (v >= 1000) return Math.round(v / 100) / 10 + 'B';
|
|
2687
|
+
if (v >= 1) return Math.round(v * 10) / 10 + 'M';
|
|
2688
|
+
return Math.round(v * 10000) / 10 + 'K';
|
|
2689
|
+
}
|
|
2690
|
+
function uDim(M, key) {
|
|
2691
|
+
var partial = M.partial[key] ? 0.42 : 1;
|
|
2692
|
+
if (!M.inspect) return partial;
|
|
2693
|
+
return key === M.inspect ? 1 : 0.32 * partial;
|
|
2694
|
+
}
|
|
2695
|
+
function uDrawChart() {
|
|
2696
|
+
var M = SX.uM, host = $('u-chart');
|
|
2697
|
+
if (!M || !host) return;
|
|
2698
|
+
var old = host.querySelector('svg, .u-chart-empty');
|
|
2699
|
+
if (old) old.remove();
|
|
2700
|
+
uBindChart(host);
|
|
2701
|
+
if (M.noChart) { host.insertAdjacentHTML('afterbegin', '<p class="u-chart-empty">' + esc(M.noChart) + '</p>'); SX.uGeom = null; return; }
|
|
2702
|
+
// The chart takes the card's height, so the two columns end together (SessionUsageSplitLayout).
|
|
2703
|
+
var W = Math.max(240, host.clientWidth), H = Math.max(U_CHART_H, Math.floor(host.clientHeight)), padL = 48, padR = 6, padT = 20, padB = 24, pw = W - padL - padR, ph = H - padT - padB;
|
|
2704
|
+
var slots = M.slots, n = slots.length, slot = pw / n, byKey = {};
|
|
2705
|
+
M.points.forEach(function (p) { (byKey[p.key] = byKey[p.key] || []).push(p); });
|
|
2706
|
+
var peak = 0;
|
|
2707
|
+
if (SX.uLines) M.points.forEach(function (p) { if (M.figure(p)) peak = Math.max(peak, M.plotted(p)); });
|
|
2708
|
+
else Object.keys(byKey).forEach(function (k) { peak = Math.max(peak, byKey[k].reduce(function (s, p) { return s + M.plotted(p); }, 0)); });
|
|
2709
|
+
var top = uNiceTop(peak), y = function (v) { return padT + ph - v / top * ph; }, f1 = function (v) { return Math.round(v * 10) / 10; };
|
|
2710
|
+
var s = ['<svg class="u-svg" width="' + W + '" height="' + H + '" viewBox="0 0 ' + W + ' ' + H + '" aria-hidden="true">'];
|
|
2711
|
+
[0, 0.25, 0.5, 0.75, 1].forEach(function (fr) {
|
|
2712
|
+
var yy = f1(y(top * fr));
|
|
2713
|
+
s.push('<line class="u-grid" x1="' + padL + '" x2="' + (W - padR) + '" y1="' + yy + '" y2="' + yy + '"/><text class="u-ylab" x="' + (padL - 7) + '" y="' + f1(yy + 3.5) + '" text-anchor="end">' + esc(uAxisValue(M, top * fr, top)) + '</text>');
|
|
2714
|
+
});
|
|
2715
|
+
var order = {}; M.order.forEach(function (name, i) { order[name] = i; });
|
|
2716
|
+
if (!SX.uLines) {
|
|
2717
|
+
var bw = Math.max(1, Math.min(slot * 0.72, 44));
|
|
2718
|
+
slots.forEach(function (k, i) {
|
|
2719
|
+
var pts = (byKey[k] || []).filter(M.figure).sort(function (a, b) { return order[a.series] - order[b.series]; });
|
|
2720
|
+
if (!pts.length) return;
|
|
2721
|
+
var x = f1(padL + i * slot + (slot - bw) / 2), base = 0;
|
|
2722
|
+
s.push('<g class="u-col" data-key="' + k + '" opacity="' + uDim(M, k) + '">');
|
|
2723
|
+
pts.forEach(function (p) {
|
|
2724
|
+
var v = M.plotted(p);
|
|
2725
|
+
if (!(v > 0)) return;
|
|
2726
|
+
var y1 = y(base + v), y0 = y(base);
|
|
2727
|
+
s.push('<rect x="' + x + '" y="' + f1(y1) + '" width="' + f1(bw) + '" height="' + f1(Math.max(0.5, y0 - y1)) + '" style="fill:' + M.color(p.series) + '"/>');
|
|
2728
|
+
base += v;
|
|
2729
|
+
});
|
|
2730
|
+
s.push('</g>');
|
|
2731
|
+
});
|
|
2732
|
+
} else {
|
|
2733
|
+
M.order.forEach(function (name) {
|
|
2734
|
+
var d = '', dots = '', pen = false;
|
|
2735
|
+
slots.forEach(function (k, i) {
|
|
2736
|
+
var p = (byKey[k] || []).filter(function (q) { return q.series === name && M.figure(q); })[0];
|
|
2737
|
+
if (!p) { pen = false; return; }
|
|
2738
|
+
var x = f1(padL + (i + 0.5) * slot), yy = f1(y(M.plotted(p)));
|
|
2739
|
+
d += (pen ? 'L' : 'M') + x + ' ' + yy; pen = true;
|
|
2740
|
+
if (n <= 62) dots += '<circle cx="' + x + '" cy="' + yy + '" r="2.4" style="fill:' + M.color(name) + '"/>';
|
|
2741
|
+
});
|
|
2742
|
+
s.push('<path class="u-line" d="' + d + '" style="stroke:' + M.color(name) + '"/>' + dots);
|
|
2743
|
+
});
|
|
2744
|
+
}
|
|
2745
|
+
if (M.average !== null) {
|
|
2746
|
+
var ay = f1(y(M.average));
|
|
2747
|
+
s.push('<line class="u-avg" x1="' + padL + '" x2="' + (W - padR) + '" y1="' + ay + '" y2="' + ay + '"/><text class="u-avglab" x="' + (W - padR - 2) + '" y="' + f1(ay - 5) + '" text-anchor="end">Average ' + esc(uAxisValue(M, M.average, top)) + '</text>');
|
|
2748
|
+
}
|
|
2749
|
+
uAxisKeys(slots).forEach(function (k) {
|
|
2750
|
+
var i = slots.indexOf(k), mid = M.scale === 'daily' && i > 0;
|
|
2751
|
+
s.push('<text class="u-xlab" x="' + f1(mid ? padL + (i + 0.5) * slot : padL + i * slot) + '" y="' + (H - 6) + '" text-anchor="' + (mid ? 'middle' : 'start') + '">' + esc(uAxisLabel(k)) + '</text>');
|
|
2752
|
+
});
|
|
2753
|
+
s.push('<line class="u-hover hidden" id="u-hover" x1="0" x2="0" y1="' + padT + '" y2="' + (padT + ph) + '"/>');
|
|
2754
|
+
s.push('<rect class="u-hit" x="' + padL + '" y="0" width="' + f1(pw) + '" height="' + (padT + ph) + '"/></svg>');
|
|
2755
|
+
host.insertAdjacentHTML('afterbegin', s.join(''));
|
|
2756
|
+
SX.uGeom = { padL: padL, slot: slot, n: n, W: W };
|
|
2757
|
+
if (SX.uPinned && M.keySet[SX.uPinned]) uTipShow(SX.uPinned, padL + (slots.indexOf(SX.uPinned) + 0.5) * slot);
|
|
2758
|
+
else uTipHide();
|
|
2759
|
+
}
|
|
2760
|
+
function uKeyAt(host, clientX) {
|
|
2761
|
+
var g = SX.uGeom, M = SX.uM;
|
|
2762
|
+
if (!g || !M) return null;
|
|
2763
|
+
var x = clientX - host.getBoundingClientRect().left, i = Math.floor((x - g.padL) / g.slot);
|
|
2764
|
+
if (i < 0 || i >= g.n) return null;
|
|
2765
|
+
var k = M.slots[i];
|
|
2766
|
+
return M.keySet[k] ? { key: k, x: x } : null;
|
|
2767
|
+
}
|
|
2768
|
+
// The hover card: the bar's period and figure, then each series in it, largest first.
|
|
2769
|
+
function uTipShow(key, px) {
|
|
2770
|
+
var M = SX.uM, tip = $('u-tip'), host = $('u-chart'), g = SX.uGeom;
|
|
2771
|
+
if (!M || !tip || !host || !g) return;
|
|
2772
|
+
var rows = M.selected.filter(function (b) { return M.keyOf[b.day] === key; }), t = uTotals(rows);
|
|
2773
|
+
var line = uPeriodTitle(key, M.scale, M.bounds) + (M.partial[key] ? ' (part of a ' + (M.scale === 'weekly' ? 'week' : 'month') + ')' : '') + ': ' +
|
|
2774
|
+
(M.cost ? uCostLabel2(t) : uTok2(uMeasured(t, M.excl)) + ' tokens');
|
|
2775
|
+
var members = M.points.filter(function (p) { return p.key === key; });
|
|
2776
|
+
var list = members.length > 1 ? M.order.map(function (name) {
|
|
2777
|
+
var p = members.filter(function (q) { return q.series === name; })[0];
|
|
2778
|
+
if (!p) return '';
|
|
2779
|
+
return '<div class="u-tiprow">' + uSwatch(M.color(name)) + '<span>' + esc(name) + '</span><b>' + esc(M.cost ? (p.priced > 0 ? uDollars(p.usd) : 'No price') : uShort(p.tokens)) + '</b></div>';
|
|
2780
|
+
}).join('') : '';
|
|
2781
|
+
tip.innerHTML = '<p>' + esc(line) + '</p>' + list;
|
|
2782
|
+
var cw = Math.min(320, host.clientWidth - 16), right = px + 18, left = px - 18 - cw, x;
|
|
2783
|
+
if (right + cw <= host.clientWidth - 8 && (left < 8 || px <= host.clientWidth / 2)) x = right;
|
|
2784
|
+
else if (left >= 8) x = left;
|
|
2785
|
+
else x = px > host.clientWidth / 2 ? 8 : Math.max(8, host.clientWidth - 8 - cw);
|
|
2786
|
+
tip.style.width = cw + 'px';
|
|
2787
|
+
tip.style.transform = 'translateX(' + Math.round(x) + 'px)';
|
|
2788
|
+
tip.classList.remove('hidden');
|
|
2789
|
+
var hover = $('u-hover'), cx = Math.round((g.padL + (M.slots.indexOf(key) + 0.5) * g.slot) * 10) / 10;
|
|
2790
|
+
if (hover) { hover.setAttribute('x1', cx); hover.setAttribute('x2', cx); hover.classList.remove('hidden'); }
|
|
2791
|
+
}
|
|
2792
|
+
function uTipHide() {
|
|
2793
|
+
var tip = $('u-tip'), hover = $('u-hover');
|
|
2794
|
+
if (tip) tip.classList.add('hidden');
|
|
2795
|
+
if (hover) hover.classList.add('hidden');
|
|
2796
|
+
}
|
|
2797
|
+
// Mouse: hover shows the card, a click chooses the bar. Touch has no hover, so a finger scrubs: the card follows it
|
|
2798
|
+
// across the bars, and lifting it chooses that bar. A vertical drag still scrolls the page (touch-action: pan-y).
|
|
2799
|
+
function uBindChart(host) {
|
|
2800
|
+
if (host.__uBound) return;
|
|
2801
|
+
host.__uBound = true;
|
|
2802
|
+
host.addEventListener('pointermove', function (e) {
|
|
2803
|
+
if (e.pointerType !== 'mouse' && !SX.uScrub) return;
|
|
2804
|
+
var h = uKeyAt(host, e.clientX);
|
|
2805
|
+
if (h) uTipShow(h.key, h.x); else if (!SX.uPinned) uTipHide();
|
|
2806
|
+
});
|
|
2807
|
+
host.addEventListener('pointerleave', function (e) { if (e.pointerType === 'mouse' && !SX.uPinned) uTipHide(); });
|
|
2808
|
+
host.addEventListener('pointerdown', function (e) {
|
|
2809
|
+
if (e.pointerType === 'mouse') return;
|
|
2810
|
+
SX.uScrub = true;
|
|
2811
|
+
var h = uKeyAt(host, e.clientX);
|
|
2812
|
+
if (h) uTipShow(h.key, h.x);
|
|
2813
|
+
});
|
|
2814
|
+
host.addEventListener('pointerup', function (e) {
|
|
2815
|
+
if (e.pointerType === 'mouse' && e.button !== 0) return;
|
|
2816
|
+
var h = uKeyAt(host, e.clientX);
|
|
2817
|
+
SX.uScrub = false;
|
|
2818
|
+
if (!h) { if (e.pointerType !== 'mouse') { SX.uPinned = null; uTipHide(); } return; }
|
|
2819
|
+
SX.uPinned = e.pointerType === 'mouse' ? null : h.key;
|
|
2820
|
+
uInspectKey(h.key, true);
|
|
2821
|
+
});
|
|
2822
|
+
host.addEventListener('pointercancel', function () { SX.uScrub = false; if (!SX.uPinned) uTipHide(); });
|
|
2823
|
+
host.addEventListener('keydown', function (e) {
|
|
2824
|
+
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { e.preventDefault(); uMoveInspect(e.key === 'ArrowLeft' ? -1 : 1); }
|
|
2825
|
+
else if (e.key === 'Escape' && SX.uInspect) { e.preventDefault(); uShowWhole(); }
|
|
2826
|
+
});
|
|
2827
|
+
}
|
|
2828
|
+
function uDrawHeat() {
|
|
2829
|
+
var M = SX.uM, host = $('u-heat');
|
|
2830
|
+
if (!M || !host || !M.weeks || M.weeks.length < 3) return;
|
|
2831
|
+
var fit = uCalendarFit(M.weeks, Math.max(0, host.clientWidth - 26 - 150 - 16)), long = M.weeks.length > 26;
|
|
2832
|
+
var counted = function (sum) { return sum && (!M.cost || sum.t.priced > 0); };
|
|
2833
|
+
var top = 0;
|
|
2834
|
+
fit.weeks.forEach(function (w) { w.days.forEach(function (x) { if (counted(x.sum)) top = Math.max(top, uDayValue(M, x.sum.t)); }); });
|
|
2835
|
+
var sums = [0, 0, 0, 0, 0, 0, 0], cnt = [0, 0, 0, 0, 0, 0, 0];
|
|
2836
|
+
uGroup(M.selected, function (b) { return b.day; }).forEach(function (g) { if (!counted(g)) return; var i = uWeekday(g.key); sums[i] += uDayValue(M, g.t); cnt[i] += 1; });
|
|
2837
|
+
var avg = sums.map(function (v, i) { return cnt[i] ? v / cnt[i] : 0; }), topAvg = Math.max.apply(null, avg);
|
|
2838
|
+
var cols = 'grid-template-columns:repeat(' + fit.weeks.length + ',minmax(0,1fr));column-gap:' + fit.spacing + 'px';
|
|
2839
|
+
var inspected = M.scale === 'daily' ? M.inspect : null, empty = M.hasFilter ? 'no records for this selection' : 'no logs';
|
|
2840
|
+
var h = '<div class="u-cal' + (fit.numbers ? ' numbers' : '') + '"' + (long ? ' role="img" aria-label="Calendar of ' + M.weeks.length + ' weeks. Daily breakdown lists each day."' : '') + '>' +
|
|
2841
|
+
'<div class="u-cal-row u-cal-months"><span class="u-cal-lab"></span><div class="u-cal-weeks" style="' + cols + '">' +
|
|
2842
|
+
fit.weeks.map(function (w) { return '<span class="u-cal-month">' + (w.label ? esc(w.label) : '') + '</span>'; }).join('') + '</div><span class="u-cal-avg">Daily average</span></div>';
|
|
2843
|
+
for (var wd = 0; wd < 7; wd++) {
|
|
2844
|
+
h += '<div class="u-cal-row"><span class="u-cal-lab">' + U_WEEKDAYS[wd].slice(0, 3) + '</span><div class="u-cal-weeks" style="' + cols + '">';
|
|
2845
|
+
fit.weeks.forEach(function (w) {
|
|
2846
|
+
var x = w.days[wd];
|
|
2847
|
+
if (!x.inRange) { h += '<span class="u-cell out"></span>'; return; }
|
|
2848
|
+
if (counted(x.sum)) {
|
|
2849
|
+
var v = uDayValue(M, x.sum.t), level = top > 0 ? Math.sqrt(v / top) : 0, t = x.sum.t;
|
|
2850
|
+
var detail = uPeriodTitle(x.id, 'daily') + ': ' + uCostLabel2(t) + ', ' + uShort(t.all) + ' tokens, ' + uShort(t.fresh) + ' new, ' + uNum(t.events) + ' records';
|
|
2851
|
+
h += '<button type="button" class="u-cell day' + (level > 0.65 ? ' deep' : '') + (inspected === x.id ? ' on' : '') + '" data-u="day" data-day="' + x.id + '"' + (long ? ' tabindex="-1"' : '') +
|
|
2852
|
+
' style="background:color-mix(in srgb, var(--u-gold) ' + Math.round((0.12 + 0.82 * level) * 100) + '%, transparent)" title="' + esc(detail) + '" aria-label="' + esc(detail) + '"' + (inspected === x.id ? ' aria-pressed="true"' : '') + '>' +
|
|
2853
|
+
(fit.numbers ? esc(uDayFigure(M, v) + (M.cost && t.priced < t.events ? '+' : '')) : '') + '</button>';
|
|
2854
|
+
} else {
|
|
2855
|
+
var why = uPeriodTitle(x.id, 'daily') + ': ' + (x.sum ? 'no price' : empty);
|
|
2856
|
+
h += '<span class="u-cell ' + (long ? 'faint' : 'none') + '" title="' + esc(why) + '"' + (long ? '' : ' role="img" aria-label="' + esc(why) + '"') + '></span>';
|
|
2857
|
+
}
|
|
2858
|
+
});
|
|
2859
|
+
h += '</div><span class="u-cal-avg"><span class="u-avgbar"><i style="width:' + (topAvg > 0 ? (avg[wd] > 0 ? Math.max(2, Math.round(88 * avg[wd] / topAvg)) : 0) : 0) + 'px"></i></span>' +
|
|
2860
|
+
'<span class="u-avgnum">' + (avg[wd] > 0 ? esc(uDayFigure(M, avg[wd])) : '') + '</span></span></div>';
|
|
2861
|
+
}
|
|
2862
|
+
if (fit.weeks.length < M.weeks.length) h += '<p class="u-cnote">Showing the most recent ' + fit.weeks.length + ' of ' + M.weeks.length + ' weeks. The daily averages cover all of them, and Daily breakdown lists every day.</p>';
|
|
2863
|
+
host.innerHTML = h + '</div>';
|
|
2864
|
+
}
|
|
2865
|
+
function uDrawWide() { uDrawChart(); uDrawHeat(); }
|
|
2866
|
+
|
|
2867
|
+
// ── Wide interactions ──
|
|
2868
|
+
// A chosen bar repaints the card, the bars and the calendar's outline, never the controls around them.
|
|
2869
|
+
function uRepaintInspect() {
|
|
2870
|
+
var M = SX.uM;
|
|
2871
|
+
if (!M) return;
|
|
2872
|
+
M.inspect = uInspected(M);
|
|
2873
|
+
var card = $('u-card'), title = $('u-table-title');
|
|
2874
|
+
if (card) card.innerHTML = uCardHtml(M);
|
|
2875
|
+
if (title) title.textContent = M.inspect ? 'Tool and model, ' + uWholeTitle(M) : 'Tool and model';
|
|
2876
|
+
document.querySelectorAll('#sessions-usage .u-brow.on').forEach(function (b) { b.classList.remove('on'); });
|
|
2877
|
+
if (M.inspect && M.scale === 'daily') { var row = document.querySelector('#sessions-usage .u-brow[data-day="' + M.inspect + '"]'); if (row) row.classList.add('on'); }
|
|
2878
|
+
uDrawWide();
|
|
2879
|
+
}
|
|
2880
|
+
function uInspectKey(key, toggle) {
|
|
2881
|
+
var M = SX.uM;
|
|
2882
|
+
if (!M) return;
|
|
2883
|
+
if (toggle && M.inspect === key) { SX.uPinned = null; return uShowWhole(); }
|
|
2884
|
+
SX.uInspect = { key: key, scale: M.scale };
|
|
2885
|
+
uRepaintInspect();
|
|
2886
|
+
}
|
|
2887
|
+
// moveInspection: with nothing chosen, left starts at the last bar and right at the first.
|
|
2888
|
+
function uMoveInspect(delta) {
|
|
2889
|
+
var M = SX.uM;
|
|
2890
|
+
if (!M || !M.keys || !M.keys.length) return;
|
|
2891
|
+
var i = M.inspect ? M.keys.indexOf(M.inspect) + delta : delta < 0 ? M.keys.length - 1 : 0;
|
|
2892
|
+
if (i < 0 || i >= M.keys.length) return;
|
|
2893
|
+
SX.uPinned = null;
|
|
2894
|
+
SX.uInspect = { key: M.keys[i], scale: M.scale };
|
|
2895
|
+
uRepaintInspect();
|
|
2896
|
+
}
|
|
2897
|
+
// Show the whole period: back from a chosen bar, and from a day jump to the scale it left.
|
|
2898
|
+
function uShowWhole() {
|
|
2899
|
+
SX.uInspect = null; SX.uPinned = null;
|
|
2900
|
+
if (SX.uDayJump) { SX.uDayJump = false; return paintUsage(); }
|
|
2901
|
+
uRepaintInspect();
|
|
2902
|
+
}
|
|
2903
|
+
function uInspectDay(day) {
|
|
2904
|
+
var M = SX.uM;
|
|
2905
|
+
if (M && M.scale === 'daily' && M.inspect === day) return uShowWhole();
|
|
2906
|
+
if (M && M.scale !== 'daily') SX.uDayJump = true;
|
|
2907
|
+
SX.uInspect = { key: day, scale: 'daily' }; SX.uPinned = null;
|
|
2908
|
+
paintUsage();
|
|
2909
|
+
}
|
|
2910
|
+
// A new period starts on the whole of it. All history opens on months and gives back a picked scale on the way out.
|
|
2911
|
+
function uPeriodChanged() {
|
|
2912
|
+
SX.uInspect = null; SX.uPinned = null; SX.uDayJump = false;
|
|
2913
|
+
if (SX.uScale) {
|
|
2914
|
+
if (SX.uPeriod === 'All history') { if (SX.uScale !== 'monthly') { SX.uScaleBeforeHistory = SX.uScale; SX.uScale = 'monthly'; } }
|
|
2915
|
+
else if (SX.uScaleBeforeHistory) { SX.uScale = SX.uScaleBeforeHistory; SX.uScaleBeforeHistory = ''; }
|
|
2916
|
+
}
|
|
2917
|
+
sxSave();
|
|
2918
|
+
}
|
|
2919
|
+
function uSliceChanged() { SX.uInspect = null; SX.uPinned = null; SX.uDayJump = false; }
|
|
2920
|
+
function uStepRange(delta) {
|
|
2921
|
+
var M = SX.uM;
|
|
2922
|
+
if (!M) return;
|
|
2923
|
+
if (M.inspect) return uMoveInspect(delta);
|
|
2924
|
+
var next = uStep(M.period, M.today, SX.uFrom, SX.uTo, delta, M.firstDay);
|
|
2925
|
+
if (!next) return;
|
|
2926
|
+
SX.uPeriod = next.period;
|
|
2927
|
+
if (next.period === U_CUSTOM) { SX.uFrom = next.from; SX.uTo = next.to; }
|
|
2928
|
+
uPeriodChanged();
|
|
2929
|
+
paintUsage();
|
|
2930
|
+
var again = document.querySelector('#u-card [data-u="step"][data-d="' + delta + '"]:not([disabled])') || document.querySelector('#u-card [data-u="step"]:not([disabled])');
|
|
2931
|
+
if (again) again.focus();
|
|
2932
|
+
}
|
|
2933
|
+
// A refresh, a breakpoint change or a resize waits while a finger is down, a menu is open or the dates are being picked.
|
|
2934
|
+
function uHeld() {
|
|
2935
|
+
var box = $('sessions-usage'), a = document.activeElement;
|
|
2936
|
+
return !!(SX.uPointer || SX.uSheet || (box && box.querySelector('.u-cols[open]')) || (box && a && box.contains(a) && a.tagName === 'SELECT'));
|
|
2937
|
+
}
|
|
2938
|
+
function uFlush() {
|
|
2939
|
+
if (uHeld()) return;
|
|
2940
|
+
if (SX.uDeferred) paintUsage();
|
|
2941
|
+
else if (SX.uRedraw) uDrawWide();
|
|
2942
|
+
SX.uRedraw = false;
|
|
2943
|
+
}
|
|
2944
|
+
function uUsageAction(t) {
|
|
2945
|
+
var act = t.getAttribute('data-u');
|
|
2946
|
+
if (act === 'range') return uOpenRange(t);
|
|
2947
|
+
if (act === 'step') return uStepRange(Number(t.getAttribute('data-d')));
|
|
2948
|
+
if (act === 'whole') return uShowWhole();
|
|
2949
|
+
if (act === 'peak' || act === 'day') return uInspectDay(t.getAttribute('data-day'));
|
|
2950
|
+
if (act === 'lines') { SX.uLines = t.getAttribute('data-on') === '1'; sxSave(); return paintUsage(); }
|
|
2951
|
+
if (act === 'sort') {
|
|
2952
|
+
var sort = t.getAttribute('data-sort');
|
|
2953
|
+
if (SX.uSort === sort) SX.uSortAsc = !SX.uSortAsc; else { SX.uSort = sort; SX.uSortAsc = sort === 'model'; }
|
|
2954
|
+
return paintUsage();
|
|
2955
|
+
}
|
|
2956
|
+
if (act === 'tool') { var p = t.getAttribute('data-p'); SX.uTool = SX.uTool === p ? 'All tools' : p; SX.uModel = 'All models'; SX.uToolBefore = null; uSliceChanged(); return paintUsage(); }
|
|
2957
|
+
if (act === 'model') {
|
|
2958
|
+
var tp = t.getAttribute('data-p'), m = t.getAttribute('data-m');
|
|
2959
|
+
if (SX.uModel === m && SX.uTool === tp) { SX.uModel = 'All models'; SX.uTool = SX.uToolBefore || SX.uTool; SX.uToolBefore = null; }
|
|
2960
|
+
else { if (SX.uToolBefore === null || SX.uToolBefore === undefined) SX.uToolBefore = SX.uTool; SX.uTool = tp; SX.uModel = m; }
|
|
2961
|
+
uSliceChanged();
|
|
2962
|
+
return paintUsage();
|
|
2963
|
+
}
|
|
2964
|
+
if (act === 'more') { SX.uOpen[t.getAttribute('data-p')] = true; return paintUsage(); }
|
|
2965
|
+
if (act === 'fewer') { delete SX.uOpen[t.getAttribute('data-p')]; return paintUsage(); }
|
|
2966
|
+
}
|
|
2967
|
+
function uUsageChange(e) {
|
|
2968
|
+
var id = e.target.id;
|
|
2969
|
+
if (id === 'u-measure') { SX.uMeasure = e.target.value; sxSave(); return paintUsage(); }
|
|
2970
|
+
if (id === 'u-series') { SX.uSeries = e.target.value; sxSave(); return paintUsage(); }
|
|
2971
|
+
if (id === 'u-scale') {
|
|
2972
|
+
var M = SX.uM, scale = e.target.value;
|
|
2973
|
+
// A day carries into the week or month that holds it; anything else would mislabel the period.
|
|
2974
|
+
if (SX.uInspect && SX.uInspect.scale === 'daily' && scale !== 'daily') SX.uInspect = { key: uPeriodKey(SX.uInspect.key, scale), scale: scale };
|
|
2975
|
+
else if (!M || !M.inspect) SX.uInspect = null;
|
|
2976
|
+
SX.uScale = scale; SX.uDayJump = false; SX.uScaleBeforeHistory = '';
|
|
2977
|
+
sxSave();
|
|
2978
|
+
return paintUsage();
|
|
2979
|
+
}
|
|
2980
|
+
var col = e.target.getAttribute && e.target.getAttribute('data-ucol');
|
|
2981
|
+
if (col) {
|
|
2982
|
+
var cols = uCols().slice(), i = cols.indexOf(col);
|
|
2983
|
+
if (i >= 0) cols.splice(i, 1); else cols.push(col);
|
|
2984
|
+
SX.uCols = U_COLUMNS.map(function (c) { return c[0]; }).filter(function (c) { return cols.indexOf(c) >= 0; });
|
|
2985
|
+
try { localStorage.setItem(U_COLS_KEY, JSON.stringify(SX.uCols)); } catch (_e) { /* storage off: the choice lasts this visit */ }
|
|
2986
|
+
// The menu stays open while columns are chosen, so the next box is one tap away.
|
|
2987
|
+
SX.uColsOpen = true;
|
|
2988
|
+
paintUsage();
|
|
2989
|
+
var cb = document.querySelector('#sessions-usage [data-ucol="' + col + '"]');
|
|
2990
|
+
if (cb) cb.focus();
|
|
2991
|
+
}
|
|
2992
|
+
}
|
|
2993
|
+
|
|
2994
|
+
// ── The date range sheet: presets, a month, or Custom From and To. A popover beside its button when wide, a bottom
|
|
2995
|
+
// sheet on a phone. Nothing applies until Apply; Cancel, Escape and a tap outside leave the range as it was. ──
|
|
2996
|
+
function uRangeContext() {
|
|
2997
|
+
var su = SX.usage && SX.usage.sessionUsage, tz = (su && su.timeZone) || '', today = uDayKey(tz), first = '', months = [];
|
|
2998
|
+
((su && su.buckets) || []).forEach(function (b) {
|
|
2999
|
+
if (!b || typeof b.day !== 'string') return;
|
|
3000
|
+
if (!first || b.day < first) first = b.day;
|
|
3001
|
+
var m = b.day.slice(0, 7); if (months.indexOf(m) < 0) months.push(m);
|
|
3002
|
+
});
|
|
3003
|
+
return { today: today, first: first, months: months.sort().reverse() };
|
|
3004
|
+
}
|
|
3005
|
+
function uSheetDates(period, ctx) {
|
|
3006
|
+
if (period === 'All history') return ctx.first ? [ctx.first.slice(0, 10), ctx.today] : [uAddDays(ctx.today, -29), ctx.today];
|
|
3007
|
+
var r = uRange(period, ctx.today, SX.uFrom, SX.uTo);
|
|
3008
|
+
if (!r) return [uAddDays(ctx.today, -29), ctx.today];
|
|
3009
|
+
return [r[0], r[1] > ctx.today ? ctx.today : r[1]];
|
|
3010
|
+
}
|
|
3011
|
+
function uOpenRange(anchor, custom) {
|
|
3012
|
+
var ctx = uRangeContext(), period = custom ? U_CUSTOM : SX.uPeriod;
|
|
3013
|
+
var dates = SX.uPeriod === U_CUSTOM && !uCustomError(SX.uFrom, SX.uTo) ? [SX.uFrom, SX.uTo] : uSheetDates(SX.uPeriod, ctx);
|
|
3014
|
+
var wide = uWide();
|
|
3015
|
+
SX.uSheet = { period: period, from: dates[0], to: dates[1], anchor: anchor, wide: wide, ctx: ctx };
|
|
3016
|
+
var back = $('u-range-back');
|
|
3017
|
+
if (!back) {
|
|
3018
|
+
back = document.createElement('div');
|
|
3019
|
+
back.id = 'u-range-back';
|
|
3020
|
+
back.className = 'u-range-back hidden';
|
|
3021
|
+
document.body.appendChild(back);
|
|
3022
|
+
back.addEventListener('click', function (e) {
|
|
3023
|
+
if (e.target === back) return uCloseRange(false);
|
|
3024
|
+
var t = e.target.closest('[data-us]');
|
|
3025
|
+
if (!t) return;
|
|
3026
|
+
var act = t.getAttribute('data-us');
|
|
3027
|
+
if (act === 'cancel') return uCloseRange(false);
|
|
3028
|
+
if (act === 'apply') return uSheetApply();
|
|
3029
|
+
if (act === 'pick') return uSheetPick(t.getAttribute('data-period'));
|
|
3030
|
+
});
|
|
3031
|
+
back.addEventListener('input', uSheetInput);
|
|
3032
|
+
back.addEventListener('change', uSheetInput);
|
|
3033
|
+
back.addEventListener('keydown', function (e) {
|
|
3034
|
+
if (e.key === 'Escape') { e.preventDefault(); uCloseRange(false); }
|
|
3035
|
+
if (e.key === 'Enter' && e.target.tagName === 'INPUT') { e.preventDefault(); uSheetApply(); }
|
|
3036
|
+
});
|
|
3037
|
+
}
|
|
3038
|
+
var S = SX.uSheet, presets = U_PRESETS.map(function (p) {
|
|
3039
|
+
var d = uSheetDates(p, ctx), words = p === 'All history' ? (ctx.first ? 'Since ' + uDateRangeTitle([ctx.first.slice(0, 10), ctx.first.slice(0, 10)]) : '') : uDateRangeTitle(d);
|
|
3040
|
+
return '<button type="button" class="u-preset" role="radio" aria-checked="' + (S.period === p) + '" data-us="pick" data-period="' + esc(p) + '"><span>' + esc(p) + '</span><span class="u-preset-dates">' + esc(words) + '</span></button>';
|
|
3041
|
+
}).join('');
|
|
3042
|
+
back.className = 'u-range-back' + (wide ? ' pop' : ' bottom');
|
|
3043
|
+
back.innerHTML = '<div class="u-range" role="dialog" aria-modal="true" aria-labelledby="u-range-title">' +
|
|
3044
|
+
'<h2 class="u-range-title" id="u-range-title">Dates</h2>' +
|
|
3045
|
+
'<div class="u-presets" role="radiogroup" aria-label="Ranges">' + presets + '</div>' +
|
|
3046
|
+
'<label class="u-range-field"><span>A month</span><select id="u-range-month" class="field"><option value="">Choose a month</option>' +
|
|
3047
|
+
ctx.months.map(function (m) { return '<option value="' + m + '"' + (S.period === m ? ' selected' : '') + '>' + esc(uRangeTitle(m)) + '</option>'; }).join('') + '</select></label>' +
|
|
3048
|
+
'<div class="u-custom' + (S.period === U_CUSTOM ? ' on' : '') + '" id="u-custom"><p class="u-custom-title">Custom dates</p><div class="u-dates">' +
|
|
3049
|
+
'<label class="u-range-field"><span>From</span><input type="date" class="field" id="u-from" value="' + S.from + '" max="' + ctx.today + '"></label>' +
|
|
3050
|
+
'<label class="u-range-field"><span>To</span><input type="date" class="field" id="u-to" value="' + S.to + '" max="' + ctx.today + '"></label></div>' +
|
|
3051
|
+
'<p class="u-range-err" id="u-range-err" role="alert"></p></div>' +
|
|
3052
|
+
'<p class="u-range-note">' + esc((ctx.first ? 'History on this Mac starts ' + uDateRangeTitle([ctx.first.slice(0, 10), ctx.first.slice(0, 10)]) + '. ' : '') + 'Days without logs are missing, not zero.') + '</p>' +
|
|
3053
|
+
'<div class="u-range-actions"><button type="button" class="cx-btn quiet" data-us="cancel">Cancel</button><button type="button" class="cx-btn primary" data-us="apply" id="u-range-apply">Apply</button></div></div>';
|
|
3054
|
+
uSheetPlace();
|
|
3055
|
+
uSheetCheck();
|
|
3056
|
+
var focus = S.period === U_CUSTOM ? $('u-from') : back.querySelector('.u-preset[aria-checked="true"]') || $('u-range-month');
|
|
3057
|
+
if (focus) focus.focus();
|
|
3058
|
+
}
|
|
3059
|
+
function uSheetPlace() {
|
|
3060
|
+
var S = SX.uSheet, panel = document.querySelector('#u-range-back .u-range');
|
|
3061
|
+
if (!S || !panel) return;
|
|
3062
|
+
panel.style.top = panel.style.left = '';
|
|
3063
|
+
if (!S.wide || !S.anchor || !S.anchor.getBoundingClientRect) return;
|
|
3064
|
+
var r = S.anchor.getBoundingClientRect(), w = Math.min(380, innerWidth - 32), left = Math.min(Math.max(16, r.right - w), innerWidth - 16 - w);
|
|
3065
|
+
var top = r.bottom + 8, room = innerHeight - top - 16;
|
|
3066
|
+
if (room < 420 && r.top > room) top = Math.max(16, r.top - 8 - Math.min(panel.scrollHeight, innerHeight - 32));
|
|
3067
|
+
panel.style.left = Math.round(left) + 'px';
|
|
3068
|
+
panel.style.top = Math.round(top) + 'px';
|
|
3069
|
+
panel.style.maxHeight = Math.round(innerHeight - top - 16) + 'px';
|
|
3070
|
+
}
|
|
3071
|
+
// In place, never rebuilt: the radios, the month and the two dates follow the choice.
|
|
3072
|
+
function uSheetPick(period) {
|
|
3073
|
+
var S = SX.uSheet;
|
|
3074
|
+
if (!S) return;
|
|
3075
|
+
S.period = period;
|
|
3076
|
+
var d = uSheetDates(period, S.ctx);
|
|
3077
|
+
S.from = d[0]; S.to = d[1];
|
|
3078
|
+
$('u-from').value = S.from; $('u-to').value = S.to;
|
|
3079
|
+
$('u-range-month').value = U_MONTH_RE.test(period) ? period : '';
|
|
3080
|
+
uSheetCheck();
|
|
3081
|
+
}
|
|
3082
|
+
function uSheetInput(e) {
|
|
3083
|
+
var S = SX.uSheet;
|
|
3084
|
+
if (!S) return;
|
|
3085
|
+
if (e.target.id === 'u-range-month') { if (e.target.value) uSheetPick(e.target.value); return; }
|
|
3086
|
+
if (e.target.id !== 'u-from' && e.target.id !== 'u-to') return;
|
|
3087
|
+
S.period = U_CUSTOM; S.from = $('u-from').value; S.to = $('u-to').value;
|
|
3088
|
+
$('u-range-month').value = '';
|
|
3089
|
+
uSheetCheck();
|
|
3090
|
+
}
|
|
3091
|
+
function uSheetCheck() {
|
|
3092
|
+
var S = SX.uSheet;
|
|
3093
|
+
if (!S) return;
|
|
3094
|
+
document.querySelectorAll('#u-range-back .u-preset').forEach(function (b) { b.setAttribute('aria-checked', String(b.getAttribute('data-period') === S.period)); });
|
|
3095
|
+
var err = S.period === U_CUSTOM ? uCustomError(S.from, S.to) : '';
|
|
3096
|
+
$('u-custom').classList.toggle('on', S.period === U_CUSTOM);
|
|
3097
|
+
$('u-range-err').textContent = err;
|
|
3098
|
+
$('u-range-apply').disabled = !!err;
|
|
3099
|
+
return err;
|
|
3100
|
+
}
|
|
3101
|
+
function uSheetApply() {
|
|
3102
|
+
var S = SX.uSheet;
|
|
3103
|
+
if (!S || uSheetCheck()) return;
|
|
3104
|
+
SX.uPeriod = S.period;
|
|
3105
|
+
if (S.period === U_CUSTOM) { SX.uFrom = S.from; SX.uTo = S.to; }
|
|
3106
|
+
uCloseRange(true);
|
|
3107
|
+
uPeriodChanged();
|
|
3108
|
+
paintUsage();
|
|
3109
|
+
uRefocus(S);
|
|
3110
|
+
}
|
|
3111
|
+
function uCloseRange(applied) {
|
|
3112
|
+
var S = SX.uSheet, back = $('u-range-back');
|
|
3113
|
+
SX.uSheet = null;
|
|
3114
|
+
if (back) { back.className = 'u-range-back hidden'; back.innerHTML = ''; }
|
|
3115
|
+
if (applied) return;
|
|
3116
|
+
// A narrow Period menu left on Custom dates goes back to the range in force.
|
|
3117
|
+
if (S && !S.wide) paintUsage(); else if (SX.uDeferred) paintUsage();
|
|
3118
|
+
uRefocus(S);
|
|
3119
|
+
}
|
|
3120
|
+
function uRefocus(S) {
|
|
3121
|
+
if (!S || !S.anchor) return;
|
|
3122
|
+
var a = S.anchor;
|
|
3123
|
+
if (!document.body.contains(a)) {
|
|
3124
|
+
var sel = a.id ? '#' + a.id : a.getAttribute('data-sx') ? '[data-sx="' + a.getAttribute('data-sx') + '"]' : a.classList.contains('u-rangelink') ? '#u-card [data-u="range"]' : '.u-rangebtn';
|
|
3125
|
+
a = document.querySelector('#sessions-usage ' + sel) || document.querySelector('#sessions-usage #usage-period');
|
|
3126
|
+
}
|
|
3127
|
+
if (a && a.focus) a.focus();
|
|
3128
|
+
}
|
|
3129
|
+
|
|
3130
|
+
// Wired once, the first time Usage shows: the breakpoint, resizes, and the finger that holds a repaint back.
|
|
3131
|
+
function uWire() {
|
|
3132
|
+
if (SX.uWired) return;
|
|
3133
|
+
var box = $('sessions-usage');
|
|
3134
|
+
if (!box) return;
|
|
3135
|
+
SX.uWired = true;
|
|
3136
|
+
box.addEventListener('click', function (e) { var t = e.target.closest('[data-u]'); if (t && box.contains(t)) uUsageAction(t); });
|
|
3137
|
+
box.addEventListener('change', uUsageChange);
|
|
3138
|
+
box.addEventListener('toggle', function (e) {
|
|
3139
|
+
if (e.target.classList.contains('u-breakdown')) SX.uBreakOpen = e.target.open;
|
|
3140
|
+
if (e.target.classList.contains('u-cols')) { SX.uColsOpen = e.target.open; if (!e.target.open) setTimeout(uFlush, 0); }
|
|
3141
|
+
}, true);
|
|
3142
|
+
box.addEventListener('pointerdown', function () { SX.uPointer = true; }, true);
|
|
3143
|
+
var release = function () { if (!SX.uPointer) return; SX.uPointer = false; if (SX.uDeferred || SX.uRedraw) setTimeout(uFlush, 400); };
|
|
3144
|
+
document.addEventListener('pointerup', release, true);
|
|
3145
|
+
document.addEventListener('pointercancel', release, true);
|
|
3146
|
+
document.addEventListener('pointerdown', function (e) {
|
|
3147
|
+
if (SX.uPinned && !e.target.closest('#u-chart')) { SX.uPinned = null; uTipHide(); }
|
|
3148
|
+
var cols = box.querySelector('.u-cols[open]');
|
|
3149
|
+
if (cols && !cols.contains(e.target)) cols.removeAttribute('open');
|
|
3150
|
+
});
|
|
3151
|
+
box.addEventListener('focusout', function () { setTimeout(function () { if (SX.uDeferred || SX.uRedraw) uFlush(); }, 0); });
|
|
3152
|
+
try {
|
|
3153
|
+
var mq = window.matchMedia(U_WIDE_MQ), onMq = function () { if (SX.view === 'usage' && SX.usage) paintUsage(true); };
|
|
3154
|
+
if (mq.addEventListener) mq.addEventListener('change', onMq); else if (mq.addListener) mq.addListener(onMq);
|
|
3155
|
+
} catch (_e) { /* no media queries: the vertical layout stays */ }
|
|
3156
|
+
var timer = null;
|
|
3157
|
+
window.addEventListener('resize', function () {
|
|
3158
|
+
clearTimeout(timer);
|
|
3159
|
+
timer = setTimeout(function () {
|
|
3160
|
+
if (SX.uSheet) uSheetPlace();
|
|
3161
|
+
if (!SX.uWideShown || SX.view !== 'usage') return;
|
|
3162
|
+
if (uHeld()) SX.uRedraw = true; else uDrawWide();
|
|
3163
|
+
}, 150);
|
|
3164
|
+
});
|
|
3165
|
+
}
|
|
3166
|
+
// The viewer's last range, scale and chart choices, checked before use (the stored words could be from an older page).
|
|
3167
|
+
function uLoadPrefs() {
|
|
3168
|
+
if (SX.uPrefsLoaded) return;
|
|
3169
|
+
SX.uPrefsLoaded = true;
|
|
3170
|
+
var p = readStore(SX_PREFS);
|
|
3171
|
+
if (uKnownPeriod(p.uPeriod) && (p.uPeriod !== U_CUSTOM || !uCustomError(p.uFrom, p.uTo))) { SX.uPeriod = p.uPeriod; SX.uFrom = p.uFrom || ''; SX.uTo = p.uTo || ''; }
|
|
3172
|
+
if (['all', 'new', 'value'].indexOf(p.uMeasure) >= 0) SX.uMeasure = p.uMeasure;
|
|
3173
|
+
if (['tool', 'model', 'type'].indexOf(p.uSeries) >= 0) SX.uSeries = p.uSeries;
|
|
3174
|
+
if (['daily', 'weekly', 'monthly'].indexOf(p.uScale) >= 0) SX.uScale = p.uScale;
|
|
3175
|
+
SX.uLines = p.uLines === true;
|
|
3176
|
+
}
|
|
3177
|
+
|
|
3178
|
+
// ── COS cost view (SessionUsageView.swift CosCostView 1773-2009, CosCostPresentation) ──
|
|
3179
|
+
var COS_CATS = {
|
|
3180
|
+
dictation: ['Dictation', 'dictation', 'dictations'],
|
|
3181
|
+
transcription: ['Transcription cleanup', 'cleanup', 'cleanups'],
|
|
3182
|
+
dictationBeforeSplit: ['Dictation and transcripts, before Oct 20', 'cleanup', 'cleanups'],
|
|
3183
|
+
meetingNotes: ['Meeting notes', 'note or summary', 'notes and summaries'],
|
|
3184
|
+
answers: ['Answers', 'answer', 'answers'],
|
|
3185
|
+
liveAndUpkeep: ['Live help and upkeep', 'check', 'checks'],
|
|
3186
|
+
memoryIndexing: ['Memory indexing', 'indexing call', 'indexing calls'],
|
|
3187
|
+
other: ['Other', 'other call', 'other calls'],
|
|
3188
|
+
work: ['Work', 'background call', 'background calls'],
|
|
3189
|
+
sessionFinding: ['Finding sessions', 'search', 'searches']
|
|
3190
|
+
};
|
|
3191
|
+
var COS_FIELDS = ['calls', 'measuredCalls', 'measuredUSD', 'measuredTokens', 'unpricedMeasuredCalls', 'unpricedLedgerTokens', 'estimatedCalls', 'estimatedUSD', 'estimatedPricedCalls', 'estimatedTokens', 'addsToTotalUSD', 'addsUnlessCursorFeedUSD', 'approximateMatches'];
|
|
3192
|
+
function cosCount(cat, n) { var c = COS_CATS[cat] || COS_CATS.other; return uNum(n) + ' ' + (n === 1 ? c[1] : c[2]); }
|
|
3193
|
+
// A category this page does not know joins Other, so every row keeps one of Control's titles.
|
|
3194
|
+
function cosRows(report) {
|
|
3195
|
+
var by = {};
|
|
3196
|
+
(report.rows || []).forEach(function (row) {
|
|
3197
|
+
var cat = COS_CATS[row.category] ? row.category : 'other';
|
|
3198
|
+
var acc = by[cat] || (by[cat] = { category: cat });
|
|
3199
|
+
COS_FIELDS.forEach(function (k) { acc[k] = (acc[k] || 0) + (Number(row[k]) || 0); });
|
|
3200
|
+
});
|
|
3201
|
+
return Object.keys(by).map(function (k) { return by[k]; });
|
|
3202
|
+
}
|
|
3203
|
+
function cosSum(rows, k) { return rows.reduce(function (s, r) { return s + (Number(r[k]) || 0); }, 0); }
|
|
3204
|
+
function cosUsageState(su) {
|
|
3205
|
+
if (!su) return 'unavailable';
|
|
3206
|
+
if (Number(su.pendingFiles) > 0 || (su.cursorFeed && su.cursorFeed.pending === true)) return 'importing';
|
|
3207
|
+
return 'ready';
|
|
3208
|
+
}
|
|
3209
|
+
function cosCostHtml(d) {
|
|
3210
|
+
var report = d.cosCost, su = d.sessionUsage;
|
|
3211
|
+
if (!report) return '<div class="sx-uempty">Reading what COS used.</div>';
|
|
3212
|
+
var rows = cosRows(report), overhead = rows.filter(function (r) { return r.category !== 'work'; });
|
|
3213
|
+
var workRow = rows.filter(function (r) { return r.category === 'work'; })[0] || null;
|
|
3214
|
+
var work = report.work || {}, sessions = report.sessions || {};
|
|
3215
|
+
var reading = (Number(sessions.stillReading) || 0) > 0 || sessions.busy === true;
|
|
3216
|
+
var us = cosUsageState(su);
|
|
3217
|
+
// The total the person ran: Usage's API value in the window, plus COS spend Usage cannot see.
|
|
3218
|
+
var lo = report.windowStart || '', hi = report.windowEnd || '';
|
|
3219
|
+
var buckets = (su && su.buckets) || [];
|
|
3220
|
+
var inWin = function (b) { return (!lo || b.day >= lo) && (!hi || b.day <= hi); };
|
|
3221
|
+
var cursorHeld = !!(su && su.cursorFeed && su.cursorFeed.lastSuccessAt) || buckets.some(function (b) { return b.provider === 'Cursor' && inWin(b); });
|
|
3222
|
+
var ran = buckets.filter(inWin).reduce(function (s, b) { return s + (Number(b.estimatedUSD) || 0); }, 0) +
|
|
3223
|
+
rows.reduce(function (s, r) { return s + (Number(r.addsToTotalUSD) || 0) + (cursorHeld ? 0 : (Number(r.addsUnlessCursorFeedUSD) || 0)); }, 0) +
|
|
3224
|
+
(Number(work.addsToTotalUSD) || 0);
|
|
3225
|
+
var ranPartial = us !== 'unavailable' && buckets.some(function (b) { return inWin(b) && (Number(b.pricedEvents) || 0) < (Number(b.events) || 0); });
|
|
3226
|
+
var total = cosSum(overhead, 'calls'), measured = cosSum(overhead, 'measuredCalls'), estimated = cosSum(overhead, 'estimatedCalls');
|
|
3227
|
+
var usd = cosSum(overhead, 'measuredUSD');
|
|
3228
|
+
var measuredPartial = overhead.some(function (r) { return r.unpricedMeasuredCalls > 0 || r.unpricedLedgerTokens > 0; });
|
|
3229
|
+
var workUSD = (Number(work.measuredUSD) || 0) + (workRow ? workRow.measuredUSD : 0);
|
|
3230
|
+
var workTokens = (Number(work.measuredTokens) || 0) + (workRow ? workRow.measuredTokens : 0);
|
|
3231
|
+
var callWord = total === 1 ? 'call' : 'calls';
|
|
3232
|
+
|
|
3233
|
+
// The headline: the answer, then what it leaves out (headlineParts).
|
|
3234
|
+
var answer, caveat = '';
|
|
3235
|
+
if (!report.auditFound && total === 0) { answer = 'COS has no record of its calls on this Mac yet.'; caveat = ' The record starts with its first one.'; }
|
|
3236
|
+
else if (total === 0) {
|
|
3237
|
+
var withoutCalls = overhead.some(function (r) { return r.measuredTokens > 0; }) || workTokens > 0 || (Number(work.widerTokens) || 0) > 0;
|
|
3238
|
+
answer = withoutCalls ? 'COS made no counted calls in the last 30 days.' : 'COS made no calls in the last 30 days.';
|
|
3239
|
+
caveat = withoutCalls ? ' Its Work and background usage is below.' : '';
|
|
3240
|
+
} else if (measured <= 0) {
|
|
3241
|
+
answer = 'COS made ' + uNum(total) + ' ' + callWord + ' in the last 30 days.';
|
|
3242
|
+
caveat = reading ? ' Still reading the session logs that could hold their cost.'
|
|
3243
|
+
: sessions.failed ? ' COS could not read the usage history, so the session logs that could hold their cost are missing from this pass.'
|
|
3244
|
+
: ' This server doesn’t measure their cost yet.';
|
|
3245
|
+
} else {
|
|
3246
|
+
var lead = reading || sessions.failed ? 'Measured so far: COS used ' : 'Measured: COS used ';
|
|
3247
|
+
var usdText = uMoney(usd) + (measuredPartial ? '+' : ''), ranText = uMoney(ran) + (ranPartial ? '+' : '');
|
|
3248
|
+
if (usd <= 0) answer = 'COS made ' + uNum(total) + ' ' + callWord + ' in the last 30 days. ' + uNum(measured) + ' were measured, on models with no verified price.';
|
|
3249
|
+
else if (us === 'unavailable' || ran <= 0 || usd > ran) answer = lead + usdText + ' in the last 30 days.';
|
|
3250
|
+
else if (us === 'importing') answer = lead + usdText + ' of the ' + ranText + ' read so far in the last 30 days.';
|
|
3251
|
+
else answer = lead + usdText + ' of the ' + ranText + ' you ran in the last 30 days.';
|
|
3252
|
+
if (estimated > 0 && usd > 0) caveat = ' ' + uNum(estimated) + ' more ' + (estimated === 1 ? 'call has' : 'calls have') + ' only character counts, so the real figure is higher.';
|
|
3253
|
+
}
|
|
3254
|
+
if (report.auditUnreadable) {
|
|
3255
|
+
if (total === 0) { answer = 'COS could not read its record of calls, so there is no count to show.'; caveat = ''; }
|
|
3256
|
+
else caveat += ' COS could not read its record of calls, so some are missing here.';
|
|
3257
|
+
}
|
|
3258
|
+
var html = '<h2 class="sx-cost-head">' + esc(answer) + (caveat ? '<span>' + esc(caveat) + '</span>' : '') + '</h2>';
|
|
3259
|
+
var showsDollars = rows.some(function (r) { return r.measuredUSD > 0 || r.estimatedUSD > 0; }) || (Number(work.widerUSD) || 0) > 0;
|
|
3260
|
+
if (showsDollars) html += '<p class="sx-cost-api">These are API values: what the same calls would cost at standard API rates. They aren’t your bill.</p>';
|
|
3261
|
+
|
|
3262
|
+
var status = [];
|
|
3263
|
+
if (reading) { var n = Number(sessions.stillReading) || 0; status.push(['', n > 0 ? 'Still reading ' + uNum(n) + ' ' + (n === 1 ? 'session' : 'sessions') + '. These figures will grow until it finishes.' : 'Still reading. The usage history was busy, so these figures may grow.']); }
|
|
3264
|
+
if (us === 'importing') {
|
|
3265
|
+
var files = Number(su.pendingFiles) || 0;
|
|
3266
|
+
status.push(['warn', files > 0 ? 'Usage is still reading ' + uNum(files) + ' log ' + (files === 1 ? 'file' : 'files') + ', so the total will grow and the share will settle.' : 'Usage is still reading your Cursor usage, so the total will grow and the share will settle.']);
|
|
3267
|
+
} else if (us === 'unavailable') status.push(['warn', 'Usage has not been read yet, so there is no total to compare this with.']);
|
|
3268
|
+
if (status.length) html += '<div class="sx-cost-status">' + status.map(function (s) { return '<p class="' + s[0] + '">' + esc(s[1]) + '</p>'; }).join('') + '</div>';
|
|
3269
|
+
|
|
3270
|
+
// Category rows (rowLines): measured first, largest first; then estimated; then tokens with no
|
|
3271
|
+
// price; then empty rows. Dictation and Transcription cleanup always show.
|
|
3272
|
+
if (total > 0 || overhead.some(function (r) { return r.measuredTokens > 0; })) {
|
|
3273
|
+
var list = overhead.slice();
|
|
3274
|
+
['dictation', 'transcription'].forEach(function (c) { if (!list.some(function (r) { return r.category === c; })) { var e = { category: c }; COS_FIELDS.forEach(function (k) { e[k] = 0; }); list.push(e); } });
|
|
3275
|
+
var rank = function (r) { return r.measuredUSD > 0 ? 0 : r.estimatedUSD > 0 ? 1 : (r.calls > 0 || r.measuredTokens > 0 ? 2 : 3); };
|
|
3276
|
+
var size = function (r) { return r.measuredUSD > 0 ? r.measuredUSD : r.estimatedUSD > 0 ? r.estimatedUSD : r.calls; };
|
|
3277
|
+
list.sort(function (a, b) { return (rank(a) - rank(b)) || (size(b) - size(a)) || (a.category < b.category ? -1 : 1); });
|
|
3278
|
+
var peak = list.reduce(function (m, r) { return Math.max(m, r.measuredUSD + r.estimatedUSD); }, 0);
|
|
3279
|
+
html += '<div class="sx-cost-rows">' + list.map(function (r) { return cosRowHtml(r, peak); }).join('') + '</div>';
|
|
3280
|
+
}
|
|
3281
|
+
|
|
3282
|
+
// Work: what a person chose, under the figure rather than inside it.
|
|
3283
|
+
var workCount = !work.handoffsFound ? 'This Mac has no record of Work runs.' : !(Number(work.runs) > 0) ? 'No Work runs in the last 30 days' : uNum(work.runs) + ' ' + (Number(work.runs) === 1 ? 'run' : 'runs') + ' you started';
|
|
3284
|
+
var workPartial = (Number(work.unpricedEvents) || 0) > 0 || (workRow ? workRow.unpricedMeasuredCalls > 0 : false);
|
|
3285
|
+
var workFigure = workUSD > 0 ? uMoney(workUSD) + (workPartial ? '+' : '') : workTokens > 0 ? 'No price' : '';
|
|
3286
|
+
var wider = (Number(work.widerUSD) || 0) + (workRow ? workRow.measuredUSD : 0);
|
|
3287
|
+
var widerPartial = (Number(work.widerUnpricedEvents) || 0) > 0 || (workRow ? workRow.unpricedMeasuredCalls > 0 : false);
|
|
3288
|
+
var workLines = [];
|
|
3289
|
+
if (wider - workUSD >= 0.01) workLines.push(['strong', uMoney(wider) + (widerPartial ? '+' : '') + ' if you count what you did later in those sessions.']);
|
|
3290
|
+
if (work.handoffsFound && Number(work.runs) > 0) workLines.push(['', 'From each run’s session log, from its start until you next typed in that session or its last turn finished. Not part of the figure above.']);
|
|
3291
|
+
if (workRow && workRow.estimatedCalls > 0) workLines.push(['', 'Plus ' + cosCount('work', workRow.estimatedCalls) + (workRow.estimatedUSD > 0 ? ', ' + uAtLeast(workRow.estimatedUSD) + ' estimated' : ', no verified price') + '.']);
|
|
3292
|
+
var unproven = Number(work.unprovenRuns) || 0;
|
|
3293
|
+
if (unproven > 0) workLines.push(['', uNum(unproven) + ' ' + (unproven === 1 ? 'run has' : 'runs have') + ' no provable end, so ' + (unproven === 1 ? 'it is' : 'they are') + ' only in the wider figure.']);
|
|
3294
|
+
var noLogs = Number(work.runsWithoutLogs) || 0;
|
|
3295
|
+
if (noLogs > 0) workLines.push(['', uNum(noLogs) + ' ' + (noLogs === 1 ? 'run' : 'runs') + ' left no log COS can read, so ' + (noLogs === 1 ? 'it isn’t' : 'they aren’t') + ' counted.']);
|
|
3296
|
+
html += '<div class="sx-work"><div class="sx-work-top"><div><b class="sx-work-title">Work</b><span class="sx-work-count">' + esc(workCount) + '</span></div>' +
|
|
3297
|
+
'<div class="sx-work-fig">' + (workFigure ? '<b>' + esc(workFigure) + '</b>' : '') + (workTokens > 0 ? '<span>' + uShort(workTokens) + ' tokens, measured</span>' : '') + '</div></div>' +
|
|
3298
|
+
workLines.map(function (l) { return '<p class="' + l[0] + '">' + esc(l[1]) + '</p>'; }).join('') + '</div>';
|
|
3299
|
+
|
|
3300
|
+
// Footer: the notes, then how this is counted.
|
|
3301
|
+
var notes = [];
|
|
3302
|
+
if (ranPartial) {
|
|
3303
|
+
var short = measuredPartial || estimated > 0 || (Number(sessions.compressed) || 0) > 0 || (Number(sessions.noLogs) || 0) > 0 || reading;
|
|
3304
|
+
notes.push(short ? 'Some of your own sessions use models with no verified price, and COS’s own figure leaves some calls out too. Neither side is complete, so the share can be higher or lower.'
|
|
3305
|
+
: 'Some of your own sessions use models with no verified price. They aren’t in the total you ran, so it is higher than shown and COS’s share of it is lower.');
|
|
3306
|
+
}
|
|
3307
|
+
var compressed = Number(sessions.compressed) || 0, gone = Number(sessions.noLogs) || 0;
|
|
3308
|
+
if (compressed > 0) notes.push(uNum(compressed) + ' ' + (compressed === 1 ? 'session is' : 'sessions are') + ' compressed and could not be read, so their cost is not in these figures.');
|
|
3309
|
+
if (gone > 0) notes.push(uNum(gone) + ' ' + (gone === 1 ? 'session has' : 'sessions have') + ' no log left on this Mac, so their cost is not in these figures.');
|
|
3310
|
+
if (sessions.busy) notes.push('The usage history was busy, so this pass read nothing new. COS tries again soon.');
|
|
3311
|
+
if (sessions.failed) notes.push('COS could not read the usage history, so this pass read nothing new. It tries again in 5 minutes.');
|
|
3312
|
+
if (!report.runLedgersFound && overhead.some(function (r) { return r.category === 'answers' && r.calls > 0; })) notes.push('This server has no run records, so answers stay estimated.');
|
|
3313
|
+
var undated = Number(report.undatedCalls) || 0, unreadable = Number(report.unreadableRows) || 0;
|
|
3314
|
+
if (undated > 0) notes.push(uNum(undated) + ' ' + (undated === 1 ? 'call has' : 'calls have') + ' no readable date, so ' + (undated === 1 ? 'it isn’t' : 'they aren’t') + ' in these 30 days.');
|
|
3315
|
+
if (unreadable > 0) notes.push(uNum(unreadable) + ' ' + (unreadable === 1 ? 'line' : 'lines') + ' could not be read at all.');
|
|
3316
|
+
var approximate = cosSum(rows, 'approximateMatches');
|
|
3317
|
+
var method = [
|
|
3318
|
+
'Measured means the provider reported the tokens, in a session log or a record COS keeps. Estimated means COS only has the length of the text, so it divides characters by four and uses the model’s plain rate. Cache reads and system prompts aren’t in that, which is why an estimate says at least.',
|
|
3319
|
+
'Estimated figures stay out of every measured total. Rates are standard API rates, checked ' + (report.ratesCheckedOn ? uDayWords(report.ratesCheckedOn) : 'recently') + '. A model with no verified rate shows tokens and No price, never $0. A dollar figure that leaves out some calls with no rate ends in a +.',
|
|
3320
|
+
'The total you ran is what Usage counted in these 30 days, plus COS calls Usage can’t see because they keep no session log.',
|
|
3321
|
+
'A Work run counts from its start until you next typed in that session, another session messaged it, or its last turn finished. COS tells a typed turn from a tool result by the row’s type and never reads the words. Background work the run started counts as the run. With none of those endings in the log, a run has no provable end and stays in the wider figure only. The wider figure is everything in those sessions from the first run’s start, and it stays out of every share.'
|
|
3322
|
+
];
|
|
3323
|
+
if (approximate > 0) method.push(uNum(approximate) + ' ' + (approximate === 1 ? 'call was' : 'calls were') + ' matched to ' + (approximate === 1 ? 'its run' : 'their runs') + ' by time and model, because older servers write no id. Newer rows carry one.');
|
|
3324
|
+
method.push('Only counts, dates and model names are read from these logs.');
|
|
3325
|
+
html += '<div class="sx-cost-foot">' + notes.map(function (n) { return '<p>' + esc(n) + '</p>'; }).join('') +
|
|
3326
|
+
'<details class="sx-how"><summary>How this is counted</summary>' + method.map(function (l) { return '<p>' + esc(l) + '</p>'; }).join('') + '</details></div>';
|
|
3327
|
+
return html;
|
|
3328
|
+
}
|
|
3329
|
+
// One category (rowLine): what a person got, a bar (measured solid, an estimated floor paler
|
|
3330
|
+
// after it), and the dollars or the word for what the row has instead.
|
|
3331
|
+
function cosRowHtml(r, peak) {
|
|
3332
|
+
var title = (COS_CATS[r.category] || COS_CATS.other)[0];
|
|
3333
|
+
var tokens = uShort(r.measuredTokens + (r.measuredCalls === 0 ? r.estimatedTokens : 0)) + ' tokens';
|
|
3334
|
+
var count = r.calls > 0 ? cosCount(r.category, r.calls) : r.measuredTokens > 0 ? 'Counted by the day' : 'None in the last 30 days';
|
|
3335
|
+
var figure = '', detail = '', evidence = 'none', mix = [];
|
|
3336
|
+
if (r.calls > 0 || r.measuredTokens > 0) {
|
|
3337
|
+
if (r.measuredCalls > 0 && r.estimatedCalls > 0) {
|
|
3338
|
+
var rest = r.estimatedPricedCalls > 0 && r.estimatedPricedCalls < r.estimatedCalls ? uNum(r.estimatedPricedCalls) + ' of the ' + uNum(r.estimatedCalls) + ' others' : 'the rest';
|
|
3339
|
+
mix.push(uNum(r.measuredCalls) + ' measured' + (r.estimatedUSD > 0 ? ', ' + uAtLeast(r.estimatedUSD) + ' estimated for ' + rest : ', no verified price for the rest') + '.');
|
|
3340
|
+
}
|
|
3341
|
+
var partial = r.measuredUSD > 0 && (r.unpricedMeasuredCalls > 0 || r.unpricedLedgerTokens > 0);
|
|
3342
|
+
if (r.measuredUSD > 0 && r.unpricedMeasuredCalls > 0) mix.push(uNum(r.unpricedMeasuredCalls) + ' measured ' + (r.unpricedMeasuredCalls === 1 ? 'call has' : 'calls have') + ' no verified price, so the dollars are a floor.');
|
|
3343
|
+
if (r.measuredUSD > 0 && r.unpricedLedgerTokens > 0) mix.push(uShort(r.unpricedLedgerTokens) + ' tokens counted by the day have no verified price, so the dollars are a floor.');
|
|
3344
|
+
if (r.measuredUSD > 0) { evidence = 'measured'; figure = uMoney(r.measuredUSD) + (partial ? '+' : ''); detail = tokens + ', measured'; }
|
|
3345
|
+
else if (r.measuredTokens > 0 && r.estimatedCalls === 0) { evidence = 'noprice'; figure = 'No price'; detail = tokens; }
|
|
3346
|
+
else if (r.estimatedUSD > 0) { evidence = 'estimated'; figure = 'Estimated'; detail = uAtLeast(r.estimatedUSD); }
|
|
3347
|
+
else { evidence = 'noprice'; figure = 'No price'; detail = tokens; }
|
|
3348
|
+
}
|
|
3349
|
+
var m = peak > 0 ? Math.min(1, r.measuredUSD / peak) : 0, e = peak > 0 ? Math.min(1 - m, r.estimatedUSD / peak) : 0;
|
|
3350
|
+
return '<div class="sx-cost-row"><div class="sx-cost-name"><b>' + esc(title) + '</b><span>' + esc(count) + '</span>' + (mix.length ? '<span class="mix">' + esc(mix.join(' ')) + '</span>' : '') + '</div>' +
|
|
3351
|
+
'<div class="sx-cost-bar" aria-hidden="true">' + (m > 0 ? '<span class="m" style="width:' + (m * 100).toFixed(2) + '%"></span>' : '') + (e > 0 ? '<span class="e" style="width:' + (e * 100).toFixed(2) + '%"></span>' : '') + '</div>' +
|
|
3352
|
+
'<div class="sx-cost-fig ' + evidence + '">' + (figure ? '<b>' + esc(figure) + '</b>' : '') + (detail ? '<span>' + esc(detail) + '</span>' : '') + '</div></div>';
|
|
3353
|
+
}
|
|
3354
|
+
|
|
3355
|
+
// ── Settings ────────────────────────────────────────────
|
|
3356
|
+
function paintAppearance() {
|
|
3357
|
+
var choice = window.CosTheme ? window.CosTheme.choice() : 'system';
|
|
3358
|
+
document.querySelectorAll('[data-theme-choice]').forEach(function (b) {
|
|
3359
|
+
var on = b.getAttribute('data-theme-choice') === choice;
|
|
3360
|
+
b.setAttribute('aria-pressed', String(on));
|
|
3361
|
+
b.querySelector('.value').innerHTML = on ? '<span class="check">✓</span>' : '';
|
|
3362
|
+
});
|
|
3363
|
+
}
|
|
3364
|
+
var codeTimer = null;
|
|
3365
|
+
// A QR as inline SVG from vendor/qrcode-encoder.js (window.COSQR). Only numbers reach the
|
|
3366
|
+
// markup; the text is encoded, never inserted. Empty when the encoder did not load.
|
|
3367
|
+
function qrSvg(text) {
|
|
3368
|
+
if (!window.COSQR || !window.COSQR.matrix) return '';
|
|
3369
|
+
var m; try { m = window.COSQR.matrix(text); } catch (_e) { return ''; }
|
|
3370
|
+
var n = m.size, q = 4, d = '';
|
|
3371
|
+
for (var y = 0; y < n; y++) for (var x = 0; x < n; x++) if (m.data[y * n + x]) d += 'M' + (x + q) + ' ' + (y + q) + 'h1v1h-1z';
|
|
3372
|
+
var w = n + q * 2;
|
|
3373
|
+
return '<svg class="qr" viewBox="0 0 ' + w + ' ' + w + '" role="img" aria-label="Sign-in QR code" shape-rendering="crispEdges"><rect width="' + w + '" height="' + w + '" fill="#fff"/><path d="' + d + '" fill="#000"/></svg>';
|
|
3374
|
+
}
|
|
3375
|
+
function showDeviceCode() {
|
|
3376
|
+
var panel = $('device-code-panel');
|
|
3377
|
+
panel.classList.remove('hidden');
|
|
3378
|
+
panel.innerHTML = '<p>Getting a code</p>';
|
|
3379
|
+
api('/api/web-session/device-code', { method: 'POST', body: {} }).then(function (d) {
|
|
3380
|
+
var url = state.tailnetUrl || (location.origin + '/control');
|
|
3381
|
+
var exp = toDate(d.expiresAt);
|
|
3382
|
+
// The QR is the phone link with this code on it: the camera opens it, the page signs in
|
|
3383
|
+
// with the code (one use, five minutes), and nothing is typed. Same code, same rules.
|
|
3384
|
+
var link = url + (url.indexOf('?') >= 0 ? '&' : '?') + 'code=' + encodeURIComponent(d.code);
|
|
3385
|
+
panel.innerHTML = qrSvg(link) +
|
|
3386
|
+
'<p class="qr-hint">Scan with your phone\'s camera to sign it in.</p>' +
|
|
3387
|
+
'<p>Or open <span class="url">' + esc(url) + '</span> on the phone and type this code:</p>' +
|
|
3388
|
+
'<span class="code">' + esc(d.code) + '</span>' +
|
|
3389
|
+
'<p id="device-code-left"></p>';
|
|
3390
|
+
clearInterval(codeTimer);
|
|
3391
|
+
var tick = function () {
|
|
3392
|
+
var left = exp ? Math.round((exp.getTime() - Date.now()) / 1000) : 0;
|
|
3393
|
+
var el = $('device-code-left');
|
|
3394
|
+
if (!el) { clearInterval(codeTimer); return; }
|
|
3395
|
+
if (left <= 0) { clearInterval(codeTimer); panel.innerHTML = '<p>That code expired. Choose Sign in another device for a new one.</p>'; return; }
|
|
3396
|
+
el.textContent = 'Works once, for ' + (left >= 60 ? Math.ceil(left / 60) + ' more min' : left + ' more sec') + '.';
|
|
3397
|
+
};
|
|
3398
|
+
tick(); codeTimer = setInterval(tick, 1000);
|
|
3399
|
+
}).catch(function (err) { panel.innerHTML = '<p class="status-line error">' + esc(err.message) + '</p>'; });
|
|
3400
|
+
}
|
|
3401
|
+
function loadTailnetUrl() {
|
|
3402
|
+
api('/api/tailscale/web-url').then(function (d) {
|
|
3403
|
+
// The phone link is plain http by name, then by address. The https name link comes last:
|
|
3404
|
+
// the Mac's local certificate does not cover the Tailscale name, so Safari refuses it.
|
|
3405
|
+
state.tailnetUrl = d.magicDnsUrl || d.ipv4Url || d.magicDnsHttpsUrl || '';
|
|
3406
|
+
// Settings renders this line itself; fill it only if it is on screen now.
|
|
3407
|
+
var el = $('tailnet-url'); if (el) el.textContent = state.tailnetUrl || 'Tailscale is not running on the Mac';
|
|
3408
|
+
}).catch(function () { var el = $('tailnet-url'); if (el) el.textContent = 'Tailscale address unavailable'; });
|
|
3409
|
+
}
|
|
3410
|
+
|
|
3411
|
+
// ── Ask sheet (⌘K) ──────────────────────────────────────
|
|
3412
|
+
var JUMPS = [
|
|
3413
|
+
['today', 'Today'], ['messages', 'Messages'], ['speakers', 'Speakers'], ['meetings', 'Meetings'], ['memories', 'Memories'],
|
|
3414
|
+
['threads', 'Threads'], ['sessions', 'Sessions'], ['work', 'Work'], ['dictation', 'Dictation'], ['usage', 'Usage'], ['settings', 'Settings']
|
|
3415
|
+
];
|
|
3416
|
+
function openAsk(prefill) {
|
|
3417
|
+
$('ask-sheet').classList.remove('hidden');
|
|
3418
|
+
document.body.style.overflow = 'hidden';
|
|
3419
|
+
var input = $('ask-q'); input.value = prefill || '';
|
|
3420
|
+
renderAsk();
|
|
3421
|
+
setTimeout(function () { input.focus(); }, 30);
|
|
3422
|
+
}
|
|
3423
|
+
function closeAsk() { $('ask-sheet').classList.add('hidden'); document.body.style.overflow = ''; }
|
|
3424
|
+
function renderAsk() {
|
|
3425
|
+
var q = $('ask-q').value.trim();
|
|
3426
|
+
var out = $('ask-results');
|
|
3427
|
+
if (!q) {
|
|
3428
|
+
var recent = state.sessions.slice(0, 5);
|
|
3429
|
+
out.innerHTML = '<h2 class="group-title">Go to</h2><div class="list">' + JUMPS.map(function (j) {
|
|
3430
|
+
return '<button type="button" class="nav-row" data-goto="' + j[0] + '"><svg class="icon"><use href="#i-' + j[0] + '"/></svg><span class="grow">' + esc(j[1]) + '</span><span class="chev"></span></button>';
|
|
3431
|
+
}).join('') + '</div>' +
|
|
3432
|
+
(recent.length ? '<h2 class="group-title">Recent sessions</h2><div class="list">' + recent.map(function (s) { return sessionRow(s); }).join('') + '</div>' : '');
|
|
3433
|
+
return;
|
|
3434
|
+
}
|
|
3435
|
+
var jumps = JUMPS.filter(function (j) { return j[1].toLowerCase().indexOf(q.toLowerCase()) === 0; });
|
|
3436
|
+
out.innerHTML = '<div class="list" style="margin-top:14px">' +
|
|
3437
|
+
'<button type="button" class="nav-row" data-action="ask-cos"><svg class="icon"><use href="#i-ask"/></svg><span class="grow">Ask COS<span class="note">“' + esc(q) + '”</span></span></button>' +
|
|
3438
|
+
'<button type="button" class="nav-row" data-action="ask-meaning"><svg class="icon"><use href="#i-sessions"/></svg><span class="grow">Find a session about this<span class="note">Ranks every session by meaning</span></span></button>' +
|
|
3439
|
+
'<button type="button" class="nav-row" data-action="ask-work"><svg class="icon"><use href="#i-work"/></svg><span class="grow">Find a task about this<span class="note">Searches Work by meaning</span></span></button>' +
|
|
3440
|
+
jumps.map(function (j) { return '<button type="button" class="nav-row" data-goto="' + j[0] + '"><svg class="icon"><use href="#i-' + j[0] + '"/></svg><span class="grow">Go to ' + esc(j[1]) + '</span><span class="chev"></span></button>'; }).join('') +
|
|
3441
|
+
'</div><div id="ask-hits"></div><p class="status-line" id="ask-status"></p>';
|
|
3442
|
+
clearTimeout(renderAsk.t);
|
|
3443
|
+
if (q.length >= 2) renderAsk.t = setTimeout(function () {
|
|
3444
|
+
api('/api/agent-sessions/search?q=' + encodeURIComponent(q) + '&limit=6').then(function (d) {
|
|
3445
|
+
var hits = (d && d.hits) || [];
|
|
3446
|
+
var el = $('ask-hits'); if (!el || $('ask-q').value.trim() !== q) return;
|
|
3447
|
+
el.innerHTML = hits.length ? '<h2 class="group-title"><span>Sessions with “' + esc(q) + '”</span><span>' + hits.length + '</span></h2><div class="list">' + hits.map(function (s) { return sessionRow(s, s.snippet); }).join('') + '</div>' : '';
|
|
3448
|
+
}).catch(function () {});
|
|
3449
|
+
}, 220);
|
|
3450
|
+
}
|
|
3451
|
+
|
|
3452
|
+
// ── events (one delegated listener) ─────────────────────
|
|
3453
|
+
document.addEventListener('click', function (e) {
|
|
3454
|
+
var t = e.target.closest('[data-goto],[data-pane],[data-session],[data-meeting],[data-speakers],[data-action],[data-open-ask],[data-theme-choice],#topbar-back,#section-btn,#sign-out,#copy-link,#ask-close,#auth-switch,#auth-token-show,#device-code-btn');
|
|
3455
|
+
if (!t) { if (e.target.id === 'ask-sheet') closeAsk(); if (e.target.id === 'section-menu') closeMenu(); return; }
|
|
3456
|
+
var inSheet = !!t.closest('#ask-sheet');
|
|
3457
|
+
if (t.id === 'topbar-back') return goBack();
|
|
3458
|
+
if (t.id === 'section-btn') return openMenu();
|
|
3459
|
+
if (t.id === 'auth-switch') {
|
|
3460
|
+
var toToken = $('auth-token-block').classList.contains('hidden');
|
|
3461
|
+
$('auth-token-block').classList.toggle('hidden', !toToken);
|
|
3462
|
+
$('auth-code-block').classList.toggle('hidden', toToken);
|
|
3463
|
+
t.textContent = toToken ? 'Use a sign-in code instead' : 'Use the pairing token instead';
|
|
3464
|
+
(toToken ? $('auth-token') : $('auth-code')).focus();
|
|
3465
|
+
return;
|
|
3466
|
+
}
|
|
3467
|
+
if (t.id === 'auth-token-show') {
|
|
3468
|
+
// A stale or partial paste is invisible in a password field; let the person look.
|
|
3469
|
+
var shown = $('auth-token').type === 'text';
|
|
3470
|
+
$('auth-token').type = shown ? 'password' : 'text';
|
|
3471
|
+
t.textContent = shown ? 'Show token' : 'Hide token';
|
|
3472
|
+
t.setAttribute('aria-pressed', shown ? 'false' : 'true');
|
|
3473
|
+
return;
|
|
3474
|
+
}
|
|
3475
|
+
if (t.id === 'device-code-btn') return showDeviceCode();
|
|
3476
|
+
if (t.id === 'ask-close') return closeAsk();
|
|
3477
|
+
if (t.id === 'sign-out') { api('/api/web-session/logout', { method: 'POST' }).finally(function () { showAuth(); }); return; }
|
|
3478
|
+
if (t.id === 'copy-link') {
|
|
3479
|
+
var link = state.tailnetUrl || location.origin + '/control';
|
|
3480
|
+
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(link).then(function () { toast('Link copied'); }, function () { toast(link); });
|
|
3481
|
+
else toast(link);
|
|
3482
|
+
return;
|
|
3483
|
+
}
|
|
3484
|
+
if (t.hasAttribute('data-open-ask')) return openAsk();
|
|
3485
|
+
if (t.hasAttribute('data-theme-choice')) { window.CosTheme.set(t.getAttribute('data-theme-choice')); paintAppearance(); syncFrameTheme(); return; }
|
|
3486
|
+
if (t.hasAttribute('data-goto') || t.hasAttribute('data-pane')) {
|
|
3487
|
+
if (inSheet) closeAsk(); closeMenu();
|
|
3488
|
+
// "work/sort" is a pane and its arguments, not one name.
|
|
3489
|
+
var dest = String(t.getAttribute('data-goto') || t.getAttribute('data-pane')).split('/');
|
|
3490
|
+
return go(dest[0], dest.slice(1));
|
|
3491
|
+
}
|
|
3492
|
+
if (t.hasAttribute('data-session')) { if (inSheet) closeAsk(); var p = t.getAttribute('data-session').split('|'); return go('sessions', [p[0], p.slice(1).join('|')]); }
|
|
3493
|
+
if (t.hasAttribute('data-meeting')) { var m = t.getAttribute('data-meeting').split('|'); return go('meetings', m); }
|
|
3494
|
+
if (t.hasAttribute('data-speakers')) return go('speakers', [t.getAttribute('data-speakers')]);
|
|
3495
|
+
var action = t.getAttribute('data-action');
|
|
3496
|
+
if (action === 'fork-session') return sendSessionTurn(true);
|
|
3497
|
+
if (action === 'send-anyway') { var rev = state.reading && state.reading.sendAnyway; if (state.reading) state.reading.sendAnyway = null; return sendSessionTurn(false, { acknowledgedRevision: rev }); }
|
|
3498
|
+
if (action === 'stop-session') return stopSession();
|
|
3499
|
+
if (action === 'drop-after') return dropHeld();
|
|
3500
|
+
if (action === 'unqueue') return removeQueued(t.getAttribute('data-turn-key'));
|
|
3501
|
+
if (action === 'revoke-all') {
|
|
3502
|
+
if (t.getAttribute('data-armed') !== '1') { t.setAttribute('data-armed', '1'); t.querySelector('.grow').firstChild.textContent = 'Tap again to sign out every browser'; return; }
|
|
3503
|
+
api('/api/web-session/revoke-all', { method: 'POST', body: {} }).then(function () { showAuth('Every browser is signed out, this one too.'); }).catch(function (err) { setStatus($('settings-status'), err.message, true); });
|
|
3504
|
+
return;
|
|
3505
|
+
}
|
|
3506
|
+
if (action === 'expand-turn') { var tr = document.querySelector('.turn[data-turn="' + t.getAttribute('data-turn') + '"]'); if (tr) tr.classList.remove('clamped'); t.remove(); return; }
|
|
3507
|
+
var q = $('ask-q').value.trim();
|
|
3508
|
+
if (action === 'ask-cos' && q) { askCos(q, $('ask-status')).then(function (where) { closeAsk(); go('messages', Array.isArray(where) ? where : []); }).catch(function () {}); return; }
|
|
3509
|
+
if (action === 'ask-meaning' && q) { $('ask-hits').innerHTML = skeletonRows(3); findByMeaning(q).then(function (html) { var el = $('ask-hits'); if (el) el.innerHTML = html; }); return; }
|
|
3510
|
+
if (action === 'ask-work' && q) {
|
|
3511
|
+
closeAsk(); go('work');
|
|
3512
|
+
// The Work pane (panes/work.js) owns its search box; hand it the query.
|
|
3513
|
+
if (PANES.work && PANES.work.search) PANES.work.search(q);
|
|
3514
|
+
}
|
|
3515
|
+
});
|
|
3516
|
+
|
|
3517
|
+
document.addEventListener('submit', function (e) {
|
|
3518
|
+
var f = e.target;
|
|
3519
|
+
e.preventDefault();
|
|
3520
|
+
if (f.id === 'auth-form') {
|
|
3521
|
+
var usingToken = !$('auth-token-block').classList.contains('hidden');
|
|
3522
|
+
var value = (usingToken ? $('auth-token') : $('auth-code')).value.trim();
|
|
3523
|
+
if (!value) return;
|
|
3524
|
+
var body = usingToken ? { token: value } : { code: value };
|
|
3525
|
+
api('/api/web-session/login', { method: 'POST', body: body }).then(function (d) { $('auth-token').value = ''; $('auth-code').value = ''; showShell(d); })
|
|
3526
|
+
.catch(function (err) {
|
|
3527
|
+
// The server names what failed (wrong token, wrong code, the glasses code, lockout).
|
|
3528
|
+
// No status means the request never reached COS (the browser's own "Load failed").
|
|
3529
|
+
showAuth(err && err.status ? err.message : 'Could not reach COS on your Mac. Check that this phone is on Tailscale and the Mac is awake.');
|
|
3530
|
+
});
|
|
3531
|
+
} else if (f.id === 'session-form') sendSessionTurn(false);
|
|
3532
|
+
else if (f.id === 'sessions-search-form') { $('sessions-q').blur(); searchSessions(); }
|
|
3533
|
+
else if (f.id === 'ask-form') {
|
|
3534
|
+
var hits = $('ask-hits'); if (hits && $('ask-q').value.trim().length >= 2) renderAsk();
|
|
3535
|
+
}
|
|
3536
|
+
});
|
|
3537
|
+
|
|
3538
|
+
document.addEventListener('input', function (e) {
|
|
3539
|
+
if (e.target.tagName === 'TEXTAREA') autoGrow(e.target);
|
|
3540
|
+
if (e.target.id === 'ask-q') renderAsk();
|
|
3541
|
+
if (e.target.id === 'sessions-q' && !e.target.value.trim()) renderSessionGroups(state.sessions);
|
|
3542
|
+
});
|
|
3543
|
+
document.addEventListener('keydown', function (e) {
|
|
3544
|
+
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { e.preventDefault(); openAsk(); return; }
|
|
3545
|
+
if (e.key === 'Escape' && !$('ask-sheet').classList.contains('hidden')) { closeAsk(); return; }
|
|
3546
|
+
if (e.key === 'Escape' && !$('section-menu').classList.contains('hidden')) { closeMenu(); $('section-btn').focus(); return; }
|
|
3547
|
+
if (e.key === 'Enter' && !e.shiftKey && e.target.tagName === 'TEXTAREA' && !isPhone()) { e.preventDefault(); var form = e.target.closest('form'); if (form) form.requestSubmit(); }
|
|
3548
|
+
});
|
|
3549
|
+
document.querySelectorAll('[data-mode]').forEach(function (b) {
|
|
3550
|
+
b.addEventListener('click', function () {
|
|
3551
|
+
state.sessionsMode = b.getAttribute('data-mode');
|
|
3552
|
+
document.querySelectorAll('[data-mode]').forEach(function (x) { x.setAttribute('aria-pressed', String(x === b)); });
|
|
3553
|
+
if ($('sessions-q').value.trim().length >= 2) searchSessions();
|
|
3554
|
+
});
|
|
3555
|
+
});
|
|
3556
|
+
window.addEventListener('hashchange', route);
|
|
3557
|
+
// Folding or unfolding the Duo crosses the 600px line: keep the hosted Memories page in step.
|
|
3558
|
+
window.matchMedia('(min-width: 600px)').addEventListener('change', syncFrameTheme);
|
|
3559
|
+
window.addEventListener('scroll', onScroll, { passive: true });
|
|
3560
|
+
document.addEventListener('visibilitychange', function () {
|
|
3561
|
+
if (document.hidden || $('app-shell').classList.contains('hidden')) return;
|
|
3562
|
+
// Coming back to the page refreshes Today in place. Other panes keep what is on screen:
|
|
3563
|
+
// rebuilding them here moved the page under Miles's finger ("it reloads the section").
|
|
3564
|
+
if (state.pane === 'today' && Date.now() - (state.lastLoad.today || 0) > 60000) { state.lastLoad.today = Date.now(); loadToday(); }
|
|
3565
|
+
});
|
|
3566
|
+
window.addEventListener('message', function (e) {
|
|
3567
|
+
if (e.origin !== location.origin) return;
|
|
3568
|
+
var d = e.data;
|
|
3569
|
+
if (d && d.type === 'cos-control-nav' && d.op === 'open.section' && d.args && d.args.section) {
|
|
3570
|
+
var target = String(d.args.section).toLowerCase();
|
|
3571
|
+
if (ROOT_PANES.indexOf(target) >= 0) go(target);
|
|
3572
|
+
}
|
|
3573
|
+
});
|
|
3574
|
+
|
|
3575
|
+
// ── boot ────────────────────────────────────────────────
|
|
3576
|
+
// Copy that tells the truth: a plain-http tailnet page is not a secure context, so
|
|
3577
|
+
// navigator.clipboard is missing there. Fall back to a selected textarea, and reject when both
|
|
3578
|
+
// fail so no toast claims a copy that did not happen.
|
|
3579
|
+
function copyText(text) {
|
|
3580
|
+
return new Promise(function (resolve, reject) {
|
|
3581
|
+
function fallback() {
|
|
3582
|
+
var ta = document.createElement('textarea');
|
|
3583
|
+
ta.value = String(text == null ? '' : text);
|
|
3584
|
+
ta.setAttribute('readonly', '');
|
|
3585
|
+
ta.style.position = 'fixed'; ta.style.top = '-1000px'; ta.style.opacity = '0';
|
|
3586
|
+
document.body.appendChild(ta);
|
|
3587
|
+
ta.select(); ta.setSelectionRange(0, ta.value.length);
|
|
3588
|
+
var ok = false;
|
|
3589
|
+
try { ok = document.execCommand('copy'); } catch (_e) { ok = false; }
|
|
3590
|
+
document.body.removeChild(ta);
|
|
3591
|
+
if (ok) resolve(); else reject(new Error('This browser would not copy. Select the text and copy it yourself.'));
|
|
3592
|
+
}
|
|
3593
|
+
if (navigator.clipboard && navigator.clipboard.writeText && window.isSecureContext) navigator.clipboard.writeText(String(text)).then(resolve, fallback);
|
|
3594
|
+
else fallback();
|
|
3595
|
+
});
|
|
3596
|
+
}
|
|
3597
|
+
|
|
3598
|
+
// ── the shared kit for pane modules (panes/*.js) ───────
|
|
3599
|
+
window.COSWeb = {
|
|
3600
|
+
$: $, esc: esc, md: md, plain: plain, api: api, toast: toast, ago: ago, clock: clock, toDate: toDate,
|
|
3601
|
+
plural: plural, countWord: countWord, dayWord: dayWord, providerLabel: providerLabel, domainLabel: domainLabel,
|
|
3602
|
+
skeletonRows: skeletonRows, emptyBox: emptyBox, setStatus: setStatus, autoGrow: autoGrow,
|
|
3603
|
+
readStore: readStore, writeStore: writeStore, copyText: copyText, isPhone: isPhone,
|
|
3604
|
+
state: state, titles: TITLES, go: go, parseRoute: parseRoute, goBack: goBack,
|
|
3605
|
+
registerPane: function (name, def) { PANES[name] = def; },
|
|
3606
|
+
onRoute: function (fn) { routeHooks.push(fn); },
|
|
3607
|
+
onShell: function (fn) { shellHooks.push(fn); },
|
|
3608
|
+
// The detail title after "COS Control > Section" (Control's breadcrumb). Pane modules set it
|
|
3609
|
+
// once a detail has loaded; route() clears it on every navigation.
|
|
3610
|
+
setCrumb: function (text) { crumbDetail = String(text || ''); runHooks(routeHooks, parseRoute()); },
|
|
3611
|
+
crumb: function () { return crumbDetail; },
|
|
3612
|
+
// Panes that read with their own fetch call this on a 401 so the sign-in screen shows.
|
|
3613
|
+
showAuth: function (message) { showAuth(message); },
|
|
3614
|
+
// Control's session state (the Sessions pane's sxState) and warm-up test, so the rail's
|
|
3615
|
+
// "N waiting on you, N working" counts exactly what the pane lists.
|
|
3616
|
+
sessionState: function (s) { return sxState(s); },
|
|
3617
|
+
isKeepWarm: function (s) { return sxKeepWarm(s); }
|
|
3618
|
+
};
|
|
3619
|
+
|
|
3620
|
+
// Every pane module is a plain script after this one; boot waits for all of them.
|
|
3621
|
+
function boot() {
|
|
3622
|
+
var params = new URLSearchParams(location.search);
|
|
3623
|
+
var linkCode = params.get('code');
|
|
3624
|
+
if (linkCode && !params.get('handoff')) {
|
|
3625
|
+
history.replaceState({}, document.title, location.pathname + location.hash);
|
|
3626
|
+
api('/api/web-session/login', { method: 'POST', body: { code: linkCode } }).then(showShell)
|
|
3627
|
+
.catch(function (err) {
|
|
3628
|
+
showAuth(err && err.status ? err.message : 'Could not reach COS on your Mac. Check that this phone is on Tailscale and the Mac is awake.');
|
|
3629
|
+
var box = $('auth-code'); if (box) box.value = linkCode;
|
|
3630
|
+
});
|
|
3631
|
+
return;
|
|
3632
|
+
}
|
|
3633
|
+
var handoff = params.get('handoff');
|
|
3634
|
+
if (handoff) {
|
|
3635
|
+
history.replaceState({}, document.title, location.pathname + location.hash);
|
|
3636
|
+
api('/api/web-session/login', { method: 'POST', body: { handoff: handoff } }).then(showShell)
|
|
3637
|
+
.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.'); });
|
|
3638
|
+
return;
|
|
3639
|
+
}
|
|
3640
|
+
api('/api/web-session/status').then(function (st) { if (st && st.authenticated) showShell(st); else showAuth(); }).catch(function () { showAuth('COS on your Mac is not answering. Check that the Mac is awake and on Tailscale.'); });
|
|
3641
|
+
}
|
|
3642
|
+
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); else boot();
|
|
3643
|
+
})();
|