@opengeni/react 2.5.0-canary.1 → 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 (70) 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-KSDXER4X.js → chunk-RGALCTWO.js} +64 -15
  15. package/dist/chunk-RGALCTWO.js.map +1 -0
  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 +61 -9
  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-KSDXER4X.js.map +0 -1
  65. package/dist/chunk-MXJGO7LE.js.map +0 -1
  66. package/dist/chunk-T7VAYG2I.js.map +0 -1
  67. /package/dist/{browser-viewer-GXUICIAG.js.map → browser-viewer-AEFHUYZK.js.map} +0 -0
  68. /package/dist/{chunk-QGBJ6GHG.js.map → chunk-ELIYWBZR.js.map} +0 -0
  69. /package/dist/{chunk-X4JAFD5R.js.map → chunk-OAM4WAAM.js.map} +0 -0
  70. /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
  }
@@ -20,6 +20,7 @@ import {
20
20
  useContext,
21
21
  useEffect,
22
22
  useId,
23
+ useLayoutEffect,
23
24
  useMemo,
24
25
  useRef,
25
26
  useState,
@@ -51,6 +52,7 @@ import {
51
52
  } from "../lib/composer-responsive-context";
52
53
  import { composerSubmissionErrorMessage, formatBytes, formatRelativeTime } from "../lib/format";
53
54
  import type { PickerModelRow } from "../model-policy";
55
+ import { useLightboxOptional } from "../timeline/screenshot-lightbox";
54
56
  import { OPEN_WORKSTREAM_CONTROL_EVENT } from "../workstream-control-event";
55
57
  import { CommandPalette as CommandPaletteView } from "./command-palette";
56
58
  import { ModelPicker as ModelPickerView } from "./model-picker";
@@ -134,6 +136,10 @@ export type ChatComposerMessages = {
134
136
  removeRestoredResource: (index: number) => string;
135
137
  uploading: string;
136
138
  uploadFailed: string;
139
+ previewAttachment: (name: string) => string;
140
+ attachmentPreviewLabel: string;
141
+ downloadAttachment: (name: string) => string;
142
+ closeAttachmentPreview: string;
137
143
  retryAttachment: (name: string) => string;
138
144
  retryUpload: string;
139
145
  removeAttachment: (name: string) => string;
@@ -197,6 +203,10 @@ export const defaultChatComposerMessages: ChatComposerMessages = {
197
203
  removeRestoredResource: (index) => `Remove restored resource ${index + 1}`,
198
204
  uploading: "Uploading",
199
205
  uploadFailed: "Upload failed",
206
+ previewAttachment: (name) => `Preview ${name}`,
207
+ attachmentPreviewLabel: "Attachment preview",
208
+ downloadAttachment: (name) => `Download ${name}`,
209
+ closeAttachmentPreview: "Close",
200
210
  retryAttachment: (name) => `Retry ${name}`,
201
211
  retryUpload: "Retry upload",
202
212
  removeAttachment: (name) => `Remove ${name}`,
@@ -366,21 +376,32 @@ export function useChatComposerController({
366
376
  const [submitting, setSubmitting] = useState(false);
367
377
  const submittingRef = useRef(false);
368
378
  const controlOperationRef = useRef(false);
379
+ const deliveryValue = delivery.value;
380
+ const setDeliveryValue = delivery.setValue;
369
381
  const mountedRef = useRef(true);
370
- const deliveredValueRef = useRef(delivery.value);
371
- const liveValueRef = useRef(delivery.value);
372
- if (delivery.value !== deliveredValueRef.current) {
373
- deliveredValueRef.current = delivery.value;
374
- liveValueRef.current = delivery.value;
382
+ const deliveredValueRef = useRef(deliveryValue);
383
+ const liveValueRef = useRef(deliveryValue);
384
+ const [renderedValue, setRenderedValue] = useState(deliveryValue);
385
+ const deliveryChanged = deliveryValue !== deliveredValueRef.current;
386
+ if (deliveryChanged) {
387
+ deliveredValueRef.current = deliveryValue;
388
+ liveValueRef.current = deliveryValue;
375
389
  }
376
390
  const setComposerValue = useCallback(
377
391
  (next: string) => {
378
392
  liveValueRef.current = next;
379
- delivery.setValue(next);
393
+ setRenderedValue(next);
394
+ setDeliveryValue(next);
380
395
  },
381
- [delivery.setValue],
396
+ [setDeliveryValue],
382
397
  );
383
- const visibleValue = liveValueRef.current;
398
+ const visibleValue = deliveryChanged ? deliveryValue : renderedValue;
399
+
400
+ useLayoutEffect(() => {
401
+ if (renderedValue !== liveValueRef.current) {
402
+ setRenderedValue(liveValueRef.current);
403
+ }
404
+ }, [deliveryValue, renderedValue]);
384
405
 
385
406
  useEffect(() => {
386
407
  mountedRef.current = true;
@@ -925,6 +946,7 @@ export function Attachments() {
925
946
  messages={controller.messages}
926
947
  onRemove={controller.attachments.remove}
927
948
  onRetry={controller.attachments.retry}
949
+ onRetainPreview={controller.attachments.retainPreview}
928
950
  />
929
951
  );
930
952
  }
@@ -1615,12 +1637,15 @@ function AttachmentChips({
1615
1637
  messages,
1616
1638
  onRemove,
1617
1639
  onRetry,
1640
+ onRetainPreview,
1618
1641
  }: {
1619
1642
  attachments: UseFileAttachmentsResult["attachments"];
1620
1643
  messages: ChatComposerMessages;
1621
1644
  onRemove: (id: string) => void;
1622
1645
  onRetry?: ((id: string) => void) | undefined;
1646
+ onRetainPreview: UseFileAttachmentsResult["retainPreview"];
1623
1647
  }) {
1648
+ const lightbox = useLightboxOptional();
1624
1649
  return (
1625
1650
  <div className="flex flex-wrap gap-2 px-3 py-2">
1626
1651
  {attachments.map((attachment) => {
@@ -1641,7 +1666,34 @@ function AttachmentChips({
1641
1666
  : "border-og-border bg-og-surface-2",
1642
1667
  )}
1643
1668
  >
1644
- {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 ? (
1645
1697
  <img
1646
1698
  src={attachment.previewUrl}
1647
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 }}