@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.
- package/README.md +15 -0
- package/dist/accounts.d.ts +64 -0
- package/dist/accounts.js +746 -0
- package/dist/accounts.js.map +1 -0
- package/dist/{browser-viewer-GXUICIAG.js → browser-viewer-AEFHUYZK.js} +3 -3
- package/dist/{chunk-MXJGO7LE.js → chunk-D3UIAJKY.js} +52 -2
- package/dist/chunk-D3UIAJKY.js.map +1 -0
- package/dist/{chunk-QGBJ6GHG.js → chunk-ELIYWBZR.js} +4 -4
- package/dist/{chunk-6H6S5V4Z.js → chunk-HQVCKCLG.js} +758 -871
- package/dist/chunk-HQVCKCLG.js.map +1 -0
- package/dist/{chunk-T7VAYG2I.js → chunk-MZ2G2XBA.js} +54 -2
- package/dist/chunk-MZ2G2XBA.js.map +1 -0
- package/dist/{chunk-X4JAFD5R.js → chunk-OAM4WAAM.js} +4 -4
- package/dist/{chunk-ZUT5QSQB.js → chunk-RGALCTWO.js} +57 -17
- package/dist/{chunk-ZUT5QSQB.js.map → chunk-RGALCTWO.js.map} +1 -1
- package/dist/chunk-YFKJ7VZH.js +268 -0
- package/dist/chunk-YFKJ7VZH.js.map +1 -0
- package/dist/{chunk-ATQJEWCA.js → chunk-YJFGAFBD.js} +124 -50
- package/dist/chunk-YJFGAFBD.js.map +1 -0
- package/dist/components/composer.d.ts +8 -0
- package/dist/components/file-browser.d.ts +4 -1
- package/dist/components/sandbox-files.d.ts +4 -1
- package/dist/components/sandbox-workspace.d.ts +5 -0
- package/dist/composer.js +3 -3
- package/dist/{computer-viewer-ZOP4Q7A7.js → computer-viewer-FWEZISW4.js} +3 -3
- package/dist/embedded-session-client.d.ts +23 -0
- package/dist/file-node-visibility.d.ts +15 -0
- package/dist/hooks/use-file-attachments.d.ts +9 -2
- package/dist/human-input.d.ts +3 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.js +139 -53
- package/dist/index.js.map +1 -1
- package/dist/interaction.js +3 -3
- package/dist/session-ui.js +4 -4
- package/dist/session.d.ts +2 -0
- package/dist/session.js +7 -5
- package/dist/timeline/index.d.ts +1 -0
- package/dist/timeline/screenshot-lightbox.d.ts +16 -1
- package/package.json +6 -2
- package/src/accounts.tsx +970 -0
- package/src/components/chat-composer.tsx +88 -84
- package/src/components/composer.tsx +53 -11
- package/src/components/file-browser.tsx +33 -20
- package/src/components/human-input-surface.tsx +10 -6
- package/src/components/message-timeline.tsx +37 -9
- package/src/components/sandbox-files.tsx +42 -2
- package/src/components/sandbox-workspace.tsx +10 -0
- package/src/embedded-session-client.ts +104 -0
- package/src/file-node-visibility.ts +66 -0
- package/src/hooks/use-composer.ts +64 -2
- package/src/hooks/use-file-attachments.ts +38 -6
- package/src/hooks/use-human-input.ts +31 -4
- package/src/human-input.ts +12 -1
- package/src/index.ts +12 -0
- package/src/session.ts +5 -0
- package/src/timeline/index.ts +1 -0
- package/src/timeline/screenshot-lightbox.tsx +93 -29
- package/styles/compiled.css +11 -1
- package/styles/tokens.css +3 -1
- package/dist/chunk-6H6S5V4Z.js.map +0 -1
- package/dist/chunk-ATQJEWCA.js.map +0 -1
- package/dist/chunk-FK6VG4LL.js +0 -90
- package/dist/chunk-FK6VG4LL.js.map +0 -1
- package/dist/chunk-MXJGO7LE.js.map +0 -1
- package/dist/chunk-T7VAYG2I.js.map +0 -1
- /package/dist/{browser-viewer-GXUICIAG.js.map → browser-viewer-AEFHUYZK.js.map} +0 -0
- /package/dist/{chunk-QGBJ6GHG.js.map → chunk-ELIYWBZR.js.map} +0 -0
- /package/dist/{chunk-X4JAFD5R.js.map → chunk-OAM4WAAM.js.map} +0 -0
- /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
|
-
}, [
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
102
|
-
|
|
103
|
-
|
|
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 ?? [])]
|
|
120
|
-
(
|
|
121
|
-
|
|
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,
|
package/src/human-input.ts
CHANGED
|
@@ -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";
|
package/src/timeline/index.ts
CHANGED
|
@@ -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";
|