@opengeni/react 3.1.0 → 3.3.2-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.
Files changed (63) hide show
  1. package/dist/accounts.js +87 -11
  2. package/dist/accounts.js.map +1 -1
  3. package/dist/{browser-viewer-AEFHUYZK.js → browser-viewer-MKKFRRRV.js} +3 -3
  4. package/dist/{chunk-W6NFJXSA.js → chunk-2DZVYHVO.js} +20 -4
  5. package/dist/chunk-2DZVYHVO.js.map +1 -0
  6. package/dist/{chunk-YJFGAFBD.js → chunk-3AGBGTRT.js} +226 -95
  7. package/dist/chunk-3AGBGTRT.js.map +1 -0
  8. package/dist/{chunk-YFKJ7VZH.js → chunk-7EAFGICR.js} +45 -11
  9. package/dist/chunk-7EAFGICR.js.map +1 -0
  10. package/dist/{chunk-MZ2G2XBA.js → chunk-IOLVN74D.js} +31 -4
  11. package/dist/chunk-IOLVN74D.js.map +1 -0
  12. package/dist/{chunk-D3UIAJKY.js → chunk-JAQ5K7W6.js} +3 -2
  13. package/dist/{chunk-D3UIAJKY.js.map → chunk-JAQ5K7W6.js.map} +1 -1
  14. package/dist/{chunk-LAKLF4PA.js → chunk-VTOREEXF.js} +8 -7
  15. package/dist/chunk-VTOREEXF.js.map +1 -0
  16. package/dist/{chunk-RGALCTWO.js → chunk-XYRDDSXF.js} +4 -4
  17. package/dist/{chunk-HQVCKCLG.js → chunk-YG4QEQF5.js} +354 -255
  18. package/dist/chunk-YG4QEQF5.js.map +1 -0
  19. package/dist/{chunk-XXWH2JLC.js → chunk-YVCHAQZY.js} +2 -2
  20. package/dist/{chunk-OAM4WAAM.js → chunk-ZPBTI3TE.js} +2 -2
  21. package/dist/components/fleet-tile.d.ts +2 -2
  22. package/dist/components/model-policy-picker.d.ts +9 -0
  23. package/dist/components/tip-follow.d.ts +16 -0
  24. package/dist/composer.js +5 -5
  25. package/dist/hooks/use-session-events.d.ts +3 -3
  26. package/dist/index.js +76 -68
  27. package/dist/index.js.map +1 -1
  28. package/dist/interaction.js +2 -2
  29. package/dist/machines.js +2 -2
  30. package/dist/realtime.js +46 -7
  31. package/dist/realtime.js.map +1 -1
  32. package/dist/session-ui.js +4 -4
  33. package/dist/session.js +4 -4
  34. package/dist/timeline/entrance.d.ts +13 -1
  35. package/package.json +3 -2
  36. package/src/accounts.tsx +116 -14
  37. package/src/components/fleet-tile.tsx +4 -15
  38. package/src/components/message-timeline.tsx +237 -101
  39. package/src/components/model-policy-picker.tsx +24 -3
  40. package/src/components/tip-follow.ts +52 -0
  41. package/src/components/tooltip.tsx +38 -5
  42. package/src/hooks/use-available-models.ts +20 -14
  43. package/src/hooks/use-composer.ts +49 -3
  44. package/src/hooks/use-goal.ts +31 -7
  45. package/src/hooks/use-last-started-turn-policy.ts +11 -5
  46. package/src/hooks/use-session-events.ts +186 -61
  47. package/src/hooks/use-session-lineage.ts +20 -16
  48. package/src/hooks/use-session.ts +23 -19
  49. package/src/hooks/use-workspace-sessions.ts +31 -27
  50. package/src/lib/format.ts +8 -6
  51. package/src/realtime/realtime-control.tsx +55 -6
  52. package/src/timeline/entrance.tsx +28 -3
  53. package/src/timeline/projection.ts +4 -0
  54. package/dist/chunk-HQVCKCLG.js.map +0 -1
  55. package/dist/chunk-LAKLF4PA.js.map +0 -1
  56. package/dist/chunk-MZ2G2XBA.js.map +0 -1
  57. package/dist/chunk-W6NFJXSA.js.map +0 -1
  58. package/dist/chunk-YFKJ7VZH.js.map +0 -1
  59. package/dist/chunk-YJFGAFBD.js.map +0 -1
  60. /package/dist/{browser-viewer-AEFHUYZK.js.map → browser-viewer-MKKFRRRV.js.map} +0 -0
  61. /package/dist/{chunk-RGALCTWO.js.map → chunk-XYRDDSXF.js.map} +0 -0
  62. /package/dist/{chunk-XXWH2JLC.js.map → chunk-YVCHAQZY.js.map} +0 -0
  63. /package/dist/{chunk-OAM4WAAM.js.map → chunk-ZPBTI3TE.js.map} +0 -0
