@opengeni/react 1.0.1 → 2.3.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 +33 -10
  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-5YK2ZDIG.js +116 -0
  8. package/dist/chunk-5YK2ZDIG.js.map +1 -0
  9. package/dist/chunk-H5YGYNXS.js +329 -0
  10. package/dist/chunk-H5YGYNXS.js.map +1 -0
  11. package/dist/{chunk-7WCLAEFV.js → chunk-ICA6R6TW.js} +2 -2
  12. package/dist/{chunk-7WCLAEFV.js.map → chunk-ICA6R6TW.js.map} +1 -1
  13. package/dist/{chunk-IGC6A2ZI.js → chunk-JOAVA2TW.js} +589 -217
  14. package/dist/chunk-JOAVA2TW.js.map +1 -0
  15. package/dist/{chunk-Y7OEDUJ5.js → chunk-LS4POUTY.js} +1057 -426
  16. package/dist/chunk-LS4POUTY.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-6KLX2RH5.js → chunk-RSXJN73N.js} +365 -21
  24. package/dist/chunk-RSXJN73N.js.map +1 -0
  25. package/dist/{chunk-B6WPZSTT.js → chunk-SE4W4WQ3.js} +78 -16
  26. package/dist/chunk-SE4W4WQ3.js.map +1 -0
  27. package/dist/{chunk-MKPIAGVL.js → chunk-W6NFJXSA.js} +8 -19
  28. package/dist/chunk-W6NFJXSA.js.map +1 -0
  29. package/dist/{chunk-XBZWOM2K.js → chunk-X4A66EWZ.js} +8 -3
  30. package/dist/chunk-X4A66EWZ.js.map +1 -0
  31. package/dist/chunk-X4JAFD5R.js +3907 -0
  32. package/dist/chunk-X4JAFD5R.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 +6 -3
  83. package/dist/realtime.js +36 -14
  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 +50 -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 +103 -59
  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 +215 -18
  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 +53 -17
  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 +529 -25
  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 +77 -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,22 +501,33 @@ 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,
494
508
  ]
495
509
  .filter(Boolean)
496
510
  .join(" ") || undefined;
