@opengeni/react 0.57.0 → 2.2.0-canary.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. package/README.md +36 -11
  2. package/dist/artifacts-spreadsheet.js +1 -1
  3. package/dist/artifacts.js +1 -1
  4. package/dist/browser-viewer-GXUICIAG.js +14 -0
  5. package/dist/{chunk-R3AZOAB5.js → chunk-4MDLEHXL.js} +9 -115
  6. package/dist/chunk-4MDLEHXL.js.map +1 -0
  7. package/dist/{chunk-Y7OEDUJ5.js → chunk-5RZN3DD5.js} +1014 -420
  8. package/dist/chunk-5RZN3DD5.js.map +1 -0
  9. package/dist/chunk-5YK2ZDIG.js +116 -0
  10. package/dist/chunk-5YK2ZDIG.js.map +1 -0
  11. package/dist/chunk-H5YGYNXS.js +329 -0
  12. package/dist/chunk-H5YGYNXS.js.map +1 -0
  13. package/dist/{chunk-7WCLAEFV.js → chunk-ICA6R6TW.js} +2 -2
  14. package/dist/{chunk-7WCLAEFV.js.map → chunk-ICA6R6TW.js.map} +1 -1
  15. package/dist/{chunk-IGC6A2ZI.js → chunk-JOAVA2TW.js} +589 -217
  16. package/dist/chunk-JOAVA2TW.js.map +1 -0
  17. package/dist/{chunk-5UQPNLIO.js → chunk-LTCHJ4MS.js} +9 -9
  18. package/dist/chunk-LTCHJ4MS.js.map +1 -0
  19. package/dist/{chunk-C5ZPSSS5.js → chunk-NEXLLTP4.js} +2 -1
  20. package/dist/{chunk-C5ZPSSS5.js.map → chunk-NEXLLTP4.js.map} +1 -1
  21. package/dist/chunk-QGBJ6GHG.js +2300 -0
  22. package/dist/chunk-QGBJ6GHG.js.map +1 -0
  23. package/dist/{chunk-B6WPZSTT.js → chunk-UOLJWT5Y.js} +78 -16
  24. package/dist/chunk-UOLJWT5Y.js.map +1 -0
  25. package/dist/{chunk-MKPIAGVL.js → chunk-W6NFJXSA.js} +8 -19
  26. package/dist/chunk-W6NFJXSA.js.map +1 -0
  27. package/dist/{chunk-XBZWOM2K.js → chunk-X4A66EWZ.js} +8 -3
  28. package/dist/chunk-X4A66EWZ.js.map +1 -0
  29. package/dist/chunk-X4JAFD5R.js +3907 -0
  30. package/dist/chunk-X4JAFD5R.js.map +1 -0
  31. package/dist/{chunk-6KLX2RH5.js → chunk-XSA2QAKP.js} +334 -19
  32. package/dist/chunk-XSA2QAKP.js.map +1 -0
  33. package/dist/{chunk-HA46CYQK.js → chunk-XXWH2JLC.js} +792 -206
  34. package/dist/chunk-XXWH2JLC.js.map +1 -0
  35. package/dist/client.d.ts +2 -2
  36. package/dist/components/browser-viewer.d.ts +7 -1
  37. package/dist/components/computer-viewer.d.ts +4 -1
  38. package/dist/components/enrollment-consent.d.ts +6 -3
  39. package/dist/components/file-browser.d.ts +7 -1
  40. package/dist/components/human-input-form.d.ts +1 -1
  41. package/dist/components/human-input-surface.d.ts +1 -1
  42. package/dist/components/machine-card.d.ts +4 -1
  43. package/dist/components/machines/machine-detail.d.ts +4 -1
  44. package/dist/components/machines/machine-operation-policy.d.ts +11 -0
  45. package/dist/components/machines-dashboard.d.ts +3 -1
  46. package/dist/components/markdown.d.ts +20 -1
  47. package/dist/components/message-timeline.d.ts +1 -1
  48. package/dist/components/model-policy-picker.d.ts +2 -0
  49. package/dist/components/pierre-file.d.ts +1 -1
  50. package/dist/components/sandbox-files.d.ts +8 -2
  51. package/dist/components/sandbox-workspace.d.ts +33 -3
  52. package/dist/components/session-chrome.d.ts +15 -3
  53. package/dist/components/workspace-dock.d.ts +6 -2
  54. package/dist/composer.js +5 -5
  55. package/dist/computer-viewer-ZOP4Q7A7.js +16 -0
  56. package/dist/computer-viewer-ZOP4Q7A7.js.map +1 -0
  57. package/dist/hooks/channel-cache.d.ts +23 -0
  58. package/dist/hooks/shared-machines-list.d.ts +14 -0
  59. package/dist/hooks/use-attached-browsers.d.ts +2 -1
  60. package/dist/hooks/use-channels.d.ts +1 -0
  61. package/dist/hooks/use-composer.d.ts +37 -9
  62. package/dist/hooks/use-machine-chip.d.ts +3 -3
  63. package/dist/hooks/use-machines.d.ts +19 -1
  64. package/dist/hooks/use-sandbox-files.d.ts +1 -0
  65. package/dist/hooks/use-session-background-commands.d.ts +14 -0
  66. package/dist/hooks/use-turn-queue.d.ts +12 -0
  67. package/dist/hooks/use-workspace-sessions.d.ts +1 -0
  68. package/dist/index.d.ts +7 -5
  69. package/dist/index.js +1096 -623
  70. package/dist/index.js.map +1 -1
  71. package/dist/interaction.js +29 -181
  72. package/dist/interaction.js.map +1 -1
  73. package/dist/lib/interaction-errors.d.ts +3 -0
  74. package/dist/machines.d.ts +4 -2
  75. package/dist/machines.js +10 -2
  76. package/dist/model-policy.d.ts +1 -1
  77. package/dist/model-policy.js +1 -1
  78. package/dist/platform-activity-row-BJHUHXAT.js +169 -0
  79. package/dist/platform-activity-row-BJHUHXAT.js.map +1 -0
  80. package/dist/{queue-surface-implementation-2KVJTYXH.js → queue-surface-implementation-BSKUWHRI.js} +76 -16
  81. package/dist/queue-surface-implementation-BSKUWHRI.js.map +1 -0
  82. package/dist/realtime/realtime-control.d.ts +10 -3
  83. package/dist/realtime.js +138 -29
  84. package/dist/realtime.js.map +1 -1
  85. package/dist/session-ui.d.ts +1 -1
  86. package/dist/session-ui.js +6 -2
  87. package/dist/session.d.ts +2 -2
  88. package/dist/session.js +4 -4
  89. package/dist/timeline/index.d.ts +1 -1
  90. package/dist/timeline/platform-activity-row.d.ts +15 -0
  91. package/dist/timeline/screenshot-lightbox.d.ts +1 -1
  92. package/dist/timeline/shared.d.ts +4 -2
  93. package/dist/timeline/types.d.ts +48 -5
  94. package/dist/types/machines.d.ts +2 -2
  95. package/package.json +3 -3
  96. package/src/client.ts +12 -1
  97. package/src/components/artifacts/editable-spreadsheet.tsx +1 -1
  98. package/src/components/browser-viewer.tsx +361 -58
  99. package/src/components/chat-composer.tsx +1 -1
  100. package/src/components/composer.tsx +6 -6
  101. package/src/components/computer-viewer.tsx +368 -115
  102. package/src/components/enrollment-consent.tsx +64 -6
  103. package/src/components/file-browser.tsx +117 -2
  104. package/src/components/human-input-form.tsx +91 -55
  105. package/src/components/human-input-surface.tsx +1 -1
  106. package/src/components/machine-card.tsx +111 -0
  107. package/src/components/machine-input-display.ts +15 -0
  108. package/src/components/machines/machine-detail.tsx +47 -0
  109. package/src/components/machines/machine-operation-policy.tsx +179 -0
  110. package/src/components/machines-dashboard.tsx +6 -0
  111. package/src/components/markdown.tsx +174 -20
  112. package/src/components/message-timeline.tsx +162 -17
  113. package/src/components/model-policy-picker.tsx +9 -17
  114. package/src/components/pierre-file.tsx +1 -1
  115. package/src/components/queue-surface-implementation.tsx +82 -16
  116. package/src/components/sandbox-files.tsx +135 -19
  117. package/src/components/sandbox-workspace.tsx +204 -92
  118. package/src/components/session-chrome.tsx +415 -208
  119. package/src/components/session-status.tsx +1 -1
  120. package/src/components/user-message-body.tsx +168 -48
  121. package/src/components/workspace-dock.tsx +141 -175
  122. package/src/hooks/channel-cache.ts +104 -0
  123. package/src/hooks/shared-machines-list.ts +239 -0
  124. package/src/hooks/use-attached-browsers.ts +5 -1
  125. package/src/hooks/use-browser-frame-stream.ts +6 -1
  126. package/src/hooks/use-browser-session.ts +6 -4
  127. package/src/hooks/use-browser-sessions.ts +7 -1
  128. package/src/hooks/use-channels.ts +32 -10
  129. package/src/hooks/use-composer.ts +760 -291
  130. package/src/hooks/use-computer-frame-stream.ts +14 -9
  131. package/src/hooks/use-computer-session.ts +16 -14
  132. package/src/hooks/use-computer-sessions.ts +8 -1
  133. package/src/hooks/use-last-started-turn-policy.ts +1 -1
  134. package/src/hooks/use-machine-chip.ts +23 -6
  135. package/src/hooks/use-machines.ts +104 -6
  136. package/src/hooks/use-sandbox-files.ts +195 -140
  137. package/src/hooks/use-sandbox-git.ts +58 -29
  138. package/src/hooks/use-session-background-commands.ts +56 -0
  139. package/src/hooks/use-session-capabilities.ts +18 -0
  140. package/src/hooks/use-turn-queue.ts +111 -12
  141. package/src/hooks/use-workspace-sessions.ts +5 -1
  142. package/src/index.ts +26 -4
  143. package/src/lib/interaction-errors.ts +18 -0
  144. package/src/machines.ts +9 -1
  145. package/src/model-policy.ts +10 -2
  146. package/src/realtime/realtime-control.tsx +200 -37
  147. package/src/session-context.ts +1 -0
  148. package/src/session-ui.ts +6 -1
  149. package/src/session.ts +9 -1
  150. package/src/timeline/activity-rail.tsx +20 -75
  151. package/src/timeline/index.ts +4 -0
  152. package/src/timeline/platform-activity-row.tsx +201 -0
  153. package/src/timeline/projection.ts +476 -23
  154. package/src/timeline/screenshot-lightbox.tsx +48 -14
  155. package/src/timeline/shared.tsx +8 -2
  156. package/src/timeline/tool-renderers.tsx +104 -6
  157. package/src/timeline/turn-summary.tsx +6 -1
  158. package/src/timeline/types.ts +75 -2
  159. package/src/types/machines.ts +10 -0
  160. package/styles/compiled.css +115 -104
  161. package/dist/chunk-5UQPNLIO.js.map +0 -1
  162. package/dist/chunk-6KLX2RH5.js.map +0 -1
  163. package/dist/chunk-B6WPZSTT.js.map +0 -1
  164. package/dist/chunk-HA46CYQK.js.map +0 -1
  165. package/dist/chunk-IGC6A2ZI.js.map +0 -1
  166. package/dist/chunk-LLOLUHVG.js +0 -5937
  167. package/dist/chunk-LLOLUHVG.js.map +0 -1
  168. package/dist/chunk-MKPIAGVL.js.map +0 -1
  169. package/dist/chunk-R3AZOAB5.js.map +0 -1
  170. package/dist/chunk-XBZWOM2K.js.map +0 -1
  171. package/dist/chunk-Y7OEDUJ5.js.map +0 -1
  172. package/dist/interactive-workbench-viewers-TOHBXJEE.js +0 -15
  173. package/dist/queue-surface-implementation-2KVJTYXH.js.map +0 -1
  174. /package/dist/{interactive-workbench-viewers-TOHBXJEE.js.map → browser-viewer-GXUICIAG.js.map} +0 -0
