@opengeni/react 2.3.0-canary.0 → 2.5.0-canary.1

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 (46) hide show
  1. package/README.md +9 -2
  2. package/dist/{chunk-LS4POUTY.js → chunk-6H6S5V4Z.js} +250 -120
  3. package/dist/chunk-6H6S5V4Z.js.map +1 -0
  4. package/dist/{chunk-SE4W4WQ3.js → chunk-ATQJEWCA.js} +154 -114
  5. package/dist/chunk-ATQJEWCA.js.map +1 -0
  6. package/dist/{chunk-LTCHJ4MS.js → chunk-KSDXER4X.js} +21 -7
  7. package/dist/chunk-KSDXER4X.js.map +1 -0
  8. package/dist/{chunk-RSXJN73N.js → chunk-MXJGO7LE.js} +34 -1
  9. package/dist/chunk-MXJGO7LE.js.map +1 -0
  10. package/dist/{chunk-JOAVA2TW.js → chunk-T7VAYG2I.js} +8 -7
  11. package/dist/{chunk-JOAVA2TW.js.map → chunk-T7VAYG2I.js.map} +1 -1
  12. package/dist/components/composer.d.ts +1 -1
  13. package/dist/components/fleet-tile.d.ts +1 -1
  14. package/dist/components/message-timeline.d.ts +7 -2
  15. package/dist/composer.js +2 -2
  16. package/dist/hooks/use-session-events.d.ts +2 -1
  17. package/dist/hooks/use-session-lineage.d.ts +4 -0
  18. package/dist/hooks/use-session.d.ts +6 -0
  19. package/dist/hooks/use-workspace-sessions.d.ts +6 -0
  20. package/dist/index.d.ts +2 -0
  21. package/dist/index.js +41 -19
  22. package/dist/index.js.map +1 -1
  23. package/dist/older-history.d.ts +29 -0
  24. package/dist/session-ui.d.ts +2 -0
  25. package/dist/session-ui.js +5 -2
  26. package/dist/session.d.ts +2 -0
  27. package/dist/session.js +5 -3
  28. package/package.json +2 -2
  29. package/src/components/composer.tsx +19 -5
  30. package/src/components/fleet-tile.tsx +7 -5
  31. package/src/components/message-timeline.tsx +399 -160
  32. package/src/components/tip-follow.ts +18 -7
  33. package/src/hooks/use-composer.ts +20 -6
  34. package/src/hooks/use-session-events.ts +84 -76
  35. package/src/hooks/use-session-lineage.ts +26 -8
  36. package/src/hooks/use-session.ts +35 -8
  37. package/src/hooks/use-workspace-sessions.ts +38 -13
  38. package/src/index.ts +2 -0
  39. package/src/older-history.ts +69 -0
  40. package/src/session-ui.ts +2 -0
  41. package/src/session.ts +2 -0
  42. package/src/timeline/activity-rail.tsx +1 -0
  43. package/dist/chunk-LS4POUTY.js.map +0 -1
  44. package/dist/chunk-LTCHJ4MS.js.map +0 -1
  45. package/dist/chunk-RSXJN73N.js.map +0 -1
  46. package/dist/chunk-SE4W4WQ3.js.map +0 -1
@@ -1,19 +1,22 @@
1
1
  /* ----------------------------------------------------------------------------
2
2
  Tip-follow camera (pure)
3
3
 
4
- DOM truth is immediate; the camera eases down toward the layout tip.
4
+ DOM truth is immediate; while pinned, the viewport tracks new layout growth
5
+ immediately so already-rendered content is never hidden behind the camera.
6
+ Any debt that existed before the growth still closes through the camera.
5
7
  No React, no DOM — the shell reads metrics, calls step, writes scrollTop.
6
8
 
7
- One law while pinned — second-order camera (no hard first bite):
9
+ One law for pre-existing debt — second-order camera:
8
10
 
9
11
  desiredVel = debt / τ
10
12
  scrollVel → exponential approach to desiredVel (accel τ = k·τ)
11
13
  scrollTop += scrollVel · dt (clamp: no overshoot past tip)
12
14
 
13
- - Debt jumps with layout; velocity cannot so motion eases in, then as
14
- debt falls desiredVel→0 and velocity eases out (soft settle).
15
+ - Layout growth advances cameraTop by the same amount, preserving rather
16
+ than increasing existing debt. This keeps the visible stream truthful.
17
+ - Existing debt eases out as desiredVel→0 (soft settle).
15
18
  - τ still adapts (calm / catch-up / line / idle settle).
16
- - maxStep is a speed ceiling only, never a one-frame glue.
19
+ - maxStep is a speed ceiling for catch-up, never a source of stream lag.
17
20
  - Shrink is separate: compensate Δh so collapse doesn't fight the ease
18
21
  - Snap only for first paint / reduced motion / cold oversized jumps
19
22
  -------------------------------------------------------------------------- */
