@opengeni/react 2.5.0-canary.2 → 3.0.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 (69) hide show
  1. package/README.md +15 -0
  2. package/dist/accounts.d.ts +64 -0
  3. package/dist/accounts.js +746 -0
  4. package/dist/accounts.js.map +1 -0
  5. package/dist/{browser-viewer-GXUICIAG.js → browser-viewer-AEFHUYZK.js} +3 -3
  6. package/dist/{chunk-MXJGO7LE.js → chunk-D3UIAJKY.js} +52 -2
  7. package/dist/chunk-D3UIAJKY.js.map +1 -0
  8. package/dist/{chunk-QGBJ6GHG.js → chunk-ELIYWBZR.js} +4 -4
  9. package/dist/{chunk-6H6S5V4Z.js → chunk-HQVCKCLG.js} +758 -871
  10. package/dist/chunk-HQVCKCLG.js.map +1 -0
  11. package/dist/{chunk-T7VAYG2I.js → chunk-MZ2G2XBA.js} +54 -2
  12. package/dist/chunk-MZ2G2XBA.js.map +1 -0
  13. package/dist/{chunk-X4JAFD5R.js → chunk-OAM4WAAM.js} +4 -4
  14. package/dist/{chunk-ZUT5QSQB.js → chunk-RGALCTWO.js} +57 -17
  15. package/dist/{chunk-ZUT5QSQB.js.map → chunk-RGALCTWO.js.map} +1 -1
  16. package/dist/chunk-YFKJ7VZH.js +268 -0
  17. package/dist/chunk-YFKJ7VZH.js.map +1 -0
  18. package/dist/{chunk-ATQJEWCA.js → chunk-YJFGAFBD.js} +124 -50
  19. package/dist/chunk-YJFGAFBD.js.map +1 -0
  20. package/dist/components/composer.d.ts +8 -0
  21. package/dist/components/file-browser.d.ts +4 -1
  22. package/dist/components/sandbox-files.d.ts +4 -1
  23. package/dist/components/sandbox-workspace.d.ts +5 -0
  24. package/dist/composer.js +3 -3
  25. package/dist/{computer-viewer-ZOP4Q7A7.js → computer-viewer-FWEZISW4.js} +3 -3
  26. package/dist/embedded-session-client.d.ts +23 -0
  27. package/dist/file-node-visibility.d.ts +15 -0
  28. package/dist/hooks/use-file-attachments.d.ts +9 -2
  29. package/dist/human-input.d.ts +3 -1
  30. package/dist/index.d.ts +5 -1
  31. package/dist/index.js +139 -53
  32. package/dist/index.js.map +1 -1
  33. package/dist/interaction.js +3 -3
  34. package/dist/session-ui.js +4 -4
  35. package/dist/session.d.ts +2 -0
  36. package/dist/session.js +7 -5
  37. package/dist/timeline/index.d.ts +1 -0
  38. package/dist/timeline/screenshot-lightbox.d.ts +16 -1
  39. package/package.json +6 -2
  40. package/src/accounts.tsx +970 -0
  41. package/src/components/chat-composer.tsx +88 -84
  42. package/src/components/composer.tsx +53 -11
  43. package/src/components/file-browser.tsx +33 -20
  44. package/src/components/human-input-surface.tsx +10 -6
  45. package/src/components/message-timeline.tsx +37 -9
  46. package/src/components/sandbox-files.tsx +42 -2
  47. package/src/components/sandbox-workspace.tsx +10 -0
  48. package/src/embedded-session-client.ts +104 -0
  49. package/src/file-node-visibility.ts +66 -0
  50. package/src/hooks/use-composer.ts +64 -2
  51. package/src/hooks/use-file-attachments.ts +38 -6
  52. package/src/hooks/use-human-input.ts +31 -4
  53. package/src/human-input.ts +12 -1
  54. package/src/index.ts +12 -0
  55. package/src/session.ts +5 -0
  56. package/src/timeline/index.ts +1 -0
  57. package/src/timeline/screenshot-lightbox.tsx +93 -29
  58. package/styles/compiled.css +11 -1
  59. package/styles/tokens.css +3 -1
  60. package/dist/chunk-6H6S5V4Z.js.map +0 -1
  61. package/dist/chunk-ATQJEWCA.js.map +0 -1
  62. package/dist/chunk-FK6VG4LL.js +0 -90
  63. package/dist/chunk-FK6VG4LL.js.map +0 -1
  64. package/dist/chunk-MXJGO7LE.js.map +0 -1
  65. package/dist/chunk-T7VAYG2I.js.map +0 -1
  66. /package/dist/{browser-viewer-GXUICIAG.js.map → browser-viewer-AEFHUYZK.js.map} +0 -0
  67. /package/dist/{chunk-QGBJ6GHG.js.map → chunk-ELIYWBZR.js.map} +0 -0
  68. /package/dist/{chunk-X4JAFD5R.js.map → chunk-OAM4WAAM.js.map} +0 -0
  69. /package/dist/{computer-viewer-ZOP4Q7A7.js.map → computer-viewer-FWEZISW4.js.map} +0 -0
