@opengeni/react 0.6.3 → 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/index.d.ts +20 -6
- package/dist/index.js +538 -218
- package/dist/index.js.map +1 -1
- package/dist/{machines-Bv9tG7qZ.d.ts → machines-BeZ3bD3t.d.ts} +1 -1
- package/dist/machines.d.ts +1 -1
- package/package.json +2 -2
- package/src/client.ts +1 -0
- package/src/components/message-timeline.tsx +144 -17
- package/src/hooks/use-session-events.ts +283 -8
- package/src/timeline/activity-rail.tsx +4 -1
- package/src/timeline/entrance.tsx +30 -0
- package/src/timeline/projection.ts +18 -3
- package/src/timeline/turn-summary.tsx +3 -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
|
+
}
|
|
@@ -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
|
>
|
|
@@ -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
|
-
|
|
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
|
}
|
|
@@ -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",
|