@opengeni/react 0.3.1 → 0.5.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 (42) hide show
  1. package/dist/index.d.ts +1055 -27
  2. package/dist/index.js +6993 -1954
  3. package/dist/index.js.map +1 -1
  4. package/package.json +65 -2
  5. package/src/client.ts +22 -0
  6. package/src/components/code-editor.tsx +398 -0
  7. package/src/components/desktop-viewer.tsx +647 -0
  8. package/src/components/diff-view.tsx +230 -0
  9. package/src/components/file-browser.tsx +838 -0
  10. package/src/components/fleet-tile.tsx +5 -0
  11. package/src/components/message-timeline.tsx +70 -196
  12. package/src/components/pierre-diff.tsx +140 -0
  13. package/src/components/pierre-file.tsx +142 -0
  14. package/src/components/sandbox-files.tsx +509 -0
  15. package/src/components/sandbox-terminal.tsx +425 -0
  16. package/src/components/workspace-dock.tsx +247 -0
  17. package/src/hooks/use-desktop-stream.ts +214 -0
  18. package/src/hooks/use-sandbox-files.ts +670 -0
  19. package/src/hooks/use-sandbox-git.ts +105 -0
  20. package/src/hooks/use-sandbox-terminal.ts +226 -0
  21. package/src/hooks/use-session-capabilities.ts +415 -0
  22. package/src/hooks/use-session.ts +80 -12
  23. package/src/hooks/use-terminal-stream.ts +207 -0
  24. package/src/index.ts +112 -3
  25. package/src/lib/cn.ts +20 -1
  26. package/src/lib/git-patch.ts +43 -0
  27. package/src/lib/use-theme-type.ts +40 -0
  28. package/src/lib/xterm-theme.ts +34 -0
  29. package/src/timeline/activity-rail.tsx +207 -0
  30. package/src/timeline/disclosure-context.tsx +34 -0
  31. package/src/timeline/index.ts +85 -0
  32. package/src/timeline/parsers.ts +253 -0
  33. package/src/{timeline.ts → timeline/projection.ts} +59 -134
  34. package/src/timeline/registry.ts +96 -0
  35. package/src/timeline/screenshot-lightbox.tsx +152 -0
  36. package/src/timeline/shared.tsx +481 -0
  37. package/src/timeline/tool-diff.tsx +91 -0
  38. package/src/timeline/tool-renderers.tsx +882 -0
  39. package/src/timeline/turn-summary.tsx +125 -0
  40. package/src/timeline/types.ts +131 -0
  41. package/src/types/external.d.ts +7 -0
  42. package/styles/index.css +72 -0