@@ -1,5 +1,5 @@
1
1
  import type { SessionEvent, SessionStatus, StreamConnectionState } from "@opengeni/sdk";
2
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import { startTransition, useCallback, useEffect, useMemo, useRef, useState } from "react";
3
3
  import { useEmbeddedSession, type EmbeddedSessionClientOverride } from "../session-context";
4
4
  import { createOlderHistoryLoadReceipt, type OlderHistoryLoadReceipt } from "../older-history";
5
5
  import { buildTimeline, groupTimeline, sessionStatusFromEvents } from "../timeline/projection";
@@ -283,7 +283,10 @@ export function useSessionEvents(
283
283
  batchResumeSequence,
284
284
  );
285
285
  }
286
- observeSessionStatus(batch, sessionStatusRef, setSessionStatusProjection);
286
+ const status = observeSessionStatus(batch, sessionStatusRef);
287
+ if (status !== undefined) {
288
+ setSessionStatusProjection(status);
289
+ }
287
290
  const current = eventWindowRef.current;
288
291
  const next = boundBrowserSessionEventWindow([...current.events, ...batch]);
289
292
  const retained = {
@@ -293,7 +296,7 @@ export function useSessionEvents(
293
296
  eventWindowRef.current = retained;
294
297
  setEventWindow(retained);
295
298
  oldestSequenceRef.current = retained.events[0]?.sequence ?? null;
296
- newestSequenceRef.current = retained.events.at(-1)?.sequence ?? null;
299
+ newestSequenceRef.current = maxResumeSequenceOrNull(retained.events);
297
300
  if (retained.truncated) {
298
301
  hasOlderRef.current = true;
299
302
  setHasOlder(true);
@@ -321,17 +324,21 @@ export function useSessionEvents(
321
324
  if (!isCurrent()) {
322
325
  return;
323
326
  }
324
- observeSessionStatus(window.events, sessionStatusRef, setSessionStatusProjection);
327
+ const status = observeSessionStatus(window.events, sessionStatusRef);
325
328
  const retained = boundBrowserSessionEventWindow(window.events);
326
329
  eventWindowRef.current = retained;
327
- setEventWindow(retained);
328
330
  oldestSequenceRef.current = retained.events[0]?.sequence ?? window.oldestSequence;
329
- newestSequenceRef.current = retained.events.at(-1)?.sequence ?? window.newestSequence;
331
+ newestSequenceRef.current =
332
+ maxResumeSequenceOrNull(retained.events) ?? window.newestSequence;
330
333
  hasOlderRef.current = window.hasOlder || retained.truncated;
331
334
  hasNewerRef.current = false;
332
335
  lastSequenceRef.current = window.newestSequence;
333
336
  streamResumeSequenceRef.current = window.newestSequence;
334
337
  initialWindowLoadedRef.current = true;
338
+ if (status !== undefined) {
339
+ setSessionStatusProjection(status);
340
+ }
341
+ setEventWindow(retained);
335
342
  setHasOlder(window.hasOlder || retained.truncated);
336
343
  setHasNewer(false);
337
344
  setInitialLoading(false);
@@ -393,6 +400,10 @@ export function useSessionEvents(
393
400
  flush();
394
401
  setError(cause instanceof Error ? cause : new Error(String(cause)));
395
402
  setConnectionState("error");
403
+ // A failed first compact-tail request has no later success path in
404
+ // this effect instance. End the loading gate so hosts can render the
405
+ // retryable error instead of an infinite initial spinner.
406
+ setInitialLoading(false);
396
407
  }
397
408
  }
398
409
  })();
@@ -444,6 +455,7 @@ export function useSessionEvents(
444
455
  const generation = generationRef.current;
445
456
  loadingOlderRef.current = true;
446
457
  setLoadingOlder(true);
458
+ let published = false;
447
459
  try {
448
460
  const window = await loadEventWindow(client, workspaceId, sessionId, {
449
461
  before,
@@ -466,7 +478,7 @@ export function useSessionEvents(
466
478
  // pending in the aborted iterator were never cursor-committed and will
467
479
  // be replayed from the retained high-water mark below.
468
480
  streamAbortRef.current?.abort();
469
- observeSessionStatus(window.events, sessionStatusRef, setSessionStatusProjection);
481
+ const status = observeSessionStatus(window.events, sessionStatusRef);
470
482
  const next = boundBrowserSessionEventWindow([...window.events, ...current.events], {
471
483
  direction: "oldest",
472
484
  });
@@ -478,10 +490,10 @@ export function useSessionEvents(
478
490
  if (retainedOldest === null || retainedOldest >= before) {
479
491
  throw new Error("@opengeni/react: loadOlder made no durable sequence progress");
480
492
  }
481
- const retainedNewest = retained.events.at(-1)?.sequence ?? null;
493
+ const retainedNewest = maxResumeSequenceOrNull(retained.events);
494
+ const previousNewest = maxResumeSequenceOrNull(current.events);
482
495
  eventWindowRef.current = retained;
483
496
  markCommitted();
484
- setEventWindow(retained);
485
497
  oldestSequenceRef.current = retainedOldest;
486
498
  newestSequenceRef.current = retainedNewest;
487
499
  streamResumeSequenceRef.current = maxResumeSequence(retained.events);
@@ -490,22 +502,52 @@ export function useSessionEvents(
490
502
  // exist; only the backward DB page can answer hasOlder truthfully.
491
503
  const olderStillAvailable = window.hasOlder;
492
504
  hasOlderRef.current = olderStillAvailable;
493
- setHasOlder(olderStillAvailable);
494
- if (viewModeRef.current === "history") {
505
+ const evictedLiveTail =
506
+ previousNewest !== null && retainedNewest !== null && retainedNewest < previousNewest;
507
+ let newer: boolean | null = null;
508
+ let enterHistory = false;
509
+ let reconnectLive = false;
510
+ if (viewModeRef.current === "history" || evictedLiveTail) {
495
511
  const highWater = lastSequenceRef.current;
496
- const newer =
497
- retainedNewest !== null && (retainedNewest < highWater || retained.truncated);
512
+ newer = retainedNewest !== null && (retainedNewest < highWater || retained.truncated);
498
513
  hasNewerRef.current = newer;
499
- setHasNewer(newer);
514
+ if (evictedLiveTail) {
515
+ // A full oldest-directed browser window cannot also retain the
516
+ // live suffix. Keep the newly loaded history and page forward;
517
+ // reconnecting SSE here immediately newest-bounds the window and
518
+ // evicts every row the reader just requested.
519
+ viewModeRef.current = "history";
520
+ enterHistory = true;
521
+ }
500
522
  } 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);
523
+ // The retained merge is still one contiguous live suffix. Restart
524
+ // SSE from its cursor so events queued during the fetch are replayed.
525
+ reconnectLive = true;
504
526
  }
527
+ loadingOlderRef.current = false;
528
+ startTransition(() => {
529
+ if (status !== undefined) {
530
+ setSessionStatusProjection(status);
531
+ }
532
+ setEventWindow(retained);
533
+ setHasOlder(olderStillAvailable);
534
+ if (newer !== null) {
535
+ setHasNewer(newer);
536
+ }
537
+ if (enterHistory) {
538
+ setViewMode("history");
539
+ } else if (reconnectLive) {
540
+ setStreamEpoch((epoch) => epoch + 1);
541
+ }
542
+ setLoadingOlder(false);
543
+ });
544
+ published = true;
505
545
  return olderStillAvailable;
506
546
  } finally {
507
- loadingOlderRef.current = false;
508
- setLoadingOlder(false);
547
+ if (!published) {
548
+ loadingOlderRef.current = false;
549
+ setLoadingOlder(false);
550
+ }
509
551
  }
510
552
  }),
511
553
  [client, workspaceId, sessionId],
@@ -518,6 +560,7 @@ export function useSessionEvents(
518
560
  const generation = generationRef.current;
519
561
  loadingOldestRef.current = true;
520
562
  setLoadingOldest(true);
563
+ let published = false;
521
564
  try {
522
565
  const window = await loadForwardEventWindow(client, workspaceId, sessionId, {
523
566
  after: 0,
@@ -534,17 +577,15 @@ export function useSessionEvents(
534
577
  return false;
535
578
  }
536
579
  streamAbortRef.current?.abort();
537
- observeSessionStatus(window.events, sessionStatusRef, setSessionStatusProjection);
580
+ const status = observeSessionStatus(window.events, sessionStatusRef);
538
581
  // Keep the oldest prefix — never the middle or tip.
539
582
  const retained = boundBrowserSessionEventWindow(window.events, { direction: "oldest" });
540
583
  const retainedOldest = retained.events[0]?.sequence ?? null;
541
- const retainedNewest = retained.events.at(-1)?.sequence ?? null;
584
+ const retainedNewest = maxResumeSequenceOrNull(retained.events);
542
585
  eventWindowRef.current = retained;
543
- setEventWindow(retained);
544
586
  oldestSequenceRef.current = retainedOldest;
545
587
  newestSequenceRef.current = retainedNewest;
546
588
  hasOlderRef.current = false;
547
- setHasOlder(false);
548
589
  const highWater = Math.max(lastSequenceRef.current, retainedNewest ?? 0);
549
590
  lastSequenceRef.current = highWater;
550
591
  const newer =
@@ -552,14 +593,26 @@ export function useSessionEvents(
552
593
  retained.truncated ||
553
594
  (retainedNewest !== null && retainedNewest < highWater);
554
595
  hasNewerRef.current = newer;
555
- setHasNewer(newer);
556
596
  initialWindowLoadedRef.current = true;
557
597
  viewModeRef.current = "history";
558
- setViewMode("history");
598
+ loadingOldestRef.current = false;
599
+ startTransition(() => {
600
+ if (status !== undefined) {
601
+ setSessionStatusProjection(status);
602
+ }
603
+ setEventWindow(retained);
604
+ setHasOlder(false);
605
+ setHasNewer(newer);
606
+ setViewMode("history");
607
+ setLoadingOldest(false);
608
+ });
609
+ published = true;
559
610
  return newer;
560
611
  } finally {
561
- loadingOldestRef.current = false;
562
- setLoadingOldest(false);
612
+ if (!published) {
613
+ loadingOldestRef.current = false;
614
+ setLoadingOldest(false);
615
+ }
563
616
  }
564
617
  }, [client, workspaceId, sessionId]);
565
618
 
@@ -576,6 +629,7 @@ export function useSessionEvents(
576
629
  const generation = generationRef.current;
577
630
  loadingNewerRef.current = true;
578
631
  setLoadingNewer(true);
632
+ let published = false;
579
633
  try {
580
634
  const window = await loadForwardEventWindow(client, workspaceId, sessionId, {
581
635
  after: afterSequence,
@@ -598,7 +652,7 @@ export function useSessionEvents(
598
652
  }
599
653
  const current = eventWindowRef.current;
600
654
  assertAppendOrder(current.events, window.events);
601
- observeSessionStatus(window.events, sessionStatusRef, setSessionStatusProjection);
655
+ const status = observeSessionStatus(window.events, sessionStatusRef);
602
656
  const previousOldest = current.events[0]?.sequence ?? null;
603
657
  // Moving forward through history: keep the newest suffix of the merge.
604
658
  const next = boundBrowserSessionEventWindow([...current.events, ...window.events], {
@@ -609,12 +663,11 @@ export function useSessionEvents(
609
663
  truncated: current.truncated || next.truncated || window.hasNewer,
610
664
  };
611
665
  const retainedOldest = retained.events[0]?.sequence ?? null;
612
- const retainedNewest = retained.events.at(-1)?.sequence ?? null;
666
+ const retainedNewest = maxResumeSequenceOrNull(retained.events);
613
667
  if (retainedNewest === null || retainedNewest <= afterSequence) {
614
668
  throw new Error("@opengeni/react: loadNewer made no durable sequence progress");
615
669
  }
616
670
  eventWindowRef.current = retained;
617
- setEventWindow(retained);
618
671
  oldestSequenceRef.current = retainedOldest;
619
672
  newestSequenceRef.current = retainedNewest;
620
673
  // Evicting the start while paging forward re-arms hasOlder.
@@ -627,23 +680,39 @@ export function useSessionEvents(
627
680
  retainedOldest > 1))
628
681
  ) {
629
682
  hasOlderRef.current = true;
630
- setHasOlder(true);
631
683
  }
684
+ const rearmedOlder = hasOlderRef.current;
632
685
  const highWater = Math.max(lastSequenceRef.current, retainedNewest);
633
686
  lastSequenceRef.current = highWater;
634
687
  const newer = window.hasNewer || retainedNewest < highWater;
635
688
  hasNewerRef.current = newer;
636
- setHasNewer(newer);
689
+ let resumeLive = false;
637
690
  if (!newer) {
638
691
  streamResumeSequenceRef.current = retainedNewest;
639
692
  viewModeRef.current = "live";
640
- setViewMode("live");
641
- setStreamEpoch((epoch) => epoch + 1);
693
+ resumeLive = true;
642
694
  }
695
+ loadingNewerRef.current = false;
696
+ startTransition(() => {
697
+ if (status !== undefined) {
698
+ setSessionStatusProjection(status);
699
+ }
700
+ setEventWindow(retained);
701
+ setHasOlder(rearmedOlder);
702
+ setHasNewer(newer);
703
+ if (resumeLive) {
704
+ setViewMode("live");
705
+ setStreamEpoch((epoch) => epoch + 1);
706
+ }
707
+ setLoadingNewer(false);
708
+ });
709
+ published = true;
643
710
  return newer;
644
711
  } finally {
645
- loadingNewerRef.current = false;
646
- setLoadingNewer(false);
712
+ if (!published) {
713
+ loadingNewerRef.current = false;
714
+ setLoadingNewer(false);
715
+ }
647
716
  }
648
717
  }, [client, workspaceId, sessionId]);
649
718
 
@@ -653,27 +722,35 @@ export function useSessionEvents(
653
722
  }
654
723
  loadingLatestRef.current = true;
655
724
  setLoadingLatest(true);
725
+ let published = false;
656
726
  try {
657
727
  streamAbortRef.current?.abort();
658
728
  hasNewerRef.current = false;
659
- setHasNewer(false);
660
729
  hasOlderRef.current = false;
661
- setHasOlder(false);
662
730
  newestSequenceRef.current = null;
663
731
  oldestSequenceRef.current = null;
664
732
  eventWindowRef.current = EMPTY_EVENT_WINDOW;
665
- setEventWindow(EMPTY_EVENT_WINDOW);
666
733
  initialWindowLoadedRef.current = false;
667
734
  streamResumeSequenceRef.current = after;
668
- setInitialLoading(true);
669
735
  viewModeRef.current = "live";
670
- setViewMode("live");
671
- setStreamEpoch((epoch) => epoch + 1);
736
+ loadingLatestRef.current = false;
737
+ startTransition(() => {
738
+ setHasNewer(false);
739
+ setHasOlder(false);
740
+ setEventWindow(EMPTY_EVENT_WINDOW);
741
+ setInitialLoading(true);
742
+ setViewMode("live");
743
+ setStreamEpoch((epoch) => epoch + 1);
744
+ setLoadingLatest(false);
745
+ });
746
+ published = true;
672
747
  // Effect reloads the tip. The tip fetch itself is owned by the effect.
673
748
  await Promise.resolve();
674
749
  } finally {
675
- loadingLatestRef.current = false;
676
- setLoadingLatest(false);
750
+ if (!published) {
751
+ loadingLatestRef.current = false;
752
+ setLoadingLatest(false);
753
+ }
677
754
  }
678
755
  }, [after, sessionId]);
679
756
 
@@ -709,9 +786,9 @@ export function useSessionEvents(
709
786
  * accumulation retains the newest suffix; backward paging retains the oldest
710
787
  * prefix so newly fetched history cannot be immediately evicted. This is
711
788
  * deliberately separate from durable history and transport paging: when a
712
- * backward page evicts the live tail, the hook reconnects from the retained
713
- * high-water mark while preserving the highest-ever-observed sequence
714
- * separately. The source event remains durable in PostgreSQL throughout.
789
+ * backward page evicts the live tail, the hook enters bounded history mode and
790
+ * preserves the highest-ever-observed sequence separately for forward paging.
791
+ * The source event remains durable in PostgreSQL throughout.
715
792
  */
716
793
  export function boundBrowserSessionEventWindow(
717
794
  events: readonly SessionEvent[],
@@ -993,8 +1070,7 @@ function isBrowserUtf8Continuation(value: number): boolean {
993
1070
  function observeSessionStatus(
994
1071
  events: readonly SessionEvent[],
995
1072
  ref: { current: { sequence: number; status: SessionStatus | null } },
996
- setStatus: (status: SessionStatus | null) => void,
997
- ): void {
1073
+ ): SessionStatus | undefined {
998
1074
  let latest: { sequence: number; status: SessionStatus } | null = null;
999
1075
  for (const event of events) {
1000
1076
  const status = sessionStatusFromEvents([event]);
@@ -1002,9 +1078,9 @@ function observeSessionStatus(
1002
1078
  latest = { sequence: event.sequence, status };
1003
1079
  }
1004
1080
  }
1005
- if (!latest || latest.sequence < ref.current.sequence) return;
1081
+ if (!latest || latest.sequence < ref.current.sequence) return undefined;
1006
1082
  ref.current = latest;
1007
- setStatus(latest.status);
1083
+ return latest.status;
1008
1084
  }
1009
1085
 
1010
1086
  type LoadedEventWindow = {
@@ -1097,6 +1173,8 @@ async function loadEventWindow(
1097
1173
  buffer = buffer.slice(boundary);
1098
1174
  }
1099
1175
  }
1176
+ const densityWindow = trimBackwardToGroupTarget(buffer, options.targetGroups);
1177
+ buffer = densityWindow.events;
1100
1178
 
1101
1179
  const oldest = buffer[0] ?? null;
1102
1180
  const newest = buffer[buffer.length - 1] ?? null;
@@ -1104,21 +1182,26 @@ async function loadEventWindow(
1104
1182
  events: buffer,
1105
1183
  oldestSequence: oldest?.sequence ?? null,
1106
1184
  newestSequence: newest ? maxResumeSequence(buffer) : 0,
1107
- hasOlder: buffer.length > 0 && !reachedStart && oldest?.type !== "session.created",
1185
+ hasOlder:
1186
+ buffer.length > 0 &&
1187
+ (densityWindow.trimmed || (!reachedStart && oldest?.type !== "session.created")),
1108
1188
  };
1109
1189
  }
1110
1190
 
1111
1191
  /** Index of the oldest clean turn start in the buffer, or -1. */
1112
1192
  function findBoundaryIndex(events: SessionEvent[]): number {
1113
1193
  for (let index = 0; index < events.length; index += 1) {
1114
- const type = events[index]!.type;
1115
- if (type === "session.created" || type === "user.message") {
1194
+ if (isTurnBoundary(events[index]!)) {
1116
1195
  return index;
1117
1196
  }
1118
1197
  }
1119
1198
  return -1;
1120
1199
  }
1121
1200
 
1201
+ function isTurnBoundary(event: SessionEvent): boolean {
1202
+ return event.type === "session.created" || event.type === "user.message";
1203
+ }
1204
+
1122
1205
  /**
1123
1206
  * Forward (oldest→newer) density-bounded window. Used for jump-to-start and
1124
1207
  * loadNewer so the browser never walks the whole middle gap.
@@ -1155,12 +1238,15 @@ async function loadForwardEventWindow(
1155
1238
  }
1156
1239
  assertAscending(page);
1157
1240
  buffer = [...buffer, ...page];
1158
- cursor = page[page.length - 1]!.sequence;
1159
- if (page.length < options.pageSize) {
1160
- reachedEnd = true;
1161
- break;
1162
- }
1241
+ // Compact events retain their first raw sequence in `sequence` and expose
1242
+ // the covered high-water mark through `coalescedUntil`. Advancing by the
1243
+ // first sequence duplicates compacted text on the next page. A compact
1244
+ // response length is not an end-of-history signal either: thousands of raw
1245
+ // events may legitimately collapse into one returned event.
1246
+ cursor = maxResumeSequence(page);
1163
1247
  }
1248
+ const densityWindow = trimForwardToGroupTarget(buffer, options.targetGroups);
1249
+ buffer = densityWindow.events;
1164
1250
 
1165
1251
  const oldest = buffer[0] ?? null;
1166
1252
  const newest = buffer[buffer.length - 1] ?? null;
@@ -1168,10 +1254,45 @@ async function loadForwardEventWindow(
1168
1254
  events: buffer,
1169
1255
  oldestSequence: oldest?.sequence ?? null,
1170
1256
  newestSequence: newest ? maxResumeSequence(buffer) : 0,
1171
- hasNewer: buffer.length > 0 && !reachedEnd,
1257
+ hasNewer: buffer.length > 0 && (densityWindow.trimmed || !reachedEnd),
1172
1258
  };
1173
1259
  }
1174
1260
 
1261
+ type DensityWindow = {
1262
+ events: SessionEvent[];
1263
+ trimmed: boolean;
1264
+ };
1265
+
1266
+ /** Keep the newest complete turn groups near an older-history seam. */
1267
+ function trimBackwardToGroupTarget(events: SessionEvent[], targetGroups: number): DensityWindow {
1268
+ if (!Number.isFinite(targetGroups) || targetGroups <= 0 || events.length === 0) {
1269
+ return { events, trimmed: false };
1270
+ }
1271
+ for (let index = events.length - 1; index > 0; index -= 1) {
1272
+ if (!isTurnBoundary(events[index]!)) continue;
1273
+ const candidate = events.slice(index);
1274
+ if (groupCount(candidate) >= targetGroups) {
1275
+ return { events: candidate, trimmed: true };
1276
+ }
1277
+ }
1278
+ return { events, trimmed: false };
1279
+ }
1280
+
1281
+ /** Keep the oldest complete turn groups near a forward-history seam. */
1282
+ function trimForwardToGroupTarget(events: SessionEvent[], targetGroups: number): DensityWindow {
1283
+ if (!Number.isFinite(targetGroups) || targetGroups <= 0 || events.length === 0) {
1284
+ return { events, trimmed: false };
1285
+ }
1286
+ for (let index = 1; index < events.length; index += 1) {
1287
+ if (!isTurnBoundary(events[index]!)) continue;
1288
+ const candidate = events.slice(0, index);
1289
+ if (groupCount(candidate) >= targetGroups) {
1290
+ return { events: candidate, trimmed: true };
1291
+ }
1292
+ }
1293
+ return { events, trimmed: false };
1294
+ }
1295
+
1175
1296
  type PreviousPage = SessionEvent[] & { requested: number };
1176
1297
 
1177
1298
  async function loadPreviousPage(
@@ -1248,10 +1369,14 @@ function isLogStart(event: SessionEvent): boolean {
1248
1369
  return event.type === "session.created" || event.sequence <= 1;
1249
1370
  }
1250
1371
 
1251
- function maxResumeSequence(events: SessionEvent[]): number {
1372
+ function maxResumeSequence(events: readonly SessionEvent[]): number {
1252
1373
  return events.reduce((max, event) => Math.max(max, eventResumeSequence(event)), 0);
1253
1374
  }
1254
1375
 
1376
+ function maxResumeSequenceOrNull(events: readonly SessionEvent[]): number | null {
1377
+ return events.length > 0 ? maxResumeSequence(events) : null;
1378
+ }
1379
+
1255
1380
  function eventResumeSequence(event: SessionEvent): number {
1256
1381
  const payload = asRecord(event.payload);
1257
1382
  const coalescedUntil = Number(payload.coalescedUntil);
@@ -69,24 +69,28 @@ export function useSessionLineage(
69
69
  const enabled = (options.enabled ?? true) && Boolean(sessionId);
70
70
  const nextReadGeneration = useRef(0);
71
71
  const beginRead = options.beginRead;
72
- const load = useCallback(async () => {
73
- if (!sessionId) {
72
+ const load = useCallback(
73
+ async (signal?: AbortSignal) => {
74
+ if (!sessionId) {
75
+ return {
76
+ lineage: { ancestors: [], children: [], truncated: false },
77
+ readGeneration: 0,
78
+ };
79
+ }
80
+ let readGeneration = 0;
81
+ const lineage = await client.getSessionLineage(workspaceId, sessionId, {
82
+ signal,
83
+ onRequestStart: (sharedReadGeneration) => {
84
+ readGeneration = sharedReadGeneration ?? beginRead?.() ?? ++nextReadGeneration.current;
85
+ },
86
+ });
74
87
  return {
75
- lineage: { ancestors: [], children: [], truncated: false },
76
- readGeneration: 0,
88
+ lineage,
89
+ readGeneration,
77
90
  };
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]);
91
+ },
92
+ [beginRead, client, workspaceId, sessionId],
93
+ );
90
94
  const state = usePolledValue(load, { pollIntervalMs: options.pollIntervalMs, enabled });
91
95
  const refresh = state.refresh;
92
96
  useEffect(() => {
@@ -51,25 +51,29 @@ export function useSession(
51
51
  const nextReadGeneration = useRef(0);
52
52
  const beginRead = options.beginRead;
53
53
  const { run, mutating, mutationError, clearMutationError } = useMutationRunner();
54
- const load = useCallback(async () => {
55
- if (!sessionId) {
56
- return null;
57
- }
58
- let readGeneration = 0;
59
- const fetched = await client.getSession(workspaceId, sessionId, {
60
- fresh: true,
61
- onRequestStart: () => {
62
- readGeneration = beginRead?.() ?? ++nextReadGeneration.current;
63
- },
64
- });
65
- // A fresh server read supersedes any optimistic/event-driven override.
66
- setOverride(null);
67
- return {
68
- session: fetched,
69
- revision: ++nextReadRevision.current,
70
- readGeneration,
71
- };
72
- }, [beginRead, client, workspaceId, sessionId]);
54
+ const load = useCallback(
55
+ async (signal?: AbortSignal) => {
56
+ if (!sessionId) {
57
+ return null;
58
+ }
59
+ let readGeneration = 0;
60
+ const fetched = await client.getSession(workspaceId, sessionId, {
61
+ fresh: true,
62
+ signal,
63
+ onRequestStart: () => {
64
+ readGeneration = beginRead?.() ?? ++nextReadGeneration.current;
65
+ },
66
+ });
67
+ // A fresh server read supersedes any optimistic/event-driven override.
68
+ setOverride(null);
69
+ return {
70
+ session: fetched,
71
+ revision: ++nextReadRevision.current,
72
+ readGeneration,
73
+ };
74
+ },
75
+ [beginRead, client, workspaceId, sessionId],
76
+ );
73
77
  const { data, loading, error, refresh } = usePolledValue(load, {
74
78
  pollIntervalMs: options.pollIntervalMs,
75
79
  enabled,
@@ -67,34 +67,38 @@ export function useWorkspaceSessions(
67
67
  useEffect(() => {
68
68
  previousQueryKey.current = queryKey;
69
69
  }, [queryKey]);
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 {
70
+ const load = useCallback(
71
+ async (signal?: AbortSignal) => {
72
+ const readGeneration = beginRead?.() ?? ++nextReadGeneration.current;
73
+ const page = await client.listSessionPage(workspaceId, {
74
+ ...(limit !== undefined ? { limit } : {}),
75
+ ...(parentSessionId !== undefined ? { parentSessionId } : {}),
76
+ ...(cursor !== undefined ? { cursor } : {}),
77
+ ...(search !== undefined ? { search } : {}),
78
+ ...(pinsOnly ? { pinsOnly: true } : {}),
79
+ ...(archivedOnly ? { archivedOnly: true } : {}),
80
+ signal,
81
+ });
82
+ return {
83
+ queryKey,
84
+ page,
85
+ revision: ++nextReadRevision.current,
86
+ readGeneration,
87
+ };
88
+ },
89
+ [
90
+ beginRead,
91
+ client,
92
+ workspaceId,
93
+ limit,
94
+ parentSessionId,
95
+ cursor,
96
+ search,
97
+ pinsOnly,
98
+ archivedOnly,
81
99
  queryKey,
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
- ]);
100
+ ],
101
+ );
98
102
  const state = usePolledValue(load, {
99
103
  pollIntervalMs: options.pollIntervalMs,
100
104
  enabled,
package/src/lib/format.ts CHANGED
@@ -1,17 +1,19 @@
1
1
  import { OpenGeniApiError } from "@opengeni/sdk";
2
2
 
3
+ const clockTimeFormatter = new Intl.DateTimeFormat(undefined, {
4
+ month: "short",
5
+ day: "numeric",
6
+ hour: "numeric",
7
+ minute: "2-digit",
8
+ });
9
+
3
10
  /** Local finished-at stamp for message footers: "Aug 2, 3:42 PM" / locale-equivalent. */
4
11
  export function formatClockTime(iso: string): string {
5
12
  const then = new Date(iso);
6
13
  if (Number.isNaN(then.getTime())) {
7
14
  return "";
8
15
  }
9
- return then.toLocaleString(undefined, {
10
- month: "short",
11
- day: "numeric",
12
- hour: "numeric",
13
- minute: "2-digit",
14
- });
16
+ return clockTimeFormatter.format(then);
15
17
  }
16
18
 
17
19
  /** Compact relative time: "now", "42s", "7m", "3h", "2d", then a date. */