511
+ const selectOtherDraft = (current: HumanInputAnswerDraft): HumanInputAnswerDraft => ({
512
+ ...current,
513
+ otherSelected: true,
514
+ ...(question.kind === "single_select" ? { values: [] } : {}),
515
+ });
497
516
  return (
498
517
  <>
499
518
  {showPromptChrome ? (
500
519
  <>
501
520
  <div className="flex flex-wrap items-baseline gap-x-2">
502
521
  <label
522
+ id={labelId}
503
523
  htmlFor={question.kind === "text" ? fieldId : undefined}
504
524
  className="text-og-sm font-medium text-og-fg"
505
525
  >
506
- {question.label ?? question.prompt}
526
+ {questionNumber === null ? null : (
527
+ <span className="mr-1.5 tabular-nums text-og-fg-muted">{questionNumber}.</span>
528
+ )}
529
+ {questionNumber === null ? null : " "}
530
+ {visibleLabel}
507
531
  {question.required && !allowSkip ? (
508
532
  <span aria-hidden className="ml-1 text-og-status-failed">
509
533
  *
@@ -515,7 +539,11 @@ function QuestionControls({
515
539
  ) : null}
516
540
  </label>
517
541
  </div>
518
- {question.label ? <p className="text-og-sm text-og-fg-muted">{question.prompt}</p> : null}
542
+ {question.label ? (
543
+ <p id={promptId} className="text-og-sm text-og-fg-muted">
544
+ {question.prompt}
545
+ </p>
546
+ ) : null}
519
547
  {question.helpText ? (
520
548
  <p id={helpId} className="text-og-xs text-og-fg-subtle">
521
549
  {question.helpText}
@@ -556,6 +584,7 @@ function QuestionControls({
556
584
  }))
557
585
  }
558
586
  aria-invalid={Boolean(error)}
587
+ aria-labelledby={controlLabelId}
559
588
  aria-describedby={describedBy}
560
589
  autoFocus={autoFocus}
561
590
  rows={2}
@@ -564,6 +593,7 @@ function QuestionControls({
564
593
  ) : (
565
594
  <div
566
595
  role={question.kind === "single_select" ? "radiogroup" : "group"}
596
+ aria-labelledby={controlLabelId}
567
597
  aria-describedby={describedBy}
568
598
  className="flex flex-col gap-0.5"
569
599
  >
@@ -613,49 +643,62 @@ function QuestionControls({
613
643
  </label>
614
644
  );
615
645
  })}
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
- >
646
+ <div
647
+ className={cn(
648
+ "flex items-start gap-2.5 rounded-og-md px-2.5 py-2 transition-colors",
649
+ draft.otherSelected
650
+ ? "bg-og-status-waiting/12 text-og-fg"
651
+ : "text-og-fg hover:bg-og-surface-1/80",
652
+ )}
653
+ >
654
+ <input
655
+ id={otherChoiceId}
656
+ type={question.kind === "single_select" ? "radio" : "checkbox"}
657
+ name={question.kind === "single_select" ? fieldId : undefined}
658
+ aria-labelledby={otherLabelId}
659
+ checked={draft.otherSelected}
660
+ autoFocus={autoFocus && firstOption && question.options.length === 0}
661
+ onChange={(event) =>
662
+ onUpdate((current) => ({
663
+ ...current,
664
+ otherSelected: event.target.checked,
665
+ ...(question.kind === "single_select" && event.target.checked
666
+ ? { values: [] }
667
+ : {}),
668
+ }))
669
+ }
670
+ className="mt-2 accent-og-accent"
671
+ />
672
+ <span className="min-w-0 flex-1">
673
+ <label
674
+ id={otherLabelId}
675
+ htmlFor={otherChoiceId}
676
+ className="block text-og-sm font-medium"
677
+ >
678
+ {messages.other}
679
+ </label>
680
+ <label htmlFor={otherTextId} className="sr-only">
681
+ {messages.other} answer for {visibleLabel}
682
+ </label>
625
683
  <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}
630
- onChange={(event) =>
684
+ id={otherTextId}
685
+ type="text"
686
+ value={draft.other}
687
+ disabled={busy}
688
+ placeholder="Type a value…"
689
+ onClick={() => onUpdate(selectOtherDraft)}
690
+ onFocus={() => onUpdate(selectOtherDraft)}
691
+ onChange={(event) => {
692
+ const other = event.target.value;
631
693
  onUpdate((current) => ({
632
- ...current,
633
- otherSelected: event.target.checked,
634
- ...(question.kind === "single_select" && event.target.checked
635
- ? { values: [] }
636
- : {}),
637
- }))
638
- }
639
- className="mt-2 accent-og-accent"
694
+ ...selectOtherDraft(current),
695
+ other,
696
+ }));
697
+ }}
698
+ 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
699
  />
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}
700
+ </span>
701
+ </div>
659
702
  </div>
660
703
  )}
661
704
  {error ? (
@@ -678,12 +721,13 @@ export function answersFromDrafts(
678
721
  for (const question of questions) {
679
722
  const draft = drafts[question.id] ?? emptyDraft();
680
723
  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);
724
+ const other = draft.otherSelected ? draft.other : "";
725
+ const hasOther = Boolean(other.trim());
726
+ const supplied = values.length + (hasOther ? 1 : 0);
683
727
 
684
728
  // Other-selected-but-empty must win over generic "required" — otherwise the
685
729
  // user sees the wrong diagnosis next to a clearly selected control.
686
- if (question.kind !== "text" && draft.otherSelected && !other) {
730
+ if (question.kind !== "text" && draft.otherSelected && !hasOther) {
687
731
  errors[question.id] = messages.otherRequired;
688
732
  continue;
689
733
  }
@@ -708,7 +752,7 @@ export function answersFromDrafts(
708
752
  answers.push({
709
753
  questionId: question.id,
710
754
  values,
711
- ...(other ? { other } : {}),
755
+ ...(hasOther ? { other } : {}),
712
756
  });
713
757
  }
714
758
  }
@@ -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
  */