@@ -0,0 +1,415 @@
1
+ import {
2
+ OpenGeniApiError,
3
+ applyUrlRotation,
4
+ type SessionCapabilities,
5
+ type SessionEvent,
6
+ type StreamUrlRotatedPayload,
7
+ } from "@opengeni/sdk";
8
+ import { useCallback, useEffect, useRef, useState } from "react";
9
+ import { useOpenGeni, type ClientOverride } from "../provider";
10
+
11
+ export type SessionCapabilitiesState = "idle" | "negotiating" | "ready" | "cold" | "error";
12
+
13
+ export type UseSessionCapabilitiesOptions = ClientOverride & {
14
+ /**
15
+ * Live event log to fold `stream.url.rotated` from (usually
16
+ * `useSessionEvents().events`). When present the desktop socket stays fresh on
17
+ * a box rollover without a round-trip; stale-epoch rotations are dropped.
18
+ */
19
+ events?: SessionEvent[] | undefined;
20
+ /**
21
+ * Whether to acquire a viewer holder for the desktop pixel plane. Requires the
22
+ * un-redacted acknowledgment to have been recorded (else the attach 409s and
23
+ * the hook surfaces the consent requirement). Default false: read-only
24
+ * negotiation (no holder, no warm) — terminal/files/git work without it.
25
+ */
26
+ attachDesktop?: boolean | undefined;
27
+ /**
28
+ * Whether to acquire a viewer holder to warm the box for the REAL interactive
29
+ * terminal (the ttyd pty-ws plane). Symmetric with `attachDesktop` and shares
30
+ * the SAME viewer attach (one warm box serves both planes), but needs NO
31
+ * un-redacted acknowledgment — a shell is interactive by nature, and the gate
32
+ * is the scoped tunnel URL + stream token. Default false: the terminal stays on
33
+ * the read-only Channel-A firehose until the user opens/focuses it. The attach
34
+ * folds the minted `pty-ws` url+token into the `Terminal` cell.
35
+ */
36
+ attachTerminal?: boolean | undefined;
37
+ /**
38
+ * Whether to acquire a viewer holder purely to KEEP THE BOX WARM while the
39
+ * structured Files surface is open. Shares the SAME viewer attach as the
40
+ * desktop/terminal (one warm box, one holder) and — like the terminal — needs
41
+ * NO un-redacted acknowledgment: listing/reading/writing files is the ordinary
42
+ * Channel-A control plane, not the pixel plane. Default false: the Files tab
43
+ * negotiates read-only and each op pays the cold-box resume (~5s). When true,
44
+ * the holder warms the box once and heartbeats it, so subsequent fs ops are
45
+ * ~100ms instead of re-resuming the box on every list/write. It folds NO live
46
+ * URL (files ride the stateless HTTP plane) — it only refcounts liveness.
47
+ */
48
+ attachFiles?: boolean | undefined;
49
+ /** Hold off negotiating (e.g. the workbench panel is collapsed). Default true. */
50
+ enabled?: boolean | undefined;
51
+ /** Poll cadence (ms) while the lease is cold/warming. Default 1500. */
52
+ warmingPollMs?: number | undefined;
53
+ /**
54
+ * Give up waiting for `warm` after this long while polling (ms) and surface a
55
+ * stalled error with a manual `renegotiate`. Default 30000 (must agree with
56
+ * the lease warming TTL — I15). 0 disables the deadline.
57
+ */
58
+ warmingDeadlineMs?: number | undefined;
59
+ };
60
+
61
+ export type UseSessionCapabilitiesResult = {
62
+ /** The negotiated capability doc — the single source of UI truth. */
63
+ capabilities: SessionCapabilities | null;
64
+ state: SessionCapabilitiesState;
65
+ error: Error | null;
66
+ /**
67
+ * 409 from the desktop attach: the un-redacted (or shared) plane needs explicit
68
+ * acknowledgment before a viewer holder is granted. Drives the consent prompt.
69
+ */
70
+ acknowledgmentRequired: "unredacted" | "shared" | null;
71
+ /** 429 from the desktop attach: the per-session viewer cap is reached. */
72
+ viewerCapReached: boolean;
73
+ /** The viewer holder id minted on a desktop attach (for detach/heartbeat). */
74
+ viewerId: string | null;
75
+ /** Force a re-negotiation (after acknowledging, a resolution change, etc.). */
76
+ renegotiate: () => void;
77
+ };
78
+
79
+ /**
80
+ * Whether a desktop attach (POST /viewers) is worth attempting for this cell.
81
+ * The desktop is FEASIBLE — and thus worth warming — when it already has a live
82
+ * transport, OR when the only thing missing is a warm box (a cold/un-provisioned
83
+ * lease). The handshake never warms a box, so a feasible-but-cold cell reports
84
+ * transport:null with a transient reason; the viewer attach is exactly what warms
85
+ * it. A genuinely-unsupported desktop (backend/os/policy/headless) is never
86
+ * attachable — attaching would 409/403/no-op without ever producing a stream.
87
+ */
88
+ function desktopAttachable(cell: SessionCapabilities["DesktopStream"]): boolean {
89
+ if (cell.transport !== null) return true;
90
+ // transport === null: attach only when the reason is a transient cold state.
91
+ return cell.reason === "lease_cold" || cell.reason === "not_provisioned" || cell.reason === null;
92
+ }
93
+
94
+ /**
95
+ * Whether warming the box for the interactive terminal (pty-ws) is worth it.
96
+ * The Terminal cell is ALWAYS feasible when the backend advertises a transport at
97
+ * all (`sse-events` on a cold box, `pty-ws` once warm) — only a genuinely
98
+ * terminal-less backend reports transport:null with a hard reason. The attach is
99
+ * what flips `sse-events` → `pty-ws` by warming the box and minting the ttyd
100
+ * tunnel URL, so we attach whenever the terminal is not hard-unavailable.
101
+ */
102
+ function terminalAttachable(cell: SessionCapabilities["Terminal"]): boolean {
103
+ if (cell.transport === null) {
104
+ // No terminal at all unless the only blocker is a transient cold state.
105
+ return cell.reason === "lease_cold" || cell.reason === "not_provisioned" || cell.reason === null;
106
+ }
107
+ // Already pty-ws (warm) is fine to (re)attach; sse-events is the cold state the
108
+ // attach upgrades. Either way it's attachable.
109
+ return true;
110
+ }
111
+
112
+ /** Read `stream.url.rotated` payloads off the live event log, newest last. */
113
+ function rotationsFrom(events: SessionEvent[]): StreamUrlRotatedPayload[] {
114
+ const out: StreamUrlRotatedPayload[] = [];
115
+ for (const event of events) {
116
+ if (event.type === "stream.url.rotated" && event.payload && typeof event.payload === "object") {
117
+ out.push(event.payload as StreamUrlRotatedPayload);
118
+ }
119
+ }
120
+ return out;
121
+ }
122
+
123
+ /**
124
+ * The capability-negotiation hook. Discovers what THIS session+backend+OS
125
+ * supports (FileSystem/Terminal/Git always-ish; DesktopStream/Recording
126
+ * sometimes), drives capability-gated rendering, and — when `attachDesktop` —
127
+ * holds a viewer lease + heartbeats it so the box stays warm while watched.
128
+ *
129
+ * Degradation is a value, never a crash: an unsupported surface comes back
130
+ * `available:false`/`transport:null` + a `reason`; the components render the
131
+ * reason-aware empty state. 409 (consent) and 429 (viewer cap) are surfaced as
132
+ * typed signals, not thrown.
133
+ */
134
+ export function useSessionCapabilities(
135
+ sessionId: string | null | undefined,
136
+ options: UseSessionCapabilitiesOptions = {},
137
+ ): UseSessionCapabilitiesResult {
138
+ const { client, workspaceId } = useOpenGeni(options);
139
+ const enabled = (options.enabled ?? true) && Boolean(sessionId);
140
+ const attachDesktop = options.attachDesktop ?? false;
141
+ const attachTerminal = options.attachTerminal ?? false;
142
+ const attachFiles = options.attachFiles ?? false;
143
+ const warmingPollMs = options.warmingPollMs ?? 1500;
144
+ const warmingDeadlineMs = options.warmingDeadlineMs ?? 30_000;
145
+
146
+ const [capabilities, setCapabilities] = useState<SessionCapabilities | null>(null);
147
+ const [state, setState] = useState<SessionCapabilitiesState>("idle");
148
+ const [error, setError] = useState<Error | null>(null);
149
+ const [acknowledgmentRequired, setAcknowledgmentRequired] = useState<"unredacted" | "shared" | null>(null);
150
+ const [viewerCapReached, setViewerCapReached] = useState(false);
151
+ const [viewerId, setViewerId] = useState<string | null>(null);
152
+ // Bumped to force a fresh negotiation cycle.
153
+ const [nonce, setNonce] = useState(0);
154
+
155
+ // The epoch the client has settled on — used to fence stale rotations folded
156
+ // from the event log, and echoed on heartbeats.
157
+ const epochRef = useRef(0);
158
+ const viewerIdRef = useRef<string | null>(null);
159
+
160
+ const renegotiate = useCallback(() => {
161
+ setNonce((n) => n + 1);
162
+ }, []);
163
+
164
+ // ── Negotiation + viewer lifecycle ──────────────────────────────────────────
165
+ useEffect(() => {
166
+ if (!enabled || !sessionId) {
167
+ setState("idle");
168
+ setCapabilities(null);
169
+ return;
170
+ }
171
+ let cancelled = false;
172
+ let pollTimer: ReturnType<typeof setTimeout> | null = null;
173
+ let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
174
+ let localViewerId: string | null = null;
175
+ const startedAt = Date.now();
176
+
177
+ const clearTimers = () => {
178
+ if (pollTimer !== null) clearTimeout(pollTimer);
179
+ if (heartbeatTimer !== null) clearInterval(heartbeatTimer);
180
+ pollTimer = null;
181
+ heartbeatTimer = null;
182
+ };
183
+
184
+ const settle = (caps: SessionCapabilities) => {
185
+ if (cancelled) return;
186
+ epochRef.current = caps.leaseEpoch;
187
+ setCapabilities(caps);
188
+ setError(null);
189
+ };
190
+
191
+ const startHeartbeat = (caps: SessionCapabilities) => {
192
+ if (!localViewerId || heartbeatTimer !== null) return;
193
+ const intervalMs = caps.viewerHeartbeatIntervalMs > 0 ? caps.viewerHeartbeatIntervalMs : 30_000;
194
+ heartbeatTimer = setInterval(() => {
195
+ if (cancelled || !localViewerId) return;
196
+ void client
197
+ .heartbeatViewer(workspaceId, sessionId, localViewerId, { leaseEpoch: epochRef.current })
198
+ .then((res) => {
199
+ // alive:false ⇒ the holder was reaped or the epoch moved under us;
200
+ // re-negotiate to re-acquire against the new owner.
201
+ if (!res.alive && !cancelled) {
202
+ renegotiate();
203
+ }
204
+ })
205
+ .catch((cause) => {
206
+ if (cancelled) return;
207
+ if (cause instanceof OpenGeniApiError && (cause.status === 409 || cause.status === 410)) {
208
+ renegotiate();
209
+ }
210
+ });
211
+ }, intervalMs);
212
+ };
213
+
214
+ const pollUntilWarm = () => {
215
+ pollTimer = setTimeout(() => {
216
+ if (cancelled) return;
217
+ void client
218
+ .getStreamCapabilities(workspaceId, sessionId)
219
+ .then((caps) => {
220
+ if (cancelled) return;
221
+ settle(caps);
222
+ if (caps.liveness === "warm" || caps.liveness === "draining") {
223
+ setState("ready");
224
+ return;
225
+ }
226
+ if (warmingDeadlineMs > 0 && Date.now() - startedAt > warmingDeadlineMs) {
227
+ setState("error");
228
+ setError(new Error("sandbox did not warm in time — retry to re-negotiate"));
229
+ return;
230
+ }
231
+ pollUntilWarm();
232
+ })
233
+ .catch((cause) => {
234
+ if (!cancelled) {
235
+ setState("error");
236
+ setError(cause instanceof Error ? cause : new Error(String(cause)));
237
+ }
238
+ });
239
+ }, warmingPollMs);
240
+ };
241
+
242
+ void (async () => {
243
+ setState("negotiating");
244
+ setAcknowledgmentRequired(null);
245
+ setViewerCapReached(false);
246
+ try {
247
+ const caps = await client.getStreamCapabilities(workspaceId, sessionId);
248
+ if (cancelled) return;
249
+ settle(caps);
250
+
251
+ // Optional viewer attach: acquire a viewer holder (warms a cold box). ONE
252
+ // attach serves BOTH live planes — the desktop pixel stream AND the
253
+ // interactive terminal (ttyd pty-ws) ride the same warm box and the same
254
+ // holder; the response folds the minted address for each requested plane.
255
+ // The consent gate (409) and the viewer cap (429) surface as typed signals
256
+ // rather than throwing — the tabs degrade gracefully.
257
+ //
258
+ // KEY: the handshake (getStreamCapabilities) NEVER spins up a cold box, so
259
+ // on a cold/warming lease the desktop cell comes back transport:null and
260
+ // the terminal cell comes back `sse-events` (read-only firehose) with a
261
+ // transient reason (`lease_cold`/`not_provisioned`). Gating the attach on
262
+ // `transport` therefore dead-ends both surfaces (forever "Starting…"): they
263
+ // never warm because they never attach, and never attach because they
264
+ // aren't warm. We attach whenever a REQUESTED plane is FEASIBLE — transport
265
+ // already live OR cold-but-feasible — and let POST /viewers warm the box and
266
+ // mint the URLs. Only a genuinely-unsupported reason suppresses the attach.
267
+ const wantDesktopAttach = attachDesktop && desktopAttachable(caps.DesktopStream);
268
+ const wantTerminalAttach = attachTerminal && terminalAttachable(caps.Terminal);
269
+ // The Files surface warms the box for fast Channel-A ops. It folds no live
270
+ // URL (files are stateless HTTP) — the attach is purely a liveness refcount,
271
+ // so it's wanted whenever the FileSystem capability is advertised at all.
272
+ const wantFilesAttach = attachFiles && caps.FileSystem.available;
273
+ if (wantDesktopAttach || wantTerminalAttach || wantFilesAttach) {
274
+ try {
275
+ // Declare WHICH plane we're attaching for. `desktop:true` opts into the
276
+ // un-redacted pixel plane (which carries the consent gate); a
277
+ // terminal-only attach (`desktop:false`) warms the box + mints the
278
+ // pty-ws terminal cell WITHOUT tripping the desktop consent 409.
279
+ const holder = await client.attachViewer(workspaceId, sessionId, { desktop: wantDesktopAttach });
280
+ if (cancelled) return;
281
+ localViewerId = holder.viewerId;
282
+ viewerIdRef.current = holder.viewerId;
283
+ setViewerId(holder.viewerId);
284
+ epochRef.current = holder.leaseEpoch;
285
+ // Fold the freshly-minted live address(es) into the doc the components
286
+ // read. Desktop fields fold only when a desktop attach was wanted;
287
+ // terminal fields fold the minted ttyd pty-ws url+token when present.
288
+ setCapabilities((prev) =>
289
+ prev
290
+ ? {
291
+ ...prev,
292
+ liveness: holder.liveness,
293
+ leaseEpoch: holder.leaseEpoch,
294
+ viewerHeartbeatIntervalMs: holder.viewerHeartbeatIntervalMs,
295
+ DesktopStream: wantDesktopAttach
296
+ ? {
297
+ ...prev.DesktopStream,
298
+ transport: holder.transport ?? prev.DesktopStream.transport,
299
+ client: holder.client ?? prev.DesktopStream.client,
300
+ url: holder.dataPlaneUrl ?? prev.DesktopStream.url,
301
+ token: holder.streamToken ?? prev.DesktopStream.token,
302
+ expiresAt: holder.streamExpiresAt ?? prev.DesktopStream.expiresAt,
303
+ resolution: holder.resolution ?? prev.DesktopStream.resolution,
304
+ }
305
+ : prev.DesktopStream,
306
+ Terminal:
307
+ holder.terminalUrl && holder.terminalTransport
308
+ ? {
309
+ ...prev.Terminal,
310
+ transport: holder.terminalTransport,
311
+ url: holder.terminalUrl,
312
+ token: holder.terminalToken ?? prev.Terminal.token,
313
+ // A live pty-ws means the box is pty-capable for real.
314
+ ptyCapable: true,
315
+ reason: null,
316
+ }
317
+ : prev.Terminal,
318
+ }
319
+ : prev,
320
+ );
321
+ } catch (cause) {
322
+ if (cancelled) return;
323
+ if (cause instanceof OpenGeniApiError) {
324
+ if (cause.status === 409) {
325
+ // The un-redacted/shared consent gate is a DESKTOP requirement. A
326
+ // terminal-only warm attach needs no consent, so a 409 there is not
327
+ // a consent prompt — the terminal just stays on the read-only
328
+ // firehose. Only raise the consent requirement when the desktop was
329
+ // the (or a) reason we attached.
330
+ if (wantDesktopAttach) {
331
+ setAcknowledgmentRequired(
332
+ cause.message.includes("shared_acknowledgment") ? "shared" : "unredacted",
333
+ );
334
+ }
335
+ } else if (cause.status === 429) {
336
+ setViewerCapReached(true);
337
+ } else if (cause.status === 403) {
338
+ setState("error");
339
+ setError(cause);
340
+ return;
341
+ }
342
+ // 409/429 are recoverable: structured surfaces still negotiated;
343
+ // keep going to set ready/cold below.
344
+ } else {
345
+ throw cause;
346
+ }
347
+ }
348
+ }
349
+
350
+ if (caps.liveness === "warm" || caps.liveness === "draining" || localViewerId) {
351
+ setState("ready");
352
+ startHeartbeat(caps);
353
+ } else {
354
+ setState("cold");
355
+ pollUntilWarm();
356
+ }
357
+ } catch (cause) {
358
+ if (cancelled) return;
359
+ if (cause instanceof OpenGeniApiError && cause.status === 403) {
360
+ setState("error");
361
+ setError(new Error("not permitted to view this session's sandbox"));
362
+ return;
363
+ }
364
+ setState("error");
365
+ setError(cause instanceof Error ? cause : new Error(String(cause)));
366
+ }
367
+ })();
368
+
369
+ return () => {
370
+ cancelled = true;
371
+ clearTimers();
372
+ // Fire-and-forget detach (idempotent delete-my-row). Capture the id so a
373
+ // re-render/unmount race still releases the right holder.
374
+ const releaseId = localViewerId ?? viewerIdRef.current;
375
+ if (releaseId) {
376
+ void client.detachViewer(workspaceId, sessionId, releaseId).catch(() => {});
377
+ }
378
+ };
379
+ // eslint-disable-next-line react-hooks/exhaustive-deps
380
+ }, [client, workspaceId, sessionId, enabled, attachDesktop, attachTerminal, attachFiles, warmingPollMs, warmingDeadlineMs, nonce]);
381
+
382
+ // ── Fold stream.url.rotated from the live event log (no round trip) ──────────
383
+ const events = options.events;
384
+ useEffect(() => {
385
+ if (!events || events.length === 0) return;
386
+ setCapabilities((prev) => {
387
+ if (!prev || !prev.DesktopStream.url) return prev;
388
+ let next = prev.DesktopStream;
389
+ let changed = false;
390
+ for (const rotation of rotationsFrom(events)) {
391
+ // Only this viewer's rotations (others' are filtered by viewerId when set).
392
+ if (rotation.viewerId && viewerIdRef.current && rotation.viewerId !== viewerIdRef.current) {
393
+ continue;
394
+ }
395
+ const applied = applyUrlRotation(next, rotation, epochRef.current);
396
+ if (applied) {
397
+ next = { ...next, url: applied.url, token: applied.token, expiresAt: applied.expiresAt };
398
+ epochRef.current = Math.max(epochRef.current, rotation.leaseEpoch);
399
+ changed = true;
400
+ }
401
+ }
402
+ return changed ? { ...prev, DesktopStream: next, leaseEpoch: epochRef.current } : prev;
403
+ });
404
+ }, [events]);
405
+
406
+ return {
407
+ capabilities,
408
+ state,
409
+ error,
410
+ acknowledgmentRequired,
411
+ viewerCapReached,
412
+ viewerId,
413
+ renegotiate,
414
+ };
415
+ }
@@ -1,33 +1,101 @@
1
- import type { Session } from "@opengeni/sdk";
2
- import { useCallback } from "react";
1
+ import type { Session, SessionEvent } from "@opengeni/sdk";
2
+ import { useCallback, useState } from "react";
3
3
  import { useOpenGeni, type ClientOverride } from "../provider";
