@opengeni/react 0.6.3 → 0.9.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.
@@ -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,64 @@ export type UseSessionEventsResult = {
22
25
  connectionState: SessionEventsConnectionState;
23
26
  /** Highest sequence seen so far (0 before the first event). */
24
27
  lastSequence: number;
28
+ /** True until the initial tail window has been applied (windowed mode). */
29
+ initialLoading: boolean;
30
+ /** Whether older durable events are available before the current window. */
31
+ hasOlder: boolean;
32
+ /** True while an older window is being fetched. */
33
+ loadingOlder: boolean;
34
+ /** Prepend an older density-bounded window; resolves true when more remain. */
35
+ loadOlder: () => Promise<boolean>;
25
36
  error: Error | null;
26
37
  };
27
38
 
39
+ const TAIL_PAGE_SIZE = 5000;
40
+ const INITIAL_FETCH_CAP = 1;
41
+ const OLDER_GROUP_TARGET = 32;
42
+ const OLDER_FETCH_CAP = 2;
43
+ const BOUNDARY_PAGE_CAP = 4;
44
+
28
45
  /**
29
46
  * 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.
47
+ * batched React updates. Fresh loads default to a bounded tail window; pass
48
+ * `replay: "full"` or a nonzero `after` for the previous full replay path.
32
49
  */
