@vgai/editor 0.4.0-canary.20260715.0 → 0.4.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 (47) hide show
  1. package/dist/assets/{axe-C7YBtOuM.js → axe-BJjvBeOZ.js} +1 -1
  2. package/dist/assets/{browserAll-DDtGGE48.js → browserAll-Dn0pgD87.js} +1 -1
  3. package/dist/assets/{create-runtime-DbTlBlKG.js → create-runtime-BF9OD-U8.js} +1 -1
  4. package/dist/assets/{game-Dch9sAPc.js → game-B346oj_f.js} +1 -1
  5. package/dist/assets/{game-BE0r7DMI.js → game-BCNN63uV.js} +1 -1
  6. package/dist/assets/{game-SAFn90pA.js → game-BR0GBNgF.js} +1 -1
  7. package/dist/assets/{game-Cqzj_lpD.js → game-CLNm2g0y.js} +1 -1
  8. package/dist/assets/{game-CcHfYxjP.js → game-DQxhCYsZ.js} +1 -1
  9. package/dist/assets/{game-BdTHR2_X.js → game-DeTGdgYt.js} +1 -1
  10. package/dist/assets/{game-loop-DMIgSMV-.js → game-loop-t5W_oqxz.js} +1 -1
  11. package/dist/assets/{game-source-Dk83w26t.js → game-source-DzE9taML.js} +1 -1
  12. package/dist/assets/{game-DSZdeCHu.js → game-u6c2Patu.js} +1 -1
  13. package/dist/assets/{game-C3iQGSqo.js → game-umrHIqFB.js} +1 -1
  14. package/dist/assets/{index-C-LhOTBN.js → index-CgcH1RpF.js} +1 -1
  15. package/dist/assets/{index-CDkxceqE.js → index-D3tcNB2T.js} +1 -1
  16. package/dist/assets/{index-CQEfi217.js → index-DbQzGLxv.js} +771 -771
  17. package/dist/assets/{index-4idIsHHJ.js → index-Fc8Ezr20.js} +1 -1
  18. package/dist/assets/index-bAGhEnnO.css +1 -0
  19. package/dist/assets/{ingest-siblings-s0P5I1Ub.js → ingest-siblings-CW_xbvrF.js} +1 -1
  20. package/dist/assets/{mount-manifest-DXMOlF79.js → mount-manifest-DgUscyaD.js} +1 -1
  21. package/dist/assets/{react-18-C9eHn8x1.js → react-18-C4tVCii_.js} +1 -1
  22. package/dist/assets/webworkerAll-BA4aq7xq.js +1 -0
  23. package/dist/assets/{world-documents-GM0a9Kv7.js → world-documents-BSaA5YIF.js} +1 -1
  24. package/dist/index.html +2 -2
  25. package/dist-server/packaged.mjs +21 -1
  26. package/package.json +3 -3
  27. package/server/packaged.ts +20 -0
  28. package/src/asset-editor-context.tsx +130 -0
  29. package/src/asset-preview.ts +4 -0
  30. package/src/asset-selection.ts +9 -4
  31. package/src/asset-workflow/thumbnail-system.ts +16 -7
  32. package/src/components/AssetBrowser.tsx +330 -539
  33. package/src/components/AssetEditorShell.tsx +42 -112
  34. package/src/components/AssetImportDetails.tsx +24 -6
  35. package/src/components/GameHierarchy.tsx +26 -1
  36. package/src/components/Inspector.tsx +38 -2
  37. package/src/components/OnlineAssetBrowser.tsx +295 -343
  38. package/src/components/WorkspaceDock.tsx +6 -12
  39. package/src/components/asset-documents.tsx +41 -12
  40. package/src/components/asset-selection-section.tsx +34 -1
  41. package/src/components/asset-viewers/ModelViewer.tsx +7 -4
  42. package/src/components/asset-viewers/OnlineAssetDetail.tsx +233 -205
  43. package/src/components/asset-workflow.css +197 -264
  44. package/src/editor-api.ts +1 -1
  45. package/template/vgai.game.json +1 -1
  46. package/dist/assets/index-Dvi-V0NE.css +0 -1
  47. package/dist/assets/webworkerAll-Fn0e-ouF.js +0 -1
