@opengeni/react 3.7.0-canary.0 → 3.7.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 (64) hide show
  1. package/dist/artifacts.js +24 -5
  2. package/dist/artifacts.js.map +1 -1
  3. package/dist/{browser-viewer-E5DDEFQ4.js → browser-viewer-NTD6UIPR.js} +3 -3
  4. package/dist/{chunk-W2RGYBCV.js → chunk-24M4YBCL.js} +4 -4
  5. package/dist/{chunk-DOK4KZF2.js → chunk-5IXTJTK3.js} +1322 -392
  6. package/dist/chunk-5IXTJTK3.js.map +1 -0
  7. package/dist/chunk-5PQJOCX5.js +902 -0
  8. package/dist/chunk-5PQJOCX5.js.map +1 -0
  9. package/dist/{chunk-R55HFTT3.js → chunk-INGB2N5R.js} +4305 -4019
  10. package/dist/chunk-INGB2N5R.js.map +1 -0
  11. package/dist/{chunk-FJBOU2TH.js → chunk-MOWYEBCQ.js} +1728 -12
  12. package/dist/chunk-MOWYEBCQ.js.map +1 -0
  13. package/dist/chunk-PAJU5KKL.js +331 -0
  14. package/dist/chunk-PAJU5KKL.js.map +1 -0
  15. package/dist/{chunk-VTOREEXF.js → chunk-PLYRL5ER.js} +24 -2
  16. package/dist/{chunk-VTOREEXF.js.map → chunk-PLYRL5ER.js.map} +1 -1
  17. package/dist/{chunk-YVCHAQZY.js → chunk-ZFOFY5ST.js} +27 -349
  18. package/dist/chunk-ZFOFY5ST.js.map +1 -0
  19. package/dist/components/artifacts/published-html-artifact-frame.d.ts +3 -2
  20. package/dist/components/session-chrome.d.ts +37 -4
  21. package/dist/components/session-commands-panel.d.ts +6 -0
  22. package/dist/components/session-conversation.d.ts +14 -0
  23. package/dist/components/timeline-anchor.d.ts +21 -0
  24. package/dist/composer.js +2 -3
  25. package/dist/conversation-timeline.d.ts +5 -0
  26. package/dist/index.d.ts +4 -1
  27. package/dist/index.js +716 -914
  28. package/dist/index.js.map +1 -1
  29. package/dist/interaction.js +2 -2
  30. package/dist/lib/format.d.ts +5 -0
  31. package/dist/machines.js +3 -2
  32. package/dist/session-ui.d.ts +3 -0
  33. package/dist/session-ui.js +14 -4
  34. package/dist/session.js +19 -19
  35. package/dist/timeline/types.d.ts +3 -1
  36. package/package.json +2 -2
  37. package/src/components/artifacts/published-html-artifact-frame.tsx +27 -4
  38. package/src/components/machine-input-display.ts +6 -0
  39. package/src/components/message-timeline.tsx +320 -197
  40. package/src/components/session-chrome.tsx +498 -125
  41. package/src/components/session-commands-panel.tsx +113 -0
  42. package/src/components/session-conversation.tsx +141 -0
  43. package/src/components/timeline-anchor.tsx +91 -0
  44. package/src/conversation-timeline.ts +83 -0
  45. package/src/hooks/use-session-background-commands.ts +21 -10
  46. package/src/hooks/use-session-events.ts +36 -14
  47. package/src/hooks/use-session.ts +23 -2
  48. package/src/index.ts +4 -0
  49. package/src/lib/format.ts +32 -0
  50. package/src/session-ui.ts +3 -0
  51. package/src/timeline/projection.ts +6 -12
  52. package/src/timeline/types.ts +4 -0
  53. package/styles/compiled.css +495 -411
  54. package/styles/index.css +23 -0
  55. package/dist/chunk-7EAFGICR.js +0 -302
  56. package/dist/chunk-7EAFGICR.js.map +0 -1
  57. package/dist/chunk-C6WDLHBA.js +0 -2590
  58. package/dist/chunk-C6WDLHBA.js.map +0 -1
  59. package/dist/chunk-DOK4KZF2.js.map +0 -1
  60. package/dist/chunk-FJBOU2TH.js.map +0 -1
  61. package/dist/chunk-R55HFTT3.js.map +0 -1
  62. package/dist/chunk-YVCHAQZY.js.map +0 -1
  63. /package/dist/{browser-viewer-E5DDEFQ4.js.map → browser-viewer-NTD6UIPR.js.map} +0 -0
  64. /package/dist/{chunk-W2RGYBCV.js.map → chunk-24M4YBCL.js.map} +0 -0