33
50
  export function useSessionEvents(sessionId: string | null | undefined, options: UseSessionEventsOptions = {}): UseSessionEventsResult {
34
51
  const { client, workspaceId } = useOpenGeni(options);
35
52
  const enabled = options.enabled ?? true;
36
53
  const after = options.after ?? 0;
54
+ const replay = options.replay ?? "windowed";
55
+ const fullReplay = replay === "full" || after !== 0;
37
56
 
38
57
  const [events, setEvents] = useState<SessionEvent[]>([]);
39
58
  const [connectionState, setConnectionState] = useState<SessionEventsConnectionState>("idle");
40
59
  const [error, setError] = useState<Error | null>(null);
60
+ const [hasOlder, setHasOlder] = useState(false);
61
+ const [initialLoading, setInitialLoading] = useState(true);
62
+ const [loadingOlder, setLoadingOlder] = useState(false);
41
63
  const lastSequenceRef = useRef(after);
64
+ const oldestSequenceRef = useRef<number | null>(null);
65
+ const hasOlderRef = useRef(false);
66
+ const loadingOlderRef = useRef(false);
42
67
  const streamKeyRef = useRef<string | null>(null);
68
+ const generationRef = useRef(0);
43
69
 
44
70
  useEffect(() => {
45
71
  // Reset the accumulated log only when the stream identity changes —
46
72
  // pausing via `enabled: false` keeps the timeline visible.
47
- const streamKey = `${workspaceId}\u0000${sessionId ?? ""}\u0000${after}`;
73
+ const streamKey = `${workspaceId}\u0000${sessionId ?? ""}\u0000${after}\u0000${fullReplay ? "full" : "windowed"}`;
48
74
  if (streamKeyRef.current !== streamKey) {
49
75
  streamKeyRef.current = streamKey;
76
+ generationRef.current += 1;
50
77
  setEvents([]);
51
78
  setError(null);
79
+ setHasOlder(false);
80
+ setLoadingOlder(false);
81
+ setInitialLoading(true);
52
82
  lastSequenceRef.current = after;
83
+ oldestSequenceRef.current = null;
84
+ hasOlderRef.current = false;
85
+ loadingOlderRef.current = false;
53
86
  }
54
87
  if (!sessionId || !enabled) {
55
88
  setConnectionState("idle");
@@ -72,7 +105,7 @@ export function useSessionEvents(sessionId: string | null | undefined, options:
72
105
  // the next connect instead of being skipped.
73
106
  const lastInBatch = batch[batch.length - 1];
74
107
  if (lastInBatch) {
75
- lastSequenceRef.current = lastInBatch.sequence;
108
+ lastSequenceRef.current = Math.max(lastSequenceRef.current, ...batch.map(eventResumeSequence));
76
109
  }
77
110
  setEvents((existing) => [...existing, ...batch]);
78
111
  };
@@ -82,6 +115,31 @@ export function useSessionEvents(sessionId: string | null | undefined, options:
82
115
 
83
116
  void (async () => {
84
117
  try {
118
+ if (!fullReplay) {
119
+ setConnectionState("connecting");
120
+ // First paint is ONE compact fetch — the newest window, revealed at
121
+ // the bottom in a few hundred ms. Deeper history loads only when the
122
+ // reader actually scrolls up (the sentinel drives loadOlder).
123
+ const window = await loadEventWindow(client, workspaceId, sessionId, {
124
+ before: Number.MAX_SAFE_INTEGER,
125
+ pageSize: TAIL_PAGE_SIZE,
126
+ targetGroups: Number.POSITIVE_INFINITY,
127
+ maxFetches: INITIAL_FETCH_CAP,
128
+ signal: controller.signal,
129
+ });
130
+ if (controller.signal.aborted) {
131
+ return;
132
+ }
133
+ oldestSequenceRef.current = window.oldestSequence;
134
+ hasOlderRef.current = window.hasOlder;
135
+ lastSequenceRef.current = window.newestSequence;
136
+ setHasOlder(window.hasOlder);
137
+ setEvents(window.events);
138
+ setInitialLoading(false);
139
+ }
140
+ if (fullReplay) {
141
+ setInitialLoading(false);
142
+ }
85
143
  const stream = client.streamEvents(workspaceId, sessionId, {
86
144
  after: lastSequenceRef.current,
87
145
  signal: controller.signal,
@@ -114,7 +172,52 @@ export function useSessionEvents(sessionId: string | null | undefined, options:
114
172
  clearTimeout(flushTimer);
115
173
  }
116
174
  };
117
- }, [client, workspaceId, sessionId, after, enabled]);
175
+ }, [client, workspaceId, sessionId, after, enabled, fullReplay]);
176
+
177
+ const loadOlder = useCallback(async (): Promise<boolean> => {
178
+ if (!sessionId || fullReplay || loadingOlderRef.current || !hasOlderRef.current) {
179
+ return false;
180
+ }
181
+ const before = oldestSequenceRef.current;
182
+ if (before === null) {
183
+ hasOlderRef.current = false;
184
+ setHasOlder(false);
185
+ return false;
186
+ }
187
+ const generation = generationRef.current;
188
+ loadingOlderRef.current = true;
189
+ setLoadingOlder(true);
190
+ try {
191
+ const window = await loadEventWindow(client, workspaceId, sessionId, {
192
+ before,
193
+ pageSize: TAIL_PAGE_SIZE,
194
+ targetGroups: OLDER_GROUP_TARGET,
195
+ maxFetches: OLDER_FETCH_CAP,
196
+ });
197
+ if (generationRef.current !== generation) {
198
+ return false;
199
+ }
200
+ if (window.events.length === 0) {
201
+ oldestSequenceRef.current = null;
202
+ hasOlderRef.current = false;
203
+ setHasOlder(false);
204
+ return false;
205
+ }
206
+ oldestSequenceRef.current = window.oldestSequence;
207
+ hasOlderRef.current = window.hasOlder;
208
+ setHasOlder(window.hasOlder);
209
+ setEvents((existing) => {
210
+ assertPrependOrder(existing, window.events);
211
+ return [...window.events, ...existing];
212
+ });
213
+ return window.hasOlder;
214
+ } finally {
215
+ if (generationRef.current === generation) {
216
+ loadingOlderRef.current = false;
217
+ setLoadingOlder(false);
218
+ }
219
+ }
220
+ }, [client, workspaceId, sessionId, fullReplay]);
118
221
 
119
222
  const timeline = useMemo(() => buildTimeline(events), [events]);
120
223
  const sessionStatus = useMemo(() => sessionStatusFromEvents(events), [events]);
@@ -125,6 +228,189 @@ export function useSessionEvents(sessionId: string | null | undefined, options:
125
228
  sessionStatus,
126
229
  connectionState,
127
230
  lastSequence: lastSequenceRef.current,
231
+ initialLoading: fullReplay ? false : initialLoading,
232
+ hasOlder: fullReplay ? false : hasOlder,
233
+ loadingOlder: fullReplay ? false : loadingOlder,
234
+ loadOlder,
128
235
  error,
129
236
  };
130
237
  }
238
+
239
+ type LoadedEventWindow = {
240
+ events: SessionEvent[];
241
+ oldestSequence: number | null;
242
+ newestSequence: number;
243
+ hasOlder: boolean;
244
+ };
245
+
246
+ async function loadEventWindow(
247
+ client: SessionClientLike,
248
+ workspaceId: string,
249
+ sessionId: string,
250
+ options: {
251
+ before: number;
252
+ pageSize: number;
253
+ targetGroups: number;
254
+ maxFetches: number;
255
+ signal?: AbortSignal;
256
+ },
257
+ ): Promise<LoadedEventWindow> {
258
+ let cursor = options.before;
259
+ let buffer: SessionEvent[] = [];
260
+ let reachedStart = false;
261
+ let fetches = 0;
262
+
263
+ while (fetches < options.maxFetches) {
264
+ if (buffer.length > 0 && groupCount(buffer) >= options.targetGroups) {
265
+ break;
266
+ }
267
+ const page = await loadPreviousPage(client, workspaceId, sessionId, cursor, {
268
+ pageSize: options.pageSize,
269
+ ...(options.signal ? { signal: options.signal } : {}),
270
+ });
271
+ fetches += 1;
272
+ if (page.length === 0) {
273
+ reachedStart = true;
274
+ break;
275
+ }
276
+ assertAscending(page);
277
+ buffer = [...page, ...buffer];
278
+ cursor = page[0]!.sequence;
279
+ if (isLogStart(page[0]!)) {
280
+ reachedStart = true;
281
+ break;
282
+ }
283
+ }
284
+
285
+ // Boundary snap: a window that starts mid-turn TRIMS its head to the oldest
286
+ // turn boundary already in the buffer — the dropped fragment is refetched by
287
+ // the next loadOlder (everything below the new oldest sequence), whose own
288
+ // window snaps the same way, so every seam lands on a turn start. Extra
289
+ // pages are fetched only when the buffer holds no boundary at all (one
290
+ // monster turn); past the cap a mid-turn top is accepted.
291
+ let snapPages = 0;
292
+ while (!reachedStart && findBoundaryIndex(buffer) === -1 && snapPages < BOUNDARY_PAGE_CAP && fetches < options.maxFetches) {
293
+ const page = await loadPreviousPage(client, workspaceId, sessionId, cursor, {
294
+ pageSize: options.pageSize,
295
+ ...(options.signal ? { signal: options.signal } : {}),
296
+ });
297
+ fetches += 1;
298
+ snapPages += 1;
299
+ if (page.length === 0) {
300
+ reachedStart = true;
301
+ break;
302
+ }
303
+ assertAscending(page);
304
+ buffer = [...page, ...buffer];
305
+ cursor = page[0]!.sequence;
306
+ if (isLogStart(page[0]!)) {
307
+ reachedStart = true;
308
+ break;
309
+ }
310
+ }
311
+ if (!reachedStart) {
312
+ const boundary = findBoundaryIndex(buffer);
313
+ if (boundary > 0) {
314
+ buffer = buffer.slice(boundary);
315
+ }
316
+ }
317
+
318
+ const oldest = buffer[0] ?? null;
319
+ const newest = buffer[buffer.length - 1] ?? null;
320
+ return {
321
+ events: buffer,
322
+ oldestSequence: oldest?.sequence ?? null,
323
+ newestSequence: newest ? maxResumeSequence(buffer) : 0,
324
+ hasOlder: buffer.length > 0 && !reachedStart && oldest?.type !== "session.created",
325
+ };
326
+ }
327
+
328
+ /** Index of the oldest clean turn start in the buffer, or -1. */
329
+ function findBoundaryIndex(events: SessionEvent[]): number {
330
+ for (let index = 0; index < events.length; index += 1) {
331
+ const type = events[index]!.type;
332
+ if (type === "session.created" || type === "user.message") {
333
+ return index;
334
+ }
335
+ }
336
+ return -1;
337
+ }
338
+
339
+ type PreviousPage = SessionEvent[] & { requested: number };
340
+
341
+ async function loadPreviousPage(
342
+ client: SessionClientLike,
343
+ workspaceId: string,
344
+ sessionId: string,
345
+ before: number,
346
+ options: {
347
+ pageSize: number;
348
+ signal?: AbortSignal;
349
+ },
350
+ ): Promise<PreviousPage> {
351
+ if (options.signal?.aborted) {
352
+ throw abortError();
353
+ }
354
+ const requested = options.pageSize;
355
+ if (requested === 0) {
356
+ return Object.assign([], { requested });
357
+ }
358
+ const page = await client.listEvents(workspaceId, sessionId, { before, limit: requested, compact: true });
359
+ if (options.signal?.aborted) {
360
+ throw abortError();
361
+ }
362
+ return Object.assign(page, { requested });
363
+ }
364
+
365
+ function groupCount(events: SessionEvent[]): number {
366
+ if (events.length === 0) {
367
+ return 0;
368
+ }
369
+ return groupTimeline(buildTimeline(events)).length;
370
+ }
371
+
372
+ function isLogStart(event: SessionEvent): boolean {
373
+ return event.type === "session.created" || event.sequence <= 1;
374
+ }
375
+
376
+ function maxResumeSequence(events: SessionEvent[]): number {
377
+ return events.reduce((max, event) => Math.max(max, eventResumeSequence(event)), 0);
378
+ }
379
+
380
+ function eventResumeSequence(event: SessionEvent): number {
381
+ const payload = asRecord(event.payload);
382
+ const coalescedUntil = Number(payload.coalescedUntil);
383
+ return Math.max(event.sequence, Number.isFinite(coalescedUntil) ? Math.floor(coalescedUntil) : 0);
384
+ }
385
+
386
+ function asRecord(value: unknown): Record<string, unknown> {
387
+ return value && typeof value === "object" ? value as Record<string, unknown> : {};
388
+ }
389
+
390
+ function assertAscending(events: SessionEvent[]): void {
391
+ for (let index = 1; index < events.length; index += 1) {
392
+ if (events[index - 1]!.sequence >= events[index]!.sequence) {
393
+ throw new Error("@opengeni/react: session events must be ordered by ascending sequence");
394
+ }
395
+ }
396
+ }
397
+
398
+ function assertPrependOrder(existing: SessionEvent[], older: SessionEvent[]): void {
399
+ if (!shouldAssertDevelopment() || existing.length === 0 || older.length === 0) {
400
+ return;
401
+ }
402
+ if (older[older.length - 1]!.sequence >= existing[0]!.sequence) {
403
+ throw new Error("@opengeni/react: loadOlder returned overlapping session events");
404
+ }
405
+ }
406
+
407
+ function shouldAssertDevelopment(): boolean {
408
+ const processEnv = (globalThis as { process?: { env?: { NODE_ENV?: string } } }).process?.env;
409
+ return processEnv !== undefined && processEnv.NODE_ENV !== "production";
410
+ }
411
+
412
+ function abortError(): Error {
413
+ const error = new Error("The operation was aborted.");
414
+ error.name = "AbortError";
415
+ return error;
416
+ }
@@ -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
  >
@@ -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
+ }
@@ -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
  }
@@ -3,6 +3,7 @@ import { useState } from "react";
3
3
  import { Collapsible } from "radix-ui";
4
4
  import { cn } from "../lib/cn";
5
5
  import { useForcedDefaultOpen } from "./disclosure-context";
6
+ import { useEntranceAnimation } from "./entrance";
6
7
  import { applyPatchOps, isApplyPatch, screenshotDataUrl } from "./parsers";
7
8
  import { rawTypeOf } from "./registry";
8
9
  import type { ActivityItem, TurnOutcome } from "./types";
@@ -39,10 +40,11 @@ export function TurnSummary({ items, outcome, failureText, durationMs, defaultOp
39
40
  // (screenshot instrumentation); otherwise the turn starts folded.
40
41
  const forcedDefaultOpen = useForcedDefaultOpen();
41
42
  const [open, setOpen] = useState(defaultOpen ?? forcedDefaultOpen ?? false);
43
+ const enter = useEntranceAnimation();
42
44
  const facets = summarizeTurn(items, durationMs);
43
45
 
44
46
  return (
45
- <Collapsible.Root open={open} onOpenChange={setOpen} className="animate-og-enter">
47
+ <Collapsible.Root open={open} onOpenChange={setOpen} className={enter ? "animate-og-enter" : undefined}>
46
48
  <Collapsible.Trigger
47
49
  className={cn(
48
50
  "group flex w-full items-center gap-2.5 rounded-og-md border px-3 py-2 text-left text-og-base transition-colors",