@@ -1,9 +1,8 @@
1
1
  import {
2
2
  faArrowLeft,
3
- faBookOpen,
4
3
  faBoxArchive,
5
- faChevronRight,
6
4
  faCubes,
5
+ faEllipsis,
7
6
  faFile,
8
7
  faFileCode,
9
8
  faFileImage,
@@ -13,11 +12,9 @@ import {
13
12
  faPaintbrush,
14
13
  faPuzzlePiece,
15
14
  faImage as faSceneIcon,
16
- faTable,
17
15
  faTableCellsLarge,
18
- faTrash,
19
16
  } from '@fortawesome/free-solid-svg-icons';
20
- import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
17
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
21
18
  import { clearSelectedAsset, getSelectedAsset, setSelectedAsset } from '../asset-selection';
22
19
  import {
23
20
  type AssetCapabilityKind,
@@ -36,7 +33,6 @@ import {
36
33
  ThumbnailJobQueue,
37
34
  } from '../asset-workflow/thumbnail-system';
38
35
  import {
39
- accent,
40
36
  Button,
41
37
  border,
42
38
  EditorIcon,
@@ -59,20 +55,7 @@ import { isEditableTarget, setActiveScope } from '../hotkeys';
59
55
  import { getModelThumbnailRenderer, modelThumbnailFormat } from '../model-thumbnail';
60
56
  import { getCurrentProject } from '../project-manager';
61
57
  import { getStorageBackend } from '../storage';
62
- import {
63
- getProjectStoryModules,
64
- refreshProjectStories,
65
- subscribeProjectStoryModules,
66
- } from '../stories/story-registry';
67
58
  import { openAssetDocument } from './asset-documents';
68
- import {
69
- dataFilesVersion,
70
- loadedDataFiles,
71
- openDataDocument,
72
- refreshDataFiles,
73
- subscribeDataFiles,
74
- } from './data-documents';
75
- import { openStoryDocument } from './story-documents';
76
59
  import './asset-workflow.css';
77
60
 
78
61
  // --- Helpers ---
@@ -89,7 +72,7 @@ type AssetKind =
89
72
  | 'source'
90
73
  | 'unknown';
91
74
 
92
- type BrowserKind = AssetKind | 'data' | 'story';
75
+ type BrowserKind = AssetKind;
93
76
  type ViewableAssetKind = Exclude<AssetKind, 'folder' | 'unknown'>;
94
77
 
95
78
  function getAssetKind(entry: AssetEntry): AssetKind {
@@ -106,8 +89,6 @@ const assetIconColors: Record<BrowserKind, string> = {
106
89
  audio: '#34d399',
107
90
  image: '#f472b6',
108
91
  json: '#fbbf24',
109
- data: '#22c55e',
110
- story: '#a78bfa',
111
92
  unknown: '#6b7280',
112
93
  source: '#fb923c',
113
94
  };
@@ -121,8 +102,6 @@ const assetIconMap: Record<BrowserKind, typeof faFolder> = {
121
102
  audio: faMusic,
122
103
  image: faFileImage,
123
104
  json: faFileCode,
124
- data: faTable,
125
- story: faBookOpen,
126
105
  unknown: faFile,
127
106
  source: faBoxArchive,
128
107
  };
@@ -139,7 +118,12 @@ function AssetIcon({ kind, size = 24 }: { kind: BrowserKind; size?: number }) {
139
118
  const projectThumbnailQueue = new ThumbnailJobQueue<string>((url) => {
140
119
  const format = modelThumbnailFormat(url);
141
120
  if (!format) return Promise.reject(new Error(`No model preview loader for ${url}.`));
142
- return getModelThumbnailRenderer().render({ url, format });
121
+ return getModelThumbnailRenderer().render({
122
+ url,
123
+ format,
124
+ background: 'neutral',
125
+ output: 'webp',
126
+ });
143
127
  }, 2);
144
128
 
145
129
  function ModelThumbnail({ url }: { url: string }) {
@@ -195,7 +179,7 @@ function ModelThumbnail({ url }: { url: string }) {
195
179
  setManifestPath(null);
196
180
  setManifestRejected(true);
197
181
  }}
198
- style={{ maxWidth: 48, maxHeight: 48, objectFit: 'contain', borderRadius: radius.sm }}
182
+ style={{ width: '100%', height: '100%', objectFit: 'contain' }}
199
183
  />
200
184
  );
201
185
  }
@@ -229,7 +213,7 @@ function ModelThumbnail({ url }: { url: string }) {
229
213
  <img
230
214
  src={src}
231
215
  alt="model preview"
232
- style={{ maxWidth: 48, maxHeight: 48, objectFit: 'contain', borderRadius: radius.sm }}
216
+ style={{ width: '100%', height: '100%', objectFit: 'contain' }}
233
217
  />
234
218
  );
235
219
  }
@@ -261,23 +245,19 @@ function projectAssetHealth(name: string, path: string): ProjectAssetHealth {
261
245
  }
262
246
 
263
247
  interface PersistedAssetBrowserState {
264
- currentRoot: AssetRoot | null;
265
248
  currentPath: string;
266
249
  viewMode: AssetViewMode;
267
250
  sort: AssetSort;
268
251
  filter: AssetFilter;
269
252
  density: number;
270
- expanded: string[];
271
253
  }
272
254
 
273
255
  const DEFAULT_BROWSER_STATE: PersistedAssetBrowserState = {
274
- currentRoot: null,
275
256
  currentPath: '',
276
257
  viewMode: 'grid',
277
258
  sort: 'name',
278
259
  filter: 'all',
279
260
  density: 96,
280
- expanded: ['public'],
281
261
  };
282
262
 
283
263
  function readAssetBrowserState(): PersistedAssetBrowserState {
@@ -286,7 +266,18 @@ function readAssetBrowserState(): PersistedAssetBrowserState {
286
266
  const parsed = JSON.parse(
287
267
  localStorage.getItem(projectAssetBrowserStateKey(projectId)) ?? '{}',
288
268
  ) as Partial<PersistedAssetBrowserState>;
289
- return { ...DEFAULT_BROWSER_STATE, ...parsed };
269
+ const legacyViewMode = (parsed as Record<string, unknown>)['viewMode'];
270
+ const legacyRoot = (parsed as Record<string, unknown>)['currentRoot'];
271
+ return {
272
+ ...DEFAULT_BROWSER_STATE,
273
+ ...parsed,
274
+ // v1 briefly persisted synthetic `data` / `stories` roots and a
275
+ // project-root landing page. Assets now opens directly at the real
276
+ // public asset root, so those old locations migrate home once.
277
+ currentPath:
278
+ legacyRoot === undefined || legacyRoot === 'public' ? (parsed.currentPath ?? '') : '',
279
+ viewMode: legacyViewMode === 'columns' ? 'list' : legacyViewMode === 'list' ? 'list' : 'grid',
280
+ };
290
281
  } catch {
291
282
  return DEFAULT_BROWSER_STATE;
292
283
  }
@@ -298,8 +289,8 @@ interface AssetBrowserProps {
298
289
  onOpenScene?: (assetPath: string) => void;
299
290
  }
300
291
 
301
- type AssetRoot = 'public' | 'data' | 'stories';
302
- type AssetViewMode = 'grid' | 'list' | 'columns';
292
+ type AssetRoot = 'public';
293
+ type AssetViewMode = 'grid' | 'list';
303
294
  type AssetSort = 'name' | 'type' | 'size' | 'health';
304
295
  type AssetFilter =
305
296
  | 'all'
@@ -309,44 +300,15 @@ type AssetFilter =
309
300
  | 'imported'
310
301
  | 'generated';
311
302
 
312
- type BrowserEntry =
313
- | {
314
- source: 'root';
315
- key: string;
316
- name: string;
317
- root: AssetRoot;
318
- detail: string;
319
- }
320
- | { source: 'asset'; key: string; name: string; entry: AssetEntry }
321
- | { source: 'data'; key: string; name: string; path: string }
322
- | {
323
- source: 'story';
324
- key: string;
325
- name: string;
326
- modulePath: string;
327
- storyName: string;
328
- };
303
+ type BrowserEntry = { source: 'asset'; key: string; name: string; entry: AssetEntry };
329
304
 
330
305
  function browserEntryKind(item: BrowserEntry): BrowserKind {
331
- if (item.source === 'root') return 'folder';
332
- if (item.source === 'data') return 'data';
333
- if (item.source === 'story') return 'story';
334
306
  return getAssetKind(item.entry);
335
307
  }
336
308
 
337
- function assetRootLabel(root: AssetRoot): string {
338
- if (root === 'data') return 'src/data';
339
- return root;
340
- }
341
-
342
- function parentAssetLocation(
343
- root: AssetRoot | null,
344
- path: string,
345
- ): readonly [AssetRoot | null, string] {
346
- if (root === null) return [null, ''];
309
+ function parentAssetPath(path: string): string {
347
310
  const separator = path.lastIndexOf('/');
348
- if (separator < 0) return path ? [root, ''] : [null, ''];
349
- return [root, path.slice(0, separator)];
311
+ return separator < 0 ? '' : path.slice(0, separator);
350
312
  }
351
313
 
352
314
  function openProjectAssetEntry(
@@ -354,7 +316,7 @@ function openProjectAssetEntry(
354
316
  options: {
355
317
  currentRoot: AssetRoot;
356
318
  currentPath: string;
357
- navigate: (root: AssetRoot | null, path: string) => void;
319
+ navigate: (root: AssetRoot, path: string) => void;
358
320
  onOpenScene?: (assetPath: string) => void;
359
321
  openAsset: (url: string, kind: ViewableAssetKind) => void;
360
322
  },
@@ -374,9 +336,6 @@ function openProjectAssetEntry(
374
336
  }
375
337
 
376
338
  function browserEntryDetail(item: BrowserEntry): string {
377
- if (item.source === 'root') return item.detail;
378
- if (item.source === 'story') return item.modulePath;
379
- if (item.source === 'data') return item.path;
380
339
  return item.entry.type === 'file' ? formatSize(item.entry.size) : 'Folder';
381
340
  }
382
341
 
@@ -385,6 +344,42 @@ function browserEntryTitle(item: BrowserEntry): string {
385
344
  return detail ? `${item.name}\n${detail}` : item.name;
386
345
  }
387
346
 
347
+ function matchesAssetFilter(item: BrowserEntry, filter: AssetFilter, currentPath: string): boolean {
348
+ if (filter === 'all' || item.entry.type === 'directory') return true;
349
+ const capability = assetCapabilities(item.name);
350
+ const path = currentPath ? `${currentPath}/${item.name}` : item.name;
351
+ const predicates: Record<Exclude<AssetFilter, 'all'>, boolean> = {
352
+ 'runtime-ready': capability.runtimeReady,
353
+ 'source-only': capability.importable && !capability.runtimeReady,
354
+ unsupported: !capability.importable,
355
+ imported: path.startsWith('asset-library/local/'),
356
+ generated: path.startsWith('.generated/'),
357
+ };
358
+ return predicates[filter];
359
+ }
360
+
361
+ function compareAssetEntries(
362
+ left: BrowserEntry,
363
+ right: BrowserEntry,
364
+ sort: AssetSort,
365
+ currentPath: string,
366
+ ): number {
367
+ if (left.entry.type !== right.entry.type) return left.entry.type === 'directory' ? -1 : 1;
368
+ if (sort === 'size' && left.entry.size !== right.entry.size)
369
+ return right.entry.size - left.entry.size;
370
+ if (sort === 'type') {
371
+ const order = getAssetKind(left.entry).localeCompare(getAssetKind(right.entry));
372
+ if (order !== 0) return order;
373
+ }
374
+ if (sort === 'health') {
375
+ const order = projectAssetHealth(left.name, currentPath).localeCompare(
376
+ projectAssetHealth(right.name, currentPath),
377
+ );
378
+ if (order !== 0) return order;
379
+ }
380
+ return left.name.localeCompare(right.name);
381
+ }
382
+
388
383
  const GRID_ENTRY_STYLE: React.CSSProperties = {
389
384
  width: '100%',
390
385
  minWidth: 0,
@@ -429,10 +424,10 @@ function BrowserEntryVisual({
429
424
  />
430
425
  );
431
426
  }
432
- if (kind === 'model' || (item.source === 'asset' && modelThumbnailFormat(assetUrl))) {
427
+ if (kind === 'model' || modelThumbnailFormat(assetUrl)) {
433
428
  return <ModelThumbnail url={assetUrl} />;
434
429
  }
435
- if (['audio', 'material', 'scene', 'prefab', 'json', 'data', 'story'].includes(kind)) {
430
+ if (['audio', 'material', 'scene', 'prefab', 'json'].includes(kind)) {
436
431
  return <TypedAssetThumbnail kind={kind} name={item.name} />;
437
432
  }
438
433
  return <AssetIcon kind={kind} />;
@@ -505,7 +500,7 @@ function BrowserEntryCard({
505
500
  }: {
506
501
  item: BrowserEntry;
507
502
  selected: boolean;
508
- currentRoot: AssetRoot | null;
503
+ currentRoot: AssetRoot;
509
504
  currentPath: string;
510
505
  onDragStart: (event: React.DragEvent, item: BrowserEntry) => void;
511
506
  onSelect: (item: BrowserEntry, event: React.MouseEvent | React.KeyboardEvent) => void;
@@ -514,9 +509,9 @@ function BrowserEntryCard({
514
509
  viewMode: AssetViewMode;
515
510
  }) {
516
511
  const kind = browserEntryKind(item);
517
- const assetEntry = item.source === 'asset' ? item.entry : null;
518
- const capability = assetEntry?.type === 'file' ? assetCapabilities(assetEntry.name) : null;
519
- const assetUrl = assetServingUrl(currentRoot ?? 'public', currentPath, item.name);
512
+ const assetEntry = item.entry;
513
+ const capability = assetEntry.type === 'file' ? assetCapabilities(assetEntry.name) : null;
514
+ const assetUrl = assetServingUrl(currentRoot, currentPath, item.name);
520
515
  const title = browserEntryTitle(item);
521
516
  const cursor = assetEntry
522
517
  ? assetEntry.type === 'directory' || capability?.placeable
@@ -530,9 +525,9 @@ function BrowserEntryCard({
530
525
 
531
526
  return (
532
527
  <div
533
- className="vgai-selectable"
528
+ className="vgai-selectable vgai-project-asset-entry"
534
529
  data-selected={selected || undefined}
535
- data-testid={item.source === 'root' ? `asset-root-${item.root}` : undefined}
530
+ data-view={viewMode}
536
531
  role="option"
537
532
  aria-selected={selected}
538
533
  tabIndex={0}
@@ -553,14 +548,15 @@ function BrowserEntryCard({
553
548
  ...(grid ? GRID_ENTRY_STYLE : LIST_ENTRY_STYLE),
554
549
  borderRadius: radius.sm,
555
550
  cursor,
556
- border: selected ? `1px solid ${accent}` : '1px solid transparent',
557
551
  userSelect: 'none',
558
552
  }}
559
553
  >
560
554
  <div
555
+ className="vgai-project-asset-entry__preview"
556
+ data-testid="asset-entry-preview"
561
557
  style={{
562
558
  ...(grid
563
- ? { width: 48, height: 48, margin: '0 auto 4px' }
559
+ ? { width: '100%', aspectRatio: '4 / 3', margin: '0 auto' }
564
560
  : { width: 18, height: 18, margin: 0 }),
565
561
  display: 'flex',
566
562
  alignItems: 'center',
@@ -624,7 +620,6 @@ function BrowserEntryCard({
624
620
  }
625
621
 
626
622
  interface AssetBrowserToolbarProps {
627
- currentRoot: AssetRoot | null;
628
623
  currentPath: string;
629
624
  searchOpen: boolean;
630
625
  searchQuery: string;
@@ -632,7 +627,7 @@ interface AssetBrowserToolbarProps {
632
627
  sort: AssetSort;
633
628
  filter: AssetFilter;
634
629
  density: number;
635
- onNavigate: (root: AssetRoot | null, path: string) => void;
630
+ onNavigate: (root: AssetRoot, path: string) => void;
636
631
  onSearchOpenChange: (open: boolean) => void;
637
632
  onSearchQueryChange: (query: string) => void;
638
633
  onViewModeChange: (mode: AssetViewMode) => void;
@@ -647,7 +642,6 @@ interface AssetBrowserToolbarProps {
647
642
  }
648
643
 
649
644
  function AssetBrowserToolbar({
650
- currentRoot,
651
645
  currentPath,
652
646
  searchOpen,
653
647
  searchQuery,
@@ -668,335 +662,254 @@ function AssetBrowserToolbar({
668
662
  deleteDisabled,
669
663
  onRefresh,
670
664
  }: AssetBrowserToolbarProps) {
665
+ const actionsRef = useRef<HTMLDivElement>(null);
666
+ const [actionsOpen, setActionsOpen] = useState(false);
671
667
  const breadcrumbSegments = currentPath ? currentPath.split('/') : [];
672
- const [parentRoot, parentPath] = parentAssetLocation(currentRoot, currentPath);
668
+ const parentPath = parentAssetPath(currentPath);
673
669
  const closeSearch = () => {
674
670
  onSearchQueryChange('');
675
671
  onSearchOpenChange(false);
676
672
  };
673
+ useEffect(() => {
674
+ if (!actionsOpen) return;
675
+ const close = (event: PointerEvent) => {
676
+ if (!actionsRef.current?.contains(event.target as Node)) setActionsOpen(false);
677
+ };
678
+ document.addEventListener('pointerdown', close);
679
+ return () => document.removeEventListener('pointerdown', close);
680
+ }, [actionsOpen]);
677
681
 
678
682
  return (
679
- <EditorToolbar
680
- label="Asset browser controls"
681
- compact
682
- style={{
683
- gap: 6,
684
- flexWrap: 'wrap',
685
- flexShrink: 0,
686
- }}
687
- >
688
- <Tooltip text="Back" position="top">
689
- <IconButton
690
- data-testid="asset-back"
691
- aria-label="Back"
692
- disabled={currentRoot === null}
693
- onClick={() => onNavigate(parentRoot, parentPath)}
694
- >
695
- <EditorIcon icon={faArrowLeft} />
696
- </IconButton>
697
- </Tooltip>
698
- {searchOpen ? (
699
- <TextInput
700
- type="text"
701
- data-testid="asset-search-input"
702
- aria-label="Search assets"
703
- placeholder="Search assets..."
704
- value={searchQuery}
705
- onChange={(event) => onSearchQueryChange(event.target.value)}
706
- onKeyDown={(event) => {
707
- if (event.key === 'Escape') closeSearch();
708
- }}
709
- className="vgai-input"
710
- autoFocus
711
- style={{ flex: 1, minWidth: 60 }}
712
- />
713
- ) : currentRoot !== null ? (
714
- <nav
715
- data-testid="asset-breadcrumb"
716
- aria-label="Asset location"
717
- style={{
718
- flex: 1,
719
- display: 'flex',
720
- alignItems: 'center',
721
- gap: 2,
722
- fontSize: 12,
723
- minWidth: 0,
724
- overflow: 'hidden',
725
- whiteSpace: 'nowrap',
726
- }}
727
- >
728
- {currentPath ? (
729
- <Button
730
- type="button"
731
- variant="ghost"
732
- size="compact"
733
- onClick={() => onNavigate(currentRoot, '')}
734
- style={{ flexShrink: 0 }}
735
- >
736
- {assetRootLabel(currentRoot)}
737
- </Button>
738
- ) : (
739
- <span aria-current="location" style={{ color: text[1] }}>
740
- {assetRootLabel(currentRoot)}
741
- </span>
742
- )}
743
- {breadcrumbSegments.map((segment, index) => {
744
- const path = breadcrumbSegments.slice(0, index + 1).join('/');
745
- const isLast = index === breadcrumbSegments.length - 1;
746
- return (
747
- <span
748
- key={path}
749
- style={{
750
- display: 'flex',
751
- alignItems: 'center',
752
- gap: 2,
753
- minWidth: isLast ? 0 : undefined,
754
- flexShrink: isLast ? 1 : 0,
755
- }}
756
- >
757
- <span style={{ color: text[3] }}>/</span>
758
- {isLast ? (
759
- <span
760
- aria-current="location"
761
- title={segment}
762
- style={{
763
- color: text[1],
764
- overflow: 'hidden',
765
- textOverflow: 'ellipsis',
766
- }}
767
- >
768
- {segment}
769
- </span>
770
- ) : (
771
- <Button
772
- type="button"
773
- variant="ghost"
774
- size="compact"
775
- onClick={() => onNavigate(currentRoot, path)}
776
- >
777
- {segment}
778
- </Button>
779
- )}
780
- </span>
781
- );
782
- })}
783
- </nav>
784
- ) : (
785
- <div style={{ flex: 1 }} />
786
- )}
787
-
788
- <Tooltip text={searchOpen ? 'Close search' : 'Search'} position="top">
789
- <IconButton
790
- data-testid={searchOpen ? 'asset-search-close' : 'asset-search-toggle'}
791
- aria-label={searchOpen ? 'Close search' : 'Search assets'}
792
- onClick={() => {
793
- if (searchOpen) closeSearch();
794
- else onSearchOpenChange(true);
795
- }}
796
- >
797
- <EditorIcon icon={searchOpen ? editorIcons.action.close : editorIcons.action.search} />
798
- </IconButton>
799
- </Tooltip>
800
- <ToolbarGroup>
801
- <Tooltip text="Grid view" position="top">
683
+ <div className="vgai-project-assets-toolbar">
684
+ <EditorToolbar label="Asset navigation" compact className="vgai-project-assets-toolbar__row">
685
+ <Tooltip text="Back" position="top">
802
686
  <IconButton
803
- aria-label="Grid view"
804
- data-testid="asset-view-grid"
805
- aria-pressed={viewMode === 'grid'}
806
- onClick={() => onViewModeChange('grid')}
687
+ data-testid="asset-back"
688
+ aria-label="Back"
689
+ disabled={!currentPath}
690
+ onClick={() => onNavigate('public', parentPath)}
807
691
  >
808
- <EditorIcon icon={faTableCellsLarge} />
692
+ <EditorIcon icon={faArrowLeft} />
809
693
  </IconButton>
810
694
  </Tooltip>
811
- <Tooltip text="List view" position="top">
812
- <IconButton
813
- aria-label="List view"
814
- data-testid="asset-view-list"
815
- aria-pressed={viewMode === 'list'}
816
- onClick={() => onViewModeChange('list')}
695
+ {searchOpen ? (
696
+ <TextInput
697
+ type="text"
698
+ data-testid="asset-search-input"
699
+ aria-label="Search assets"
700
+ placeholder="Search assets..."
701
+ value={searchQuery}
702
+ onChange={(event) => onSearchQueryChange(event.target.value)}
703
+ onKeyDown={(event) => {
704
+ if (event.key === 'Escape') closeSearch();
705
+ }}
706
+ className="vgai-input"
707
+ autoFocus
708
+ style={{ flex: 1, minWidth: 60 }}
709
+ />
710
+ ) : (
711
+ <nav
712
+ data-testid="asset-breadcrumb"
713
+ aria-label="Asset location"
714
+ style={{
715
+ flex: 1,
716
+ display: 'flex',
717
+ alignItems: 'center',
718
+ gap: 2,
719
+ fontSize: 12,
720
+ minWidth: 0,
721
+ overflow: 'hidden',
722
+ whiteSpace: 'nowrap',
723
+ }}
817
724
  >
818
- <EditorIcon icon={faList} />
819
- </IconButton>
820
- </Tooltip>
821
- <Tooltip text="Column view" position="top">
725
+ {currentPath ? (
726
+ <Button
727
+ type="button"
728
+ variant="ghost"
729
+ size="compact"
730
+ onClick={() => onNavigate('public', '')}
731
+ style={{ flexShrink: 0 }}
732
+ >
733
+ Assets
734
+ </Button>
735
+ ) : (
736
+ <span aria-current="location" style={{ color: text[1] }}>
737
+ Assets
738
+ </span>
739
+ )}
740
+ {breadcrumbSegments.map((segment, index) => {
741
+ const path = breadcrumbSegments.slice(0, index + 1).join('/');
742
+ const isLast = index === breadcrumbSegments.length - 1;
743
+ return (
744
+ <span
745
+ key={path}
746
+ style={{
747
+ display: 'flex',
748
+ alignItems: 'center',
749
+ gap: 2,
750
+ minWidth: isLast ? 0 : undefined,
751
+ flexShrink: isLast ? 1 : 0,
752
+ }}
753
+ >
754
+ <span style={{ color: text[3] }}>/</span>
755
+ {isLast ? (
756
+ <span
757
+ aria-current="location"
758
+ title={segment}
759
+ style={{
760
+ color: text[1],
761
+ overflow: 'hidden',
762
+ textOverflow: 'ellipsis',
763
+ }}
764
+ >
765
+ {segment}
766
+ </span>
767
+ ) : (
768
+ <Button
769
+ type="button"
770
+ variant="ghost"
771
+ size="compact"
772
+ onClick={() => onNavigate('public', path)}
773
+ >
774
+ {segment}
775
+ </Button>
776
+ )}
777
+ </span>
778
+ );
779
+ })}
780
+ </nav>
781
+ )}
782
+
783
+ <Tooltip text={searchOpen ? 'Close search' : 'Search'} position="top">
822
784
  <IconButton
823
- aria-label="Column view"
824
- data-testid="asset-view-columns"
825
- aria-pressed={viewMode === 'columns'}
826
- onClick={() => onViewModeChange('columns')}
785
+ data-testid={searchOpen ? 'asset-search-close' : 'asset-search-toggle'}
786
+ aria-label={searchOpen ? 'Close search' : 'Search assets'}
787
+ onClick={() => {
788
+ if (searchOpen) closeSearch();
789
+ else onSearchOpenChange(true);
790
+ }}
827
791
  >
828
- <EditorIcon icon={faTable} />
792
+ <EditorIcon icon={searchOpen ? editorIcons.action.close : editorIcons.action.search} />
829
793
  </IconButton>
830
794
  </Tooltip>
831
- </ToolbarGroup>
832
- <Select
833
- className="vgai-select"
834
- aria-label="Sort project assets"
835
- value={sort}
836
- onChange={(event) => onSortChange(event.target.value as AssetSort)}
795
+ </EditorToolbar>
796
+ <EditorToolbar
797
+ label="Asset browser controls"
798
+ compact
799
+ className="vgai-project-assets-toolbar__row"
837
800
  >
838
- <option value="name">Name</option>
839
- <option value="type">Type</option>
840
- <option value="size">Size</option>
841
- <option value="health">Health</option>
842
- </Select>
843
- <Select
844
- className="vgai-select"
845
- aria-label="Filter project assets"
846
- value={filter}
847
- onChange={(event) => onFilterChange(event.target.value as AssetFilter)}
848
- >
849
- <option value="all">All</option>
850
- <option value="runtime-ready">Runtime-ready</option>
851
- <option value="source-only">Source-only</option>
852
- <option value="unsupported">Unsupported</option>
853
- <option value="imported">Imported</option>
854
- <option value="generated">Generated</option>
855
- </Select>
856
- {viewMode === 'grid' && (
857
- <TextInput
858
- type="range"
859
- aria-label="Project asset thumbnail size"
860
- min="72"
861
- max="160"
862
- value={density}
863
- onChange={(event) => onDensityChange(Number(event.target.value))}
864
- />
865
- )}
866
- <Button
867
- data-command-id={PROJECT_ASSET_COMMANDS.import.id}
868
- variant="ghost"
869
- size="comfortable"
870
- onClick={onImport}
871
- >
872
- Import
873
- </Button>
874
- <Button
875
- data-command-id={PROJECT_ASSET_COMMANDS.createFolder.id}
876
- variant="ghost"
877
- size="comfortable"
878
- onClick={onCreateFolder}
879
- >
880
- New folder
881
- </Button>
882
- <IconButton
883
- data-command-id={PROJECT_ASSET_COMMANDS.delete.id}
884
- aria-label="Delete selected assets"
885
- disabled={deleteDisabled}
886
- onClick={onDelete}
887
- >
888
- <EditorIcon icon={faTrash} />
889
- </IconButton>
890
- <Tooltip text="Refresh" position="top">
891
- <IconButton
892
- aria-label="Refresh assets"
893
- onClick={(event) => {
894
- event.stopPropagation();
895
- onRefresh();
896
- }}
801
+ <ToolbarGroup>
802
+ <Tooltip text="Grid view" position="top">
803
+ <IconButton
804
+ aria-label="Grid view"
805
+ data-testid="asset-view-grid"
806
+ aria-pressed={viewMode === 'grid'}
807
+ onClick={() => onViewModeChange('grid')}
808
+ >
809
+ <EditorIcon icon={faTableCellsLarge} />
810
+ </IconButton>
811
+ </Tooltip>
812
+ <Tooltip text="List view" position="top">
813
+ <IconButton
814
+ aria-label="List view"
815
+ data-testid="asset-view-list"
816
+ aria-pressed={viewMode === 'list'}
817
+ onClick={() => onViewModeChange('list')}
818
+ >
819
+ <EditorIcon icon={faList} />
820
+ </IconButton>
821
+ </Tooltip>
822
+ </ToolbarGroup>
823
+ <Select
824
+ className="vgai-select vgai-project-assets-toolbar__select"
825
+ aria-label="Sort project assets"
826
+ value={sort}
827
+ onChange={(event) => onSortChange(event.target.value as AssetSort)}
897
828
  >
898
- <EditorIcon icon={editorIcons.action.refresh} />
899
- </IconButton>
900
- </Tooltip>
901
- </EditorToolbar>
902
- );
903
- }
904
-
905
- function ProjectAssetTree(props: {
906
- currentRoot: AssetRoot | null;
907
- currentPath: string;
908
- expanded: ReadonlySet<string>;
909
- onExpanded(path: string): void;
910
- onNavigate(root: AssetRoot | null, path: string): void;
911
- }) {
912
- const [folders, setFolders] = useState(new Map<string, string[]>());
913
- useEffect(() => {
914
- for (const key of props.expanded) {
915
- if (!key.startsWith('public') || folders.has(key)) continue;
916
- const path = key === 'public' ? '' : key.slice('public/'.length);
917
- void listAssets('public', path).then((entries) => {
918
- setFolders((current) => {
919
- const next = new Map(current);
920
- next.set(
921
- key,
922
- entries.filter((entry) => entry.type === 'directory').map((entry) => entry.name),
923
- );
924
- return next;
925
- });
926
- });
927
- }
928
- }, [folders, props.expanded]);
929
-
930
- const renderFolder = (path: string, depth: number): React.ReactNode => {
931
- const key = path ? `public/${path}` : 'public';
932
- const open = props.expanded.has(key);
933
- return (
934
- <div key={key}>
935
- <div className="vgai-asset-tree-row" style={{ paddingLeft: 6 + depth * 14 }}>
936
- <Button
937
- type="button"
938
- className="vgai-asset-tree-toggle"
939
- aria-label={`${open ? 'Collapse' : 'Expand'} ${path.split('/').pop() || 'public'}`}
940
- aria-expanded={open}
941
- onClick={() => props.onExpanded(key)}
942
- >
943
- <EditorIcon
944
- icon={faChevronRight}
945
- style={{ transform: open ? 'rotate(90deg)' : undefined }}
946
- />
947
- </Button>
948
- <Button
949
- type="button"
950
- className="vgai-asset-tree-target"
951
- aria-current={
952
- props.currentRoot === 'public' && props.currentPath === path ? 'page' : undefined
953
- }
954
- onClick={() => props.onNavigate('public', path)}
829
+ <option value="name">Name</option>
830
+ <option value="type">Type</option>
831
+ <option value="size">Size</option>
832
+ <option value="health">Health</option>
833
+ </Select>
834
+ <Select
835
+ className="vgai-select vgai-project-assets-toolbar__select"
836
+ aria-label="Filter project assets"
837
+ value={filter}
838
+ onChange={(event) => onFilterChange(event.target.value as AssetFilter)}
839
+ >
840
+ <option value="all">All</option>
841
+ <option value="runtime-ready">Runtime-ready</option>
842
+ <option value="source-only">Source-only</option>
843
+ <option value="unsupported">Unsupported</option>
844
+ <option value="imported">Imported</option>
845
+ <option value="generated">Generated</option>
846
+ </Select>
847
+ <Tooltip text="Import files" position="top">
848
+ <IconButton
849
+ data-command-id={PROJECT_ASSET_COMMANDS.import.id}
850
+ aria-label="Import files"
851
+ onClick={onImport}
955
852
  >
956
- <EditorIcon icon={faFolder} />
957
- {path.split('/').pop() || 'public'}
958
- </Button>
853
+ <EditorIcon icon={faBoxArchive} />
854
+ </IconButton>
855
+ </Tooltip>
856
+ <div ref={actionsRef} className="vgai-project-assets-toolbar__more">
857
+ <Tooltip text="More asset actions" position="top">
858
+ <IconButton
859
+ aria-label="More asset actions"
860
+ aria-expanded={actionsOpen}
861
+ aria-haspopup="menu"
862
+ onClick={() => setActionsOpen((value) => !value)}
863
+ >
864
+ <EditorIcon icon={faEllipsis} />
865
+ </IconButton>
866
+ </Tooltip>
867
+ {actionsOpen && (
868
+ <Menu
869
+ autoFocusFirst
870
+ className="vgai-project-assets-toolbar__menu"
871
+ onDismiss={() => setActionsOpen(false)}
872
+ >
873
+ <MenuItem
874
+ data-command-id={PROJECT_ASSET_COMMANDS.createFolder.id}
875
+ onSelect={() => {
876
+ setActionsOpen(false);
877
+ onCreateFolder();
878
+ }}
879
+ >
880
+ New folder
881
+ </MenuItem>
882
+ <MenuItem
883
+ onSelect={() => {
884
+ setActionsOpen(false);
885
+ onDensityChange(density <= 96 ? 132 : 88);
886
+ }}
887
+ >
888
+ {density <= 96 ? 'Larger thumbnails' : 'Smaller thumbnails'}
889
+ </MenuItem>
890
+ <MenuItem
891
+ disabled={deleteDisabled}
892
+ data-command-id={PROJECT_ASSET_COMMANDS.delete.id}
893
+ onSelect={() => {
894
+ setActionsOpen(false);
895
+ onDelete();
896
+ }}
897
+ >
898
+ Delete selected
899
+ </MenuItem>
900
+ <MenuItem
901
+ onSelect={() => {
902
+ setActionsOpen(false);
903
+ onRefresh();
904
+ }}
905
+ >
906
+ Refresh
907
+ </MenuItem>
908
+ </Menu>
909
+ )}
959
910
  </div>
960
- {open &&
961
- folders.get(key)?.map((name) => renderFolder(path ? `${path}/${name}` : name, depth + 1))}
962
- </div>
963
- );
964
- };
965
-
966
- return (
967
- <aside className="vgai-asset-tree" aria-label="Project asset sources">
968
- <div className="vgai-asset-facet-title">Project</div>
969
- {renderFolder('', 0)}
970
- <Button
971
- type="button"
972
- className="vgai-asset-tree-row"
973
- onClick={() => props.onNavigate('data', '')}
974
- >
975
- <EditorIcon icon={faTable} /> src/data
976
- </Button>
977
- <Button
978
- type="button"
979
- className="vgai-asset-tree-row"
980
- onClick={() => props.onNavigate('stories', '')}
981
- >
982
- <EditorIcon icon={faBookOpen} /> stories
983
- </Button>
984
- <div className="vgai-asset-facet-title">References</div>
985
- <Button
986
- type="button"
987
- className="vgai-asset-tree-row"
988
- onClick={() => props.onNavigate('public', 'asset-library/local')}
989
- >
990
- Imported
991
- </Button>
992
- <Button
993
- type="button"
994
- className="vgai-asset-tree-row"
995
- onClick={() => props.onNavigate('public', '.generated')}
996
- >
997
- Generated
998
- </Button>
999
- </aside>
911
+ </EditorToolbar>
912
+ </div>
1000
913
  );
1001
914
  }
1002
915
 
@@ -1004,19 +917,12 @@ export function AssetBrowser({ onOpenScene }: AssetBrowserProps) {
1004
917
  const store = useEditorStore();
1005
918
  const history = useHistoryService();
1006
919
  const initial = useMemo(readAssetBrowserState, []);
1007
- const dataVersion = useSyncExternalStore(subscribeDataFiles, dataFilesVersion, dataFilesVersion);
1008
- const storyModules = useSyncExternalStore(
1009
- subscribeProjectStoryModules,
1010
- getProjectStoryModules,
1011
- getProjectStoryModules,
1012
- );
1013
- const [currentRoot, setCurrentRoot] = useState<AssetRoot | null>(initial.currentRoot);
920
+ const currentRoot: AssetRoot = 'public';
1014
921
  const [currentPath, setCurrentPath] = useState(initial.currentPath);
1015
922
  const [viewMode, setViewMode] = useState<AssetViewMode>(initial.viewMode);
1016
923
  const [sort, setSort] = useState<AssetSort>(initial.sort);
1017
924
  const [filter, setFilter] = useState<AssetFilter>(initial.filter);
1018
925
  const [density, setDensity] = useState(initial.density);
1019
- const [expanded, setExpanded] = useState(new Set(initial.expanded));
1020
926
  const [searchOpen, setSearchOpen] = useState(false);
1021
927
  const [entries, setEntries] = useState<AssetEntry[]>([]);
1022
928
  const [searchQuery, setSearchQuery] = useState('');
@@ -1035,16 +941,14 @@ export function AssetBrowser({ onOpenScene }: AssetBrowserProps) {
1035
941
  localStorage.setItem(
1036
942
  projectAssetBrowserStateKey(projectId),
1037
943
  JSON.stringify({
1038
- currentRoot,
1039
944
  currentPath,
1040
945
  viewMode,
1041
946
  sort,
1042
947
  filter,
1043
948
  density,
1044
- expanded: [...expanded],
1045
949
  } satisfies PersistedAssetBrowserState),
1046
950
  );
1047
- }, [currentPath, currentRoot, density, expanded, filter, sort, viewMode]);
951
+ }, [currentPath, density, filter, sort, viewMode]);
1048
952
 
1049
953
  const fetchEntries = useCallback(async (root: AssetRoot, dir: string) => {
1050
954
  const key = `${root}:${dir}`;
@@ -1063,11 +967,10 @@ export function AssetBrowser({ onOpenScene }: AssetBrowserProps) {
1063
967
  }, []);
1064
968
 
1065
969
  useEffect(() => {
1066
- if (currentRoot === 'public') fetchEntries(currentRoot, currentPath);
970
+ fetchEntries(currentRoot, currentPath);
1067
971
  }, [currentRoot, currentPath, fetchEntries]);
1068
972
 
1069
- const navigate = useCallback((root: AssetRoot | null, path: string) => {
1070
- setCurrentRoot(root);
973
+ const navigate = useCallback((_root: AssetRoot, path: string) => {
1071
974
  setCurrentPath(path);
1072
975
  setSelectedFiles(new Set());
1073
976
  setSelectionAnchor(null);
@@ -1076,101 +979,30 @@ export function AssetBrowser({ onOpenScene }: AssetBrowserProps) {
1076
979
  setSearchOpen(false);
1077
980
  }, []);
1078
981
 
1079
- // Project data and CSF stories are typed content inside the Assets browser,
1080
- // not permanent peer navigation modes. Their existing center documents
1081
- // remain the editing/preview surfaces.
982
+ // Assets is a file browser over the project's actual public asset root.
983
+ // Data and React stories have their own semantic document/hierarchy entry
984
+ // points; presenting them here as synthetic folders made the browser lie
985
+ // about the filesystem and duplicated navigation.
1082
986
  const browserEntries = useMemo<BrowserEntry[]>(() => {
1083
- if (currentRoot === null) {
1084
- return [
1085
- {
1086
- source: 'root',
1087
- key: 'root:public',
1088
- name: 'public',
1089
- root: 'public',
1090
- detail: 'Models, images, scenes, audio, and other game assets',
1091
- },
1092
- {
1093
- source: 'root',
1094
- key: 'root:data',
1095
- name: 'src/data',
1096
- root: 'data',
1097
- detail: 'Typed game data',
1098
- },
1099
- {
1100
- source: 'root',
1101
- key: 'root:stories',
1102
- name: 'stories',
1103
- root: 'stories',
1104
- detail: 'React component stories',
1105
- },
1106
- ];
1107
- }
1108
- if (currentRoot === 'data') {
1109
- return loadedDataFiles().map((file) => ({
1110
- source: 'data',
1111
- key: `data:${file.path}`,
1112
- name: file.path.split('/').pop() ?? file.path,
1113
- path: file.path,
1114
- }));
1115
- }
1116
- if (currentRoot === 'stories') {
1117
- return storyModules.flatMap((module_) =>
1118
- module_.ok
1119
- ? module_.stories.map((story) => ({
1120
- source: 'story' as const,
1121
- key: `story:${module_.modulePath}:${story.name}`,
1122
- name: story.name,
1123
- modulePath: module_.modulePath,
1124
- storyName: story.name,
1125
- }))
1126
- : [],
1127
- );
1128
- }
1129
987
  return entries.map((entry) => ({
1130
988
  source: 'asset' as const,
1131
989
  key: `asset:${currentPath}:${entry.name}`,
1132
990
  name: entry.name,
1133
991
  entry,
1134
992
  }));
1135
- }, [currentPath, currentRoot, dataVersion, entries, storyModules]);
993
+ }, [currentPath, entries]);
1136
994
  const filtered = useMemo(() => {
1137
995
  const query = searchQuery.toLowerCase();
1138
996
  const visible = browserEntries.filter((item) => {
1139
997
  if (query && !item.name.toLowerCase().includes(query)) return false;
1140
- if (filter === 'all' || item.source !== 'asset' || item.entry.type === 'directory')
1141
- return true;
1142
- const capability = assetCapabilities(item.name);
1143
- const path = currentPath ? `${currentPath}/${item.name}` : item.name;
1144
- if (filter === 'runtime-ready') return capability.runtimeReady;
1145
- if (filter === 'source-only') return capability.importable && !capability.runtimeReady;
1146
- if (filter === 'unsupported') return !capability.importable;
1147
- if (filter === 'imported') return path.startsWith('asset-library/local/');
1148
- return path.startsWith('.generated/');
1149
- });
1150
- return [...visible].sort((left, right) => {
1151
- if (left.source === 'asset' && right.source === 'asset') {
1152
- if (left.entry.type !== right.entry.type) return left.entry.type === 'directory' ? -1 : 1;
1153
- if (sort === 'size' && left.entry.size !== right.entry.size)
1154
- return right.entry.size - left.entry.size;
1155
- if (sort === 'type') {
1156
- const order = getAssetKind(left.entry).localeCompare(getAssetKind(right.entry));
1157
- if (order !== 0) return order;
1158
- }
1159
- if (sort === 'health') {
1160
- const leftHealth = projectAssetHealth(left.name, currentPath);
1161
- const rightHealth = projectAssetHealth(right.name, currentPath);
1162
- const order = leftHealth.localeCompare(rightHealth);
1163
- if (order !== 0) return order;
1164
- }
1165
- }
1166
- return left.name.localeCompare(right.name);
998
+ return matchesAssetFilter(item, filter, currentPath);
1167
999
  });
1000
+ return [...visible].sort((left, right) => compareAssetEntries(left, right, sort, currentPath));
1168
1001
  }, [browserEntries, currentPath, filter, searchQuery, sort]);
1169
1002
 
1170
1003
  // --- Drag start ---
1171
1004
  const onDragStart = useCallback(
1172
1005
  (e: React.DragEvent, item: BrowserEntry) => {
1173
- if (item.source !== 'asset' || currentRoot !== 'public') return;
1174
1006
  const { entry } = item;
1175
1007
  const capability = assetCapabilities(entry.name);
1176
1008
  if (entry.type !== 'file' || !capability.placeable) {
@@ -1212,10 +1044,6 @@ export function AssetBrowser({ onOpenScene }: AssetBrowserProps) {
1212
1044
  setSelectionAnchor(item.key);
1213
1045
  }
1214
1046
  setSelectedFiles(next);
1215
- if (item.source !== 'asset' || currentRoot !== 'public') {
1216
- clearSelectedAsset();
1217
- return;
1218
- }
1219
1047
  const { entry } = item;
1220
1048
  if (entry.type === 'directory') {
1221
1049
  clearSelectedAsset();
@@ -1269,20 +1097,8 @@ export function AssetBrowser({ onOpenScene }: AssetBrowserProps) {
1269
1097
  // workspace document (W2; was a right-rail asset tab pre-W2). ---
1270
1098
  const onDoubleClick = useCallback(
1271
1099
  (item: BrowserEntry) => {
1272
- if (item.source === 'root') {
1273
- navigate(item.root, '');
1274
- return;
1275
- }
1276
- if (item.source === 'data') {
1277
- openDataDocument(store, item.path);
1278
- return;
1279
- }
1280
- if (item.source === 'story') {
1281
- openStoryDocument(store, item.modulePath, item.storyName);
1282
- return;
1283
- }
1284
1100
  openProjectAssetEntry(item.entry, {
1285
- currentRoot: currentRoot ?? 'public',
1101
+ currentRoot,
1286
1102
  currentPath,
1287
1103
  navigate,
1288
1104
  ...(onOpenScene ? { onOpenScene } : {}),
@@ -1294,20 +1110,6 @@ export function AssetBrowser({ onOpenScene }: AssetBrowserProps) {
1294
1110
 
1295
1111
  // --- Refresh ---
1296
1112
  const onRefresh = useCallback(() => {
1297
- if (currentRoot === null) {
1298
- cacheRef.current.clear();
1299
- void refreshDataFiles();
1300
- void refreshProjectStories(getCurrentProject());
1301
- return;
1302
- }
1303
- if (currentRoot === 'data') {
1304
- void refreshDataFiles();
1305
- return;
1306
- }
1307
- if (currentRoot === 'stories') {
1308
- void refreshProjectStories(getCurrentProject());
1309
- return;
1310
- }
1311
1113
  cacheRef.current.clear();
1312
1114
  fetchEntries(currentRoot, currentPath);
1313
1115
  }, [currentRoot, currentPath, fetchEntries]);
@@ -1340,9 +1142,7 @@ export function AssetBrowser({ onOpenScene }: AssetBrowserProps) {
1340
1142
  const selectedAssetPaths = useMemo(
1341
1143
  () =>
1342
1144
  selectedItems.flatMap((item) =>
1343
- item.source === 'asset' && item.entry.type === 'file'
1344
- ? [currentPath ? `${currentPath}/${item.name}` : item.name]
1345
- : [],
1145
+ item.entry.type === 'file' ? [currentPath ? `${currentPath}/${item.name}` : item.name] : [],
1346
1146
  ),
1347
1147
  [currentPath, selectedItems],
1348
1148
  );
@@ -1402,7 +1202,7 @@ export function AssetBrowser({ onOpenScene }: AssetBrowserProps) {
1402
1202
 
1403
1203
  const renameItem = useCallback(
1404
1204
  async (item: BrowserEntry) => {
1405
- if (item.source !== 'asset' || item.entry.type !== 'file') return;
1205
+ if (item.entry.type !== 'file') return;
1406
1206
  const nextName = window.prompt('Rename asset', item.name);
1407
1207
  if (!nextName || nextName === item.name) return;
1408
1208
  const source = currentPath ? `${currentPath}/${item.name}` : item.name;
@@ -1414,7 +1214,7 @@ export function AssetBrowser({ onOpenScene }: AssetBrowserProps) {
1414
1214
 
1415
1215
  const duplicateItem = useCallback(
1416
1216
  async (item: BrowserEntry) => {
1417
- if (item.source !== 'asset' || item.entry.type !== 'file') return;
1217
+ if (item.entry.type !== 'file') return;
1418
1218
  const dot = item.name.lastIndexOf('.');
1419
1219
  const copyName =
1420
1220
  dot > 0 ? `${item.name.slice(0, dot)} copy${item.name.slice(dot)}` : `${item.name} copy`;
@@ -1425,15 +1225,6 @@ export function AssetBrowser({ onOpenScene }: AssetBrowserProps) {
1425
1225
  [currentPath, finishOperation],
1426
1226
  );
1427
1227
 
1428
- const toggleExpanded = useCallback((path: string) => {
1429
- setExpanded((current) => {
1430
- const next = new Set(current);
1431
- if (next.has(path)) next.delete(path);
1432
- else next.add(path);
1433
- return next;
1434
- });
1435
- }, []);
1436
-
1437
1228
  useEffect(() => {
1438
1229
  const handler = (event: Event) => {
1439
1230
  const id = (event as CustomEvent<{ id?: string }>).detail?.id;
@@ -1480,16 +1271,15 @@ export function AssetBrowser({ onOpenScene }: AssetBrowserProps) {
1480
1271
  event.preventDefault();
1481
1272
  }
1482
1273
  }}
1483
- style={{ display: 'flex', flex: 1, minHeight: 0, position: 'relative' }}
1274
+ style={{
1275
+ display: 'flex',
1276
+ flexDirection: 'column',
1277
+ flex: 1,
1278
+ minHeight: 0,
1279
+ position: 'relative',
1280
+ }}
1484
1281
  >
1485
- <ProjectAssetTree
1486
- currentRoot={currentRoot}
1487
- currentPath={currentPath}
1488
- expanded={expanded}
1489
- onExpanded={toggleExpanded}
1490
- onNavigate={navigate}
1491
- />
1492
- <div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
1282
+ <div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0, minHeight: 0 }}>
1493
1283
  <TextInput
1494
1284
  ref={importInputRef}
1495
1285
  type="file"
@@ -1502,7 +1292,6 @@ export function AssetBrowser({ onOpenScene }: AssetBrowserProps) {
1502
1292
  }}
1503
1293
  />
1504
1294
  <AssetBrowserToolbar
1505
- currentRoot={currentRoot}
1506
1295
  currentPath={currentPath}
1507
1296
  searchOpen={searchOpen}
1508
1297
  searchQuery={searchQuery}
@@ -1526,6 +1315,8 @@ export function AssetBrowser({ onOpenScene }: AssetBrowserProps) {
1526
1315
 
1527
1316
  {/* One file-browser surface; grid/list are presentation modes, not navigation. */}
1528
1317
  <div
1318
+ className="vgai-project-assets-results"
1319
+ data-view={viewMode}
1529
1320
  style={{
1530
1321
  flex: 1,
1531
1322
  overflowY: 'auto',