@kylindc/ccxray 1.2.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/CHANGELOG.md +39 -0
- package/LICENSE +21 -0
- package/README.ja.md +144 -0
- package/README.md +145 -0
- package/README.zh-TW.md +144 -0
- package/package.json +46 -0
- package/public/app.js +99 -0
- package/public/cost-budget-ui.js +305 -0
- package/public/entry-rendering.js +535 -0
- package/public/index.html +119 -0
- package/public/intercept-ui.js +335 -0
- package/public/keyboard-nav.js +208 -0
- package/public/messages.js +750 -0
- package/public/miller-columns.js +1686 -0
- package/public/quota-ticker.js +67 -0
- package/public/style.css +431 -0
- package/public/system-prompt-ui.js +327 -0
- package/server/auth.js +34 -0
- package/server/bedrock-credentials.js +141 -0
- package/server/config.js +190 -0
- package/server/cost-budget.js +220 -0
- package/server/cost-worker.js +110 -0
- package/server/eventstream.js +148 -0
- package/server/forward.js +683 -0
- package/server/helpers.js +393 -0
- package/server/hub.js +418 -0
- package/server/index.js +551 -0
- package/server/pricing.js +133 -0
- package/server/restore.js +141 -0
- package/server/routes/api.js +123 -0
- package/server/routes/costs.js +124 -0
- package/server/routes/intercept.js +89 -0
- package/server/routes/sse.js +44 -0
- package/server/sigv4.js +104 -0
- package/server/sse-broadcast.js +71 -0
- package/server/storage/index.js +36 -0
- package/server/storage/interface.js +26 -0
- package/server/storage/local.js +79 -0
- package/server/storage/s3.js +91 -0
- package/server/store.js +108 -0
- package/server/system-prompt.js +150 -0
|
@@ -0,0 +1,750 @@
|
|
|
1
|
+
// ── Messages column helpers ──
|
|
2
|
+
const INJECTED_TAG_RE = /^<(system-reminder|user-prompt-submit-hook|context|antml:function_calls)[^>]*>/;
|
|
3
|
+
function classifyUserMessage(msg) {
|
|
4
|
+
if (msg.role !== 'user') return null;
|
|
5
|
+
const blocks = Array.isArray(msg.content)
|
|
6
|
+
? msg.content
|
|
7
|
+
: [{ type: 'text', text: String(msg.content || '') }];
|
|
8
|
+
const hasToolResult = blocks.some(b => b.type === 'tool_result');
|
|
9
|
+
const hasHuman = blocks.some(b => b.type === 'text' && b.text && !INJECTED_TAG_RE.test(b.text.trimStart()));
|
|
10
|
+
const hasSystem = blocks.some(b => b.type === 'text' && b.text && INJECTED_TAG_RE.test(b.text.trimStart()));
|
|
11
|
+
if (!hasToolResult && !hasHuman && !hasSystem) return null;
|
|
12
|
+
return { hasHuman, hasSystem, hasToolResult };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function getUserBadgeHtml(cls) {
|
|
16
|
+
let html = '';
|
|
17
|
+
if (cls.hasHuman) html += '<span class="msg-badge msg-badge-human">YOU</span> ';
|
|
18
|
+
if (cls.hasSystem) html += '<span class="msg-badge msg-badge-system">SYS</span> ';
|
|
19
|
+
if (cls.hasToolResult) html += '<span class="msg-badge msg-badge-tool_results">TOOL</span> ';
|
|
20
|
+
return html;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function getUserMessagePreview(msg, cls) {
|
|
24
|
+
if (cls.hasToolResult && !cls.hasHuman) {
|
|
25
|
+
const blocks = Array.isArray(msg.content) ? msg.content : [];
|
|
26
|
+
const count = blocks.filter(b => b.type === 'tool_result').length;
|
|
27
|
+
return count + ' result' + (count !== 1 ? 's' : '');
|
|
28
|
+
}
|
|
29
|
+
const blocks = Array.isArray(msg.content)
|
|
30
|
+
? msg.content : [{ type: 'text', text: String(msg.content || '') }];
|
|
31
|
+
const text = blocks
|
|
32
|
+
.filter(b => b.type === 'text' && b.text && !INJECTED_TAG_RE.test(b.text.trimStart()))
|
|
33
|
+
.map(b => b.text).join(' ').trim();
|
|
34
|
+
return text.slice(0, 40) || getMessagePreview(msg);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function classifyAssistantMessage(msg) {
|
|
38
|
+
if (msg.role !== 'assistant') return null;
|
|
39
|
+
const blocks = Array.isArray(msg.content) ? msg.content : [];
|
|
40
|
+
const tools = blocks.filter(b => b.type === 'tool_use');
|
|
41
|
+
const thinkingBlock = blocks.find(b => b.type === 'thinking');
|
|
42
|
+
const hasThinking = !!thinkingBlock;
|
|
43
|
+
const hasCall = tools.length > 0;
|
|
44
|
+
if (!hasThinking && !hasCall) return null;
|
|
45
|
+
const thinkingText = thinkingBlock ? (thinkingBlock.thinking || '') : '';
|
|
46
|
+
return { hasThinking, hasCall, tools, thinkingText };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function getAssistantBadgeHtml(asmCls) {
|
|
50
|
+
let html = '';
|
|
51
|
+
if (asmCls.hasThinking) html += '<span class="msg-badge msg-badge-think">THINK</span> ';
|
|
52
|
+
if (asmCls.hasCall) html += '<span class="msg-badge msg-badge-call">CALL</span> ';
|
|
53
|
+
return html;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function getAssistantPreview(asmCls) {
|
|
57
|
+
if (!asmCls.hasCall) return '';
|
|
58
|
+
const first = asmCls.tools[0].name || '?';
|
|
59
|
+
return asmCls.tools.length > 1 ? first + ' +' + (asmCls.tools.length - 1) : first;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function getMessagePreview(m) {
|
|
63
|
+
if (typeof m.content === 'string') return m.content.slice(0, 40);
|
|
64
|
+
if (!Array.isArray(m.content) || !m.content.length) return '';
|
|
65
|
+
const first = m.content[0];
|
|
66
|
+
if (first.type === 'text') return first.text.slice(0, 40);
|
|
67
|
+
if (first.type === 'tool_use') return '[tool] ' + first.name;
|
|
68
|
+
if (first.type === 'tool_result') return '[result]';
|
|
69
|
+
if (first.type === 'image') return '[image]';
|
|
70
|
+
return first.type || '';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── Merged Steps: transform flat messages into logical steps ──
|
|
74
|
+
function getToolPreview(toolUse) {
|
|
75
|
+
const inp = toolUse.input || {};
|
|
76
|
+
switch (toolUse.name) {
|
|
77
|
+
case 'Bash': return (inp.command || '').split('\n')[0].slice(0, 60);
|
|
78
|
+
case 'Read': case 'Write': case 'Edit': case 'NotebookEdit':
|
|
79
|
+
return (inp.file_path || inp.notebook_path || '').split('/').pop() || '';
|
|
80
|
+
case 'Grep': return (inp.pattern || '').slice(0, 40);
|
|
81
|
+
case 'Glob': return (inp.pattern || '').slice(0, 40);
|
|
82
|
+
case 'Agent': return (inp.description || inp.prompt || '').slice(0, 50);
|
|
83
|
+
case 'Skill': return inp.skill || '';
|
|
84
|
+
case 'TaskCreate': return (inp.subject || '').slice(0, 40);
|
|
85
|
+
case 'TaskUpdate': return (inp.taskId || '') + (inp.status ? ' → ' + inp.status : '');
|
|
86
|
+
case 'TaskStop': return inp.task_id || '';
|
|
87
|
+
case 'TaskOutput': return inp.task_id || '';
|
|
88
|
+
case 'WebSearch': return (inp.query || '').slice(0, 50);
|
|
89
|
+
case 'WebFetch': return (inp.url || '').replace(/^https?:\/\//, '').slice(0, 50);
|
|
90
|
+
default: {
|
|
91
|
+
const firstKey = Object.keys(inp)[0];
|
|
92
|
+
return firstKey ? String(inp[firstKey]).slice(0, 40) : '';
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function buildMergedSteps(messages, resEvents) {
|
|
98
|
+
if ((!messages || !messages.length) && (!resEvents || !resEvents.length)) return [];
|
|
99
|
+
|
|
100
|
+
// Phase 1a: Build tool_use_id → tool_result map
|
|
101
|
+
const resultMap = new Map();
|
|
102
|
+
if (messages) {
|
|
103
|
+
for (const msg of messages) {
|
|
104
|
+
if (msg.role !== 'user') continue;
|
|
105
|
+
const blocks = Array.isArray(msg.content) ? msg.content : [];
|
|
106
|
+
for (const b of blocks) {
|
|
107
|
+
if (b.type === 'tool_result' && b.tool_use_id) {
|
|
108
|
+
resultMap.set(b.tool_use_id, b);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Phase 1b: Collect all tool_use_ids from assistant messages
|
|
115
|
+
// After context compression, some tool_results may have no matching tool_use
|
|
116
|
+
const knownToolUseIds = new Set();
|
|
117
|
+
if (messages) {
|
|
118
|
+
for (const msg of messages) {
|
|
119
|
+
if (msg.role !== 'assistant') continue;
|
|
120
|
+
const blocks = Array.isArray(msg.content) ? msg.content : [];
|
|
121
|
+
for (const b of blocks) {
|
|
122
|
+
if (b.type === 'tool_use' && b.id) knownToolUseIds.add(b.id);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Phase 2: Build history steps from messages
|
|
128
|
+
const steps = [];
|
|
129
|
+
if (messages) {
|
|
130
|
+
for (let i = 0; i < messages.length; i++) {
|
|
131
|
+
const msg = messages[i];
|
|
132
|
+
const blocks = Array.isArray(msg.content) ? msg.content : [{ type: 'text', text: String(msg.content || '') }];
|
|
133
|
+
|
|
134
|
+
if (msg.role === 'user') {
|
|
135
|
+
const humanTexts = blocks.filter(b => b.type === 'text' && b.text && !INJECTED_TAG_RE.test(b.text.trimStart()));
|
|
136
|
+
const hasSys = blocks.some(b => b.type === 'text' && b.text && INJECTED_TAG_RE.test(b.text.trimStart()));
|
|
137
|
+
const hasToolResult = blocks.some(b => b.type === 'tool_result');
|
|
138
|
+
const hasOrphanedResult = blocks.some(b => b.type === 'tool_result' && b.tool_use_id && !knownToolUseIds.has(b.tool_use_id));
|
|
139
|
+
if (humanTexts.length || (hasSys && !hasToolResult) || hasOrphanedResult) {
|
|
140
|
+
steps.push({
|
|
141
|
+
type: 'human',
|
|
142
|
+
source: 'history',
|
|
143
|
+
humanText: humanTexts.map(b => b.text).join('\n').slice(0, 200),
|
|
144
|
+
hasSys,
|
|
145
|
+
hasToolResult,
|
|
146
|
+
msgIndices: [i],
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
} else if (msg.role === 'assistant') {
|
|
151
|
+
const thinkingBlock = blocks.find(b => b.type === 'thinking');
|
|
152
|
+
const toolUses = blocks.filter(b => b.type === 'tool_use');
|
|
153
|
+
const textBlocks = blocks.filter(b => b.type === 'text' && b.text && b.text.trim());
|
|
154
|
+
|
|
155
|
+
if (textBlocks.length && !toolUses.length) {
|
|
156
|
+
steps.push({
|
|
157
|
+
type: 'assistant-text',
|
|
158
|
+
source: 'history',
|
|
159
|
+
text: textBlocks.map(b => b.text).join('\n'),
|
|
160
|
+
msgIndices: [i],
|
|
161
|
+
});
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (toolUses.length) {
|
|
166
|
+
const calls = toolUses.map(tu => {
|
|
167
|
+
const result = resultMap.get(tu.id);
|
|
168
|
+
const resultContent = result ? (typeof result.content === 'string' ? result.content : JSON.stringify(result.content)) : '';
|
|
169
|
+
return {
|
|
170
|
+
name: tu.name,
|
|
171
|
+
preview: getToolPreview(tu),
|
|
172
|
+
input: tu.input,
|
|
173
|
+
result: result?.content,
|
|
174
|
+
isError: !!(result?.is_error),
|
|
175
|
+
errorSummary: result?.is_error ? resultContent.slice(0, 80) : '',
|
|
176
|
+
toolUseId: tu.id,
|
|
177
|
+
pending: !result,
|
|
178
|
+
};
|
|
179
|
+
});
|
|
180
|
+
let resultMsgIdx = -1;
|
|
181
|
+
for (let j = i + 1; j < messages.length; j++) {
|
|
182
|
+
if (messages[j].role === 'user') { resultMsgIdx = j; break; }
|
|
183
|
+
}
|
|
184
|
+
steps.push({
|
|
185
|
+
type: 'tool-group',
|
|
186
|
+
source: 'history',
|
|
187
|
+
thinking: thinkingBlock ? (thinkingBlock.thinking || '') : null,
|
|
188
|
+
calls,
|
|
189
|
+
msgIndices: resultMsgIdx >= 0 ? [i, resultMsgIdx] : [i],
|
|
190
|
+
});
|
|
191
|
+
if (textBlocks.length) {
|
|
192
|
+
steps.push({
|
|
193
|
+
type: 'assistant-text',
|
|
194
|
+
source: 'history',
|
|
195
|
+
text: textBlocks.map(b => b.text).join('\n'),
|
|
196
|
+
msgIndices: [i],
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
} else if (thinkingBlock && !toolUses.length && !textBlocks.length) {
|
|
200
|
+
steps.push({
|
|
201
|
+
type: 'tool-group',
|
|
202
|
+
source: 'history',
|
|
203
|
+
thinking: thinkingBlock.thinking || '',
|
|
204
|
+
calls: [],
|
|
205
|
+
msgIndices: [i],
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Phase 3: Build current turn steps from resEvents
|
|
213
|
+
if (resEvents && resEvents.length) {
|
|
214
|
+
let curThinking = null;
|
|
215
|
+
let curThinkingStart = null;
|
|
216
|
+
let curThinkingEnd = null;
|
|
217
|
+
const curToolUses = []; // { index, name, id, inputChunks[] }
|
|
218
|
+
let curText = '';
|
|
219
|
+
|
|
220
|
+
for (const ev of resEvents) {
|
|
221
|
+
if (ev.type === 'content_block_start') {
|
|
222
|
+
if (ev.content_block?.type === 'thinking') {
|
|
223
|
+
curThinking = '';
|
|
224
|
+
curThinkingStart = ev._ts || null;
|
|
225
|
+
} else if (ev.content_block?.type === 'tool_use') {
|
|
226
|
+
curToolUses.push({
|
|
227
|
+
index: ev.index,
|
|
228
|
+
name: ev.content_block.name,
|
|
229
|
+
id: ev.content_block.id,
|
|
230
|
+
inputChunks: [],
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
} else if (ev.type === 'content_block_delta') {
|
|
234
|
+
if (ev.delta?.type === 'thinking_delta') {
|
|
235
|
+
if (curThinking !== null) curThinking += ev.delta.thinking || '';
|
|
236
|
+
} else if (ev.delta?.type === 'input_json_delta') {
|
|
237
|
+
const tu = curToolUses.find(t => t.index === ev.index);
|
|
238
|
+
if (tu) tu.inputChunks.push(ev.delta.partial_json || '');
|
|
239
|
+
} else if (ev.delta?.type === 'text_delta') {
|
|
240
|
+
curText += ev.delta.text || '';
|
|
241
|
+
}
|
|
242
|
+
} else if (ev.type === 'content_block_stop') {
|
|
243
|
+
if (curThinkingStart && !curThinkingEnd) curThinkingEnd = ev._ts || null;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Build current turn tool calls
|
|
248
|
+
const currentCalls = curToolUses.map(tu => {
|
|
249
|
+
let input = {};
|
|
250
|
+
try { input = JSON.parse(tu.inputChunks.join('')); } catch {}
|
|
251
|
+
return {
|
|
252
|
+
name: tu.name,
|
|
253
|
+
preview: getToolPreview({ name: tu.name, input }),
|
|
254
|
+
input,
|
|
255
|
+
result: null,
|
|
256
|
+
isError: false,
|
|
257
|
+
errorSummary: '',
|
|
258
|
+
toolUseId: tu.id,
|
|
259
|
+
pending: true,
|
|
260
|
+
};
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// Emit current turn thinking + tool group
|
|
264
|
+
if (currentCalls.length || curThinking !== null) {
|
|
265
|
+
const thinkingDuration = (curThinkingStart && curThinkingEnd)
|
|
266
|
+
? ((curThinkingEnd - curThinkingStart) / 1000) : null;
|
|
267
|
+
steps.push({
|
|
268
|
+
type: 'tool-group',
|
|
269
|
+
source: 'current',
|
|
270
|
+
thinking: curThinking,
|
|
271
|
+
thinkingDuration,
|
|
272
|
+
calls: currentCalls,
|
|
273
|
+
msgIndices: [],
|
|
274
|
+
resEventSource: true,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Emit current turn text
|
|
279
|
+
if (curText.trim()) {
|
|
280
|
+
steps.push({
|
|
281
|
+
type: 'assistant-text',
|
|
282
|
+
source: 'current',
|
|
283
|
+
text: curText,
|
|
284
|
+
msgIndices: [],
|
|
285
|
+
resEventSource: true,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return steps;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
let currentSteps = []; // cached merged steps for current turn
|
|
294
|
+
let _stepsCache = { msgs: null, res: null, steps: [] };
|
|
295
|
+
|
|
296
|
+
// Memoized buildMergedSteps — returns cached result if inputs unchanged (by reference)
|
|
297
|
+
function getCachedSteps(messages, resEvents) {
|
|
298
|
+
if (messages === _stepsCache.msgs && resEvents === _stepsCache.res) {
|
|
299
|
+
return _stepsCache.steps;
|
|
300
|
+
}
|
|
301
|
+
const steps = buildMergedSteps(messages, resEvents);
|
|
302
|
+
_stepsCache = { msgs: messages, res: resEvents, steps };
|
|
303
|
+
return steps;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Build timeline steps and cache them
|
|
307
|
+
function prepareTimelineSteps(messages, resEvents) {
|
|
308
|
+
if ((!messages || !messages.length) && (!resEvents || !resEvents.length)) {
|
|
309
|
+
currentSteps = [];
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
currentSteps = getCachedSteps(messages, resEvents);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Generate the step list HTML (used in both accordion and split-pane modes)
|
|
316
|
+
function renderStepListHtml(steps, activeStepKey) {
|
|
317
|
+
let html = '';
|
|
318
|
+
let lastSource = null;
|
|
319
|
+
|
|
320
|
+
for (let si = 0; si < steps.length; si++) {
|
|
321
|
+
const step = steps[si];
|
|
322
|
+
|
|
323
|
+
// Insert history/current separator
|
|
324
|
+
if (lastSource === 'history' && step.source === 'current') {
|
|
325
|
+
html += '<div style="display:flex;align-items:center;margin:8px 8px 4px;gap:6px"><div style="flex:1;border-top:1px dashed var(--accent)"></div><span style="font-size:10px;color:var(--accent);white-space:nowrap">current turn</span><div style="flex:1;border-top:1px dashed var(--accent)"></div></div>';
|
|
326
|
+
}
|
|
327
|
+
lastSource = step.source;
|
|
328
|
+
|
|
329
|
+
if (step.type === 'human') {
|
|
330
|
+
html += '<div class="step-separator" style="height:2px;background:var(--accent);margin:8px 0 2px"></div>';
|
|
331
|
+
const sel = (activeStepKey === si + ':') ? ' active' : '';
|
|
332
|
+
html += '<div class="tl-step-summary' + sel + '" data-step="' + si + '" onclick="selectStep(' + si + ')">';
|
|
333
|
+
html += '<div style="color:var(--accent);padding:6px 8px;font-size:12px;white-space:normal;line-height:1.5;background:rgba(88,166,255,0.08);border-radius:4px;border-left:2px solid var(--accent);margin:4px 0">';
|
|
334
|
+
html += '<span style="font-size:13px">👤</span> ' + escapeHtml((step.humanText || '').slice(0, 300));
|
|
335
|
+
html += '</div>';
|
|
336
|
+
if (step.hasSys) html += '<div style="padding:0 8px 0 24px;font-size:10px;color:var(--dim)">📋 system-reminder</div>';
|
|
337
|
+
html += '</div>';
|
|
338
|
+
html += '<div style="height:2px;background:var(--accent);margin:2px 0 4px"></div>';
|
|
339
|
+
|
|
340
|
+
} else if (step.type === 'tool-group') {
|
|
341
|
+
// Thinking line — T8: history=indicator only, current=Ns+preview
|
|
342
|
+
if (step.thinking != null) {
|
|
343
|
+
const tSel = (activeStepKey === si + ':thinking') ? ' active' : '';
|
|
344
|
+
html += '<div class="tl-step-summary' + tSel + '" data-step="' + si + '" data-sub="thinking" onclick="selectStep(' + si + ',"thinking")" style="color:var(--dim);padding:2px 8px;font-size:11px">';
|
|
345
|
+
if (step.source === 'history') {
|
|
346
|
+
html += '🧠'; // indicator only — prior turn content not shown
|
|
347
|
+
} else {
|
|
348
|
+
const durLabel = step.thinkingDuration ? ' ' + step.thinkingDuration.toFixed(1) + 's' : '';
|
|
349
|
+
const thinkPreview = (step.thinking || '').slice(0, 80).replace(/\n/g, ' ').trim();
|
|
350
|
+
html += '🧠' + durLabel + (thinkPreview ? ' <span style="opacity:0.7">' + escapeHtml(thinkPreview) + '…</span>' : '');
|
|
351
|
+
}
|
|
352
|
+
html += '</div>';
|
|
353
|
+
}
|
|
354
|
+
// Tool calls
|
|
355
|
+
const calls = step.calls;
|
|
356
|
+
const isParallel = calls.length > 1;
|
|
357
|
+
for (let ci = 0; ci < calls.length; ci++) {
|
|
358
|
+
const c = calls[ci];
|
|
359
|
+
const bracket = isParallel ? (ci === 0 ? '┌' : ci === calls.length - 1 ? '└' : '│') : ' ';
|
|
360
|
+
const cSel = (activeStepKey === si + ':' + ci) ? ' active' : '';
|
|
361
|
+
// T6: error highlighting — CSS class + data-has-error for filter/jump
|
|
362
|
+
const errCls = c.isError ? ' tool-call-error' : '';
|
|
363
|
+
const errAttr = c.isError ? ' data-has-error="1"' : '';
|
|
364
|
+
html += '<div class="tl-step-summary' + cSel + errCls + '" data-step="' + si + '" data-call="' + ci + '"' + errAttr + ' onclick="selectStep(' + si + ',' + ci + ')">';
|
|
365
|
+
html += '<div class="msg-list-row" style="gap:4px">';
|
|
366
|
+
html += '<span style="color:var(--dim);width:8px;text-align:center;flex-shrink:0">' + bracket + '</span>';
|
|
367
|
+
html += '<span style="color:var(--green);min-width:40px;flex-shrink:0;font-weight:600">' + escapeHtml(c.name) + '</span>';
|
|
368
|
+
html += '<span style="color:var(--text);opacity:0.8;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + escapeHtml(c.preview) + '</span>';
|
|
369
|
+
if (c.pending) {
|
|
370
|
+
html += '<span style="color:var(--dim)">⏳</span>';
|
|
371
|
+
} else {
|
|
372
|
+
html += '<span style="color:' + (c.isError ? 'var(--red)' : 'var(--dim)') + ';flex-shrink:0">' + (c.isError ? '✗' : '✓') + '</span>';
|
|
373
|
+
}
|
|
374
|
+
html += '</div>';
|
|
375
|
+
if (c.isError && c.errorSummary) {
|
|
376
|
+
html += '<div style="padding:1px 8px 2px 52px;font-size:10px;color:var(--red)">' + escapeHtml(c.errorSummary.slice(0, 60)) + '</div>';
|
|
377
|
+
}
|
|
378
|
+
html += '</div>';
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
} else if (step.type === 'assistant-text') {
|
|
382
|
+
const aSel = (activeStepKey === si + ':') ? ' active' : '';
|
|
383
|
+
html += '<div class="tl-step-summary' + aSel + '" data-step="' + si + '" onclick="selectStep(' + si + ')">';
|
|
384
|
+
html += '<div style="color:var(--text);padding:6px 8px;font-size:12px;white-space:normal;line-height:1.5;background:rgba(63,185,80,0.08);border-radius:4px;border-left:2px solid var(--green);margin:4px 0">';
|
|
385
|
+
html += '<span style="font-size:13px">🤖</span> ' + escapeHtml((step.text || '').slice(0, 200));
|
|
386
|
+
html += '</div>';
|
|
387
|
+
html += '</div>';
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
return html;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Get the active step key string for highlighting
|
|
394
|
+
function getActiveStepKey() {
|
|
395
|
+
if (selectedMessageIdx < 0) return null;
|
|
396
|
+
const stepIdx = Math.floor(selectedMessageIdx / 1000);
|
|
397
|
+
const subIdx = selectedMessageIdx % 1000;
|
|
398
|
+
if (subIdx === 999) return stepIdx + ':thinking';
|
|
399
|
+
return stepIdx + ':' + (subIdx || '');
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Render step detail content as HTML
|
|
403
|
+
function renderStepDetailHtml(req, tok) {
|
|
404
|
+
if (selectedMessageIdx < 0) return '';
|
|
405
|
+
const stepIdx = Math.floor(selectedMessageIdx / 1000);
|
|
406
|
+
const subIdx = selectedMessageIdx % 1000;
|
|
407
|
+
const step = currentSteps[stepIdx];
|
|
408
|
+
if (!step) return '<div class="col-empty">No step data</div>';
|
|
409
|
+
|
|
410
|
+
if (step.type === 'human') {
|
|
411
|
+
const msgIdx = step.msgIndices[0];
|
|
412
|
+
const msg = req?.messages?.[msgIdx];
|
|
413
|
+
return msg ? '<div class="detail-content">' + renderSingleMessage(msg, tok?.perMessage?.[msgIdx], msgIdx) + '</div>' : '<div class="col-empty">No message</div>';
|
|
414
|
+
} else if (step.type === 'assistant-text') {
|
|
415
|
+
return '<div class="detail-content"><pre>' + escapeHtml(step.text || '') + '</pre></div>';
|
|
416
|
+
} else if (step.type === 'tool-group') {
|
|
417
|
+
if (subIdx === 999) {
|
|
418
|
+
const durLabel = step.thinkingDuration ? ' · ' + step.thinkingDuration.toFixed(1) + 's' : '';
|
|
419
|
+
return '<div class="detail-content">' + renderThinkingDetail(step.thinking, durLabel) + '</div>';
|
|
420
|
+
} else if (subIdx < step.calls.length) {
|
|
421
|
+
return '<div class="detail-content">' + renderToolDetail(step.calls[subIdx]) + '</div>';
|
|
422
|
+
} else {
|
|
423
|
+
const c = step.calls[0];
|
|
424
|
+
return c ? '<div class="detail-content">' + renderToolDetail(c) + '</div>' : '<div class="col-empty">Empty tool group</div>';
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
return '<div class="col-empty">Unknown step type</div>';
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function selectStep(stepIdx, sub) {
|
|
431
|
+
if (!currentSteps[stepIdx]) return; // guard: invalid step index
|
|
432
|
+
// If not in focused mode, enter it first (click on step = drill into timeline)
|
|
433
|
+
if (!isFocusedMode && typeof enterFocusedMode === 'function') {
|
|
434
|
+
selectedMessageIdx = stepIdx * 1000 + (sub === 'thinking' ? 999 : (typeof sub === 'number' ? sub : 0));
|
|
435
|
+
enterFocusedMode();
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
if (sub === 'thinking') {
|
|
439
|
+
selectedMessageIdx = stepIdx * 1000 + 999;
|
|
440
|
+
} else if (typeof sub === 'number') {
|
|
441
|
+
selectedMessageIdx = stepIdx * 1000 + sub;
|
|
442
|
+
} else {
|
|
443
|
+
selectedMessageIdx = stepIdx * 1000;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Split pane: update list highlights + detail pane
|
|
447
|
+
const listEl = colDetail.querySelector('.tl-scroll-area');
|
|
448
|
+
const detailEl = colDetail.querySelector('.tl-split-detail');
|
|
449
|
+
if (listEl) {
|
|
450
|
+
listEl.querySelectorAll('.tl-step-summary').forEach(el => {
|
|
451
|
+
el.classList.remove('active');
|
|
452
|
+
const elStep = parseInt(el.dataset.step);
|
|
453
|
+
const elCall = el.dataset.call != null ? parseInt(el.dataset.call) : -1;
|
|
454
|
+
const elSub = el.dataset.sub;
|
|
455
|
+
if (elStep === stepIdx) {
|
|
456
|
+
if (sub === 'thinking' && elSub === 'thinking') el.classList.add('active');
|
|
457
|
+
else if (typeof sub === 'number' && elCall === sub) el.classList.add('active');
|
|
458
|
+
else if (sub == null && (elCall < 0 || elCall === 0) && !elSub) el.classList.add('active');
|
|
459
|
+
}
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
if (detailEl) {
|
|
463
|
+
const e = selectedTurnIdx >= 0 ? allEntries[selectedTurnIdx] : null;
|
|
464
|
+
detailEl.innerHTML = renderStepDetailHtml(e?.req, e?.tokens);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Minimap active state — shared by both modes (step-level, not sub-item)
|
|
468
|
+
const mm = colDetail.querySelector('.minimap');
|
|
469
|
+
if (mm) {
|
|
470
|
+
mm.querySelectorAll('.minimap-block').forEach(b =>
|
|
471
|
+
b.classList.toggle('mm-active', b.dataset.step === String(stepIdx)));
|
|
472
|
+
}
|
|
473
|
+
renderBreadcrumb();
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function selectMessage(idx) {
|
|
477
|
+
selectedMessageIdx = idx;
|
|
478
|
+
renderDetailCol();
|
|
479
|
+
renderBreadcrumb();
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function renderSingleMessage(msg, perMsg, msgIdx) {
|
|
483
|
+
const tokLabel = perMsg ? ' <span class="badge">' + perMsg.tokens + ' tok</span>' : '';
|
|
484
|
+
let body = '';
|
|
485
|
+
if (typeof msg.content === 'string') {
|
|
486
|
+
body = '<pre>' + escapeHtml(msg.content) + '</pre>';
|
|
487
|
+
} else if (Array.isArray(msg.content)) {
|
|
488
|
+
for (const block of msg.content) {
|
|
489
|
+
if (block.type === 'text') {
|
|
490
|
+
body += '<div class="content-block"><div class="type">text</div><pre>' + escapeHtml(block.text) + '</pre></div>';
|
|
491
|
+
} else if (block.type === 'tool_use') {
|
|
492
|
+
body += '<div class="content-block"><div class="type">tool_use: ' + escapeHtml(block.name) +
|
|
493
|
+
' <span style="color:var(--dim);font-size:10px">' + escapeHtml(block.id || '') + '</span></div>' +
|
|
494
|
+
'<pre>' + escapeHtml(JSON.stringify(block.input, null, 2)) + '</pre></div>';
|
|
495
|
+
} else if (block.type === 'tool_result') {
|
|
496
|
+
const content = typeof block.content === 'string'
|
|
497
|
+
? block.content : JSON.stringify(block.content, null, 2);
|
|
498
|
+
body += '<div class="content-block"><div class="type">tool_result ← ' +
|
|
499
|
+
escapeHtml(block.tool_use_id || '') + '</div>' +
|
|
500
|
+
'<pre>' + escapeHtml(content) + '</pre></div>';
|
|
501
|
+
} else if (block.type === 'image') {
|
|
502
|
+
body += '<div class="content-block"><div class="type">image (' +
|
|
503
|
+
escapeHtml(block.source?.media_type || '') + ')</div>' +
|
|
504
|
+
'<div style="color:var(--dim);font-size:11px">[image data]</div></div>';
|
|
505
|
+
} else {
|
|
506
|
+
body += '<div class="content-block"><pre>' + escapeHtml(JSON.stringify(block, null, 2)) + '</pre></div>';
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
const cls = msg.role === 'user' ? classifyUserMessage(msg) : null;
|
|
511
|
+
const asmCls = msg.role === 'assistant' ? classifyAssistantMessage(msg) : null;
|
|
512
|
+
const clsHtml = cls
|
|
513
|
+
? ' ' + getUserBadgeHtml(cls).trimEnd()
|
|
514
|
+
: asmCls
|
|
515
|
+
? ' ' + getAssistantBadgeHtml(asmCls).trimEnd()
|
|
516
|
+
: '';
|
|
517
|
+
return '<div class="msg"><div class="msg-role ' + msg.role + '">[' + msgIdx + '] ' + msg.role + clsHtml + tokLabel + '</div>' + body + '</div>';
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// ── Token Minimap ──
|
|
521
|
+
// Color mapping for minimap blocks by content type
|
|
522
|
+
function mmBlockColor(stepType, blockType) {
|
|
523
|
+
if (blockType === 'thinking') return 'var(--color-thinking)';
|
|
524
|
+
if (blockType === 'tool_use') return 'var(--color-tool-use)';
|
|
525
|
+
if (blockType === 'tool_result') return 'var(--color-tool-result)';
|
|
526
|
+
if (stepType === 'human') return 'var(--accent)';
|
|
527
|
+
if (stepType === 'text') return 'var(--green)';
|
|
528
|
+
if (stepType === 'thinking') return 'var(--color-thinking)';
|
|
529
|
+
if (stepType === 'tool-group') return 'var(--color-tool-use)';
|
|
530
|
+
return 'var(--dim)';
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Build minimap block data from currentSteps + tok.perMessage
|
|
534
|
+
// Returns array of { stepIdx, color, tokens, label, isError }
|
|
535
|
+
function buildMinimapBlocks(steps, perMessage) {
|
|
536
|
+
const blocks = [];
|
|
537
|
+
if (!steps || !steps.length) return blocks;
|
|
538
|
+
|
|
539
|
+
for (let si = 0; si < steps.length; si++) {
|
|
540
|
+
const step = steps[si];
|
|
541
|
+
const indices = step.msgIndices || [];
|
|
542
|
+
const hasError = step.type === 'tool-group' && step.calls && step.calls.some(c => c.isError);
|
|
543
|
+
|
|
544
|
+
if (step.type === 'human') {
|
|
545
|
+
let totalTokens = 0;
|
|
546
|
+
if (perMessage && indices.length) {
|
|
547
|
+
for (const mi of indices) { totalTokens += (perMessage[mi]?.tokens || 0); }
|
|
548
|
+
}
|
|
549
|
+
blocks.push({ stepIdx: si, color: mmBlockColor('human'), tokens: totalTokens || 1, label: 'human', isError: false });
|
|
550
|
+
continue;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
if (perMessage && indices.length) {
|
|
554
|
+
for (const mi of indices) {
|
|
555
|
+
const pm = perMessage[mi];
|
|
556
|
+
if (!pm) continue;
|
|
557
|
+
if (pm.blocks && pm.blocks.length > 1) {
|
|
558
|
+
for (const b of pm.blocks) {
|
|
559
|
+
blocks.push({ stepIdx: si, color: mmBlockColor(step.type, b.type), tokens: b.tokens || 0, label: b.type + (b.name ? ':' + b.name : ''), isError: hasError });
|
|
560
|
+
}
|
|
561
|
+
} else {
|
|
562
|
+
blocks.push({ stepIdx: si, color: mmBlockColor(step.type), tokens: pm.tokens || 1, label: step.type, isError: hasError });
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
} else {
|
|
566
|
+
blocks.push({ stepIdx: si, color: mmBlockColor(step.type), tokens: 1, label: step.type, isError: hasError });
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
return blocks;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// Render minimap HTML — cache bar + blocks + viewport + usage label
|
|
573
|
+
function renderMinimapHtml(steps, perMessage, activeStepIdx, maxContext, usage) {
|
|
574
|
+
const blocks = buildMinimapBlocks(steps, perMessage);
|
|
575
|
+
if (!blocks.length) return '';
|
|
576
|
+
|
|
577
|
+
const estimatedTotal = blocks.reduce((s, b) => s + b.tokens, 0) || 1;
|
|
578
|
+
// Use API usage as authoritative total when available (same logic as progress bar)
|
|
579
|
+
const apiTotal = usage
|
|
580
|
+
? (usage.input_tokens || 0) + (usage.cache_read_input_tokens || 0) + (usage.cache_creation_input_tokens || 0)
|
|
581
|
+
: 0;
|
|
582
|
+
const totalTokens = apiTotal > estimatedTotal ? apiTotal : estimatedTotal;
|
|
583
|
+
const ctxWindow = maxContext || 200000;
|
|
584
|
+
const usedPct = Math.min(100, totalTokens / ctxWindow * 100).toFixed(0);
|
|
585
|
+
const remaining = Math.max(0, ctxWindow - totalTokens);
|
|
586
|
+
|
|
587
|
+
let html = '';
|
|
588
|
+
|
|
589
|
+
// Cache breakdown bar (D7) — 4px tall at top
|
|
590
|
+
if (usage) {
|
|
591
|
+
const cr = usage.cache_read_input_tokens || 0;
|
|
592
|
+
const cw = usage.cache_creation_input_tokens || 0;
|
|
593
|
+
const inp = usage.input_tokens || 0;
|
|
594
|
+
const cacheTotal = cr + cw + inp;
|
|
595
|
+
if (cacheTotal > 0) {
|
|
596
|
+
html += '<div class="minimap-cache-bar">';
|
|
597
|
+
if (cr) html += '<div style="width:' + (cr / cacheTotal * 100).toFixed(1) + '%;background:var(--color-cache-read)" title="cache_read: ' + cr.toLocaleString() + ' tok (' + (cr / cacheTotal * 100).toFixed(0) + '%)"></div>';
|
|
598
|
+
if (cw) html += '<div style="width:' + (cw / cacheTotal * 100).toFixed(1) + '%;background:var(--color-cache-write)" title="cache_write: ' + cw.toLocaleString() + ' tok (' + (cw / cacheTotal * 100).toFixed(0) + '%)"></div>';
|
|
599
|
+
if (inp) html += '<div style="width:' + (inp / cacheTotal * 100).toFixed(1) + '%;background:var(--color-input)" title="input: ' + inp.toLocaleString() + ' tok (' + (inp / cacheTotal * 100).toFixed(0) + '%)"></div>';
|
|
600
|
+
html += '</div>';
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// Blocks container
|
|
605
|
+
html += '<div class="minimap-blocks" data-total-tokens="' + totalTokens + '" data-max-context="' + ctxWindow + '">';
|
|
606
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
607
|
+
const b = blocks[i];
|
|
608
|
+
const isActive = activeStepIdx >= 0 && b.stepIdx === activeStepIdx;
|
|
609
|
+
const errStyle = b.isError ? ';border-left:2px solid var(--red)' : '';
|
|
610
|
+
const cls = 'minimap-block' + (b.isError ? ' mm-error' : '') + (isActive ? ' mm-active' : '');
|
|
611
|
+
html += '<div class="' + cls + '" data-step="' + b.stepIdx + '" data-block="' + i + '" data-tokens="' + b.tokens + '" '
|
|
612
|
+
+ 'style="background:' + b.color + errStyle + '" '
|
|
613
|
+
+ 'title="' + b.label + ' · ' + b.tokens.toLocaleString() + ' tok"></div>';
|
|
614
|
+
}
|
|
615
|
+
html += '</div>';
|
|
616
|
+
|
|
617
|
+
// Empty context area with tooltip
|
|
618
|
+
html += '<div class="minimap-empty" title="' + remaining.toLocaleString() + ' remaining"></div>';
|
|
619
|
+
|
|
620
|
+
// Usage label (hover only)
|
|
621
|
+
html += '<div class="minimap-usage">' + usedPct + '%</div>';
|
|
622
|
+
|
|
623
|
+
return html;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// Compute block heights — blocks region = (totalTokens / maxContext) * containerH
|
|
627
|
+
function layoutMinimapBlocks(minimapEl) {
|
|
628
|
+
if (!minimapEl) return;
|
|
629
|
+
const blocksContainer = minimapEl.querySelector('.minimap-blocks');
|
|
630
|
+
const emptyEl = minimapEl.querySelector('.minimap-empty');
|
|
631
|
+
if (!blocksContainer) return;
|
|
632
|
+
|
|
633
|
+
const containerH = minimapEl.clientHeight;
|
|
634
|
+
if (containerH <= 0) return;
|
|
635
|
+
|
|
636
|
+
const blockEls = blocksContainer.querySelectorAll('.minimap-block');
|
|
637
|
+
if (!blockEls.length) return;
|
|
638
|
+
|
|
639
|
+
const maxContext = parseInt(blocksContainer.dataset.maxContext) || 200000;
|
|
640
|
+
const totalTokens = parseInt(blocksContainer.dataset.totalTokens) || 1;
|
|
641
|
+
|
|
642
|
+
// Blocks region height = proportion of context used
|
|
643
|
+
const usedRatio = Math.min(1, totalTokens / maxContext);
|
|
644
|
+
// Subtract cache bar (4px) from available height
|
|
645
|
+
const cacheBar = minimapEl.querySelector('.minimap-cache-bar');
|
|
646
|
+
const cacheBarH = cacheBar ? cacheBar.offsetHeight : 0;
|
|
647
|
+
const availH = containerH - cacheBarH;
|
|
648
|
+
const blocksH = Math.max(blockEls.length, usedRatio * availH);
|
|
649
|
+
|
|
650
|
+
// Scale factor — fit all blocks within the proportional region
|
|
651
|
+
// Use actual sum of block tokens (not apiTotal) so blocks fill the region completely
|
|
652
|
+
let blockTokenSum = 0;
|
|
653
|
+
for (const el of blockEls) blockTokenSum += parseInt(el.dataset.tokens) || 1;
|
|
654
|
+
const scale = blocksH / (blockTokenSum || 1);
|
|
655
|
+
|
|
656
|
+
for (const el of blockEls) {
|
|
657
|
+
const tokens = parseInt(el.dataset.tokens) || 1;
|
|
658
|
+
el.style.height = Math.max(0.5, tokens * scale) + 'px';
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
blocksContainer.style.height = blocksH + 'px';
|
|
662
|
+
|
|
663
|
+
// Empty area fills the rest
|
|
664
|
+
if (emptyEl) {
|
|
665
|
+
const emptyH = Math.max(0, availH - blocksH);
|
|
666
|
+
emptyEl.style.height = emptyH + 'px';
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// Wire up minimap interactions: click, hover
|
|
671
|
+
let _minimapCleanup = null;
|
|
672
|
+
function initMinimapInteractions(minimapEl, scrollAreaEl) {
|
|
673
|
+
if (_minimapCleanup) { _minimapCleanup(); _minimapCleanup = null; }
|
|
674
|
+
if (!minimapEl || !scrollAreaEl) return;
|
|
675
|
+
|
|
676
|
+
const blocksContainer = minimapEl.querySelector('.minimap-blocks');
|
|
677
|
+
if (!blocksContainer) return;
|
|
678
|
+
|
|
679
|
+
// Find which block is at a given Y position (relative to minimap top)
|
|
680
|
+
function blockAtY(y) {
|
|
681
|
+
const blockEls = blocksContainer.querySelectorAll('.minimap-block');
|
|
682
|
+
const mmRect = minimapEl.getBoundingClientRect();
|
|
683
|
+
for (const b of blockEls) {
|
|
684
|
+
const bRect = b.getBoundingClientRect();
|
|
685
|
+
const bTop = bRect.top - mmRect.top;
|
|
686
|
+
if (y >= bTop && y < bTop + bRect.height) return b;
|
|
687
|
+
}
|
|
688
|
+
// Between sub-pixel blocks — find closest
|
|
689
|
+
let closest = null, closestDist = Infinity;
|
|
690
|
+
for (const b of blockEls) {
|
|
691
|
+
const bRect = b.getBoundingClientRect();
|
|
692
|
+
const bMid = (bRect.top - mmRect.top) + bRect.height / 2;
|
|
693
|
+
const dist = Math.abs(y - bMid);
|
|
694
|
+
if (dist < closestDist) { closestDist = dist; closest = b; }
|
|
695
|
+
}
|
|
696
|
+
return closest;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// Click to navigate
|
|
700
|
+
minimapEl.addEventListener('click', (e) => {
|
|
701
|
+
if (e.target.classList.contains('minimap-empty')) return;
|
|
702
|
+
const mmRect = minimapEl.getBoundingClientRect();
|
|
703
|
+
const block = blockAtY(e.clientY - mmRect.top);
|
|
704
|
+
if (block) {
|
|
705
|
+
const targetStep = parseInt(block.dataset.step);
|
|
706
|
+
if (targetStep >= 0 && typeof selectStep === 'function') {
|
|
707
|
+
selectStep(targetStep);
|
|
708
|
+
const stepEl = scrollAreaEl.querySelector('[data-step="' + targetStep + '"]');
|
|
709
|
+
if (stepEl) stepEl.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
// Minimap → Timeline hover
|
|
715
|
+
minimapEl.addEventListener('mousemove', (e) => {
|
|
716
|
+
if (e.target.classList.contains('minimap-empty')) {
|
|
717
|
+
minimapEl.querySelectorAll('.mm-highlight').forEach(b => b.classList.remove('mm-highlight'));
|
|
718
|
+
scrollAreaEl.querySelectorAll('.mm-hover').forEach(el => el.classList.remove('mm-hover'));
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
const mmRect = minimapEl.getBoundingClientRect();
|
|
722
|
+
const block = blockAtY(e.clientY - mmRect.top);
|
|
723
|
+
if (block) {
|
|
724
|
+
const stepIdx = block.dataset.step;
|
|
725
|
+
minimapEl.querySelectorAll('.minimap-block').forEach(b => b.classList.toggle('mm-highlight', b.dataset.step === stepIdx));
|
|
726
|
+
scrollAreaEl.querySelectorAll('.tl-step-summary').forEach(el => el.classList.toggle('mm-hover', el.dataset.step === stepIdx));
|
|
727
|
+
}
|
|
728
|
+
});
|
|
729
|
+
minimapEl.addEventListener('mouseleave', () => {
|
|
730
|
+
minimapEl.querySelectorAll('.mm-highlight').forEach(b => b.classList.remove('mm-highlight'));
|
|
731
|
+
scrollAreaEl.querySelectorAll('.mm-hover').forEach(el => el.classList.remove('mm-hover'));
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
// Timeline → minimap hover
|
|
735
|
+
scrollAreaEl.addEventListener('mouseover', (e) => {
|
|
736
|
+
const stepEl = e.target.closest('.tl-step-summary');
|
|
737
|
+
if (!stepEl) return;
|
|
738
|
+
const stepIdx = stepEl.dataset.step;
|
|
739
|
+
minimapEl.querySelectorAll('.minimap-block').forEach(b => b.classList.toggle('mm-highlight', b.dataset.step === stepIdx));
|
|
740
|
+
});
|
|
741
|
+
scrollAreaEl.addEventListener('mouseleave', () => {
|
|
742
|
+
minimapEl.querySelectorAll('.mm-highlight').forEach(b => b.classList.remove('mm-highlight'));
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
// Recompute on resize
|
|
746
|
+
const ro = new ResizeObserver(() => { layoutMinimapBlocks(minimapEl); });
|
|
747
|
+
ro.observe(minimapEl);
|
|
748
|
+
|
|
749
|
+
_minimapCleanup = () => { ro.disconnect(); };
|
|
750
|
+
}
|