@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.
Files changed (45) hide show
  1. package/README.md +17 -6
  2. package/dist/{chunk-DEW2ZNF2.js → chunk-5C7RGAWA.js} +70 -37
  3. package/dist/chunk-5C7RGAWA.js.map +1 -0
  4. package/dist/index.d.ts +45 -9
  5. package/dist/index.js +1385 -732
  6. package/dist/index.js.map +1 -1
  7. package/dist/{machines-BD6h9P_s.d.ts → machines-BeZ3bD3t.d.ts} +2 -2
  8. package/dist/machines.d.ts +1 -1
  9. package/dist/machines.js +1 -1
  10. package/package.json +2 -2
  11. package/src/client.ts +1 -0
  12. package/src/components/chat-composer.tsx +127 -60
  13. package/src/components/code-editor.tsx +15 -15
  14. package/src/components/desktop-viewer.tsx +40 -36
  15. package/src/components/diff-view.tsx +22 -22
  16. package/src/components/enrollment-consent.tsx +13 -13
  17. package/src/components/file-browser.tsx +74 -67
  18. package/src/components/fleet-tile.tsx +3 -3
  19. package/src/components/machine-card.tsx +6 -6
  20. package/src/components/machine-status-pill.tsx +3 -3
  21. package/src/components/machines-dashboard.tsx +28 -14
  22. package/src/components/markdown.tsx +6 -6
  23. package/src/components/message-timeline.tsx +207 -12
  24. package/src/components/pierre-diff.tsx +9 -10
  25. package/src/components/pierre-file.tsx +8 -8
  26. package/src/components/sandbox-files.tsx +37 -40
  27. package/src/components/sandbox-terminal.tsx +9 -11
  28. package/src/components/session-status.tsx +2 -2
  29. package/src/components/workspace-dock.tsx +153 -49
  30. package/src/hooks/use-file-attachments.ts +45 -15
  31. package/src/hooks/use-session-events.ts +283 -8
  32. package/src/index.ts +2 -2
  33. package/src/lib/format.ts +32 -0
  34. package/src/lib/xterm-theme.ts +8 -11
  35. package/src/timeline/activity-rail.tsx +5 -2
  36. package/src/timeline/entrance.tsx +30 -0
  37. package/src/timeline/parsers.ts +104 -0
  38. package/src/timeline/projection.ts +22 -5
  39. package/src/timeline/screenshot-lightbox.tsx +1 -1
  40. package/src/timeline/shared.tsx +4 -1
  41. package/src/timeline/tool-diff.tsx +1 -1
  42. package/src/timeline/tool-renderers.tsx +46 -7
  43. package/src/timeline/turn-summary.tsx +24 -4
  44. package/styles/tokens.css +8 -0
  45. 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). Defaults to 0 = full replay. */
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. The SDK guarantees ordered, gap-free, exactly-once
31
- * delivery; this hook accumulates the log and projects it into a timeline.
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 = lastInBatch.sequence;
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
+ }
@@ -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 (or
5
- * the app's `--color-*` aliases). Reads the COMPUTED values so xterm — which
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 = (names: string[]): string | undefined => {
16
- for (const name of names) {
17
- const value = style.getPropertyValue(name).trim();
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(["--og-color-bg", "--color-bg"]);
23
- const fg = read(["--og-color-fg", "--color-fg"]);
24
- const accent = read(["--og-color-accent", "--color-brand", "--color-accent"]);
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 && "animate-og-enter border-l-2 border-og-border pl-3 sm:pl-4",
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
+ }
@@ -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
- finalizeOpen(turnId, "failed");
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
- group.outcome = turnEnd.outcome;
617
- if (turnEnd.failureText) {
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
- return value;
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;