main
js 396 lines 11.5 KB
Raw
1 const DEFAULT_INITIAL_LIMIT = 60;
2 const DEFAULT_PAGE_SIZE = 60;
3 const DEFAULT_MAX_WINDOW = DEFAULT_PAGE_SIZE * 2;
4
5 function compareRecords(a, b) {
6 const aNo = getRecordOrder(a.message);
7 const bNo = getRecordOrder(b.message);
8 return aNo - bNo || a.sequence - b.sequence;
9 }
10
11 function getRecordOrder(message) {
12 const rawNo = message?.no;
13 return rawNo !== undefined && rawNo !== null && Number.isFinite(Number(rawNo))
14 ? Number(rawNo)
15 : Number.MAX_SAFE_INTEGER;
16 }
17
18 export function getMessageCacheKey(message) {
19 const id = message?.id;
20 if (id !== undefined && id !== null && String(id) !== "") {
21 // A root agent's final GEN record and its response intentionally share an
22 // id, but they are separate log entries and both must survive replay.
23 // Including the type still lets optimistic user messages merge with their
24 // backend update while keeping that GEN/response pair distinct.
25 const type = String(message?.type || "unknown");
26 return `id:${String(id)}:type:${type}`;
27 }
28
29 const no = message?.no;
30 if (no !== undefined && no !== null && String(no) !== "") {
31 return `no:${String(no)}`;
32 }
33
34 return null;
35 }
36
37 const PROCESS_STEP_TYPES = new Set([
38 "agent",
39 "code_exe",
40 "tool",
41 "mcp",
42 "subagent",
43 "progress",
44 "info",
45 ]);
46
47 function hasUpcomingProcessStep(messages, startIndex) {
48 for (let index = startIndex + 1; index < messages.length; index++) {
49 const message = messages[index];
50 const type = String(message?.type || "");
51 if (type === "util") continue;
52 if (PROCESS_STEP_TYPES.has(type)) return true;
53 if (type === "warning" || type === "rate_limit") return true;
54 if (type === "response" && Number(message?.agentno || 0) > 0) {
55 return true;
56 }
57 return false;
58 }
59 return false;
60 }
61
62 /**
63 * Classifies raw log entries into the same logical units that the message DOM
64 * renderer creates. Window boundaries use these units so plugin-backed steps
65 * such as code execution cannot split an otherwise contiguous process group.
66 */
67 export function classifyMessageRenderUnits(messages = []) {
68 let activeGroup = null;
69 let lastGroup = null;
70 let lastUnitType = null;
71
72 const startGroup = (message, index) => {
73 const rawIdentity = message?.id !== undefined && message?.id !== null &&
74 String(message.id) !== ""
75 ? message.id
76 : message?.no !== undefined && message?.no !== null
77 ? message.no
78 : `anonymous-${index}`;
79 const identity = String(rawIdentity);
80 return { id: identity, key: `process:${identity}`, complete: false };
81 };
82 const assignGroup = (group, isStep) => {
83 lastGroup = group;
84 lastUnitType = "process";
85 return { key: group.key, group, isStep };
86 };
87
88 return messages.map((message, index) => {
89 const type = String(message?.type || "");
90 const standalone = {
91 key: `entry:${getMessageCacheKey(message) || index}`,
92 group: null,
93 isStep: false,
94 };
95
96 if (PROCESS_STEP_TYPES.has(type)) {
97 activeGroup ||= startGroup(message, index);
98 const unit = assignGroup(activeGroup, true);
99 if (type === "info" && message?.kvps?.finished) {
100 activeGroup.complete = true;
101 activeGroup = null;
102 }
103 return unit;
104 }
105
106 if (type === "util") {
107 if (activeGroup || hasUpcomingProcessStep(messages, index)) {
108 activeGroup ||= startGroup(message, index);
109 return assignGroup(activeGroup, true);
110 }
111
112 // Utilities on their own must not manufacture a visible process group
113 // around a root response. They remain standalone until a real process
114 // step appears, and post-response utilities cannot reopen the group.
115 activeGroup = null;
116 lastUnitType = "standalone";
117 return standalone;
118 }
119
120 if (type === "response" && Number(message?.agentno || 0) > 0) {
121 activeGroup ||= startGroup(message, index);
122 return assignGroup(activeGroup, true);
123 }
124
125 if (
126 type === "response" &&
127 (activeGroup || (lastUnitType === "process" && lastGroup))
128 ) {
129 const group = activeGroup || lastGroup;
130 const unit = assignGroup(group, false);
131 group.complete = true;
132 activeGroup = null;
133 return unit;
134 }
135
136 if ((type === "warning" || type === "rate_limit") && activeGroup) {
137 return assignGroup(activeGroup, true);
138 }
139
140 activeGroup = null;
141 lastUnitType = "standalone";
142 return standalone;
143 });
144 }
145
146 /**
147 * Keeps the complete raw log in JavaScript while exposing a bounded contiguous
148 * slice for DOM rendering. The class deliberately has no DOM dependencies so
149 * window selection can be tested independently from message handlers.
150 */
151 export class MessageWindow {
152 constructor({
153 initialLimit = DEFAULT_INITIAL_LIMIT,
154 pageSize = DEFAULT_PAGE_SIZE,
155 maxWindow = DEFAULT_MAX_WINDOW,
156 getUnitKeys = null,
157 } = {}) {
158 this.initialLimit = Math.max(1, initialLimit);
159 this.pageSize = Math.max(1, pageSize);
160 this.maxWindow = Math.max(this.initialLimit, maxWindow);
161 this.getUnitKeys = typeof getUnitKeys === "function" ? getUnitKeys : null;
162 this.reset([]);
163 }
164
165 reset(messages = []) {
166 this._recordsByKey = new Map();
167 this._indexByKey = new Map();
168 this._records = [];
169 this._nextSequence = 0;
170 this._nextAnonymous = 0;
171 this.start = 0;
172 this.end = 0;
173 this.merge(messages);
174 this.showTail();
175 }
176
177 merge(messages = [], { followTail = true } = {}) {
178 const previousStartKey = this._records[this.start]?.key || null;
179 const previousEndKey = this._records[this.end - 1]?.key || null;
180 const wasAtTail = followTail && this.end >= this._records.length;
181 const addedKeys = new Set();
182 let requiresSort = false;
183
184 for (const message of Array.isArray(messages) ? messages : []) {
185 if (!message) continue;
186 const key = getMessageCacheKey(message) ||
187 `anonymous:${this._nextAnonymous++}`;
188 const existing = this._recordsByKey.get(key);
189 if (existing) {
190 requiresSort ||=
191 getRecordOrder(existing.message) !== getRecordOrder(message);
192 existing.message = message;
193 } else {
194 const record = {
195 key,
196 message,
197 sequence: this._nextSequence++,
198 };
199 const previous = this._records[this._records.length - 1];
200 if (previous && compareRecords(previous, record) > 0) {
201 requiresSort = true;
202 }
203 this._recordsByKey.set(key, record);
204 this._indexByKey.set(key, this._records.length);
205 this._records.push(record);
206 addedKeys.add(key);
207 }
208 }
209
210 if (requiresSort) {
211 this._records.sort(compareRecords);
212 this._rebuildIndexes();
213 }
214 this._rebuildRenderUnits();
215
216 if (!previousStartKey || !this._records.length) {
217 this.showTail();
218 } else if (wasAtTail) {
219 const previousStart = this._indexOf(previousStartKey);
220 this.start = previousStart >= 0
221 ? previousStart
222 : Math.max(0, this._records.length - this.initialLimit);
223 this.end = this._records.length;
224 } else {
225 const previousStart = this._indexOf(previousStartKey);
226 const previousEnd = this._indexOf(previousEndKey);
227 this.start = previousStart >= 0 ? previousStart : this.start;
228 this.end = previousEnd >= 0 ? previousEnd + 1 : this.end;
229 this._clampBounds();
230 }
231 return addedKeys;
232 }
233
234 showTail() {
235 this.end = this._records.length;
236 this.start = Math.max(0, this.end - this.initialLimit);
237 }
238
239 showHead() {
240 this.start = 0;
241 this.end = Math.min(this._records.length, this.initialLimit);
242 }
243
244 compactTailIfNeeded() {
245 if (!this.isAtTail() || this.baseRenderedCount <= this.maxWindow) {
246 return false;
247 }
248 this.end = this._records.length;
249 this.start = Math.max(0, this.end - this.maxWindow);
250 return true;
251 }
252
253 shiftOlder() {
254 if (!this.hasOlder) return false;
255 const previous = this._getVisibleBounds(this.start, this.end);
256 let nextStart = Math.max(0, this.start - this.pageSize);
257 let nextEnd = Math.min(this._records.length, nextStart + this.maxWindow);
258 const next = this._getVisibleBounds(nextStart, nextEnd);
259 if (
260 next.start === previous.start &&
261 next.end === previous.end &&
262 previous.start > 0
263 ) {
264 nextStart = Math.max(0, previous.start - this.pageSize);
265 nextEnd = Math.min(this._records.length, nextStart + this.maxWindow);
266 }
267 this.start = nextStart;
268 this.end = nextEnd;
269 this._clampBounds();
270 return true;
271 }
272
273 shiftNewer() {
274 if (!this.hasNewer) return false;
275 const previous = this._getVisibleBounds(this.start, this.end);
276 let nextEnd = Math.min(this._records.length, this.end + this.pageSize);
277 let nextStart = Math.max(0, nextEnd - this.maxWindow);
278 const next = this._getVisibleBounds(nextStart, nextEnd);
279 if (
280 next.start === previous.start &&
281 next.end === previous.end &&
282 previous.end < this._records.length
283 ) {
284 nextEnd = Math.min(
285 this._records.length,
286 previous.end + this.pageSize,
287 );
288 nextStart = Math.max(0, nextEnd - this.maxWindow);
289 }
290 this.start = nextStart;
291 this.end = nextEnd;
292 this._clampBounds();
293 return true;
294 }
295
296 visibleMessages() {
297 const bounds = this._getVisibleBounds(this.start, this.end);
298 return this._records.slice(bounds.start, bounds.end).map((record) =>
299 record.message
300 );
301 }
302
303 isKeyVisible(key) {
304 if (!key) return false;
305 const index = this._indexOf(key);
306 const bounds = this._getVisibleBounds(this.start, this.end);
307 return index >= bounds.start && index < bounds.end;
308 }
309
310 isAtTail() {
311 return this.visibleEnd >= this._records.length;
312 }
313
314 get size() {
315 return this._records.length;
316 }
317
318 get renderedCount() {
319 return Math.max(0, this.visibleEnd - this.visibleStart);
320 }
321
322 get baseRenderedCount() {
323 return Math.max(0, this.end - this.start);
324 }
325
326 get visibleStart() {
327 return this._getVisibleBounds(this.start, this.end).start;
328 }
329
330 get visibleEnd() {
331 return this._getVisibleBounds(this.start, this.end).end;
332 }
333
334 get hasOlder() {
335 return this.visibleStart > 0;
336 }
337
338 get hasNewer() {
339 return this.visibleEnd < this._records.length;
340 }
341
342 get olderCount() {
343 return this.visibleStart;
344 }
345
346 get newerCount() {
347 return Math.max(0, this._records.length - this.visibleEnd);
348 }
349
350 _indexOf(key) {
351 if (!key) return -1;
352 return this._indexByKey.get(key) ?? -1;
353 }
354
355 _rebuildIndexes() {
356 this._indexByKey.clear();
357 this._records.forEach((record, index) => {
358 this._indexByKey.set(record.key, index);
359 });
360 }
361
362 _rebuildRenderUnits() {
363 const messages = this._records.map((record) => record.message);
364 const suppliedKeys = this.getUnitKeys?.(messages);
365 const unitKeys = Array.isArray(suppliedKeys) &&
366 suppliedKeys.length === messages.length
367 ? suppliedKeys
368 : messages.map((_, index) => index);
369
370 this._unitStartByIndex = new Array(messages.length);
371 this._unitEndByIndex = new Array(messages.length);
372 let start = 0;
373 while (start < unitKeys.length) {
374 let end = start + 1;
375 while (end < unitKeys.length && unitKeys[end] === unitKeys[start]) end++;
376 for (let index = start; index < end; index++) {
377 this._unitStartByIndex[index] = start;
378 this._unitEndByIndex[index] = end;
379 }
380 start = end;
381 }
382 }
383
384 _getVisibleBounds(start, end) {
385 if (!this._records.length || end <= start) return { start, end };
386 return {
387 start: this._unitStartByIndex?.[start] ?? start,
388 end: this._unitEndByIndex?.[end - 1] ?? end,
389 };
390 }
391
392 _clampBounds() {
393 this.start = Math.max(0, Math.min(this.start, this._records.length));
394 this.end = Math.max(this.start, Math.min(this.end, this._records.length));
395 }
396 }