@opengeni/react 0.13.0 → 0.20.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 (113) hide show
  1. package/README.md +129 -23
  2. package/dist/chunk-6BXBGJ3B.js +1956 -0
  3. package/dist/chunk-6BXBGJ3B.js.map +1 -0
  4. package/dist/chunk-6UT5OC3P.js +2133 -0
  5. package/dist/chunk-6UT5OC3P.js.map +1 -0
  6. package/dist/chunk-EBTT3MYN.js +92 -0
  7. package/dist/chunk-EBTT3MYN.js.map +1 -0
  8. package/dist/chunk-HHFA4AFX.js +425 -0
  9. package/dist/chunk-HHFA4AFX.js.map +1 -0
  10. package/dist/chunk-LAZ2A743.js +2393 -0
  11. package/dist/chunk-LAZ2A743.js.map +1 -0
  12. package/dist/chunk-MRSECXRP.js +563 -0
  13. package/dist/chunk-MRSECXRP.js.map +1 -0
  14. package/dist/chunk-TK7G6XLT.js +18 -0
  15. package/dist/chunk-TK7G6XLT.js.map +1 -0
  16. package/dist/composer-DvUaa5ki.d.ts +585 -0
  17. package/dist/composer.d.ts +6 -0
  18. package/dist/composer.js +56 -0
  19. package/dist/composer.js.map +1 -0
  20. package/dist/index.d.ts +642 -1080
  21. package/dist/index.js +7672 -8005
  22. package/dist/index.js.map +1 -1
  23. package/dist/machines.d.ts +412 -3
  24. package/dist/machines.js +25 -1
  25. package/dist/noto-sans-arabic-loader-IA2T4X2A.js +21 -0
  26. package/dist/noto-sans-arabic-loader-IA2T4X2A.js.map +1 -0
  27. package/dist/noto-sans-jp-loader.generated-HFGLYBR3.js +292 -0
  28. package/dist/noto-sans-jp-loader.generated-HFGLYBR3.js.map +1 -0
  29. package/dist/queue-surface-implementation-NQMV2ML3.js +890 -0
  30. package/dist/queue-surface-implementation-NQMV2ML3.js.map +1 -0
  31. package/dist/session-Gd4iVPYw.d.ts +425 -0
  32. package/dist/session-context-DIFFlXKc.d.ts +52 -0
  33. package/dist/session.d.ts +4 -0
  34. package/dist/session.js +50 -0
  35. package/dist/session.js.map +1 -0
  36. package/dist/use-composer-BCfm4mzX.d.ts +112 -0
  37. package/package.json +23 -4
  38. package/src/approvals.ts +5 -3
  39. package/src/client.ts +50 -1
  40. package/src/components/approval-surface.tsx +198 -0
  41. package/src/components/chat-composer.tsx +104 -751
  42. package/src/components/code-editor.tsx +104 -26
  43. package/src/components/command-palette.tsx +8 -2
  44. package/src/components/composer-transcription-control.tsx +211 -0
  45. package/src/components/composer.tsx +1536 -0
  46. package/src/components/desktop-viewer.tsx +3 -3
  47. package/src/components/enrollment-consent.tsx +2 -2
  48. package/src/components/file-browser.tsx +367 -29
  49. package/src/components/human-input-form.tsx +417 -0
  50. package/src/components/machine-card.tsx +82 -16
  51. package/src/components/machine-health-pill.tsx +68 -0
  52. package/src/components/machine-metrics.tsx +10 -24
  53. package/src/components/machines/health.ts +146 -0
  54. package/src/components/machines/machine-detail.tsx +220 -0
  55. package/src/components/machines/metric-history-chart.tsx +298 -0
  56. package/src/components/machines/metric-sparkline.tsx +76 -0
  57. package/src/components/machines/series.ts +113 -0
  58. package/src/components/machines-dashboard.tsx +16 -4
  59. package/src/components/message-timeline.tsx +15 -7
  60. package/src/components/model-picker.tsx +12 -3
  61. package/src/components/pierre-diff.tsx +32 -6
  62. package/src/components/queue-surface-implementation.tsx +1041 -0
  63. package/src/components/queue-surface-state.tsx +94 -0
  64. package/src/components/queue-surface.tsx +60 -0
  65. package/src/components/sandbox-files.tsx +230 -21
  66. package/src/components/sandbox-terminal.tsx +8 -2
  67. package/src/components/sandbox-workspace.tsx +489 -135
  68. package/src/components/session-status.tsx +0 -6
  69. package/src/components/workbench-changes.tsx +145 -50
  70. package/src/components/workspace-dock.tsx +329 -68
  71. package/src/composer.ts +60 -0
  72. package/src/hooks/internal.ts +115 -34
  73. package/src/hooks/use-composer.ts +635 -74
  74. package/src/hooks/use-human-input.ts +131 -0
  75. package/src/hooks/use-machine-chip.ts +2 -2
  76. package/src/hooks/use-machines.ts +27 -11
  77. package/src/hooks/use-sandbox-files.ts +310 -62
  78. package/src/hooks/use-sandbox-git.ts +154 -40
  79. package/src/hooks/use-sandbox-terminal.ts +28 -6
  80. package/src/hooks/use-session-capabilities.ts +38 -9
  81. package/src/hooks/use-session-control.ts +43 -15
  82. package/src/hooks/use-session-events.ts +523 -48
  83. package/src/hooks/use-session-lineage.ts +15 -6
  84. package/src/hooks/use-session.ts +10 -2
  85. package/src/hooks/use-slash-commands.ts +38 -38
  86. package/src/hooks/use-transcription.ts +757 -0
  87. package/src/hooks/use-turn-queue.ts +273 -77
  88. package/src/hooks/use-windowed-sections.ts +2 -2
  89. package/src/hooks/use-workspace-capture.ts +146 -40
  90. package/src/hooks/use-workspace-edit.ts +50 -14
  91. package/src/hooks/use-workspace-sessions.ts +3 -0
  92. package/src/human-input.ts +72 -0
  93. package/src/index.ts +88 -12
  94. package/src/lib/noto-sans-arabic-loader.ts +21 -0
  95. package/src/lib/noto-sans-jp-loader.generated.ts +295 -0
  96. package/src/lib/use-portal-token-style.ts +51 -0
  97. package/src/lib/use-unicode-fonts.ts +57 -0
  98. package/src/machines.ts +16 -0
  99. package/src/provider.tsx +163 -41
  100. package/src/session-context.ts +135 -0
  101. package/src/session.ts +79 -0
  102. package/src/timeline/parsers.ts +78 -6
  103. package/src/timeline/projection.ts +36 -6
  104. package/src/timeline/tool-renderers.tsx +41 -0
  105. package/src/timeline/turn-summary.tsx +2 -2
  106. package/src/timeline/types.ts +16 -8
  107. package/styles/index.css +78 -0
  108. package/styles/index.d.ts +2 -0
  109. package/styles/tokens.css +11 -6
  110. package/styles/tokens.d.ts +2 -0
  111. package/dist/chunk-NFYVQWIB.js +0 -1377
  112. package/dist/chunk-NFYVQWIB.js.map +0 -1
  113. package/dist/machines-CnlMb7E-.d.ts +0 -329
