@opengeni/react 0.6.2 → 0.8.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/README.md +17 -6
- package/dist/{chunk-DEW2ZNF2.js → chunk-5C7RGAWA.js} +70 -37
- package/dist/chunk-5C7RGAWA.js.map +1 -0
- package/dist/index.d.ts +45 -9
- package/dist/index.js +1385 -732
- package/dist/index.js.map +1 -1
- package/dist/{machines-BD6h9P_s.d.ts → machines-BeZ3bD3t.d.ts} +2 -2
- package/dist/machines.d.ts +1 -1
- package/dist/machines.js +1 -1
- package/package.json +2 -2
- package/src/client.ts +1 -0
- package/src/components/chat-composer.tsx +127 -60
- package/src/components/code-editor.tsx +15 -15
- package/src/components/desktop-viewer.tsx +40 -36
- package/src/components/diff-view.tsx +22 -22
- package/src/components/enrollment-consent.tsx +13 -13
- package/src/components/file-browser.tsx +74 -67
- package/src/components/fleet-tile.tsx +3 -3
- package/src/components/machine-card.tsx +6 -6
- package/src/components/machine-status-pill.tsx +3 -3
- package/src/components/machines-dashboard.tsx +28 -14
- package/src/components/markdown.tsx +6 -6
- package/src/components/message-timeline.tsx +207 -12
- package/src/components/pierre-diff.tsx +9 -10
- package/src/components/pierre-file.tsx +8 -8
- package/src/components/sandbox-files.tsx +37 -40
- package/src/components/sandbox-terminal.tsx +9 -11
- package/src/components/session-status.tsx +2 -2
- package/src/components/workspace-dock.tsx +153 -49
- package/src/hooks/use-file-attachments.ts +45 -15
- package/src/hooks/use-session-events.ts +283 -8
- package/src/index.ts +2 -2
- package/src/lib/format.ts +32 -0
- package/src/lib/xterm-theme.ts +8 -11
- package/src/timeline/activity-rail.tsx +5 -2
- package/src/timeline/entrance.tsx +30 -0
- package/src/timeline/parsers.ts +104 -0
- package/src/timeline/projection.ts +22 -5
- package/src/timeline/screenshot-lightbox.tsx +1 -1
- package/src/timeline/shared.tsx +4 -1
- package/src/timeline/tool-diff.tsx +1 -1
- package/src/timeline/tool-renderers.tsx +46 -7
- package/src/timeline/turn-summary.tsx +24 -4
- package/styles/tokens.css +8 -0
- package/dist/chunk-DEW2ZNF2.js.map +0 -1
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import type { SessionEvent, SessionStatus, StreamConnectionState } from "@opengeni/sdk";
|
|
2
|
-
import { useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
3
3
|
import { useOpenGeni, type ClientOverride } from "../provider";
|
|
4
|
-
import { buildTimeline, sessionStatusFromEvents, type TimelineItem } from "../timeline";
|
|
4
|
+
import { buildTimeline, groupTimeline, sessionStatusFromEvents, type TimelineItem } from "../timeline";
|
|
5
|
+
import type { SessionClientLike } from "../client";
|
|
5
6
|
|
|
6
7
|
export type SessionEventsConnectionState = StreamConnectionState | "idle" | "ended" | "error";
|
|
7
8
|
|
|
8
9
|
export type UseSessionEventsOptions = ClientOverride & {
|
|
9
|
-
/** Resume after this sequence (exclusive).
|
|
10
|
+
/** Resume after this sequence (exclusive). Nonzero keeps full replay/resume semantics. */
|
|
10
11
|
after?: number | undefined;
|
|
12
|
+
/** Load a bounded tail by default, or opt back into full replay from `after`. */
|
|
13
|
+
replay?: "windowed" | "full" | undefined;
|
|
11
14
|
/** Pause the stream without unmounting (e.g. hidden tab). Defaults to true. */
|
|
12
15
|
enabled?: boolean | undefined;
|
|
13
16
|
};
|
|
@@ -22,34 +25,61 @@ export type UseSessionEventsResult = {
|
|
|
22
25
|
connectionState: SessionEventsConnectionState;
|
|
23
26
|
/** Highest sequence seen so far (0 before the first event). */
|
|
24
27
|
lastSequence: number;
|
|
28
|
+
/** Whether older durable events are available before the current window. */
|
|
29
|
+
hasOlder: boolean;
|
|
30
|
+
/** True while an older window is being fetched. */
|
|
31
|
+
loadingOlder: boolean;
|
|
32
|
+
/** Prepend an older density-bounded window; resolves true when more remain. */
|
|
33
|
+
loadOlder: () => Promise<boolean>;
|
|
25
34
|
error: Error | null;
|
|
26
35
|
};
|
|
27
36
|
|
|
37
|
+
const TAIL_PAGE_SIZE = 5000;
|
|
38
|
+
const INITIAL_GROUP_TARGET = 48;
|
|
39
|
+
const INITIAL_FETCH_CAP = 3;
|
|
40
|
+
const OLDER_GROUP_TARGET = 32;
|
|
41
|
+
const OLDER_FETCH_CAP = 2;
|
|
42
|
+
const BOUNDARY_PAGE_CAP = 4;
|
|
43
|
+
|
|
28
44
|
/**
|
|
29
45
|
* Live-stream a session's event log with replay-by-sequence, reconnect, and
|
|
30
|
-
* batched React updates.
|
|
31
|
-
*
|
|
46
|
+
* batched React updates. Fresh loads default to a bounded tail window; pass
|
|
47
|
+
* `replay: "full"` or a nonzero `after` for the previous full replay path.
|
|
32
48
|
*/
|
|
33
49
|
export function useSessionEvents(sessionId: string | null | undefined, options: UseSessionEventsOptions = {}): UseSessionEventsResult {
|
|
34
50
|
const { client, workspaceId } = useOpenGeni(options);
|
|
35
51
|
const enabled = options.enabled ?? true;
|
|
36
52
|
const after = options.after ?? 0;
|
|
53
|
+
const replay = options.replay ?? "windowed";
|
|
54
|
+
const fullReplay = replay === "full" || after !== 0;
|
|
37
55
|
|
|
38
56
|
const [events, setEvents] = useState<SessionEvent[]>([]);
|
|
39
57
|
const [connectionState, setConnectionState] = useState<SessionEventsConnectionState>("idle");
|
|
40
58
|
const [error, setError] = useState<Error | null>(null);
|
|
59
|
+
const [hasOlder, setHasOlder] = useState(false);
|
|
60
|
+
const [loadingOlder, setLoadingOlder] = useState(false);
|
|
41
61
|
const lastSequenceRef = useRef(after);
|
|
62
|
+
const oldestSequenceRef = useRef<number | null>(null);
|
|
63
|
+
const hasOlderRef = useRef(false);
|
|
64
|
+
const loadingOlderRef = useRef(false);
|
|
42
65
|
const streamKeyRef = useRef<string | null>(null);
|
|
66
|
+
const generationRef = useRef(0);
|
|
43
67
|
|
|
44
68
|
useEffect(() => {
|
|
45
69
|
// Reset the accumulated log only when the stream identity changes —
|
|
46
70
|
// pausing via `enabled: false` keeps the timeline visible.
|
|
47
|
-
const streamKey = `${workspaceId}\u0000${sessionId ?? ""}\u0000${after}`;
|
|
71
|
+
const streamKey = `${workspaceId}\u0000${sessionId ?? ""}\u0000${after}\u0000${fullReplay ? "full" : "windowed"}`;
|
|
48
72
|
if (streamKeyRef.current !== streamKey) {
|
|
49
73
|
streamKeyRef.current = streamKey;
|
|
74
|
+
generationRef.current += 1;
|
|
50
75
|
setEvents([]);
|
|
51
76
|
setError(null);
|
|
77
|
+
setHasOlder(false);
|
|
78
|
+
setLoadingOlder(false);
|
|
52
79
|
lastSequenceRef.current = after;
|
|
80
|
+
oldestSequenceRef.current = null;
|
|
81
|
+
hasOlderRef.current = false;
|
|
82
|
+
loadingOlderRef.current = false;
|
|
53
83
|
}
|
|
54
84
|
if (!sessionId || !enabled) {
|
|
55
85
|
setConnectionState("idle");
|
|
@@ -72,7 +102,7 @@ export function useSessionEvents(sessionId: string | null | undefined, options:
|
|
|
72
102
|
// the next connect instead of being skipped.
|
|
73
103
|
const lastInBatch = batch[batch.length - 1];
|
|
74
104
|
if (lastInBatch) {
|
|
75
|
-
lastSequenceRef.current =
|
|
105
|
+
lastSequenceRef.current = Math.max(lastSequenceRef.current, ...batch.map(eventResumeSequence));
|
|
76
106
|
}
|
|
77
107
|
setEvents((existing) => [...existing, ...batch]);
|
|
78
108
|
};
|
|
@@ -82,6 +112,24 @@ export function useSessionEvents(sessionId: string | null | undefined, options:
|
|
|
82
112
|
|
|
83
113
|
void (async () => {
|
|
84
114
|
try {
|
|
115
|
+
if (!fullReplay) {
|
|
116
|
+
setConnectionState("connecting");
|
|
117
|
+
const window = await loadEventWindow(client, workspaceId, sessionId, {
|
|
118
|
+
before: Number.MAX_SAFE_INTEGER,
|
|
119
|
+
pageSize: TAIL_PAGE_SIZE,
|
|
120
|
+
targetGroups: INITIAL_GROUP_TARGET,
|
|
121
|
+
maxFetches: INITIAL_FETCH_CAP,
|
|
122
|
+
signal: controller.signal,
|
|
123
|
+
});
|
|
124
|
+
if (controller.signal.aborted) {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
oldestSequenceRef.current = window.oldestSequence;
|
|
128
|
+
hasOlderRef.current = window.hasOlder;
|
|
129
|
+
lastSequenceRef.current = window.newestSequence;
|
|
130
|
+
setHasOlder(window.hasOlder);
|
|
131
|
+
setEvents(window.events);
|
|
132
|
+
}
|
|
85
133
|
const stream = client.streamEvents(workspaceId, sessionId, {
|
|
86
134
|
after: lastSequenceRef.current,
|
|
87
135
|
signal: controller.signal,
|
|
@@ -114,7 +162,52 @@ export function useSessionEvents(sessionId: string | null | undefined, options:
|
|
|
114
162
|
clearTimeout(flushTimer);
|
|
115
163
|
}
|
|
116
164
|
};
|
|
117
|
-
}, [client, workspaceId, sessionId, after, enabled]);
|
|
165
|
+
}, [client, workspaceId, sessionId, after, enabled, fullReplay]);
|
|
166
|
+
|
|
167
|
+
const loadOlder = useCallback(async (): Promise<boolean> => {
|
|
168
|
+
if (!sessionId || fullReplay || loadingOlderRef.current || !hasOlderRef.current) {
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
const before = oldestSequenceRef.current;
|
|
172
|
+
if (before === null) {
|
|
173
|
+
hasOlderRef.current = false;
|
|
174
|
+
setHasOlder(false);
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
const generation = generationRef.current;
|
|
178
|
+
loadingOlderRef.current = true;
|
|
179
|
+
setLoadingOlder(true);
|
|
180
|
+
try {
|
|
181
|
+
const window = await loadEventWindow(client, workspaceId, sessionId, {
|
|
182
|
+
before,
|
|
183
|
+
pageSize: TAIL_PAGE_SIZE,
|
|
184
|
+
targetGroups: OLDER_GROUP_TARGET,
|
|
185
|
+
maxFetches: OLDER_FETCH_CAP,
|
|
186
|
+
});
|
|
187
|
+
if (generationRef.current !== generation) {
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
if (window.events.length === 0) {
|
|
191
|
+
oldestSequenceRef.current = null;
|
|
192
|
+
hasOlderRef.current = false;
|
|
193
|
+
setHasOlder(false);
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
oldestSequenceRef.current = window.oldestSequence;
|
|
197
|
+
hasOlderRef.current = window.hasOlder;
|
|
198
|
+
setHasOlder(window.hasOlder);
|
|
199
|
+
setEvents((existing) => {
|
|
200
|
+
assertPrependOrder(existing, window.events);
|
|
201
|
+
return [...window.events, ...existing];
|
|
202
|
+
});
|
|
203
|
+
return window.hasOlder;
|
|
204
|
+
} finally {
|
|
205
|
+
if (generationRef.current === generation) {
|
|
206
|
+
loadingOlderRef.current = false;
|
|
207
|
+
setLoadingOlder(false);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}, [client, workspaceId, sessionId, fullReplay]);
|
|
118
211
|
|
|
119
212
|
const timeline = useMemo(() => buildTimeline(events), [events]);
|
|
120
213
|
const sessionStatus = useMemo(() => sessionStatusFromEvents(events), [events]);
|
|
@@ -125,6 +218,188 @@ export function useSessionEvents(sessionId: string | null | undefined, options:
|
|
|
125
218
|
sessionStatus,
|
|
126
219
|
connectionState,
|
|
127
220
|
lastSequence: lastSequenceRef.current,
|
|
221
|
+
hasOlder: fullReplay ? false : hasOlder,
|
|
222
|
+
loadingOlder: fullReplay ? false : loadingOlder,
|
|
223
|
+
loadOlder,
|
|
128
224
|
error,
|
|
129
225
|
};
|
|
130
226
|
}
|
|
227
|
+
|
|
228
|
+
type LoadedEventWindow = {
|
|
229
|
+
events: SessionEvent[];
|
|
230
|
+
oldestSequence: number | null;
|
|
231
|
+
newestSequence: number;
|
|
232
|
+
hasOlder: boolean;
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
async function loadEventWindow(
|
|
236
|
+
client: SessionClientLike,
|
|
237
|
+
workspaceId: string,
|
|
238
|
+
sessionId: string,
|
|
239
|
+
options: {
|
|
240
|
+
before: number;
|
|
241
|
+
pageSize: number;
|
|
242
|
+
targetGroups: number;
|
|
243
|
+
maxFetches: number;
|
|
244
|
+
signal?: AbortSignal;
|
|
245
|
+
},
|
|
246
|
+
): Promise<LoadedEventWindow> {
|
|
247
|
+
let cursor = options.before;
|
|
248
|
+
let buffer: SessionEvent[] = [];
|
|
249
|
+
let reachedStart = false;
|
|
250
|
+
let fetches = 0;
|
|
251
|
+
|
|
252
|
+
while (fetches < options.maxFetches) {
|
|
253
|
+
if (buffer.length > 0 && groupCount(buffer) >= options.targetGroups) {
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
const page = await loadPreviousPage(client, workspaceId, sessionId, cursor, {
|
|
257
|
+
pageSize: options.pageSize,
|
|
258
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
259
|
+
});
|
|
260
|
+
fetches += 1;
|
|
261
|
+
if (page.length === 0) {
|
|
262
|
+
reachedStart = true;
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
265
|
+
assertAscending(page);
|
|
266
|
+
buffer = [...page, ...buffer];
|
|
267
|
+
cursor = page[0]!.sequence;
|
|
268
|
+
if (isLogStart(page[0]!)) {
|
|
269
|
+
reachedStart = true;
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Boundary snap: a window that starts mid-turn TRIMS its head to the oldest
|
|
275
|
+
// turn boundary already in the buffer — the dropped fragment is refetched by
|
|
276
|
+
// the next loadOlder (everything below the new oldest sequence), whose own
|
|
277
|
+
// window snaps the same way, so every seam lands on a turn start. Extra
|
|
278
|
+
// pages are fetched only when the buffer holds no boundary at all (one
|
|
279
|
+
// monster turn); past the cap a mid-turn top is accepted.
|
|
280
|
+
let snapPages = 0;
|
|
281
|
+
while (!reachedStart && findBoundaryIndex(buffer) === -1 && snapPages < BOUNDARY_PAGE_CAP && fetches < options.maxFetches) {
|
|
282
|
+
const page = await loadPreviousPage(client, workspaceId, sessionId, cursor, {
|
|
283
|
+
pageSize: options.pageSize,
|
|
284
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
285
|
+
});
|
|
286
|
+
fetches += 1;
|
|
287
|
+
snapPages += 1;
|
|
288
|
+
if (page.length === 0) {
|
|
289
|
+
reachedStart = true;
|
|
290
|
+
break;
|
|
291
|
+
}
|
|
292
|
+
assertAscending(page);
|
|
293
|
+
buffer = [...page, ...buffer];
|
|
294
|
+
cursor = page[0]!.sequence;
|
|
295
|
+
if (isLogStart(page[0]!)) {
|
|
296
|
+
reachedStart = true;
|
|
297
|
+
break;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
if (!reachedStart) {
|
|
301
|
+
const boundary = findBoundaryIndex(buffer);
|
|
302
|
+
if (boundary > 0) {
|
|
303
|
+
buffer = buffer.slice(boundary);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const oldest = buffer[0] ?? null;
|
|
308
|
+
const newest = buffer[buffer.length - 1] ?? null;
|
|
309
|
+
return {
|
|
310
|
+
events: buffer,
|
|
311
|
+
oldestSequence: oldest?.sequence ?? null,
|
|
312
|
+
newestSequence: newest ? maxResumeSequence(buffer) : 0,
|
|
313
|
+
hasOlder: buffer.length > 0 && !reachedStart && oldest?.type !== "session.created",
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Index of the oldest clean turn start in the buffer, or -1. */
|
|
318
|
+
function findBoundaryIndex(events: SessionEvent[]): number {
|
|
319
|
+
for (let index = 0; index < events.length; index += 1) {
|
|
320
|
+
const type = events[index]!.type;
|
|
321
|
+
if (type === "session.created" || type === "user.message") {
|
|
322
|
+
return index;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return -1;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
type PreviousPage = SessionEvent[] & { requested: number };
|
|
329
|
+
|
|
330
|
+
async function loadPreviousPage(
|
|
331
|
+
client: SessionClientLike,
|
|
332
|
+
workspaceId: string,
|
|
333
|
+
sessionId: string,
|
|
334
|
+
before: number,
|
|
335
|
+
options: {
|
|
336
|
+
pageSize: number;
|
|
337
|
+
signal?: AbortSignal;
|
|
338
|
+
},
|
|
339
|
+
): Promise<PreviousPage> {
|
|
340
|
+
if (options.signal?.aborted) {
|
|
341
|
+
throw abortError();
|
|
342
|
+
}
|
|
343
|
+
const requested = options.pageSize;
|
|
344
|
+
if (requested === 0) {
|
|
345
|
+
return Object.assign([], { requested });
|
|
346
|
+
}
|
|
347
|
+
const page = await client.listEvents(workspaceId, sessionId, { before, limit: requested, compact: true });
|
|
348
|
+
if (options.signal?.aborted) {
|
|
349
|
+
throw abortError();
|
|
350
|
+
}
|
|
351
|
+
return Object.assign(page, { requested });
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function groupCount(events: SessionEvent[]): number {
|
|
355
|
+
if (events.length === 0) {
|
|
356
|
+
return 0;
|
|
357
|
+
}
|
|
358
|
+
return groupTimeline(buildTimeline(events)).length;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function isLogStart(event: SessionEvent): boolean {
|
|
362
|
+
return event.type === "session.created" || event.sequence <= 1;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function maxResumeSequence(events: SessionEvent[]): number {
|
|
366
|
+
return events.reduce((max, event) => Math.max(max, eventResumeSequence(event)), 0);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function eventResumeSequence(event: SessionEvent): number {
|
|
370
|
+
const payload = asRecord(event.payload);
|
|
371
|
+
const coalescedUntil = Number(payload.coalescedUntil);
|
|
372
|
+
return Math.max(event.sequence, Number.isFinite(coalescedUntil) ? Math.floor(coalescedUntil) : 0);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function asRecord(value: unknown): Record<string, unknown> {
|
|
376
|
+
return value && typeof value === "object" ? value as Record<string, unknown> : {};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function assertAscending(events: SessionEvent[]): void {
|
|
380
|
+
for (let index = 1; index < events.length; index += 1) {
|
|
381
|
+
if (events[index - 1]!.sequence >= events[index]!.sequence) {
|
|
382
|
+
throw new Error("@opengeni/react: session events must be ordered by ascending sequence");
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function assertPrependOrder(existing: SessionEvent[], older: SessionEvent[]): void {
|
|
388
|
+
if (!shouldAssertDevelopment() || existing.length === 0 || older.length === 0) {
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
if (older[older.length - 1]!.sequence >= existing[0]!.sequence) {
|
|
392
|
+
throw new Error("@opengeni/react: loadOlder returned overlapping session events");
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function shouldAssertDevelopment(): boolean {
|
|
397
|
+
const processEnv = (globalThis as { process?: { env?: { NODE_ENV?: string } } }).process?.env;
|
|
398
|
+
return processEnv !== undefined && processEnv.NODE_ENV !== "production";
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function abortError(): Error {
|
|
402
|
+
const error = new Error("The operation was aborted.");
|
|
403
|
+
error.name = "AbortError";
|
|
404
|
+
return error;
|
|
405
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -13,7 +13,7 @@ export { useSession, isTitleEvent } from "./hooks/use-session";
|
|
|
13
13
|
export type { UseSessionOptions, UseSessionResult } from "./hooks/use-session";
|
|
14
14
|
export { useSessionEvents } from "./hooks/use-session-events";
|
|
15
15
|
export type { SessionEventsConnectionState, UseSessionEventsOptions, UseSessionEventsResult } from "./hooks/use-session-events";
|
|
16
|
-
export { useComposer, composeSendInput, shouldSubmitOnKey } from "./hooks/use-composer";
|
|
16
|
+
export { useComposer, composeSendInput, shouldSubmitOnKey, FILE_ONLY_MESSAGE_TEXT } from "./hooks/use-composer";
|
|
17
17
|
export type { ComposerMode, ComposerSendExtras, ComposerState, UseComposerOptions } from "./hooks/use-composer";
|
|
18
18
|
export { useFileAttachments } from "./hooks/use-file-attachments";
|
|
19
19
|
export type { FileAttachment, UseFileAttachmentsOptions, UseFileAttachmentsResult } from "./hooks/use-file-attachments";
|
|
@@ -242,4 +242,4 @@ export { xtermThemeFromTokens } from "./lib/xterm-theme";
|
|
|
242
242
|
|
|
243
243
|
// Utilities
|
|
244
244
|
export { cn } from "./lib/cn";
|
|
245
|
-
export { formatBytes, formatRelativeTime, stringifyPayload, truncate, tryParseJson } from "./lib/format";
|
|
245
|
+
export { formatBytes, formatRelativeTime, humanizeFailureReason, stringifyPayload, truncate, tryParseJson } from "./lib/format";
|
package/src/lib/format.ts
CHANGED
|
@@ -82,3 +82,35 @@ export function tryParseJson(text: string): unknown {
|
|
|
82
82
|
return undefined;
|
|
83
83
|
}
|
|
84
84
|
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Humanize engine/provider failure text before it reaches the timeline or a
|
|
88
|
+
* failure banner. Raw provider errors leak the wrong audience's instructions —
|
|
89
|
+
* "Incorrect API key … find your API key at platform.openai.com" tells a
|
|
90
|
+
* managed-deployment USER to fix credentials only an OPERATOR controls (and is
|
|
91
|
+
* flatly wrong for Azure or subscription-backed engines). Auth and quota
|
|
92
|
+
* failures collapse to one neutral, honest sentence; every other reason passes
|
|
93
|
+
* through untouched. Raw payloads stay available in the debug surfaces.
|
|
94
|
+
*/
|
|
95
|
+
export function humanizeFailureReason(reason: string | null): string | null {
|
|
96
|
+
if (!reason) {
|
|
97
|
+
return reason;
|
|
98
|
+
}
|
|
99
|
+
const normalized = reason.toLowerCase();
|
|
100
|
+
const authFailure =
|
|
101
|
+
normalized.includes("incorrect api key") ||
|
|
102
|
+
normalized.includes("invalid api key") ||
|
|
103
|
+
normalized.includes("invalid_api_key") ||
|
|
104
|
+
normalized.includes("platform.openai.com/account/api-keys") ||
|
|
105
|
+
(normalized.includes("401") && (normalized.includes("api key") || normalized.includes("unauthorized")));
|
|
106
|
+
if (authFailure) {
|
|
107
|
+
return "The model provider rejected this deployment's engine credentials. Sending messages won't help until the deployment's engine configuration is fixed.";
|
|
108
|
+
}
|
|
109
|
+
const quotaFailure =
|
|
110
|
+
normalized.includes("insufficient_quota") ||
|
|
111
|
+
normalized.includes("exceeded your current quota");
|
|
112
|
+
if (quotaFailure) {
|
|
113
|
+
return "The model provider refused the request: this deployment's provider quota is exhausted.";
|
|
114
|
+
}
|
|
115
|
+
return reason;
|
|
116
|
+
}
|
package/src/lib/xterm-theme.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { XtermTheme } from "../components/sandbox-terminal";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Derive an xterm `ITheme` subset from the live OKLCH `--og-*` token system
|
|
5
|
-
*
|
|
4
|
+
* Derive an xterm `ITheme` subset from the live OKLCH `--og-*` token system.
|
|
5
|
+
* Reads the COMPUTED values so xterm — which
|
|
6
6
|
* paints into a canvas and can't consume CSS vars — gets concrete colors. Call
|
|
7
7
|
* on mount and re-derive on a `data-og-theme` flip.
|
|
8
8
|
*
|
|
@@ -12,16 +12,13 @@ export function xtermThemeFromTokens(root?: HTMLElement | null): XtermTheme | un
|
|
|
12
12
|
if (typeof window === "undefined" || typeof getComputedStyle === "undefined") return undefined;
|
|
13
13
|
const el = root ?? document.documentElement;
|
|
14
14
|
const style = getComputedStyle(el);
|
|
15
|
-
const read = (
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
if (value) return value;
|
|
19
|
-
}
|
|
20
|
-
return undefined;
|
|
15
|
+
const read = (name: string): string | undefined => {
|
|
16
|
+
const value = style.getPropertyValue(name).trim();
|
|
17
|
+
return value || undefined;
|
|
21
18
|
};
|
|
22
|
-
const bg = read(
|
|
23
|
-
const fg = read(
|
|
24
|
-
const accent = read(
|
|
19
|
+
const bg = read("--og-color-bg");
|
|
20
|
+
const fg = read("--og-color-fg");
|
|
21
|
+
const accent = read("--og-color-accent");
|
|
25
22
|
const theme: XtermTheme = {};
|
|
26
23
|
if (bg) theme.background = bg;
|
|
27
24
|
if (fg) theme.foreground = fg;
|
|
@@ -2,6 +2,7 @@ import { BotIcon, BrainIcon, SquareTerminalIcon } from "lucide-react";
|
|
|
2
2
|
import { cn } from "../lib/cn";
|
|
3
3
|
import { truncate } from "../lib/format";
|
|
4
4
|
import { defaultToolRegistry } from "./tool-renderers";
|
|
5
|
+
import { useEntranceAnimation } from "./entrance";
|
|
5
6
|
import type { ToolRegistry } from "./registry";
|
|
6
7
|
import { PayloadBlock, ActivityDisclosure } from "./shared";
|
|
7
8
|
import { toolDisplayName } from "./projection";
|
|
@@ -42,6 +43,7 @@ function familyOf(item: ActivityItem): string {
|
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
export function ActivityRail({ items, toolRegistry = defaultToolRegistry, onOpenSession, bare, className }: ActivityRailProps) {
|
|
46
|
+
const enter = useEntranceAnimation();
|
|
45
47
|
return (
|
|
46
48
|
<div
|
|
47
49
|
className={cn(
|
|
@@ -49,7 +51,8 @@ export function ActivityRail({ items, toolRegistry = defaultToolRegistry, onOpen
|
|
|
49
51
|
// calm cluster; a family change opens real breathing room (mt-3) below,
|
|
50
52
|
// so a long rail reads as a few clusters, not a metronome of rows.
|
|
51
53
|
"flex flex-col gap-0.5",
|
|
52
|
-
!bare && "
|
|
54
|
+
!bare && "border-l-2 border-og-border pl-3 sm:pl-4",
|
|
55
|
+
!bare && enter && "animate-og-enter",
|
|
53
56
|
className,
|
|
54
57
|
)}
|
|
55
58
|
>
|
|
@@ -200,7 +203,7 @@ function WorkerRow({ item, onOpenSession }: { item: WorkerItem; onOpenSession?:
|
|
|
200
203
|
type="button"
|
|
201
204
|
onClick={() => item.workerSessionId && onOpenSession(item.workerSessionId)}
|
|
202
205
|
className={cn(
|
|
203
|
-
"shrink-0 self-center rounded-og-sm border border-og-border px-2.5 py-1 text-og-sm font-medium text-og-fg-muted",
|
|
206
|
+
"shrink-0 self-center rounded-og-sm border border-og-border px-2.5 py-1 text-og-sm font-medium text-og-fg-muted pointer-coarse:py-2",
|
|
204
207
|
"outline-none transition-colors duration-150 hover:border-og-border-strong hover:text-og-fg",
|
|
205
208
|
"focus-visible:ring-2 focus-visible:ring-og-accent",
|
|
206
209
|
)}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { createContext, useContext, useRef } from "react";
|
|
2
|
+
|
|
3
|
+
/* ----------------------------------------------------------------------------
|
|
4
|
+
Entrance animation gating
|
|
5
|
+
|
|
6
|
+
Bulk paints (the initial tail window, a prepended older window) must not run
|
|
7
|
+
per-row entrance animations — hundreds of rows fading in at once reads as a
|
|
8
|
+
full-timeline flash. Toggling `animation: none` on and off is NOT an option:
|
|
9
|
+
removing the override restarts every animation, which is itself the flash.
|
|
10
|
+
|
|
11
|
+
Instead each animated element decides ONCE, at its own mount, whether it was
|
|
12
|
+
born in a bulk paint — and keeps that decision forever. Rows born in a bulk
|
|
13
|
+
paint never animate; rows appended live animate exactly as before. Nothing
|
|
14
|
+
is ever toggled on existing DOM, so nothing can replay.
|
|
15
|
+
-------------------------------------------------------------------------- */
|
|
16
|
+
|
|
17
|
+
const EntranceAnimationContext = createContext(true);
|
|
18
|
+
|
|
19
|
+
export const EntranceAnimationProvider = EntranceAnimationContext.Provider;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Whether this element should wear the entrance animation. Captured at mount
|
|
23
|
+
* from the nearest provider (true outside any provider) and stable for the
|
|
24
|
+
* element's lifetime — see the module doctrine above.
|
|
25
|
+
*/
|
|
26
|
+
export function useEntranceAnimation(): boolean {
|
|
27
|
+
const enabled = useContext(EntranceAnimationContext);
|
|
28
|
+
const captured = useRef(enabled);
|
|
29
|
+
return captured.current;
|
|
30
|
+
}
|
package/src/timeline/parsers.ts
CHANGED
|
@@ -251,3 +251,107 @@ export function unwrapMcpOutput(output: unknown): { text: string; isError: boole
|
|
|
251
251
|
}
|
|
252
252
|
return { text: typeof output === "string" ? output : output == null ? "" : JSON.stringify(output), isError: false };
|
|
253
253
|
}
|
|
254
|
+
|
|
255
|
+
/* --- computer-use screenshot extraction ------------------------------------- */
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Extract a renderable `data:` URL from a computer-use screenshot output,
|
|
259
|
+
* whatever transport produced it. The hosted `computer_call` and the
|
|
260
|
+
* function-text mode persist a plain `data:image/...` string; the
|
|
261
|
+
* function-image mode (codex-backed sessions) persists the STRUCTURED image
|
|
262
|
+
* output — `{type:"image", image:{data, mediaType}}` with `data` arriving as a
|
|
263
|
+
* number array / index map / Buffer-JSON / base64 string after event
|
|
264
|
+
* serialization — or the agents-core normalized `input_image` content item.
|
|
265
|
+
* Returns null when the output carries no image (so callers fall back to their
|
|
266
|
+
* text/empty presentation).
|
|
267
|
+
*/
|
|
268
|
+
export function screenshotDataUrl(out: unknown): string | null {
|
|
269
|
+
if (typeof out === "string") {
|
|
270
|
+
if (out.startsWith("data:image")) {
|
|
271
|
+
return out;
|
|
272
|
+
}
|
|
273
|
+
// A JSON-encoded structured output (some transports stringify tool results).
|
|
274
|
+
if (out.startsWith("{") || out.startsWith("[")) {
|
|
275
|
+
const parsed = tryParseJson(out);
|
|
276
|
+
if (parsed !== undefined && parsed !== out) {
|
|
277
|
+
return screenshotDataUrl(parsed);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
if (Array.isArray(out)) {
|
|
283
|
+
for (const entry of out) {
|
|
284
|
+
const url = screenshotDataUrl(entry);
|
|
285
|
+
if (url) {
|
|
286
|
+
return url;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
if (out === null || typeof out !== "object") {
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
const record = out as Record<string, unknown>;
|
|
295
|
+
// agents-core normalized content item: {type:"input_image", image_url: "data:…" | {url}}
|
|
296
|
+
const imageUrl = record.image_url ?? record.imageUrl;
|
|
297
|
+
if (typeof imageUrl === "string" && imageUrl.startsWith("data:image")) {
|
|
298
|
+
return imageUrl;
|
|
299
|
+
}
|
|
300
|
+
if (imageUrl && typeof imageUrl === "object") {
|
|
301
|
+
const url = (imageUrl as Record<string, unknown>).url;
|
|
302
|
+
if (typeof url === "string" && url.startsWith("data:image")) {
|
|
303
|
+
return url;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
// Structured tool output: {type:"image", image:{data, mediaType}}
|
|
307
|
+
const image = record.image as Record<string, unknown> | undefined;
|
|
308
|
+
if (image && typeof image === "object") {
|
|
309
|
+
const mediaType = typeof image.mediaType === "string" ? image.mediaType : "image/png";
|
|
310
|
+
const base64 = bytesToBase64(image.data);
|
|
311
|
+
if (base64) {
|
|
312
|
+
return `data:${mediaType};base64,${base64}`;
|
|
313
|
+
}
|
|
314
|
+
if (typeof image.data === "string" && image.data.length > 0) {
|
|
315
|
+
// Already base64 text.
|
|
316
|
+
return `data:${mediaType};base64,${image.data}`;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Serialize whatever a Uint8Array became in JSON (number[], {"0":n,…} index
|
|
323
|
+
* map, or Buffer-JSON {type:"Buffer",data:[…]}) back into base64. */
|
|
324
|
+
function bytesToBase64(data: unknown): string | null {
|
|
325
|
+
const isByte = (n: unknown): n is number => typeof n === "number" && Number.isInteger(n) && n >= 0 && n <= 255;
|
|
326
|
+
let bytes: number[] | null = null;
|
|
327
|
+
if (Array.isArray(data) && data.every(isByte)) {
|
|
328
|
+
bytes = data;
|
|
329
|
+
} else if (data && typeof data === "object") {
|
|
330
|
+
const record = data as Record<string, unknown>;
|
|
331
|
+
if (record.type === "Buffer" && Array.isArray(record.data) && record.data.every(isByte)) {
|
|
332
|
+
bytes = record.data;
|
|
333
|
+
} else {
|
|
334
|
+
const keys = Object.keys(record);
|
|
335
|
+
if (keys.length > 0 && keys.every((key) => /^\d+$/.test(key))) {
|
|
336
|
+
const values = keys.sort((a, b) => Number(a) - Number(b)).map((key) => record[key]);
|
|
337
|
+
if (values.every(isByte)) {
|
|
338
|
+
bytes = values;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
if (!bytes || bytes.length === 0) {
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
try {
|
|
347
|
+
let binary = "";
|
|
348
|
+
const CHUNK = 0x8000;
|
|
349
|
+
for (let i = 0; i < bytes.length; i += CHUNK) {
|
|
350
|
+
binary += String.fromCharCode(...bytes.slice(i, i + CHUNK));
|
|
351
|
+
}
|
|
352
|
+
return typeof btoa === "function" ? btoa(binary) : Buffer.from(binary, "binary").toString("base64");
|
|
353
|
+
} catch {
|
|
354
|
+
// A hostile/absurd payload must degrade to "no image", never crash a render.
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { SessionEvent, SessionStatus } from "@opengeni/sdk";
|
|
2
|
-
import { tryParseJson } from "../lib/format";
|
|
2
|
+
import { humanizeFailureReason, tryParseJson } from "../lib/format";
|
|
3
3
|
import type {
|
|
4
4
|
AgentMessageItem,
|
|
5
5
|
ActivityItem,
|
|
@@ -319,7 +319,11 @@ export function buildTimeline(events: SessionEvent[]): TimelineItem[] {
|
|
|
319
319
|
case "turn.failed": {
|
|
320
320
|
const hadActivity = hasTurnActivity(items, turnId);
|
|
321
321
|
const failureText = failureMessage(payload);
|
|
322
|
-
|
|
322
|
+
// The TURN failed — the in-flight items did not. Chip doctrine: red is
|
|
323
|
+
// spent once, on the turn-level outcome. Items caught mid-flight read
|
|
324
|
+
// as calm "interrupted" (same as turn.cancelled); an item that itself
|
|
325
|
+
// failed keeps its own failed status from its output event.
|
|
326
|
+
finalizeOpen(turnId, "cancelled");
|
|
323
327
|
items.push(turnEndItem(event, "failed", failureText));
|
|
324
328
|
if (!hadActivity) {
|
|
325
329
|
items.push({
|
|
@@ -613,8 +617,19 @@ function stampTurnOutcome(groups: TimelineGroup[], turnEnd: TurnEndItem): void {
|
|
|
613
617
|
}
|
|
614
618
|
|
|
615
619
|
function applyTurnOutcome(group: Extract<TimelineGroup, { kind: "activity" }>, turnEnd: TurnEndItem): void {
|
|
616
|
-
|
|
617
|
-
|
|
620
|
+
// A sub-cluster reports ITS OWN outcome, not the turn's. When a turn fails
|
|
621
|
+
// at step 7, clusters 1–6 completed — painting them all red says "everything
|
|
622
|
+
// broke" when one thing did. The turn-level fold carries the turn outcome;
|
|
623
|
+
// a cluster goes red only if an item inside it actually failed, and reads
|
|
624
|
+
// "cancelled" (interrupted) only when it holds the items cut off mid-flight.
|
|
625
|
+
if (turnEnd.outcome === "complete") {
|
|
626
|
+
group.outcome = "complete";
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
const hasFailed = group.items.some((item) => "status" in item && item.status === "failed");
|
|
630
|
+
const hasInterrupted = group.items.some((item) => "status" in item && item.status === "cancelled");
|
|
631
|
+
group.outcome = hasFailed ? "failed" : hasInterrupted ? "cancelled" : "complete";
|
|
632
|
+
if (turnEnd.failureText && hasFailed) {
|
|
618
633
|
group.failureText = turnEnd.failureText;
|
|
619
634
|
}
|
|
620
635
|
}
|
|
@@ -775,7 +790,9 @@ function failureMessage(payload: Record<string, unknown>): string | null {
|
|
|
775
790
|
for (const key of ["error", "message"] as const) {
|
|
776
791
|
const value = payload[key];
|
|
777
792
|
if (typeof value === "string" && value.trim().length > 0) {
|
|
778
|
-
|
|
793
|
+
// Auth/quota provider errors are rewritten for the right audience
|
|
794
|
+
// (raw text remains in the event payload for debug surfaces).
|
|
795
|
+
return humanizeFailureReason(value);
|
|
779
796
|
}
|
|
780
797
|
}
|
|
781
798
|
return null;
|