@@ -1,4 +1,5 @@
1
1
  import { type ReactNode, useState } from "react";
2
+ import type { ResourceAuthorityScope } from "@opengeni/sdk";
2
3
  import {
3
4
  CircleAlertIcon,
4
5
  LaptopIcon,
@@ -30,8 +31,10 @@ export type EnrollmentConsentProps = {
30
31
  userCode: string;
31
32
  machine: EnrollmentConsentMachine;
32
33
  phase?: EnrollmentConsentPhase | undefined;
33
- /** Approve with the chosen screen-control consent. */
34
- onApprove?: ((allowScreenControl: boolean) => void) | undefined;
34
+ /** Whether this caller may publish the machine to the whole organization. */
35
+ allowOrganizationScope?: boolean | undefined;
36
+ /** Approve with the chosen screen-control consent and explicit access scope. */
37
+ onApprove?: ((allowScreenControl: boolean, scope: ResourceAuthorityScope) => void) | undefined;
35
38
  onDeny?: (() => void) | undefined;
36
39
  /** Error message when phase === "error". */
37
40
  errorMessage?: string | undefined;
@@ -61,13 +64,29 @@ export function EnrollmentConsent({
61
64
  userCode,
62
65
  machine,
63
66
  phase = "review",
67
+ allowOrganizationScope = false,
64
68
  onApprove,
65
69
  onDeny,
66
70
  errorMessage,
67
71
  className,
68
72
  }: EnrollmentConsentProps) {
69
73
  const [allowScreenControl, setAllowScreenControl] = useState(machine.requestsScreenControl);
74
+ const [scope, setScope] = useState<ResourceAuthorityScope>("user");
70
75
  const busy = phase === "approving";
76
+ const scopeCopy = {
77
+ user: {
78
+ label: "Personal",
79
+ body: "Only you can use this machine, from any workspace you can access in this organization.",
80
+ },
81
+ workspace: {
82
+ label: "Workspace",
83
+ body: "Everyone with access to this workspace can use this machine.",
84
+ },
85
+ organization: {
86
+ label: "Organization",
87
+ body: "Members of this organization can use this machine from their workspaces.",
88
+ },
89
+ } satisfies Record<ResourceAuthorityScope, { label: string; body: string }>;
71
90
 
72
91
  if (phase === "approved") {
73
92
  return (
@@ -75,7 +94,7 @@ export function EnrollmentConsent({
75
94
  className={className}
76
95
  tone="ok"
77
96
  title="Machine connected"
78
- body={`${machine.machineName} is now enrolled in this workspace. You can close this page — it will appear in your Machines dashboard.`}
97
+ body={`${machine.machineName} is now connected with ${scopeCopy[scope].label.toLowerCase()} access. You can close this page — it will appear in your Machines dashboard.`}
79
98
  />
80
99
  );
81
100
  }
@@ -156,11 +175,50 @@ export function EnrollmentConsent({
156
175
  ) : null}
157
176
  <Capability
158
177
  icon={<UsersIcon className="size-4" aria-hidden />}
159
- title="Shared while connected"
160
- body="Any session in this workspace can use this machine while it is online. Disconnect the agent to revoke access instantly."
178
+ title={`${scopeCopy[scope].label} access while connected`}
179
+ body={`${scopeCopy[scope].body} Disconnect the agent to revoke access instantly.`}
161
180
  />
162
181
  </ul>
163
182
 
183
+ <fieldset className="flex flex-col gap-2" disabled={busy}>
184
+ <legend className="mb-1 text-og-sm font-medium text-og-fg">
185
+ Who can use this machine?
186
+ </legend>
187
+ {(["user", "workspace", "organization"] as const).map((candidate) => {
188
+ const disabled = candidate === "organization" && !allowOrganizationScope;
189
+ return (
190
+ <label
191
+ key={candidate}
192
+ data-enrollment-scope={candidate}
193
+ data-disabled={disabled ? "true" : "false"}
194
+ className={cn(
195
+ "flex items-start gap-2.5 rounded-og-md border border-og-border bg-og-bg px-3 py-2.5",
196
+ disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer",
197
+ )}
198
+ >
199
+ <input
200
+ type="radio"
201
+ name="enrollment-scope"
202
+ value={candidate}
203
+ checked={scope === candidate}
204
+ disabled={disabled || busy}
205
+ onChange={() => setScope(candidate)}
206
+ className="mt-0.5 size-4 accent-og-accent"
207
+ />
208
+ <span className="min-w-0">
209
+ <span className="block text-og-base font-medium text-og-fg">
210
+ {scopeCopy[candidate].label}
211
+ {disabled ? " · organization admin required" : ""}
212
+ </span>
213
+ <span className="block text-og-sm text-og-fg-muted">
214
+ {scopeCopy[candidate].body}
215
+ </span>
216
+ </span>
217
+ </label>
218
+ );
219
+ })}
220
+ </fieldset>
221
+
164
222
  {machine.canOfferDisplay ? (
165
223
  <label
166
224
  data-screen-control-toggle
@@ -206,7 +264,7 @@ export function EnrollmentConsent({
206
264
  type="button"
207
265
  data-approve
208
266
  disabled={busy}
209
- onClick={() => onApprove?.(allowScreenControl)}
267
+ onClick={() => onApprove?.(allowScreenControl, scope)}
210
268
  className="flex-1 rounded-og-sm bg-og-status-failed px-3 py-2 text-og-sm font-semibold text-og-accent-fg transition-colors hover:opacity-90 disabled:opacity-50 pointer-coarse:min-h-11"
211
269
  >
212
270
  {busy ? "Connecting…" : "Grant full access"}
@@ -43,6 +43,12 @@ export type FileBrowserProps = {
43
43
  /** Selection callback for the preview pane. */
44
44
  onSelectFile?: ((path: string) => void) | undefined;
45
45
  selectedPath?: string | undefined;
46
+ /** Deliberately reveal this selected path by lazily opening every loaded
47
+ * ancestor and scrolling its row into view. The path is opaque and is never
48
+ * normalized or re-rooted here. */
49
+ revealPath?: string | undefined;
50
+ /** Increment/change to reveal the same path again after a user collapses it. */
51
+ revealPathRequestId?: string | number | undefined;
46
52
  /** Render-prop to theme/replace a row entirely (the Pierre-swap escape hatch). */
47
53
  renderNode?: ((node: FileTreeNode, depth: number, expanded: boolean) => ReactNode) | undefined;
48
54
  /** Shown when the tree is empty (no files / not loaded yet). */
@@ -96,11 +102,18 @@ function itemKey(item: RenderItem): string {
96
102
  }
97
103
  }
98
104
 
99
- /** Walk the tree to find a node by path. */
105
+ function pathIsInsideDirectory(path: string, directory: string): boolean {
106
+ if (!path.startsWith(directory) || path === directory) return false;
107
+ if (directory.endsWith("/") || directory.endsWith("\\")) return true;
108
+ const boundary = path[directory.length];
109
+ return boundary === "/" || boundary === "\\";
110
+ }
111
+
112
+ /** Walk the authoritative tree without assuming its target-owned separator. */
100
113
  function findNode(nodes: FileTreeNode[], path: string): FileTreeNode | undefined {
101
114
  for (const node of nodes) {
102
115
  if (node.path === path) return node;
103
- if (node.kind === "dir" && node.children && path.startsWith(`${node.path}/`)) {
116
+ if (node.kind === "dir" && node.children && pathIsInsideDirectory(path, node.path)) {
104
117
  const hit = findNode(node.children, path);
105
118
  if (hit) return hit;
106
119
  }
@@ -108,6 +121,34 @@ function findNode(nodes: FileTreeNode[], path: string): FileTreeNode | undefined
108
121
  return undefined;
109
122
  }
110
123
 
124
+ type RevealTrace = {
125
+ directories: string[];
126
+ frontier: string | null;
127
+ };
128
+
129
+ /** Follow only paths already named by the authoritative tree. This deliberately
130
+ * avoids manufacturing ancestors from a platform-specific path string. */
131
+ function traceRevealPath(nodes: FileTreeNode[], targetPath: string): RevealTrace {
132
+ for (const node of nodes) {
133
+ if (node.path === targetPath) {
134
+ return {
135
+ directories: node.kind === "dir" ? [node.path] : [],
136
+ frontier: node.kind === "dir" && node.children === undefined ? node.path : null,
137
+ };
138
+ }
139
+ if (node.kind !== "dir" || !pathIsInsideDirectory(targetPath, node.path)) continue;
140
+ if (node.children === undefined) {
141
+ return { directories: [node.path], frontier: node.path };
142
+ }
143
+ const child = traceRevealPath(node.children, targetPath);
144
+ return {
145
+ directories: [node.path, ...child.directories],
146
+ frontier: child.frontier,
147
+ };
148
+ }
149
+ return { directories: [], frontier: null };
150
+ }
151
+
111
152
  /** A pending inline-create: a phantom input row under `parent`. */
112
153
  type DraftCreate = { parent: string; kind: "file" | "dir" };
113
154
  /** A pending inline-rename of an existing node. */
@@ -142,6 +183,8 @@ export function FileBrowser({
142
183
  fallback,
143
184
  onSelectFile,
144
185
  selectedPath,
186
+ revealPath,
187
+ revealPathRequestId,
145
188
  renderNode,
146
189
  emptyState,
147
190
  editable = true,
@@ -158,6 +201,15 @@ export function FileBrowser({
158
201
  const [pendingDelete, setPendingDelete] = useState<FileTreeNode | null>(null);
159
202
  const [busy, setBusy] = useState(false);
160
203
  const [coarsePointer, setCoarsePointer] = useState(false);
204
+ const [revealRequest, setRevealRequest] = useState<{
205
+ path: string;
206
+ requestId: string | number;
207
+ } | null>(null);
208
+ const handledRevealRequestRef = useRef<{
209
+ path: string;
210
+ requestId: string | number;
211
+ } | null>(null);
212
+ const attemptedRevealPathsRef = useRef<Set<string>>(new Set());
161
213
  const treeId = useId();
162
214
  const container = usePortalTokenSource<HTMLDivElement>();
163
215
  const containerRef = container.currentRef;
@@ -184,6 +236,23 @@ export function FileBrowser({
184
236
  }
185
237
  }, []);
186
238
 
239
+ const effectiveRevealRequestId = revealPath ? (revealPathRequestId ?? revealPath) : null;
240
+ useEffect(() => {
241
+ if (!revealPath || effectiveRevealRequestId === null) {
242
+ setRevealRequest(null);
243
+ return;
244
+ }
245
+ const handled = handledRevealRequestRef.current;
246
+ if (handled?.path === revealPath && handled.requestId === effectiveRevealRequestId) return;
247
+ handledRevealRequestRef.current = {
248
+ path: revealPath,
249
+ requestId: effectiveRevealRequestId,
250
+ };
251
+ attemptedRevealPathsRef.current = new Set();
252
+ setActive(null);
253
+ setRevealRequest({ path: revealPath, requestId: effectiveRevealRequestId });
254
+ }, [effectiveRevealRequestId, revealPath]);
255
+
187
256
  // The keyboard cursor: an explicitly-navigated node falls back to the
188
257
  // externally-selected file so arrow keys pick up where the preview pane is.
189
258
  const cursor = active ?? selectedPath ?? null;
@@ -215,6 +284,34 @@ export function FileBrowser({
215
284
  [expand],
216
285
  );
217
286
 
287
+ useEffect(() => {
288
+ if (!revealRequest) return;
289
+ const trace = traceRevealPath(result.tree, revealRequest.path);
290
+ if (trace.directories.length > 0) {
291
+ setExpanded((previous) => {
292
+ const next = new Set(previous);
293
+ let changed = false;
294
+ for (const path of trace.directories) {
295
+ if (next.has(path)) continue;
296
+ next.add(path);
297
+ changed = true;
298
+ }
299
+ return changed ? next : previous;
300
+ });
301
+ }
302
+ const frontier = trace.frontier;
303
+ if (
304
+ !frontier ||
305
+ attemptedRevealPathsRef.current.has(frontier) ||
306
+ loadingPaths.has(frontier) ||
307
+ result.expandingPaths.has(frontier)
308
+ ) {
309
+ return;
310
+ }
311
+ attemptedRevealPathsRef.current.add(frontier);
312
+ void expand(frontier);
313
+ }, [expand, loadingPaths, result.expandingPaths, result.tree, revealRequest]);
314
+
218
315
  const toggle = useCallback(
219
316
  async (node: FileTreeNode) => {
220
317
  if (node.kind !== "dir") return;
@@ -800,6 +897,24 @@ export function FileBrowser({
800
897
  if (index >= 0) vlistRef.current?.scrollToIndex(index);
801
898
  }, [cursor, renderItems]);
802
899
 
900
+ useEffect(() => {
901
+ if (!revealRequest) return;
902
+ const index = renderItems.findIndex(
903
+ (item) => item.type === "node" && item.node.path === revealRequest.path,
904
+ );
905
+ if (index < 0) return;
906
+ if (usesVirtualTree) {
907
+ vlistRef.current?.scrollToIndex(index);
908
+ } else {
909
+ document.getElementById(`${treeId}-item-${index}`)?.scrollIntoView?.({ block: "nearest" });
910
+ }
911
+ setRevealRequest((current) =>
912
+ current?.path === revealRequest.path && current.requestId === revealRequest.requestId
913
+ ? null
914
+ : current,
915
+ );
916
+ }, [renderItems, revealRequest, treeId, usesVirtualTree]);
917
+
803
918
  return (
804
919
  <div className={cn("flex min-h-0 min-w-0 flex-col", className)}>
805
920
  {showToolbar && (
@@ -40,7 +40,7 @@ export const defaultHumanInputFormMessages: HumanInputFormMessages = {
40
40
  title: "Input required",
41
41
  /** Multi-question chrome has no default subtitle; hosts may still override. */
42
42
  description: "",
43
- submit: "Continue",
43
+ submit: "Send answers",
44
44
  skip: "Skip",
45
45
  submitting: "Submitting…",
46
46
  other: "Other",
@@ -87,7 +87,15 @@ export type HumanInputFormProps = {
87
87
  * options, sticky ask/submit chrome. Hosts can replace title/description or
88
88
  * use `useHumanInputRequests` headlessly.
89
89
  */
90
- export function HumanInputForm({
90
+ export function HumanInputForm(props: HumanInputFormProps) {
91
+ // The pending-request read model is refreshed after session events and
92
+ // returns newly allocated question arrays for the same durable request.
93
+ // Key the state owner by that request's lifecycle identity so reconciliation
94
+ // cannot erase an answer that the operator is still typing.
95
+ return <HumanInputRequestForm key={props.request.id} {...props} />;
96
+ }
97
+
98
+ function HumanInputRequestForm({
91
99
  request,
92
100
  onSubmit,
93
101
  submitting = false,
@@ -135,16 +143,6 @@ export function HumanInputForm({
135
143
  const submissionGeneration = useRef(0);
136
144
  const busy = submitting || submittingInternally;
137
145
 
138
- useEffect(() => {
139
- submissionGeneration.current += 1;
140
- submissionInFlight.current = false;
141
- setDrafts(initialDrafts(request.questions));
142
- setValidationErrors({});
143
- setSubmissionError(null);
144
- setSubmittingInternally(false);
145
- setCollapsed(defaultCollapsed);
146
- }, [request.id, request.questions, defaultCollapsed]);
147
-
148
146
  useEffect(() => {
149
147
  if (collapsed) {
150
148
  setOverflowBelow(false);
@@ -369,6 +367,8 @@ export function HumanInputForm({
369
367
  <div key={question.id} data-human-input-question={question.id}>
370
368
  <QuestionControls
371
369
  question={question}
370
+ questionNumber={null}
371
+ labelledBy={titleId}
372
372
  draft={drafts[question.id] ?? emptyDraft()}
373
373
  fieldId={`${formId}-${index}`}
374
374
  error={validationErrors[question.id]}
@@ -391,6 +391,8 @@ export function HumanInputForm({
391
391
  >
392
392
  <QuestionControls
393
393
  question={question}
394
+ questionNumber={index + 1}
395
+ labelledBy={undefined}
394
396
  draft={drafts[question.id] ?? emptyDraft()}
395
397
  fieldId={`${formId}-${index}`}
396
398
  error={validationErrors[question.id]}
@@ -443,7 +445,7 @@ export function HumanInputForm({
443
445
  <button
444
446
  type="submit"
445
447
  disabled={busy}
446
- className="inline-flex min-h-9 items-center rounded-og-md bg-og-accent px-3 py-1.5 text-og-sm font-medium text-og-accent-fg transition-colors hover:bg-og-accent-strong disabled:opacity-50"
448
+ className="inline-flex min-h-9 items-center rounded-og-md bg-og-accent-deep px-3 py-1.5 text-og-sm font-medium text-og-accent-fg transition hover:brightness-110 disabled:opacity-50"
447
449
  >
448
450
  {busy ? messages.submitting : resolvedSubmitLabel}
449
451
  </button>
@@ -454,6 +456,8 @@ export function HumanInputForm({
454
456
 
455
457
  function QuestionControls({
456
458
  question,
459
+ questionNumber,
460
+ labelledBy,
457
461
  draft,
458
462
  fieldId,
459
463
  error,
@@ -466,6 +470,8 @@ function QuestionControls({
466
470
  onUpdate,
467
471
  }: {
468
472
  question: HumanInputQuestion;
473
+ questionNumber: number | null;
474
+ labelledBy: string | undefined;
469
475
  draft: HumanInputAnswerDraft;
470
476
  fieldId: string;
471
477
  error: string | undefined;
@@ -479,6 +485,13 @@ function QuestionControls({
479
485
  }) {
480
486
  const errorId = `${fieldId}-error`;
481
487
  const helpId = `${fieldId}-help`;
488
+ const labelId = `${fieldId}-label`;
489
+ const promptId = `${fieldId}-prompt`;
490
+ const otherChoiceId = `${fieldId}-other-choice`;
491
+ const otherLabelId = `${fieldId}-other-label`;
492
+ const otherTextId = `${fieldId}-other-text`;
493
+ const visibleLabel = question.label ?? question.prompt;
494
+ const controlLabelId = showPromptChrome ? labelId : labelledBy;
482
495
  const hint =
483
496
  question.kind === "multi_select"
484
497
  ? messages.selectionHint(
@@ -488,6 +501,7 @@ function QuestionControls({
488
501
  : null;
489
502
  const describedBy =
490
503
  [
504
+ question.label && showPromptChrome ? promptId : null,
491
505
  question.helpText && showPromptChrome ? helpId : null,
492
506
  hint ? `${fieldId}-hint` : null,
493
507
  error ? errorId : null,
@@ -500,10 +514,15 @@ function QuestionControls({
500
514
  <>
501
515
  <div className="flex flex-wrap items-baseline gap-x-2">
502
516
  <label
517
+ id={labelId}
503
518
  htmlFor={question.kind === "text" ? fieldId : undefined}
504
519
  className="text-og-sm font-medium text-og-fg"
505
520
  >
506
- {question.label ?? question.prompt}
521
+ {questionNumber === null ? null : (
522
+ <span className="mr-1.5 tabular-nums text-og-fg-muted">{questionNumber}.</span>
523
+ )}
524
+ {questionNumber === null ? null : " "}
525
+ {visibleLabel}
507
526
  {question.required && !allowSkip ? (
508
527
  <span aria-hidden className="ml-1 text-og-status-failed">
509
528
  *
@@ -515,7 +534,11 @@ function QuestionControls({
515
534
  ) : null}
516
535
  </label>
517
536
  </div>
518
- {question.label ? <p className="text-og-sm text-og-fg-muted">{question.prompt}</p> : null}
537
+ {question.label ? (
538
+ <p id={promptId} className="text-og-sm text-og-fg-muted">
539
+ {question.prompt}
540
+ </p>
541
+ ) : null}
519
542
  {question.helpText ? (
520
543
  <p id={helpId} className="text-og-xs text-og-fg-subtle">
521
544
  {question.helpText}
@@ -556,6 +579,7 @@ function QuestionControls({
556
579
  }))
557
580
  }
558
581
  aria-invalid={Boolean(error)}
582
+ aria-labelledby={controlLabelId}
559
583
  aria-describedby={describedBy}
560
584
  autoFocus={autoFocus}
561
585
  rows={2}
@@ -564,6 +588,7 @@ function QuestionControls({
564
588
  ) : (
565
589
  <div
566
590
  role={question.kind === "single_select" ? "radiogroup" : "group"}
591
+ aria-labelledby={controlLabelId}
567
592
  aria-describedby={describedBy}
568
593
  className="flex flex-col gap-0.5"
569
594
  >
@@ -613,49 +638,59 @@ function QuestionControls({
613
638
  </label>
614
639
  );
615
640
  })}
616
- {question.allowOther ? (
617
- <label
618
- className={cn(
619
- "flex items-start gap-2.5 rounded-og-md px-2.5 py-2 transition-colors",
620
- draft.otherSelected
621
- ? "bg-og-status-waiting/12 text-og-fg"
622
- : "text-og-fg hover:bg-og-surface-1/80",
623
- )}
624
- >
641
+ <div
642
+ className={cn(
643
+ "flex items-start gap-2.5 rounded-og-md px-2.5 py-2 transition-colors",
644
+ draft.otherSelected
645
+ ? "bg-og-status-waiting/12 text-og-fg"
646
+ : "text-og-fg hover:bg-og-surface-1/80",
647
+ )}
648
+ >
649
+ <input
650
+ id={otherChoiceId}
651
+ type={question.kind === "single_select" ? "radio" : "checkbox"}
652
+ name={question.kind === "single_select" ? fieldId : undefined}
653
+ aria-labelledby={otherLabelId}
654
+ checked={draft.otherSelected}
655
+ autoFocus={autoFocus && firstOption && question.options.length === 0}
656
+ onChange={(event) =>
657
+ onUpdate((current) => ({
658
+ ...current,
659
+ otherSelected: event.target.checked,
660
+ ...(question.kind === "single_select" && event.target.checked
661
+ ? { values: [] }
662
+ : {}),
663
+ }))
664
+ }
665
+ className="mt-2 accent-og-accent"
666
+ />
667
+ <span className="min-w-0 flex-1">
668
+ <label
669
+ id={otherLabelId}
670
+ htmlFor={otherChoiceId}
671
+ className="block text-og-sm font-medium"
672
+ >
673
+ {messages.other}
674
+ </label>
675
+ <label htmlFor={otherTextId} className="sr-only">
676
+ {messages.other} answer for {visibleLabel}
677
+ </label>
625
678
  <input
626
- type={question.kind === "single_select" ? "radio" : "checkbox"}
627
- name={question.kind === "single_select" ? fieldId : undefined}
628
- checked={draft.otherSelected}
629
- autoFocus={autoFocus && firstOption && question.options.length === 0}
679
+ id={otherTextId}
680
+ type="text"
681
+ value={draft.other}
682
+ disabled={!draft.otherSelected || busy}
683
+ placeholder="Type a value…"
630
684
  onChange={(event) =>
631
685
  onUpdate((current) => ({
632
686
  ...current,
633
- otherSelected: event.target.checked,
634
- ...(question.kind === "single_select" && event.target.checked
635
- ? { values: [] }
636
- : {}),
687
+ other: event.target.value,
637
688
  }))
638
689
  }
639
- className="mt-2 accent-og-accent"
690
+ className="mt-1.5 w-full rounded-og-sm border border-og-border bg-og-surface-1 px-2 py-1.5 text-og-sm text-og-fg outline-hidden focus:border-og-accent disabled:opacity-50"
640
691
  />
641
- <span className="min-w-0 flex-1">
642
- <span className="block text-og-sm font-medium">{messages.other}</span>
643
- <input
644
- type="text"
645
- value={draft.other}
646
- disabled={!draft.otherSelected || busy}
647
- placeholder="Type a value…"
648
- onChange={(event) =>
649
- onUpdate((current) => ({
650
- ...current,
651
- other: event.target.value,
652
- }))
653
- }
654
- className="mt-1.5 w-full rounded-og-sm border border-og-border bg-og-surface-1 px-2 py-1.5 text-og-sm text-og-fg outline-hidden focus:border-og-accent disabled:opacity-50"
655
- />
656
- </span>
657
- </label>
658
- ) : null}
692
+ </span>
693
+ </div>
659
694
  </div>
660
695
  )}
661
696
  {error ? (
@@ -678,12 +713,13 @@ export function answersFromDrafts(
678
713
  for (const question of questions) {
679
714
  const draft = drafts[question.id] ?? emptyDraft();
680
715
  const values = question.kind === "text" ? draft.values.filter(Boolean) : draft.values;
681
- const other = draft.otherSelected ? draft.other.trim() : "";
682
- const supplied = values.length + (other ? 1 : 0);
716
+ const other = draft.otherSelected ? draft.other : "";
717
+ const hasOther = Boolean(other.trim());
718
+ const supplied = values.length + (hasOther ? 1 : 0);
683
719
 
684
720
  // Other-selected-but-empty must win over generic "required" — otherwise the
685
721
  // user sees the wrong diagnosis next to a clearly selected control.
686
- if (question.kind !== "text" && draft.otherSelected && !other) {
722
+ if (question.kind !== "text" && draft.otherSelected && !hasOther) {
687
723
  errors[question.id] = messages.otherRequired;
688
724
  continue;
689
725
  }
@@ -708,7 +744,7 @@ export function answersFromDrafts(
708
744
  answers.push({
709
745
  questionId: question.id,
710
746
  values,
711
- ...(other ? { other } : {}),
747
+ ...(hasOther ? { other } : {}),
712
748
  });
713
749
  }
714
750
  }
@@ -20,7 +20,7 @@ export type HumanInputSurfaceProps = {
20
20
 
21
21
  /**
22
22
  * One decision shell for pending structured human-input requests. Parallel
23
- * freezes are shown one at a time (oldest first) as “N of M”; Continue/Skip
23
+ * freezes are shown one at a time (oldest first) as “N of M”; Send answers/Skip
24
24
  * settles the active request and the next remaining set advances automatically
25
25
  * when the authoritative pending list updates.
26
26
  */