@@ -621,7 +621,7 @@ function WarmingNotice({ onRetry }: { onRetry?: (() => void) | undefined }) {
621
621
  <button
622
622
  type="button"
623
623
  onClick={onRetry}
624
- className="rounded-og-sm border border-og-border px-3 py-1.5 text-og-sm text-og-fg-muted transition-colors hover:text-og-fg pointer-coarse:min-h-10"
624
+ className="rounded-og-sm border border-og-border px-3 py-1.5 text-og-sm text-og-fg-muted transition-colors hover:text-og-fg pointer-coarse:min-h-11"
625
625
  >
626
626
  Taking too long? Retry
627
627
  </button>
@@ -644,7 +644,7 @@ function DefaultConsentGate({ shared, onAccept }: { shared: boolean; onAccept: (
644
644
  <button
645
645
  type="button"
646
646
  onClick={onAccept}
647
- className="rounded-og-sm bg-og-accent px-3 py-1.5 text-og-sm font-medium text-og-accent-fg pointer-coarse:min-h-10"
647
+ className="rounded-og-sm bg-og-accent px-3 py-1.5 text-og-sm font-medium text-og-accent-fg pointer-coarse:min-h-11"
648
648
  >
649
649
  I understand — show the desktop
650
650
  </button>
@@ -664,7 +664,7 @@ function defaultNotice(title: string, body: string, onRetry?: () => void): React
664
664
  <button
665
665
  type="button"
666
666
  onClick={onRetry}
667
- className="mt-3 rounded-og-sm border border-og-border px-3 py-1.5 text-og-sm transition-colors hover:border-og-accent pointer-coarse:min-h-10"
667
+ className="mt-3 rounded-og-sm border border-og-border px-3 py-1.5 text-og-sm transition-colors hover:border-og-accent pointer-coarse:min-h-11"
668
668
  >
669
669
  Reconnect
670
670
  </button>
@@ -198,7 +198,7 @@ export function EnrollmentConsent({
198
198
  data-deny
199
199
  disabled={busy}
200
200
  onClick={() => onDeny?.()}
201
- className="flex-1 rounded-og-sm border border-og-border px-3 py-2 text-og-sm font-medium text-og-fg-muted transition-colors hover:border-og-border-strong hover:text-og-fg disabled:opacity-50 pointer-coarse:min-h-10"
201
+ className="flex-1 rounded-og-sm border border-og-border px-3 py-2 text-og-sm font-medium text-og-fg-muted transition-colors hover:border-og-border-strong hover:text-og-fg disabled:opacity-50 pointer-coarse:min-h-11"
202
202
  >
203
203
  Cancel
204
204
  </button>
@@ -207,7 +207,7 @@ export function EnrollmentConsent({
207
207
  data-approve
208
208
  disabled={busy}
209
209
  onClick={() => onApprove?.(allowScreenControl)}
210
- 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-10"
210
+ 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
211
  >
212
212
  {busy ? "Connecting…" : "Grant full access"}
213
213
  </button>
@@ -6,20 +6,26 @@ import {
6
6
  FolderPlusIcon,
7
7
  PencilIcon,
8
8
  RefreshCwIcon,
9
+ TriangleAlertIcon,
9
10
  Trash2Icon,
10
11
  } from "lucide-react";
11
12
  import {
12
13
  type KeyboardEvent as ReactKeyboardEvent,
14
+ type MouseEvent as ReactMouseEvent,
13
15
  type ReactNode,
14
16
  useCallback,
15
17
  useEffect,
18
+ useId,
16
19
  useLayoutEffect,
17
20
  useMemo,
18
21
  useRef,
19
22
  useState,
20
23
  } from "react";
24
+ import { AlertDialog } from "radix-ui";
21
25
  import { VList, type VListHandle } from "virtua";
22
26
  import { cn } from "../lib/cn";
27
+ import { type PortalTokenStyle, usePortalTokenStyle } from "../lib/use-portal-token-style";
28
+ import { useUnicodeFallbackFonts } from "../lib/use-unicode-fonts";
23
29
  import type { FileTreeNode, UseSandboxFilesResult } from "../hooks/use-sandbox-files";
24
30
 
25
31
  export type FileBrowserProps = {
@@ -45,7 +51,8 @@ export type FileBrowserProps = {
45
51
  editable?: boolean | undefined;
46
52
  /**
47
53
  * Confirm a (recursive) delete before it runs. Return `false` to cancel.
48
- * Defaults to `window.confirm`. Pass a no-op returning `true` to skip.
54
+ * The default is an accessible, non-blocking confirmation dialog. Supply this
55
+ * callback to delegate confirmation to a host-owned dialog.
49
56
  */
50
57
  confirmDelete?: ((node: FileTreeNode) => boolean | Promise<boolean>) | undefined;
51
58
  className?: string | undefined;
@@ -144,10 +151,18 @@ export function FileBrowser({
144
151
  const [draftRename, setDraftRename] = useState<DraftRename | null>(null);
145
152
  const [dragOver, setDragOver] = useState<string | null>(null);
146
153
  const [menu, setMenu] = useState<ContextMenuState | null>(null);
154
+ const [pendingDelete, setPendingDelete] = useState<FileTreeNode | null>(null);
147
155
  const [busy, setBusy] = useState(false);
148
156
  const [coarsePointer, setCoarsePointer] = useState(false);
157
+ const treeId = useId();
149
158
  const containerRef = useRef<HTMLDivElement | null>(null);
159
+ const deleteReturnFocusRef = useRef<HTMLElement | null>(null);
150
160
  const vlistRef = useRef<VListHandle>(null);
161
+ const portalTokenStyle = usePortalTokenStyle(containerRef);
162
+ const getTreeElement = useCallback(
163
+ () => (typeof document === "undefined" ? null : document.getElementById(treeId)),
164
+ [treeId],
165
+ );
151
166
 
152
167
  useBrowserLayoutEffect(() => {
153
168
  if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
@@ -248,6 +263,11 @@ export function FileBrowser({
248
263
  walk(result.tree, 0);
249
264
  return items;
250
265
  }, [result.tree, expanded, draftCreate, loadingPaths, result.expandingPaths, cold]);
266
+ const unicodeFontPaths = useMemo(
267
+ () => renderItems.flatMap((item) => (item.type === "node" ? [item.node.path] : [])),
268
+ [renderItems],
269
+ );
270
+ useUnicodeFallbackFonts(unicodeFontPaths);
251
271
 
252
272
  // The visible NODE rows in order — drives keyboard up/down navigation.
253
273
  const flatRows = useMemo(
@@ -257,23 +277,34 @@ export function FileBrowser({
257
277
  ),
258
278
  [renderItems],
259
279
  );
280
+ const rowIndexByPath = useMemo(
281
+ () => new Map(flatRows.map((row, index) => [row.node.path, index] as const)),
282
+ [flatRows],
283
+ );
284
+ const treePositionByPath = useMemo(() => {
285
+ const positions = new Map<string, { position: number; setSize: number }>();
286
+ const visit = (nodes: FileTreeNode[]) => {
287
+ nodes.forEach((node, index) => {
288
+ positions.set(node.path, { position: index + 1, setSize: nodes.length });
289
+ if (node.children) visit(node.children);
290
+ });
291
+ };
292
+ visit(result.tree);
293
+ return positions;
294
+ }, [result.tree]);
295
+ const cursorIndex = cursor ? (rowIndexByPath.get(cursor) ?? -1) : -1;
296
+ const activeDescendantId = cursorIndex >= 0 ? `${treeId}-item-${cursorIndex}` : undefined;
260
297
 
261
- const supportsMutation = editable;
298
+ // A capture is immutable review data even if a stale capability document still
299
+ // says the machine is writable. Mutations become available only after a live
300
+ // reconciliation has actually succeeded.
301
+ const supportsMutation =
302
+ editable && result.source === "live" && result.error === null && !result.loading;
262
303
 
263
- const runDelete = useCallback(
304
+ const performDelete = useCallback(
264
305
  async (node: FileTreeNode) => {
265
306
  if (!supportsMutation) return;
266
307
  const recursive = node.kind === "dir";
267
- const confirmFn =
268
- confirmDelete ??
269
- ((n: FileTreeNode) =>
270
- typeof window !== "undefined" && typeof window.confirm === "function"
271
- ? window.confirm(
272
- `Delete ${n.kind === "dir" ? "folder" : "file"} "${n.name}"${recursive ? " and its contents" : ""}?`,
273
- )
274
- : true);
275
- const ok = await confirmFn(node);
276
- if (!ok) return;
277
308
  setBusy(true);
278
309
  try {
279
310
  await result.deleteEntry(node.path, recursive);
@@ -283,7 +314,40 @@ export function FileBrowser({
283
314
  setBusy(false);
284
315
  }
285
316
  },
286
- [supportsMutation, confirmDelete, result],
317
+ [supportsMutation, result],
318
+ );
319
+
320
+ const runDelete = useCallback(
321
+ async (node: FileTreeNode, returnFocus?: HTMLElement) => {
322
+ if (!supportsMutation) return;
323
+ setMenu(null);
324
+ if (!confirmDelete) {
325
+ deleteReturnFocusRef.current =
326
+ returnFocus ??
327
+ (typeof document !== "undefined" && document.activeElement instanceof HTMLElement
328
+ ? document.activeElement
329
+ : null);
330
+ setPendingDelete(node);
331
+ return;
332
+ }
333
+ if (await confirmDelete(node)) await performDelete(node);
334
+ },
335
+ [supportsMutation, confirmDelete, performDelete],
336
+ );
337
+
338
+ const closePendingDelete = useCallback(
339
+ (restoreTrigger: boolean) => {
340
+ const returnTarget = restoreTrigger ? deleteReturnFocusRef.current : getTreeElement();
341
+ deleteReturnFocusRef.current = null;
342
+ setPendingDelete(null);
343
+ if (typeof window !== "undefined") {
344
+ window.requestAnimationFrame(() => {
345
+ const target = returnTarget?.isConnected ? returnTarget : getTreeElement();
346
+ target?.focus();
347
+ });
348
+ }
349
+ },
350
+ [getTreeElement],
287
351
  );
288
352
 
289
353
  const commitCreate = useCallback(
@@ -410,6 +474,28 @@ export function FileBrowser({
410
474
  case "ArrowUp":
411
475
  move(idx < 0 ? 0 : idx - 1);
412
476
  break;
477
+ case "Home":
478
+ move(0);
479
+ break;
480
+ case "End":
481
+ move(flatRows.length - 1);
482
+ break;
483
+ case "PageDown": {
484
+ const pageSize = Math.max(
485
+ 1,
486
+ Math.floor((containerRef.current?.clientHeight || 280) / (coarsePointer ? 44 : 28)) - 1,
487
+ );
488
+ move((idx < 0 ? 0 : idx) + pageSize);
489
+ break;
490
+ }
491
+ case "PageUp": {
492
+ const pageSize = Math.max(
493
+ 1,
494
+ Math.floor((containerRef.current?.clientHeight || 280) / (coarsePointer ? 44 : 28)) - 1,
495
+ );
496
+ move((idx < 0 ? 0 : idx) - pageSize);
497
+ break;
498
+ }
413
499
  case "ArrowRight":
414
500
  if (cur?.node.kind === "dir") {
415
501
  if (!expanded.has(cur.node.path)) open(cur.node.path);
@@ -439,6 +525,22 @@ export function FileBrowser({
439
525
  e.preventDefault();
440
526
  }
441
527
  break;
528
+ case "ContextMenu":
529
+ case "F10":
530
+ if (cur && supportsMutation && (e.key === "ContextMenu" || e.shiftKey)) {
531
+ const rowIndex = rowIndexByPath.get(cur.node.path) ?? 0;
532
+ const row = document.getElementById(`${treeId}-item-${rowIndex}`);
533
+ const rect = row?.getBoundingClientRect();
534
+ setMenu({
535
+ node: cur.node,
536
+ x: rect ? rect.left + Math.min(32, rect.width / 2) : 8,
537
+ y: rect ? rect.bottom : 8,
538
+ });
539
+ e.preventDefault();
540
+ } else if (e.key === "F10") {
541
+ break;
542
+ }
543
+ break;
442
544
  case "F2":
443
545
  if (cur) {
444
546
  startRename(cur.node);
@@ -466,6 +568,9 @@ export function FileBrowser({
466
568
  startRename,
467
569
  runDelete,
468
570
  supportsMutation,
571
+ coarsePointer,
572
+ rowIndexByPath,
573
+ treeId,
469
574
  ],
470
575
  );
471
576
 
@@ -484,8 +589,12 @@ export function FileBrowser({
484
589
  }, [menu]);
485
590
 
486
591
  const showErrorEmpty = result.error && result.tree.length === 0 && !draftCreate;
592
+ const showDegradedTree = result.error && result.tree.length > 0;
487
593
  const showEmpty = !result.loading && result.tree.length === 0 && !draftCreate;
488
- const showToolbar = supportsMutation || Boolean(result.error) || result.loading;
594
+ // Retry lives next to the state it explains (the degraded/empty notice), so do
595
+ // not render a second toolbar retry for the same failure.
596
+ const showToolbar = supportsMutation || result.loading;
597
+ const usesVirtualTree = !showErrorEmpty && !showEmpty;
489
598
 
490
599
  // Render a SINGLE node row (no recursion — children are separate flat items the
491
600
  // virtualizer renders). Returns the row element; the caller assigns the key.
@@ -501,10 +610,15 @@ export function FileBrowser({
501
610
  const isRenaming = draftRename?.path === node.path;
502
611
  const isDropTarget = dragOver === (isDir ? node.path : parentOf(node.path));
503
612
  const dropDir = isDir ? node.path : parentOf(node.path);
613
+ const treePosition = treePositionByPath.get(node.path);
504
614
 
505
615
  return (
506
616
  <div
617
+ id={`${treeId}-item-${rowIndexByPath.get(node.path) ?? 0}`}
507
618
  role="treeitem"
619
+ aria-level={depth + 1}
620
+ aria-posinset={treePosition?.position}
621
+ aria-setsize={treePosition?.setSize}
508
622
  aria-expanded={isDir ? isOpen : undefined}
509
623
  aria-selected={isCursor || undefined}
510
624
  aria-busy={isLoading || undefined}
@@ -547,6 +661,7 @@ export function FileBrowser({
547
661
  ) : (
548
662
  <button
549
663
  type="button"
664
+ tabIndex={-1}
550
665
  draggable={supportsMutation || undefined}
551
666
  onDragStart={
552
667
  supportsMutation
@@ -676,7 +791,11 @@ export function FileBrowser({
676
791
  return (
677
792
  <div className={cn("flex min-h-0 min-w-0 flex-col", className)}>
678
793
  {showToolbar && (
679
- <div className="flex shrink-0 flex-wrap items-center gap-0.5 border-b border-og-border px-1 py-1">
794
+ <div
795
+ role="toolbar"
796
+ aria-label="File actions"
797
+ className="flex shrink-0 flex-wrap items-center gap-0.5 border-b border-og-border px-1 py-1"
798
+ >
680
799
  {supportsMutation ? (
681
800
  <>
682
801
  <ToolbarButton label="New file" onClick={() => startCreate("file")} disabled={busy}>
@@ -697,9 +816,9 @@ export function FileBrowser({
697
816
  </ToolbarButton>
698
817
  <ToolbarButton
699
818
  label="Delete"
700
- onClick={() => {
819
+ onClick={(event) => {
701
820
  const node = cursor ? findNode(result.tree, cursor) : undefined;
702
- if (node) void runDelete(node);
821
+ if (node) void runDelete(node, event.currentTarget);
703
822
  }}
704
823
  disabled={busy || !cursor}
705
824
  >
@@ -718,13 +837,50 @@ export function FileBrowser({
718
837
  </div>
719
838
  )}
720
839
 
840
+ {showDegradedTree ? (
841
+ <div
842
+ role="status"
843
+ aria-live="polite"
844
+ data-opengeni-files-degraded
845
+ className="flex shrink-0 items-center gap-2 border-b border-og-status-running/30 bg-og-status-running/10 px-2 py-1.5 text-og-xs text-og-fg-muted"
846
+ >
847
+ <TriangleAlertIcon className="size-3.5 shrink-0 text-og-status-running" aria-hidden />
848
+ <span className="min-w-0 flex-1">
849
+ {result.source === "capture"
850
+ ? "Live files are temporarily unavailable. Showing the latest captured revision."
851
+ : "Live refresh failed. Showing the last loaded files."}
852
+ </span>
853
+ <button
854
+ type="button"
855
+ onClick={() => void result.refresh()}
856
+ disabled={result.loading}
857
+ className="inline-flex min-h-7 shrink-0 items-center gap-1 rounded-og-sm border border-og-border bg-og-surface-1 px-2 font-medium text-og-fg transition-colors hover:border-og-border-strong focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-og-accent disabled:cursor-not-allowed disabled:opacity-50 pointer-coarse:min-h-11"
858
+ >
859
+ <RefreshCwIcon
860
+ className={cn("size-3", result.loading && "animate-spin motion-reduce:animate-none")}
861
+ aria-hidden
862
+ />
863
+ Retry
864
+ </button>
865
+ </div>
866
+ ) : null}
867
+
721
868
  {/* The tree itself. The root is a drop target so a node can be moved to "". */}
722
869
  {/* biome-ignore lint/a11y/noNoninteractiveTabindex: the tree owns keyboard nav */}
723
870
  <div
724
871
  ref={containerRef}
725
- role="tree"
726
- tabIndex={0}
727
- aria-multiselectable={false}
872
+ id={usesVirtualTree ? undefined : treeId}
873
+ role={usesVirtualTree ? undefined : "tree"}
874
+ aria-label={usesVirtualTree ? undefined : "Workspace files"}
875
+ aria-activedescendant={usesVirtualTree ? undefined : activeDescendantId}
876
+ tabIndex={usesVirtualTree ? undefined : 0}
877
+ aria-multiselectable={usesVirtualTree ? undefined : false}
878
+ onFocus={(event) => {
879
+ if ((event.target as HTMLElement).id === treeId && cursorIndex < 0) {
880
+ const first = flatRows[0];
881
+ if (first) setActive(first.node.path);
882
+ }
883
+ }}
728
884
  onKeyDown={onKeyDown}
729
885
  onDragOver={
730
886
  supportsMutation
@@ -745,7 +901,7 @@ export function FileBrowser({
745
901
  : undefined
746
902
  }
747
903
  className={cn(
748
- "flex min-h-0 min-w-0 flex-1 flex-col p-1 outline-none",
904
+ "flex min-h-0 min-w-0 flex-1 flex-col p-1 outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-og-accent",
749
905
  dragOver === "" && "ring-1 ring-inset ring-og-accent",
750
906
  )}
751
907
  data-opengeni-file-tree
@@ -760,7 +916,7 @@ export function FileBrowser({
760
916
  type="button"
761
917
  onClick={() => void result.refresh()}
762
918
  disabled={result.loading}
763
- className="inline-flex items-center gap-1.5 rounded-og-sm border border-og-border px-2 py-1 text-og-xs font-medium text-og-fg-muted transition-colors hover:border-og-border-strong hover:text-og-fg disabled:cursor-not-allowed disabled:opacity-50 pointer-coarse:min-h-10"
919
+ className="inline-flex items-center gap-1.5 rounded-og-sm border border-og-border px-2 py-1 text-og-xs font-medium text-og-fg-muted transition-colors hover:border-og-border-strong hover:text-og-fg disabled:cursor-not-allowed disabled:opacity-50 pointer-coarse:min-h-11"
764
920
  >
765
921
  <RefreshCwIcon
766
922
  className={cn("size-3.5", result.loading && "animate-spin")}
@@ -779,7 +935,13 @@ export function FileBrowser({
779
935
  // expanded subtree + synthetic (create/skeleton/residue) rows.
780
936
  <VList
781
937
  ref={vlistRef}
782
- className="min-h-0 flex-1"
938
+ id={treeId}
939
+ role="tree"
940
+ aria-label="Workspace files"
941
+ aria-activedescendant={activeDescendantId}
942
+ aria-multiselectable={false}
943
+ tabIndex={0}
944
+ className="min-h-0 flex-1 outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-og-accent"
783
945
  itemSize={coarsePointer ? 44 : 28}
784
946
  ssrCount={Math.min(32, renderItems.length)}
785
947
  >
@@ -801,12 +963,154 @@ export function FileBrowser({
801
963
  onDelete={() => menu.node && void runDelete(menu.node)}
802
964
  onNewFile={() => startCreate("file")}
803
965
  onNewFolder={() => startCreate("dir")}
966
+ onClose={() => {
967
+ setMenu(null);
968
+ window.requestAnimationFrame(() => getTreeElement()?.focus());
969
+ }}
804
970
  />
805
971
  )}
972
+ {pendingDelete ? (
973
+ <DeleteDialog
974
+ node={pendingDelete}
975
+ busy={busy}
976
+ portalTokenStyle={portalTokenStyle}
977
+ onCancel={() => closePendingDelete(true)}
978
+ onConfirm={() => {
979
+ const node = pendingDelete;
980
+ closePendingDelete(false);
981
+ void performDelete(node);
982
+ }}
983
+ />
984
+ ) : null}
806
985
  </div>
807
986
  );
808
987
  }
809
988
 
989
+ function DeleteDialog({
990
+ node,
991
+ busy,
992
+ portalTokenStyle,
993
+ onCancel,
994
+ onConfirm,
995
+ }: {
996
+ node: FileTreeNode;
997
+ busy: boolean;
998
+ portalTokenStyle: PortalTokenStyle;
999
+ onCancel: () => void;
1000
+ onConfirm: () => void;
1001
+ }) {
1002
+ const cancelRef = useRef<HTMLButtonElement | null>(null);
1003
+ const [open, setOpen] = useState(true);
1004
+ const closeIntentRef = useRef<"cancel" | "confirm">("cancel");
1005
+ const settledRef = useRef(false);
1006
+ const settleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
1007
+
1008
+ const settle = () => {
1009
+ if (settledRef.current) return;
1010
+ settledRef.current = true;
1011
+ if (closeIntentRef.current === "confirm") {
1012
+ onConfirm();
1013
+ } else {
1014
+ onCancel();
1015
+ }
1016
+ };
1017
+
1018
+ const scheduleSettle = () => {
1019
+ if (settleTimerRef.current !== null) return;
1020
+ settleTimerRef.current = setTimeout(() => {
1021
+ settleTimerRef.current = null;
1022
+ settle();
1023
+ }, 0);
1024
+ };
1025
+
1026
+ useEffect(
1027
+ () => () => {
1028
+ if (settleTimerRef.current !== null) clearTimeout(settleTimerRef.current);
1029
+ },
1030
+ [],
1031
+ );
1032
+
1033
+ return (
1034
+ <AlertDialog.Root
1035
+ open={open}
1036
+ onOpenChange={(nextOpen) => {
1037
+ if (!nextOpen && !busy) {
1038
+ setOpen(false);
1039
+ scheduleSettle();
1040
+ }
1041
+ }}
1042
+ >
1043
+ <AlertDialog.Portal container={typeof document === "undefined" ? null : document.body}>
1044
+ <AlertDialog.Overlay className="fixed inset-0 z-[100] bg-black/45 backdrop-blur-[2px]" />
1045
+ <AlertDialog.Content
1046
+ className="og-root fixed left-1/2 top-1/2 z-[101] max-h-[calc(100dvh-2rem)] w-[calc(100%-2rem)] max-w-sm -translate-x-1/2 -translate-y-1/2 overflow-y-auto rounded-og-lg border border-og-border-strong bg-og-surface-1 p-4 text-og-fg shadow-2xl outline-none"
1047
+ style={portalTokenStyle}
1048
+ onOpenAutoFocus={(event) => {
1049
+ event.preventDefault();
1050
+ cancelRef.current?.focus();
1051
+ }}
1052
+ onCloseAutoFocus={(event) => {
1053
+ event.preventDefault();
1054
+ scheduleSettle();
1055
+ }}
1056
+ onEscapeKeyDown={(event) => {
1057
+ event.stopPropagation();
1058
+ if (busy) {
1059
+ event.preventDefault();
1060
+ } else {
1061
+ closeIntentRef.current = "cancel";
1062
+ }
1063
+ }}
1064
+ >
1065
+ <div className="flex items-start gap-3">
1066
+ <span className="flex size-9 shrink-0 items-center justify-center rounded-full bg-og-status-failed/12 text-og-status-failed">
1067
+ <Trash2Icon className="size-4" aria-hidden />
1068
+ </span>
1069
+ <div className="min-w-0">
1070
+ <AlertDialog.Title className="text-og-sm font-semibold">
1071
+ Delete {node.kind === "dir" ? "folder" : "file"}?
1072
+ </AlertDialog.Title>
1073
+ <AlertDialog.Description className="mt-1 text-og-sm leading-relaxed text-og-fg-muted">
1074
+ <span className="break-all font-og-mono text-og-fg">{node.path}</span>
1075
+ {node.kind === "dir"
1076
+ ? " and everything inside it will be permanently removed."
1077
+ : " will be permanently removed."}
1078
+ </AlertDialog.Description>
1079
+ </div>
1080
+ </div>
1081
+ <div className="mt-4 flex justify-end gap-2">
1082
+ <AlertDialog.Cancel asChild>
1083
+ <button
1084
+ ref={cancelRef}
1085
+ type="button"
1086
+ onClick={() => {
1087
+ closeIntentRef.current = "cancel";
1088
+ }}
1089
+ disabled={busy}
1090
+ className="min-h-9 rounded-og-md border border-og-border px-3 text-og-sm font-medium text-og-fg hover:bg-og-surface-2 disabled:opacity-50 pointer-coarse:min-h-11"
1091
+ >
1092
+ Cancel
1093
+ </button>
1094
+ </AlertDialog.Cancel>
1095
+ <AlertDialog.Action asChild>
1096
+ <button
1097
+ type="button"
1098
+ onClick={() => {
1099
+ closeIntentRef.current = "confirm";
1100
+ }}
1101
+ disabled={busy}
1102
+ className="min-h-9 rounded-og-md bg-og-status-failed px-3 text-og-sm font-semibold text-white shadow-sm hover:brightness-110 disabled:opacity-50 pointer-coarse:min-h-11"
1103
+ >
1104
+ Delete permanently
1105
+ </button>
1106
+ </AlertDialog.Action>
1107
+ </div>
1108
+ </AlertDialog.Content>
1109
+ </AlertDialog.Portal>
1110
+ </AlertDialog.Root>
1111
+ );
1112
+ }
1113
+
810
1114
  function ToolbarButton({
811
1115
  label,
812
1116
  onClick,
@@ -814,7 +1118,7 @@ function ToolbarButton({
814
1118
  children,
815
1119
  }: {
816
1120
  label: string;
817
- onClick: () => void;
1121
+ onClick: (event: ReactMouseEvent<HTMLButtonElement>) => void;
818
1122
  disabled?: boolean;
819
1123
  children: ReactNode;
820
1124
  }) {
@@ -920,6 +1224,7 @@ function ContextMenu({
920
1224
  onDelete,
921
1225
  onNewFile,
922
1226
  onNewFolder,
1227
+ onClose,
923
1228
  }: {
924
1229
  node: FileTreeNode;
925
1230
  x: number;
@@ -928,23 +1233,53 @@ function ContextMenu({
928
1233
  onDelete: () => void;
929
1234
  onNewFile: () => void;
930
1235
  onNewFolder: () => void;
1236
+ onClose: () => void;
931
1237
  }) {
932
1238
  const isDir = node.kind === "dir";
1239
+ const menuRef = useRef<HTMLDivElement | null>(null);
933
1240
  // Keep the menu on-screen when opened near the viewport edge.
934
1241
  const vw = typeof window !== "undefined" ? window.innerWidth : 9999;
935
1242
  const vh = typeof window !== "undefined" ? window.innerHeight : 9999;
936
- const left = Math.min(x, vw - 180);
937
- const top = Math.min(y, vh - 160);
1243
+ const left = Math.max(4, Math.min(x, vw - 180));
1244
+ const top = Math.max(4, Math.min(y, vh - 160));
1245
+
1246
+ useEffect(() => {
1247
+ menuRef.current?.querySelector<HTMLButtonElement>('[role="menuitem"]')?.focus();
1248
+ }, []);
1249
+
1250
+ const onKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
1251
+ const items = Array.from(
1252
+ menuRef.current?.querySelectorAll<HTMLButtonElement>('[role="menuitem"]') ?? [],
1253
+ );
1254
+ if (items.length === 0) return;
1255
+ const current = items.findIndex((item) => item === document.activeElement);
1256
+ let next: number | null = null;
1257
+ if (event.key === "ArrowDown") next = (current + 1 + items.length) % items.length;
1258
+ else if (event.key === "ArrowUp") next = (current - 1 + items.length) % items.length;
1259
+ else if (event.key === "Home") next = 0;
1260
+ else if (event.key === "End") next = items.length - 1;
1261
+ else if (event.key === "Escape") {
1262
+ event.preventDefault();
1263
+ event.stopPropagation();
1264
+ onClose();
1265
+ return;
1266
+ }
1267
+ if (next === null) return;
1268
+ event.preventDefault();
1269
+ event.stopPropagation();
1270
+ items[next]?.focus();
1271
+ };
938
1272
 
939
1273
  const item = (label: string, icon: ReactNode, onClick: () => void, danger?: boolean) => (
940
1274
  <button
941
1275
  type="button"
1276
+ role="menuitem"
942
1277
  onClick={(e) => {
943
1278
  e.stopPropagation();
944
1279
  onClick();
945
1280
  }}
946
1281
  className={cn(
947
- "flex w-full items-center gap-2 px-2.5 py-1 text-left text-og-sm pointer-coarse:min-h-10",
1282
+ "flex w-full items-center gap-2 px-2.5 py-1 text-left text-og-sm pointer-coarse:min-h-11",
948
1283
  "hover:bg-og-surface-2",
949
1284
  danger ? "text-og-status-failed" : "text-og-fg",
950
1285
  )}
@@ -956,7 +1291,10 @@ function ContextMenu({
956
1291
 
957
1292
  return (
958
1293
  <div
1294
+ ref={menuRef}
959
1295
  role="menu"
1296
+ aria-label={`Actions for ${node.name}`}
1297
+ onKeyDown={onKeyDown}
960
1298
  onClick={(e) => e.stopPropagation()}
961
1299
  style={{ left, top }}
962
1300
  className={cn(
@@ -969,7 +1307,7 @@ function ContextMenu({
969
1307
  <>
970
1308
  {item("New file", <FilePlusIcon className="size-3.5" />, onNewFile)}
971
1309
  {item("New folder", <FolderPlusIcon className="size-3.5" />, onNewFolder)}
972
- <div className="my-1 h-px bg-og-border" />
1310
+ <div role="separator" className="my-1 h-px bg-og-border" />
973
1311
  </>
974
1312
  )}
975
1313
  {item("Rename", <PencilIcon className="size-3.5" />, onRename)}