@@ -0,0 +1,113 @@
1
+ import { ChevronRightIcon, Loader2Icon, SquareIcon } from "lucide-react";
2
+ import { useState } from "react";
3
+ import type { UseSessionBackgroundCommandsResult } from "../hooks/use-session-background-commands";
4
+ import { formatClockTime } from "../lib/format";
5
+
6
+ /** The current session's live commands. Settled commands belong to the timeline. */
7
+ export function SessionCommandsPanel({
8
+ commands: state,
9
+ readOnly = false,
10
+ }: {
11
+ commands: UseSessionBackgroundCommandsResult;
12
+ readOnly?: boolean;
13
+ }) {
14
+ const [pending, setPending] = useState<string | null>(null);
15
+ const [error, setError] = useState<string | null>(null);
16
+ const active = state.commands.filter(
17
+ (command) => command.state === "running" || command.state === "stopping",
18
+ );
19
+ const stop = async (id: string) => {
20
+ setPending(id);
21
+ setError(null);
22
+ try {
23
+ await state.cancel(id);
24
+ } catch (cause) {
25
+ setError(cause instanceof Error ? cause.message : "Stop was not confirmed. Try again.");
26
+ } finally {
27
+ setPending(null);
28
+ }
29
+ };
30
+ return (
31
+ <div className="space-y-2 text-og-xs" data-og-session-commands="">
32
+ {state.loading && active.length === 0 ? (
33
+ <p role="status" className="text-og-fg-muted">
34
+ Loading commands…
35
+ </p>
36
+ ) : null}
37
+ {state.error ? (
38
+ <div role="alert" className="flex items-center justify-between gap-2 text-og-danger">
39
+ <span>Commands unavailable.</span>
40
+ <button
41
+ type="button"
42
+ className="underline outline-hidden focus-visible:ring-2 focus-visible:ring-og-accent/40"
43
+ onClick={() => void state.refresh()}
44
+ >
45
+ Retry
46
+ </button>
47
+ </div>
48
+ ) : null}
49
+ {!state.loading && !state.error && active.length === 0 ? (
50
+ <p role="status" className="text-og-fg-muted">
51
+ No background commands running.
52
+ </p>
53
+ ) : null}
54
+ {active.length > 0 ? (
55
+ <ul className="max-h-64 overflow-y-auto overscroll-contain divide-y divide-og-border/40">
56
+ {active.map((command) => (
57
+ <li key={command.id} className="flex items-start gap-3 py-2 first:pt-0 last:pb-0">
58
+ <div className="min-w-0 flex-1">
59
+ <details className="group/command">
60
+ <summary
61
+ className="flex cursor-pointer list-none items-center gap-1.5 rounded-og-sm font-mono text-og-fg outline-hidden focus-visible:ring-2 focus-visible:ring-og-accent/40"
62
+ title="Expand command"
63
+ >
64
+ <ChevronRightIcon className="size-3 shrink-0 text-og-fg-subtle transition-transform group-open/command:rotate-90" />
65
+ <span className="truncate">
66
+ {command.commandPreview || "Background command"}
67
+ </span>
68
+ </summary>
69
+ <div className="mt-2 space-y-1">
70
+ <p className="whitespace-pre-wrap break-all font-mono text-og-fg-muted">
71
+ {command.commandPreview || "Background command"}
72
+ </p>
73
+ <time
74
+ className="block text-og-fg-subtle"
75
+ dateTime={command.startedAt}
76
+ title={new Date(command.startedAt).toLocaleString()}
77
+ >
78
+ Started {formatClockTime(command.startedAt)}
79
+ </time>
80
+ </div>
81
+ </details>
82
+ <p className="mt-1 text-og-fg-subtle">
83
+ {command.state === "stopping" ? "Stopping…" : "Running"}
84
+ </p>
85
+ </div>
86
+ {!readOnly ? (
87
+ <button
88
+ type="button"
89
+ aria-label={`Stop ${command.commandPreview || "background command"}`}
90
+ disabled={pending !== null || command.state === "stopping"}
91
+ onClick={() => void stop(command.id)}
92
+ className="inline-flex min-h-7 shrink-0 items-center gap-1 rounded-og-sm px-2 text-og-fg-muted outline-hidden hover:bg-og-surface-3/70 hover:text-og-fg focus-visible:ring-2 focus-visible:ring-og-accent/40 disabled:opacity-50 pointer-coarse:min-h-11"
93
+ >
94
+ {pending === command.id || command.state === "stopping" ? (
95
+ <Loader2Icon className="size-3 animate-og-spin motion-reduce:animate-none" />
96
+ ) : (
97
+ <SquareIcon className="size-3" />
98
+ )}
99
+ Stop
100
+ </button>
101
+ ) : null}
102
+ </li>
103
+ ))}
104
+ </ul>
105
+ ) : null}
106
+ {error ? (
107
+ <p role="alert" className="text-og-danger">
108
+ {error}
109
+ </p>
110
+ ) : null}
111
+ </div>
112
+ );
113
+ }
@@ -0,0 +1,141 @@
1
+ import { useRef, type CSSProperties } from "react";
2
+ import { useOpenGeni, type ClientOverride } from "../session-context";
3
+ import { useWorkspaceModelCatalog } from "../hooks/use-available-models";
4
+ import { ModelPolicyPicker } from "./model-policy-picker";
5
+ import { useSessionEvents } from "../hooks/use-session-events";
6
+ import { useSession } from "../hooks/use-session";
7
+ import { useTurnQueue } from "../hooks/use-turn-queue";
8
+ import { useComposer } from "../hooks/use-composer";
9
+ import { useHumanInputRequests } from "../hooks/use-human-input";
10
+ import { ChatComposer, type ChatComposerProps } from "./chat-composer";
11
+ import { QueueSurface } from "./queue-surface";
12
+ import { HumanInputSurface } from "./human-input-surface";
13
+ import { MessageTimeline } from "./message-timeline";
14
+ import { conversationTimeline } from "../conversation-timeline";
15
+ import { cn } from "../lib/cn";
16
+
17
+ export type SessionConversationProps = ClientOverride & {
18
+ sessionId: string;
19
+ className?: string;
20
+ /** Defaults to filling the host. The host owns available height. */
21
+ height?: CSSProperties["height"];
22
+ /** Presentation/custom controls only; queue and delivery wiring stay owned here. */
23
+ composerProps?: Omit<ChatComposerProps, "composer" | "effectiveControl" | "queuedAheadCount">;
24
+ };
25
+
26
+ /** Complete existing-session conversation. Uses the provider's normal SDK client
27
+ * (including Site clients), one shared event feed, and authoritative queue state. */
28
+ export function SessionConversation(props: SessionConversationProps) {
29
+ return <Conversation key={`${props.workspaceId ?? ""}:${props.sessionId}`} {...props} />;
30
+ }
31
+
32
+ function Conversation({
33
+ sessionId,
34
+ client,
35
+ workspaceId,
36
+ className,
37
+ height = "100%",
38
+ composerProps,
39
+ }: SessionConversationProps) {
40
+ const scope = { client, workspaceId };
41
+ const context = useOpenGeni(scope);
42
+ const catalog = useWorkspaceModelCatalog({
43
+ client: context.client,
44
+ workspaceId: context.workspaceId,
45
+ });
46
+ const feed = useSessionEvents(sessionId, scope);
47
+ const options = { ...scope, events: feed.events };
48
+ const detail = useSession(sessionId, options);
49
+ const queue = useTurnQueue(sessionId, options);
50
+ const human = useHumanInputRequests(sessionId, options);
51
+ const status = feed.sessionStatus ?? detail.session?.status;
52
+ const terminal = status === "cancelled";
53
+ const composer = useComposer(sessionId, {
54
+ ...options,
55
+ effectiveControl: queue.effectiveControl ?? detail.session?.effectiveControl,
56
+ sendDestination: () => (queue.queue.length > 0 || status === "running" ? "queue" : "chat"),
57
+ });
58
+ const region = useRef<HTMLDivElement>(null);
59
+ const error = detail.error ?? feed.error ?? human.error;
60
+ return (
61
+ <div
62
+ className={cn("og-root flex min-h-0 min-w-0 flex-col gap-2 overflow-hidden", className)}
63
+ ref={region}
64
+ style={{ height }}
65
+ data-og-conversation=""
66
+ >
67
+ {error && <p role="alert">{error.message}</p>}
68
+ <MessageTimeline
69
+ className="min-h-0 flex-1"
70
+ items={conversationTimeline(feed.timeline, queue, composer)}
71
+ status={status}
72
+ hasOlder={feed.hasOlder}
73
+ loadingOlder={feed.loadingOlder}
74
+ onLoadOlder={feed.loadOlder}
75
+ hasNewer={feed.hasNewer}
76
+ loadingNewer={feed.loadingNewer}
77
+ onLoadNewer={() => {
78
+ void feed.loadNewer();
79
+ }}
80
+ onJumpToStart={async () => {
81
+ await feed.loadOldest();
82
+ }}
83
+ loadingOldest={feed.loadingOldest}
84
+ onJumpToLatest={feed.jumpToLatest}
85
+ onAnnotate={composer.addAnnotation}
86
+ />
87
+ <div className="min-h-0 max-h-[40%] shrink-0 overflow-y-auto" data-og-conversation-inputs="">
88
+ <HumanInputSurface
89
+ requests={human.requests}
90
+ onSubmit={async (id, response) => {
91
+ await human.respond(id, response);
92
+ }}
93
+ respondingRequestId={human.respondingRequestId}
94
+ error={human.mutationError?.message}
95
+ autoFocus={false}
96
+ />
97
+ {terminal ? (
98
+ <QueueSurface queue={queue} readOnly />
99
+ ) : (
100
+ <QueueSurface
101
+ queue={queue}
102
+ composer={composer}
103
+ onRequestComposerFocus={() =>
104
+ region.current?.querySelector<HTMLTextAreaElement>("textarea")?.focus()
105
+ }
106
+ />
107
+ )}
108
+ </div>
109
+ <div className="shrink-0" data-og-conversation-composer="">
110
+ <ChatComposer
111
+ {...composerProps}
112
+ composer={composer}
113
+ disabled={terminal || composerProps?.disabled}
114
+ controlsStart={
115
+ composerProps?.controlsStart ??
116
+ (composer.policy && (
117
+ <ModelPolicyPicker
118
+ rows={catalog.rows}
119
+ model={composer.policy.model}
120
+ effort={composer.policy.reasoningEffort}
121
+ latencyMode={composer.policy.latencyMode}
122
+ loading={catalog.loading}
123
+ error={catalog.error?.message}
124
+ disabled={terminal}
125
+ sessionKey={sessionId}
126
+ onModelChange={(model) => composer.setModel?.(model)}
127
+ onEffortChange={(effort) => composer.setReasoningEffort?.(effort)}
128
+ onLatencyModeChange={(mode) => composer.setLatencyMode?.(mode)}
129
+ />
130
+ ))
131
+ }
132
+ responsiveBasis={composerProps?.responsiveBasis ?? "container"}
133
+ effectiveControl={
134
+ composer.effectiveControl ?? queue.effectiveControl ?? detail.session?.effectiveControl
135
+ }
136
+ queuedAheadCount={queue.queue.length}
137
+ />
138
+ </div>
139
+ </div>
140
+ );
141
+ }
@@ -0,0 +1,91 @@
1
+ import { Component, type ReactNode } from "react";
2
+
3
+ type Anchor = { element: HTMLElement; key: string | null; text: string | null; top: number };
4
+ export type TimelineAnchor = Anchor[];
5
+
6
+ /** Read the old DOM immediately before React changes it, not when a fetch starts. */
7
+ export class TimelineBeforeLayout extends Component<{
8
+ capture: () => void;
9
+ children: ReactNode;
10
+ }> {
11
+ getSnapshotBeforeUpdate() {
12
+ this.props.capture();
13
+ return null;
14
+ }
15
+ componentDidUpdate() {}
16
+ render() {
17
+ return this.props.children;
18
+ }
19
+ }
20
+
21
+ export function captureTimelineAnchor(scroller: HTMLElement): TimelineAnchor | null {
22
+ const viewport = scroller.getBoundingClientRect();
23
+ if (viewport.height <= 0) return null;
24
+ const groups = Array.from(scroller.querySelectorAll<HTMLElement>("[data-og-group-key]")).filter(
25
+ (group) => group.getBoundingClientRect().height > 0,
26
+ );
27
+ const anchors: TimelineAnchor = [];
28
+ // A disclosure is the reader's explicit point of interaction. In particular,
29
+ // anchoring a paragraph below an expanding disclosure would move its button.
30
+ const focused = scroller.ownerDocument.activeElement;
31
+ if (
32
+ focused instanceof HTMLElement &&
33
+ scroller.contains(focused) &&
34
+ focused.matches("button[aria-expanded]")
35
+ ) {
36
+ const box = focused.getBoundingClientRect();
37
+ if (box.bottom > viewport.top && box.top < viewport.bottom) {
38
+ anchors.push({ element: focused, key: null, text: null, top: box.top });
39
+ }
40
+ }
41
+ // A paragraph survives even when earlier deltas reconstruct its containing message.
42
+ for (const group of groups) {
43
+ const rect = group.getBoundingClientRect();
44
+ if (rect.bottom <= viewport.top || rect.top >= viewport.bottom) continue;
45
+ for (const element of group.querySelectorAll<HTMLElement>("p, li, pre, h1, h2, h3, h4")) {
46
+ const box = element.getBoundingClientRect();
47
+ const text = element.textContent;
48
+ if (box.bottom > viewport.top && box.top < viewport.bottom && text && text.length >= 12) {
49
+ anchors.push({ element, key: null, text, top: box.top });
50
+ }
51
+ }
52
+ }
53
+ // Prefer a retained visible row, then a following row. A following row also
54
+ // anchors the unchanged suffix of a partially loaded message above it.
55
+ const rows = groups.map((element) => ({
56
+ element,
57
+ key: element.getAttribute("data-og-group-key"),
58
+ text: null,
59
+ top: element.getBoundingClientRect().top,
60
+ }));
61
+ anchors.push(...rows.filter((row) => row.top >= viewport.top));
62
+ anchors.push(...rows.filter((row) => row.top < viewport.top).reverse());
63
+ return anchors;
64
+ }
65
+
66
+ /** Return only the correction native browser anchoring has not already made. */
67
+ export function timelineAnchorCorrection(
68
+ scroller: HTMLElement,
69
+ anchors: TimelineAnchor,
70
+ ): number | null {
71
+ let blocks: HTMLElement[] | undefined;
72
+ const groups = Array.from(scroller.querySelectorAll<HTMLElement>("[data-og-group-key]"));
73
+ for (const anchor of anchors) {
74
+ let element: HTMLElement | undefined;
75
+ if (
76
+ scroller.contains(anchor.element) &&
77
+ (!anchor.text || anchor.element.textContent === anchor.text)
78
+ ) {
79
+ element = anchor.element;
80
+ } else if (anchor.key) {
81
+ element = groups.find((group) => group.getAttribute("data-og-group-key") === anchor.key);
82
+ } else if (anchor.text) {
83
+ blocks ??= Array.from(scroller.querySelectorAll<HTMLElement>("p, li, pre, h1, h2, h3, h4"));
84
+ const matches = blocks.filter((block) => block.textContent === anchor.text);
85
+ // Repeated boilerplate is not sufficient evidence of retained content.
86
+ if (matches.length === 1) element = matches[0];
87
+ }
88
+ if (element) return element.getBoundingClientRect().top - anchor.top;
89
+ }
90
+ return null;
91
+ }
@@ -0,0 +1,83 @@
1
+ import type { ComposerState } from "./hooks/use-composer";
2
+ import type { UseTurnQueueResult } from "./hooks/use-turn-queue";
3
+ import type { TimelineItem, UserMessageItem } from "./timeline/types";
4
+
5
+ /** Keep pending prompts in the queue, not duplicated in the conversation. */
6
+ export function conversationTimeline(
7
+ items: TimelineItem[],
8
+ queue: Pick<UseTurnQueueResult, "queue" | "snapshot" | "acceptedSteers">,
9
+ composer: Pick<
10
+ ComposerState,
11
+ "optimisticMessages" | "retryOptimisticMessage" | "removeOptimisticMessage"
12
+ >,
13
+ ): TimelineItem[] {
14
+ const queued = new Set(
15
+ queue.queue
16
+ .filter((turn) => turn.metadata.delivery !== "steer")
17
+ .map((turn) => turn.triggerEventId),
18
+ );
19
+ const pending = composer.optimisticMessages ?? [];
20
+ const pendingQueue = new Set(
21
+ pending
22
+ .filter(
23
+ (message) =>
24
+ message.destination === "queue" &&
25
+ !(
26
+ message.turnId &&
27
+ message.appliedQueueVersion != null &&
28
+ queue.snapshot &&
29
+ queue.snapshot.version >= message.appliedQueueVersion &&
30
+ !queue.queue.some((turn) => turn.id === message.turnId)
31
+ ),
32
+ )
33
+ .map((message) => `user-message:${message.clientEventId}`),
34
+ );
35
+ const visible = items.filter(
36
+ (item) =>
37
+ item.kind !== "user-message" ||
38
+ (!queued.has(item.id) && !pendingQueue.has(item.reconciliationKey ?? "")),
39
+ );
40
+ const keys = new Set(
41
+ visible.flatMap((item) => (item.kind === "user-message" ? [item.reconciliationKey] : [])),
42
+ );
43
+ const optimistic: UserMessageItem[] = pending
44
+ .filter(
45
+ (message) =>
46
+ !keys.has(`user-message:${message.clientEventId}`) &&
47
+ !queue.queue.some((turn) => turn.id === message.turnId),
48
+ )
49
+ .map((message) => ({
50
+ kind: "user-message",
51
+ id: `optimistic:${message.clientEventId}`,
52
+ reconciliationKey: `user-message:${message.clientEventId}`,
53
+ text: message.text,
54
+ annotations: message.annotations.map((annotation, ordinal) => ({ ...annotation, ordinal })),
55
+ resources: message.resources,
56
+ tools: [],
57
+ occurredAt: message.occurredAt,
58
+ delivery: {
59
+ state: message.state,
60
+ ...(message.error ? { error: message.error } : {}),
61
+ ...(message.state === "failed"
62
+ ? {
63
+ onRetry: () => composer.retryOptimisticMessage?.(message.clientEventId),
64
+ onRemove: () => composer.removeOptimisticMessage?.(message.clientEventId),
65
+ }
66
+ : {}),
67
+ },
68
+ }));
69
+ const ids = new Set(visible.map((item) => item.id));
70
+ const steers: UserMessageItem[] = (queue.acceptedSteers ?? [])
71
+ .filter((steer) => !ids.has(steer.triggerEventId))
72
+ .map((steer) => ({
73
+ kind: "user-message",
74
+ id: steer.triggerEventId,
75
+ text: steer.text,
76
+ annotations: steer.annotations,
77
+ resources: steer.resources,
78
+ tools: steer.tools,
79
+ occurredAt: steer.occurredAt,
80
+ delivery: { state: steer.state },
81
+ }));
82
+ return [...visible, ...optimistic, ...steers];
83
+ }
@@ -1,5 +1,5 @@
1
1
  import type { SessionBackgroundCommand } from "@opengeni/sdk";