4
- import { usePolledValue } from "./internal";
4
+ import { useMutationRunner, usePolledValue, useSessionEventTrigger, type SessionEventFeedOptions } from "./internal";
5
5
 
6
- export type UseSessionOptions = ClientOverride & {
7
- /** Re-fetch on an interval (ms). Off by default — pair with `useSessionEvents` for live status. */
8
- pollIntervalMs?: number | undefined;
9
- enabled?: boolean | undefined;
10
- };
6
+ export type UseSessionOptions = ClientOverride &
7
+ SessionEventFeedOptions & {
8
+ /** Re-fetch on an interval (ms). Off by default — pair with `useSessionEvents` for live status. */
9
+ pollIntervalMs?: number | undefined;
10
+ };
11
11
 
12
12
  export type UseSessionResult = {
13
13
  session: Session | null;
14
14
  loading: boolean;
15
15
  error: Error | null;
16
16
  refresh: () => Promise<void>;
17
+ /** Manually rename the session (PATCH, source='user'). Returns the updated session, or null on failure. */
18
+ updateTitle: (title: string) => Promise<Session | null>;
19
+ /** True while a rename is in flight. */
20
+ updating: boolean;
21
+ mutationError: Error | null;
22
+ clearMutationError: () => void;
17
23
  };
18
24
 
