@opengeni/react 2.2.0-canary.0 → 2.5.0-canary.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 +9 -2
- package/dist/{chunk-5RZN3DD5.js → chunk-6H6S5V4Z.js} +280 -113
- package/dist/chunk-6H6S5V4Z.js.map +1 -0
- package/dist/{chunk-UOLJWT5Y.js → chunk-ATQJEWCA.js} +154 -114
- package/dist/chunk-ATQJEWCA.js.map +1 -0
- package/dist/{chunk-XSA2QAKP.js → chunk-MXJGO7LE.js} +65 -3
- package/dist/chunk-MXJGO7LE.js.map +1 -0
- package/dist/{chunk-LTCHJ4MS.js → chunk-NVREJA6V.js} +2 -2
- package/dist/{chunk-JOAVA2TW.js → chunk-T7VAYG2I.js} +8 -7
- package/dist/{chunk-JOAVA2TW.js.map → chunk-T7VAYG2I.js.map} +1 -1
- package/dist/components/fleet-tile.d.ts +1 -1
- package/dist/components/message-timeline.d.ts +7 -2
- package/dist/composer.js +2 -2
- package/dist/hooks/use-session-events.d.ts +2 -1
- package/dist/hooks/use-session-lineage.d.ts +4 -0
- package/dist/hooks/use-session.d.ts +6 -0
- package/dist/hooks/use-workspace-sessions.d.ts +6 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +41 -19
- package/dist/index.js.map +1 -1
- package/dist/older-history.d.ts +29 -0
- package/dist/session-ui.d.ts +2 -0
- package/dist/session-ui.js +5 -2
- package/dist/session.d.ts +2 -0
- package/dist/session.js +5 -3
- package/dist/timeline/types.d.ts +2 -0
- package/package.json +2 -2
- package/src/components/fleet-tile.tsx +7 -5
- package/src/components/human-input-form.tsx +14 -6
- package/src/components/message-timeline.tsx +428 -137
- package/src/components/tip-follow.ts +18 -7
- package/src/hooks/use-composer.ts +20 -6
- package/src/hooks/use-session-events.ts +84 -76
- package/src/hooks/use-session-lineage.ts +26 -8
- package/src/hooks/use-session.ts +35 -8
- package/src/hooks/use-workspace-sessions.ts +38 -13
- package/src/index.ts +2 -0
- package/src/older-history.ts +69 -0
- package/src/session-ui.ts +2 -0
- package/src/session.ts +2 -0
- package/src/timeline/activity-rail.tsx +1 -0
- package/src/timeline/projection.ts +53 -2
- package/src/timeline/types.ts +2 -0
- package/dist/chunk-5RZN3DD5.js.map +0 -1
- package/dist/chunk-UOLJWT5Y.js.map +0 -1
- package/dist/chunk-XSA2QAKP.js.map +0 -1
- /package/dist/{chunk-LTCHJ4MS.js.map → chunk-NVREJA6V.js.map} +0 -0
|
@@ -1,19 +1,22 @@
|
|
|
1
1
|
/* ----------------------------------------------------------------------------
|
|
2
2
|
Tip-follow camera (pure)
|
|
3
3
|
|
|
4
|
-
DOM truth is immediate;
|
|
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
|
|
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
|
-
-
|
|
14
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
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:
|
|
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
|
-
|
|
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: () =>
|
|
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(
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
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
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
}
|
package/src/hooks/use-session.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
53
|
-
|
|
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
|
-
|
|
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
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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,
|