2
- import { useCallback } from "react";
2
+ import { useCallback, useEffect, useRef } from "react";
3
3
 
4
4
  import { useOpenGeni, type ClientOverride } from "../provider";
5
5
  import { usePolledValue } from "./internal";
@@ -23,28 +23,39 @@ export function useSessionBackgroundCommands(
23
23
  ): UseSessionBackgroundCommandsResult {
24
24
  const { client, workspaceId } = useOpenGeni(options);
25
25
  const enabled = (options.enabled ?? true) && Boolean(sessionId);
26
- const load = useCallback(async () => {
27
- if (!sessionId) return { commands: [] };
28
- if (!client.listSessionBackgroundCommands) {
29
- throw new Error("The configured OpenGeni client does not support background commands");
30
- }
31
- return await client.listSessionBackgroundCommands(workspaceId, sessionId);
32
- }, [client, workspaceId, sessionId]);
26
+ const load = useCallback(
27
+ async (signal?: AbortSignal) => {
28
+ if (!sessionId) return { commands: [] };
29
+ if (!client.listSessionBackgroundCommands) {
30
+ throw new Error("The configured OpenGeni client does not support background commands");
31
+ }
32
+ return await client.listSessionBackgroundCommands(workspaceId, sessionId, { signal });
33
+ },
34
+ [client, workspaceId, sessionId],
35
+ );
33
36
  const state = usePolledValue(load, {
34
37
  enabled,
35
38
  pollIntervalMs: options.pollIntervalMs,
36
39
  });
37
40
  const refresh = state.refresh;
41
+ const generation = useRef(0);
42
+ useEffect(() => {
43
+ generation.current += 1;
44
+ return () => {
45
+ generation.current += 1;
46
+ };
47
+ }, [client, workspaceId, sessionId, enabled]);
38
48
  const cancel = useCallback(
39
49
  async (commandId: string): Promise<void> => {
40
50
  if (!sessionId) return;
41
51
  if (!client.cancelSessionBackgroundCommand) {
42
52
  throw new Error("The configured OpenGeni client does not support background commands");
43
53
  }
54
+ const ticket = generation.current;
44
55
  await client.cancelSessionBackgroundCommand(workspaceId, sessionId, commandId);
45
- await refresh();
56
+ if (enabled && ticket === generation.current) await refresh();
46
57
  },
47
- [client, workspaceId, sessionId, refresh],
58
+ [client, workspaceId, sessionId, refresh, enabled],
48
59
  );
49
60
  return {
50
61
  commands: state.data?.commands ?? [],
@@ -77,7 +77,7 @@ export type UseSessionEventsResult = {
77
77
  // Keep every browser history read inside one database batch, including the
78
78
  // server's one-row continuation lookahead. A large total session must never
79
79
  // turn one lazy page into dozens of sequential database round trips.
80
- const SESSION_HISTORY_PAGE_SIZE = 255;
80
+ const SESSION_HISTORY_PAGE_SIZE = 1000;
81
81
  const INITIAL_FETCH_CAP = 1;
82
82
  const OLDER_GROUP_TARGET = 32;
83
83
  const OLDER_FETCH_CAP = 2;
@@ -193,6 +193,7 @@ export function useSessionEvents(
193
193
  const streamAbortRef = useRef<AbortController | null>(null);
194
194
  const streamKeyRef = useRef<string | null>(null);
195
195
  const generationRef = useRef(0);
196
+ const navigationGenerationRef = useRef(0);
196
197
  const eventWindowRef = useRef<BrowserSessionEventWindow>(EMPTY_EVENT_WINDOW);
197
198
  const sessionStatusRef = useRef<{
198
199
  sequence: number;
@@ -205,6 +206,21 @@ export function useSessionEvents(
205
206
  // new stream identity cannot expose the previous session's event log.
206
207
  const [stateStreamKey, setStateStreamKey] = useState(streamKey);
207
208
 
209
+ // Reopening SSE after a prepend must not cancel the next history page.
210
+ // Navigation belongs to the session/client lifetime, not the transport.
211
+ useEffect(() => {
212
+ navigationGenerationRef.current += 1;
213
+ loadingOlderRef.current = false;
214
+ loadingNewerRef.current = false;
215
+ loadingOldestRef.current = false;
216
+ setLoadingOlder(false);
217
+ setLoadingNewer(false);
218
+ setLoadingOldest(false);
219
+ return () => {
220
+ navigationGenerationRef.current += 1;
221
+ };
222
+ }, [client, workspaceId, sessionId, after, enabled, fullReplay]);
223
+
208
224
  useEffect(() => {
209
225
  // Reset the accumulated log only when the stream identity changes —
210
226
  // pausing via `enabled: false` keeps the timeline visible.
@@ -243,10 +259,6 @@ export function useSessionEvents(
243
259
  // only the newest dependency generation can mutate refs or React state.
244
260
  const generation = generationRef.current + 1;
245
261
  generationRef.current = generation;
246
- if (loadingOlderRef.current) {
247
- loadingOlderRef.current = false;
248
- setLoadingOlder(false);
249
- }
250
262
  if (!sessionId || !streamEnabled) {
251
263
  // A page that stayed hidden beyond the live-activity grace deliberately
252
264
  // closed its SSE connection. Replaying from the old cursor on return can
@@ -415,6 +427,16 @@ export function useSessionEvents(
415
427
  }
416
428
  const status = observeSessionStatus(window.events, sessionStatusRef);
417
429
  const retained = boundBrowserSessionEventWindow(window.events);
430
+ // A replacement tail retires requests against the discarded window.
431
+ // Ordinary SSE reconnects preserve navigation, but splicing an old
432
+ // page into this new tail could leave an inaccessible history gap.
433
+ navigationGenerationRef.current += 1;
434
+ loadingOlderRef.current = false;
435
+ loadingNewerRef.current = false;
436
+ loadingOldestRef.current = false;
437
+ setLoadingOlder(false);
438
+ setLoadingNewer(false);
439
+ setLoadingOldest(false);
418
440
  eventWindowRef.current = retained;
419
441
  oldestSequenceRef.current = retained.events[0]?.sequence ?? window.oldestSequence;
420
442
  newestSequenceRef.current =
@@ -543,7 +565,7 @@ export function useSessionEvents(
543
565
  setHasOlder(false);
544
566
  return false;
545
567
  }
546
- const generation = generationRef.current;
568
+ const generation = navigationGenerationRef.current;
547
569
  loadingOlderRef.current = true;
548
570
  setLoadingOlder(true);
549
571
  let published = false;
@@ -554,7 +576,7 @@ export function useSessionEvents(
554
576
  targetGroups: OLDER_GROUP_TARGET,
555
577
  maxFetches: OLDER_FETCH_CAP,
556
578
  });
557
- if (generationRef.current !== generation) {
579
+ if (navigationGenerationRef.current !== generation) {
558
580
  return false;
559
581
  }
560
582
  if (window.events.length === 0) {
@@ -635,7 +657,7 @@ export function useSessionEvents(
635
657
  published = true;
636
658
  return olderStillAvailable;
637
659
  } finally {
638
- if (!published) {
660
+ if (!published && navigationGenerationRef.current === generation) {
639
661
  loadingOlderRef.current = false;
640
662
  setLoadingOlder(false);
641
663
  }
@@ -648,7 +670,7 @@ export function useSessionEvents(
648
670
  if (!sessionId || navigationBusy() || !hasOlderRef.current) {
649
671
  return false;
650
672
  }
651
- const generation = generationRef.current;
673
+ const generation = navigationGenerationRef.current;
652
674
  loadingOldestRef.current = true;
653
675
  setLoadingOldest(true);
654
676
  let published = false;
@@ -659,7 +681,7 @@ export function useSessionEvents(
659
681
  targetGroups: OLDEST_GROUP_TARGET,
660
682
  maxFetches: OLDEST_FETCH_CAP,
661
683
  });
662
- if (generationRef.current !== generation) {
684
+ if (navigationGenerationRef.current !== generation) {
663
685
  return false;
664
686
  }
665
687
  if (window.events.length === 0) {
@@ -700,7 +722,7 @@ export function useSessionEvents(
700
722
  published = true;
701
723
  return newer;
702
724
  } finally {
703
- if (!published) {
725
+ if (!published && navigationGenerationRef.current === generation) {
704
726
  loadingOldestRef.current = false;
705
727
  setLoadingOldest(false);
706
728
  }
@@ -717,7 +739,7 @@ export function useSessionEvents(
717
739
  setHasNewer(false);
718
740
  return false;
719
741
  }
720
- const generation = generationRef.current;
742
+ const generation = navigationGenerationRef.current;
721
743
  loadingNewerRef.current = true;
722
744
  setLoadingNewer(true);
723
745
  let published = false;
@@ -728,7 +750,7 @@ export function useSessionEvents(
728
750
  targetGroups: NEWER_GROUP_TARGET,
729
751
  maxFetches: NEWER_FETCH_CAP,
730
752
  });
731
- if (generationRef.current !== generation) {
753
+ if (navigationGenerationRef.current !== generation) {
732
754
  return false;
733
755
  }
734
756
  if (window.events.length === 0) {
@@ -800,7 +822,7 @@ export function useSessionEvents(
800
822
  published = true;
801
823
  return newer;
802
824
  } finally {
803
- if (!published) {
825
+ if (!published && navigationGenerationRef.current === generation) {
804
826
  loadingNewerRef.current = false;
805
827
  setLoadingNewer(false);
806
828
  }
@@ -38,6 +38,10 @@ export function isTitleEvent(event: Pick<SessionEvent, "type">): boolean {
38
38
  return event.type === "session.title_set";
39
39
  }
40
40
 
41
+ function isSessionDetailEvent(event: Pick<SessionEvent, "type">): boolean {
42
+ return isTitleEvent(event) || event.type.startsWith("session.command.");
43
+ }
44
+
41
45
  /** Fetch one session (with optional polling), live-patching its title on `session.title_set`. */
42
46
  export function useSession(
43
47
  sessionId: string | null | undefined,
@@ -99,10 +103,27 @@ export function useSession(
99
103
  [base, override],
100
104
  );
101
105
 
106
+ const sharedEvents = options.events;
107
+
102
108
  // Live-patch the title on auto (agent) + cross-client (user/agent) renames so
103
109
  // the UI reflects the new title without polling or a full re-fetch.
104
110
  const onTitleEvent = useCallback(
105
111
  (event: SessionEvent) => {
112
+ // Reconciliation emits only the last matching member of a shared batch.
113
+ // A later title must not hide an earlier command activity change.
114
+ if (
115
+ sharedEvents?.some(
116
+ (item) =>
117
+ item.sessionId === sessionId &&
118
+ item.type.startsWith("session.command.") &&
119
+ item.sequence > (base?.lastSequence ?? -1),
120
+ )
121
+ ) {
122
+ void refresh();
123
+ } else if (event.type.startsWith("session.command.")) {
124
+ if (!base || event.sequence > base.lastSequence) void refresh();
125
+ return;
126
+ }
106
127
  // The fetched session row is the authoritative title projection through
107
128
  // lastSequence. Shared feeds may replay that historical tail after the
108
129
  // fetch; applying it would undo a row-only migration quarantine. Only an
@@ -122,9 +143,9 @@ export function useSession(
122
143
  return { ...next, title, titleSource: source };
123
144
  });
124
145
  },
125
- [base],
146
+ [base, refresh, sharedEvents, sessionId],
126
147
  );
127
- useSessionEventTrigger(client, workspaceId, sessionId, isTitleEvent, onTitleEvent, {
148
+ useSessionEventTrigger(client, workspaceId, sessionId, isSessionDetailEvent, onTitleEvent, {
128
149
  enabled,
129
150
  ...(options.events !== undefined ? { events: options.events } : {}),
130
151
  });