@vale-dinap/lucidly 0.0.0-stage → 0.1.0
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/LICENSE +202 -0
- package/NOTICE +2 -0
- package/README.md +114 -2
- package/bin/lucidly.js +5 -0
- package/dist/adapters/claude-code.js +367 -0
- package/dist/adapters/claude-tools.js +123 -0
- package/dist/adapters/text.js +106 -0
- package/dist/cli.js +215 -0
- package/dist/core/engine.js +1034 -0
- package/dist/core/events.js +5 -0
- package/dist/core/model.js +1 -0
- package/dist/core/text.js +253 -0
- package/dist/outline.js +73 -0
- package/dist/server/server.js +286 -0
- package/dist/server/session.js +101 -0
- package/dist/sources/claude-sessions.js +329 -0
- package/dist/sources/source.js +1 -0
- package/dist/sources/stdin.js +74 -0
- package/dist/sources/tail.js +105 -0
- package/package.json +49 -4
- package/web/app.js +3317 -0
- package/web/favicon.svg +9 -0
- package/web/index.html +114 -0
- package/web/style.css +950 -0
package/web/app.js
ADDED
|
@@ -0,0 +1,3317 @@
|
|
|
1
|
+
// Lucidly web UI. Plain ES module, no framework: state in, HTML strings out.
|
|
2
|
+
|
|
3
|
+
const $ = (s, el = document) => el.querySelector(s);
|
|
4
|
+
const esc = (s) =>
|
|
5
|
+
String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
6
|
+
|
|
7
|
+
// ───────────────────────────── icons (stroke paths, 24×24 grid)
|
|
8
|
+
const PATHS = {
|
|
9
|
+
read: 'M4 4h11l5 5v11H4z M15 4v5h5 M8 13h8 M8 17h6',
|
|
10
|
+
edit: 'M4 20h4L19 9l-4-4L4 16z M13 7l4 4',
|
|
11
|
+
write: 'M4 4h11l5 5v11H4z M12 11v6 M9 14h6',
|
|
12
|
+
command: 'M4 6l6 6-6 6 M12 18h8',
|
|
13
|
+
search: 'M11 4a7 7 0 1 0 0 14a7 7 0 1 0 0-14z M16 16l4 4',
|
|
14
|
+
web: 'M12 3a9 9 0 1 0 0 18a9 9 0 1 0 0-18z M3 12h18 M12 3c3 3 3 15 0 18 M12 3c-3 3-3 15 0 18',
|
|
15
|
+
agent: 'M12 3l2.2 5.8L20 11l-5.8 2.2L12 19l-2.2-5.8L4 11l5.8-2.2z',
|
|
16
|
+
ask: 'M9 9a3 3 0 1 1 4 2.8c-.7.3-1 .8-1 1.6V14 M12 18h.01',
|
|
17
|
+
user: 'M12 4a4 4 0 1 0 0 8a4 4 0 1 0 0-8z M4 20c1.5-4 4.5-5 8-5s6.5 1 8 5',
|
|
18
|
+
other: 'M12 8a4 4 0 1 0 0 8a4 4 0 1 0 0-8z',
|
|
19
|
+
issue: 'M12 3l9 16H3z M12 10v4 M12 17h.01',
|
|
20
|
+
};
|
|
21
|
+
const SVG = (d, cls = '') => `<svg${cls ? ` class="${cls}"` : ''} viewBox="0 0 24 24" aria-hidden="true"><path d="${d}"/></svg>`;
|
|
22
|
+
const icon = (k) => `<span class="k">${SVG(PATHS[k] ?? PATHS.other)}</span>`;
|
|
23
|
+
const CHEV = SVG('M9 6l6 6-6 6', 'chev');
|
|
24
|
+
const CLOSE = SVG('M6 6l12 12M18 6L6 18');
|
|
25
|
+
|
|
26
|
+
// ───────────────────────────── state
|
|
27
|
+
const S = {
|
|
28
|
+
runId: null,
|
|
29
|
+
run: null,
|
|
30
|
+
nodes: {},
|
|
31
|
+
selected: null,
|
|
32
|
+
follow: true,
|
|
33
|
+
es: null,
|
|
34
|
+
queued: false,
|
|
35
|
+
lastFollowTarget: null,
|
|
36
|
+
view: 'path', // 'path' | 'actions'
|
|
37
|
+
// The path view's layout: 'simple' (tasks as nodes) or 'detailed' (cards with every step).
|
|
38
|
+
layout: ['detailed', 'files'].includes(localStorage.getItem('lucidly.layout')) ? localStorage.getItem('lucidly.layout') : 'simple',
|
|
39
|
+
// Keep the overview (stats and timeline) on screen while scrolling.
|
|
40
|
+
pinOv: localStorage.getItem('lucidly.pinOverview') !== '0', // pinned unless you unpinned it
|
|
41
|
+
// Token heat: a green-to-red glow around tasks and steps by how many tokens they used.
|
|
42
|
+
heat: localStorage.getItem('lucidly.heat') === '1',
|
|
43
|
+
// path view
|
|
44
|
+
openGoals: new Set(),
|
|
45
|
+
closedGoals: new Set(),
|
|
46
|
+
openSteps: new Set(),
|
|
47
|
+
closedSteps: new Set(),
|
|
48
|
+
allActs: new Set(), // units whose full action list is shown
|
|
49
|
+
expandAll: false,
|
|
50
|
+
pf: { mode: 'all', hideChat: false, q: '' },
|
|
51
|
+
// File edits view: sort order, files opened, files showing their diffs inline.
|
|
52
|
+
ff: { sort: 'recent', open: new Set(), diffs: new Set(), outside: false, layout: 'files', histDiffs: false },
|
|
53
|
+
// detail panel
|
|
54
|
+
raw: new Map(), // nodeId -> { chunks, stale, loading, at }
|
|
55
|
+
openActs: new Set(),
|
|
56
|
+
closedActs: new Set(),
|
|
57
|
+
fullPre: new Set(),
|
|
58
|
+
closedSecs: new Set(),
|
|
59
|
+
// actions view
|
|
60
|
+
actions: null,
|
|
61
|
+
actionsStale: true,
|
|
62
|
+
actionsLoading: false,
|
|
63
|
+
actionsAt: 0,
|
|
64
|
+
actionsLimit: 400,
|
|
65
|
+
af: { kinds: new Set(), errors: 'all', q: '', goal: 'all' },
|
|
66
|
+
stripRows: [],
|
|
67
|
+
zoom: { a: 0, b: 1 },
|
|
68
|
+
laneRows: [],
|
|
69
|
+
pickerScope: 'project',
|
|
70
|
+
};
|
|
71
|
+
window.__lucidly = S; // debugging aid
|
|
72
|
+
const N = (id) => S.nodes[id];
|
|
73
|
+
const kids = (n) => (n?.children ?? []).map(N).filter(Boolean);
|
|
74
|
+
const isAct = (c) => c.type === 'activity' && c.kind !== 'user';
|
|
75
|
+
|
|
76
|
+
// ───────────────────────────── formatting
|
|
77
|
+
function ago(ts) {
|
|
78
|
+
if (!ts) return '';
|
|
79
|
+
const s = Math.max(0, Math.round((Date.now() - (typeof ts === 'number' ? ts : Date.parse(ts))) / 1000));
|
|
80
|
+
if (s < 45) return 'just now';
|
|
81
|
+
if (s < 3600) return `${Math.round(s / 60)}m ago`;
|
|
82
|
+
if (s < 86400) return `${Math.round(s / 3600)}h ago`;
|
|
83
|
+
return `${Math.round(s / 86400)}d ago`;
|
|
84
|
+
}
|
|
85
|
+
function dur(a, b) {
|
|
86
|
+
if (!a) return '';
|
|
87
|
+
const ms = (b ? Date.parse(b) : Date.now()) - Date.parse(a);
|
|
88
|
+
if (!(ms >= 0)) return '';
|
|
89
|
+
const s = Math.round(ms / 1000);
|
|
90
|
+
if (s < 60) return `${s}s`;
|
|
91
|
+
if (s < 3600) return `${Math.floor(s / 60)}m ${s % 60}s`;
|
|
92
|
+
return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`;
|
|
93
|
+
}
|
|
94
|
+
const base = (p) => String(p).split(/[\\/]/).filter(Boolean).pop() ?? p;
|
|
95
|
+
const plural = (n, w) => `${n} ${w}${n === 1 ? '' : 's'}`;
|
|
96
|
+
const clip = (s, n) => (String(s ?? '').length > n ? String(s).slice(0, n - 1) + '…' : String(s ?? ''));
|
|
97
|
+
const clock = (ts) => (ts ? new Date(ts).toLocaleTimeString([], { hour12: false }) : '');
|
|
98
|
+
const firstOf = (s) => {
|
|
99
|
+
const lines = String(s ?? '').split('\n');
|
|
100
|
+
return lines[0] + (lines.length > 1 ? ' …' : '');
|
|
101
|
+
};
|
|
102
|
+
const stMark = (n) => (n.status === 'active' && S.run?.status !== 'idle' ? '<span class="spin"></span>' : n.status === 'failed' ? '✕' : n.status === 'active' ? '·' : '✓');
|
|
103
|
+
|
|
104
|
+
/** "claude-haiku-5-5" → "Haiku 5.5"; unknown ids pass through. */
|
|
105
|
+
function modelName(id) {
|
|
106
|
+
const m = String(id ?? '');
|
|
107
|
+
let x = /^claude-(opus|sonnet|haiku|fable)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?$/i.exec(m);
|
|
108
|
+
if (x) return `${x[1][0].toUpperCase()}${x[1].slice(1)} ${x[2]}${x[3] ? '.' + x[3] : ''}`;
|
|
109
|
+
x = /^claude-(\d+)(?:-(\d+))?-(opus|sonnet|haiku)/i.exec(m);
|
|
110
|
+
if (x) return `${x[3][0].toUpperCase()}${x[3].slice(1)} ${x[1]}${x[2] ? '.' + x[2] : ''}`;
|
|
111
|
+
return m;
|
|
112
|
+
}
|
|
113
|
+
/** 1234 → "1.2k", 5470752 → "5.5M". */
|
|
114
|
+
function tok(n) {
|
|
115
|
+
n = n ?? 0;
|
|
116
|
+
if (n < 1000) return String(n);
|
|
117
|
+
if (n < 1e6) return `${(n / 1e3).toFixed(n < 1e4 ? 1 : 0)}k`;
|
|
118
|
+
if (n < 1e9) return `${(n / 1e6).toFixed(n < 1e7 ? 2 : 1)}M`;
|
|
119
|
+
return `${(n / 1e9).toFixed(2)}B`;
|
|
120
|
+
}
|
|
121
|
+
const freshIn = (u) => (u?.input ?? 0) + (u?.cacheWrite ?? 0);
|
|
122
|
+
const modelsLabel = (n) => (n?.models ?? []).map(modelName).join(' · ');
|
|
123
|
+
const usageLine = (u) => (u ? `${tok(freshIn(u))} in · ${tok(u.output)} out${u.cacheRead ? ` · ${tok(u.cacheRead)} cached` : ''}` : '');
|
|
124
|
+
|
|
125
|
+
/** Escapes text and highlights the path search term. */
|
|
126
|
+
function hl(text) {
|
|
127
|
+
const s = esc(text);
|
|
128
|
+
const q = S.pf.q.trim();
|
|
129
|
+
if (!q) return s;
|
|
130
|
+
const re = new RegExp(esc(q).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
|
|
131
|
+
return s.replace(re, (m) => `<mark>${m}</mark>`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Safe markdown (GitHub flavour, the parts agents write): fenced code with highlighting,
|
|
136
|
+
* headings, tables, nested and task lists, quotes, rules, and inline code, links, bold,
|
|
137
|
+
* italic and strikethrough. Everything is escaped first; only these tags come out.
|
|
138
|
+
*/
|
|
139
|
+
function mdInline(t) {
|
|
140
|
+
const keep = [];
|
|
141
|
+
const stash = (html) => `\u0000${keep.push(html) - 1}\u0000`;
|
|
142
|
+
let s = String(t)
|
|
143
|
+
// code spans first: nothing inside them is markdown
|
|
144
|
+
.replace(/(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/g, (_, __, c) => stash(`<code>${esc(c.trim() || c)}</code>`))
|
|
145
|
+
// backslash escapes
|
|
146
|
+
.replace(/\\([\\`*_{}\[\]()#+\-.!|~>])/g, (_, c) => stash(esc(c)));
|
|
147
|
+
const url = (u) => (/^(https?:|mailto:)/i.test(u) ? u : null);
|
|
148
|
+
s = s
|
|
149
|
+
.replace(/!?\[([^\]]+)\]\(\s*<?([^\s)>]+)>?(?:\s+"[^"]*")?\s*\)/g, (m, text, href) => {
|
|
150
|
+
const u = url(href);
|
|
151
|
+
return u ? stash(`<a href="${esc(u)}" target="_blank" rel="noopener noreferrer">${mdInline(text)}</a>`) : m;
|
|
152
|
+
})
|
|
153
|
+
.replace(/<(https?:\/\/[^\s>]+)>/g, (_, u) => stash(`<a href="${esc(u)}" target="_blank" rel="noopener noreferrer">${esc(u)}</a>`))
|
|
154
|
+
.replace(/(^|[\s(])(https?:\/\/[^\s<>()]*[^\s<>().,;:!?'"])/g, (_, pre, u) => pre + stash(`<a href="${esc(u)}" target="_blank" rel="noopener noreferrer">${esc(u)}</a>`));
|
|
155
|
+
s = esc(s)
|
|
156
|
+
.replace(/\*\*\*(?=\S)([\s\S]*?\S)\*\*\*/g, '<b><i>$1</i></b>')
|
|
157
|
+
.replace(/\*\*(?=\S)([\s\S]*?\S)\*\*/g, '<b>$1</b>')
|
|
158
|
+
.replace(/(^|[^\w])__(?=\S)([\s\S]*?\S)__(?!\w)/g, '$1<b>$2</b>')
|
|
159
|
+
.replace(/(^|[^*\w])\*(?=[^\s*])([^*]*?[^\s*])\*(?!\*)/g, '$1<i>$2</i>')
|
|
160
|
+
.replace(/(^|[^\w])_(?=[^\s_])([^_]*?[^\s_])_(?!\w)/g, '$1<i>$2</i>')
|
|
161
|
+
.replace(/~~(?=\S)([\s\S]*?\S)~~/g, '<s>$1</s>');
|
|
162
|
+
return s.replace(/\u0000(\d+)\u0000/g, (_, i) => keep[Number(i)]);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const MD_LANG = { py: 'python', python: 'python', python3: 'python', sh: 'shell', bash: 'shell', shell: 'shell', zsh: 'shell', console: 'shell', shellsession: 'shell', powershell: 'shell', ps1: 'shell', pwsh: 'shell' };
|
|
166
|
+
|
|
167
|
+
/** Cells of a table row: outer pipes dropped, `\|` kept as a literal pipe. */
|
|
168
|
+
function mdCells(line) {
|
|
169
|
+
let t = line.trim();
|
|
170
|
+
if (t.startsWith('|')) t = t.slice(1);
|
|
171
|
+
if (t.endsWith('|') && !t.endsWith('\\|')) t = t.slice(0, -1);
|
|
172
|
+
const out = [];
|
|
173
|
+
let cur = '';
|
|
174
|
+
let tick = false;
|
|
175
|
+
for (let i = 0; i < t.length; i++) {
|
|
176
|
+
const c = t[i];
|
|
177
|
+
if (c === '\\' && t[i + 1] === '|') {
|
|
178
|
+
cur += '\\|';
|
|
179
|
+
i++;
|
|
180
|
+
} else if (c === '`') {
|
|
181
|
+
tick = !tick;
|
|
182
|
+
cur += c;
|
|
183
|
+
} else if (c === '|' && !tick) {
|
|
184
|
+
out.push(cur.trim());
|
|
185
|
+
cur = '';
|
|
186
|
+
} else cur += c;
|
|
187
|
+
}
|
|
188
|
+
out.push(cur.trim());
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const MD_TABLE_SEP = /^\s*\|?\s*:?-{1,}:?\s*(\|\s*:?-{1,}:?\s*)*\|?\s*$/;
|
|
193
|
+
const MD_ITEM = /^(\s*)([-*+]|\d{1,9}[.)])\s+(.*)$/;
|
|
194
|
+
const MD_FENCE = /^\s*(`{3,}|~{3,})\s*([\w+#.-]*)/;
|
|
195
|
+
|
|
196
|
+
function md(src) {
|
|
197
|
+
const lines = String(src ?? '').replace(/\r\n?/g, '\n').split('\n');
|
|
198
|
+
const out = [];
|
|
199
|
+
let para = [];
|
|
200
|
+
const flush = () => {
|
|
201
|
+
if (para.length) out.push(`<p>${para.map((l) => mdInline(l.trim())).join('<br>')}</p>`);
|
|
202
|
+
para = [];
|
|
203
|
+
};
|
|
204
|
+
let i = 0;
|
|
205
|
+
while (i < lines.length) {
|
|
206
|
+
const line = lines[i];
|
|
207
|
+
const fence = MD_FENCE.exec(line);
|
|
208
|
+
if (fence) {
|
|
209
|
+
flush();
|
|
210
|
+
const close = new RegExp(`^\\s*${fence[1][0] === '`' ? '`' : '~'}{${fence[1].length},}\\s*$`);
|
|
211
|
+
const body = [];
|
|
212
|
+
i++;
|
|
213
|
+
while (i < lines.length && !close.test(lines[i])) body.push(lines[i++]);
|
|
214
|
+
i++;
|
|
215
|
+
const lang = MD_LANG[fence[2].toLowerCase()] ?? null;
|
|
216
|
+
out.push(`<pre${lang ? ` class="lang-${lang}"` : ''}>${highlight(body.join('\n'), lang)}</pre>`);
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
if (!line.trim()) {
|
|
220
|
+
flush();
|
|
221
|
+
i++;
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
const h = /^\s{0,3}(#{1,6})\s+(.*?)\s*#*\s*$/.exec(line);
|
|
225
|
+
if (h) {
|
|
226
|
+
flush();
|
|
227
|
+
out.push(`<h${Math.min(6, h[1].length + 2)} class="md-h${h[1].length}">${mdInline(h[2])}</h${Math.min(6, h[1].length + 2)}>`);
|
|
228
|
+
i++;
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
if (/^\s{0,3}([-*_])(\s*\1){2,}\s*$/.test(line)) {
|
|
232
|
+
flush();
|
|
233
|
+
out.push('<hr>');
|
|
234
|
+
i++;
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
if (line.includes('|') && i + 1 < lines.length && MD_TABLE_SEP.test(lines[i + 1]) && lines[i + 1].includes('-') && lines[i + 1].includes('|')) {
|
|
238
|
+
flush();
|
|
239
|
+
const head = mdCells(line);
|
|
240
|
+
const align = mdCells(lines[i + 1]).map((c) => (/^:-+:$/.test(c) ? 'center' : /-+:$/.test(c) ? 'right' : /^:-+/.test(c) ? 'left' : ''));
|
|
241
|
+
const cell = (tag, c, k) => `<${tag}${align[k] ? ` style="text-align:${align[k]}"` : ''}>${mdInline(c)}</${tag}>`;
|
|
242
|
+
const rows = [];
|
|
243
|
+
i += 2;
|
|
244
|
+
while (i < lines.length && lines[i].includes('|') && lines[i].trim()) {
|
|
245
|
+
const cells = mdCells(lines[i++]);
|
|
246
|
+
rows.push(`<tr>${head.map((_, k) => cell('td', cells[k] ?? '', k)).join('')}</tr>`);
|
|
247
|
+
}
|
|
248
|
+
out.push(`<div class="md-table"><table><thead><tr>${head.map((c, k) => cell('th', c, k)).join('')}</tr></thead><tbody>${rows.join('')}</tbody></table></div>`);
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
if (/^\s{0,3}>/.test(line)) {
|
|
252
|
+
flush();
|
|
253
|
+
const body = [];
|
|
254
|
+
while (i < lines.length && /^\s{0,3}>/.test(lines[i])) body.push(lines[i++].replace(/^\s{0,3}>\s?/, ''));
|
|
255
|
+
out.push(`<blockquote>${md(body.join('\n'))}</blockquote>`);
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
if (MD_ITEM.test(line)) {
|
|
259
|
+
flush();
|
|
260
|
+
// The whole list block: items, their wrapped lines, and blank lines between items.
|
|
261
|
+
const items = [];
|
|
262
|
+
while (i < lines.length) {
|
|
263
|
+
const l = lines[i];
|
|
264
|
+
const m = MD_ITEM.exec(l);
|
|
265
|
+
if (m && !MD_TABLE_SEP.test(l)) {
|
|
266
|
+
const num = /^\d/.test(m[2]);
|
|
267
|
+
const task = /^\[([ xX])\]\s+(.*)$/.exec(m[3]);
|
|
268
|
+
items.push({ ind: m[1].replace(/\t/g, ' ').length, tag: num ? 'ol' : 'ul', start: num ? parseInt(m[2], 10) : 1, text: task ? task[2] : m[3], task: task ? task[1] !== ' ' : null });
|
|
269
|
+
i++;
|
|
270
|
+
} else if (l.trim() && /^\s+/.test(l) && items.length && !MD_FENCE.test(l)) {
|
|
271
|
+
items[items.length - 1].text += ' ' + l.trim();
|
|
272
|
+
i++;
|
|
273
|
+
} else if (!l.trim() && i + 1 < lines.length && (MD_ITEM.test(lines[i + 1]) || /^\s{2,}\S/.test(lines[i + 1]))) {
|
|
274
|
+
i++;
|
|
275
|
+
} else break;
|
|
276
|
+
}
|
|
277
|
+
out.push(mdList(items));
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
para.push(line);
|
|
281
|
+
i++;
|
|
282
|
+
}
|
|
283
|
+
flush();
|
|
284
|
+
return out.join('');
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Nested lists from items with their indentation: deeper items go inside the previous one. */
|
|
288
|
+
function mdList(items) {
|
|
289
|
+
const root = { ind: -1, lists: [] };
|
|
290
|
+
const stack = [];
|
|
291
|
+
for (const it of items) {
|
|
292
|
+
while (stack.length && stack[stack.length - 1].ind > it.ind) stack.pop();
|
|
293
|
+
let top = stack[stack.length - 1];
|
|
294
|
+
if (top && top.ind === it.ind && top.tag !== it.tag) {
|
|
295
|
+
stack.pop();
|
|
296
|
+
top = stack[stack.length - 1];
|
|
297
|
+
}
|
|
298
|
+
if (!top || top.ind < it.ind) {
|
|
299
|
+
const list = { ind: it.ind, tag: it.tag, start: it.start, items: [] };
|
|
300
|
+
const parent = top ? top.items[top.items.length - 1] : root;
|
|
301
|
+
(parent.lists ??= []).push(list);
|
|
302
|
+
stack.push(list);
|
|
303
|
+
top = list;
|
|
304
|
+
}
|
|
305
|
+
top.items.push({ ...it, lists: [] });
|
|
306
|
+
}
|
|
307
|
+
const render = (list) =>
|
|
308
|
+
`<${list.tag}${list.tag === 'ol' && list.start !== 1 ? ` start="${list.start}"` : ''}${list.items.some((x) => x.task !== null) ? ' class="tasks"' : ''}>${list.items
|
|
309
|
+
.map((x) => `<li${x.task !== null ? ' class="task"' : ''}>${x.task !== null ? `<span class="tick${x.task ? ' on' : ''}" aria-label="${x.task ? 'done' : 'to do'}"></span>` : ''}${mdInline(x.text)}${x.lists.map(render).join('')}</li>`)
|
|
310
|
+
.join('')}</${list.tag}>`;
|
|
311
|
+
return root.lists.map(render).join('');
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ───────────────────────────── token heat
|
|
315
|
+
/** Tokens a unit used, the way the meter counts them: fresh input, cache writes and output. */
|
|
316
|
+
const unitTokens = (n) => (n?.usage ? n.usage.input + n.usage.cacheWrite + n.usage.output : 0);
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Each task and step's place on a green-to-red scale, relative to the others of its kind in this
|
|
320
|
+
* run. Log scale: token use is lopsided, and on a linear one a single huge step would leave
|
|
321
|
+
* everything else green. Tasks and steps are ranked apart (a task's total includes its steps).
|
|
322
|
+
*/
|
|
323
|
+
function computeHeat() {
|
|
324
|
+
const heat = new Map();
|
|
325
|
+
if (!S.heat || !S.run) return heat;
|
|
326
|
+
const goals = S.run.goals.map(N).filter(Boolean);
|
|
327
|
+
const rank = (nodes) => {
|
|
328
|
+
const xs = nodes.map((n) => [n, unitTokens(n)]).filter(([, t]) => t > 0);
|
|
329
|
+
if (!xs.length) return;
|
|
330
|
+
const logs = xs.map(([, t]) => Math.log(t)).sort((a, b) => a - b);
|
|
331
|
+
const lo = logs[Math.floor(logs.length * 0.05)];
|
|
332
|
+
const hi = logs[logs.length - 1];
|
|
333
|
+
for (const [n, t] of xs) heat.set(n.id, { v: hi - lo < 1e-6 ? 0.5 : Math.max(0, Math.min(1, (Math.log(t) - lo) / (hi - lo))), t });
|
|
334
|
+
};
|
|
335
|
+
rank(goals); // questions too: they are ranked with the tasks
|
|
336
|
+
rank(goals.flatMap((g) => kids(g).filter((c) => c.type === 'step')));
|
|
337
|
+
return heat;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Inline style for a glowing element: its colour from green (few tokens) to red (most). */
|
|
341
|
+
function heatStyle(id) {
|
|
342
|
+
const h = S.heatMap?.get(id);
|
|
343
|
+
if (!h) return '';
|
|
344
|
+
const hue = Math.round(120 * (1 - h.v));
|
|
345
|
+
return ` style="--heat:hsl(${hue} 100% 50%);--heat-a:${(0.5 + 0.45 * h.v).toFixed(2)}"`;
|
|
346
|
+
}
|
|
347
|
+
const heatTip = (id) => {
|
|
348
|
+
const h = S.heatMap?.get(id);
|
|
349
|
+
const u = N(id)?.usage;
|
|
350
|
+
return h && u ? `\n${tok(h.t)} tokens (${tok(freshIn(u))} in · ${tok(u.output)} out)${u.cacheRead ? ` · ${tok(u.cacheRead)} cached` : ''}` : '';
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
// ───────────────────────────── derived info
|
|
354
|
+
const idNum = (id) => Number(String(id).slice(1));
|
|
355
|
+
|
|
356
|
+
function latestActivity(id) {
|
|
357
|
+
let best = null;
|
|
358
|
+
const visit = (n) => {
|
|
359
|
+
for (const c of kids(n)) {
|
|
360
|
+
if (isAct(c) && (!best || idNum(c.id) > idNum(best.id))) best = c;
|
|
361
|
+
if (c.type === 'subtask' || c.type === 'step') visit(c);
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
visit(N(id));
|
|
365
|
+
return best;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function descendants(n, f, acc = []) {
|
|
369
|
+
for (const c of kids(n)) {
|
|
370
|
+
if (f(c)) acc.push(c);
|
|
371
|
+
if (c.type === 'subtask') descendants(c, f, acc);
|
|
372
|
+
}
|
|
373
|
+
return acc;
|
|
374
|
+
}
|
|
375
|
+
const openBlockers = (n) => descendants(n, (c) => c.type === 'blocker' && c.status === 'open');
|
|
376
|
+
/** Failed actions nothing has succeeded after yet (in their step or subtask). */
|
|
377
|
+
const unresolvedOf = (n) => Math.max(0, (n?.stats?.errors ?? 0) - (n?.stats?.recovered ?? 0));
|
|
378
|
+
/** Needs a look: an open blocker, or failures not recovered. */
|
|
379
|
+
const hasOpenIssue = (s) => openBlockers(s).length > 0 || unresolvedOf(s) > 0;
|
|
380
|
+
/** Had trouble along the way, all of it got past. */
|
|
381
|
+
const hasRecovered = (s) => !hasOpenIssue(s) && ((s.stats?.recovered ?? 0) > 0 || descendants(s, (c) => c.type === 'blocker').length > 0);
|
|
382
|
+
const isUnresolved = (r) => r.status === 'failed' && !r.recovered;
|
|
383
|
+
const hasAgent = (s) => descendants(s, (c) => c.type === 'subtask' && c.origin === 'subagent').length > 0;
|
|
384
|
+
/** A question answered without doing anything. A task still in progress isn't one yet: it may be about to act. */
|
|
385
|
+
const isQA = (g) => g.status !== 'active' && !kids(g).some((c) => c.type === 'step') && !(g.stats?.actions > 0);
|
|
386
|
+
|
|
387
|
+
const isStale = () => !!S.run?.updatedAt && Date.now() - Date.parse(S.run.updatedAt) > 5 * 60 * 1000;
|
|
388
|
+
const liveEnd = (n) => n.endedAt ?? (isStale() || n.status !== 'active' ? S.run.updatedAt : undefined);
|
|
389
|
+
|
|
390
|
+
function runState() {
|
|
391
|
+
const r = S.run;
|
|
392
|
+
if (!r) return ['idle', 'No run'];
|
|
393
|
+
const stale = isStale();
|
|
394
|
+
if (r.status === 'working' && r.currentStep && N(r.currentStep) && openBlockers(N(r.currentStep)).length) return ['blocked', 'Blocked'];
|
|
395
|
+
if (r.status === 'working') return stale ? ['idle', `Quiet · ${ago(r.updatedAt)}`] : ['working', 'Working'];
|
|
396
|
+
// The agent finished its turn: nothing is running until the next message.
|
|
397
|
+
if (r.status === 'waiting') return ['idle', `Idle · ${ago(r.updatedAt)}`];
|
|
398
|
+
return ['idle', 'Idle'];
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function followTarget() {
|
|
402
|
+
const r = S.run;
|
|
403
|
+
if (!r) return null;
|
|
404
|
+
if (S.view === 'actions') {
|
|
405
|
+
const rows = filteredActions();
|
|
406
|
+
if (rows.length) return rows[rows.length - 1].id;
|
|
407
|
+
}
|
|
408
|
+
return r.currentUnit ?? r.currentStep ?? r.currentGoal ?? r.goals[r.goals.length - 1] ?? null;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function ancestors(id) {
|
|
412
|
+
const out = [];
|
|
413
|
+
let n = N(id);
|
|
414
|
+
while (n) {
|
|
415
|
+
out.push(n);
|
|
416
|
+
n = n.parent ? N(n.parent) : null;
|
|
417
|
+
}
|
|
418
|
+
return out; // self first, goal last
|
|
419
|
+
}
|
|
420
|
+
const goalOf = (id) => ancestors(id).pop();
|
|
421
|
+
|
|
422
|
+
// ───────────────────────────── path: filters and open state
|
|
423
|
+
function pathQuery() {
|
|
424
|
+
return S.pf.q.trim().toLowerCase();
|
|
425
|
+
}
|
|
426
|
+
const filtering = () => S.pf.mode !== 'all' || !!pathQuery();
|
|
427
|
+
|
|
428
|
+
function stepText(s) {
|
|
429
|
+
return [
|
|
430
|
+
s.title,
|
|
431
|
+
...(s.context?.files ?? []),
|
|
432
|
+
...(s.context?.commands ?? []),
|
|
433
|
+
...descendants(s, (c) => c.type !== 'activity' || c.kind === 'user').map((c) => c.title),
|
|
434
|
+
...kids(s).filter(isAct).map((c) => c.title),
|
|
435
|
+
]
|
|
436
|
+
.join('\n')
|
|
437
|
+
.toLowerCase();
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function stepMatches(s, q) {
|
|
441
|
+
if (S.pf.mode === 'open' && !hasOpenIssue(s)) return false;
|
|
442
|
+
if (S.pf.mode === 'recovered' && !hasRecovered(s)) return false;
|
|
443
|
+
if (S.pf.mode === 'agents' && !hasAgent(s)) return false;
|
|
444
|
+
return !q || stepText(s).includes(q);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function isGoalOpen(g) {
|
|
448
|
+
if (S.openGoals.has(g.id)) return true;
|
|
449
|
+
if (S.closedGoals.has(g.id)) return false;
|
|
450
|
+
if (S.expandAll || filtering()) return true;
|
|
451
|
+
return g.id === S.run.currentGoal || S.run.goals.length === 1;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function isStepOpen(s) {
|
|
455
|
+
if (S.openSteps.has(s.id)) return true;
|
|
456
|
+
if (S.closedSteps.has(s.id)) return false;
|
|
457
|
+
if (S.expandAll) return true;
|
|
458
|
+
const q = pathQuery();
|
|
459
|
+
if (q && !s.title.toLowerCase().includes(q) && stepText(s).includes(q)) return true; // the match is inside
|
|
460
|
+
return s.status === 'active' && s.id === S.run.currentStep;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// ───────────────────────────── overview
|
|
464
|
+
function renderOverview() {
|
|
465
|
+
const goals = S.run.goals.map(N).filter(Boolean);
|
|
466
|
+
const work = goals.filter((g) => !isQA(g));
|
|
467
|
+
let steps = 0;
|
|
468
|
+
let done = 0;
|
|
469
|
+
for (const g of goals)
|
|
470
|
+
for (const s of kids(g))
|
|
471
|
+
if (s.type === 'step') {
|
|
472
|
+
steps++;
|
|
473
|
+
if (s.status === 'done') done++;
|
|
474
|
+
}
|
|
475
|
+
const rows = S.actions ? liveActions() : null;
|
|
476
|
+
const nAct = rows ? rows.length : goals.reduce((a, g) => a + (g.stats?.actions ?? 0), 0);
|
|
477
|
+
const nErr = rows ? rows.filter((r) => r.status === 'failed').length : goals.reduce((a, g) => a + (g.stats?.errors ?? 0), 0);
|
|
478
|
+
const nUn = rows ? rows.filter(isUnresolved).length : goals.reduce((a, g) => a + unresolvedOf(g), 0);
|
|
479
|
+
const qa = goals.length - work.length;
|
|
480
|
+
const first = goals[0]?.startedAt;
|
|
481
|
+
const stat = (n, label, cls = '') => `<div class="stat ${cls}"><b>${n}</b><span>${label}</span></div>`;
|
|
482
|
+
const span = first ? `<div class="stat span">${esc(clock(first))} → ${S.run.status === 'working' && !isStale() ? 'now' : esc(clock(S.run.updatedAt))} · ${esc(dur(first, isStale() || S.run.status !== 'working' ? S.run.updatedAt : undefined))}</div>` : '';
|
|
483
|
+
const working = S.run.status === 'working' && !isStale();
|
|
484
|
+
setHTML($('#overview'), `<div class="ov${working ? ' working' : ''}">
|
|
485
|
+
<div class="ov-stats">
|
|
486
|
+
${stat(work.length, work.length === 1 ? 'task' : 'tasks')}
|
|
487
|
+
${qa ? stat(qa, qa === 1 ? 'question' : 'questions') : ''}
|
|
488
|
+
${steps ? stat(`${done}/${steps}`, 'steps done') : ''}
|
|
489
|
+
${stat(nAct, nAct === 1 ? 'action' : 'actions')}
|
|
490
|
+
${nErr ? `<div class="stat${nUn ? ' bad' : ' calm'}" title="Failed: nothing has succeeded after them in their step yet. Recovered: something worked afterwards."><b>${nUn}</b><span>failed</span><small>${nErr - nUn} of ${nErr} failures recovered</small></div>` : stat(0, 'errors')}
|
|
491
|
+
${tokStat()}
|
|
492
|
+
${span}
|
|
493
|
+
</div>
|
|
494
|
+
<button type="button" class="ibtn pin${S.pinOv ? ' on' : ''}" data-pin title="${S.pinOv ? 'Unpin the timeline: let it scroll away (P)' : 'Pin the timeline: keep it on screen while scrolling (P)'}" aria-pressed="${S.pinOv}">${SVG('M9 4h6l-1 5 3 3v2H7v-2l3-3z M12 14v6')}</button>
|
|
495
|
+
${rows && rows.length ? stripHtml(rows) : ''}
|
|
496
|
+
${laneHtml()}
|
|
497
|
+
</div>`);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** Writes HTML only when it changed: untouched parts keep their elements, hover and animations. */
|
|
501
|
+
function setHTML(el, html) {
|
|
502
|
+
if (el.__html === html) return false;
|
|
503
|
+
el.innerHTML = html;
|
|
504
|
+
el.__html = html;
|
|
505
|
+
return true;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Pins every looping animation (glows, pulses, spinners) to the page clock, so an element
|
|
510
|
+
* rebuilt by a live update continues the same beat instead of restarting from zero.
|
|
511
|
+
*/
|
|
512
|
+
function syncAnimations() {
|
|
513
|
+
if (!document.getAnimations) return;
|
|
514
|
+
for (const a of document.getAnimations()) {
|
|
515
|
+
if (a.startTime !== 0 && a.effect?.getTiming?.().iterations === Infinity) a.startTime = 0;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/** Tokens in the overview row too, where the eye already is (the top-bar meter is easy to miss). */
|
|
520
|
+
function tokStat() {
|
|
521
|
+
const u = S.run?.usage;
|
|
522
|
+
if (!u) return '';
|
|
523
|
+
const rate = liveRate(u);
|
|
524
|
+
return `<button class="stat tokstat" data-tokens title="Token usage: fresh input + output (cache reads not counted). Click for the breakdown (T)"><b>${tok(freshIn(u) + u.output)}</b><span>tokens</span><small>${tok(freshIn(u))} in · ${tok(u.output)} out${rate ? ` · ${tok(rate)}/min` : ''}</small></button>`;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// Tell the user when the server runs older engine code than the page it serves.
|
|
528
|
+
async function checkHealth() {
|
|
529
|
+
try {
|
|
530
|
+
const res = await fetch('/api/health');
|
|
531
|
+
// A server without this route predates the page: it is outdated too.
|
|
532
|
+
if (res.status === 404) $('#outdated').hidden = false;
|
|
533
|
+
else if (res.ok) $('#outdated').hidden = !(await res.json()).outdated;
|
|
534
|
+
} catch {
|
|
535
|
+
// Server unreachable: the status pill already says so.
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
setInterval(checkHealth, 30000);
|
|
539
|
+
addEventListener('load', checkHealth);
|
|
540
|
+
|
|
541
|
+
// ───────────────────────────── file edits: every change to every file, at a glance
|
|
542
|
+
/** Edits and writes grouped by file, with their totals. */
|
|
543
|
+
function fileEdits() {
|
|
544
|
+
const map = new Map();
|
|
545
|
+
for (const r of liveActions()) {
|
|
546
|
+
if ((r.kind !== 'edit' && r.kind !== 'write') || !r.files?.length) continue;
|
|
547
|
+
const key = relPath(r.files[0]);
|
|
548
|
+
const id = key.toLowerCase();
|
|
549
|
+
let e = map.get(id);
|
|
550
|
+
if (!e) map.set(id, (e = { key, path: r.files[0], inside: !isAbsPath(key), edits: [], add: 0, del: 0, failed: 0, last: '' }));
|
|
551
|
+
e.edits.push(r);
|
|
552
|
+
if (r.status === 'failed') e.failed++;
|
|
553
|
+
else if (r.diff) {
|
|
554
|
+
e.add += r.diff.add;
|
|
555
|
+
e.del += r.diff.del;
|
|
556
|
+
}
|
|
557
|
+
if ((r.startedAt ?? '') > e.last) e.last = r.startedAt ?? '';
|
|
558
|
+
}
|
|
559
|
+
return [...map.values()];
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const isAbsPath = (p) => /^(?:[a-z]:)?[\\/]/i.test(p);
|
|
563
|
+
/** Long paths outside the project: keep the last two segments, the full path goes in the tooltip. */
|
|
564
|
+
const shortPath = (p) => {
|
|
565
|
+
const parts = p.replace(/\\/g, '/').split('/').filter(Boolean);
|
|
566
|
+
return parts.length > 3 ? `…/${parts.slice(-2).join('/')}` : p;
|
|
567
|
+
};
|
|
568
|
+
|
|
569
|
+
/** Where in the run a file's edits happened: one tick per edit along the run. */
|
|
570
|
+
function fileSpark(f, index, total) {
|
|
571
|
+
const ticks = f.edits
|
|
572
|
+
.map((r) => {
|
|
573
|
+
const i = index.get(r.id);
|
|
574
|
+
if (i === undefined) return '';
|
|
575
|
+
const x = total > 1 ? (100 * i) / (total - 1) : 50;
|
|
576
|
+
return `<rect x="${Math.min(99, x).toFixed(2)}" y="1" width="1" height="8" class="${r.status === 'failed' ? 'k-bad' : r.kind === 'write' ? 'k-w' : 'k-ok'}"/>`;
|
|
577
|
+
})
|
|
578
|
+
.join('');
|
|
579
|
+
return `<svg class="fspark" viewBox="0 0 100 10" preserveAspectRatio="none" aria-hidden="true"><rect x="0" y="4.5" width="100" height="1" class="k-base"/>${ticks}</svg>`;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function editDiff(r) {
|
|
583
|
+
const chunks = rawFor(r.id);
|
|
584
|
+
if (!chunks) return '<div class="loading">Loading the change…</div>';
|
|
585
|
+
const c = chunks.find((x) => x.kind === 'tool' && x.node === r.id);
|
|
586
|
+
const res = chunks.find((x) => x.kind === 'result' && x.node === r.id);
|
|
587
|
+
return c ? inputHtml(c, N(r.id) ?? r, res) : '<div class="loading">No change recorded.</div>';
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/** Every edit in time order, oldest first: the same file shows once per change. */
|
|
591
|
+
function historyHtml(files, ql) {
|
|
592
|
+
const inside = files.filter((f) => f.inside);
|
|
593
|
+
const outside = files.filter((f) => !f.inside);
|
|
594
|
+
const showOut = S.ff.outside || (ql && !inside.length);
|
|
595
|
+
const pick = showOut ? files : inside.length ? inside : files;
|
|
596
|
+
const byId = new Map();
|
|
597
|
+
for (const f of pick) for (const r of f.edits) byId.set(r.id, f);
|
|
598
|
+
const edits = [...byId.keys()]
|
|
599
|
+
.map((id) => ({ r: byId.get(id).edits.find((x) => x.id === id), f: byId.get(id) }))
|
|
600
|
+
.sort((x, y) => ((x.r.startedAt ?? '') < (y.r.startedAt ?? '') ? -1 : (x.r.startedAt ?? '') > (y.r.startedAt ?? '') ? 1 : 0));
|
|
601
|
+
if (!edits.length) return '<div class="no-match">No file or edit matches.</div>';
|
|
602
|
+
let goal = null;
|
|
603
|
+
const out = [];
|
|
604
|
+
for (const { r, f } of edits) {
|
|
605
|
+
if (r.goalId !== goal) {
|
|
606
|
+
goal = r.goalId;
|
|
607
|
+
out.push(`<div class="fh-goal"><span>${esc(clip(r.goal ?? 'Task', 110))}</span><span class="fago">${esc(clock(r.startedAt))}</span></div>`);
|
|
608
|
+
}
|
|
609
|
+
const shown = f.inside ? f.key : shortPath(f.key);
|
|
610
|
+
const slash = shown.lastIndexOf('/');
|
|
611
|
+
const dir = slash >= 0 ? shown.slice(0, slash + 1) : '';
|
|
612
|
+
const base = shown.slice(slash + 1);
|
|
613
|
+
const sel = S.selected === r.id ? ' sel' : '';
|
|
614
|
+
out.push(`<div class="fe fe-hist${sel} s-${r.status}" data-fe="${r.id}" title="${esc(f.path)}">
|
|
615
|
+
<span class="fe-t">${esc(clock(r.startedAt))}</span>
|
|
616
|
+
<span class="fe-k">${r.kind === 'write' ? 'Write' : 'Edit'}</span>
|
|
617
|
+
<span class="fe-m"><span class="fpath"><span class="fdir">${hl(dir)}</span><b>${hl(base)}</b></span>${r.subagent ? `<span class="tag agent">subagent</span>` : ''}${failBadge(r)}<span class="fe-where">${esc(clip(r.step ?? r.title, 80))}</span></span>
|
|
618
|
+
<span class="fe-d">${diffStat(r)}</span>
|
|
619
|
+
<span class="as">${stMark(r)}</span>
|
|
620
|
+
</div>${S.ff.histDiffs ? `<div class="fe-diff">${editDiff(r)}</div>` : ''}`);
|
|
621
|
+
}
|
|
622
|
+
const outNote = outside.length && !showOut && inside.length
|
|
623
|
+
? `<button type="button" class="fgroup" data-foutside><span class="chev">${CHEV}</span>Include files outside the project <span class="n">${plural(outside.reduce((a, f) => a + f.edits.length, 0), 'edit')}</span></button>`
|
|
624
|
+
: outside.length && S.ff.outside
|
|
625
|
+
? `<button type="button" class="fgroup" data-foutside><span class="chev open">${CHEV}</span>Hide files outside the project</button>`
|
|
626
|
+
: '';
|
|
627
|
+
return `<div class="fhist">${out.join('')}</div>${outNote}`;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function filesHtml(q) {
|
|
631
|
+
if (!S.actions) return '<div class="loading">Loading edits…</div>';
|
|
632
|
+
const allRows = S.allRows ?? S.stripRows;
|
|
633
|
+
const index = new Map(allRows.map((r, i) => [r.id, i]));
|
|
634
|
+
const total = allRows.length;
|
|
635
|
+
const ql = q.trim().toLowerCase();
|
|
636
|
+
let files = fileEdits();
|
|
637
|
+
if (!files.length) return '<div class="no-match">No files have been edited in this run yet.</div>';
|
|
638
|
+
if (ql) {
|
|
639
|
+
files = files
|
|
640
|
+
.map((f) => (f.key.toLowerCase().includes(ql) ? f : { ...f, edits: f.edits.filter((r) => [r.title, r.step, r.goal].some((x) => x && x.toLowerCase().includes(ql))) }))
|
|
641
|
+
.filter((f) => f.edits.length);
|
|
642
|
+
}
|
|
643
|
+
const sorters = {
|
|
644
|
+
recent: (a, b) => (a.last < b.last ? 1 : a.last > b.last ? -1 : 0),
|
|
645
|
+
most: (a, b) => b.edits.length - a.edits.length || b.add + b.del - (a.add + a.del),
|
|
646
|
+
path: (a, b) => a.key.localeCompare(b.key),
|
|
647
|
+
};
|
|
648
|
+
files.sort(sorters[S.ff.sort] ?? sorters.recent);
|
|
649
|
+
const proj = files.some((f) => f.inside) ? files.filter((f) => f.inside) : files;
|
|
650
|
+
const E = proj.reduce((a, f) => a + f.edits.length, 0);
|
|
651
|
+
const A = proj.reduce((a, f) => a + f.add, 0);
|
|
652
|
+
const D = proj.reduce((a, f) => a + f.del, 0);
|
|
653
|
+
const sorts = [['recent', 'Recent'], ['most', 'Most edited'], ['path', 'Path']]
|
|
654
|
+
.map(([k, l]) => `<button type="button" data-fsort="${k}" class="${S.ff.sort === k ? 'on' : ''}">${l}</button>`)
|
|
655
|
+
.join('');
|
|
656
|
+
const layouts = [['files', 'By file'], ['history', 'History']]
|
|
657
|
+
.map(([k, l]) => `<button type="button" data-flayout="${k}" class="${S.ff.layout === k ? 'on' : ''}">${l}</button>`)
|
|
658
|
+
.join('');
|
|
659
|
+
const hist = S.ff.layout === 'history';
|
|
660
|
+
const right = hist
|
|
661
|
+
? `<button type="button" class="linkbtn" data-fhdiffs>${S.ff.histDiffs ? 'Hide diffs' : 'Show all diffs'}</button>`
|
|
662
|
+
: `<span class="fsum-l">Sort</span><nav class="seg fsort">${sorts}</nav>`;
|
|
663
|
+
const head = `<div class="fsum"><nav class="seg flayout">${layouts}</nav><span><b>${proj.length}</b> ${proj.length === 1 ? 'file' : 'files'}${proj !== files ? ' in the project' : ''} · <b>${E}</b> ${E === 1 ? 'edit' : 'edits'}</span>${diffBadge(D, A)}<span class="grow"></span>${right}</div>`;
|
|
664
|
+
if (hist) return head + historyHtml(files, ql);
|
|
665
|
+
const card = (f) => {
|
|
666
|
+
const open = S.ff.open.has(f.key) || (ql && files.length <= 3);
|
|
667
|
+
const shown = f.inside ? f.key : shortPath(f.key);
|
|
668
|
+
const slash = shown.lastIndexOf('/');
|
|
669
|
+
const dir = slash >= 0 ? shown.slice(0, slash + 1) : '';
|
|
670
|
+
const base = shown.slice(slash + 1);
|
|
671
|
+
const created = f.edits[0].kind === 'write';
|
|
672
|
+
const inline = S.ff.diffs.has(f.key);
|
|
673
|
+
const head = `<div class="fh${open ? ' open' : ''}" data-file="${esc(f.key)}" title="${esc(f.path)}">
|
|
674
|
+
<span class="chev">${CHEV}</span>
|
|
675
|
+
<span class="fpath"><span class="fdir">${hl(dir)}</span><b>${hl(base)}</b></span>
|
|
676
|
+
${created ? '<span class="ftag" title="The first change was a full write: probably created in this run">written</span>' : ''}
|
|
677
|
+
${f.failed ? `<span class="ftag bad" title="${plural(f.failed, 'edit')} failed to apply">${f.failed} failed</span>` : ''}
|
|
678
|
+
<span class="grow"></span>
|
|
679
|
+
${fileSpark(f, index, total)}
|
|
680
|
+
<span class="fcount">${plural(f.edits.length, 'edit')}</span>
|
|
681
|
+
<span class="fdiff">${diffBadge(f.del, f.add)}</span>
|
|
682
|
+
<span class="fago">${esc(ago(f.last))}</span>
|
|
683
|
+
</div>`;
|
|
684
|
+
if (!open) return `<div class="fcard">${head}</div>`;
|
|
685
|
+
const rows = f.edits
|
|
686
|
+
.map((r) => {
|
|
687
|
+
const sel = S.selected === r.id ? ' sel' : '';
|
|
688
|
+
const where = [r.goal, r.step].filter(Boolean).map((x) => esc(clip(x, 60))).join(' › ');
|
|
689
|
+
return `<div class="fe${sel} s-${r.status}" data-fe="${r.id}">
|
|
690
|
+
<span class="fe-t">${esc(clock(r.startedAt))}</span>
|
|
691
|
+
<span class="fe-k">${r.kind === 'write' ? 'Write' : 'Edit'}</span>
|
|
692
|
+
<span class="fe-m"><span class="fe-title">${hl(r.title)}</span>${r.subagent ? `<span class="tag agent">subagent</span>` : ''}${failBadge(r)}<span class="fe-where">${where}</span></span>
|
|
693
|
+
<span class="fe-d">${diffStat(r)}</span>
|
|
694
|
+
<span class="as">${stMark(r)}</span>
|
|
695
|
+
</div>${inline ? `<div class="fe-diff">${editDiff(r)}</div>` : ''}`;
|
|
696
|
+
})
|
|
697
|
+
.join('');
|
|
698
|
+
return `<div class="fcard open">${head}
|
|
699
|
+
<div class="fbody">
|
|
700
|
+
<div class="fbar"><span>History, oldest first</span><span class="grow"></span><button type="button" class="linkbtn" data-fdiffs="${esc(f.key)}">${inline ? 'Hide diffs' : 'Show all diffs'}</button></div>
|
|
701
|
+
${rows}
|
|
702
|
+
</div></div>`;
|
|
703
|
+
};
|
|
704
|
+
const inside = files.filter((f) => f.inside);
|
|
705
|
+
const outside = files.filter((f) => !f.inside);
|
|
706
|
+
const showOut = S.ff.outside || (ql && !inside.length);
|
|
707
|
+
const outHtml = outside.length
|
|
708
|
+
? `<button type="button" class="fgroup" data-foutside><span class="chev${showOut ? ' open' : ''}">${CHEV}</span>Outside the project <span class="n">${plural(outside.length, 'file')} · ${plural(outside.reduce((a, f) => a + f.edits.length, 0), 'edit')}</span><span class="fgroup-h">scratch files, notes, configs</span></button>${showOut ? outside.map(card).join('') : ''}`
|
|
709
|
+
: '';
|
|
710
|
+
return head + (inside.map(card).join('') + outHtml || '<div class="no-match">No file or edit matches.</div>');
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// ───────────────────────────── subagent lane: its own timeline, open while it (or anything in it) is selected
|
|
714
|
+
/** The subagent the selection sits in (or is), if any. */
|
|
715
|
+
function openAgent() {
|
|
716
|
+
if (!S.selected) return null;
|
|
717
|
+
return ancestors(S.selected).find((n) => n.type === 'subtask' && n.origin === 'subagent') ?? null;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function laneHtml() {
|
|
721
|
+
const ag = openAgent();
|
|
722
|
+
if (!ag) {
|
|
723
|
+
S.laneRows = [];
|
|
724
|
+
S.laneShown = null;
|
|
725
|
+
return '';
|
|
726
|
+
}
|
|
727
|
+
const acts = descendants(ag, isAct).sort((x, y) => ((x.startedAt ?? '') < (y.startedAt ?? '') ? -1 : 1));
|
|
728
|
+
S.laneRows = acts;
|
|
729
|
+
const n = acts.length;
|
|
730
|
+
const B = Math.min(n, 360);
|
|
731
|
+
const { unitPx: laneUnit, nowW: laneNow, total } = stripScale($('#overview .lane-strip')?.clientWidth || 700, B, ag.status === 'active');
|
|
732
|
+
let rects = '';
|
|
733
|
+
for (let i = 0; i < B; i++) {
|
|
734
|
+
const a = Math.floor((i * n) / B);
|
|
735
|
+
const b = Math.max(a + 1, Math.floor(((i + 1) * n) / B));
|
|
736
|
+
const seg = acts.slice(a, b);
|
|
737
|
+
const bad = seg.some(isUnresolved);
|
|
738
|
+
const rec = !bad && seg.some((r) => r.status === 'failed');
|
|
739
|
+
const live = seg.some((r) => r.status === 'active');
|
|
740
|
+
const sel = seg.some((r) => r.id === S.selected);
|
|
741
|
+
const kind = seg.some((r) => r.kind === 'edit' || r.kind === 'write') ? 'ed' : seg.some((r) => r.kind === 'command') ? 'cmd' : 'etc';
|
|
742
|
+
const cls = sel ? 'k-sel' : bad ? 'k-bad' : live ? 'k-live' : rec ? 'k-rec' : kind === 'ed' ? 'k-ed' : kind === 'cmd' ? 'k-ok' : 'k-rd';
|
|
743
|
+
const h = bad || sel ? 8.5 : live ? 7.5 : rec ? 7 : kind === 'ed' ? 7.5 : kind === 'cmd' ? 5.5 : 3.5;
|
|
744
|
+
const last = seg[seg.length - 1];
|
|
745
|
+
const state = bad ? ' · failed' : rec ? ' · failed, then recovered' : live ? ' · running' : '';
|
|
746
|
+
const tip = seg.length > 1 ? `${seg.length} actions · ${clock(seg[0].startedAt)}–${clock(last.startedAt)}${state}` : `${clock(last.startedAt)} · ${last.title}${state}`;
|
|
747
|
+
rects += barShape(i, h, kind, cls, `data-lb="${b - 1}"`, tip, laneUnit);
|
|
748
|
+
}
|
|
749
|
+
const title = ag.title.replace(/^Subagent:\s*/, '');
|
|
750
|
+
const un = unresolvedOf(ag);
|
|
751
|
+
const rec = ag.stats?.recovered ?? 0;
|
|
752
|
+
const meta = [
|
|
753
|
+
ag.models?.length ? `<span class="model">${esc(modelsLabel(ag))}</span>` : '',
|
|
754
|
+
`<span>${plural(n, 'action')}</span>`,
|
|
755
|
+
ag.startedAt ? `<span>${esc(dur(ag.startedAt, ag.status === 'active' ? undefined : ag.endedAt ?? lastOf(acts)?.endedAt))}</span>` : '',
|
|
756
|
+
ag.usage ? `<span>${esc(usageLine(ag.usage))}</span>` : '',
|
|
757
|
+
un ? `<span class="bad">${un} failed</span>` : '',
|
|
758
|
+
rec ? `<span>${rec} recovered</span>` : '',
|
|
759
|
+
].filter(Boolean).join('<i>·</i>');
|
|
760
|
+
const status = ag.status === 'active' ? '<span class="now-dot"></span>running' : STATUS_LABEL[ag.status] ?? ag.status;
|
|
761
|
+
const nowMark = laneNow ? nowRect(B, laneNow) : '';
|
|
762
|
+
rects += nowMark;
|
|
763
|
+
if (S.laneShown?.id !== ag.id) S.laneShown = { id: ag.id, at: Date.now() };
|
|
764
|
+
const enter = Date.now() - S.laneShown.at < 250 ? ' enter' : '';
|
|
765
|
+
return `<div class="lane${enter}${ag.status === 'active' ? ' working' : ''}" data-lane="${ag.id}">
|
|
766
|
+
<div class="lane-h">
|
|
767
|
+
<span class="lane-tag">Subagent</span>
|
|
768
|
+
<button class="lane-t" data-lane-open="${ag.id}" title="Show this subagent's details">${esc(title)}</button>
|
|
769
|
+
<span class="lane-st s-${ag.status}">${status}</span>
|
|
770
|
+
<span class="grow"></span>
|
|
771
|
+
<button class="ibtn" data-lane-close aria-label="Close the subagent timeline" title="Close">${CLOSE}</button>
|
|
772
|
+
</div>
|
|
773
|
+
<div class="lane-m">${meta}</div>
|
|
774
|
+
${n ? `<svg class="strip lane-strip" viewBox="0 0 ${total} 10" preserveAspectRatio="none" role="img" aria-label="Subagent activity">${rects}</svg>
|
|
775
|
+
<div class="strip-axis"><span>${esc(clock(acts[0].startedAt))}</span><span></span><span>${esc(clock(acts[n - 1].startedAt))}</span></div>` : '<div class="lane-empty">No actions yet</div>'}
|
|
776
|
+
</div>`;
|
|
777
|
+
}
|
|
778
|
+
const lastOf = (a) => a[a.length - 1];
|
|
779
|
+
|
|
780
|
+
// The strip always spans its full width: bars stretch to fill it and sit side by side, with a
|
|
781
|
+
// thin gap between them (at most BAR_GAP_PX, or the usual 28% of a slot on long runs).
|
|
782
|
+
const BAR_GAP_PX = 3;
|
|
783
|
+
/** Width (in slot units) and left edge of a bar filling slot `i`; `frac` is its share on long runs. */
|
|
784
|
+
function slotBar(i, unitPx, frac = 0.72) {
|
|
785
|
+
const w = 1 - Math.min(1 - frac, BAR_GAP_PX / Math.max(1, unitPx));
|
|
786
|
+
return [i + 0.5 - w / 2, w];
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* Slot sizes for a strip of `slots` bars plus an optional live mark. On long runs the mark takes
|
|
791
|
+
* one slot like a bar; on short ones bars stretch, so the mark keeps a slim fixed width instead.
|
|
792
|
+
*/
|
|
793
|
+
function stripScale(px, slots, live) {
|
|
794
|
+
const NOW_PX = 6;
|
|
795
|
+
let unitPx = px / Math.max(1, slots + (live ? 1 : 0));
|
|
796
|
+
let nowW = live ? 1 : 0;
|
|
797
|
+
if (live && slots && unitPx > NOW_PX * 1.5) {
|
|
798
|
+
unitPx = (px - NOW_PX) / slots;
|
|
799
|
+
nowW = NOW_PX / unitPx;
|
|
800
|
+
}
|
|
801
|
+
return { unitPx, nowW, total: slots + nowW };
|
|
802
|
+
}
|
|
803
|
+
const nowRect = (x, w) => `<rect x="${x + w * 0.1}" y="0" width="${w * 0.8}" height="8.5" class="k-now"><title>Working now</title></rect>`;
|
|
804
|
+
|
|
805
|
+
/** A strip bar; file edits get a pointed top (visible once zoomed in enough to be wide). */
|
|
806
|
+
function barShape(i, h, kind, cls, data, tip, unitPx = 8) {
|
|
807
|
+
const [x0, bw] = slotBar(i, unitPx);
|
|
808
|
+
const x1 = x0 + bw;
|
|
809
|
+
let top = 8.5 - h;
|
|
810
|
+
const title = `<title>${esc(tip)}</title>`;
|
|
811
|
+
// The tip follows the bar's real width on screen: a gentle roof when wide, none when a bar
|
|
812
|
+
// is only a couple of pixels (zoomed far out, where spikes would just look like noise).
|
|
813
|
+
const wpx = bw * unitPx;
|
|
814
|
+
// Zoomed far out the colour already sets edits apart: keep them only a little taller.
|
|
815
|
+
if (kind === 'ed' && wpx < 3 && cls.startsWith('k-ed')) {
|
|
816
|
+
h = 6;
|
|
817
|
+
top = 8.5 - h;
|
|
818
|
+
}
|
|
819
|
+
if (kind !== 'ed' || wpx < 3) return `<rect x="${x0}" y="${top}" width="${bw}" height="${h}" class="${cls}" ${data}>${title}</rect>`;
|
|
820
|
+
const cap = Math.min(1.6, h / 3, (Math.min(wpx, 16) * 0.5) / 2.6); // 2.6 px per vertical unit (26px / 10)
|
|
821
|
+
// Wide bars (a short run stretched to fit) keep the same small roof at each corner.
|
|
822
|
+
const run = wpx > 16 ? (8 / wpx) * bw : bw / 2;
|
|
823
|
+
return `<polygon points="${x0},8.5 ${x0},${top + cap} ${x0 + run},${top} ${x1 - run},${top} ${x1},${top + cap} ${x1},8.5" class="${cls}" ${data}>${title}</polygon>`;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/** One thin bar per action (bucketed on long runs): green = done, red = failed, blue = running. */
|
|
827
|
+
function stripHtml(rows) {
|
|
828
|
+
// One axis for everything, in time order: the main track draws the main agent's actions
|
|
829
|
+
// (gaps are where it waited), the layers below draw each subagent across its own actions.
|
|
830
|
+
S.allRows = rows;
|
|
831
|
+
const n = rows.length;
|
|
832
|
+
S.stripRows = rows;
|
|
833
|
+
const [lo, hi] = zoomWindow(n);
|
|
834
|
+
const zoomed = lo > 0 || hi < n;
|
|
835
|
+
const span = hi - lo;
|
|
836
|
+
const B = Math.min(span, 360);
|
|
837
|
+
const stripPx = $('#overview .strip')?.clientWidth || ($('#overview').clientWidth || 800) - 34;
|
|
838
|
+
// The current task's steps that haven't started yet: the work still ahead, in grey.
|
|
839
|
+
const g = N(S.run.currentGoal);
|
|
840
|
+
const pending = hi === n && g ? kids(g).filter((c) => c.type === 'step' && c.status === 'pending') : [];
|
|
841
|
+
const P = Math.min(pending.length, 40);
|
|
842
|
+
// While the agent works, a pulsing blue mark holds the live end of the run.
|
|
843
|
+
const W = hi === n && S.run.status === 'working' && !isStale() ? 1 : 0;
|
|
844
|
+
const { unitPx, nowW, total } = stripScale(stripPx, B + P, !!W);
|
|
845
|
+
let rects = '';
|
|
846
|
+
let lines = '';
|
|
847
|
+
for (let i = 0; i < B; i++) {
|
|
848
|
+
const a = lo + Math.floor((i * span) / B);
|
|
849
|
+
const b = Math.max(a + 1, lo + Math.floor(((i + 1) * span) / B));
|
|
850
|
+
const slice = rows.slice(a, b);
|
|
851
|
+
if (i > 0 && slice.some((r, j) => (j ? slice[j - 1] : rows[a - 1]).goalId !== r.goalId)) lines += `<line x1="${i}" x2="${i}" y1="0" y2="10"/>`;
|
|
852
|
+
const seg = slice.filter((r) => !r.subagentId);
|
|
853
|
+
if (!seg.length) continue; // only subagents worked here: their layer shows it
|
|
854
|
+
const lastMain = a + slice.lastIndexOf(seg[seg.length - 1]);
|
|
855
|
+
const bad = seg.some(isUnresolved);
|
|
856
|
+
const rec = !bad && seg.some((r) => r.status === 'failed');
|
|
857
|
+
const live = seg.some((r) => r.status === 'active');
|
|
858
|
+
const sel = seg.some((r) => r.id === S.selected);
|
|
859
|
+
const sa = seg.some((r) => r.kind === 'agent');
|
|
860
|
+
// What kind of work: file edits stand tallest with a pointed top, commands are plain bars,
|
|
861
|
+
// reads, searches and the rest stay short, so the eye finds the changes.
|
|
862
|
+
const kind = seg.some((r) => r.kind === 'edit' || r.kind === 'write') ? 'ed' : seg.some((r) => r.kind === 'command') ? 'cmd' : 'etc';
|
|
863
|
+
const cls = `${sel ? 'k-sel' : bad ? 'k-bad' : live ? 'k-live' : rec ? 'k-rec' : sa ? 'k-sa' : kind === 'ed' ? 'k-ed' : kind === 'cmd' ? 'k-ok' : 'k-rd'} kk-${kind}`;
|
|
864
|
+
const h = bad || sel ? 8.5 : live ? 7.5 : rec ? 7 : kind === 'ed' ? 7.5 : kind === 'cmd' ? 5.5 : 3.5;
|
|
865
|
+
const last = seg[seg.length - 1];
|
|
866
|
+
const state = bad ? ' · failed' : rec ? ' · failed, then recovered' : '';
|
|
867
|
+
const nEd = seg.filter((r) => r.kind === 'edit' || r.kind === 'write').length;
|
|
868
|
+
const nCmd = seg.filter((r) => r.kind === 'command').length;
|
|
869
|
+
const mix = seg.length > 1 && (nEd || nCmd) ? ` (${[nEd ? plural(nEd, 'file edit') : '', nCmd ? plural(nCmd, 'command') : ''].filter(Boolean).join(', ')})` : '';
|
|
870
|
+
const tip = seg.length > 1 ? `${seg.length} actions${mix} · ${clock(seg[0].startedAt)}–${clock(last.startedAt)}${state}` : `${clock(last.startedAt)} · ${last.subagent ? 'subagent · ' : ''}${last.title}${state}`;
|
|
871
|
+
rects += barShape(i, h, kind, cls, `data-b="${lastMain}"`, tip, unitPx);
|
|
872
|
+
}
|
|
873
|
+
if (W) rects += nowRect(B, nowW);
|
|
874
|
+
pending.slice(0, P).forEach((p, j) => {
|
|
875
|
+
const [px, pw] = slotBar(B + nowW + j, unitPx);
|
|
876
|
+
rects += `<rect x="${px}" y="3.5" width="${pw}" height="5" class="k-pend" data-pstep="${p.id}"><title>${esc(`Not started · ${p.title}`)}</title></rect>`;
|
|
877
|
+
});
|
|
878
|
+
if (P) lines += `<line x1="${B + nowW}" x2="${B + nowW}" y1="0" y2="10"/>`;
|
|
879
|
+
const zctl = n > 20
|
|
880
|
+
? `<span class="zctl"><button data-zoom="out" title="Zoom out" ${zoomed ? '' : 'disabled'}>−</button><button data-zoom="in" title="Zoom in (scroll over the strip)" ${span <= minSpanCount(n) ? 'disabled' : ''}>+</button>${zoomed ? `<button data-zoom="fit" title="Show the whole run">Fit</button>` : ''}</span>`
|
|
881
|
+
: '';
|
|
882
|
+
const range = zoomed ? `<span class="zrange">actions ${lo + 1}–${hi} of ${n}</span>` : '';
|
|
883
|
+
return `<svg class="strip" viewBox="0 0 ${total} 10" preserveAspectRatio="none" role="img" aria-label="Activity over the run">${rects}${lines}</svg>
|
|
884
|
+
${layersHtml(rows, lo, hi, B, total)}
|
|
885
|
+
${n > 20 ? navHtml(rows, lo, hi) : ''}
|
|
886
|
+
<div class="strip-axis"><span>${esc(clock(rows[lo].startedAt))}</span>
|
|
887
|
+
<span class="lg"><span><i class="lg-ed"></i>file edit</span><span><i style="background:var(--done)"></i>command</span><span><i class="lg-rd"></i>read, search, other</span><span><i class="lg-sa"></i>subagent</span><span><i style="background:var(--accent)"></i>running</span><span><i class="lg-rec"></i>recovered</span><span><i style="background:var(--bad)"></i>failed</span>${P ? '<span><i style="background:var(--border-strong)"></i>to do</span>' : ''}${range}${zctl}</span>
|
|
888
|
+
<span>${esc(clock(rows[hi - 1].startedAt))}</span></div>`;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// ───────────────────────────── subagent layers (one box per subagent, from launch to finish)
|
|
892
|
+
/**
|
|
893
|
+
* Subagents as boxes on their own track under the main one, on the same axis: a box spans
|
|
894
|
+
* the main agent's actions from the launch to the finish, so parallel work shows as overlap.
|
|
895
|
+
* Overlapping subagents stack on separate rows, like layers in a video editor.
|
|
896
|
+
*/
|
|
897
|
+
function layersHtml(rows, lo, hi, B, total) {
|
|
898
|
+
const agents = Object.values(S.nodes).filter((n) => n.type === 'subtask' && n.origin === 'subagent' && n.startedAt);
|
|
899
|
+
if (!agents.length) return '';
|
|
900
|
+
const n = rows.length;
|
|
901
|
+
const span = hi - lo;
|
|
902
|
+
const pos = new Map(rows.map((r, i) => [r.id, i]));
|
|
903
|
+
const live = S.run.status === 'working' && !isStale();
|
|
904
|
+
const items = agents
|
|
905
|
+
.filter((g) => pos.has(g.id))
|
|
906
|
+
.map((g) => {
|
|
907
|
+
const s0 = pos.get(g.id);
|
|
908
|
+
const running = g.status === 'active' && live;
|
|
909
|
+
// From the launch to its last action (to the live end while it runs).
|
|
910
|
+
let e0 = s0;
|
|
911
|
+
for (const a of descendants(g, isAct)) e0 = Math.max(e0, pos.get(a.id) ?? s0);
|
|
912
|
+
if (running) e0 = n - 1;
|
|
913
|
+
return { g, s0, e0, running };
|
|
914
|
+
})
|
|
915
|
+
.sort((x, y) => x.s0 - y.s0 || x.e0 - y.e0);
|
|
916
|
+
// Stack only what is in view, and at most MAX rows: a burst of parallel subagents shares
|
|
917
|
+
// the last row (marked "+N") instead of pushing the page down.
|
|
918
|
+
const MAX = 4;
|
|
919
|
+
const visible = items.filter((it) => it.e0 >= lo && it.s0 < hi);
|
|
920
|
+
const ends = [];
|
|
921
|
+
for (const it of visible) {
|
|
922
|
+
let row = ends.findIndex((e) => e < it.s0);
|
|
923
|
+
if (row < 0) row = ends.push(-1) - 1;
|
|
924
|
+
ends[row] = it.e0;
|
|
925
|
+
it.row = row;
|
|
926
|
+
}
|
|
927
|
+
const extra = visible.filter((it) => it.row >= MAX - 1);
|
|
928
|
+
const capped = ends.length > MAX;
|
|
929
|
+
if (capped) for (const it of extra) it.row = MAX - 1;
|
|
930
|
+
const xOf = (j) => (100 * (((j - lo) * B) / span)) / total;
|
|
931
|
+
const boxes = visible
|
|
932
|
+
.map(({ g, s0, e0, row, running }) => {
|
|
933
|
+
const left = Math.max(0, xOf(s0));
|
|
934
|
+
const right = Math.min((100 * B) / total, xOf(e0 + 1));
|
|
935
|
+
const acts = g.stats?.actions ?? 0;
|
|
936
|
+
const un = unresolvedOf(g);
|
|
937
|
+
const title = g.title.replace(/^Subagent:\s*/, '');
|
|
938
|
+
const tip = [
|
|
939
|
+
`Subagent · ${title}`,
|
|
940
|
+
[g.models?.length ? modelsLabel(g) : '', plural(acts, 'action'), g.startedAt ? dur(g.startedAt, running ? undefined : g.endedAt) : '', un ? `${un} failed` : ''].filter(Boolean).join(' · '),
|
|
941
|
+
running ? 'running' : `${clock(g.startedAt)}–${clock(g.endedAt)}`,
|
|
942
|
+
].join('\n');
|
|
943
|
+
const sel = openAgent()?.id === g.id ? ' sel' : '';
|
|
944
|
+
return `<button type="button" class="layer-box${running ? ' running' : ''}${un ? ' bad' : ''}${row ? ' rx' : ''}${sel}" data-layer="${g.id}" style="left:${left}%;width:${Math.max(0, right - left)}%;top:${row * 17}px" title="${esc(tip)}"><span>${esc(title)}</span></button>`;
|
|
945
|
+
})
|
|
946
|
+
.join('');
|
|
947
|
+
if (!visible.length) return '';
|
|
948
|
+
const rowsN = Math.min(MAX, Math.max(1, ends.length));
|
|
949
|
+
const more = capped
|
|
950
|
+
? `<span class="layer-more" style="top:${(MAX - 1) * 17}px;left:calc(${Math.min(94, Math.max(...extra.map((it) => xOf(it.e0 + 1))))}% + 4px)" title="${esc(`${extra.length} subagents overlap on this row:\n${extra.map((it) => it.g.title.replace(/^Subagent:\s*/, '')).join('\n')}`)}">+${extra.length}</span>`
|
|
951
|
+
: '';
|
|
952
|
+
// The compact (pinned, scrolled) card keeps one row: what overlaps it is counted on a "+N".
|
|
953
|
+
const folded = visible.filter((it) => it.row > 0);
|
|
954
|
+
const more1 = folded.length
|
|
955
|
+
? `<span class="layer-more c1" style="left:calc(${Math.min(94, Math.max(...folded.map((it) => xOf(it.e0 + 1))))}% + 4px)" title="${esc(`${folded.length} more subagents in parallel:\n${folded.map((it) => it.g.title.replace(/^Subagent:\s*/, '')).join('\n')}`)}">+${folded.length}</span>`
|
|
956
|
+
: '';
|
|
957
|
+
return `<div class="layers" style="--lh:${rowsN * 17 - 2}px" aria-label="Subagents">${boxes}${more}${more1}</div>`;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
// ───────────────────────────── strip zoom (a window over the run, like a video editor's zoom bar)
|
|
961
|
+
const minSpanCount = (n) => Math.min(n, 12);
|
|
962
|
+
/** The visible slice [lo, hi) of the actions, from the zoom window (fractions of the run). */
|
|
963
|
+
function zoomWindow(n) {
|
|
964
|
+
const lo = Math.max(0, Math.min(n - 1, Math.floor(S.zoom.a * n)));
|
|
965
|
+
const hi = Math.max(lo + Math.min(n, minSpanCount(n)), Math.min(n, Math.ceil(S.zoom.b * n)));
|
|
966
|
+
return [lo, Math.min(n, hi)];
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
/** A mini map of the whole run with the visible window on it: drag it, or its edges. */
|
|
970
|
+
function navHtml(rows, lo, hi) {
|
|
971
|
+
const n = rows.length;
|
|
972
|
+
const M = Math.min(n, 240);
|
|
973
|
+
let cells = '';
|
|
974
|
+
for (let i = 0; i < M; i++) {
|
|
975
|
+
const a = Math.floor((i * n) / M);
|
|
976
|
+
const b = Math.max(a + 1, Math.floor(((i + 1) * n) / M));
|
|
977
|
+
const bad = rows.slice(a, b).some(isUnresolved);
|
|
978
|
+
cells += `<rect x="${i}" y="0" width="1" height="1"${bad ? ' class="k-bad"' : ''}/>`;
|
|
979
|
+
}
|
|
980
|
+
const left = (100 * lo) / n;
|
|
981
|
+
const width = (100 * (hi - lo)) / n;
|
|
982
|
+
return `<div class="nav" data-nav title="Drag the window to pan, its edges to zoom · double-click to fit">
|
|
983
|
+
<svg class="nav-mini" viewBox="0 0 ${M} 1" preserveAspectRatio="none">${cells}</svg>
|
|
984
|
+
<div class="nav-win" style="left:${left}%;width:${width}%"><i class="nav-h" data-h="a"></i><i class="nav-h" data-h="b"></i></div>
|
|
985
|
+
</div>`;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
function setZoom(a, b) {
|
|
989
|
+
S.zoom = { a: Math.max(0, Math.min(1, a)), b: Math.max(0, Math.min(1, b)) };
|
|
990
|
+
if (S.zoom.b - S.zoom.a >= 0.999) S.zoom = { a: 0, b: 1 };
|
|
991
|
+
if (S.run) renderOverview();
|
|
992
|
+
}
|
|
993
|
+
/** Zooms by factor k (<1 zooms in) around a point given as a fraction of the visible window. */
|
|
994
|
+
function zoomBy(k, at = 0.5) {
|
|
995
|
+
const n = S.stripRows.length || 1;
|
|
996
|
+
const min = minSpanCount(n) / n;
|
|
997
|
+
const { a, b } = S.zoom;
|
|
998
|
+
const span = b - a;
|
|
999
|
+
const ns = Math.max(min, Math.min(1, span * k));
|
|
1000
|
+
const c = a + at * span;
|
|
1001
|
+
let na = c - at * ns;
|
|
1002
|
+
na = Math.max(0, Math.min(1 - ns, na));
|
|
1003
|
+
setZoom(na, na + ns);
|
|
1004
|
+
}
|
|
1005
|
+
function panBy(frac) {
|
|
1006
|
+
const { a, b } = S.zoom;
|
|
1007
|
+
const span = b - a;
|
|
1008
|
+
const na = Math.max(0, Math.min(1 - span, a + frac));
|
|
1009
|
+
setZoom(na, na + span);
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
// ───────────────────────────── path rendering
|
|
1013
|
+
function nowLine(n) {
|
|
1014
|
+
if (n.status !== 'active') return '';
|
|
1015
|
+
const act = latestActivity(n.id);
|
|
1016
|
+
if (!act) return '';
|
|
1017
|
+
const running = act.status === 'active' && S.run.status !== 'idle';
|
|
1018
|
+
return `<div class="now${running ? '' : ' idle'}">${running ? '<span class="spin"></span>' : icon(act.kind ?? 'other')}<span>${esc(act.title)}</span></div>`;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
function tagsHtml(ctx, max) {
|
|
1022
|
+
if (!ctx) return '';
|
|
1023
|
+
const files = [...new Set(ctx.files)];
|
|
1024
|
+
const items = [
|
|
1025
|
+
...files.map((f) => `<span class="tag" title="${esc(f)}">${hl(base(f))}</span>`),
|
|
1026
|
+
...ctx.commands.map((c) => `<span class="tag" title="${esc(c)}">$ ${hl(c)}</span>`),
|
|
1027
|
+
];
|
|
1028
|
+
if (!items.length) return '';
|
|
1029
|
+
const more = items.length > max ? `<span class="tag more">+${items.length - max}</span>` : '';
|
|
1030
|
+
return `<div class="ctx">${items.slice(0, max).join('')}${more}</div>`;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
function miniRow(a) {
|
|
1034
|
+
const sel = S.selected === a.id ? ' sel' : '';
|
|
1035
|
+
const d = a.endedAt ? dur(a.startedAt, a.endedAt) : '';
|
|
1036
|
+
return `<div class="mini-row s-${a.status}${sel}" data-select="${a.id}" data-node="${a.id}" title="${esc(a.issue ?? a.detail ?? a.title)}">
|
|
1037
|
+
${icon(a.kind)}<span class="ml">${hl(a.title)}${a.retry ? '<span class="retry">retry</span>' : ''}</span>
|
|
1038
|
+
<span class="st-count">${esc(d)}</span><span class="ms">${stMark(a)}</span></div>`;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
function branchHtml(n) {
|
|
1042
|
+
const sel = S.selected === n.id ? ' sel' : '';
|
|
1043
|
+
if (n.type === 'note' || n.kind === 'user') {
|
|
1044
|
+
const cls = n.kind === 'user' ? 'note you' : `note l-${n.level ?? 'info'}`;
|
|
1045
|
+
return `<div class="branch ${cls}${sel}" data-select="${n.id}" data-node="${n.id}">${n.kind === 'user' ? icon('user') : ''}<span class="bt">${hl(n.title)}</span></div>`;
|
|
1046
|
+
}
|
|
1047
|
+
let tag;
|
|
1048
|
+
let state = '';
|
|
1049
|
+
if (n.type === 'blocker') {
|
|
1050
|
+
tag = n.status === 'resolved' ? 'Resolved' : 'Blocker';
|
|
1051
|
+
state = n.detail ?? '';
|
|
1052
|
+
} else {
|
|
1053
|
+
tag = n.origin === 'subagent' ? 'Subagent' : 'Subtask';
|
|
1054
|
+
const a = n.stats?.actions ?? 0;
|
|
1055
|
+
state = n.status === 'active' ? 'running' : a ? plural(a, 'action') : '';
|
|
1056
|
+
if (n.origin === 'subagent' && n.models?.length) state = `${modelsLabel(n)}${state ? ' · ' + state : ''}`;
|
|
1057
|
+
if (n.stats?.errors) state += ` · ${plural(n.stats.errors, 'error')}`;
|
|
1058
|
+
}
|
|
1059
|
+
const title = n.origin === 'subagent' ? n.title.replace(/^Subagent:\s*/, '') : n.title;
|
|
1060
|
+
const row = `<div class="branch ${n.type} ${n.origin ?? ''} s-${n.status}${sel}" data-select="${n.id}" data-node="${n.id}">
|
|
1061
|
+
<span class="bk">${tag}</span><span class="bt" title="${esc(n.title)}">${hl(title)}</span><span class="bs">${esc(clip(state, 70))}</span></div>`;
|
|
1062
|
+
if (n.type !== 'subtask') return row;
|
|
1063
|
+
const inner = kids(n).filter((c) => c.type === 'subtask' || c.type === 'blocker' || c.type === 'note');
|
|
1064
|
+
const now = nowLine(n);
|
|
1065
|
+
return inner.length || now ? `<div>${row}<div class="nested">${now}${inner.map(branchHtml).join('')}</div></div>` : row;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
/** A unit's children in order: actions as compact rows (latest few), everything else as branches. */
|
|
1069
|
+
function childrenHtml(n) {
|
|
1070
|
+
const acts = kids(n).filter(isAct);
|
|
1071
|
+
const LIMIT = 5;
|
|
1072
|
+
const hide = S.allActs.has(n.id) ? 0 : Math.max(0, acts.length - LIMIT);
|
|
1073
|
+
const hidden = new Set(acts.slice(0, hide).map((a) => a.id));
|
|
1074
|
+
const out = [];
|
|
1075
|
+
let mini = [];
|
|
1076
|
+
const flush = () => {
|
|
1077
|
+
if (mini.length) out.push(`<div class="mini">${mini.join('')}</div>`);
|
|
1078
|
+
mini = [];
|
|
1079
|
+
};
|
|
1080
|
+
if (hide) out.push(`<button class="mini-more" data-allacts="${n.id}">Show ${plural(hide, 'earlier action')}</button>`);
|
|
1081
|
+
for (const c of kids(n)) {
|
|
1082
|
+
if (c.type === 'step') continue;
|
|
1083
|
+
if (isAct(c)) {
|
|
1084
|
+
if (!hidden.has(c.id)) mini.push(miniRow(c));
|
|
1085
|
+
continue;
|
|
1086
|
+
}
|
|
1087
|
+
flush();
|
|
1088
|
+
out.push(branchHtml(c));
|
|
1089
|
+
}
|
|
1090
|
+
flush();
|
|
1091
|
+
return out.join('');
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
function stepHtml(s) {
|
|
1095
|
+
const open = isStepOpen(s);
|
|
1096
|
+
const blockers = openBlockers(s);
|
|
1097
|
+
const errs = s.stats?.errors ?? 0;
|
|
1098
|
+
const subs = descendants(s, (c) => c.type === 'subtask');
|
|
1099
|
+
const nSa = subs.filter((c) => c.origin === 'subagent').length;
|
|
1100
|
+
const nSub = subs.length - nSa;
|
|
1101
|
+
const flags = [
|
|
1102
|
+
blockers.length
|
|
1103
|
+
? `<span class="flag bad">Blocked</span>`
|
|
1104
|
+
: unresolvedOf(s)
|
|
1105
|
+
? `<span class="flag bad" title="${plural(unresolvedOf(s), 'failed action')} nothing has succeeded after yet">${unresolvedOf(s)} ✕</span>`
|
|
1106
|
+
: '',
|
|
1107
|
+
!blockers.length && (s.stats?.recovered ?? 0)
|
|
1108
|
+
? `<span class="flag rec" title="${plural(s.stats.recovered, 'failure')} along the way, recovered: something worked afterwards">↺ ${s.stats.recovered}</span>`
|
|
1109
|
+
: '',
|
|
1110
|
+
nSub ? `<span class="flag sub">${plural(nSub, 'subtask')}</span>` : '',
|
|
1111
|
+
nSa ? `<span class="flag sa">${plural(nSa, 'subagent')}</span>` : '',
|
|
1112
|
+
].join('');
|
|
1113
|
+
const a = s.stats?.actions ?? 0;
|
|
1114
|
+
const hasBody = s.children.length > 0;
|
|
1115
|
+
const sel = S.selected === s.id ? ' sel' : '';
|
|
1116
|
+
const cls = `step s-${s.status}${s.tentative ? ' tentative' : ''}${open && hasBody ? ' open' : ''}${sel}`;
|
|
1117
|
+
let body = '';
|
|
1118
|
+
if (open && hasBody) body = `<div class="step-b">${unitInfo(s)}${tagsHtml(s.context, 10)}${childrenHtml(s)}</div>`;
|
|
1119
|
+
else if (blockers.length || s.status === 'active') body = `${nowLine(s)}${blockers.length ? `<div class="step-b">${blockers.map(branchHtml).join('')}</div>` : ''}`;
|
|
1120
|
+
return `<li class="${cls}">
|
|
1121
|
+
<div class="step-h${s.untracked ? ' untracked' : ''}" data-step="${s.id}" data-node="${s.id}" title="${esc(s.untracked ? `${s.title}
|
|
1122
|
+
Done together with the other steps: the agent's work on it wasn't told apart.` : s.title)}">
|
|
1123
|
+
<span class="mk"${heatStyle(s.id)}${S.heat ? ` title="${esc(heatTip(s.id).trim())}"` : ''}></span><span class="st-title">${hl(s.title)}</span>
|
|
1124
|
+
<span class="flags">${s.untracked ? '<span class="flag untr">with the others</span>' : ''}${flags}</span><span class="st-count" title="${plural(a, 'action')}">${a || ''}</span>
|
|
1125
|
+
${hasBody ? CHEV : '<span style="width:16px"></span>'}
|
|
1126
|
+
</div>${body}</li>`;
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
/** Models and tokens of a unit, shown when it is expanded. */
|
|
1130
|
+
function unitInfo(n) {
|
|
1131
|
+
if (!n.usage) return '';
|
|
1132
|
+
return `<div class="uinfo">${(n.models ?? []).map((m) => `<span class="model">${esc(modelName(m))}</span>`).join('')}<span title="${esc(usageTitle(n.usage))}">${esc(usageLine(n.usage))}</span></div>`;
|
|
1133
|
+
}
|
|
1134
|
+
const usageTitle = (u) => `Fresh input ${u.input.toLocaleString()} · cache write ${u.cacheWrite.toLocaleString()} · cache read ${u.cacheRead.toLocaleString()} · output ${u.output.toLocaleString()} · ${plural(u.responses, 'response')}`;
|
|
1135
|
+
|
|
1136
|
+
function segbarHtml(steps) {
|
|
1137
|
+
if (!steps.length) return '';
|
|
1138
|
+
return `<div class="segbar" title="${steps.filter((s) => s.status === 'done').length} of ${steps.length} steps done">${steps
|
|
1139
|
+
.map((s) => `<i class="s-${s.status}${openBlockers(s).length ? ' blk' : ''}" style="flex:${Math.max(1, Math.min(12, s.stats?.actions ?? 0))}"></i>`)
|
|
1140
|
+
.join('')}</div>`;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
/**
|
|
1144
|
+
* What a task is heading for. The agent's intent line counts only when it reads as an aim for
|
|
1145
|
+
* the whole task, not as its first move (then it is just the first step's title); otherwise the
|
|
1146
|
+
* request itself is the target.
|
|
1147
|
+
*/
|
|
1148
|
+
function goalAim(g) {
|
|
1149
|
+
const norm = (t) => String(t ?? '').toLowerCase().replace(/[^a-z0-9 ]+/g, '').trim();
|
|
1150
|
+
const intent = norm(g.intent);
|
|
1151
|
+
const first = norm(kids(g).find((c) => c.type === 'step')?.title);
|
|
1152
|
+
const isFirstMove = !!first && (intent === first || intent.startsWith(first) || first.startsWith(intent));
|
|
1153
|
+
return g.intent && !isFirstMove && intent !== norm(g.title) ? g.intent : g.title;
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
function taskHtml(g, q) {
|
|
1157
|
+
if (!g) return '';
|
|
1158
|
+
const steps = kids(g).filter((c) => c.type === 'step');
|
|
1159
|
+
const qa = isQA(g);
|
|
1160
|
+
const mode = S.pf.mode;
|
|
1161
|
+
if (qa && S.pf.hideChat && mode === 'all') return '';
|
|
1162
|
+
if (mode === 'qa' && !qa) return '';
|
|
1163
|
+
if (qa && mode !== 'all' && mode !== 'qa') return '';
|
|
1164
|
+
let shown = steps;
|
|
1165
|
+
if (filtering()) {
|
|
1166
|
+
const gm = !!q && (g.title.toLowerCase().includes(q) || (g.intent ?? '').toLowerCase().includes(q) || (g.outcome ?? '').toLowerCase().includes(q));
|
|
1167
|
+
shown = steps.filter((s) => stepMatches(s, gm ? '' : q));
|
|
1168
|
+
if (!shown.length && !(qa && (!q || gm)) && !(gm && mode === 'all')) return '';
|
|
1169
|
+
}
|
|
1170
|
+
const open = isGoalOpen(g);
|
|
1171
|
+
const done = steps.filter((s) => s.status === 'done').length;
|
|
1172
|
+
const a = g.stats?.actions ?? 0;
|
|
1173
|
+
const e = g.stats?.errors ?? 0;
|
|
1174
|
+
const meta = qa
|
|
1175
|
+
? `<span>${esc(ago(g.startedAt))}</span>`
|
|
1176
|
+
: [
|
|
1177
|
+
steps.length ? `<span>${done}/${steps.length} steps</span>` : '',
|
|
1178
|
+
a ? `<span>${plural(a, 'action')}</span>` : '',
|
|
1179
|
+
unresolvedOf(g) ? `<span class="bad">${unresolvedOf(g)} failed</span>` : '',
|
|
1180
|
+
e - unresolvedOf(g) ? `<span title="Failures along the way that something worked after">${e - unresolvedOf(g)} recovered</span>` : '',
|
|
1181
|
+
g.startedAt ? `<span>${esc(dur(g.startedAt, liveEnd(g)))}</span>` : '',
|
|
1182
|
+
`<span>${esc(ago(g.startedAt))}</span>`,
|
|
1183
|
+
].join('');
|
|
1184
|
+
const answer = qa && !open && g.outcome ? `<div class="task-answer">${esc(g.outcome)}</div>` : '';
|
|
1185
|
+
const cur = g.id === S.run.currentGoal;
|
|
1186
|
+
const sel = S.selected === g.id ? ' sel' : '';
|
|
1187
|
+
const head = `<div class="task-h" data-toggle="${g.id}" data-node="${g.id}">
|
|
1188
|
+
<span class="tstat${qa ? ' qa' : ''} s-${g.status}"${heatStyle(g.id)}${S.heat ? ` title="${esc(heatTip(g.id).trim())}"` : ''}></span>
|
|
1189
|
+
<div class="task-main"><div class="task-title">${hl(g.title)}</div><div class="task-meta">${meta}</div>${answer}</div>
|
|
1190
|
+
${segbarHtml(steps)}${CHEV}
|
|
1191
|
+
</div>`;
|
|
1192
|
+
const cls = `task${open ? ' open' : ''}${cur ? ' current' : ''}${cur && S.run.status === 'working' && !isStale() ? ' working' : ''}${sel}`;
|
|
1193
|
+
if (!open) return `<section class="${cls}">${head}</section>`;
|
|
1194
|
+
|
|
1195
|
+
const reached = !!g.outcome || g.status === 'done';
|
|
1196
|
+
// The end point names what the task is heading for; once reached, what came out of it.
|
|
1197
|
+
const aim = goalAim(g);
|
|
1198
|
+
const endText = g.outcome
|
|
1199
|
+
? `<small>Result</small>${esc(clip(g.outcome, 360))}`
|
|
1200
|
+
: `<small>Goal${g.status === 'waiting' ? ' · interrupted' : g.status === 'done' ? ' · done' : ''}</small>${hl(clip(aim, 240))}`;
|
|
1201
|
+
const finish = (tag) => `<${tag} class="finish${reached ? ' reached' : ''}" data-select="${g.id}"><span class="mk"></span><div class="ft">${endText}</div></${tag}>`;
|
|
1202
|
+
if (qa) return `<section class="${cls}">${head}<div class="task-b">${finish('div')}</div></section>`;
|
|
1203
|
+
const own = filtering() ? '' : childrenHtml(g);
|
|
1204
|
+
return `<section class="${cls}">${head}
|
|
1205
|
+
<div class="task-b">
|
|
1206
|
+
${g.usage ? `<div style="margin:0 0 8px 30px">${unitInfo(g)}</div>` : ''}
|
|
1207
|
+
${own ? `<div class="step-b" style="margin-left:30px">${own}</div>` : ''}
|
|
1208
|
+
<ol class="steps">${shown.map(stepHtml).join('')}${finish('li')}</ol>
|
|
1209
|
+
</div></section>`;
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
// ───────────────────────────── simple view: tasks on a spine, each task's steps as a chain of nodes
|
|
1213
|
+
/*
|
|
1214
|
+
* Built with keyed DOM updates rather than HTML strings: a node keeps its element for its whole
|
|
1215
|
+
* life, so it pops in once, changes colour smoothly when its status changes, and a chain keeps
|
|
1216
|
+
* its scroll position while the agent works.
|
|
1217
|
+
*/
|
|
1218
|
+
const GLYPH = {
|
|
1219
|
+
edit: PATHS.edit,
|
|
1220
|
+
command: PATHS.command,
|
|
1221
|
+
read: PATHS.read,
|
|
1222
|
+
agent: PATHS.agent,
|
|
1223
|
+
ask: PATHS.ask,
|
|
1224
|
+
start: PATHS.user,
|
|
1225
|
+
end: 'M6 21V4h11l-2.5 3.5L17 11H6',
|
|
1226
|
+
check: 'M5 12.5l4.5 4.5L19 7',
|
|
1227
|
+
pause: 'M9 6v12 M15 6v12',
|
|
1228
|
+
todo: '',
|
|
1229
|
+
};
|
|
1230
|
+
// One silhouette per kind of work, so the chain reads even without colour.
|
|
1231
|
+
const SHAPE = {
|
|
1232
|
+
edit: '<path class="sh" d="M5 31V12.5L11 6h14l6 6.5V31z"/>',
|
|
1233
|
+
command: '<rect class="sh" x="3.5" y="8" width="29" height="20" rx="6"/>',
|
|
1234
|
+
read: '<circle class="sh" cx="18" cy="18" r="13"/>',
|
|
1235
|
+
agent: '<path class="sh" d="M18 4.5l12 7v13l-12 7-12-7v-13z"/>',
|
|
1236
|
+
ask: '<path class="sh" d="M6 7h24a2 2 0 0 1 2 2v15a2 2 0 0 1-2 2H15l-6 5v-5H6a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2z"/>',
|
|
1237
|
+
todo: '<circle class="sh" cx="18" cy="18" r="11"/>',
|
|
1238
|
+
start: '<circle class="sh" cx="18" cy="18" r="13"/>',
|
|
1239
|
+
end: '<path class="sh" d="M18 4l14 14-14 14L4 18z"/>',
|
|
1240
|
+
};
|
|
1241
|
+
const KIND_LABEL = { edit: 'file edits', command: 'commands', read: 'reading and searching', agent: 'a subagent', ask: 'a question', todo: 'not started' };
|
|
1242
|
+
const glyphSvg = (k) => (GLYPH[k] ? `<svg class="gl" viewBox="0 0 24 24" aria-hidden="true"><path d="${GLYPH[k]}"/></svg>` : '');
|
|
1243
|
+
|
|
1244
|
+
/** What a step mostly did: it sets the node's shape (and colour, once done). */
|
|
1245
|
+
function stepKind(s) {
|
|
1246
|
+
const acts = descendants(s, isAct);
|
|
1247
|
+
if (!acts.length) return s.status === 'pending' || s.status === 'active' ? 'todo' : 'read';
|
|
1248
|
+
if (descendants(s, (c) => c.type === 'subtask' && c.origin === 'subagent').length) return 'agent';
|
|
1249
|
+
if (acts.some((a) => a.kind === 'edit' || a.kind === 'write')) return 'edit';
|
|
1250
|
+
if (acts.some((a) => a.kind === 'command')) return 'command';
|
|
1251
|
+
if (acts.some((a) => a.kind === 'ask')) return 'ask';
|
|
1252
|
+
return 'read';
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
const liveNow = () => S.run.status === 'working' && !isStale();
|
|
1256
|
+
|
|
1257
|
+
function stepState(s) {
|
|
1258
|
+
if (hasOpenIssue(s)) return 'bad';
|
|
1259
|
+
if (s.status === 'active') return liveNow() && s.id === S.run.currentStep ? 'live' : 'idle';
|
|
1260
|
+
if (s.status === 'done') return 'done';
|
|
1261
|
+
if (s.status === 'failed') return 'bad';
|
|
1262
|
+
return 'pend';
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
function flowOpen(g, q, hit) {
|
|
1266
|
+
if (S.openGoals.has(g.id)) return true;
|
|
1267
|
+
if (S.closedGoals.has(g.id)) return false;
|
|
1268
|
+
if (S.expandAll) return true;
|
|
1269
|
+
if (q || (S.pf.mode !== 'all' && S.pf.mode !== 'qa')) return hit;
|
|
1270
|
+
return g.id === S.run.currentGoal || S.run.goals.length === 1;
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
/** One chain node: its incoming connector, the shape, and a short label. */
|
|
1274
|
+
function flowNode({ key, id, kind, st, label, tip, badge = '', extra = '' }) {
|
|
1275
|
+
// Start and end both stand for the task: only the one you clicked shows as selected.
|
|
1276
|
+
const sel = S.selected === id && (key.startsWith('s:') || key.startsWith('w:') || (S.flowPick ?? 'start') === key) ? ' sel' : '';
|
|
1277
|
+
return {
|
|
1278
|
+
key,
|
|
1279
|
+
st,
|
|
1280
|
+
cls: `fn k-${kind} st-${st}${sel}${extra}`,
|
|
1281
|
+
html: `<i class="fl"></i><button type="button" class="fn-s" data-fnode="${id}" data-node="${key.startsWith('s:') ? id : ''}" title="${esc(tip + (key.startsWith('s:') || key.startsWith('w:') ? heatTip(id) : ''))}" aria-label="${esc(tip)}"${key.startsWith('s:') || key.startsWith('w:') ? heatStyle(id) : ''}><svg class="shp" viewBox="0 0 36 36" aria-hidden="true">${SHAPE[kind]}</svg>${glyphSvg(kind)}${badge}</button><span class="fn-l">${esc(label)}</span>`,
|
|
1282
|
+
};
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
function chainItems(g, q) {
|
|
1286
|
+
const steps = kids(g).filter((c) => c.type === 'step');
|
|
1287
|
+
const items = [
|
|
1288
|
+
flowNode({ key: 'start', id: g.id, kind: 'start', st: 'start', label: 'Request', tip: `You asked (${clock(g.startedAt)}): ${clip(g.title, 300)}`, extra: ' start' }),
|
|
1289
|
+
];
|
|
1290
|
+
// A task with no steps but some work of its own: one node stands for that work.
|
|
1291
|
+
if (!steps.length && (g.stats?.actions ?? 0) > 0) {
|
|
1292
|
+
const kind = stepKind(g);
|
|
1293
|
+
const st = unresolvedOf(g) ? 'bad' : g.status === 'active' ? (liveNow() ? 'live' : 'idle') : 'done';
|
|
1294
|
+
items.push(flowNode({ key: `w:${g.id}`, id: g.id, kind, st, label: plural(g.stats.actions, 'action'), tip: `${plural(g.stats.actions, 'action')}, mostly ${KIND_LABEL[kind]}` }));
|
|
1295
|
+
}
|
|
1296
|
+
steps.forEach((s, i) => {
|
|
1297
|
+
const kind = stepKind(s);
|
|
1298
|
+
const st = stepState(s);
|
|
1299
|
+
const a = s.stats?.actions ?? 0;
|
|
1300
|
+
const rec = st !== 'bad' && hasRecovered(s);
|
|
1301
|
+
const dim = (q || S.pf.mode !== 'all') && !stepMatches(s, q) ? ' dim' : '';
|
|
1302
|
+
const tip = [
|
|
1303
|
+
`${i + 1}. ${s.title}`,
|
|
1304
|
+
[a ? plural(a, 'action') : 'no actions yet', kind !== 'todo' ? `mostly ${KIND_LABEL[kind]}` : '', s.startedAt ? dur(s.startedAt, liveEnd(s)) : ''].filter(Boolean).join(' · '),
|
|
1305
|
+
st === 'bad' ? 'Failed: nothing has worked after it yet' : rec ? 'Failed along the way, then recovered' : s.untracked ? "Done with the others: its work wasn't told apart" : '',
|
|
1306
|
+
].filter(Boolean).join('\n');
|
|
1307
|
+
const badge = (st === 'bad' ? '<span class="fn-b bad">!</span>' : rec ? '<span class="fn-b rec"></span>' : '') + (a > 1 ? `<span class="fn-n">${a > 99 ? '99+' : a}</span>` : '');
|
|
1308
|
+
items.push({ ...flowNode({ key: `s:${s.id}`, id: s.id, kind, st, label: s.title, tip, badge, extra: (s.untracked ? ' untr' : '') + dim }), i });
|
|
1309
|
+
const cap = capsuleItem(s, dim);
|
|
1310
|
+
if (cap) items.push(cap);
|
|
1311
|
+
});
|
|
1312
|
+
const reached = !!g.outcome || g.status === 'done';
|
|
1313
|
+
const st = reached ? 'done' : g.status === 'waiting' ? 'wait' : 'pend';
|
|
1314
|
+
const label = reached ? 'Done' : g.status === 'waiting' ? 'Interrupted' : 'Goal';
|
|
1315
|
+
const tip = g.outcome ? `Result: ${clip(g.outcome, 400)}` : `Goal: ${clip(goalAim(g), 300)}`;
|
|
1316
|
+
items.push(flowNode({ key: 'end', id: g.id, kind: 'end', st, label, tip, extra: ' end' }));
|
|
1317
|
+
items.forEach((it, i) => (it.i = i));
|
|
1318
|
+
return items;
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
/** At most this many action marks in a capsule; the rest are a "+N" that opens the step. */
|
|
1322
|
+
const CAP_MAX = 12;
|
|
1323
|
+
const MARK_KIND = { edit: 'ed', write: 'ed', command: 'cmd', agent: 'sa' };
|
|
1324
|
+
|
|
1325
|
+
/**
|
|
1326
|
+
* The selected step, opened in place: a capsule of small marks, one per action, in the usual
|
|
1327
|
+
* colours (failed red, recovered ringed, running blue). Only while the step or one of its
|
|
1328
|
+
* actions is selected, so the chain stays as short as ever otherwise.
|
|
1329
|
+
*/
|
|
1330
|
+
function capsuleItem(s, dim) {
|
|
1331
|
+
if (!S.selected) return null;
|
|
1332
|
+
const acts = descendants(s, isAct).sort((x, y) => ((x.startedAt ?? '') < (y.startedAt ?? '') ? -1 : 1));
|
|
1333
|
+
if (!acts.length || (S.selected !== s.id && !acts.some((a) => a.id === S.selected))) return null;
|
|
1334
|
+
const marks = acts.slice(0, acts.length > CAP_MAX ? CAP_MAX - 1 : CAP_MAX).map((a, j) => {
|
|
1335
|
+
const st = isUnresolved(a) ? 's-bad' : a.status === 'failed' ? 's-rec' : a.status === 'active' ? 's-live' : '';
|
|
1336
|
+
const tip = [
|
|
1337
|
+
a.title,
|
|
1338
|
+
[MARK_LABEL[a.kind] ?? 'Action', a.endedAt ? dur(a.startedAt, a.endedAt) : a.status === 'active' ? 'running' : ''].filter(Boolean).join(' · '),
|
|
1339
|
+
st === 's-bad' ? `Failed${a.issue ? ': ' + clip(a.issue, 160) : ''}` : st === 's-rec' ? 'Failed, then recovered' : '',
|
|
1340
|
+
].filter(Boolean).join('\n');
|
|
1341
|
+
return `<button type="button" class="fm m-${MARK_KIND[a.kind] ?? 'rd'} ${st}${S.selected === a.id ? ' sel' : ''}" style="--j:${j}" data-select="${a.id}" data-node="${a.id}" title="${esc(tip)}" aria-label="${esc(a.title)}">${icon(a.kind)}</button>`;
|
|
1342
|
+
});
|
|
1343
|
+
const rest = acts.length - marks.length;
|
|
1344
|
+
const more = rest ? `<button type="button" class="fm-more" data-select="${s.id}" title="${plural(rest, 'more action')}: all of them are in the panel">+${rest}</button>` : '';
|
|
1345
|
+
return { key: `c:${s.id}`, st: 'cap', cls: `fcap${dim}`, html: `<div class="fcap-in"${heatStyle(s.id)}>${marks.join('')}${more}</div>` };
|
|
1346
|
+
}
|
|
1347
|
+
const MARK_LABEL = { read: 'Read', edit: 'Edit', write: 'Write', command: 'Command', search: 'Search', web: 'Web', agent: 'Subagent', ask: 'Question', other: 'Other' };
|
|
1348
|
+
|
|
1349
|
+
/** A capsule closing: it folds back to nothing, pulling the nodes after it in, then goes. */
|
|
1350
|
+
function foldAway(el) {
|
|
1351
|
+
if (el.__leaving) return;
|
|
1352
|
+
el.__leaving = true;
|
|
1353
|
+
el.style.overflow = 'hidden';
|
|
1354
|
+
const cs = getComputedStyle(el);
|
|
1355
|
+
el.__anim = el.animate(
|
|
1356
|
+
[
|
|
1357
|
+
{ maxWidth: `${el.offsetWidth}px`, paddingLeft: cs.paddingLeft, paddingRight: cs.paddingRight, marginLeft: cs.marginLeft, opacity: 1 },
|
|
1358
|
+
{ maxWidth: '0px', paddingLeft: '0px', paddingRight: '0px', marginLeft: '0px', opacity: 0 },
|
|
1359
|
+
],
|
|
1360
|
+
{ duration: 220, easing: 'cubic-bezier(.4,0,.6,1)', fill: 'forwards' },
|
|
1361
|
+
);
|
|
1362
|
+
el.__anim.onfinish = () => el.__leaving && el.remove();
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
/** Keyed update of a list of elements: reuse, reorder, add (with an entrance) and remove. */
|
|
1366
|
+
function reconcile(parent, items, animate) {
|
|
1367
|
+
const old = new Map();
|
|
1368
|
+
for (const el of parent.children) old.set(el.dataset.key, el);
|
|
1369
|
+
// Capsules on their way out stay where they are while they fold (see foldAway).
|
|
1370
|
+
const keep = new Set(items.map((it) => it.key));
|
|
1371
|
+
for (const [k, el] of old) if (!keep.has(k) && el.classList.contains('fcap')) el.__leaving ||= 'soon';
|
|
1372
|
+
let prev = null;
|
|
1373
|
+
for (const it of items) {
|
|
1374
|
+
let el = old.get(it.key);
|
|
1375
|
+
const fresh = !el;
|
|
1376
|
+
if (fresh) {
|
|
1377
|
+
el = document.createElement('div');
|
|
1378
|
+
el.dataset.key = it.key;
|
|
1379
|
+
} else old.delete(it.key);
|
|
1380
|
+
if (el.__leaving) {
|
|
1381
|
+
// Selected again while folding: open it back up.
|
|
1382
|
+
el.__anim?.cancel();
|
|
1383
|
+
el.__leaving = false;
|
|
1384
|
+
el.style.overflow = '';
|
|
1385
|
+
}
|
|
1386
|
+
const turnedDone = !fresh && el.__st && el.__st !== it.st && it.st === 'done';
|
|
1387
|
+
if (el.__cls !== it.cls) {
|
|
1388
|
+
el.className = it.cls;
|
|
1389
|
+
el.__cls = it.cls;
|
|
1390
|
+
}
|
|
1391
|
+
el.__st = it.st;
|
|
1392
|
+
if (el.__html !== it.html) {
|
|
1393
|
+
el.innerHTML = it.html;
|
|
1394
|
+
el.__html = it.html;
|
|
1395
|
+
}
|
|
1396
|
+
if (it.i !== undefined) el.style.setProperty('--i', String(Math.min(it.i, 24)));
|
|
1397
|
+
if ((fresh && animate) || turnedDone) {
|
|
1398
|
+
const c = fresh ? 'enter' : 'just-done';
|
|
1399
|
+
el.classList.add(c);
|
|
1400
|
+
setTimeout(() => el.classList.remove(c), 900);
|
|
1401
|
+
}
|
|
1402
|
+
let next = prev ? prev.nextSibling : parent.firstChild;
|
|
1403
|
+
while (next && next.__leaving && next !== el) next = next.nextSibling;
|
|
1404
|
+
if (next !== el) parent.insertBefore(el, next);
|
|
1405
|
+
prev = el;
|
|
1406
|
+
// A capsule that opens past the chain's edge: bring it into view once it has its width.
|
|
1407
|
+
if (fresh && it.st === 'cap') setTimeout(() => el.isConnected && el.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'smooth' }), 300);
|
|
1408
|
+
}
|
|
1409
|
+
for (const el of old.values()) {
|
|
1410
|
+
if (el.__leaving === 'soon') {
|
|
1411
|
+
el.__leaving = false;
|
|
1412
|
+
foldAway(el);
|
|
1413
|
+
} else if (!el.__leaving) el.remove();
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
/** Keeps the working node of a live chain in view, unless you scrolled the chain yourself. */
|
|
1418
|
+
function followChain(chain, instant) {
|
|
1419
|
+
const t = chain.querySelector('.fn.st-live, .fn.st-idle') ?? [...chain.querySelectorAll('.fn.st-done, .fn.st-bad')].pop();
|
|
1420
|
+
if (!t || chain.__user) return;
|
|
1421
|
+
const right = t.offsetLeft + t.offsetWidth + 110; // the live node, plus a peek at what comes next
|
|
1422
|
+
const left = t.offsetLeft - 120; // never under the pinned start node
|
|
1423
|
+
let x = chain.scrollLeft;
|
|
1424
|
+
if (right > x + chain.clientWidth) x = right - chain.clientWidth;
|
|
1425
|
+
if (left < x) x = Math.max(0, left);
|
|
1426
|
+
if (Math.abs(x - chain.scrollLeft) < 2) return;
|
|
1427
|
+
chain.__autoAt = Date.now();
|
|
1428
|
+
chain.scrollTo({ left: x, behavior: instant ? 'auto' : 'smooth' });
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
function chainEdges(chain) {
|
|
1432
|
+
const more = chain.scrollLeft + chain.clientWidth < chain.scrollWidth - 2;
|
|
1433
|
+
chain.parentElement.classList.toggle('more-r', more);
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
function flowGoalHead(g, qa) {
|
|
1437
|
+
const steps = kids(g).filter((c) => c.type === 'step');
|
|
1438
|
+
const done = steps.filter((s) => s.status === 'done').length;
|
|
1439
|
+
const live = g.id === S.run.currentGoal && g.status === 'active' && liveNow();
|
|
1440
|
+
const st = qa ? 'qa' : live ? 'live' : g.status === 'active' ? 'idle' : g.status === 'waiting' ? 'wait' : 'done';
|
|
1441
|
+
const glyph = qa ? glyphSvg('ask') : st === 'done' ? glyphSvg('check') : st === 'wait' ? glyphSvg('pause') : '';
|
|
1442
|
+
const un = unresolvedOf(g);
|
|
1443
|
+
const meta = qa
|
|
1444
|
+
? `<span class="fg-ans">${esc(clip(g.outcome ?? '', 200))}</span>`
|
|
1445
|
+
: [
|
|
1446
|
+
steps.length ? `<span>${done}/${steps.length} steps</span>` : g.stats?.actions ? `<span>${plural(g.stats.actions, 'action')}</span>` : '',
|
|
1447
|
+
un ? `<span class="bad">${un} failed</span>` : '',
|
|
1448
|
+
g.startedAt ? `<span>${esc(dur(g.startedAt, liveEnd(g)))}</span>` : '',
|
|
1449
|
+
st === 'wait' ? '<span>interrupted</span>' : '',
|
|
1450
|
+
`<span>${esc(ago(g.startedAt))}</span>`,
|
|
1451
|
+
].filter(Boolean).join('<i>·</i>');
|
|
1452
|
+
return {
|
|
1453
|
+
st,
|
|
1454
|
+
html: `<span class="fg-n st-${st}"${heatStyle(g.id)}${S.heat ? ` title="${esc(heatTip(g.id).trim())}"` : ''}>${glyph}${un ? '<span class="fn-b bad">!</span>' : ''}</span>
|
|
1455
|
+
<span class="fg-t"><span class="fg-title">${hl(g.title)}</span><span class="fg-meta">${meta}</span></span>
|
|
1456
|
+
${qa ? '' : CHEV}`,
|
|
1457
|
+
};
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
function renderFlow(view, goals, q) {
|
|
1461
|
+
if (!view.__flow) {
|
|
1462
|
+
view.innerHTML = '<div class="flow"></div>';
|
|
1463
|
+
view.__html = null;
|
|
1464
|
+
view.__flow = true;
|
|
1465
|
+
S.flowReady = false;
|
|
1466
|
+
}
|
|
1467
|
+
const root = view.firstChild;
|
|
1468
|
+
if (!goals.length) {
|
|
1469
|
+
root.innerHTML = '<div class="no-match">The run is open. Progress appears as soon as the agent does something.</div>';
|
|
1470
|
+
return;
|
|
1471
|
+
}
|
|
1472
|
+
root.querySelector(':scope > .no-match')?.remove();
|
|
1473
|
+
const animate = !!S.flowReady;
|
|
1474
|
+
const old = new Map();
|
|
1475
|
+
for (const el of root.children) old.set(el.dataset.key, el);
|
|
1476
|
+
let prev = null;
|
|
1477
|
+
for (const g of goals) {
|
|
1478
|
+
const qa = isQA(g);
|
|
1479
|
+
const hit = !q || g.title.toLowerCase().includes(q) || (g.intent ?? '').toLowerCase().includes(q) || kids(g).some((s) => s.type === 'step' && stepMatches(s, q));
|
|
1480
|
+
const open = !qa && flowOpen(g, q, hit);
|
|
1481
|
+
let row = old.get(g.id);
|
|
1482
|
+
const fresh = !row;
|
|
1483
|
+
if (fresh) {
|
|
1484
|
+
row = document.createElement('section');
|
|
1485
|
+
row.dataset.key = g.id;
|
|
1486
|
+
row.innerHTML = `<div class="fg-h" data-ftoggle="${g.id}" data-node="${g.id}"></div><div class="fg-b"><div class="fg-bi"><div class="chain"><div class="chain-in"></div></div></div></div>`;
|
|
1487
|
+
const chain = row.querySelector('.chain');
|
|
1488
|
+
chain.addEventListener(
|
|
1489
|
+
'scroll',
|
|
1490
|
+
() => {
|
|
1491
|
+
// Your own scroll pauses the auto-follow; scrolling back to the end resumes it.
|
|
1492
|
+
if (Date.now() - (chain.__autoAt ?? 0) > 700) chain.__user = chain.scrollLeft + chain.clientWidth < chain.scrollWidth - 30;
|
|
1493
|
+
chainEdges(chain);
|
|
1494
|
+
},
|
|
1495
|
+
{ passive: true },
|
|
1496
|
+
);
|
|
1497
|
+
if (animate) {
|
|
1498
|
+
row.classList.add('enter');
|
|
1499
|
+
setTimeout(() => row.classList.remove('enter'), 900);
|
|
1500
|
+
}
|
|
1501
|
+
} else old.delete(g.id);
|
|
1502
|
+
const head = flowGoalHead(g, qa);
|
|
1503
|
+
const cur = g.id === S.run.currentGoal;
|
|
1504
|
+
const cls = `fg${open ? ' open' : ''}${qa ? ' qa' : ''}${cur ? ' cur' : ''} st-${head.st}${S.selected === g.id ? ' sel' : ''}${hit ? '' : ' dim'}`;
|
|
1505
|
+
const wasOpen = row.classList.contains('open');
|
|
1506
|
+
if (row.__cls !== cls) {
|
|
1507
|
+
row.className = cls;
|
|
1508
|
+
row.__cls = cls;
|
|
1509
|
+
}
|
|
1510
|
+
if (open && (fresh ? animate : !wasOpen)) {
|
|
1511
|
+
row.classList.add('opening');
|
|
1512
|
+
setTimeout(() => row.classList.remove('opening'), 900);
|
|
1513
|
+
}
|
|
1514
|
+
const h = row.firstChild;
|
|
1515
|
+
if (h.__html !== head.html) {
|
|
1516
|
+
h.innerHTML = head.html;
|
|
1517
|
+
h.__html = head.html;
|
|
1518
|
+
}
|
|
1519
|
+
const chain = row.querySelector('.chain');
|
|
1520
|
+
// A chain is built the first time it opens and kept (for the closing animation) afterwards.
|
|
1521
|
+
if (open) {
|
|
1522
|
+
const track = chain.firstChild;
|
|
1523
|
+
const first = !track.firstChild;
|
|
1524
|
+
reconcile(track, chainItems(g, q), animate && !first);
|
|
1525
|
+
if (cur) followChain(chain, first || !animate);
|
|
1526
|
+
requestAnimationFrame(() => chainEdges(chain));
|
|
1527
|
+
}
|
|
1528
|
+
const next = prev ? prev.nextSibling : root.firstChild;
|
|
1529
|
+
if (next !== row) root.insertBefore(row, next);
|
|
1530
|
+
prev = row;
|
|
1531
|
+
}
|
|
1532
|
+
for (const el of old.values()) el.remove();
|
|
1533
|
+
S.flowReady = true;
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
/** Expand all / collapse all, as a compact icon button: arrows apart to expand, together to collapse. */
|
|
1537
|
+
function setExpandBtn(collapse) {
|
|
1538
|
+
const b = $('#expandAll');
|
|
1539
|
+
const label = collapse ? 'Collapse all (E)' : 'Expand all (E)';
|
|
1540
|
+
if (b.title === label) return;
|
|
1541
|
+
b.title = label;
|
|
1542
|
+
b.setAttribute('aria-label', label);
|
|
1543
|
+
b.textContent = collapse ? 'Collapse all' : 'Expand all';
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
const MODE_LABEL = { all: 'All', open: 'Failed', recovered: 'Recovered', agents: 'Subagents', qa: 'Questions' };
|
|
1547
|
+
|
|
1548
|
+
/** The filters as chips, plus the same as a menu for when the toolbar is too narrow for chips. */
|
|
1549
|
+
function modesHtml(modes, nOpen) {
|
|
1550
|
+
const cur = S.pf.mode;
|
|
1551
|
+
const chips = modes
|
|
1552
|
+
.filter(([m, , n]) => m === 'all' || n || cur === m)
|
|
1553
|
+
.map(([m, label, n, cls]) => `<button class="chip-f${cur === m ? ' on' : ''} ${n ? cls : ''}" data-mode="${m}">${label}${n !== null ? ` <span class="n">${n}</span>` : ''}</button>`)
|
|
1554
|
+
.join('');
|
|
1555
|
+
const n = modes.find(([m]) => m === cur)?.[2];
|
|
1556
|
+
const alert = nOpen && cur !== 'open' ? `<i class="fsel-dot" title="${plural(nOpen, 'step')} with failures nothing has fixed yet"></i>` : '';
|
|
1557
|
+
const menu = S.fselOpen
|
|
1558
|
+
? `<div class="fsel-menu" role="menu">${modes
|
|
1559
|
+
.map(([m, label, k, cls]) => `<button type="button" role="menuitemradio" aria-checked="${cur === m}" class="${cur === m ? 'on' : ''} ${k ? cls : ''}" data-mode="${m}"${m !== 'all' && !k && cur !== m ? ' disabled' : ''}><span>${label}</span>${k !== null ? `<span class="n">${k}</span>` : ''}</button>`)
|
|
1560
|
+
.join('')}</div>`
|
|
1561
|
+
: '';
|
|
1562
|
+
return `<div class="chips-row">${chips}</div><div class="fsel"><button type="button" class="fsel-b${S.fselOpen ? ' open' : ''}" data-fsel aria-haspopup="menu" aria-expanded="${!!S.fselOpen}" title="Show">${MODE_LABEL[cur] ?? 'All'}${n ? ` <span class="n">${n}</span>` : ''}${alert}${SVG('M7 10l5 5 5-5', 'dd')}</button>${menu}</div>`;
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
/** Chips while they fit on one row with the rest of the toolbar; a menu when they don't. */
|
|
1566
|
+
function fitToolbar() {
|
|
1567
|
+
const tb = $('#tbPath');
|
|
1568
|
+
if (tb.hidden) return;
|
|
1569
|
+
tb.classList.remove('tight');
|
|
1570
|
+
const kids = [...tb.children].filter((el) => el.offsetParent !== null);
|
|
1571
|
+
// Items are centred on the row: an item whose middle sits lower than the first one's has wrapped.
|
|
1572
|
+
const mid = (el) => el.offsetTop + el.offsetHeight / 2;
|
|
1573
|
+
const wrapped = kids.length > 1 && kids.some((el) => mid(el) > mid(kids[0]) + 10);
|
|
1574
|
+
tb.classList.toggle('tight', wrapped);
|
|
1575
|
+
}
|
|
1576
|
+
new ResizeObserver(() => fitToolbar()).observe($('#pane'));
|
|
1577
|
+
|
|
1578
|
+
function renderPath() {
|
|
1579
|
+
const q = pathQuery();
|
|
1580
|
+
const goals = S.run.goals.map(N).filter(Boolean);
|
|
1581
|
+
let nOpen = 0;
|
|
1582
|
+
let nRec = 0;
|
|
1583
|
+
let nAgents = 0;
|
|
1584
|
+
for (const g of goals)
|
|
1585
|
+
for (const s of kids(g))
|
|
1586
|
+
if (s.type === 'step') {
|
|
1587
|
+
if (hasOpenIssue(s)) nOpen++;
|
|
1588
|
+
if (hasRecovered(s)) nRec++;
|
|
1589
|
+
if (hasAgent(s)) nAgents++;
|
|
1590
|
+
}
|
|
1591
|
+
const nQA = goals.filter(isQA).length;
|
|
1592
|
+
const modes = [
|
|
1593
|
+
['all', 'All', null, ''],
|
|
1594
|
+
['open', 'Failed', nOpen, 'bad'],
|
|
1595
|
+
['recovered', 'Recovered', nRec, ''],
|
|
1596
|
+
['agents', 'Subagents', nAgents, ''],
|
|
1597
|
+
['qa', 'Questions', nQA, ''],
|
|
1598
|
+
];
|
|
1599
|
+
const files = S.layout === 'files';
|
|
1600
|
+
const simple = S.layout === 'simple';
|
|
1601
|
+
const view = $('#pathView');
|
|
1602
|
+
$('#pathModes').hidden = files;
|
|
1603
|
+
$('#hideChat').closest('.switch').hidden = files;
|
|
1604
|
+
if (!files) setHTML($('#pathModes'), modesHtml(modes, nOpen));
|
|
1605
|
+
// Hide Q&A only means something in All: the other filters leave questions out anyway, and
|
|
1606
|
+
// Questions shows nothing else. Elsewhere it stays in place, greyed out (no reshuffling).
|
|
1607
|
+
const sw = $('#hideChat').closest('.switch');
|
|
1608
|
+
const applies = S.pf.mode === 'all';
|
|
1609
|
+
$('#hideChat').checked = S.pf.hideChat && applies;
|
|
1610
|
+
$('#hideChat').disabled = !applies;
|
|
1611
|
+
sw.classList.toggle('off', !applies);
|
|
1612
|
+
sw.title = !applies
|
|
1613
|
+
? `Only applies to All: ${S.pf.mode === 'qa' ? 'Questions shows only them' : 'this filter already leaves questions out'}`
|
|
1614
|
+
: nQA
|
|
1615
|
+
? `${S.pf.hideChat ? 'Hiding' : 'Hide'} ${plural(nQA, 'question')} answered without any work`
|
|
1616
|
+
: 'No questions answered without work in this run yet: nothing to hide for now';
|
|
1617
|
+
setExpandBtn(files ? S.ff.open.size > 0 : S.expandAll);
|
|
1618
|
+
const hb = $('#heatBtn');
|
|
1619
|
+
hb.hidden = files;
|
|
1620
|
+
hb.classList.toggle('on', S.heat);
|
|
1621
|
+
hb.setAttribute('aria-pressed', String(S.heat));
|
|
1622
|
+
hb.title = S.heat ? 'Token heat on: tasks and steps glow green to red by tokens used, relative to the rest of the run (H)' : 'Token heat: make tasks and steps glow green to red by tokens used (H)';
|
|
1623
|
+
$('#pathSearch').placeholder = files ? 'Filter files and edits' : 'Search steps, files, commands';
|
|
1624
|
+
requestAnimationFrame(fitToolbar);
|
|
1625
|
+
if (files) {
|
|
1626
|
+
if (view.__flow) {
|
|
1627
|
+
view.__flow = false;
|
|
1628
|
+
view.__html = null;
|
|
1629
|
+
}
|
|
1630
|
+
view.classList.remove('working');
|
|
1631
|
+
setHTML(view, filesHtml(q));
|
|
1632
|
+
return;
|
|
1633
|
+
}
|
|
1634
|
+
if (simple) {
|
|
1635
|
+
view.classList.remove('working');
|
|
1636
|
+
const mode = S.pf.mode;
|
|
1637
|
+
const shown = goals.filter((g) =>
|
|
1638
|
+
mode === 'qa' ? isQA(g) : mode === 'all' ? !(S.pf.hideChat && isQA(g)) : kids(g).some((s) => s.type === 'step' && stepMatches(s, '')),
|
|
1639
|
+
);
|
|
1640
|
+
renderFlow(view, shown, q);
|
|
1641
|
+
return;
|
|
1642
|
+
}
|
|
1643
|
+
if (view.__flow) {
|
|
1644
|
+
view.__flow = false;
|
|
1645
|
+
view.__html = null;
|
|
1646
|
+
}
|
|
1647
|
+
const html = goals.map((g) => taskHtml(g, q)).join('');
|
|
1648
|
+
view.classList.toggle('working', S.run.status === 'working' && !isStale());
|
|
1649
|
+
setHTML(
|
|
1650
|
+
view,
|
|
1651
|
+
html ||
|
|
1652
|
+
(goals.length
|
|
1653
|
+
? '<div class="no-match">Nothing matches these filters.</div>'
|
|
1654
|
+
: '<div class="no-match">The run is open. Progress appears as soon as the agent does something.</div>'),
|
|
1655
|
+
);
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
/** Tokens per minute over the last hour, as a tiny bar chart. */
|
|
1659
|
+
function rateChart(u) {
|
|
1660
|
+
const now = Math.floor(Date.now() / 60000);
|
|
1661
|
+
const end = isStale() ? Math.floor(Date.parse(u.lastAt ?? S.run.updatedAt) / 60000) : now;
|
|
1662
|
+
const M = 60;
|
|
1663
|
+
const vals = new Array(M).fill(0);
|
|
1664
|
+
for (const [m, v] of u.perMinute) if (m > end - M && m <= end) vals[M - 1 - (end - m)] = v;
|
|
1665
|
+
const max = Math.max(...vals, 1);
|
|
1666
|
+
const bars = vals.map((v, i) => (v ? `<rect x="${i + 0.12}" y="${20 - (18 * v) / max}" width="0.76" height="${(18 * v) / max}"><title>${tok(v)} tokens</title></rect>` : '')).join('');
|
|
1667
|
+
return `<svg class="rate" viewBox="0 0 ${M} 20" preserveAspectRatio="none">${bars}<line x1="0" x2="${M}" y1="20" y2="20"/></svg>
|
|
1668
|
+
<div class="rate-axis"><span>${isStale() ? 'last hour of activity' : '60 min ago'}</span><span>peak ${tok(max)}/min</span><span>${isStale() ? clock(u.lastAt) : 'now'}</span></div>`;
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
/** Average tokens per minute over the last 5 minutes, while the run is live. */
|
|
1672
|
+
function liveRate(u) {
|
|
1673
|
+
if (isStale() || !u?.perMinute.length) return 0;
|
|
1674
|
+
const now = Math.floor(Date.now() / 60000);
|
|
1675
|
+
const recent = u.perMinute.filter(([m]) => m > now - 5);
|
|
1676
|
+
return recent.length ? Math.round(recent.reduce((a, [, v]) => a + v, 0) / 5) : 0;
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
function renderTokens() {
|
|
1680
|
+
const u = S.run?.usage;
|
|
1681
|
+
const btn = $('#tokens');
|
|
1682
|
+
btn.hidden = !u;
|
|
1683
|
+
if (!u) {
|
|
1684
|
+
$('#tokensPop').hidden = true;
|
|
1685
|
+
return;
|
|
1686
|
+
}
|
|
1687
|
+
const rate = liveRate(u);
|
|
1688
|
+
btn.innerHTML = `${SVG('M4 7h16M4 12h10M4 17h6')}<span><b>${tok(freshIn(u))}</b> in</span><span><b>${tok(u.output)}</b> out</span>${
|
|
1689
|
+
u.context ? `<span class="ctxn" title="Context size of the main agent's latest response">ctx <b>${tok(u.context)}</b></span>` : ''
|
|
1690
|
+
}${rate ? `<span class="live">${tok(rate)}/min</span>` : ''}`;
|
|
1691
|
+
const pop = $('#tokensPop');
|
|
1692
|
+
if (pop.hidden) return;
|
|
1693
|
+
const models = Object.entries(u.byModel).sort((a, b) => freshIn(b[1]) + b[1].output - (freshIn(a[1]) + a[1].output));
|
|
1694
|
+
const totalWork = freshIn(u) + u.output || 1;
|
|
1695
|
+
const row = (label, x, share) => `<tr><td>${label}</td><td>${x.responses}</td><td>${tok(freshIn(x))}</td><td>${tok(x.cacheRead)}</td><td>${tok(x.output)}</td><td class="sh"><i style="width:${Math.max(2, Math.round(share * 100))}%"></i></td></tr>`;
|
|
1696
|
+
pop.innerHTML = `<div class="pop-h">Token usage<span>${plural(u.responses, 'response')}</span></div>
|
|
1697
|
+
<div class="pop-tot">
|
|
1698
|
+
<div><b>${tok(freshIn(u))}</b><span>input</span><small>${tok(u.input)} fresh + ${tok(u.cacheWrite)} cache write</small></div>
|
|
1699
|
+
<div><b>${tok(u.output)}</b><span>output</span></div>
|
|
1700
|
+
<div><b>${tok(u.cacheRead)}</b><span>cache reads</span><small>billed at a fraction of input</small></div>
|
|
1701
|
+
${u.context ? `<div><b>${tok(u.context)}</b><span>context now</span><small>${esc(modelName(u.contextModel))}, main agent</small></div>` : ''}
|
|
1702
|
+
</div>
|
|
1703
|
+
<table class="pop-t"><thead><tr><th>Model</th><th>Resp.</th><th>In</th><th>Cached</th><th>Out</th><th></th></tr></thead>
|
|
1704
|
+
<tbody>${models.map(([m, x]) => row(esc(modelName(m)), x, (freshIn(x) + x.output) / totalWork)).join('')}</tbody></table>
|
|
1705
|
+
<div class="pop-sec">Tokens per minute <small>(input + cache write + output)</small></div>
|
|
1706
|
+
${rateChart(u)}
|
|
1707
|
+
<div class="pop-note">As reported in the agent's transcripts, subagents included. Shares are by input + output.</div>`;
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
function renderHeader() {
|
|
1711
|
+
const r = S.run;
|
|
1712
|
+
$('#runTitle').textContent = r ? (r.title ?? r.cwd ?? 'Untitled run') : 'No run open';
|
|
1713
|
+
$('#runSub').textContent = r ? [r.cwd, r.updatedAt ? `updated ${ago(r.updatedAt)}` : ''].filter(Boolean).join(' · ') : '';
|
|
1714
|
+
document.title = r?.title ? `${r.title} · Lucidly` : 'Lucidly';
|
|
1715
|
+
const [st, text] = runState();
|
|
1716
|
+
$('#status').dataset.status = st;
|
|
1717
|
+
$('#statusText').textContent = text;
|
|
1718
|
+
$('#status').hidden = !r;
|
|
1719
|
+
const save = $('#saveBtn');
|
|
1720
|
+
if (S.runId) {
|
|
1721
|
+
save.href = `/api/s/${S.runId}/save`;
|
|
1722
|
+
save.setAttribute('download', '');
|
|
1723
|
+
save.removeAttribute('aria-disabled');
|
|
1724
|
+
} else {
|
|
1725
|
+
save.removeAttribute('href');
|
|
1726
|
+
save.setAttribute('aria-disabled', 'true');
|
|
1727
|
+
}
|
|
1728
|
+
renderTokens();
|
|
1729
|
+
void syncWake(); // the run's state decides whether "only while working" holds the lock
|
|
1730
|
+
const shown = S.view === 'actions' ? 'actions' : S.layout;
|
|
1731
|
+
for (const b of document.querySelectorAll('[data-view]')) b.classList.toggle('on', b.dataset.view === shown);
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
// ───────────────────────────── detail panel
|
|
1735
|
+
function rawFor(id) {
|
|
1736
|
+
const n = N(id);
|
|
1737
|
+
let entry = S.raw.get(id);
|
|
1738
|
+
if (!entry) {
|
|
1739
|
+
entry = { chunks: null, stale: true, loading: false, at: 0 };
|
|
1740
|
+
S.raw.set(id, entry);
|
|
1741
|
+
}
|
|
1742
|
+
if (entry.stale && !entry.loading) {
|
|
1743
|
+
const wait = Math.max(0, 700 - (Date.now() - entry.at));
|
|
1744
|
+
entry.loading = true;
|
|
1745
|
+
setTimeout(async () => {
|
|
1746
|
+
try {
|
|
1747
|
+
const own = n?.type === 'goal' ? '?own=1' : '';
|
|
1748
|
+
const res = await fetch(`/api/s/${S.runId}/raw/${id}${own}`);
|
|
1749
|
+
if (res.ok) {
|
|
1750
|
+
entry.chunks = (await res.json()).chunks;
|
|
1751
|
+
entry.stale = false;
|
|
1752
|
+
}
|
|
1753
|
+
} finally {
|
|
1754
|
+
entry.loading = false;
|
|
1755
|
+
entry.at = Date.now();
|
|
1756
|
+
if (S.selected === id) renderDetail();
|
|
1757
|
+
if (S.view === 'path' && S.layout === 'files') schedule();
|
|
1758
|
+
}
|
|
1759
|
+
}, entry.chunks ? wait : 0);
|
|
1760
|
+
}
|
|
1761
|
+
return entry.chunks;
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
function invalidateRaw(id) {
|
|
1765
|
+
for (const n of ancestors(id)) {
|
|
1766
|
+
const e = S.raw.get(n.id);
|
|
1767
|
+
if (e) e.stale = true;
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
/**
|
|
1772
|
+
* Colours diff lines: an edit's "- old" / "+ new" lines (mode 'edit'), or a unified diff
|
|
1773
|
+
* (git diff output, patches) wherever it is recognised.
|
|
1774
|
+
*/
|
|
1775
|
+
const UNIFIED = /^(?:diff --git |@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@|--- a\/|\+\+\+ b\/)/m;
|
|
1776
|
+
function diffHtml(text, mode) {
|
|
1777
|
+
if (mode === 'shell' || mode === 'python') return highlight(text, mode);
|
|
1778
|
+
const unified = mode !== 'edit' && UNIFIED.test(text);
|
|
1779
|
+
if (mode !== 'edit' && !unified) return esc(text);
|
|
1780
|
+
return String(text)
|
|
1781
|
+
.split('\n')
|
|
1782
|
+
.map((l) => {
|
|
1783
|
+
let cls = '';
|
|
1784
|
+
if (mode === 'edit') cls = l.startsWith('- ') ? 'del' : l.startsWith('+ ') ? 'add' : '';
|
|
1785
|
+
else if (/^(?:diff --git |index |--- |\+\+\+ )/.test(l)) cls = 'dh';
|
|
1786
|
+
else if (l.startsWith('@@')) cls = 'hunk';
|
|
1787
|
+
else if (l.startsWith('+')) cls = 'add';
|
|
1788
|
+
else if (l.startsWith('-')) cls = 'del';
|
|
1789
|
+
return cls ? `<span class="${cls}">${esc(l)}</span>` : esc(l);
|
|
1790
|
+
})
|
|
1791
|
+
.join('\n');
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
function preBlock(key, label, text, cls = '', mode = 'auto') {
|
|
1795
|
+
const lines = String(text ?? '').split('\n');
|
|
1796
|
+
const LIMIT = 16;
|
|
1797
|
+
const full = S.fullPre.has(key) || lines.length <= LIMIT;
|
|
1798
|
+
const shown = full ? text : lines.slice(0, LIMIT).join('\n');
|
|
1799
|
+
const body = cls.includes('failed') ? esc(shown) : diffHtml(shown, mode);
|
|
1800
|
+
return `<div class="pl">${esc(label)}</div><pre class="${cls}">${body}</pre>${
|
|
1801
|
+
full ? '' : `<button class="show-all" data-full="${esc(key)}">Show all ${lines.length} lines</button>`
|
|
1802
|
+
}`;
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
function scopeOf(nodeId, rootId) {
|
|
1806
|
+
// nearest subtask/blocker between the chunk's node and the selected root
|
|
1807
|
+
let found = null;
|
|
1808
|
+
for (const n of ancestors(nodeId)) {
|
|
1809
|
+
if (n.id === rootId) break;
|
|
1810
|
+
if (n.type === 'subtask' || n.type === 'blocker') found = n;
|
|
1811
|
+
}
|
|
1812
|
+
return found;
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
// ───────────────────────────── syntax highlighting (shell + Python, dependency-free)
|
|
1816
|
+
const PY_KW = new Set(
|
|
1817
|
+
'False None True and as assert async await break class continue def del elif else except finally for from global if import in is lambda nonlocal not or pass raise return try while with yield match case'.split(' '),
|
|
1818
|
+
);
|
|
1819
|
+
const PY_BI = new Set(
|
|
1820
|
+
'print len range open str int float bool list dict set tuple enumerate zip map filter sorted sum min max abs any all isinstance type super self cls repr round iter next getattr setattr hasattr Exception ValueError TypeError KeyError'.split(' '),
|
|
1821
|
+
);
|
|
1822
|
+
const SH_KW = new Set('if then else elif fi for while until do done case esac in function return export local readonly unset select time'.split(' '));
|
|
1823
|
+
/** Keywords followed by a command, and wrappers whose next word is the real command. */
|
|
1824
|
+
const SH_THEN_CMD = new Set('if then else elif while until do time'.split(' '));
|
|
1825
|
+
const SH_WRAP = new Set('timeout sudo env nohup xargs nice exec command builtin'.split(' '));
|
|
1826
|
+
const SH_BI = new Set('cd echo printf exit set source eval exec test read shift trap wait true false'.split(' '));
|
|
1827
|
+
const span = (cls, text) => `<span class="tk-${cls}">${esc(text)}</span>`;
|
|
1828
|
+
|
|
1829
|
+
/** Python: comments, strings (incl. triple-quoted, f/r/b prefixes), numbers, keywords, builtins, defs, decorators. */
|
|
1830
|
+
function hlPython(code) {
|
|
1831
|
+
const re =
|
|
1832
|
+
/(#[^\n]*)|((?:[rRbBuUfF]{1,2})?(?:'''[\s\S]*?(?:'''|$)|"""[\s\S]*?(?:"""|$)|'(?:\\.|[^'\\\n])*'?|"(?:\\.|[^"\\\n])*"?))|(\b\d[\d_]*(?:\.\d+)?(?:[eE][+-]?\d+)?\b)|(@[\w.]+)|([A-Za-z_]\w*)/g;
|
|
1833
|
+
let out = '';
|
|
1834
|
+
let last = 0;
|
|
1835
|
+
let prev = '';
|
|
1836
|
+
for (let m; (m = re.exec(code)); ) {
|
|
1837
|
+
out += esc(code.slice(last, m.index));
|
|
1838
|
+
last = re.lastIndex;
|
|
1839
|
+
const [t, com, str, num, deco, word] = m;
|
|
1840
|
+
if (com) out += span('c', t);
|
|
1841
|
+
else if (str) out += span('s', t);
|
|
1842
|
+
else if (num) out += span('n', t);
|
|
1843
|
+
else if (deco) out += span('f', t);
|
|
1844
|
+
else if (word) {
|
|
1845
|
+
if (prev === 'def' || prev === 'class') out += span('f', t);
|
|
1846
|
+
else if (PY_KW.has(t)) out += span('k', t);
|
|
1847
|
+
else if (PY_BI.has(t)) out += span('b', t);
|
|
1848
|
+
else out += esc(t);
|
|
1849
|
+
prev = t;
|
|
1850
|
+
continue;
|
|
1851
|
+
}
|
|
1852
|
+
prev = '';
|
|
1853
|
+
}
|
|
1854
|
+
return out + esc(code.slice(last));
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
/**
|
|
1858
|
+
* Shell (bash / PowerShell): command names, flags, strings, $variables, comments, operators.
|
|
1859
|
+
* Python heredocs (`python - <<'EOF'`) and long `python -c "…"` strings are highlighted as Python.
|
|
1860
|
+
*/
|
|
1861
|
+
function hlShell(code) {
|
|
1862
|
+
const lines = String(code).split('\n');
|
|
1863
|
+
const out = [];
|
|
1864
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1865
|
+
const line = lines[i];
|
|
1866
|
+
out.push(hlShellLine(line));
|
|
1867
|
+
const hd = /<<-?\s*(['"]?)([A-Za-z_]\w*)\1/.exec(line);
|
|
1868
|
+
if (!hd) continue;
|
|
1869
|
+
// heredoc body: until the terminator line
|
|
1870
|
+
const body = [];
|
|
1871
|
+
let j = i + 1;
|
|
1872
|
+
while (j < lines.length && lines[j].trim() !== hd[2]) body.push(lines[j++]);
|
|
1873
|
+
const isPy = /\bpython[\d.]*(?:\.exe)?\b|\bpy\b/.test(line.slice(0, hd.index));
|
|
1874
|
+
if (body.length) out.push(isPy ? hlPython(body.join('\n')) : span('s', body.join('\n')));
|
|
1875
|
+
if (j < lines.length) out.push(span('k', lines[j]));
|
|
1876
|
+
i = j;
|
|
1877
|
+
}
|
|
1878
|
+
return out.join('\n');
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
function hlShellLine(line) {
|
|
1882
|
+
const re =
|
|
1883
|
+
/(\s#[^\n]*|^#[^\n]*)|('[^']*'?|"(?:\\.|[^"\\])*"?)|(\$\{[^}]*\}|\$\(|\$[A-Za-z_]\w*|\$[@#?$!*0-9]|%[A-Za-z_]\w*%)|(&&|\|\||[|;&]|>>|2>&1|[<>])|((?<=^|\s)--?[A-Za-z][\w-]*)|(\b\d+\b)|([^\s'"$|;&<>()#]+)/g;
|
|
1884
|
+
let out = '';
|
|
1885
|
+
let last = 0;
|
|
1886
|
+
let expectCmd = true;
|
|
1887
|
+
let cmd = '';
|
|
1888
|
+
for (let m; (m = re.exec(line)); ) {
|
|
1889
|
+
out += esc(line.slice(last, m.index));
|
|
1890
|
+
last = re.lastIndex;
|
|
1891
|
+
const [t, com, str, v, op, flag, num, word] = m;
|
|
1892
|
+
if (com) out += span('c', t);
|
|
1893
|
+
else if (str) {
|
|
1894
|
+
const inner = t.slice(1, t.endsWith(t[0]) && t.length > 1 ? -1 : undefined);
|
|
1895
|
+
// python -c "…": show the code as Python
|
|
1896
|
+
if (/^python|^py$/.test(cmd) && inner.length > 12) out += span('s', t[0]) + hlPython(inner) + (t.length > 1 && t.endsWith(t[0]) ? span('s', t[0]) : '');
|
|
1897
|
+
else out += span('s', t);
|
|
1898
|
+
expectCmd = false;
|
|
1899
|
+
} else if (v) {
|
|
1900
|
+
out += span('v', t);
|
|
1901
|
+
if (t === '$(') expectCmd = true;
|
|
1902
|
+
} else if (op) {
|
|
1903
|
+
out += span('o', t);
|
|
1904
|
+
expectCmd = /^(?:&&|\|\||[|;&])$/.test(t);
|
|
1905
|
+
} else if (flag) out += span('fl', t);
|
|
1906
|
+
else if (num) out += span('n', t);
|
|
1907
|
+
else if (word || num) {
|
|
1908
|
+
if (/^[A-Za-z_]\w*=/.test(t) && expectCmd) out += span('v', t.slice(0, t.indexOf('=') + 1)) + esc(t.slice(t.indexOf('=') + 1));
|
|
1909
|
+
else if (SH_KW.has(t) && (expectCmd || t === 'in' || t === 'do' || t === 'then')) {
|
|
1910
|
+
out += span('k', t);
|
|
1911
|
+
expectCmd = SH_THEN_CMD.has(t);
|
|
1912
|
+
continue;
|
|
1913
|
+
} else if (expectCmd) {
|
|
1914
|
+
cmd = t.split(/[\\/]/).pop().toLowerCase().replace(/\.exe$/, '');
|
|
1915
|
+
out += span(SH_BI.has(t) ? 'b' : 'f', t);
|
|
1916
|
+
expectCmd = SH_WRAP.has(cmd); // `timeout 90 node …`: node is the command
|
|
1917
|
+
} else out += esc(t);
|
|
1918
|
+
}
|
|
1919
|
+
if (t === ')') expectCmd = false;
|
|
1920
|
+
}
|
|
1921
|
+
return out + esc(line.slice(last));
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
/** Language for a file path, if we highlight it. */
|
|
1925
|
+
function langOf(path) {
|
|
1926
|
+
const ext = /\.([A-Za-z0-9]+)$/.exec(String(path ?? ''))?.[1]?.toLowerCase();
|
|
1927
|
+
return ext === 'py' || ext === 'pyw' || ext === 'pyi' ? 'python' : ext === 'sh' || ext === 'bash' || ext === 'zsh' || ext === 'ps1' ? 'shell' : null;
|
|
1928
|
+
}
|
|
1929
|
+
const highlight = (code, lang) => (lang === 'python' ? hlPython(code) : lang === 'shell' ? hlShell(code) : esc(code));
|
|
1930
|
+
|
|
1931
|
+
// ───────────────────────────── diffs (GitHub-style)
|
|
1932
|
+
/** Line diff of two texts (longest common subsequence) → rows of context / removed / added. */
|
|
1933
|
+
function lineDiff(a, b) {
|
|
1934
|
+
const n = a.length;
|
|
1935
|
+
const m = b.length;
|
|
1936
|
+
if (n * m > 250000) return [...a.map((text) => ({ t: 'del', text })), ...b.map((text) => ({ t: 'add', text }))];
|
|
1937
|
+
const L = Array.from({ length: n + 1 }, () => new Uint16Array(m + 1));
|
|
1938
|
+
for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--) L[i][j] = a[i] === b[j] ? L[i + 1][j + 1] + 1 : Math.max(L[i + 1][j], L[i][j + 1]);
|
|
1939
|
+
const out = [];
|
|
1940
|
+
let i = 0;
|
|
1941
|
+
let j = 0;
|
|
1942
|
+
while (i < n && j < m) {
|
|
1943
|
+
if (a[i] === b[j]) {
|
|
1944
|
+
out.push({ t: 'ctx', text: a[i] });
|
|
1945
|
+
i++;
|
|
1946
|
+
j++;
|
|
1947
|
+
} else if (L[i + 1][j] >= L[i][j + 1]) out.push({ t: 'del', text: a[i++] });
|
|
1948
|
+
else out.push({ t: 'add', text: b[j++] });
|
|
1949
|
+
}
|
|
1950
|
+
while (i < n) out.push({ t: 'del', text: a[i++] });
|
|
1951
|
+
while (j < m) out.push({ t: 'add', text: b[j++] });
|
|
1952
|
+
return out;
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
/** Rows from recorded hunks, with real file line numbers. */
|
|
1956
|
+
function rowsFromPatch(patch) {
|
|
1957
|
+
const rows = [];
|
|
1958
|
+
for (const h of patch) {
|
|
1959
|
+
rows.push({ t: 'hunk', text: `@@ -${h.oldStart},${h.oldLines} +${h.newStart},${h.newLines} @@` });
|
|
1960
|
+
let o = h.oldStart;
|
|
1961
|
+
let nn = h.newStart;
|
|
1962
|
+
for (const l of h.lines) {
|
|
1963
|
+
const c = l[0];
|
|
1964
|
+
const text = l.slice(1);
|
|
1965
|
+
if (c === '+') rows.push({ t: 'add', n: nn++, text });
|
|
1966
|
+
else if (c === '-') rows.push({ t: 'del', o: o++, text });
|
|
1967
|
+
else if (c !== '\\') rows.push({ t: 'ctx', o: o++, n: nn++, text });
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1970
|
+
return rows;
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
/** Rows computed from an edit's "- old" / "+ new" input, when no patch was recorded (no line numbers). */
|
|
1974
|
+
function rowsFromInput(text) {
|
|
1975
|
+
const groups = [];
|
|
1976
|
+
let cur = null;
|
|
1977
|
+
for (const l of String(text).split('\n').slice(1)) {
|
|
1978
|
+
if (l.startsWith('- ') || l === '-') {
|
|
1979
|
+
if (!cur || cur.add.length) groups.push((cur = { del: [], add: [] }));
|
|
1980
|
+
cur.del.push(l.slice(2));
|
|
1981
|
+
} else if (l.startsWith('+ ') || l === '+') {
|
|
1982
|
+
if (!cur) groups.push((cur = { del: [], add: [] }));
|
|
1983
|
+
cur.add.push(l.slice(2));
|
|
1984
|
+
}
|
|
1985
|
+
}
|
|
1986
|
+
const rows = [];
|
|
1987
|
+
groups.forEach((g, i) => {
|
|
1988
|
+
if (groups.length > 1) rows.push({ t: 'hunk', text: `Change ${i + 1} of ${groups.length}` });
|
|
1989
|
+
rows.push(...lineDiff(g.del, g.add));
|
|
1990
|
+
});
|
|
1991
|
+
return rows;
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
/** Highlights the changed part of lines replaced one-for-one, like GitHub's word highlights. */
|
|
1995
|
+
function markWords(rows) {
|
|
1996
|
+
for (let i = 0; i < rows.length; ) {
|
|
1997
|
+
if (rows[i].t !== 'del') {
|
|
1998
|
+
i++;
|
|
1999
|
+
continue;
|
|
2000
|
+
}
|
|
2001
|
+
let d = i;
|
|
2002
|
+
while (d < rows.length && rows[d].t === 'del') d++;
|
|
2003
|
+
let a = d;
|
|
2004
|
+
while (a < rows.length && rows[a].t === 'add') a++;
|
|
2005
|
+
if (a - d === d - i) for (let k = 0; k < d - i; k++) pairMark(rows[i + k], rows[d + k]);
|
|
2006
|
+
i = Math.max(a, i + 1);
|
|
2007
|
+
}
|
|
2008
|
+
return rows;
|
|
2009
|
+
}
|
|
2010
|
+
function pairMark(x, y) {
|
|
2011
|
+
const a = x.text;
|
|
2012
|
+
const b = y.text;
|
|
2013
|
+
let p = 0;
|
|
2014
|
+
while (p < a.length && p < b.length && a[p] === b[p]) p++;
|
|
2015
|
+
let s = 0;
|
|
2016
|
+
while (s < a.length - p && s < b.length - p && a[a.length - 1 - s] === b[b.length - 1 - s]) s++;
|
|
2017
|
+
if (p + s < Math.min(a.length, b.length) * 0.35) return; // rewritten, not tweaked
|
|
2018
|
+
x.mark = [p, a.length - s];
|
|
2019
|
+
y.mark = [p, b.length - s];
|
|
2020
|
+
}
|
|
2021
|
+
function codeHtml(r, lang) {
|
|
2022
|
+
if (!r.mark || r.mark[0] >= r.mark[1]) return (lang ? highlight(r.text, lang) : esc(r.text)) || ' ';
|
|
2023
|
+
const [s, e] = r.mark;
|
|
2024
|
+
return `${esc(r.text.slice(0, s))}<span class="wd">${esc(r.text.slice(s, e))}</span>${esc(r.text.slice(e))}`;
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
function relPath(p) {
|
|
2028
|
+
const cwd = S.run?.cwd;
|
|
2029
|
+
const a = String(p ?? '').replace(/\\/g, '/');
|
|
2030
|
+
const c = String(cwd ?? '').replace(/\\/g, '/').replace(/\/$/, '');
|
|
2031
|
+
return c && a.toLowerCase().startsWith(c.toLowerCase() + '/') ? a.slice(c.length + 1) : a;
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
function diffView(key, file, rows, numbered) {
|
|
2035
|
+
const LIMIT = 80;
|
|
2036
|
+
const full = S.fullPre.has(key) || rows.length <= LIMIT;
|
|
2037
|
+
const shown = full ? rows : rows.slice(0, LIMIT);
|
|
2038
|
+
const add = rows.filter((r) => r.t === 'add').length;
|
|
2039
|
+
const del = rows.filter((r) => r.t === 'del').length;
|
|
2040
|
+
const lnCols = numbered ? 2 : 0;
|
|
2041
|
+
const lang = langOf(file);
|
|
2042
|
+
const body = shown
|
|
2043
|
+
.map((r) =>
|
|
2044
|
+
r.t === 'hunk'
|
|
2045
|
+
? `<tr class="dl-h"><td colspan="${lnCols + 2}">${esc(r.text)}</td></tr>`
|
|
2046
|
+
: `<tr class="dl-${r.t}">${numbered ? `<td class="ln">${r.o ?? ''}</td><td class="ln">${r.n ?? ''}</td>` : ''}<td class="sg">${r.t === 'add' ? '+' : r.t === 'del' ? '−' : ''}</td><td class="cd">${codeHtml(r, lang)}</td></tr>`,
|
|
2047
|
+
)
|
|
2048
|
+
.join('');
|
|
2049
|
+
return `<div class="pl dfile"><span title="${esc(file)}">${esc(relPath(file))}</span><span class="dstat">${del ? `<span class="d">−${del}</span>` : ''}${add ? `<span class="a">+${add}</span>` : ''}</span></div>
|
|
2050
|
+
<div class="diff"><table>${body}</table></div>${full ? '' : `<button class="show-all" data-full="${esc(key)}">Show all ${rows.length} lines</button>`}`;
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
function transcriptHtml(rootId, chunks) {
|
|
2054
|
+
if (!chunks.length) return '<div class="loading">No output recorded for this item.</div>';
|
|
2055
|
+
const out = [];
|
|
2056
|
+
let scope = null;
|
|
2057
|
+
// Pair each action with its result even when parallel calls interleave them.
|
|
2058
|
+
const results = new Map();
|
|
2059
|
+
const tools = new Set(chunks.filter((c) => c.kind === 'tool').map((c) => c.node));
|
|
2060
|
+
for (const c of chunks) if (c.kind === 'result' && tools.has(c.node) && !results.has(c.node)) results.set(c.node, c);
|
|
2061
|
+
const paired = new Set([...results.values()]);
|
|
2062
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
2063
|
+
const c = chunks[i];
|
|
2064
|
+
const sc = scopeOf(c.node, rootId);
|
|
2065
|
+
if ((sc?.id ?? null) !== (scope?.id ?? null)) {
|
|
2066
|
+
scope = sc;
|
|
2067
|
+
if (sc) {
|
|
2068
|
+
const label = sc.type === 'blocker' ? 'Blocker' : sc.origin === 'subagent' ? 'Subagent' : 'Subtask';
|
|
2069
|
+
out.push(`<div class="scope ${sc.type === 'blocker' ? 'blocker' : sc.origin ?? ''}">↳ ${label}: ${esc(sc.title.replace(/^Subagent:\s*/, ''))}</div>`);
|
|
2070
|
+
} else out.push('<div class="scope back">↰ back to this step</div>');
|
|
2071
|
+
}
|
|
2072
|
+
switch (c.kind) {
|
|
2073
|
+
case 'prompt':
|
|
2074
|
+
out.push(`<div class="ev ev-prompt"><span class="who">${esc(c.label === 'Task given to subagent' ? 'Task for subagent' : 'You')}</span>${esc(c.text)}</div>`);
|
|
2075
|
+
break;
|
|
2076
|
+
case 'text':
|
|
2077
|
+
out.push(`<div class="ev ev-text">${md(c.text)}</div>`);
|
|
2078
|
+
break;
|
|
2079
|
+
case 'plan':
|
|
2080
|
+
out.push(`<div class="ev ev-text ev-plan"><span class="who">Plan</span>${md(c.text)}</div>`);
|
|
2081
|
+
break;
|
|
2082
|
+
case 'outcome':
|
|
2083
|
+
out.push(`<div class="ev ev-text ev-outcome"><span class="who">Result</span>${md(c.text)}</div>`);
|
|
2084
|
+
break;
|
|
2085
|
+
case 'thinking':
|
|
2086
|
+
out.push(`<details class="ev ev-think"><summary>Thinking…</summary><div>${esc(c.text)}</div></details>`);
|
|
2087
|
+
break;
|
|
2088
|
+
case 'notice': {
|
|
2089
|
+
const n = N(c.node);
|
|
2090
|
+
out.push(`<div class="ev ev-notice${n?.type === 'blocker' || n?.level === 'error' ? ' err' : ''}">${esc(c.text)}</div>`);
|
|
2091
|
+
break;
|
|
2092
|
+
}
|
|
2093
|
+
case 'tool': {
|
|
2094
|
+
const n = N(c.node) ?? { status: 'done', title: c.label, kind: 'other' };
|
|
2095
|
+
const result = results.get(c.node) ?? null;
|
|
2096
|
+
const open = S.openActs.has(c.node) || (c.node === rootId && !S.closedActs.has(c.node)) ? ' open' : '';
|
|
2097
|
+
const kindIcon = n.type === 'subtask' ? 'agent' : (n.kind ?? 'other');
|
|
2098
|
+
const detail = n.detail && n.detail !== n.title ? `<span class="ad">${esc(n.detail)}</span>` : '';
|
|
2099
|
+
out.push(`<details class="ev act s-${n.status}" data-act="${c.node}"${open}>
|
|
2100
|
+
<summary>${icon(kindIcon)}<span class="al">${esc(n.title)}</span>${n.retry ? '<span class="retry">retry</span>' : ''}${failBadge(n)}${detail}<span class="as">${stMark(n)}</span></summary>
|
|
2101
|
+
${n.issue ? `<div class="iss">${esc(n.issue)}</div>` : ''}
|
|
2102
|
+
${open ? inputHtml(c, n, result) : ''}
|
|
2103
|
+
${open && result && !(result.patch?.length && !result.failed) ? preBlock(`${c.node}:out`, result.failed ? 'Error' : 'Output', result.text || '(no output)', result.failed ? 'failed' : '') : ''}
|
|
2104
|
+
${open && !result ? '<div class="pl" style="padding-bottom:8px">Waiting for result…</div>' : ''}
|
|
2105
|
+
</details>`);
|
|
2106
|
+
break;
|
|
2107
|
+
}
|
|
2108
|
+
case 'result':
|
|
2109
|
+
if (paired.has(c)) break;
|
|
2110
|
+
out.push(`<div class="ev act">${preBlock(`${c.node}:r${c.id}`, 'Output', c.text)}</div>`);
|
|
2111
|
+
break;
|
|
2112
|
+
}
|
|
2113
|
+
}
|
|
2114
|
+
return out.join('');
|
|
2115
|
+
}
|
|
2116
|
+
|
|
2117
|
+
/** An action's input: a diff for file changes, the command or raw input otherwise. */
|
|
2118
|
+
function inputHtml(c, n, result) {
|
|
2119
|
+
const file = n.files?.[0] ?? String(c.text).split('\n')[0].replace(/^(?:Edit|Write)\s+/, '');
|
|
2120
|
+
if (result?.patch?.length && !result.failed) return diffView(`${c.node}:diff`, file, markWords(rowsFromPatch(result.patch)), true);
|
|
2121
|
+
if (n.kind === 'edit') return diffView(`${c.node}:diff`, file, markWords(rowsFromInput(c.text)), false);
|
|
2122
|
+
if (n.kind === 'command') return preBlock(`${c.node}:in`, 'Command', c.text, '', 'shell');
|
|
2123
|
+
if (n.kind === 'write' && langOf(file)) {
|
|
2124
|
+
const body = String(c.text).split('\n').slice(2).join('\n'); // drop the "Write <file>" header
|
|
2125
|
+
return preBlock(`${c.node}:in`, relPath(file), body, '', langOf(file));
|
|
2126
|
+
}
|
|
2127
|
+
return preBlock(`${c.node}:in`, 'Input', c.text);
|
|
2128
|
+
}
|
|
2129
|
+
|
|
2130
|
+
const TYPE_LABEL = (n) =>
|
|
2131
|
+
n.type === 'goal'
|
|
2132
|
+
? 'Task'
|
|
2133
|
+
: n.type === 'step'
|
|
2134
|
+
? n.origin === 'plan'
|
|
2135
|
+
? 'Planned step'
|
|
2136
|
+
: 'Step'
|
|
2137
|
+
: n.type === 'subtask'
|
|
2138
|
+
? n.origin === 'subagent'
|
|
2139
|
+
? 'Subagent'
|
|
2140
|
+
: 'Subtask'
|
|
2141
|
+
: n.type === 'blocker'
|
|
2142
|
+
? 'Blocker'
|
|
2143
|
+
: n.type === 'note'
|
|
2144
|
+
? 'Notice'
|
|
2145
|
+
: 'Action';
|
|
2146
|
+
const STATUS_LABEL = { active: 'In progress', done: 'Done', pending: 'Not started', failed: 'Failed', open: 'Open', resolved: 'Resolved', waiting: 'Interrupted' };
|
|
2147
|
+
|
|
2148
|
+
function secHtml(key, title, body, ctl = '') {
|
|
2149
|
+
return `<details class="d-sec" data-sec="${key}"${S.closedSecs.has(key) ? '' : ' open'}><summary>${CHEV}${title}${ctl ? `<span class="ctl">${ctl}</span>` : ''}</summary><div class="d-body">${body}</div></details>`;
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2152
|
+
/**
|
|
2153
|
+
* The details panel slides open and shut (fast) instead of snapping: its width animates while
|
|
2154
|
+
* its content keeps the final width, so the text doesn't rewrap as it goes.
|
|
2155
|
+
*/
|
|
2156
|
+
function panelAnim(el, open) {
|
|
2157
|
+
// Turned around halfway: carry on from the width it has now.
|
|
2158
|
+
const from = el.__anim && !el.hidden ? el.offsetWidth : null;
|
|
2159
|
+
el.__anim?.cancel();
|
|
2160
|
+
el.__closing = !open;
|
|
2161
|
+
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
|
2162
|
+
el.hidden = !open;
|
|
2163
|
+
el.__closing = false;
|
|
2164
|
+
return;
|
|
2165
|
+
}
|
|
2166
|
+
el.hidden = false;
|
|
2167
|
+
const w = el.offsetWidth;
|
|
2168
|
+
const px = `${w}px`;
|
|
2169
|
+
el.style.setProperty('--dw', px);
|
|
2170
|
+
el.classList.add('anim');
|
|
2171
|
+
const a = (el.__anim = el.animate(
|
|
2172
|
+
open ? [{ flexBasis: `${from ?? 0}px`, opacity: from === null ? 0.3 : 1 }, { flexBasis: px, opacity: 1 }] : [{ flexBasis: `${from ?? w}px`, opacity: 1 }, { flexBasis: '0px', opacity: 0.3 }],
|
|
2173
|
+
{ duration: open ? 180 : 160, easing: open ? 'cubic-bezier(.2,.7,.3,1)' : 'cubic-bezier(.4,0,.7,1)', fill: 'forwards' },
|
|
2174
|
+
));
|
|
2175
|
+
a.onfinish = () => {
|
|
2176
|
+
if (el.__anim !== a) return;
|
|
2177
|
+
el.__anim = null;
|
|
2178
|
+
el.classList.remove('anim');
|
|
2179
|
+
if (!open) {
|
|
2180
|
+
el.hidden = true;
|
|
2181
|
+
el.__closing = false;
|
|
2182
|
+
}
|
|
2183
|
+
a.cancel();
|
|
2184
|
+
};
|
|
2185
|
+
}
|
|
2186
|
+
|
|
2187
|
+
function renderDetail() {
|
|
2188
|
+
const el = $('#detail');
|
|
2189
|
+
const n = N(S.selected);
|
|
2190
|
+
if (!n || !S.run) {
|
|
2191
|
+
if (!el.hidden && !el.__closing) panelAnim(el, false);
|
|
2192
|
+
delete el.dataset.shown;
|
|
2193
|
+
return;
|
|
2194
|
+
}
|
|
2195
|
+
if (el.hidden || el.__closing) panelAnim(el, true);
|
|
2196
|
+
const meta = [
|
|
2197
|
+
n.startedAt ? `Started ${ago(n.startedAt)}` : '',
|
|
2198
|
+
n.startedAt && (n.endedAt || n.status === 'active') ? `${n.endedAt ? 'Took' : 'Running for'} ${dur(n.startedAt, liveEnd(n))}` : '',
|
|
2199
|
+
n.stats ? `${plural(n.stats.actions, 'action')}${n.stats.errors ? `, ${plural(n.stats.errors, 'error')}` : ''}` : '',
|
|
2200
|
+
n.models?.length ? modelsLabel(n) : '',
|
|
2201
|
+
n.usage ? `${usageLine(n.usage)} tokens` : '',
|
|
2202
|
+
].filter(Boolean);
|
|
2203
|
+
const chunks = rawFor(n.id);
|
|
2204
|
+
const scroll = el.scrollTop;
|
|
2205
|
+
const sameNode = el.dataset.shown === n.id;
|
|
2206
|
+
const ctx = n.type !== 'activity' ? tagsHtml(n.context, 60) : '';
|
|
2207
|
+
const hasActs = chunks?.some((c) => c.kind === 'tool');
|
|
2208
|
+
el.innerHTML = `<div class="d-head">
|
|
2209
|
+
<div class="d-top"><span class="d-type">${TYPE_LABEL(n)}</span><span class="badge s-${n.status}">${STATUS_LABEL[n.status] ?? esc(n.status)}</span><span class="grow"></span><button class="ibtn" data-close aria-label="Close (Esc)" title="Close (Esc)">${CLOSE}</button></div>
|
|
2210
|
+
<h2>${esc(n.title)}</h2>
|
|
2211
|
+
${n.type === 'goal' && n.intent ? `<div class="d-meta"><span>Intent · ${esc(n.intent)}</span></div>` : ''}
|
|
2212
|
+
<div class="d-meta">${meta.map((m) => `<span>${esc(m)}</span>`).join('')}</div>
|
|
2213
|
+
${crumbHtml(n)}
|
|
2214
|
+
</div>
|
|
2215
|
+
${ctx ? secHtml('ctx', 'Working with', ctx) : ''}
|
|
2216
|
+
${secHtml('tr', 'Transcript', chunks ? transcriptHtml(n.id, chunks) : '<div class="loading">Loading…</div>', hasActs ? '<a data-acts="open">Expand all</a><a data-acts="close">Collapse all</a>' : '')}`;
|
|
2217
|
+
el.dataset.shown = n.id;
|
|
2218
|
+
el.scrollTop = sameNode ? scroll : 0;
|
|
2219
|
+
// While following a live step, keep its newest output in view.
|
|
2220
|
+
if (S.follow && n.status === 'active' && sameNode && scroll + el.clientHeight >= el.scrollHeight - 140) el.scrollTop = el.scrollHeight;
|
|
2221
|
+
}
|
|
2222
|
+
|
|
2223
|
+
/** "Task › Step" for the detail header, with a jump back to the visual path. */
|
|
2224
|
+
function crumbHtml(n) {
|
|
2225
|
+
if (n.type === 'goal') return '';
|
|
2226
|
+
const chain = ancestors(n.id).slice(1).reverse();
|
|
2227
|
+
if (!chain.length) return '';
|
|
2228
|
+
const parts = chain.map((c) => `<span title="${esc(c.title)}">${esc(clip(c.title, 60))}</span>`).join('<span>›</span>');
|
|
2229
|
+
const link = S.view === 'actions' ? ` <a data-showpath="${n.id}">Show in path</a>` : '';
|
|
2230
|
+
return `<div class="crumb">${parts}${link}</div>`;
|
|
2231
|
+
}
|
|
2232
|
+
|
|
2233
|
+
// ───────────────────────────── actions view
|
|
2234
|
+
const KIND_LABELS = [
|
|
2235
|
+
['command', 'Commands'],
|
|
2236
|
+
['edit', 'Edits'],
|
|
2237
|
+
['write', 'Writes'],
|
|
2238
|
+
['read', 'Reads'],
|
|
2239
|
+
['search', 'Searches'],
|
|
2240
|
+
['web', 'Web'],
|
|
2241
|
+
['agent', 'Subagents'],
|
|
2242
|
+
['ask', 'Questions'],
|
|
2243
|
+
['other', 'Other'],
|
|
2244
|
+
];
|
|
2245
|
+
|
|
2246
|
+
function loadActions() {
|
|
2247
|
+
if (!S.runId || S.actionsLoading || !S.actionsStale) return;
|
|
2248
|
+
const wait = Math.max(0, 800 - (Date.now() - S.actionsAt));
|
|
2249
|
+
S.actionsLoading = true;
|
|
2250
|
+
const runId = S.runId;
|
|
2251
|
+
setTimeout(async () => {
|
|
2252
|
+
try {
|
|
2253
|
+
S.actionsStale = false;
|
|
2254
|
+
const res = await fetch(`/api/s/${runId}/actions`);
|
|
2255
|
+
if (res.ok && S.runId === runId) S.actions = (await res.json()).actions;
|
|
2256
|
+
} finally {
|
|
2257
|
+
S.actionsLoading = false;
|
|
2258
|
+
S.actionsAt = Date.now();
|
|
2259
|
+
schedule();
|
|
2260
|
+
}
|
|
2261
|
+
}, S.actions ? wait : 0);
|
|
2262
|
+
}
|
|
2263
|
+
|
|
2264
|
+
/** Action rows with live status from the node map (it updates before the next fetch). */
|
|
2265
|
+
function liveActions() {
|
|
2266
|
+
return (S.actions ?? []).map((r) => {
|
|
2267
|
+
const n = N(r.id);
|
|
2268
|
+
return n ? { ...r, status: n.status, issue: n.issue, retry: n.retry, recovered: n.recovered, endedAt: n.endedAt } : r;
|
|
2269
|
+
});
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2272
|
+
function inGoal(r) {
|
|
2273
|
+
if (S.af.goal === 'all') return true;
|
|
2274
|
+
const g = S.af.goal === 'current' ? S.run?.currentGoal : S.af.goal;
|
|
2275
|
+
return r.goalId === g;
|
|
2276
|
+
}
|
|
2277
|
+
|
|
2278
|
+
function filteredActions() {
|
|
2279
|
+
const q = S.af.q.trim().toLowerCase();
|
|
2280
|
+
return liveActions().filter(
|
|
2281
|
+
(r) =>
|
|
2282
|
+
inGoal(r) &&
|
|
2283
|
+
(!S.af.kinds.size || S.af.kinds.has(r.kind)) &&
|
|
2284
|
+
(S.af.errors === 'all' || (S.af.errors === 'open' ? isUnresolved(r) : r.status === 'failed' && !!r.recovered)) &&
|
|
2285
|
+
(!q || [r.title, r.command, r.input, r.step, r.issue, r.subagent].some((x) => x && x.toLowerCase().includes(q))),
|
|
2286
|
+
);
|
|
2287
|
+
}
|
|
2288
|
+
|
|
2289
|
+
/** "−2 +3" for an edit, counted from its recorded change (skipped when the input was capped). */
|
|
2290
|
+
function diffStat(r) {
|
|
2291
|
+
if (r.diff) return diffBadge(r.diff.del, r.diff.add);
|
|
2292
|
+
if (r.kind !== 'edit' || !r.input || r.input.endsWith('\n…')) return '';
|
|
2293
|
+
let add = 0;
|
|
2294
|
+
let del = 0;
|
|
2295
|
+
for (const l of r.input.split('\n')) {
|
|
2296
|
+
if (l.startsWith('+ ')) add++;
|
|
2297
|
+
else if (l.startsWith('- ')) del++;
|
|
2298
|
+
}
|
|
2299
|
+
return diffBadge(del, add);
|
|
2300
|
+
}
|
|
2301
|
+
function diffBadge(del, add) {
|
|
2302
|
+
return add || del ? `<span class="dstat" title="${del} removed, ${add} added lines">${del ? `<span class="d">−${del}</span>` : ''}${add ? `<span class="a">+${add}</span>` : ''}</span>` : '';
|
|
2303
|
+
}
|
|
2304
|
+
|
|
2305
|
+
/** "recovered" / "failed" next to a failed action. */
|
|
2306
|
+
function failBadge(n) {
|
|
2307
|
+
if (n.status !== 'failed') return '';
|
|
2308
|
+
return n.recovered
|
|
2309
|
+
? '<span class="fb rec" title="Something succeeded after this, in the same step">recovered</span>'
|
|
2310
|
+
: '<span class="fb open" title="Nothing has succeeded after this in its step yet">failed</span>';
|
|
2311
|
+
}
|
|
2312
|
+
|
|
2313
|
+
function actionRowHtml(r) {
|
|
2314
|
+
const sel = S.selected === r.id ? ' sel' : '';
|
|
2315
|
+
let sub = '';
|
|
2316
|
+
if (r.command !== undefined) sub = `$ ${r.short ?? firstOf(r.command)}`;
|
|
2317
|
+
else if (['read', 'edit', 'write'].includes(r.kind) && r.input && !r.input.startsWith('{')) sub = firstOf(r.input);
|
|
2318
|
+
const where = [r.subagent ? `<span class="sa">subagent · ${esc(r.subagent)}</span>` : '', r.step && r.step !== r.title ? esc(r.step) : ''].filter(Boolean).join(' › ');
|
|
2319
|
+
return `<div class="ar s-${r.status}${sel}" data-arow="${r.id}">
|
|
2320
|
+
<span class="t">${esc(clock(r.startedAt))}</span>
|
|
2321
|
+
<span class="ic">${icon(r.kind)}</span>
|
|
2322
|
+
<div style="min-width:0">
|
|
2323
|
+
<div class="lbl">${esc(r.title)}${r.retry ? '<span class="retry">retry</span>' : ''}${failBadge(r)}</div>
|
|
2324
|
+
${sub ? `<div class="cmd" title="${esc(r.command ?? r.input)}">${r.command !== undefined ? `<span class="tk-o">$</span> ${highlight(sub.replace(/^\$ /, ''), 'shell')}` : esc(sub)}</div>` : ''}
|
|
2325
|
+
${r.status === 'failed' && r.issue ? `<div class="iss">${esc(r.issue)}</div>` : ''}
|
|
2326
|
+
${where ? `<div class="wh">${where}</div>` : ''}
|
|
2327
|
+
</div>
|
|
2328
|
+
<div class="rt">${diffStat(r)}${r.endedAt ? esc(dur(r.startedAt, r.endedAt)) : ''}<span class="${r.status === 'failed' ? 'no' : r.status === 'done' ? 'ok' : ''}">${stMark(r)}</span></div>
|
|
2329
|
+
</div>`;
|
|
2330
|
+
}
|
|
2331
|
+
|
|
2332
|
+
function renderActions() {
|
|
2333
|
+
const all = liveActions().filter(inGoal);
|
|
2334
|
+
const counts = {};
|
|
2335
|
+
for (const r of all) counts[r.kind] = (counts[r.kind] ?? 0) + 1;
|
|
2336
|
+
const chips = [`<button class="chip-f${S.af.kinds.size ? '' : ' on'}" data-kind="all">All <span class="n">${all.length}</span></button>`];
|
|
2337
|
+
for (const [k, label] of KIND_LABELS) {
|
|
2338
|
+
if (!counts[k]) continue;
|
|
2339
|
+
chips.push(`<button class="chip-f${S.af.kinds.has(k) ? ' on' : ''}" data-kind="${k}">${SVG(PATHS[k])}${label} <span class="n">${counts[k]}</span></button>`);
|
|
2340
|
+
}
|
|
2341
|
+
$('#kindFilters').innerHTML = chips.join('');
|
|
2342
|
+
const errors = all.filter((r) => r.status === 'failed').length;
|
|
2343
|
+
const open = all.filter(isUnresolved).length;
|
|
2344
|
+
$('#errFilter').innerHTML = [
|
|
2345
|
+
['all', 'Everything', null],
|
|
2346
|
+
['open', 'Failed', open],
|
|
2347
|
+
['errors', 'Recovered', errors - open],
|
|
2348
|
+
]
|
|
2349
|
+
.map(([k, label, n]) => `<button type="button" data-err="${k}" class="${S.af.errors === k ? 'on' : ''}" ${n === 0 ? 'disabled' : ''}>${label}${n ? ` <b class="${k === 'open' ? 'bad' : ''}">${n}</b>` : ''}</button>`)
|
|
2350
|
+
.join('');
|
|
2351
|
+
|
|
2352
|
+
// task selector: rebuilt only when the set of tasks changes
|
|
2353
|
+
const sel = $('#actionsGoal');
|
|
2354
|
+
const key = S.run.goals.join(',');
|
|
2355
|
+
if (sel.dataset.key !== key) {
|
|
2356
|
+
const opts = ['<option value="all">All tasks</option>', '<option value="current">Current task</option>'];
|
|
2357
|
+
for (const id of [...S.run.goals].reverse()) {
|
|
2358
|
+
const g = N(id);
|
|
2359
|
+
if (g?.stats?.actions) opts.push(`<option value="${id}">${esc(clip(g.title, 70))}</option>`);
|
|
2360
|
+
}
|
|
2361
|
+
sel.innerHTML = opts.join('');
|
|
2362
|
+
sel.dataset.key = key;
|
|
2363
|
+
}
|
|
2364
|
+
sel.value = S.af.goal;
|
|
2365
|
+
if (sel.value !== S.af.goal) {
|
|
2366
|
+
S.af.goal = 'all';
|
|
2367
|
+
sel.value = 'all';
|
|
2368
|
+
}
|
|
2369
|
+
|
|
2370
|
+
const view = $('#actionsView');
|
|
2371
|
+
if (!S.actions) {
|
|
2372
|
+
view.innerHTML = '<div class="no-match">Loading actions…</div>';
|
|
2373
|
+
return;
|
|
2374
|
+
}
|
|
2375
|
+
const rows = filteredActions();
|
|
2376
|
+
if (!rows.length) {
|
|
2377
|
+
view.innerHTML = `<div class="no-match">${all.length ? 'No actions match these filters.' : 'No actions yet.'}</div>`;
|
|
2378
|
+
return;
|
|
2379
|
+
}
|
|
2380
|
+
const hidden = Math.max(0, rows.length - S.actionsLimit);
|
|
2381
|
+
const shown = rows.slice(hidden);
|
|
2382
|
+
const groups = [];
|
|
2383
|
+
for (const r of shown) {
|
|
2384
|
+
const last = groups[groups.length - 1];
|
|
2385
|
+
if (last && last.id === r.goalId) last.rows.push(r);
|
|
2386
|
+
else groups.push({ id: r.goalId, title: r.goal ?? 'Session', rows: [r] });
|
|
2387
|
+
}
|
|
2388
|
+
const out = [];
|
|
2389
|
+
if (hidden) out.push(`<button class="tbtn a-more" id="actionsMore">Show ${Math.min(400, hidden)} earlier · ${hidden} hidden</button>`);
|
|
2390
|
+
for (const g of groups) {
|
|
2391
|
+
const errs = g.rows.filter((r) => r.status === 'failed').length;
|
|
2392
|
+
out.push(`<div class="ag"><div class="ag-h"><span class="agt" title="${esc(g.title)}">${esc(g.title)}</span><span class="agm">${plural(g.rows.length, 'action')}${errs ? ` · <span class="bad">${errs} failed</span>` : ''}</span></div>
|
|
2393
|
+
<div class="ag-b">${g.rows.map(actionRowHtml).join('')}</div></div>`);
|
|
2394
|
+
}
|
|
2395
|
+
view.innerHTML = out.join('');
|
|
2396
|
+
}
|
|
2397
|
+
|
|
2398
|
+
function setView(v) {
|
|
2399
|
+
if (v === 'simple' || v === 'detailed' || v === 'files') {
|
|
2400
|
+
S.layout = v;
|
|
2401
|
+
localStorage.setItem('lucidly.layout', v);
|
|
2402
|
+
v = 'path';
|
|
2403
|
+
}
|
|
2404
|
+
if (v !== 'path' && v !== 'actions') return;
|
|
2405
|
+
S.view = v;
|
|
2406
|
+
if (S.follow) S.lastFollowTarget = null;
|
|
2407
|
+
writeHash();
|
|
2408
|
+
schedule();
|
|
2409
|
+
}
|
|
2410
|
+
|
|
2411
|
+
function writeHash() {
|
|
2412
|
+
if (!S.runId) return;
|
|
2413
|
+
history.replaceState(null, '', `#run=${S.runId}${S.view === 'actions' ? '&view=actions' : ''}`);
|
|
2414
|
+
}
|
|
2415
|
+
|
|
2416
|
+
function toast(text) {
|
|
2417
|
+
const t = document.createElement('div');
|
|
2418
|
+
t.className = 'toast';
|
|
2419
|
+
t.textContent = text;
|
|
2420
|
+
document.body.append(t);
|
|
2421
|
+
setTimeout(() => t.remove(), 2200);
|
|
2422
|
+
}
|
|
2423
|
+
|
|
2424
|
+
async function copyCommands() {
|
|
2425
|
+
const rows = filteredActions().filter((r) => r.command !== undefined);
|
|
2426
|
+
if (!rows.length) return toast('No commands in this list');
|
|
2427
|
+
const lines = [];
|
|
2428
|
+
let goal = null;
|
|
2429
|
+
for (const r of rows) {
|
|
2430
|
+
if (r.goalId !== goal) {
|
|
2431
|
+
goal = r.goalId;
|
|
2432
|
+
lines.push(`${lines.length ? '\n' : ''}# ── ${r.goal ?? 'Session'}`);
|
|
2433
|
+
}
|
|
2434
|
+
if (r.status === 'failed') lines.push(`# ✖ failed: ${r.issue ?? ''}`);
|
|
2435
|
+
lines.push(r.command);
|
|
2436
|
+
}
|
|
2437
|
+
try {
|
|
2438
|
+
await navigator.clipboard.writeText(lines.join('\n') + '\n');
|
|
2439
|
+
toast(`Copied ${plural(rows.length, 'command')}`);
|
|
2440
|
+
} catch {
|
|
2441
|
+
toast('Clipboard not available');
|
|
2442
|
+
}
|
|
2443
|
+
}
|
|
2444
|
+
|
|
2445
|
+
/** Opens every task and step on the way to a node, so it is visible in the path. */
|
|
2446
|
+
function reveal(id) {
|
|
2447
|
+
for (const n of ancestors(id)) {
|
|
2448
|
+
if (n.type === 'goal') {
|
|
2449
|
+
S.closedGoals.delete(n.id);
|
|
2450
|
+
S.openGoals.add(n.id);
|
|
2451
|
+
} else if (n.type === 'step' && n.id !== id) {
|
|
2452
|
+
S.closedSteps.delete(n.id);
|
|
2453
|
+
S.openSteps.add(n.id);
|
|
2454
|
+
}
|
|
2455
|
+
}
|
|
2456
|
+
}
|
|
2457
|
+
|
|
2458
|
+
function findEl(id) {
|
|
2459
|
+
const root = $('#pathView');
|
|
2460
|
+
for (const n of ancestors(id)) {
|
|
2461
|
+
const el = root.querySelector(`[data-node="${n.id}"]`);
|
|
2462
|
+
if (el) return el;
|
|
2463
|
+
}
|
|
2464
|
+
return null;
|
|
2465
|
+
}
|
|
2466
|
+
|
|
2467
|
+
// ───────────────────────────── render loop
|
|
2468
|
+
function schedule() {
|
|
2469
|
+
if (S.queued) return;
|
|
2470
|
+
S.queued = true;
|
|
2471
|
+
requestAnimationFrame(() => {
|
|
2472
|
+
S.queued = false;
|
|
2473
|
+
render();
|
|
2474
|
+
});
|
|
2475
|
+
}
|
|
2476
|
+
|
|
2477
|
+
function render() {
|
|
2478
|
+
// Following with the panel closed scrolls along without opening it.
|
|
2479
|
+
if (S.follow && !S.followQuiet) {
|
|
2480
|
+
const t = followTarget();
|
|
2481
|
+
if (t) S.selected = t;
|
|
2482
|
+
}
|
|
2483
|
+
if (S.firstPaint && S.run) {
|
|
2484
|
+
// First picture of a session: the overview at the top, no panel until you pick something.
|
|
2485
|
+
S.firstPaint = false;
|
|
2486
|
+
S.selected = null;
|
|
2487
|
+
$('#pane').scrollTop = 0;
|
|
2488
|
+
}
|
|
2489
|
+
if (S.selected && !N(S.selected)) S.selected = null;
|
|
2490
|
+
S.heatMap = computeHeat();
|
|
2491
|
+
$('#pane').classList.toggle('heat-on', S.heat);
|
|
2492
|
+
renderHeader();
|
|
2493
|
+
const has = !!S.run;
|
|
2494
|
+
const pane = $('#pane');
|
|
2495
|
+
$('#empty').hidden = has;
|
|
2496
|
+
$('#overview').hidden = !has;
|
|
2497
|
+
pane.classList.toggle('pin-ov', has && S.pinOv);
|
|
2498
|
+
if (!S.pinOv) $('#overview').classList.remove('compact');
|
|
2499
|
+
$('#tbPath').hidden = $('#pathView').hidden = !has || S.view !== 'path';
|
|
2500
|
+
$('#tbActions').hidden = $('#actionsView').hidden = !has || S.view !== 'actions';
|
|
2501
|
+
if (has) {
|
|
2502
|
+
const top = pane.scrollTop;
|
|
2503
|
+
loadActions();
|
|
2504
|
+
renderOverview();
|
|
2505
|
+
if (S.view === 'path') renderPath();
|
|
2506
|
+
else renderActions();
|
|
2507
|
+
pane.scrollTop = top;
|
|
2508
|
+
}
|
|
2509
|
+
renderDetail();
|
|
2510
|
+
syncAnimations();
|
|
2511
|
+
requestAnimationFrame(updateToEnd);
|
|
2512
|
+
if (!has || !S.follow) return;
|
|
2513
|
+
if (S.view === 'actions') {
|
|
2514
|
+
pane.scrollTop = pane.scrollHeight;
|
|
2515
|
+
return;
|
|
2516
|
+
}
|
|
2517
|
+
const t = S.followQuiet ? followTarget() : S.selected;
|
|
2518
|
+
const el = t && findEl(t);
|
|
2519
|
+
if (el && S.lastFollowTarget !== t) el.scrollIntoView({ block: 'center', behavior: S.lastFollowTarget ? 'smooth' : 'auto' });
|
|
2520
|
+
else if (el) el.scrollIntoView({ block: 'nearest' });
|
|
2521
|
+
S.lastFollowTarget = t;
|
|
2522
|
+
}
|
|
2523
|
+
|
|
2524
|
+
// ───────────────────────────── connection
|
|
2525
|
+
function connect(runId) {
|
|
2526
|
+
S.es?.close();
|
|
2527
|
+
// A session opens at the top, on the timeline; the arrow button (or F) jumps to the end.
|
|
2528
|
+
Object.assign(S, { runId, run: null, nodes: {}, selected: null, follow: false, raw: new Map(), lastFollowTarget: null, expandAll: false, firstPaint: true, seenActs: null });
|
|
2529
|
+
for (const set of [S.openGoals, S.closedGoals, S.openSteps, S.closedSteps, S.allActs, S.openActs, S.closedActs]) set.clear();
|
|
2530
|
+
Object.assign(S, { actions: null, actionsStale: true, actionsLimit: 400, stripRows: [], zoom: { a: 0, b: 1 } });
|
|
2531
|
+
writeHash();
|
|
2532
|
+
const es = new EventSource(`/api/s/${runId}/stream`);
|
|
2533
|
+
S.es = es;
|
|
2534
|
+
es.addEventListener('snapshot', (e) => {
|
|
2535
|
+
const snap = JSON.parse(e.data);
|
|
2536
|
+
S.run = snap.run;
|
|
2537
|
+
S.nodes = snap.nodes;
|
|
2538
|
+
S.actionsStale = true;
|
|
2539
|
+
for (const v of S.raw.values()) v.stale = true;
|
|
2540
|
+
schedule();
|
|
2541
|
+
});
|
|
2542
|
+
es.addEventListener('patch', (e) => {
|
|
2543
|
+
const p = JSON.parse(e.data);
|
|
2544
|
+
S.run = p.run;
|
|
2545
|
+
S.actionsStale = true;
|
|
2546
|
+
for (const [id, n] of Object.entries(p.nodes)) {
|
|
2547
|
+
if (n === null) {
|
|
2548
|
+
invalidateRaw(S.nodes[id]?.parent ?? '');
|
|
2549
|
+
delete S.nodes[id];
|
|
2550
|
+
S.raw.delete(id);
|
|
2551
|
+
} else {
|
|
2552
|
+
S.nodes[id] = n;
|
|
2553
|
+
invalidateRaw(id);
|
|
2554
|
+
}
|
|
2555
|
+
}
|
|
2556
|
+
schedule();
|
|
2557
|
+
});
|
|
2558
|
+
es.onerror = async () => {
|
|
2559
|
+
try {
|
|
2560
|
+
const res = await fetch('/api/runs');
|
|
2561
|
+
const { runs } = await res.json();
|
|
2562
|
+
if (!runs.some((r) => r.id === runId) && S.runId === runId) {
|
|
2563
|
+
es.close();
|
|
2564
|
+
S.run = null;
|
|
2565
|
+
S.runId = null;
|
|
2566
|
+
history.replaceState(null, '', '#');
|
|
2567
|
+
schedule();
|
|
2568
|
+
openPicker();
|
|
2569
|
+
}
|
|
2570
|
+
} catch {
|
|
2571
|
+
/* server down; EventSource keeps retrying */
|
|
2572
|
+
}
|
|
2573
|
+
};
|
|
2574
|
+
}
|
|
2575
|
+
|
|
2576
|
+
// ───────────────────────────── picker
|
|
2577
|
+
async function openPicker() {
|
|
2578
|
+
const dlg = $('#picker');
|
|
2579
|
+
if (!dlg.open) dlg.showModal();
|
|
2580
|
+
for (const b of dlg.querySelectorAll('[data-scope]')) b.classList.toggle('on', b.dataset.scope === S.pickerScope);
|
|
2581
|
+
const list = $('#pickerList');
|
|
2582
|
+
list.innerHTML = '<li class="mute">Loading…</li>';
|
|
2583
|
+
const [{ runs }, data] = await Promise.all([
|
|
2584
|
+
fetch('/api/runs').then((r) => r.json()),
|
|
2585
|
+
fetch(`/api/sessions?scope=${S.pickerScope}`).then((r) => r.json()),
|
|
2586
|
+
]);
|
|
2587
|
+
const others = runs.filter((r) => !r.key.startsWith('claude:'));
|
|
2588
|
+
$('#pickerOpen').innerHTML = others.length
|
|
2589
|
+
? `<div class="psec">Open in Lucidly</div><ul class="plist">${others
|
|
2590
|
+
.map((r) => `<li data-run="${r.id}"><span class="dot${r.live ? ' live' : ''}"></span><div class="pm"><div class="pt">${esc(r.title ?? r.label)}</div><div class="ps">${esc(r.live ? 'Live stream' : 'Saved run')} · ${r.eventCount} events</div></div></li>`)
|
|
2591
|
+
.join('')}</ul>`
|
|
2592
|
+
: '';
|
|
2593
|
+
if (!data.sessions.length) {
|
|
2594
|
+
list.innerHTML = `<li class="mute">${S.pickerScope === 'project' ? `No Claude Code sessions for ${esc(data.cwd)} yet. Try “All projects”.` : 'No Claude Code sessions found.'}</li>`;
|
|
2595
|
+
return;
|
|
2596
|
+
}
|
|
2597
|
+
list.innerHTML = data.sessions
|
|
2598
|
+
.map((s) => {
|
|
2599
|
+
const title = s.title ?? s.firstPrompt ?? s.id;
|
|
2600
|
+
const sub = [s.title ? s.firstPrompt : '', S.pickerScope === 'all' ? s.cwd ?? s.project : ''].filter(Boolean).join(' · ');
|
|
2601
|
+
return `<li data-path="${esc(s.path)}" title="${esc(s.path)}">
|
|
2602
|
+
<span class="dot${s.live ? ' live' : ''}" title="${s.live ? 'Active in the last few minutes' : ''}"></span>
|
|
2603
|
+
<div class="pm"><div class="pt">${esc(title)}</div><div class="ps">${esc(sub)}</div></div>
|
|
2604
|
+
<span class="pw">${esc(ago(s.mtime))}</span></li>`;
|
|
2605
|
+
})
|
|
2606
|
+
.join('');
|
|
2607
|
+
}
|
|
2608
|
+
|
|
2609
|
+
async function openSession(path) {
|
|
2610
|
+
const res = await fetch('/api/open', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ path }) });
|
|
2611
|
+
const data = await res.json();
|
|
2612
|
+
if (!res.ok) return alert(data.error ?? 'Could not open the session');
|
|
2613
|
+
$('#picker').close();
|
|
2614
|
+
connect(data.run.id);
|
|
2615
|
+
}
|
|
2616
|
+
|
|
2617
|
+
async function loadFile(file) {
|
|
2618
|
+
const text = await file.text();
|
|
2619
|
+
const res = await fetch('/api/load', { method: 'POST', headers: { 'content-type': 'application/json' }, body: text });
|
|
2620
|
+
const data = await res.json();
|
|
2621
|
+
if (!res.ok) return alert(data.error ?? 'Could not load the file');
|
|
2622
|
+
connect(data.run.id);
|
|
2623
|
+
}
|
|
2624
|
+
|
|
2625
|
+
// ───────────────────────────── interaction
|
|
2626
|
+
// ───────────────────────────── keep awake (the Screen Wake Lock API)
|
|
2627
|
+
/*
|
|
2628
|
+
* Holds a screen wake lock so the device doesn't sleep. "Only while the agent works" holds it
|
|
2629
|
+
* while a turn is running and lets it go once the session is idle. Browsers drop the lock when
|
|
2630
|
+
* the tab is hidden; it is taken again as soon as the tab is visible.
|
|
2631
|
+
*/
|
|
2632
|
+
const QUIET_LIMIT = 30 * 60 * 1000; // a "working" session with no news for this long has stopped
|
|
2633
|
+
S.wake = {
|
|
2634
|
+
on: localStorage.getItem('lucidly.wake') === '1',
|
|
2635
|
+
onlyWorking: localStorage.getItem('lucidly.wakeWorking') !== '0',
|
|
2636
|
+
lock: null,
|
|
2637
|
+
pending: false,
|
|
2638
|
+
err: '',
|
|
2639
|
+
};
|
|
2640
|
+
const wakeSupported = () => 'wakeLock' in navigator;
|
|
2641
|
+
const agentWorking = () => !!S.run && S.run.status === 'working' && !!S.run.updatedAt && Date.now() - Date.parse(S.run.updatedAt) < QUIET_LIMIT;
|
|
2642
|
+
const wakeWanted = () => S.wake.on && wakeSupported() && document.visibilityState === 'visible' && (!S.wake.onlyWorking || agentWorking());
|
|
2643
|
+
|
|
2644
|
+
async function syncWake() {
|
|
2645
|
+
const w = S.wake;
|
|
2646
|
+
if (wakeWanted() && !w.lock && !w.pending) {
|
|
2647
|
+
w.pending = true;
|
|
2648
|
+
try {
|
|
2649
|
+
const lock = await navigator.wakeLock.request('screen');
|
|
2650
|
+
lock.addEventListener('release', () => {
|
|
2651
|
+
// Released by the browser (tab hidden, battery saver...): take it again when we can.
|
|
2652
|
+
if (w.lock !== lock) return;
|
|
2653
|
+
w.lock = null;
|
|
2654
|
+
renderWake();
|
|
2655
|
+
});
|
|
2656
|
+
w.lock = lock;
|
|
2657
|
+
w.err = '';
|
|
2658
|
+
} catch (e) {
|
|
2659
|
+
w.err = e?.message || 'The browser refused';
|
|
2660
|
+
}
|
|
2661
|
+
w.pending = false;
|
|
2662
|
+
if (!wakeWanted()) return syncWake(); // things changed while we waited
|
|
2663
|
+
} else if (!wakeWanted() && w.lock) {
|
|
2664
|
+
const lock = w.lock;
|
|
2665
|
+
w.lock = null;
|
|
2666
|
+
lock.release().catch(() => {});
|
|
2667
|
+
}
|
|
2668
|
+
renderWake();
|
|
2669
|
+
}
|
|
2670
|
+
document.addEventListener('visibilitychange', () => void syncWake());
|
|
2671
|
+
setInterval(() => void syncWake(), 20000); // a working session that falls quiet lets go in time
|
|
2672
|
+
|
|
2673
|
+
function wakeState() {
|
|
2674
|
+
const w = S.wake;
|
|
2675
|
+
if (!w.on) return ['off', 'Off: the device sleeps as usual'];
|
|
2676
|
+
if (!wakeSupported()) return ['err', "This browser can't keep the device awake"];
|
|
2677
|
+
if (w.err) return ['err', `Couldn't keep the device awake: ${w.err}`];
|
|
2678
|
+
if (w.lock) return ['awake', w.onlyWorking ? 'Awake while the agent works' : 'Keeping the device awake'];
|
|
2679
|
+
if (document.visibilityState !== 'visible') return ['wait', 'Paused while this tab is hidden'];
|
|
2680
|
+
if (w.onlyWorking && !agentWorking()) return ['wait', 'The agent is idle: the device may sleep. Awake again as soon as it works'];
|
|
2681
|
+
return ['wait', 'Starting…'];
|
|
2682
|
+
}
|
|
2683
|
+
|
|
2684
|
+
function renderWake() {
|
|
2685
|
+
const [st, text] = wakeState();
|
|
2686
|
+
const btn = $('#wakeBtn');
|
|
2687
|
+
btn.dataset.wake = st;
|
|
2688
|
+
const label = st === 'off' ? 'Keep this device awake' : `Keep awake: ${text.toLowerCase()}`;
|
|
2689
|
+
if (btn.title !== label) {
|
|
2690
|
+
btn.title = label;
|
|
2691
|
+
btn.setAttribute('aria-label', label);
|
|
2692
|
+
}
|
|
2693
|
+
const pop = $('#wakePop');
|
|
2694
|
+
if (pop.hidden) return;
|
|
2695
|
+
const w = S.wake;
|
|
2696
|
+
setHTML(
|
|
2697
|
+
pop,
|
|
2698
|
+
`<div class="pop-h">Keep awake</div>
|
|
2699
|
+
<label class="switch wk-row"><input type="checkbox" data-wake="on"${w.on ? ' checked' : ''} /><span></span>Keep this device from sleeping</label>
|
|
2700
|
+
${w.on ? `<label class="wk-opt"><input type="checkbox" data-wake="working"${w.onlyWorking ? ' checked' : ''} /><span><b>Only while the agent is working</b><small>Once the work is done and the session is idle, the device can sleep again.</small></span></label>` : ''}
|
|
2701
|
+
<div class="wk-st st-${st}"><i></i>${esc(text)}</div>
|
|
2702
|
+
<p class="wk-note">Works while this tab is visible: a hidden or minimised tab lets the device sleep.</p>`,
|
|
2703
|
+
);
|
|
2704
|
+
}
|
|
2705
|
+
|
|
2706
|
+
function toggleWake(show = $('#wakePop').hidden) {
|
|
2707
|
+
$('#wakePop').hidden = !show;
|
|
2708
|
+
$('#wakeBtn').classList.toggle('on', show);
|
|
2709
|
+
renderWake();
|
|
2710
|
+
}
|
|
2711
|
+
|
|
2712
|
+
document.addEventListener('change', (e) => {
|
|
2713
|
+
const t = e.target;
|
|
2714
|
+
if (!(t instanceof HTMLInputElement) || !t.dataset.wake) return;
|
|
2715
|
+
if (t.dataset.wake === 'on') {
|
|
2716
|
+
S.wake.on = t.checked;
|
|
2717
|
+
S.wake.err = '';
|
|
2718
|
+
localStorage.setItem('lucidly.wake', t.checked ? '1' : '0');
|
|
2719
|
+
} else {
|
|
2720
|
+
S.wake.onlyWorking = t.checked;
|
|
2721
|
+
localStorage.setItem('lucidly.wakeWorking', t.checked ? '1' : '0');
|
|
2722
|
+
}
|
|
2723
|
+
void syncWake();
|
|
2724
|
+
});
|
|
2725
|
+
|
|
2726
|
+
function toggleTokens(show = $('#tokensPop').hidden) {
|
|
2727
|
+
$('#tokensPop').hidden = !show;
|
|
2728
|
+
$('#tokens').classList.toggle('on', show);
|
|
2729
|
+
renderTokens();
|
|
2730
|
+
}
|
|
2731
|
+
|
|
2732
|
+
function toggleHeat() {
|
|
2733
|
+
S.heat = !S.heat;
|
|
2734
|
+
localStorage.setItem('lucidly.heat', S.heat ? '1' : '0');
|
|
2735
|
+
schedule();
|
|
2736
|
+
}
|
|
2737
|
+
|
|
2738
|
+
function togglePin() {
|
|
2739
|
+
S.pinOv = !S.pinOv;
|
|
2740
|
+
localStorage.setItem('lucidly.pinOverview', S.pinOv ? '1' : '0');
|
|
2741
|
+
schedule();
|
|
2742
|
+
}
|
|
2743
|
+
|
|
2744
|
+
// The toolbar sits right under a pinned overview: track its height.
|
|
2745
|
+
// The Files header pins right under the toolbar: track the toolbar's height too.
|
|
2746
|
+
new ResizeObserver(() => $('#pane').style.setProperty('--tb-h', `${$('#tbPath').offsetHeight}px`)).observe($('#tbPath'));
|
|
2747
|
+
let ovH = 0;
|
|
2748
|
+
new ResizeObserver(() => {
|
|
2749
|
+
const ov = $('#overview');
|
|
2750
|
+
const pane = $('#pane');
|
|
2751
|
+
const h = ov.offsetHeight;
|
|
2752
|
+
pane.style.setProperty('--ov-h', `${h}px`);
|
|
2753
|
+
if (ovH && h !== ovH && S.pinOv && ov.classList.contains('compact')) pane.scrollTop += h - ovH;
|
|
2754
|
+
ovH = h;
|
|
2755
|
+
}).observe($('#overview'));
|
|
2756
|
+
|
|
2757
|
+
function setFollow(on) {
|
|
2758
|
+
S.follow = on;
|
|
2759
|
+
if (on) S.lastFollowTarget = null;
|
|
2760
|
+
schedule();
|
|
2761
|
+
}
|
|
2762
|
+
|
|
2763
|
+
function select(id) {
|
|
2764
|
+
S.selected = id;
|
|
2765
|
+
S.followQuiet = false;
|
|
2766
|
+
S.follow = id === followTarget();
|
|
2767
|
+
schedule();
|
|
2768
|
+
}
|
|
2769
|
+
|
|
2770
|
+
function toggleIn(open, id, openSet, closedSet) {
|
|
2771
|
+
if (open) {
|
|
2772
|
+
openSet.delete(id);
|
|
2773
|
+
closedSet.add(id);
|
|
2774
|
+
} else {
|
|
2775
|
+
closedSet.delete(id);
|
|
2776
|
+
openSet.add(id);
|
|
2777
|
+
}
|
|
2778
|
+
}
|
|
2779
|
+
|
|
2780
|
+
document.addEventListener('click', (e) => {
|
|
2781
|
+
const t = e.target;
|
|
2782
|
+
if (!(t instanceof Element)) return;
|
|
2783
|
+
const on = (sel) => t.closest(sel);
|
|
2784
|
+
let el;
|
|
2785
|
+
if (on('[data-close]')) {
|
|
2786
|
+
S.follow = false;
|
|
2787
|
+
S.selected = null;
|
|
2788
|
+
return schedule();
|
|
2789
|
+
}
|
|
2790
|
+
if (on('#tokens')) return toggleTokens();
|
|
2791
|
+
if (!on('#tokensPop') && !$('#tokensPop').hidden) toggleTokens(false);
|
|
2792
|
+
if (on('#wakeBtn')) return toggleWake();
|
|
2793
|
+
if (!on('#wakePop') && !$('#wakePop').hidden) toggleWake(false);
|
|
2794
|
+
if (on('#wakePop')) return;
|
|
2795
|
+
if (on('#emptyOpen')) return void openPicker();
|
|
2796
|
+
if ((el = on('[data-view]'))) return setView(el.dataset.view);
|
|
2797
|
+
if (on('[data-pin]')) return togglePin();
|
|
2798
|
+
if (on('#heatBtn')) return toggleHeat();
|
|
2799
|
+
if (on('[data-fsel]')) {
|
|
2800
|
+
S.fselOpen = !S.fselOpen;
|
|
2801
|
+
return schedule();
|
|
2802
|
+
}
|
|
2803
|
+
if (S.fselOpen && !on('.fsel-menu')) {
|
|
2804
|
+
S.fselOpen = false;
|
|
2805
|
+
schedule();
|
|
2806
|
+
}
|
|
2807
|
+
if ((el = on('[data-mode]'))) {
|
|
2808
|
+
S.fselOpen = false;
|
|
2809
|
+
S.pf.mode = el.dataset.mode;
|
|
2810
|
+
return schedule();
|
|
2811
|
+
}
|
|
2812
|
+
if ((el = on('[data-err]'))) {
|
|
2813
|
+
S.af.errors = el.dataset.err;
|
|
2814
|
+
S.actionsLimit = 400;
|
|
2815
|
+
return schedule();
|
|
2816
|
+
}
|
|
2817
|
+
if ((el = on('[data-kind]'))) {
|
|
2818
|
+
const k = el.dataset.kind;
|
|
2819
|
+
if (k === 'all') S.af.kinds.clear();
|
|
2820
|
+
else if (S.af.kinds.has(k)) S.af.kinds.delete(k);
|
|
2821
|
+
else S.af.kinds.add(k);
|
|
2822
|
+
S.actionsLimit = 400;
|
|
2823
|
+
return schedule();
|
|
2824
|
+
}
|
|
2825
|
+
if ((el = on('[data-arow]'))) return select(el.dataset.arow);
|
|
2826
|
+
if ((el = on('[data-flayout]'))) {
|
|
2827
|
+
S.ff.layout = el.dataset.flayout;
|
|
2828
|
+
return schedule();
|
|
2829
|
+
}
|
|
2830
|
+
if (on('[data-fhdiffs]')) {
|
|
2831
|
+
S.ff.histDiffs = !S.ff.histDiffs;
|
|
2832
|
+
return schedule();
|
|
2833
|
+
}
|
|
2834
|
+
if (on('[data-foutside]')) {
|
|
2835
|
+
S.ff.outside = !S.ff.outside;
|
|
2836
|
+
return schedule();
|
|
2837
|
+
}
|
|
2838
|
+
if ((el = on('[data-fsort]'))) {
|
|
2839
|
+
S.ff.sort = el.dataset.fsort;
|
|
2840
|
+
return schedule();
|
|
2841
|
+
}
|
|
2842
|
+
if ((el = on('[data-fdiffs]'))) {
|
|
2843
|
+
const k = el.dataset.fdiffs;
|
|
2844
|
+
if (S.ff.diffs.has(k)) S.ff.diffs.delete(k);
|
|
2845
|
+
else S.ff.diffs.add(k);
|
|
2846
|
+
return schedule();
|
|
2847
|
+
}
|
|
2848
|
+
if ((el = on('[data-fe]'))) {
|
|
2849
|
+
S.follow = false;
|
|
2850
|
+
S.selected = el.dataset.fe;
|
|
2851
|
+
return schedule();
|
|
2852
|
+
}
|
|
2853
|
+
if ((el = on('[data-file]'))) {
|
|
2854
|
+
const k = el.dataset.file;
|
|
2855
|
+
if (S.ff.open.has(k)) S.ff.open.delete(k);
|
|
2856
|
+
else S.ff.open.add(k);
|
|
2857
|
+
return schedule();
|
|
2858
|
+
}
|
|
2859
|
+
if (on('[data-tokens]')) return toggleTokens();
|
|
2860
|
+
if ((el = on('[data-layer]'))) {
|
|
2861
|
+
S.follow = false;
|
|
2862
|
+
S.selected = el.dataset.layer;
|
|
2863
|
+
schedule();
|
|
2864
|
+
requestAnimationFrame(() => $('.lane')?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }));
|
|
2865
|
+
return;
|
|
2866
|
+
}
|
|
2867
|
+
if (on('[data-lane-close]')) {
|
|
2868
|
+
// Back to where the subagent was called from.
|
|
2869
|
+
const ag = openAgent();
|
|
2870
|
+
S.follow = false;
|
|
2871
|
+
S.selected = ag?.parent ?? null;
|
|
2872
|
+
return schedule();
|
|
2873
|
+
}
|
|
2874
|
+
if ((el = on('[data-lane-open]'))) {
|
|
2875
|
+
S.follow = false;
|
|
2876
|
+
S.selected = el.dataset.laneOpen;
|
|
2877
|
+
if (S.view === 'path') reveal(S.selected);
|
|
2878
|
+
return schedule();
|
|
2879
|
+
}
|
|
2880
|
+
if ((el = on('[data-lb]'))) {
|
|
2881
|
+
const a = S.laneRows[Number(el.dataset.lb)];
|
|
2882
|
+
if (!a) return;
|
|
2883
|
+
S.follow = false;
|
|
2884
|
+
S.selected = a.id;
|
|
2885
|
+
if (S.view === 'path') reveal(a.id);
|
|
2886
|
+
schedule();
|
|
2887
|
+
requestAnimationFrame(() => {
|
|
2888
|
+
const t = S.view === 'path' ? findEl(a.id) : $(`[data-arow="${a.id}"]`);
|
|
2889
|
+
t?.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|
2890
|
+
});
|
|
2891
|
+
return;
|
|
2892
|
+
}
|
|
2893
|
+
if ((el = on('[data-zoom]'))) {
|
|
2894
|
+
const z = el.dataset.zoom;
|
|
2895
|
+
if (z === 'fit') setZoom(0, 1);
|
|
2896
|
+
else zoomBy(z === 'in' ? 0.5 : 2);
|
|
2897
|
+
return;
|
|
2898
|
+
}
|
|
2899
|
+
if ((el = on('#overview [data-pstep]'))) {
|
|
2900
|
+
const id = el.dataset.pstep;
|
|
2901
|
+
reveal(id);
|
|
2902
|
+
if (S.view !== 'path') setView('path');
|
|
2903
|
+
select(id);
|
|
2904
|
+
requestAnimationFrame(() => findEl(id)?.scrollIntoView({ block: 'center' }));
|
|
2905
|
+
return;
|
|
2906
|
+
}
|
|
2907
|
+
if ((el = on('#overview [data-b]'))) {
|
|
2908
|
+
const row = S.stripRows[Number(el.dataset.b)];
|
|
2909
|
+
if (!row) return;
|
|
2910
|
+
S.follow = false;
|
|
2911
|
+
S.selected = row.id;
|
|
2912
|
+
if (row.subagentId || N(row.id)?.origin === 'subagent') {
|
|
2913
|
+
// Subagent work: open its timeline and bring it into view; its own bars lead to the details.
|
|
2914
|
+
schedule();
|
|
2915
|
+
requestAnimationFrame(() => $('.lane')?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }));
|
|
2916
|
+
return;
|
|
2917
|
+
}
|
|
2918
|
+
if (S.view === 'path') reveal(row.id);
|
|
2919
|
+
schedule();
|
|
2920
|
+
requestAnimationFrame(() => {
|
|
2921
|
+
const target = S.view === 'path' ? findEl(row.id) : $(`[data-arow="${row.id}"]`);
|
|
2922
|
+
target?.scrollIntoView({ block: 'center' });
|
|
2923
|
+
});
|
|
2924
|
+
return;
|
|
2925
|
+
}
|
|
2926
|
+
if ((el = on('[data-showpath]'))) {
|
|
2927
|
+
const id = el.dataset.showpath;
|
|
2928
|
+
reveal(id);
|
|
2929
|
+
S.follow = false;
|
|
2930
|
+
S.selected = id;
|
|
2931
|
+
setView('path');
|
|
2932
|
+
requestAnimationFrame(() => findEl(id)?.scrollIntoView({ block: 'center' }));
|
|
2933
|
+
return;
|
|
2934
|
+
}
|
|
2935
|
+
if (on('#actionsMore')) {
|
|
2936
|
+
S.actionsLimit += 400;
|
|
2937
|
+
return schedule();
|
|
2938
|
+
}
|
|
2939
|
+
if (on('#copyCommands')) return void copyCommands();
|
|
2940
|
+
if (on('#expandAll')) {
|
|
2941
|
+
if (S.view === 'path' && S.layout === 'files') {
|
|
2942
|
+
if (S.ff.open.size) S.ff.open.clear();
|
|
2943
|
+
else for (const f of fileEdits()) S.ff.open.add(f.key);
|
|
2944
|
+
return schedule();
|
|
2945
|
+
}
|
|
2946
|
+
S.expandAll = !S.expandAll;
|
|
2947
|
+
for (const set of [S.openGoals, S.closedGoals, S.openSteps, S.closedSteps]) set.clear();
|
|
2948
|
+
return schedule();
|
|
2949
|
+
}
|
|
2950
|
+
if ((el = on('[data-full]'))) {
|
|
2951
|
+
S.fullPre.add(el.dataset.full);
|
|
2952
|
+
return renderDetail();
|
|
2953
|
+
}
|
|
2954
|
+
if ((el = on('[data-allacts]'))) {
|
|
2955
|
+
S.allActs.add(el.dataset.allacts);
|
|
2956
|
+
return schedule();
|
|
2957
|
+
}
|
|
2958
|
+
if ((el = on('[data-acts]'))) {
|
|
2959
|
+
e.preventDefault(); // don't toggle the section it sits in
|
|
2960
|
+
const openAll = el.dataset.acts === 'open';
|
|
2961
|
+
for (const d of $('#detail').querySelectorAll('[data-act]')) {
|
|
2962
|
+
const id = d.dataset.act;
|
|
2963
|
+
if (openAll) {
|
|
2964
|
+
S.openActs.add(id);
|
|
2965
|
+
S.closedActs.delete(id);
|
|
2966
|
+
} else {
|
|
2967
|
+
S.openActs.delete(id);
|
|
2968
|
+
S.closedActs.add(id);
|
|
2969
|
+
}
|
|
2970
|
+
}
|
|
2971
|
+
return renderDetail();
|
|
2972
|
+
}
|
|
2973
|
+
if ((el = on('#picker li[data-path]'))) return void openSession(el.dataset.path);
|
|
2974
|
+
if ((el = on('#picker li[data-run]'))) {
|
|
2975
|
+
$('#picker').close();
|
|
2976
|
+
return connect(el.dataset.run);
|
|
2977
|
+
}
|
|
2978
|
+
if ((el = on('[data-scope]'))) {
|
|
2979
|
+
S.pickerScope = el.dataset.scope;
|
|
2980
|
+
return void openPicker();
|
|
2981
|
+
}
|
|
2982
|
+
if ((el = on('[data-fnode]'))) {
|
|
2983
|
+
const key = el.closest('.fn')?.dataset.key ?? '';
|
|
2984
|
+
// Clicking the selected node again clears the selection (and folds its capsule).
|
|
2985
|
+
if (el.closest('.fn')?.classList.contains('sel')) {
|
|
2986
|
+
S.follow = false;
|
|
2987
|
+
S.selected = null;
|
|
2988
|
+
return schedule();
|
|
2989
|
+
}
|
|
2990
|
+
S.flowPick = key === 'start' || key === 'end' ? key : null;
|
|
2991
|
+
return select(el.dataset.fnode);
|
|
2992
|
+
}
|
|
2993
|
+
if ((el = on('[data-ftoggle]'))) {
|
|
2994
|
+
const g = N(el.dataset.ftoggle);
|
|
2995
|
+
if (!g) return;
|
|
2996
|
+
if (isQA(g)) return select(g.id); // nothing to unfold: its answer is in the panel
|
|
2997
|
+
const open = el.closest('.fg').classList.contains('open');
|
|
2998
|
+
toggleIn(open, g.id, S.openGoals, S.closedGoals);
|
|
2999
|
+
return schedule();
|
|
3000
|
+
}
|
|
3001
|
+
if ((el = on('[data-step]'))) {
|
|
3002
|
+
const s = N(el.dataset.step);
|
|
3003
|
+
if (!s) return;
|
|
3004
|
+
const open = isStepOpen(s);
|
|
3005
|
+
// Click opens and selects; clicking the selected step (or its chevron) folds it again.
|
|
3006
|
+
if (s.children.length && (on('.chev') || !open || S.selected === s.id)) toggleIn(open, s.id, S.openSteps, S.closedSteps);
|
|
3007
|
+
if (on('.chev')) return schedule();
|
|
3008
|
+
return select(s.id);
|
|
3009
|
+
}
|
|
3010
|
+
if ((el = on('[data-toggle]'))) {
|
|
3011
|
+
const g = N(el.dataset.toggle);
|
|
3012
|
+
if (!g) return;
|
|
3013
|
+
const open = isGoalOpen(g);
|
|
3014
|
+
toggleIn(open, g.id, S.openGoals, S.closedGoals);
|
|
3015
|
+
if (!open) return select(g.id);
|
|
3016
|
+
return schedule();
|
|
3017
|
+
}
|
|
3018
|
+
// The selected mark in a capsule, clicked again: back to its step (the capsule stays open).
|
|
3019
|
+
if ((el = on('#pathView .fcap .fm.sel'))) return select(el.closest('.fcap').dataset.key.slice(2));
|
|
3020
|
+
if ((el = on('#pathView [data-select]'))) return select(el.dataset.select);
|
|
3021
|
+
});
|
|
3022
|
+
|
|
3023
|
+
document.addEventListener(
|
|
3024
|
+
'toggle',
|
|
3025
|
+
(e) => {
|
|
3026
|
+
const d = e.target;
|
|
3027
|
+
if (!(d instanceof HTMLDetailsElement)) return;
|
|
3028
|
+
if (d.dataset.sec) {
|
|
3029
|
+
if (d.open) S.closedSecs.delete(d.dataset.sec);
|
|
3030
|
+
else S.closedSecs.add(d.dataset.sec);
|
|
3031
|
+
return;
|
|
3032
|
+
}
|
|
3033
|
+
if (!d.dataset.act) return;
|
|
3034
|
+
const was = S.openActs.has(d.dataset.act) || (!S.closedActs.has(d.dataset.act) && d.dataset.act === S.selected);
|
|
3035
|
+
if (d.open) {
|
|
3036
|
+
S.openActs.add(d.dataset.act);
|
|
3037
|
+
S.closedActs.delete(d.dataset.act);
|
|
3038
|
+
} else {
|
|
3039
|
+
S.openActs.delete(d.dataset.act);
|
|
3040
|
+
S.closedActs.add(d.dataset.act);
|
|
3041
|
+
}
|
|
3042
|
+
if (was !== d.open) renderDetail();
|
|
3043
|
+
},
|
|
3044
|
+
true,
|
|
3045
|
+
);
|
|
3046
|
+
|
|
3047
|
+
// Strip zoom: drag the navigator window (pan) or its edges (zoom); click the track to jump there.
|
|
3048
|
+
document.addEventListener('pointerdown', (e) => {
|
|
3049
|
+
const t = e.target;
|
|
3050
|
+
if (!(t instanceof Element) || e.button !== 0) return;
|
|
3051
|
+
const nav = t.closest('[data-nav]');
|
|
3052
|
+
if (!nav) return;
|
|
3053
|
+
e.preventDefault();
|
|
3054
|
+
const rect = nav.getBoundingClientRect();
|
|
3055
|
+
const n = S.stripRows.length || 1;
|
|
3056
|
+
const min = minSpanCount(n) / n;
|
|
3057
|
+
const edge = t.closest('[data-h]')?.dataset.h;
|
|
3058
|
+
let z0 = { ...S.zoom };
|
|
3059
|
+
if (!edge && !t.closest('.nav-win')) {
|
|
3060
|
+
const span = z0.b - z0.a;
|
|
3061
|
+
const c = (e.clientX - rect.left) / rect.width;
|
|
3062
|
+
const na = Math.max(0, Math.min(1 - span, c - span / 2));
|
|
3063
|
+
setZoom(na, na + span);
|
|
3064
|
+
z0 = { ...S.zoom };
|
|
3065
|
+
}
|
|
3066
|
+
const x0 = e.clientX;
|
|
3067
|
+
document.body.classList.add('zooming');
|
|
3068
|
+
const move = (ev) => {
|
|
3069
|
+
const d = (ev.clientX - x0) / rect.width;
|
|
3070
|
+
if (edge === 'a') setZoom(Math.max(0, Math.min(z0.b - min, z0.a + d)), z0.b);
|
|
3071
|
+
else if (edge === 'b') setZoom(z0.a, Math.min(1, Math.max(z0.a + min, z0.b + d)));
|
|
3072
|
+
else {
|
|
3073
|
+
const span = z0.b - z0.a;
|
|
3074
|
+
const na = Math.max(0, Math.min(1 - span, z0.a + d));
|
|
3075
|
+
setZoom(na, na + span);
|
|
3076
|
+
}
|
|
3077
|
+
};
|
|
3078
|
+
const up = () => {
|
|
3079
|
+
removeEventListener('pointermove', move);
|
|
3080
|
+
removeEventListener('pointerup', up);
|
|
3081
|
+
document.body.classList.remove('zooming');
|
|
3082
|
+
};
|
|
3083
|
+
addEventListener('pointermove', move);
|
|
3084
|
+
addEventListener('pointerup', up);
|
|
3085
|
+
});
|
|
3086
|
+
document.addEventListener('dblclick', (e) => {
|
|
3087
|
+
if (e.target instanceof Element && e.target.closest('[data-nav]')) setZoom(0, 1);
|
|
3088
|
+
});
|
|
3089
|
+
// Scroll over the strip to zoom around the cursor; shift+scroll (or a sideways swipe) pans.
|
|
3090
|
+
document.addEventListener(
|
|
3091
|
+
'wheel',
|
|
3092
|
+
(e) => {
|
|
3093
|
+
const t = e.target;
|
|
3094
|
+
if (!(t instanceof Element)) return;
|
|
3095
|
+
const el = t.closest('.strip, [data-nav]');
|
|
3096
|
+
if (!el || (S.stripRows.length ?? 0) <= 20) return;
|
|
3097
|
+
e.preventDefault();
|
|
3098
|
+
const rect = el.getBoundingClientRect();
|
|
3099
|
+
const span = S.zoom.b - S.zoom.a;
|
|
3100
|
+
if (e.shiftKey || Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
|
|
3101
|
+
const d = (e.shiftKey ? e.deltaY : e.deltaX) || e.deltaY;
|
|
3102
|
+
panBy((Math.sign(d) * span) / 8);
|
|
3103
|
+
} else if (el.matches('[data-nav]')) {
|
|
3104
|
+
zoomBy(e.deltaY > 0 ? 1.25 : 0.8, 0.5);
|
|
3105
|
+
} else {
|
|
3106
|
+
zoomBy(e.deltaY > 0 ? 1.25 : 0.8, (e.clientX - rect.left) / rect.width);
|
|
3107
|
+
}
|
|
3108
|
+
},
|
|
3109
|
+
{ passive: false },
|
|
3110
|
+
);
|
|
3111
|
+
|
|
3112
|
+
$('#pathSearch').addEventListener('input', (e) => {
|
|
3113
|
+
S.pf.q = e.target.value;
|
|
3114
|
+
schedule();
|
|
3115
|
+
});
|
|
3116
|
+
// Hide Q&A folds the questions away (and unfolds them back) rather than making them blink.
|
|
3117
|
+
const qaEls = () =>
|
|
3118
|
+
S.layout === 'simple'
|
|
3119
|
+
? [...document.querySelectorAll('#pathView .fg.qa')]
|
|
3120
|
+
: [...document.querySelectorAll('#pathView .task')].filter((el) => el.querySelector(':scope > .task-h .tstat.qa'));
|
|
3121
|
+
const FOLD = { duration: 240, easing: 'cubic-bezier(0.3, 0.7, 0.2, 1)' };
|
|
3122
|
+
function foldFrames(el) {
|
|
3123
|
+
const cs = getComputedStyle(el);
|
|
3124
|
+
return [
|
|
3125
|
+
{ height: `${el.offsetHeight}px`, opacity: 1, marginTop: cs.marginTop, marginBottom: cs.marginBottom, paddingTop: cs.paddingTop, paddingBottom: cs.paddingBottom },
|
|
3126
|
+
{ height: '0px', opacity: 0, marginTop: '0px', marginBottom: '0px', paddingTop: '0px', paddingBottom: '0px' },
|
|
3127
|
+
];
|
|
3128
|
+
}
|
|
3129
|
+
function fold(els, open) {
|
|
3130
|
+
return Promise.all(
|
|
3131
|
+
els.map((el) => {
|
|
3132
|
+
const frames = foldFrames(el);
|
|
3133
|
+
el.style.overflow = 'hidden';
|
|
3134
|
+
const anim = el.animate(open ? frames.reverse() : frames, { ...FOLD, fill: open ? 'none' : 'forwards' });
|
|
3135
|
+
return anim.finished.then(() => (el.style.overflow = ''), () => {});
|
|
3136
|
+
}),
|
|
3137
|
+
);
|
|
3138
|
+
}
|
|
3139
|
+
$('#hideChat').addEventListener('change', async (e) => {
|
|
3140
|
+
const hide = e.target.checked;
|
|
3141
|
+
const calm = matchMedia('(prefers-reduced-motion: reduce)').matches || S.view !== 'path' || S.layout === 'files';
|
|
3142
|
+
if (hide && !calm) await fold(qaEls(), false);
|
|
3143
|
+
S.pf.hideChat = hide;
|
|
3144
|
+
render();
|
|
3145
|
+
if (!hide && !calm) {
|
|
3146
|
+
const els = qaEls();
|
|
3147
|
+
for (const el of els) el.classList.remove('enter');
|
|
3148
|
+
void fold(els, true);
|
|
3149
|
+
}
|
|
3150
|
+
});
|
|
3151
|
+
$('#actionsSearch').addEventListener('input', (e) => {
|
|
3152
|
+
S.af.q = e.target.value;
|
|
3153
|
+
S.actionsLimit = 400;
|
|
3154
|
+
schedule();
|
|
3155
|
+
});
|
|
3156
|
+
$('#actionsGoal').addEventListener('change', (e) => {
|
|
3157
|
+
S.af.goal = e.target.value;
|
|
3158
|
+
schedule();
|
|
3159
|
+
});
|
|
3160
|
+
|
|
3161
|
+
// ───────────────────────────── jump to the end (a floating arrow, shown when not at the bottom)
|
|
3162
|
+
const totalActs = () => (S.run ? S.run.goals.map(N).filter(Boolean).reduce((a, g) => a + (g.stats?.actions ?? 0), 0) : 0);
|
|
3163
|
+
function atEnd() {
|
|
3164
|
+
const p = $('#pane');
|
|
3165
|
+
return p.scrollTop + p.clientHeight >= p.scrollHeight - 60;
|
|
3166
|
+
}
|
|
3167
|
+
function updateToEnd() {
|
|
3168
|
+
const btn = $('#toEnd');
|
|
3169
|
+
const show = !!S.run && !atEnd();
|
|
3170
|
+
btn.hidden = !show;
|
|
3171
|
+
// Like a chat app: count what arrived while you were looking elsewhere.
|
|
3172
|
+
const n = totalActs();
|
|
3173
|
+
if (!show || S.seenActs === null) S.seenActs = n;
|
|
3174
|
+
const fresh = n - S.seenActs;
|
|
3175
|
+
const badge = $('#toEndN');
|
|
3176
|
+
badge.hidden = !show || fresh <= 0;
|
|
3177
|
+
badge.textContent = fresh > 99 ? '99+' : String(fresh);
|
|
3178
|
+
btn.title = fresh > 0 ? `${plural(fresh, 'new action')}: jump to the end and follow the agent (F)` : 'Jump to the end and follow the agent (F)';
|
|
3179
|
+
}
|
|
3180
|
+
function jumpToEnd() {
|
|
3181
|
+
S.follow = true;
|
|
3182
|
+
S.followQuiet = !N(S.selected);
|
|
3183
|
+
S.lastFollowTarget = null;
|
|
3184
|
+
render();
|
|
3185
|
+
const p = $('#pane');
|
|
3186
|
+
p.scrollTo({ top: p.scrollHeight, behavior: 'smooth' });
|
|
3187
|
+
}
|
|
3188
|
+
$('#toEnd').addEventListener('click', jumpToEnd);
|
|
3189
|
+
$('#pane').addEventListener('scroll', () => requestAnimationFrame(updateToEnd), { passive: true });
|
|
3190
|
+
|
|
3191
|
+
// Pinned and scrolled down, the overview folds to just its timeline; back at the top it unfolds.
|
|
3192
|
+
// The content below moves up by what the card loses, so the scroll position takes that back:
|
|
3193
|
+
// what you were looking at stays put.
|
|
3194
|
+
function updateCompact() {
|
|
3195
|
+
const pane = $('#pane');
|
|
3196
|
+
const ov = $('#overview');
|
|
3197
|
+
const on = ov.classList.contains('compact');
|
|
3198
|
+
const want = S.pinOv && (on ? pane.scrollTop > 4 : pane.scrollTop > Math.max(90, ov.offsetHeight - 70));
|
|
3199
|
+
if (want !== on) ov.classList.toggle('compact', want);
|
|
3200
|
+
}
|
|
3201
|
+
$('#pane').addEventListener('scroll', () => requestAnimationFrame(updateCompact), { passive: true });
|
|
3202
|
+
$('#sessionsBtn').addEventListener('click', () => openPicker());
|
|
3203
|
+
$('#runBox').addEventListener('click', () => openPicker());
|
|
3204
|
+
$('#loadInput').addEventListener('change', (e) => {
|
|
3205
|
+
const f = e.target.files?.[0];
|
|
3206
|
+
if (f) {
|
|
3207
|
+
if ($('#picker').open) $('#picker').close();
|
|
3208
|
+
loadFile(f);
|
|
3209
|
+
}
|
|
3210
|
+
e.target.value = '';
|
|
3211
|
+
});
|
|
3212
|
+
// Scrolling by hand pauses following; the arrow button (or F) resumes it.
|
|
3213
|
+
$('#pane').addEventListener('wheel', () => S.follow && S.run && setFollow(false), { passive: true });
|
|
3214
|
+
document.addEventListener('keydown', (e) => {
|
|
3215
|
+
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLSelectElement) {
|
|
3216
|
+
if (e.key === 'Escape') e.target.blur();
|
|
3217
|
+
return;
|
|
3218
|
+
}
|
|
3219
|
+
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
|
3220
|
+
if (e.key === 'f' || e.key === 'End') jumpToEnd();
|
|
3221
|
+
else if (e.key === 'v') setView(S.view === 'actions' ? 'simple' : S.layout === 'simple' ? 'detailed' : S.layout === 'detailed' ? 'files' : 'actions');
|
|
3222
|
+
else if (e.key === 'e' && S.view === 'path') $('#expandAll').click();
|
|
3223
|
+
else if (e.key === '/') {
|
|
3224
|
+
e.preventDefault();
|
|
3225
|
+
$(S.view === 'path' ? '#pathSearch' : '#actionsSearch').focus();
|
|
3226
|
+
} else if (e.key === 'o') openPicker();
|
|
3227
|
+
else if (e.key === 'p' && S.run) togglePin();
|
|
3228
|
+
else if (e.key === 'h' && S.run && S.view === 'path') toggleHeat();
|
|
3229
|
+
else if (e.key === 't' && S.run?.usage) toggleTokens();
|
|
3230
|
+
else if (e.key === 'Escape' && !$('#tokensPop').hidden) toggleTokens(false);
|
|
3231
|
+
else if (e.key === 'Escape' && !$('#wakePop').hidden) toggleWake(false);
|
|
3232
|
+
else if (e.key === 'Escape' && !$('#picker').open) {
|
|
3233
|
+
S.follow = false;
|
|
3234
|
+
S.selected = null;
|
|
3235
|
+
schedule();
|
|
3236
|
+
}
|
|
3237
|
+
});
|
|
3238
|
+
// Relative times and the "quiet" state age even without new events.
|
|
3239
|
+
setInterval(() => S.run && renderHeader(), 15000);
|
|
3240
|
+
|
|
3241
|
+
// ───────────────────────────── boot
|
|
3242
|
+
(async () => {
|
|
3243
|
+
// Wait for styles: following needs the real scroll containers to exist.
|
|
3244
|
+
if (document.readyState !== 'complete') await new Promise((r) => addEventListener('load', r, { once: true }));
|
|
3245
|
+
const m = /run=([\w-]+)/.exec(location.hash);
|
|
3246
|
+
const { runs } = await fetch('/api/runs').then((r) => r.json());
|
|
3247
|
+
if (location.hash.includes('view=actions')) S.view = 'actions';
|
|
3248
|
+
const wanted = m && runs.find((r) => r.id === m[1]);
|
|
3249
|
+
if (wanted) connect(wanted.id);
|
|
3250
|
+
else if (runs.length) connect(runs[runs.length - 1].id);
|
|
3251
|
+
else render();
|
|
3252
|
+
})();
|
|
3253
|
+
// The search box widens while in use: re-check whether the filter chips still fit.
|
|
3254
|
+
for (const ev of ['focus', 'blur']) $('#pathSearch').addEventListener(ev, () => setTimeout(fitToolbar, 230));
|
|
3255
|
+
|
|
3256
|
+
// ───────────────────────────── instant tooltips on the timeline
|
|
3257
|
+
/*
|
|
3258
|
+
* The strip's bars and the subagent boxes describe themselves on hover right away, instead of
|
|
3259
|
+
* after the browser's tooltip delay. Their native title is taken over on first hover, so the
|
|
3260
|
+
* browser's own tooltip never doubles it.
|
|
3261
|
+
*/
|
|
3262
|
+
const tipEl = document.createElement('div');
|
|
3263
|
+
tipEl.className = 'tip';
|
|
3264
|
+
tipEl.hidden = true;
|
|
3265
|
+
document.body.append(tipEl);
|
|
3266
|
+
const TIP_TARGET = '#overview .strip > rect, #overview .strip > polygon, #overview .layers > *, #pathView .fcap .fm, #pathView .fcap .fm-more';
|
|
3267
|
+
function tipText(el) {
|
|
3268
|
+
if (el.dataset.tip === undefined) {
|
|
3269
|
+
const t = el.querySelector(':scope > title');
|
|
3270
|
+
el.dataset.tip = t ? t.textContent : (el.getAttribute('title') ?? '');
|
|
3271
|
+
t?.remove();
|
|
3272
|
+
el.removeAttribute('title');
|
|
3273
|
+
}
|
|
3274
|
+
return el.dataset.tip;
|
|
3275
|
+
}
|
|
3276
|
+
function placeTip(x, y) {
|
|
3277
|
+
const r = tipEl.getBoundingClientRect();
|
|
3278
|
+
const left = Math.max(8, Math.min(innerWidth - r.width - 8, x - r.width / 2));
|
|
3279
|
+
const top = y - r.height - 14 < 8 ? y + 18 : y - r.height - 14;
|
|
3280
|
+
tipEl.style.transform = `translate(${Math.round(left)}px, ${Math.round(top)}px)`;
|
|
3281
|
+
}
|
|
3282
|
+
let tipOn = null;
|
|
3283
|
+
document.addEventListener('pointerover', (e) => {
|
|
3284
|
+
const el = e.target instanceof Element ? e.target.closest(TIP_TARGET) : null;
|
|
3285
|
+
if (el === tipOn) return;
|
|
3286
|
+
tipOn = el;
|
|
3287
|
+
const text = el && tipText(el);
|
|
3288
|
+
if (!text) {
|
|
3289
|
+
tipEl.hidden = true;
|
|
3290
|
+
return;
|
|
3291
|
+
}
|
|
3292
|
+
tipEl.textContent = text;
|
|
3293
|
+
tipEl.hidden = false;
|
|
3294
|
+
placeTip(e.clientX, e.clientY);
|
|
3295
|
+
});
|
|
3296
|
+
document.addEventListener(
|
|
3297
|
+
'pointermove',
|
|
3298
|
+
(e) => {
|
|
3299
|
+
if (!tipOn || tipEl.hidden) return;
|
|
3300
|
+
// A live update may have rebuilt the strip under the pointer: describe what is there now.
|
|
3301
|
+
if (!tipOn.isConnected) {
|
|
3302
|
+
const el = document.elementFromPoint(e.clientX, e.clientY)?.closest(TIP_TARGET);
|
|
3303
|
+
if (!el) return hideTip();
|
|
3304
|
+
tipOn = el;
|
|
3305
|
+
tipEl.textContent = tipText(el);
|
|
3306
|
+
}
|
|
3307
|
+
placeTip(e.clientX, e.clientY);
|
|
3308
|
+
},
|
|
3309
|
+
{ passive: true },
|
|
3310
|
+
);
|
|
3311
|
+
const hideTip = () => {
|
|
3312
|
+
tipOn = null;
|
|
3313
|
+
tipEl.hidden = true;
|
|
3314
|
+
};
|
|
3315
|
+
document.addEventListener('pointerleave', hideTip);
|
|
3316
|
+
$('#pane').addEventListener('scroll', hideTip, { passive: true });
|
|
3317
|
+
document.addEventListener('pointerdown', hideTip);
|