@opengeni/react 2.5.0-canary.2 → 3.1.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 (69) hide show
  1. package/README.md +15 -0
  2. package/dist/accounts.d.ts +64 -0
  3. package/dist/accounts.js +746 -0
  4. package/dist/accounts.js.map +1 -0
  5. package/dist/{browser-viewer-GXUICIAG.js → browser-viewer-AEFHUYZK.js} +3 -3
  6. package/dist/{chunk-MXJGO7LE.js → chunk-D3UIAJKY.js} +52 -2
  7. package/dist/chunk-D3UIAJKY.js.map +1 -0
  8. package/dist/{chunk-QGBJ6GHG.js → chunk-ELIYWBZR.js} +4 -4
  9. package/dist/{chunk-6H6S5V4Z.js → chunk-HQVCKCLG.js} +758 -871
  10. package/dist/chunk-HQVCKCLG.js.map +1 -0
  11. package/dist/{chunk-T7VAYG2I.js → chunk-MZ2G2XBA.js} +54 -2
  12. package/dist/chunk-MZ2G2XBA.js.map +1 -0
  13. package/dist/{chunk-X4JAFD5R.js → chunk-OAM4WAAM.js} +4 -4
  14. package/dist/{chunk-ZUT5QSQB.js → chunk-RGALCTWO.js} +57 -17
  15. package/dist/{chunk-ZUT5QSQB.js.map → chunk-RGALCTWO.js.map} +1 -1
  16. package/dist/chunk-YFKJ7VZH.js +268 -0
  17. package/dist/chunk-YFKJ7VZH.js.map +1 -0
  18. package/dist/{chunk-ATQJEWCA.js → chunk-YJFGAFBD.js} +124 -50
  19. package/dist/chunk-YJFGAFBD.js.map +1 -0
  20. package/dist/components/composer.d.ts +8 -0
  21. package/dist/components/file-browser.d.ts +4 -1
  22. package/dist/components/sandbox-files.d.ts +4 -1
  23. package/dist/components/sandbox-workspace.d.ts +5 -0
  24. package/dist/composer.js +3 -3
  25. package/dist/{computer-viewer-ZOP4Q7A7.js → computer-viewer-FWEZISW4.js} +3 -3
  26. package/dist/embedded-session-client.d.ts +23 -0
  27. package/dist/file-node-visibility.d.ts +15 -0
  28. package/dist/hooks/use-file-attachments.d.ts +9 -2
  29. package/dist/human-input.d.ts +3 -1
  30. package/dist/index.d.ts +5 -1
  31. package/dist/index.js +139 -53
  32. package/dist/index.js.map +1 -1
  33. package/dist/interaction.js +3 -3
  34. package/dist/session-ui.js +4 -4
  35. package/dist/session.d.ts +2 -0
  36. package/dist/session.js +7 -5
  37. package/dist/timeline/index.d.ts +1 -0
  38. package/dist/timeline/screenshot-lightbox.d.ts +16 -1
  39. package/package.json +6 -2
  40. package/src/accounts.tsx +970 -0
  41. package/src/components/chat-composer.tsx +88 -84
  42. package/src/components/composer.tsx +53 -11
  43. package/src/components/file-browser.tsx +33 -20
  44. package/src/components/human-input-surface.tsx +10 -6
  45. package/src/components/message-timeline.tsx +37 -9
  46. package/src/components/sandbox-files.tsx +42 -2
  47. package/src/components/sandbox-workspace.tsx +10 -0
  48. package/src/embedded-session-client.ts +104 -0
  49. package/src/file-node-visibility.ts +66 -0
  50. package/src/hooks/use-composer.ts +64 -2
  51. package/src/hooks/use-file-attachments.ts +38 -6
  52. package/src/hooks/use-human-input.ts +31 -4
  53. package/src/human-input.ts +12 -1
  54. package/src/index.ts +12 -0
  55. package/src/session.ts +5 -0
  56. package/src/timeline/index.ts +1 -0
  57. package/src/timeline/screenshot-lightbox.tsx +93 -29
  58. package/styles/compiled.css +11 -1
  59. package/styles/tokens.css +3 -1
  60. package/dist/chunk-6H6S5V4Z.js.map +0 -1
  61. package/dist/chunk-ATQJEWCA.js.map +0 -1
  62. package/dist/chunk-FK6VG4LL.js +0 -90
  63. package/dist/chunk-FK6VG4LL.js.map +0 -1
  64. package/dist/chunk-MXJGO7LE.js.map +0 -1
  65. package/dist/chunk-T7VAYG2I.js.map +0 -1
  66. /package/dist/{browser-viewer-GXUICIAG.js.map → browser-viewer-AEFHUYZK.js.map} +0 -0
  67. /package/dist/{chunk-QGBJ6GHG.js.map → chunk-ELIYWBZR.js.map} +0 -0
  68. /package/dist/{chunk-X4JAFD5R.js.map → chunk-OAM4WAAM.js.map} +0 -0
  69. /package/dist/{computer-viewer-ZOP4Q7A7.js.map → computer-viewer-FWEZISW4.js.map} +0 -0