@@ -148,90 +148,94 @@ export function ChatComposer({
148
148
  const stackActions = hasControls && Boolean(actionsStart);
149
149
 
150
150
  return (
151
- <Root controller={controller} responsiveBasis={responsiveBasis} className={className}>
152
- <Frame>
153
- <CommandPalette />
154
- <Surface>
155
- <PausedState />
156
- <RestoredResources />
157
- <Attachments />
158
- {header}
159
- {composer.annotations && composer.annotations.length > 0 ? (
160
- <div className="px-3 pt-2 sm:px-4">
161
- <TimelineAnnotationsChip
162
- annotations={composer.annotations}
163
- editable
164
- focusAnnotationId={composer.annotationReviewTargetId}
165
- onFocusConsumed={composer.clearAnnotationReviewTarget}
166
- onUpdate={composer.updateAnnotation}
167
- onRemove={composer.removeAnnotation}
168
- />
169
- </div>
170
- ) : null}
171
- <Input placeholder={placeholder} autoFocus={autoFocus} />
172
- {controller.confirmState ? (
173
- <Confirmation />
174
- ) : (
175
- <Footer
176
- data-og-stack-actions={stackActions ? "" : undefined}
177
- className={stackActions ? "max-sm:flex-nowrap sm:flex-wrap" : undefined}
178
- >
179
- <LayoutGroup id="og-composer-footer">
180
- {hasControls ? (
181
- <Controls
182
- className={stackActions ? "min-w-0 max-sm:flex-1 sm:w-auto" : undefined}
183
- >
184
- {controlsLeading}
185
- <AttachButton className={attachButtonClassName} />
186
- {transcription ? (
187
- <ComposerTranscriptionControl
188
- {...transcription}
189
- suppressed={transcriptionSuppressed}
190
- className={[transcription.className, transcriptionClassName]
191
- .filter(Boolean)
192
- .join(" ")}
193
- />
194
- ) : null}
195
- {models ? (
196
- <motion.span
197
- layout
198
- transition={{ duration: 0.36, ease: [0.22, 1, 0.36, 1] }}
199
- className="inline-flex min-w-0"
200
- >
201
- <ModelPicker
202
- models={models}
203
- value={selectedModel}
204
- onChange={onSelectModel}
151
+ <>
152
+ <Root controller={controller} responsiveBasis={responsiveBasis} className={className}>
153
+ <Frame>
154
+ <CommandPalette />
155
+ <Surface>
156
+ <PausedState />
157
+ <RestoredResources />
158
+ <Attachments />
159
+ {header}
160
+ {composer.annotations && composer.annotations.length > 0 ? (
161
+ <div className="px-3 pt-2 sm:px-4">
162
+ <TimelineAnnotationsChip
163
+ annotations={composer.annotations}
164
+ editable
165
+ focusAnnotationId={composer.annotationReviewTargetId}
166
+ onFocusConsumed={composer.clearAnnotationReviewTarget}
167
+ onUpdate={composer.updateAnnotation}
168
+ onRemove={composer.removeAnnotation}
169
+ />
170
+ </div>
171
+ ) : null}
172
+ <Input placeholder={placeholder} autoFocus={autoFocus} />
173
+ {controller.confirmState ? (
174
+ <Confirmation />
175
+ ) : (
176
+ <Footer
177
+ data-og-stack-actions={stackActions ? "" : undefined}
178
+ className={stackActions ? "max-sm:flex-nowrap sm:flex-wrap" : undefined}
179
+ >
180
+ <LayoutGroup id="og-composer-footer">
181
+ {hasControls ? (
182
+ <Controls
183
+ className={stackActions ? "min-w-0 max-sm:flex-1 sm:w-auto" : undefined}
184
+ >
185
+ {controlsLeading}
186
+ <AttachButton className={attachButtonClassName} />
187
+ {transcription ? (
188
+ <ComposerTranscriptionControl
189
+ {...transcription}
190
+ suppressed={transcriptionSuppressed}
191
+ className={[transcription.className, transcriptionClassName]
192
+ .filter(Boolean)
193
+ .join(" ")}
205
194
  />
206
- </motion.span>
207
- ) : null}
208
- {controlsStart ? (
209
- <motion.span
210
- layout
211
- transition={{ duration: 0.36, ease: [0.22, 1, 0.36, 1] }}
212
- className="inline-flex min-w-0 flex-1 items-center gap-1.5"
213
- >
214
- {controlsStart}
215
- </motion.span>
216
- ) : null}
217
- </Controls>
218
- ) : (
219
- <Hint>{hint}</Hint>
220
- )}
221
- <Actions
222
- className={stackActions ? "max-sm:shrink-0 sm:w-auto sm:justify-end" : undefined}
223
- >
224
- {actionsStart}
225
- <PauseButton />
226
- <SendButton />
227
- </Actions>
228
- </LayoutGroup>
229
- </Footer>
230
- )}
231
- </Surface>
232
- </Frame>
233
- <Help />
234
- <Status />
235
- </Root>
195
+ ) : null}
196
+ {models ? (
197
+ <motion.span
198
+ layout
199
+ transition={{ duration: 0.36, ease: [0.22, 1, 0.36, 1] }}
200
+ className="inline-flex min-w-0"
201
+ >
202
+ <ModelPicker
203
+ models={models}
204
+ value={selectedModel}
205
+ onChange={onSelectModel}
206
+ />
207
+ </motion.span>
208
+ ) : null}
209
+ {controlsStart ? (
210
+ <motion.span
211
+ layout
212
+ transition={{ duration: 0.36, ease: [0.22, 1, 0.36, 1] }}
213
+ className="inline-flex min-w-0 flex-1 items-center gap-1.5"
214
+ >
215
+ {controlsStart}
216
+ </motion.span>
217
+ ) : null}
218
+ </Controls>
219
+ ) : (
220
+ <Hint>{hint}</Hint>
221
+ )}
222
+ <Actions
223
+ className={
224
+ stackActions ? "max-sm:shrink-0 sm:w-auto sm:justify-end" : undefined
225
+ }
226
+ >
227
+ {actionsStart}
228
+ <PauseButton />
229
+ <SendButton />
230
+ </Actions>
231
+ </LayoutGroup>
232
+ </Footer>
233
+ )}
234
+ </Surface>
235
+ </Frame>
236
+ <Help />
237
+ <Status />
238
+ </Root>
239
+ </>
236
240
  );
237
241
  }
@@ -52,6 +52,7 @@ import {
52
52
  } from "../lib/composer-responsive-context";
53
53
  import { composerSubmissionErrorMessage, formatBytes, formatRelativeTime } from "../lib/format";
54
54
  import type { PickerModelRow } from "../model-policy";
55
+ import { useLightboxOptional } from "../timeline/screenshot-lightbox";
55
56
  import { OPEN_WORKSTREAM_CONTROL_EVENT } from "../workstream-control-event";
56
57
  import { CommandPalette as CommandPaletteView } from "./command-palette";
57
58
  import { ModelPicker as ModelPickerView } from "./model-picker";
@@ -135,6 +136,10 @@ export type ChatComposerMessages = {
135
136
  removeRestoredResource: (index: number) => string;
136
137
  uploading: string;
137
138
  uploadFailed: string;
139
+ previewAttachment: (name: string) => string;
140
+ attachmentPreviewLabel: string;
141
+ downloadAttachment: (name: string) => string;
142
+ closeAttachmentPreview: string;
138
143
  retryAttachment: (name: string) => string;
139
144
  retryUpload: string;
140
145
  removeAttachment: (name: string) => string;
@@ -198,6 +203,10 @@ export const defaultChatComposerMessages: ChatComposerMessages = {
198
203
  removeRestoredResource: (index) => `Remove restored resource ${index + 1}`,
199
204
  uploading: "Uploading",
200
205
  uploadFailed: "Upload failed",
206
+ previewAttachment: (name) => `Preview ${name}`,
207
+ attachmentPreviewLabel: "Attachment preview",
208
+ downloadAttachment: (name) => `Download ${name}`,
209
+ closeAttachmentPreview: "Close",
201
210
  retryAttachment: (name) => `Retry ${name}`,
202
211
  retryUpload: "Retry upload",
203
212
  removeAttachment: (name) => `Remove ${name}`,
@@ -367,30 +376,32 @@ export function useChatComposerController({
367
376
  const [submitting, setSubmitting] = useState(false);
368
377
  const submittingRef = useRef(false);
369
378
  const controlOperationRef = useRef(false);
379
+ const deliveryValue = delivery.value;
380
+ const setDeliveryValue = delivery.setValue;
370
381
  const mountedRef = useRef(true);
371
- const deliveredValueRef = useRef(delivery.value);
372
- const liveValueRef = useRef(delivery.value);
373
- const [renderedValue, setRenderedValue] = useState(delivery.value);
374
- const deliveryChanged = delivery.value !== deliveredValueRef.current;
382
+ const deliveredValueRef = useRef(deliveryValue);
383
+ const liveValueRef = useRef(deliveryValue);
384
+ const [renderedValue, setRenderedValue] = useState(deliveryValue);
385
+ const deliveryChanged = deliveryValue !== deliveredValueRef.current;
375
386
  if (deliveryChanged) {
376
- deliveredValueRef.current = delivery.value;
377
- liveValueRef.current = delivery.value;
387
+ deliveredValueRef.current = deliveryValue;
388
+ liveValueRef.current = deliveryValue;
378
389
  }
379
390
  const setComposerValue = useCallback(
380
391
  (next: string) => {
381
392
  liveValueRef.current = next;
382
393
  setRenderedValue(next);
383
- delivery.setValue(next);
394
+ setDeliveryValue(next);
384
395
  },
385
- [delivery.setValue],
396
+ [setDeliveryValue],
386
397
  );
387
- const visibleValue = deliveryChanged ? delivery.value : renderedValue;
398
+ const visibleValue = deliveryChanged ? deliveryValue : renderedValue;
388
399
 
389
400
  useLayoutEffect(() => {
390
401
  if (renderedValue !== liveValueRef.current) {
391
402
  setRenderedValue(liveValueRef.current);
392
403
  }
393
- }, [delivery.value, renderedValue]);
404
+ }, [deliveryValue, renderedValue]);
394
405
 
395
406
  useEffect(() => {
396
407
  mountedRef.current = true;
@@ -935,6 +946,7 @@ export function Attachments() {
935
946
  messages={controller.messages}
936
947
  onRemove={controller.attachments.remove}
937
948
  onRetry={controller.attachments.retry}
949
+ onRetainPreview={controller.attachments.retainPreview}
938
950
  />
939
951
  );
940
952
  }
@@ -1625,12 +1637,15 @@ function AttachmentChips({
1625
1637
  messages,
1626
1638
  onRemove,
1627
1639
  onRetry,
1640
+ onRetainPreview,
1628
1641
  }: {
1629
1642
  attachments: UseFileAttachmentsResult["attachments"];
1630
1643
  messages: ChatComposerMessages;
1631
1644
  onRemove: (id: string) => void;
1632
1645
  onRetry?: ((id: string) => void) | undefined;
1646
+ onRetainPreview: UseFileAttachmentsResult["retainPreview"];
1633
1647
  }) {
1648
+ const lightbox = useLightboxOptional();
1634
1649
  return (
1635
1650
  <div className="flex flex-wrap gap-2 px-3 py-2">
1636
1651
  {attachments.map((attachment) => {
@@ -1651,7 +1666,34 @@ function AttachmentChips({
1651
1666
  : "border-og-border bg-og-surface-2",
1652
1667
  )}
1653
1668
  >
1654
- {attachment.previewUrl ? (
1669
+ {attachment.previewUrl && lightbox ? (
1670
+ <button
1671
+ type="button"
1672
+ className="size-8 shrink-0 overflow-hidden rounded outline-hidden focus-visible:ring-2 focus-visible:ring-og-accent"
1673
+ aria-label={messages.previewAttachment(attachment.name)}
1674
+ onClick={(event) => {
1675
+ const releasePreview = onRetainPreview(attachment.id);
1676
+ lightbox.open(
1677
+ attachment.previewUrl!,
1678
+ attachment.name,
1679
+ event.currentTarget,
1680
+ messages.attachmentPreviewLabel,
1681
+ attachment.name,
1682
+ {
1683
+ download: messages.downloadAttachment(attachment.name),
1684
+ close: messages.closeAttachmentPreview,
1685
+ },
1686
+ releasePreview,
1687
+ );
1688
+ }}
1689
+ >
1690
+ <img
1691
+ src={attachment.previewUrl}
1692
+ alt=""
1693
+ className="h-full w-full object-cover transition-opacity hover:opacity-80"
1694
+ />
1695
+ </button>
1696
+ ) : attachment.previewUrl ? (
1655
1697
  <img
1656
1698
  src={attachment.previewUrl}
1657
1699
  alt=""
@@ -31,6 +31,7 @@ import {
31
31
  } from "../lib/use-portal-token-style";
32
32
  import { useUnicodeFallbackFonts } from "../lib/use-unicode-fonts";
33
33
  import type { FileTreeNode, UseSandboxFilesResult } from "../hooks/use-sandbox-files";
34
+ import { visibleFileTree, type FileNodeVisibilityPredicate } from "../file-node-visibility";
34
35
 
35
36
  export type FileBrowserProps = {
36
37
  /** From `useSandboxFiles(...)`. */
@@ -43,6 +44,8 @@ export type FileBrowserProps = {
43
44
  /** Selection callback for the preview pane. */
44
45
  onSelectFile?: ((path: string) => void) | undefined;
45
46
  selectedPath?: string | undefined;
47
+ /** Presentation-only node filter. Hidden parents never expose their children. */
48
+ isNodeVisible?: FileNodeVisibilityPredicate | undefined;
46
49
  /** Deliberately reveal this selected path by lazily opening every loaded
47
50
  * ancestor and scrolling its row into view. The path is opaque and is never
48
51
  * normalized or re-rooted here. */
@@ -183,6 +186,7 @@ export function FileBrowser({
183
186
  fallback,
184
187
  onSelectFile,
185
188
  selectedPath,
189
+ isNodeVisible,
186
190
  revealPath,
187
191
  revealPathRequestId,
188
192
  renderNode,
@@ -220,6 +224,10 @@ export function FileBrowser({
220
224
  () => (typeof document === "undefined" ? null : document.getElementById(treeId)),
221
225
  [treeId],
222
226
  );
227
+ const visibleTree = useMemo(
228
+ () => visibleFileTree(result.tree, isNodeVisible),
229
+ [isNodeVisible, result.tree],
230
+ );
223
231
 
224
232
  useBrowserLayoutEffect(() => {
225
233
  if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
@@ -255,11 +263,16 @@ export function FileBrowser({
255
263
 
256
264
  // The keyboard cursor: an explicitly-navigated node falls back to the
257
265
  // externally-selected file so arrow keys pick up where the preview pane is.
258
- const cursor = active ?? selectedPath ?? null;
266
+ const visibleActive = active && findNode(visibleTree, active) ? active : null;
267
+ const cursor =
268
+ visibleActive ?? (selectedPath && findNode(visibleTree, selectedPath) ? selectedPath : null);
269
+ useEffect(() => {
270
+ if (active !== null && visibleActive === null) setActive(null);
271
+ }, [active, visibleActive]);
259
272
 
260
273
  const expand = useCallback(
261
274
  async (path: string) => {
262
- const node = findNode(result.tree, path);
275
+ const node = findNode(visibleTree, path);
263
276
  if (node && node.kind === "dir" && node.children === undefined) {
264
277
  setLoadingPaths((prev) => new Set(prev).add(path));
265
278
  try {
@@ -273,7 +286,7 @@ export function FileBrowser({
273
286
  }
274
287
  }
275
288
  },
276
- [result],
289
+ [result, visibleTree],
277
290
  );
278
291
 
279
292
  const open = useCallback(
@@ -286,7 +299,7 @@ export function FileBrowser({
286
299
 
287
300
  useEffect(() => {
288
301
  if (!revealRequest) return;
289
- const trace = traceRevealPath(result.tree, revealRequest.path);
302
+ const trace = traceRevealPath(visibleTree, revealRequest.path);
290
303
  if (trace.directories.length > 0) {
291
304
  setExpanded((previous) => {
292
305
  const next = new Set(previous);
@@ -310,7 +323,7 @@ export function FileBrowser({
310
323
  }
311
324
  attemptedRevealPathsRef.current.add(frontier);
312
325
  void expand(frontier);
313
- }, [expand, loadingPaths, result.expandingPaths, result.tree, revealRequest]);
326
+ }, [expand, loadingPaths, result.expandingPaths, revealRequest, visibleTree]);
314
327
 
315
328
  const toggle = useCallback(
316
329
  async (node: FileTreeNode) => {
@@ -362,9 +375,9 @@ export function FileBrowser({
362
375
  }
363
376
  }
364
377
  };
365
- walk(result.tree, 0);
378
+ walk(visibleTree, 0);
366
379
  return items;
367
- }, [result.tree, expanded, draftCreate, loadingPaths, result.expandingPaths, cold]);
380
+ }, [visibleTree, expanded, draftCreate, loadingPaths, result.expandingPaths, cold]);
368
381
  const unicodeFontPaths = useMemo(
369
382
  () => renderItems.flatMap((item) => (item.type === "node" ? [item.node.path] : [])),
370
383
  [renderItems],
@@ -391,9 +404,9 @@ export function FileBrowser({
391
404
  if (node.children) visit(node.children);
392
405
  });
393
406
  };
394
- visit(result.tree);
407
+ visit(visibleTree);
395
408
  return positions;
396
- }, [result.tree]);
409
+ }, [visibleTree]);
397
410
  const cursorIndex = cursor ? (rowIndexByPath.get(cursor) ?? -1) : -1;
398
411
  const activeDescendantId = cursorIndex >= 0 ? `${treeId}-item-${cursorIndex}` : undefined;
399
412
 
@@ -485,7 +498,7 @@ export function FileBrowser({
485
498
  const draft = draftRename;
486
499
  setDraftRename(null);
487
500
  if (!draft || !supportsMutation) return;
488
- const node = findNode(result.tree, draft.path);
501
+ const node = findNode(visibleTree, draft.path);
489
502
  const trimmed = name.trim().replace(/\/+$/, "");
490
503
  if (!node || !trimmed || trimmed === node.name) return;
491
504
  const newPath = joinPath(parentOf(draft.path), trimmed);
@@ -502,21 +515,21 @@ export function FileBrowser({
502
515
  setBusy(false);
503
516
  }
504
517
  },
505
- [draftRename, supportsMutation, result, selectedPath, active, onSelectFile],
518
+ [draftRename, supportsMutation, result, selectedPath, active, onSelectFile, visibleTree],
506
519
  );
507
520
 
508
521
  // Begin a new file/folder under the active node's directory (or root).
509
522
  const startCreate = useCallback(
510
523
  (kind: "file" | "dir") => {
511
524
  if (!supportsMutation) return;
512
- const anchor = cursor ? findNode(result.tree, cursor) : undefined;
525
+ const anchor = cursor ? findNode(visibleTree, cursor) : undefined;
513
526
  const parent = anchor ? (anchor.kind === "dir" ? anchor.path : parentOf(anchor.path)) : "";
514
527
  if (parent) open(parent);
515
528
  setDraftRename(null);
516
529
  setMenu(null);
517
530
  setDraftCreate({ parent, kind });
518
531
  },
519
- [supportsMutation, cursor, result.tree, open],
532
+ [supportsMutation, cursor, open, visibleTree],
520
533
  );
521
534
 
522
535
  const startRename = useCallback(
@@ -534,7 +547,7 @@ export function FileBrowser({
534
547
  async (targetDir: string, sourcePath: string) => {
535
548
  setDragOver(null);
536
549
  if (!supportsMutation || !sourcePath) return;
537
- const src = findNode(result.tree, sourcePath);
550
+ const src = findNode(visibleTree, sourcePath);
538
551
  if (!src) return;
539
552
  // No-op drops: onto its own current parent, onto itself, or into its own subtree.
540
553
  if (parentOf(sourcePath) === targetDir) return;
@@ -554,7 +567,7 @@ export function FileBrowser({
554
567
  setBusy(false);
555
568
  }
556
569
  },
557
- [supportsMutation, result, selectedPath, active, onSelectFile],
570
+ [supportsMutation, result, selectedPath, active, onSelectFile, visibleTree],
558
571
  );
559
572
 
560
573
  const onKeyDown = useCallback(
@@ -691,9 +704,9 @@ export function FileBrowser({
691
704
  };
692
705
  }, [menu]);
693
706
 
694
- const showErrorEmpty = result.error && result.tree.length === 0 && !draftCreate;
695
- const showDegradedTree = result.error && result.tree.length > 0;
696
- const showEmpty = !result.loading && result.tree.length === 0 && !draftCreate;
707
+ const showErrorEmpty = result.error && visibleTree.length === 0 && !draftCreate;
708
+ const showDegradedTree = result.error && visibleTree.length > 0;
709
+ const showEmpty = !result.loading && visibleTree.length === 0 && !draftCreate;
697
710
  // Retry lives next to the state it explains (the degraded/empty notice), so do
698
711
  // not render a second toolbar retry for the same failure.
699
712
  const showToolbar = supportsMutation || result.loading;
@@ -934,7 +947,7 @@ export function FileBrowser({
934
947
  <ToolbarButton
935
948
  label="Rename"
936
949
  onClick={() => {
937
- const node = cursor ? findNode(result.tree, cursor) : undefined;
950
+ const node = cursor ? findNode(visibleTree, cursor) : undefined;
938
951
  if (node) startRename(node);
939
952
  }}
940
953
  disabled={busy || !cursor}
@@ -944,7 +957,7 @@ export function FileBrowser({
944
957
  <ToolbarButton
945
958
  label="Delete"
946
959
  onClick={(event) => {
947
- const node = cursor ? findNode(result.tree, cursor) : undefined;
960
+ const node = cursor ? findNode(visibleTree, cursor) : undefined;
948
961
  if (node) void runDelete(node, event.currentTarget);
949
962
  }}
950
963
  disabled={busy || !cursor}
@@ -1,5 +1,6 @@
1
1
  import type { SessionHumanInputRequest, SubmitHumanInputResponseRequest } from "@opengeni/sdk";
2
2
  import { useEffect, useMemo, useRef } from "react";
3
+ import { isActionableHumanInputRequest } from "../human-input";
3
4
  import { cn } from "../lib/cn";
4
5
  import {
5
6
  HumanInputForm,
@@ -36,12 +37,15 @@ export function HumanInputSurface({
36
37
  const batchTotalRef = useRef(0);
37
38
 
38
39
  const ordered = useMemo(() => {
39
- return [...requests].sort((a, b) => {
40
- const aAt = a.createdAt ? Date.parse(a.createdAt) : 0;
41
- const bAt = b.createdAt ? Date.parse(b.createdAt) : 0;
42
- if (aAt !== bAt) return aAt - bAt;
43
- return a.id.localeCompare(b.id);
44
- });
40
+ const now = Date.now();
41
+ return requests
42
+ .filter((request) => isActionableHumanInputRequest(request, now))
43
+ .sort((a, b) => {
44
+ const aAt = a.createdAt ? Date.parse(a.createdAt) : 0;
45
+ const bAt = b.createdAt ? Date.parse(b.createdAt) : 0;
46
+ if (aAt !== bAt) return aAt - bAt;
47
+ return a.id.localeCompare(b.id);
48
+ });
45
49
  }, [requests]);
46
50
 
47
51
  useEffect(() => {
@@ -237,6 +237,13 @@ export type MessageTimelineProps = {
237
237
  * alone is wrong. Late layout while unpinned stays browser-owned.
238
238
  */
239
239
  const PIN_THRESHOLD_PX = 48;
240
+ /**
241
+ * A pinned timeline can still have meaningful pre-existing debt while the
242
+ * tip-follow camera catches up. Surface the existing explicit jump once that
243
+ * debt is large enough to be useful, without flashing the control for ordinary
244
+ * line-sized streaming movement.
245
+ */
246
+ const JUMP_TO_LATEST_CATCHUP_DEBT_PX = 240;
240
247
  /**
241
248
  * Prefetch older history when the top sentinel is this far from the viewport.
242
249
  * After a page loads we stay cool until the reader leaves this band (scrolls
@@ -432,6 +439,8 @@ export function MessageTimeline({
432
439
  const bottomSentinelRef = useRef<HTMLDivElement | null>(null);
433
440
  const previousBulkFirstKeyRef = useRef<string | null | undefined>(undefined);
434
441
  const [pinned, setPinned] = useState(true);
442
+ const [canSkipTipCatchup, setCanSkipTipCatchup] = useState(false);
443
+ const canSkipTipCatchupRef = useRef(false);
435
444
  const [bulkActive, setBulkActive] = useState(true);
436
445
  // Older history prefetch is user-driven: a window shorter than the viewport
437
446
  // + rootMargin would otherwise keep the top sentinel intersecting and fetch
@@ -545,15 +554,28 @@ export function MessageTimeline({
545
554
  previousBulkFirstKeyRef.current !== firstGroupKey;
546
555
  const bulkRender = allGroups.length > 0 && (bulkActive || firstKeyChangedForBulk);
547
556
 
557
+ const applyCanSkipTipCatchup = useCallback((value: boolean) => {
558
+ if (canSkipTipCatchupRef.current !== value) {
559
+ canSkipTipCatchupRef.current = value;
560
+ setCanSkipTipCatchup(value);
561
+ }
562
+ }, []);
563
+
548
564
  // The ONLY writer of the pinned flag. Ref and state move together, so
549
565
  // behavior (refs read by rAF callbacks) and rendering (the anchor class,
550
566
  // the Jump-to-latest button) can never desync.
551
- const applyPinned = useCallback((value: boolean) => {
552
- if (pinnedRef.current !== value) {
553
- pinnedRef.current = value;
554
- setPinned(value);
555
- }
556
- }, []);
567
+ const applyPinned = useCallback(
568
+ (value: boolean) => {
569
+ if (pinnedRef.current !== value) {
570
+ pinnedRef.current = value;
571
+ setPinned(value);
572
+ }
573
+ if (!value) {
574
+ applyCanSkipTipCatchup(false);
575
+ }
576
+ },
577
+ [applyCanSkipTipCatchup],
578
+ );
557
579
 
558
580
  const rearmOlderPrefetchAfterLeavingTop = useCallback((node: HTMLElement) => {
559
581
  if (node.scrollTop > OLDER_PREFETCH_MARGIN_PX) {
@@ -757,8 +779,9 @@ export function MessageTimeline({
757
779
  lastClientHeight: node.clientHeight,
758
780
  cameraTop: null,
759
781
  };
782
+ applyCanSkipTipCatchup(false);
760
783
  },
761
- [cancelLeaveFallback, stopFollow, syncScrollBaseline, writeScrollTop],
784
+ [applyCanSkipTipCatchup, cancelLeaveFallback, stopFollow, syncScrollBaseline, writeScrollTop],
762
785
  );
763
786
 
764
787
  const beginUserMessageDisclosureChange = useCallback(
@@ -965,6 +988,10 @@ export function MessageTimeline({
965
988
  followRef.current = result.state;
966
989
  writeScrollTop(node, result.scrollTop);
967
990
  syncScrollBaseline(node);
991
+ applyCanSkipTipCatchup(
992
+ result.state.running &&
993
+ maxScrollOf(node) - node.scrollTop >= JUMP_TO_LATEST_CATCHUP_DEBT_PX,
994
+ );
968
995
  if (result.state.running) {
969
996
  cancelLeaveFallback();
970
997
  if (followFrameRef.current == null) {
@@ -981,7 +1008,7 @@ export function MessageTimeline({
981
1008
  followFrameRef.current = null;
982
1009
  }
983
1010
  },
984
- [cancelLeaveFallback, stopFollow, syncScrollBaseline, writeScrollTop],
1011
+ [applyCanSkipTipCatchup, cancelLeaveFallback, stopFollow, syncScrollBaseline, writeScrollTop],
985
1012
  );
986
1013
  driveFollowRef.current = driveFollow;
987
1014
 
@@ -1825,9 +1852,10 @@ export function MessageTimeline({
1825
1852
  ) : null}
1826
1853
  </AnimatePresence>
1827
1854
  <AnimatePresence>
1828
- {((!pinned && autoFollow) || hasNewer) && autoFollow ? (
1855
+ {((!pinned && autoFollow) || hasNewer || canSkipTipCatchup) && autoFollow ? (
1829
1856
  <motion.button
1830
1857
  type="button"
1858
+ data-og-jump-to-latest=""
1831
1859
  initial={{ opacity: 0, y: 8 }}
1832
1860
  animate={{ opacity: 1, y: 0 }}
1833
1861
  exit={{ opacity: 0, y: 8 }}