@@ -310,7 +313,7 @@ export function tipFollowMaxStepPx(
310
313
  TIP_FOLLOW_CALM_MAX_PX_S +
311
314
  (TIP_FOLLOW_BURST_MAX_PX_S - TIP_FOLLOW_CALM_MAX_PX_S) * blend * blend;
312
315
  if (hot && frameGrowthPx > 0 && dtMs > 0) {
313
- // Allow tracking this frame's growth rate (growthTrack uses a fraction).
316
+ // Allow pre-existing debt to close without falling behind current growth.
314
317
  maxPxS = Math.max(maxPxS, (frameGrowthPx / dtMs) * 1000);
315
318
  }
316
319
  if (hot && growthVelocityPxPerSec > TIP_FOLLOW_CALM_MAX_PX_S) {
@@ -502,6 +505,14 @@ export function tipFollowStep(
502
505
  );
503
506
  }
504
507
  const target = targetScrollTop(scrollHeight, clientHeight);
508
+ if (pinned && frameGrowth > 0) {
509
+ // Preserve the distance that existed before this layout growth instead of
510
+ // manufacturing new visual debt. The content is already in the DOM; while
511
+ // pinned, hiding it behind an eased scroll makes frontend streaming appear
512
+ // slower than the provider and leaves a visible catch-up tail after the
513
+ // final token. Existing debt still goes through the camera below.
514
+ cameraTop = Math.min(target, cameraTop + frameGrowth);
515
+ }
505
516
  const debt = target - cameraTop;
506
517
  const hot = now < noted.hotUntil;
507
518
  const settling = !hot && Math.abs(debt) < TIP_FOLLOW_CATCHUP_DEBT_PX;
@@ -550,7 +561,7 @@ export function tipFollowStep(
550
561
  const dtSec = dt / 1000;
551
562
  const tauSec = Math.max(tau / 1000, 1e-3);
552
563
  // P-gain: held desiredVel would close debt in ~τ. Velocity cannot jump —
553
- // it eases toward desiredVel, so a new line accelerates in then settles out.
564
+ // pre-existing debt accelerates toward desiredVel and then settles out.
554
565
  const desiredVel = debt / tauSec;
555
566
  const accelTauSec = Math.max(tauSec * TIP_FOLLOW_ACCEL_TAU_FRAC, 0.02);
556
567
  const velAlpha = 1 - Math.exp(-dtSec / accelTauSec);
@@ -1984,10 +1984,12 @@ export function useComposer(
1984
1984
  },
1985
1985
  canRetry: true,
1986
1986
  };
1987
- replaceOptimisticSends((current) => [...current, operation]);
1988
- queueMicrotask(processOptimisticSends);
1989
- setError(null);
1990
- onSubmitted?.(sendText, input);
1987
+ // The local submission acknowledgement owns the composer before any
1988
+ // host callback or queued processor can observe it. Hosts commonly use
1989
+ // onSubmitted to remove attachment/repository state, and those updates
1990
+ // may synchronously re-render the controlled composer. Clear the refs
1991
+ // first so that handoff cannot re-project the submitted text as the next
1992
+ // draft. The immutable operation above still owns the exact retry input.
1991
1993
  if (explicit === undefined) {
1992
1994
  valueRef.current = "";
1993
1995
  annotationsRef.current = [];
@@ -1998,6 +2000,10 @@ export function useComposer(
1998
2000
  setAnnotationReviewTargetId(null);
1999
2001
  setRestoredResources([]);
2000
2002
  }
2003
+ replaceOptimisticSends((current) => [...current, operation]);
2004
+ queueMicrotask(processOptimisticSends);
2005
+ setError(null);
2006
+ onSubmitted?.(sendText, input);
2001
2007
  return true;
2002
2008
  },
2003
2009
  [
@@ -2529,9 +2535,17 @@ export function useComposer(
2529
2535
  setError(null);
2530
2536
  setDraftConflict(null);
2531
2537
  }, [targetKey]);
2538
+ // `valueRef` is the synchronous composer authority. React state exists to
2539
+ // schedule renders, but a concurrent autosave settlement can render the
2540
+ // previous state lane after a newer input event has already updated the ref.
2541
+ // Projecting that stale state into a controlled textarea rewrites the old
2542
+ // draft for one commit, which moves the caret and can briefly resurrect a
2543
+ // submitted message. Never let an incidental render outrank the latest
2544
+ // local lifecycle decision.
2545
+ const visibleValue = identityMatches ? valueRef.current : "";
2532
2546
 
2533
2547
  return {
2534
- value: identityMatches ? value : "",
2548
+ value: visibleValue,
2535
2549
  setValue: updateValue,
2536
2550
  annotations: identityMatches ? annotations : [],
2537
2551
  addAnnotation,
@@ -2561,7 +2575,7 @@ export function useComposer(
2561
2575
  sendBlockedRef.current?.() !== true &&
2562
2576
  annotationsComplete &&
2563
2577
  (hasPendingOperation ||
2564
- value.trim().length > 0 ||
2578
+ visibleValue.trim().length > 0 ||
2565
2579
  hasReadyResources ||
2566
2580
  annotations.length > 0),
2567
2581
  pause,
@@ -1,6 +1,7 @@
1
1
  import type { SessionEvent, SessionStatus, StreamConnectionState } from "@opengeni/sdk";
2
2
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
3
  import { useEmbeddedSession, type EmbeddedSessionClientOverride } from "../session-context";
4
+ import { createOlderHistoryLoadReceipt, type OlderHistoryLoadReceipt } from "../older-history";
4
5
  import { buildTimeline, groupTimeline, sessionStatusFromEvents } from "../timeline/projection";
5
6
  import type { TimelineItem } from "../timeline/types";
6
7
  import type { EmbeddedSessionClientLike } from "../client";
@@ -38,7 +39,7 @@ export type UseSessionEventsResult = {
38
39
  /** True while an older window is being fetched. */
39
40
  loadingOlder: boolean;
40
41
  /** Prepend an older density-bounded window; resolves true when more remain. */
41
- loadOlder: () => Promise<boolean>;
42
+ loadOlder: () => OlderHistoryLoadReceipt;
42
43
  /**
43
44
  * Durable events exist after the current window (history view jumped away
44
45
  * from the live tip, or a forward page is incomplete).
@@ -427,81 +428,88 @@ export function useSessionEvents(
427
428
  loadingOldestRef.current ||
428
429
  loadingLatestRef.current;
429
430
 
430
- const loadOlder = useCallback(async (): Promise<boolean> => {
431
- if (!sessionId || navigationBusy() || !hasOlderRef.current) {
432
- return false;
433
- }
434
- const before = oldestSequenceRef.current;
435
- if (before === null) {
436
- hasOlderRef.current = false;
437
- setHasOlder(false);
438
- return false;
439
- }
440
- const generation = generationRef.current;
441
- loadingOlderRef.current = true;
442
- setLoadingOlder(true);
443
- try {
444
- const window = await loadEventWindow(client, workspaceId, sessionId, {
445
- before,
446
- pageSize: OLDER_PAGE_SIZE,
447
- targetGroups: OLDER_GROUP_TARGET,
448
- maxFetches: OLDER_FETCH_CAP,
449
- });
450
- if (generationRef.current !== generation) {
451
- return false;
452
- }
453
- if (window.events.length === 0) {
454
- oldestSequenceRef.current = null;
455
- hasOlderRef.current = false;
456
- setHasOlder(false);
457
- return false;
458
- }
459
- const current = eventWindowRef.current;
460
- assertPrependOrder(current.events, window.events);
461
- // Freeze the live iterator before replacing its in-memory window. Rows
462
- // pending in the aborted iterator were never cursor-committed and will
463
- // be replayed from the retained high-water mark below.
464
- streamAbortRef.current?.abort();
465
- observeSessionStatus(window.events, sessionStatusRef, setSessionStatusProjection);
466
- const next = boundBrowserSessionEventWindow([...window.events, ...current.events], {
467
- direction: "oldest",
468
- });
469
- const retained = {
470
- ...next,
471
- truncated: current.truncated || next.truncated,
472
- };
473
- const retainedOldest = retained.events[0]?.sequence ?? null;
474
- if (retainedOldest === null || retainedOldest >= before) {
475
- throw new Error("@opengeni/react: loadOlder made no durable sequence progress");
476
- }
477
- const retainedNewest = retained.events.at(-1)?.sequence ?? null;
478
- eventWindowRef.current = retained;
479
- setEventWindow(retained);
480
- oldestSequenceRef.current = retainedOldest;
481
- newestSequenceRef.current = retainedNewest;
482
- streamResumeSequenceRef.current = maxResumeSequence(retained.events);
483
- // Oldest-directed eviction can discard newer in-memory rows. That fact
484
- // keeps windowTruncated true, but it does not imply older durable rows
485
- // exist; only the backward DB page can answer hasOlder truthfully.
486
- const olderStillAvailable = window.hasOlder;
487
- hasOlderRef.current = olderStillAvailable;
488
- setHasOlder(olderStillAvailable);
489
- if (viewModeRef.current === "history") {
490
- const highWater = lastSequenceRef.current;
491
- const newer = retainedNewest !== null && (retainedNewest < highWater || retained.truncated);
492
- hasNewerRef.current = newer;
493
- setHasNewer(newer);
494
- } else {
495
- // Live mode keeps the historical contract: reconnect and replay the
496
- // evicted tip through SSE so the window stays one contiguous suffix.
497
- setStreamEpoch((epoch) => epoch + 1);
498
- }
499
- return olderStillAvailable;
500
- } finally {
501
- loadingOlderRef.current = false;
502
- setLoadingOlder(false);
503
- }
504
- }, [client, workspaceId, sessionId]);
431
+ const loadOlder = useCallback(
432
+ (): OlderHistoryLoadReceipt =>
433
+ createOlderHistoryLoadReceipt(async (markCommitted) => {
434
+ if (!sessionId || navigationBusy() || !hasOlderRef.current) {
435
+ return false;
436
+ }
437
+ const before = oldestSequenceRef.current;
438
+ if (before === null) {
439
+ oldestSequenceRef.current = null;
440
+ hasOlderRef.current = false;
441
+ setHasOlder(false);
442
+ return false;
443
+ }
444
+ const generation = generationRef.current;
445
+ loadingOlderRef.current = true;
446
+ setLoadingOlder(true);
447
+ try {
448
+ const window = await loadEventWindow(client, workspaceId, sessionId, {
449
+ before,
450
+ pageSize: OLDER_PAGE_SIZE,
451
+ targetGroups: OLDER_GROUP_TARGET,
452
+ maxFetches: OLDER_FETCH_CAP,
453
+ });
454
+ if (generationRef.current !== generation) {
455
+ return false;
456
+ }
457
+ if (window.events.length === 0) {
458
+ oldestSequenceRef.current = null;
459
+ hasOlderRef.current = false;
460
+ setHasOlder(false);
461
+ return false;
462
+ }
463
+ const current = eventWindowRef.current;
464
+ assertPrependOrder(current.events, window.events);
465
+ // Freeze the live iterator before replacing its in-memory window. Rows
466
+ // pending in the aborted iterator were never cursor-committed and will
467
+ // be replayed from the retained high-water mark below.
468
+ streamAbortRef.current?.abort();
469
+ observeSessionStatus(window.events, sessionStatusRef, setSessionStatusProjection);
470
+ const next = boundBrowserSessionEventWindow([...window.events, ...current.events], {
471
+ direction: "oldest",
472
+ });
473
+ const retained = {
474
+ ...next,
475
+ truncated: current.truncated || next.truncated,
476
+ };
477
+ const retainedOldest = retained.events[0]?.sequence ?? null;
478
+ if (retainedOldest === null || retainedOldest >= before) {
479
+ throw new Error("@opengeni/react: loadOlder made no durable sequence progress");
480
+ }
481
+ const retainedNewest = retained.events.at(-1)?.sequence ?? null;
482
+ eventWindowRef.current = retained;
483
+ markCommitted();
484
+ setEventWindow(retained);
485
+ oldestSequenceRef.current = retainedOldest;
486
+ newestSequenceRef.current = retainedNewest;
487
+ streamResumeSequenceRef.current = maxResumeSequence(retained.events);
488
+ // Oldest-directed eviction can discard newer in-memory rows. That fact
489
+ // keeps windowTruncated true, but it does not imply older durable rows
490
+ // exist; only the backward DB page can answer hasOlder truthfully.
491
+ const olderStillAvailable = window.hasOlder;
492
+ hasOlderRef.current = olderStillAvailable;
493
+ setHasOlder(olderStillAvailable);
494
+ if (viewModeRef.current === "history") {
495
+ const highWater = lastSequenceRef.current;
496
+ const newer =
497
+ retainedNewest !== null && (retainedNewest < highWater || retained.truncated);
498
+ hasNewerRef.current = newer;
499
+ setHasNewer(newer);
500
+ } else {
501
+ // Live mode keeps the historical contract: reconnect and replay the
502
+ // evicted tip through SSE so the window stays one contiguous suffix.
503
+ setStreamEpoch((epoch) => epoch + 1);
504
+ }
505
+ return olderStillAvailable;
506
+ } finally {
507
+ loadingOlderRef.current = false;
508
+ setLoadingOlder(false);
509
+ }
510
+ }),
511
+ [client, workspaceId, sessionId],
512
+ );
505
513
 
506
514
  const loadOldest = useCallback(async (): Promise<boolean> => {
507
515
  if (!sessionId || navigationBusy() || !hasOlderRef.current) {
@@ -11,12 +11,16 @@ export type UseSessionLineageOptions = EmbeddedSessionLineageClientOverride & {
11
11
  /** Refresh interval (ms). Off by default. */
12
12
  pollIntervalMs?: number | undefined;
13
13
  enabled?: boolean | undefined;
14
+ /** Optional shared causal clock invoked when each network read starts. */
15
+ beginRead?: (() => number) | undefined;
14
16
  };
15
17
 
16
18
  export type UseSessionLineageResult = {
17
19
  lineage: SessionLineageResponse | null;
18
20
  loading: boolean;
19
21
  error: Error | null;
22
+ /** Causal generation captured when the accepted lineage read started. */
23
+ readGeneration: number;
20
24
  refresh: () => Promise<void>;
21
25
  };
22
26
 
@@ -63,13 +67,26 @@ export function useSessionLineage(
63
67
  const { client, workspaceId, workspaceControlEvent, registerSessionReconciler } =
64
68
  useEmbeddedSessionLineage(options);
65
69
  const enabled = (options.enabled ?? true) && Boolean(sessionId);
66
- const load = useCallback(
67
- async () =>
68
- sessionId
69
- ? await client.getSessionLineage(workspaceId, sessionId)
70
- : { ancestors: [], children: [], truncated: false },
71
- [client, workspaceId, sessionId],
72
- );
70
+ const nextReadGeneration = useRef(0);
71
+ const beginRead = options.beginRead;
72
+ const load = useCallback(async () => {
73
+ if (!sessionId) {
74
+ return {
75
+ lineage: { ancestors: [], children: [], truncated: false },
76
+ readGeneration: 0,
77
+ };
78
+ }
79
+ let readGeneration = 0;
80
+ const lineage = await client.getSessionLineage(workspaceId, sessionId, {
81
+ onRequestStart: (sharedReadGeneration) => {
82
+ readGeneration = sharedReadGeneration ?? beginRead?.() ?? ++nextReadGeneration.current;
83
+ },
84
+ });
85
+ return {
86
+ lineage,
87
+ readGeneration,
88
+ };
89
+ }, [beginRead, client, workspaceId, sessionId]);
73
90
  const state = usePolledValue(load, { pollIntervalMs: options.pollIntervalMs, enabled });
74
91
  const refresh = state.refresh;
75
92
  useEffect(() => {
@@ -123,9 +140,10 @@ export function useSessionLineage(
123
140
  { events: options.events, enabled: enabled && options.events !== undefined },
124
141
  );
125
142
  return {
126
- lineage: state.data,
143
+ lineage: state.data?.lineage ?? null,
127
144
  loading: state.loading,
128
145
  error: state.error,
146
+ readGeneration: state.data?.readGeneration ?? 0,
129
147
  refresh,
130
148
  };
131
149
  }
@@ -1,5 +1,5 @@
1
1
  import type { Session, SessionEvent } from "@opengeni/sdk";
2
- import { useCallback, useEffect, useMemo, useState } from "react";
2
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
3
  import { useEmbeddedSessionRead, type EmbeddedSessionReadClientOverride } from "../session-context";
4
4
  import {
5
5
  useMutationRunner,
@@ -12,12 +12,18 @@ export type UseSessionOptions = EmbeddedSessionReadClientOverride &
12
12
  SessionEventFeedOptions & {
13
13
  /** Re-fetch on an interval (ms). Off by default — pair with `useSessionEvents` for live status. */
14
14
  pollIntervalMs?: number | undefined;
15
+ /** Optional shared causal clock invoked when each network read starts. */
16
+ beginRead?: (() => number) | undefined;
15
17
  };
16
18
 
17
19
  export type UseSessionResult = {
18
20
  session: Session | null;
19
21
  loading: boolean;
20
22
  error: Error | null;
23
+ /** Monotonic revision of accepted authoritative detail reads. */
24
+ readRevision: number;
25
+ /** Causal generation captured when the accepted network read started. */
26
+ readGeneration: number;
21
27
  refresh: () => Promise<void>;
22
28
  /** Manually rename the session (PATCH, source='user'). Returns the updated session, or null on failure. */
23
29
  updateTitle: (title: string) => Promise<Session | null>;
@@ -41,16 +47,29 @@ export function useSession(
41
47
  useEmbeddedSessionRead(options);
42
48
  const enabled = (options.enabled ?? true) && Boolean(sessionId);
43
49
  const [override, setOverride] = useState<Session | null>(null);
50
+ const nextReadRevision = useRef(0);
51
+ const nextReadGeneration = useRef(0);
52
+ const beginRead = options.beginRead;
44
53
  const { run, mutating, mutationError, clearMutationError } = useMutationRunner();
45
54
  const load = useCallback(async () => {
46
55
  if (!sessionId) {
47
56
  return null;
48
57
  }
49
- const fetched = await client.getSession(workspaceId, sessionId);
58
+ let readGeneration = 0;
59
+ const fetched = await client.getSession(workspaceId, sessionId, {
60
+ fresh: true,
61
+ onRequestStart: () => {
62
+ readGeneration = beginRead?.() ?? ++nextReadGeneration.current;
63
+ },
64
+ });
50
65
  // A fresh server read supersedes any optimistic/event-driven override.
51
66
  setOverride(null);
52
- return fetched;
53
- }, [client, workspaceId, sessionId]);
67
+ return {
68
+ session: fetched,
69
+ revision: ++nextReadRevision.current,
70
+ readGeneration,
71
+ };
72
+ }, [beginRead, client, workspaceId, sessionId]);
54
73
  const { data, loading, error, refresh } = usePolledValue(load, {
55
74
  pollIntervalMs: options.pollIntervalMs,
56
75
  enabled,
@@ -63,7 +82,9 @@ export function useSession(
63
82
  return registerSessionReconciler(sessionId, "session", refresh);
64
83
  }, [enabled, refresh, registerSessionReconciler, sessionId]);
65
84
 
66
- const base = data ?? null;
85
+ const base = data?.session ?? null;
86
+ const readRevision = data?.revision ?? 0;
87
+ const readGeneration = data?.readGeneration ?? 0;
67
88
  // The override only ever carries title/titleSource patches; it is reset on
68
89
  // every fresh load so it can never go stale against the server snapshot.
69
90
  const session = useMemo(
@@ -78,6 +99,13 @@ export function useSession(
78
99
  // the UI reflects the new title without polling or a full re-fetch.
79
100
  const onTitleEvent = useCallback(
80
101
  (event: SessionEvent) => {
102
+ // The fetched session row is the authoritative title projection through
103
+ // lastSequence. Shared feeds may replay that historical tail after the
104
+ // fetch; applying it would undo a row-only migration quarantine. Only an
105
+ // event committed after this snapshot may live-patch the title.
106
+ if (!base || event.sequence <= base.lastSequence) {
107
+ return;
108
+ }
81
109
  const payload = (event.payload ?? {}) as { title?: unknown; source?: unknown };
82
110
  const title = payload.title;
83
111
  if (typeof title !== "string") {
@@ -87,9 +115,6 @@ export function useSession(
87
115
  payload.source === "user" || payload.source === "agent" ? payload.source : null;
88
116
  setOverride((current): Session | null => {
89
117
  const next = current ?? base;
90
- if (!next) {
91
- return current;
92
- }
93
118
  return { ...next, title, titleSource: source };
94
119
  });
95
120
  },
@@ -118,6 +143,8 @@ export function useSession(
118
143
  session,
119
144
  loading,
120
145
  error,
146
+ readRevision,
147
+ readGeneration,
121
148
  refresh,
122
149
  updateTitle,
123
150
  updating: mutating,
@@ -14,6 +14,8 @@ export type UseWorkspaceSessionsOptions = ClientOverride & {
14
14
  /** Refresh interval (ms) for fleet/manager views. Off by default. */
15
15
  pollIntervalMs?: number | undefined;
16
16
  enabled?: boolean | undefined;
17
+ /** Optional shared causal clock invoked when each network read starts. */
18
+ beginRead?: (() => number) | undefined;
17
19
  };
18
20
 
19
21
  export type UseWorkspaceSessionsResult = {
@@ -29,6 +31,10 @@ export type UseWorkspaceSessionsResult = {
29
31
  nextCursor: string | null;
30
32
  loading: boolean;
31
33
  error: Error | null;
34
+ /** Monotonic revision of accepted authoritative list-page reads. */
35
+ readRevision: number;
36
+ /** Causal generation captured when the accepted network read started. */
37
+ readGeneration: number;
32
38
  refresh: () => Promise<void>;
33
39
  };
34
40
 
@@ -44,6 +50,9 @@ export function useWorkspaceSessions(
44
50
  const pinsOnly = options.pinsOnly;
45
51
  const archivedOnly = options.archivedOnly;
46
52
  const enabled = options.enabled ?? true;
53
+ const nextReadRevision = useRef(0);
54
+ const nextReadGeneration = useRef(0);
55
+ const beginRead = options.beginRead;
47
56
  const queryKey = [
48
57
  workspaceId,
49
58
  limit ?? "",
@@ -58,20 +67,34 @@ export function useWorkspaceSessions(
58
67
  useEffect(() => {
59
68
  previousQueryKey.current = queryKey;
60
69
  }, [queryKey]);
61
- const load = useCallback(
62
- async () => ({
70
+ const load = useCallback(async () => {
71
+ const readGeneration = beginRead?.() ?? ++nextReadGeneration.current;
72
+ const page = await client.listSessionPage(workspaceId, {
73
+ ...(limit !== undefined ? { limit } : {}),
74
+ ...(parentSessionId !== undefined ? { parentSessionId } : {}),
75
+ ...(cursor !== undefined ? { cursor } : {}),
76
+ ...(search !== undefined ? { search } : {}),
77
+ ...(pinsOnly ? { pinsOnly: true } : {}),
78
+ ...(archivedOnly ? { archivedOnly: true } : {}),
79
+ });
80
+ return {
63
81
  queryKey,
64
- page: await client.listSessionPage(workspaceId, {
65
- ...(limit !== undefined ? { limit } : {}),
66
- ...(parentSessionId !== undefined ? { parentSessionId } : {}),
67
- ...(cursor !== undefined ? { cursor } : {}),
68
- ...(search !== undefined ? { search } : {}),
69
- ...(pinsOnly ? { pinsOnly: true } : {}),
70
- ...(archivedOnly ? { archivedOnly: true } : {}),
71
- }),
72
- }),
73
- [client, workspaceId, limit, parentSessionId, cursor, search, pinsOnly, archivedOnly, queryKey],
74
- );
82
+ page,
83
+ revision: ++nextReadRevision.current,
84
+ readGeneration,
85
+ };
86
+ }, [
87
+ beginRead,
88
+ client,
89
+ workspaceId,
90
+ limit,
91
+ parentSessionId,
92
+ cursor,
93
+ search,
94
+ pinsOnly,
95
+ archivedOnly,
96
+ queryKey,
97
+ ]);
75
98
  const state = usePolledValue(load, {
76
99
  pollIntervalMs: options.pollIntervalMs,
77
100
  enabled,
@@ -100,6 +123,8 @@ export function useWorkspaceSessions(
100
123
  queryKeyTransition ||
101
124
  (state.data !== null && state.data.queryKey !== queryKey)),
102
125
  error: state.error,
126
+ readRevision: page ? (state.data?.revision ?? 0) : 0,
127
+ readGeneration: page ? (state.data?.readGeneration ?? 0) : 0,
103
128
  refresh: state.refresh,
104
129
  };
105
130
  }
package/src/index.ts CHANGED
@@ -41,12 +41,14 @@ export {
41
41
  boundBrowserSessionEventWindow,
42
42
  useSessionEvents,
43
43
  } from "./hooks/use-session-events";
44
+ export { createOlderHistoryLoadReceipt } from "./older-history";
44
45
  export type {
45
46
  BrowserSessionEventWindow,
46
47
  SessionEventsConnectionState,
47
48
  UseSessionEventsOptions,
48
49
  UseSessionEventsResult,
49
50
  } from "./hooks/use-session-events";
51
+ export type { OlderHistoryLoader, OlderHistoryLoadReceipt } from "./older-history";
50
52
  export {
51
53
  useComposer,
52
54
  composeSendInput,
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Await-compatible older-history result with a causal commit receipt.
3
+ *
4
+ * `committed` flips synchronously before the accepted older window is exposed
5
+ * to React. Returning this exact object preserves the receipt directly;
6
+ * synchronous wrappers that discard it remain supported through receipt
7
+ * capture, while replacing it asynchronously with a plain promise does not.
8
+ */
9
+ export type OlderHistoryLoadReceipt = Promise<boolean> & {
10
+ readonly committed: boolean;
11
+ };
12
+
13
+ /**
14
+ * Source-compatible public callback accepted by MessageTimeline.
15
+ *
16
+ * The callback historically returned `void`, which permits consumers to
17
+ * return any synchronous value or promise. Receipt-aware loaders opt into the
18
+ * stronger runtime contract by returning (or synchronously creating through a
19
+ * wrapper) an {@link OlderHistoryLoadReceipt}.
20
+ */
21
+ export type OlderHistoryLoader = () => unknown;
22
+
23
+ type MutableOlderHistoryLoadReceipt = Promise<boolean> & {
24
+ committed: boolean;
25
+ };
26
+
27
+ type OlderHistoryReceiptCapture = (receipt: OlderHistoryLoadReceipt) => void;
28
+
29
+ let activeReceiptCapture: OlderHistoryReceiptCapture | undefined;
30
+
31
+ /** Bind a receipt before a synchronous forwarding stack can publish state. */
32
+ export function invokeOlderHistoryLoaderWithReceiptCapture(
33
+ load: OlderHistoryLoader,
34
+ capture: OlderHistoryReceiptCapture,
35
+ ): unknown {
36
+ const previousCapture = activeReceiptCapture;
37
+ activeReceiptCapture = capture;
38
+ try {
39
+ return load();
40
+ } finally {
41
+ activeReceiptCapture = previousCapture;
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Build an await-compatible older-history receipt for custom timeline hosts.
47
+ * Call `markCommitted` immediately before publishing an accepted older window.
48
+ */
49
+ export function createOlderHistoryLoadReceipt(
50
+ load: (markCommitted: () => void) => boolean | Promise<boolean>,
51
+ ): OlderHistoryLoadReceipt {
52
+ let resolveResult!: (value: boolean | PromiseLike<boolean>) => void;
53
+ let rejectResult!: (reason?: unknown) => void;
54
+ const result = new Promise<boolean>((resolve, reject) => {
55
+ resolveResult = resolve;
56
+ rejectResult = reject;
57
+ }) as MutableOlderHistoryLoadReceipt;
58
+ result.committed = false;
59
+ activeReceiptCapture?.(result);
60
+ try {
61
+ const loaded = load(() => {
62
+ result.committed = true;
63
+ });
64
+ Promise.resolve(loaded).then(resolveResult, rejectResult);
65
+ } catch (error) {
66
+ rejectResult(error);
67
+ }
68
+ return result;
69
+ }
package/src/session-ui.ts CHANGED
@@ -11,6 +11,8 @@ export { HumanInputSurface } from "./components/human-input-surface";
11
11
  export type { HumanInputSurfaceProps } from "./components/human-input-surface";
12
12
  export { MessageTimeline, TimelineRow } from "./components/message-timeline";
13
13
  export type { MessageTimelineProps } from "./components/message-timeline";
14
+ export { createOlderHistoryLoadReceipt } from "./older-history";
15
+ export type { OlderHistoryLoader, OlderHistoryLoadReceipt } from "./older-history";
14
16
  export { UserMessageBody, userMessageLikelyNeedsDisclosure } from "./components/user-message-body";
15
17
  export type { UserMessageBodyProps } from "./components/user-message-body";
16
18
  export { BUILT_IN_TURN_SUMMARY_FACET_IDS } from "./timeline/turn-summary";
package/src/session.ts CHANGED
@@ -31,6 +31,8 @@ export type {
31
31
  UseSessionEventsOptions,
32
32
  UseSessionEventsResult,
33
33
  } from "./hooks/use-session-events";
34
+ export { createOlderHistoryLoadReceipt } from "./older-history";
35
+ export type { OlderHistoryLoader, OlderHistoryLoadReceipt } from "./older-history";
34
36
  export {
35
37
  useComposer,
36
38
  composeSendInput,