@@ -10,6 +10,7 @@ import {
10
10
  type UseSandboxFilesResult,
11
11
  } from "../hooks/use-sandbox-files";
12
12
  import type { UseSandboxGitResult } from "../hooks/use-sandbox-git";
13
+ import { filePathVisibility, type FileNodeVisibilityPredicate } from "../file-node-visibility";
13
14
  import { CodeEditor } from "./code-editor";
14
15
  import { FileBrowser } from "./file-browser";
15
16
  import { PierreFile } from "./pierre-file";
@@ -37,6 +38,8 @@ export type SandboxFilesProps = {
37
38
  * surface the ordinary file-view error instead of changing files silently. */
38
39
  initialSelectedPath?: string | null | undefined;
39
40
  onSelectedPathChange?: ((path: string | null) => void) | undefined;
41
+ /** Presentation-only node filter shared with the tree and selected-file view. */
42
+ isNodeVisible?: FileNodeVisibilityPredicate | undefined;
40
43
  /** A deliberate file-open path routed here by the parent workspace. */
41
44
  requestedPath?: string | undefined;
42
45
  /** 1-based line to reveal after `requestedPath` opens. */
@@ -76,6 +79,7 @@ export function SandboxFiles({
76
79
  onEditIntent,
77
80
  initialSelectedPath,
78
81
  onSelectedPathChange,
82
+ isNodeVisible,
79
83
  requestedPath,
80
84
  requestedLine,
81
85
  requestedPathRequestId,
@@ -118,6 +122,19 @@ export function SandboxFiles({
118
122
  setTreeRevealRequest(null);
119
123
  return;
120
124
  }
125
+ if (isNodeVisible && files.loading && files.tree.length === 0) return;
126
+ if (isNodeVisible && !files.loading && files.tree.length === 0) {
127
+ handledRequestRef.current = requestKey;
128
+ pendingRequestRef.current = null;
129
+ setTreeRevealRequest(null);
130
+ return;
131
+ }
132
+ if (filePathVisibility(files.tree, requestedPath, isNodeVisible) === "hidden") {
133
+ handledRequestRef.current = requestKey;
134
+ pendingRequestRef.current = null;
135
+ setTreeRevealRequest(null);
136
+ return;
137
+ }
121
138
  handledRequestRef.current = requestKey;
122
139
  pendingRequestRef.current = null;
123
140
  setSelected(requestedPath);
@@ -126,7 +143,16 @@ export function SandboxFiles({
126
143
  setFocusLine(requestedLine != null && requestedLine > 0 ? requestedLine : null);
127
144
  setTreeRevealRequest({ path: requestedPath, requestId: requestKey });
128
145
  setViewReloadRevision((revision) => revision + 1);
129
- }, [onSelectedPathChange, requestKey, requestedLine, requestedPath, requestedPathReady]);
146
+ }, [
147
+ files.loading,
148
+ files.tree,
149
+ isNodeVisible,
150
+ onSelectedPathChange,
151
+ requestKey,
152
+ requestedLine,
153
+ requestedPath,
154
+ requestedPathReady,
155
+ ]);
130
156
 
131
157
  // Side-by-side (tree left, viewer right) once the surface is wide enough;
132
158
  // stacked (tree over viewer) on a narrow dock. Tracked off the container so it
@@ -165,7 +191,20 @@ export function SandboxFiles({
165
191
  // The selected tree file opens in the viewer (read-only) or the editor (when
166
192
  // writable). Nothing is auto-selected — the pane waits for a tree click, so the
167
193
  // Files tab opens as a calm browser, not a diff.
168
- const viewPath = selected;
194
+ const selectedVisibility = selected
195
+ ? filePathVisibility(files.tree, selected, isNodeVisible)
196
+ : "unknown";
197
+ const visibilityPending = Boolean(isNodeVisible && files.loading && files.tree.length === 0);
198
+ const viewPath = selectedVisibility === "hidden" || visibilityPending ? null : selected;
199
+ useEffect(() => {
200
+ if (selected === null || selectedVisibility !== "hidden") return;
201
+ setSelected(null);
202
+ onSelectedPathChange?.(null);
203
+ setEditMode(false);
204
+ setFocusLine(null);
205
+ setTreeRevealRequest(null);
206
+ setLiveRequestedPath(null);
207
+ }, [onSelectedPathChange, selected, selectedVisibility]);
169
208
  const fileView = useFileView(
170
209
  viewPath,
171
210
  files.readFile,
@@ -297,6 +336,7 @@ export function SandboxFiles({
297
336
  >
298
337
  <FileBrowser
299
338
  result={files}
339
+ isNodeVisible={isNodeVisible}
300
340
  selectedPath={selected ?? undefined}
301
341
  {...(treeRevealRequest
302
342
  ? {
@@ -45,6 +45,7 @@ import { xtermThemeFromTokens } from "../lib/xterm-theme";
45
45
  import { sandboxAcceptsLiveIo } from "../lib/sandbox-liveness";
46
46
  import type { BrowserFrameWebSocketFactory } from "../hooks/use-browser-frame-stream";
47
47
  import type { ComputerFrameWebSocketFactory } from "../hooks/use-computer-frame-stream";
48
+ import type { FileNodeVisibilityPredicate } from "../file-node-visibility";
48
49
  import { useSessionCapabilities } from "../hooks/use-session-capabilities";
49
50
  import { useSandboxFiles } from "../hooks/use-sandbox-files";
50
51
  import {
@@ -301,6 +302,8 @@ export type UseSandboxWorkspaceTabsOptions = ClientOverride & {
301
302
  /** Restore and report navigation inside the built-in workbench surfaces. */
302
303
  initialFilePath?: string | null | undefined;
303
304
  onFilePathChange?: ((path: string | null) => void) | undefined;
305
+ /** Presentation-only filter for Files tree nodes and selected-file viewing. */
306
+ isFileNodeVisible?: FileNodeVisibilityPredicate | undefined;
304
307
  initialBrowserSessionId?: string | null | undefined;
305
308
  onBrowserSessionIdChange?: ((browserSessionId: string | null) => void) | undefined;
306
309
  initialComputerSessionId?: string | null | undefined;
@@ -361,6 +364,7 @@ export function useSandboxWorkspaceTabs(
361
364
  onOpenFile,
362
365
  initialFilePath,
363
366
  onFilePathChange,
367
+ isFileNodeVisible,
364
368
  initialBrowserSessionId,
365
369
  onBrowserSessionIdChange,
366
370
  initialComputerSessionId,
@@ -752,6 +756,7 @@ export function useSandboxWorkspaceTabs(
752
756
  key={sessionId}
753
757
  files={files}
754
758
  git={git}
759
+ isNodeVisible={isFileNodeVisible}
755
760
  fileSystemAvailable={fileSystemOn || captureAvailable}
756
761
  editable={
757
762
  filesEditable && files.source === "live" && files.error === null && !files.loading
@@ -900,6 +905,7 @@ export function useSandboxWorkspaceTabs(
900
905
  onOpenFile,
901
906
  initialFilePath,
902
907
  onFilePathChange,
908
+ isFileNodeVisible,
903
909
  initialBrowserSessionId,
904
910
  onBrowserSessionIdChange,
905
911
  initialComputerSessionId,
@@ -965,6 +971,8 @@ export type SandboxWorkspaceProps = ClientOverride & {
965
971
  onActiveTabChange?: ((activeTab: string) => void) | undefined;
966
972
  initialFilePath?: string | null | undefined;
967
973
  onFilePathChange?: ((path: string | null) => void) | undefined;
974
+ /** Presentation-only filter for Files tree nodes and selected-file viewing. */
975
+ isFileNodeVisible?: FileNodeVisibilityPredicate | undefined;
968
976
  /**
969
977
  * Host request to open a workspace file (and optional line) in Files. A new
970
978
  * `requestId` re-opens even the same path.
@@ -1022,6 +1030,7 @@ export function SandboxWorkspace(props: SandboxWorkspaceProps): ReactNode {
1022
1030
  onActiveTabChange,
1023
1031
  initialFilePath,
1024
1032
  onFilePathChange,
1033
+ isFileNodeVisible,
1025
1034
  openFileRequest,
1026
1035
  initialBrowserSessionId,
1027
1036
  onBrowserSessionIdChange,
@@ -1117,6 +1126,7 @@ export function SandboxWorkspace(props: SandboxWorkspaceProps): ReactNode {
1117
1126
  ...(browserExtensionSetupUrl ? { browserExtensionSetupUrl } : {}),
1118
1127
  initialFilePath,
1119
1128
  onFilePathChange,
1129
+ isFileNodeVisible,
1120
1130
  initialBrowserSessionId,
1121
1131
  onBrowserSessionIdChange,
1122
1132
  initialComputerSessionId,
@@ -0,0 +1,104 @@
1
+ import type { ComposerDraft } from "@opengeni/sdk";
2
+ import type { EmbeddedSessionClientLike } from "./client";
3
+
4
+ export type ComposerDraftMapContext = {
5
+ operation: "read" | "save" | "submit";
6
+ workspaceId: string;
7
+ sessionId: string;
8
+ };
9
+
10
+ export type CreateEmbeddedSessionClientOptions = {
11
+ /**
12
+ * Explicit host-owned method replacements. Methods inherited from `base`
13
+ * retain `base` as their receiver; overrides retain this object as theirs.
14
+ */
15
+ overrides?: Partial<EmbeddedSessionClientLike> | undefined;
16
+ /** Presentation/policy projection for every draft entering the React layer. */
17
+ mapComposerDraft?:
18
+ | ((draft: ComposerDraft, context: ComposerDraftMapContext) => ComposerDraft)
19
+ | undefined;
20
+ };
21
+
22
+ const requiredMethods = [
23
+ "getSession",
24
+ "listEvents",
25
+ "streamEvents",
26
+ "getComposerDraft",
27
+ "saveComposerDraft",
28
+ "submitComposerDraft",
29
+ "sendMessage",
30
+ "steerMessage",
31
+ "getQueue",
32
+ "moveQueueItem",
33
+ "editQueueItem",
34
+ "steerQueueItem",
35
+ "deleteQueueItem",
36
+ "pauseSession",
37
+ "resumeSession",
38
+ "sendApprovalDecision",
39
+ ] as const satisfies readonly (keyof EmbeddedSessionClientLike)[];
40
+
41
+ /**
42
+ * Build the exact client required by `@opengeni/react/session` from a full SDK
43
+ * client or host proxy. The constructor binds every delegated method once,
44
+ * supports a host-owned atomic composer-submit override, and fails at creation
45
+ * time instead of waiting for a missing method to be exercised by a hook.
46
+ */
47
+ export function createEmbeddedSessionClient(
48
+ base: EmbeddedSessionClientLike,
49
+ options: CreateEmbeddedSessionClientOptions = {},
50
+ ): EmbeddedSessionClientLike {
51
+ const overrides = options.overrides ?? {};
52
+ const client = {} as EmbeddedSessionClientLike;
53
+ for (const key of requiredMethods) {
54
+ const override = overrides[key];
55
+ const method = override ?? base[key];
56
+ if (typeof method !== "function") {
57
+ throw new TypeError(`Embedded session client method is required: ${key}`);
58
+ }
59
+ client[key] = method.bind(override === undefined ? base : overrides) as never;
60
+ }
61
+
62
+ const inferenceOverride = overrides.setWorkspaceInferenceState;
63
+ const inferenceBase = base.setWorkspaceInferenceState;
64
+ const inferenceMethod = inferenceOverride ?? inferenceBase;
65
+ if (typeof inferenceMethod === "function") {
66
+ client.setWorkspaceInferenceState = inferenceMethod.bind(
67
+ inferenceOverride === undefined ? base : overrides,
68
+ );
69
+ }
70
+
71
+ const mapDraft = options.mapComposerDraft;
72
+ if (!mapDraft) return client;
73
+
74
+ const getComposerDraft = client.getComposerDraft;
75
+ client.getComposerDraft = async (workspaceId, sessionId) =>
76
+ mapDraft(await getComposerDraft(workspaceId, sessionId), {
77
+ operation: "read",
78
+ workspaceId,
79
+ sessionId,
80
+ });
81
+
82
+ const saveComposerDraft = client.saveComposerDraft;
83
+ client.saveComposerDraft = async (workspaceId, sessionId, request) =>
84
+ mapDraft(await saveComposerDraft(workspaceId, sessionId, request), {
85
+ operation: "save",
86
+ workspaceId,
87
+ sessionId,
88
+ });
89
+
90
+ const submitComposerDraft = client.submitComposerDraft;
91
+ client.submitComposerDraft = async (workspaceId, sessionId, request) => {
92
+ const response = await submitComposerDraft(workspaceId, sessionId, request);
93
+ return {
94
+ ...response,
95
+ draft: mapDraft(response.draft, {
96
+ operation: "submit",
97
+ workspaceId,
98
+ sessionId,
99
+ }),
100
+ };
101
+ };
102
+
103
+ return client;
104
+ }
@@ -0,0 +1,66 @@
1
+ import type { FileTreeNode } from "./hooks/use-sandbox-files";
2
+
3
+ export type FileNodeVisibilityContext = {
4
+ depth: number;
5
+ parent: FileTreeNode | null;
6
+ };
7
+
8
+ /** Presentation-only predicate. It never changes filesystem authorization. */
9
+ export type FileNodeVisibilityPredicate = (
10
+ node: FileTreeNode,
11
+ context: FileNodeVisibilityContext,
12
+ ) => boolean;
13
+
14
+ export type FilePathVisibility = "visible" | "hidden" | "unknown";
15
+
16
+ function pathIsInsideDirectory(path: string, directory: string): boolean {
17
+ if (!path.startsWith(directory) || path === directory) return false;
18
+ if (directory.endsWith("/") || directory.endsWith("\\")) return true;
19
+ const boundary = path[directory.length];
20
+ return boundary === "/" || boundary === "\\";
21
+ }
22
+
23
+ /**
24
+ * Filter an authoritative tree without promoting children of a hidden parent.
25
+ * Undefined children stay undefined so lazy-loading semantics are preserved.
26
+ */
27
+ export function visibleFileTree(
28
+ nodes: FileTreeNode[],
29
+ predicate?: FileNodeVisibilityPredicate,
30
+ parent: FileTreeNode | null = null,
31
+ depth = 0,
32
+ ): FileTreeNode[] {
33
+ if (!predicate) return nodes;
34
+ const visible: FileTreeNode[] = [];
35
+ for (const node of nodes) {
36
+ if (!predicate(node, { depth, parent })) continue;
37
+ visible.push(
38
+ node.children === undefined
39
+ ? node
40
+ : {
41
+ ...node,
42
+ children: visibleFileTree(node.children, predicate, node, depth + 1),
43
+ },
44
+ );
45
+ }
46
+ return visible;
47
+ }
48
+
49
+ /** Resolve only what the currently loaded tree can prove. */
50
+ export function filePathVisibility(
51
+ nodes: FileTreeNode[],
52
+ path: string,
53
+ predicate?: FileNodeVisibilityPredicate,
54
+ parent: FileTreeNode | null = null,
55
+ depth = 0,
56
+ ): FilePathVisibility {
57
+ if (!predicate) return "visible";
58
+ for (const node of nodes) {
59
+ if (node.path !== path && !pathIsInsideDirectory(path, node.path)) continue;
60
+ if (!predicate(node, { depth, parent })) return "hidden";
61
+ if (node.path === path) return "visible";
62
+ if (node.kind !== "dir" || node.children === undefined) return "unknown";
63
+ return filePathVisibility(node.children, path, predicate, node, depth + 1);
64
+ }
65
+ return "unknown";
66
+ }
@@ -115,7 +115,6 @@ type StoredOptimisticSendOperation = Omit<OptimisticSendOperation, "input" | "ca
115
115
 
116
116
  const PENDING_COMPOSER_STORAGE_PREFIX = "opengeni.pending-composer.v1:";
117
117
  const OPTIMISTIC_SEND_STORAGE_PREFIX = "opengeni.optimistic-sends.v1:";
118
-
119
118
  // A remount must not manufacture a new operation while the previous mutation
120
119
  // is still outcome-unknown. Keep only non-credential request fields here; the
121
120
  // mounted hook retains the exact input, including any credential updates. The
@@ -697,6 +696,12 @@ export function useComposer(
697
696
  const localEditRevision = useRef(initialShadow ? 1 : 0);
698
697
  const targetGeneration = useRef(0);
699
698
  const draftReadGeneration = useRef(0);
699
+ const draftReadInFlightRef = useRef<string | null>(null);
700
+ const draftReadRetryRef = useRef<{
701
+ targetKey: string;
702
+ failures: number;
703
+ timer: ReturnType<typeof setTimeout> | null;
704
+ }>({ targetKey, failures: 0, timer: null });
700
705
  const lastSavedSignature = useRef<string | null>(null);
701
706
  const saveChain = useRef<Promise<void>>(Promise.resolve());
702
707
  const onSent = options.onSent;
@@ -725,6 +730,10 @@ export function useComposer(
725
730
  const targetKeyRef = useRef(targetKey);
726
731
  useLayoutEffect(() => {
727
732
  if (targetKeyRef.current === targetKey) return;
733
+ if (draftReadRetryRef.current.timer !== null) {
734
+ clearTimeout(draftReadRetryRef.current.timer);
735
+ }
736
+ draftReadRetryRef.current = { targetKey, failures: 0, timer: null };
728
737
  targetKeyRef.current = targetKey;
729
738
  targetGeneration.current += 1;
730
739
  draftReadGeneration.current += 1;
@@ -777,6 +786,14 @@ export function useComposer(
777
786
  setDraftConflict(null);
778
787
  setRestoredResources(shadow?.resources ?? []);
779
788
  }, [durableDrafts, options.initialPolicy, pendingOperationKey, sessionId, targetKey]);
789
+ useEffect(
790
+ () => () => {
791
+ if (draftReadRetryRef.current.timer !== null) {
792
+ clearTimeout(draftReadRetryRef.current.timer);
793
+ }
794
+ },
795
+ [],
796
+ );
780
797
 
781
798
  const setOptimisticDraftShadow = useCallback(
782
799
  (shadow: ComposerDraftShadow): void => {
@@ -886,6 +903,30 @@ export function useComposer(
886
903
  setDraftLoading(false);
887
904
  return;
888
905
  }
906
+ let retry = draftReadRetryRef.current;
907
+ if (retry.targetKey !== targetKey) {
908
+ if (retry.timer !== null) clearTimeout(retry.timer);
909
+ retry = { targetKey, failures: 0, timer: null };
910
+ draftReadRetryRef.current = retry;
911
+ }
912
+ const scheduleRetry = (): void => {
913
+ if (retry.timer !== null) return;
914
+ retry.timer = setTimeout(
915
+ () => {
916
+ if (draftReadRetryRef.current !== retry || targetKeyRef.current !== targetKey) return;
917
+ retry.timer = null;
918
+ void loadDraft(false);
919
+ },
920
+ Math.min(25_000, 1_000 * 2 ** (retry.failures - 1)) * (0.8 + Math.random() * 0.4),
921
+ );
922
+ };
923
+ if (!replaceLocal && retry.timer !== null) return;
924
+ if (draftReadInFlightRef.current === targetKey) return;
925
+ if (retry.timer !== null) {
926
+ clearTimeout(retry.timer);
927
+ retry.timer = null;
928
+ }
929
+ draftReadInFlightRef.current = targetKey;
889
930
  const generation = targetGeneration.current;
890
931
  const readTicket = ++draftReadGeneration.current;
891
932
  const localAtStart = localEditRevision.current;
@@ -925,8 +966,15 @@ export function useComposer(
925
966
  ) {
926
967
  return;
927
968
  }
969
+ if (retry.timer !== null) clearTimeout(retry.timer);
970
+ retry.failures = 0;
971
+ retry.timer = null;
928
972
  const currentRevision = draftRef.current?.revision ?? -1;
929
- if (fetched.revision >= currentRevision) {
973
+ // Reconnect and client-generation effects can legitimately ask for the
974
+ // draft again. A same-revision response is not new authority: publishing
975
+ // its freshly decoded object back into React creates a response -> render
976
+ // -> read feedback loop. Hard reload remains the explicit exception.
977
+ if (replaceLocal || fetched.revision > currentRevision) {
930
978
  draftRef.current = fetched;
931
979
  setDraft(fetched);
932
980
  setDraftConflict(null);
@@ -978,6 +1026,17 @@ export function useComposer(
978
1026
  readTicket === draftReadGeneration.current
979
1027
  ) {
980
1028
  setError(asError(cause));
1029
+ if (
1030
+ cause instanceof TypeError ||
1031
+ (cause && typeof cause === "object" && "retryable" in cause && cause.retryable === true)
1032
+ ) {
1033
+ retry.failures += 1;
1034
+ scheduleRetry();
1035
+ } else {
1036
+ if (retry.timer !== null) clearTimeout(retry.timer);
1037
+ retry.failures = 0;
1038
+ retry.timer = null;
1039
+ }
981
1040
  }
982
1041
  } finally {
983
1042
  if (
@@ -987,6 +1046,9 @@ export function useComposer(
987
1046
  ) {
988
1047
  setDraftLoading(false);
989
1048
  }
1049
+ if (draftReadInFlightRef.current === targetKey) {
1050
+ draftReadInFlightRef.current = null;
1051
+ }
990
1052
  }
991
1053
  },
992
1054
  [client, durableDrafts, sessionId, targetKey, workspaceId],
@@ -55,7 +55,14 @@ export type UseFileAttachmentsResult = {
55
55
  * source file). No-op for an id that isn't a known failed upload.
56
56
  */
57
57
  retry: (id: string) => void;
58
- /** Remove one attachment; revokes its object-URL. */
58
+ /**
59
+ * Keep an attachment's object-URL alive for a consumer that outlives its
60
+ * queue entry (for example, an open route-level lightbox). The returned
61
+ * release is idempotent; pending revocation finishes after the last holder
62
+ * releases. Returns `undefined` when the attachment has no local preview.
63
+ */
64
+ retainPreview: (id: string) => (() => void) | undefined;
65
+ /** Remove one attachment; revokes its object-URL after retained users finish. */
59
66
  remove: (id: string) => void;
60
67
  /**
61
68
  * Remove only finalized files whose durable ids were accepted by a send.
@@ -63,7 +70,7 @@ export type UseFileAttachmentsResult = {
63
70
  * next message.
64
71
  */
65
72
  removeReadyFiles: (fileIds: Iterable<string>) => void;
66
- /** Remove all attachments and revoke every object-URL. */
73
+ /** Remove all attachments and revoke every unretained object-URL. */
67
74
  clear: () => void;
68
75
  };
69
76
 
@@ -91,17 +98,41 @@ export function useFileAttachments(
91
98
  // state update when its component unmounts in the same batch that minted the
92
99
  // preview. The registry remains available to cleanup even before a render.
93
100
  const previewUrls = useRef<Map<string, string>>(new Map());
101
+ const previewRetainers = useRef<Map<string, number>>(new Map());
102
+ const pendingPreviewRevocations = useRef<Set<string>>(new Set());
94
103
  const revokePreview = useCallback((id: string) => {
95
104
  const previewUrl = previewUrls.current.get(id);
96
105
  if (!previewUrl) return;
106
+ if ((previewRetainers.current.get(id) ?? 0) > 0) {
107
+ pendingPreviewRevocations.current.add(id);
108
+ return;
109
+ }
97
110
  previewUrls.current.delete(id);
111
+ pendingPreviewRevocations.current.delete(id);
98
112
  URL.revokeObjectURL(previewUrl);
99
113
  }, []);
100
114
  const revokeAllPreviews = useCallback(() => {
101
- const urls = [...previewUrls.current.values()];
102
- previewUrls.current.clear();
103
- for (const previewUrl of urls) URL.revokeObjectURL(previewUrl);
104
- }, []);
115
+ for (const id of previewUrls.current.keys()) revokePreview(id);
116
+ }, [revokePreview]);
117
+ const retainPreview = useCallback(
118
+ (id: string): (() => void) | undefined => {
119
+ if (!previewUrls.current.has(id)) return undefined;
120
+ previewRetainers.current.set(id, (previewRetainers.current.get(id) ?? 0) + 1);
121
+ let released = false;
122
+ return () => {
123
+ if (released) return;
124
+ released = true;
125
+ const remaining = (previewRetainers.current.get(id) ?? 1) - 1;
126
+ if (remaining > 0) {
127
+ previewRetainers.current.set(id, remaining);
128
+ return;
129
+ }
130
+ previewRetainers.current.delete(id);
131
+ if (pendingPreviewRevocations.current.has(id)) revokePreview(id);
132
+ };
133
+ },
134
+ [revokePreview],
135
+ );
105
136
  // Ready-file reconciliation uses functional state updaters, which React may
106
137
  // evaluate during a concurrent render that later suspends or is abandoned.
107
138
  // Diff only committed attachment sets here so URL revocation cannot run from
@@ -338,6 +369,7 @@ export function useFileAttachments(
338
369
  addFromPaste,
339
370
  restoreReadyFiles,
340
371
  retry,
372
+ retainPreview,
341
373
  remove,
342
374
  removeReadyFiles,
343
375
  clear,
@@ -8,6 +8,7 @@ import {
8
8
  useEmbeddedHumanInputSession,
9
9
  type EmbeddedHumanInputClientOverride,
10
10
  } from "../session-context";
11
+ import { isActionableHumanInputRequest } from "../human-input";
11
12
  import {
12
13
  useDebouncedCallback,
13
14
  useMutationRunner,
@@ -70,8 +71,11 @@ export function useHumanInputRequests(
70
71
  enabled,
71
72
  pollIntervalMs: options.pollIntervalMs,
72
73
  });
74
+ const loadedRequests = state.data;
75
+ const refreshRequests = state.refresh;
73
76
  const mutation = useMutationRunner(`${workspaceId}\u0000${sessionId ?? ""}`);
74
77
  const [respondingRequestId, setRespondingRequestId] = useState<string | null>(null);
78
+ const [deadlineVersion, setDeadlineVersion] = useState(0);
75
79
  const respondingRequestRef = useRef<string | null>(null);
76
80
 
77
81
  useEffect(() => {
@@ -89,6 +93,25 @@ export function useHumanInputRequests(
89
93
  ...(options.events !== undefined ? { events: options.events } : {}),
90
94
  });
91
95
 
96
+ useEffect(() => {
97
+ const now = Date.now();
98
+ const nextDeadline = (loadedRequests ?? []).reduce<number | null>((next, request) => {
99
+ if (request.status !== "pending" || !request.expiresAt) return next;
100
+ const deadline = Date.parse(request.expiresAt);
101
+ if (!Number.isFinite(deadline) || deadline <= now) return next;
102
+ return next === null ? deadline : Math.min(next, deadline);
103
+ }, null);
104
+ if (nextDeadline === null) return;
105
+ const timer = globalThis.setTimeout(
106
+ () => {
107
+ setDeadlineVersion((current) => current + 1);
108
+ void refreshRequests();
109
+ },
110
+ Math.min(2_147_483_647, Math.max(0, nextDeadline - now + 1)),
111
+ );
112
+ return () => globalThis.clearTimeout(timer);
113
+ }, [loadedRequests, refreshRequests]);
114
+
92
115
  const respond = useCallback(
93
116
  async (
94
117
  requestId: string,
@@ -115,11 +138,15 @@ export function useHumanInputRequests(
115
138
  [client, mutation, sessionId, state, workspaceId],
116
139
  );
117
140
 
141
+ const now = Date.now();
142
+ void deadlineVersion;
118
143
  return {
119
- requests: [...(state.data ?? [])].sort(
120
- (left, right) =>
121
- left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id),
122
- ),
144
+ requests: [...(state.data ?? [])]
145
+ .filter((request) => isActionableHumanInputRequest(request, now))
146
+ .sort(
147
+ (left, right) =>
148
+ left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id),
149
+ ),
123
150
  loading: state.loading,
124
151
  error: state.error,
125
152
  refresh: state.refresh,
@@ -1,4 +1,4 @@
1
- import type { HumanInputQuestion, SessionEvent } from "@opengeni/sdk";
1
+ import type { HumanInputQuestion, SessionEvent, SessionHumanInputRequest } from "@opengeni/sdk";
2
2
 
3
3
  /** Minimal actionable request shape available from the durable event log. */
4
4
  export type PendingHumanInputRequest = {
@@ -9,6 +9,17 @@ export type PendingHumanInputRequest = {
9
9
  expiresAt: string | null;
10
10
  };
11
11
 
12
+ /** A pending row stops being actionable at its durable deadline, even before refresh. */
13
+ export function isActionableHumanInputRequest(
14
+ request: Pick<SessionHumanInputRequest, "status" | "expiresAt">,
15
+ nowMs = Date.now(),
16
+ ): boolean {
17
+ if (request.status !== "pending") return false;
18
+ if (!request.expiresAt) return true;
19
+ const deadline = Date.parse(request.expiresAt);
20
+ return Number.isFinite(deadline) && deadline > nowMs;
21
+ }
22
+
12
23
  /** Parse one generic `session.humanInput.requested` event defensively. */
13
24
  export function humanInputRequestFromEvent(
14
25
  event: Pick<SessionEvent, "type" | "payload" | "turnId">,
package/src/index.ts CHANGED
@@ -27,7 +27,18 @@ export type { OpenGeniProviderProps } from "./provider";
27
27
  export { useOpenGeni, useOpenGeniClient } from "./session-context";
28
28
  export { useVideoArtifactPlaybackLoader } from "./hooks/use-video-artifact-playback";
29
29
  export type { ClientOverride, OpenGeniContextValue } from "./session-context";
30
+ export { createEmbeddedSessionClient } from "./embedded-session-client";
31
+ export type {
32
+ ComposerDraftMapContext,
33
+ CreateEmbeddedSessionClientOptions,
34
+ } from "./embedded-session-client";
30
35
  export { usePageLiveActivity } from "./hooks/internal";
36
+ export { filePathVisibility, visibleFileTree } from "./file-node-visibility";
37
+ export type {
38
+ FileNodeVisibilityContext,
39
+ FileNodeVisibilityPredicate,
40
+ FilePathVisibility,
41
+ } from "./file-node-visibility";
31
42
 
32
43
  // Hooks
33
44
  export { useSession, isTitleEvent } from "./hooks/use-session";
@@ -397,6 +408,7 @@ export type {
397
408
  TurnSummaryFacetResult,
398
409
  TurnSummaryOptions,
399
410
  TurnSummaryProps,
411
+ LightboxControlLabels,
400
412
  } from "./timeline";
401
413
 
402
414
  // Pure provider-shape parsers (exec banner, V4A diff, tool arguments, …)
package/src/session.ts CHANGED
@@ -22,6 +22,11 @@ export type {
22
22
  EmbeddedSessionReadClientOverride as SessionReadClientOverride,
23
23
  EmbeddedSessionClientOverride as ClientOverride,
24
24
  } from "./session-context";
25
+ export { createEmbeddedSessionClient } from "./embedded-session-client";
26
+ export type {
27
+ ComposerDraftMapContext,
28
+ CreateEmbeddedSessionClientOptions,
29
+ } from "./embedded-session-client";
25
30
 
26
31
  export { useSession, isTitleEvent } from "./hooks/use-session";
27
32
  export type { UseSessionOptions, UseSessionResult } from "./hooks/use-session";
@@ -92,6 +92,7 @@ export type { ActivityDisclosureProps, DisclosureChip } from "./shared";
92
92
 
93
93
  // screenshot lightbox
94
94
  export { LightboxProvider, useLightbox, useLightboxOptional } from "./screenshot-lightbox";
95
+ export type { LightboxControlLabels } from "./screenshot-lightbox";
95
96
 
96
97
  // disclosure defaults (opt-in initial-open seed; for screenshot/test instrumentation)
97
98
  export { DisclosureDefaultsProvider, useForcedDefaultOpen } from "./disclosure-context";