19
- /** Fetch one session (with optional polling). */
25
+ /** Event types that change the session title (auto + cross-client renames). */
26
+ export function isTitleEvent(event: Pick<SessionEvent, "type">): boolean {
27
+ return event.type === "session.title_set";
28
+ }
29
+
30
+ /** Fetch one session (with optional polling), live-patching its title on `session.title_set`. */
20
31
  export function useSession(sessionId: string | null | undefined, options: UseSessionOptions = {}): UseSessionResult {
21
32
  const { client, workspaceId } = useOpenGeni(options);
33
+ const enabled = (options.enabled ?? true) && Boolean(sessionId);
34
+ const [override, setOverride] = useState<Session | null>(null);
35
+ const mutation = useMutationRunner();
22
36
  const load = useCallback(async () => {
23
37
  if (!sessionId) {
24
38
  return null;
25
39
  }
26
- return await client.getSession(workspaceId, sessionId);
40
+ const fetched = await client.getSession(workspaceId, sessionId);
41
+ // A fresh server read supersedes any optimistic/event-driven override.
42
+ setOverride(null);
43
+ return fetched;
27
44
  }, [client, workspaceId, sessionId]);
28
45
  const state = usePolledValue(load, {
29
46
  pollIntervalMs: options.pollIntervalMs,
30
- enabled: (options.enabled ?? true) && Boolean(sessionId),
47
+ enabled,
31
48
  });
32
- return { session: state.data ?? null, loading: state.loading, error: state.error, refresh: state.refresh };
49
+
50
+ const base = state.data ?? null;
51
+ // The override only ever carries title/titleSource patches; it is reset on
52
+ // every fresh load so it can never go stale against the server snapshot.
53
+ const session = base && override && override.id === base.id ? { ...base, title: override.title, titleSource: override.titleSource } : base;
54
+
55
+ // Live-patch the title on auto (agent) + cross-client (user/agent) renames so
56
+ // the UI reflects the new title without polling or a full re-fetch.
57
+ const onTitleEvent = useCallback((event: SessionEvent) => {
58
+ const payload = (event.payload ?? {}) as { title?: unknown; source?: unknown };
59
+ const title = payload.title;
60
+ if (typeof title !== "string") {
61
+ return;
62
+ }
63
+ const source: "user" | "agent" | null = payload.source === "user" || payload.source === "agent" ? payload.source : null;
64
+ setOverride((current): Session | null => {
65
+ const next = current ?? base;
66
+ if (!next) {
67
+ return current;
68
+ }
69
+ return { ...next, title, titleSource: source };
70
+ });
71
+ }, [base]);
72
+ useSessionEventTrigger(client, workspaceId, sessionId, isTitleEvent, onTitleEvent, {
73
+ enabled,
74
+ ...(options.events !== undefined ? { events: options.events } : {}),
75
+ });
76
+
77
+ const updateTitle = useCallback(
78
+ async (title: string): Promise<Session | null> => {
79
+ if (!sessionId) {
80
+ return null;
81
+ }
82
+ const result = await mutation.run(() => client.updateSession(workspaceId, sessionId, { title }));
83
+ if (result) {
84
+ setOverride(result);
85
+ }
86
+ return result;
87
+ },
88
+ [client, workspaceId, sessionId, mutation.run],
89
+ );
90
+
91
+ return {
92
+ session,
93
+ loading: state.loading,
94
+ error: state.error,
95
+ refresh: state.refresh,
96
+ updateTitle,
97
+ updating: mutation.mutating,
98
+ mutationError: mutation.mutationError,
99
+ clearMutationError: mutation.clearMutationError,
100
+ };
33
101
  }