@vgai/editor 0.4.0 → 0.4.1

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 (137) hide show
  1. package/dist/assets/{axe-BJjvBeOZ.js → axe-N4cNyzfs.js} +1 -1
  2. package/dist/assets/{browserAll-Dn0pgD87.js → browserAll-BIBT0Z2H.js} +1 -1
  3. package/dist/assets/{create-runtime-BF9OD-U8.js → create-runtime-DxLhdk4U.js} +1 -1
  4. package/dist/assets/{game-u6c2Patu.js → game-4NbNRROm.js} +1 -1
  5. package/dist/assets/{game-umrHIqFB.js → game-B3UH5LjS.js} +1 -1
  6. package/dist/assets/{game-BR0GBNgF.js → game-ByxRLJi1.js} +1 -1
  7. package/dist/assets/{game-DeTGdgYt.js → game-CJ5BqOYG.js} +1 -1
  8. package/dist/assets/{game-DQxhCYsZ.js → game-CSJm1kwB.js} +1 -1
  9. package/dist/assets/{game-CLNm2g0y.js → game-D9Mo4L3Q.js} +1 -1
  10. package/dist/assets/{game-B346oj_f.js → game-DJmU9BQF.js} +1 -1
  11. package/dist/assets/{game-BCNN63uV.js → game-ZqGLgxRj.js} +1 -1
  12. package/dist/assets/{game-loop-t5W_oqxz.js → game-loop-9YP76JB1.js} +1 -1
  13. package/dist/assets/{game-source-DzE9taML.js → game-source-DLKG9pmj.js} +1 -1
  14. package/dist/assets/{index-DbQzGLxv.js → index-8A4JLNIp.js} +902 -892
  15. package/dist/assets/{index-CgcH1RpF.js → index-Brzr17q0.js} +1 -1
  16. package/dist/assets/{index-D3tcNB2T.js → index-CnKtvrru.js} +1 -1
  17. package/dist/assets/{index-Fc8Ezr20.js → index-D-WR11Jw.js} +1 -1
  18. package/dist/assets/index-DiUacMXR.css +1 -0
  19. package/dist/assets/{ingest-siblings-CW_xbvrF.js → ingest-siblings-Cmx39OBK.js} +1 -1
  20. package/dist/assets/{mount-manifest-DgUscyaD.js → mount-manifest-kzdZgkkc.js} +1 -1
  21. package/dist/assets/{react-18-C4tVCii_.js → react-18-DvWsdwa_.js} +1 -1
  22. package/dist/assets/webworkerAll-C9GR-KrR.js +1 -0
  23. package/dist/assets/{world-documents-BSaA5YIF.js → world-documents-DM_aIMAW.js} +1 -1
  24. package/dist/index.html +2 -2
  25. package/dist-server/packaged.mjs +27125 -1075
  26. package/package.json +2 -2
  27. package/server/asset-history-snapshots.ts +189 -0
  28. package/server/asset-library-routes.ts +404 -53
  29. package/server/catalog-import-recipe.ts +97 -0
  30. package/server/catalog-reimport.ts +141 -0
  31. package/server/cloud-asset-catalog.ts +71 -5
  32. package/server/dev.ts +1 -1
  33. package/server/editor-server.ts +106 -51
  34. package/server/editor-sse.ts +5 -0
  35. package/server/generative-execution-context.ts +45 -0
  36. package/server/model-import-conversion.ts +345 -0
  37. package/server/packaged.ts +1 -1
  38. package/server/project-hmr-files.ts +3 -5
  39. package/server/project-output-writer.ts +440 -36
  40. package/server/project-tools.ts +529 -0
  41. package/server/server-utils.ts +14 -11
  42. package/src/EditorContext.tsx +4 -4
  43. package/src/action-registry.ts +12 -0
  44. package/src/asset-editor-context.tsx +9 -0
  45. package/src/asset-events.ts +3 -15
  46. package/src/asset-selection.ts +3 -0
  47. package/src/asset-workflow/asset-capabilities.ts +40 -0
  48. package/src/asset-workflow/asset-import-jobs.ts +108 -0
  49. package/src/asset-workflow/asset-library-query.ts +8 -1
  50. package/src/asset-workflow/asset-workflow-quality.ts +2 -0
  51. package/src/asset-workflow/import-contract.ts +2 -0
  52. package/src/asset-workflow/model-inspection.ts +92 -1
  53. package/src/asset-workflow/preview-resource-lifetime.ts +44 -0
  54. package/src/asset-workflow/project-asset-commands.ts +1 -0
  55. package/src/asset-workflow/project-asset-health.ts +3 -6
  56. package/src/asset-workflow/project-asset-operations.ts +119 -4
  57. package/src/command-listener.ts +37 -9
  58. package/src/components/ApplicationMenus.tsx +13 -15
  59. package/src/components/AssetBrowser.tsx +301 -26
  60. package/src/components/AssetImportDetails.tsx +207 -92
  61. package/src/components/AssetImportJobStatus.tsx +55 -0
  62. package/src/components/CenterDocuments.tsx +28 -1
  63. package/src/components/CommandPalette.tsx +2 -2
  64. package/src/components/DefaultEditorLayout.tsx +4 -0
  65. package/src/components/InspectorToolSection.tsx +12 -10
  66. package/src/components/OnlineAssetBrowser.tsx +197 -11
  67. package/src/components/ToolHost.tsx +8 -8
  68. package/src/components/ViewportOverlay.tsx +13 -26
  69. package/src/components/ViewportPanel.tsx +19 -14
  70. package/src/components/ViewportShadingMenu.tsx +103 -0
  71. package/src/components/WorkspaceDock.tsx +103 -17
  72. package/src/components/asset-documents.tsx +38 -11
  73. package/src/components/asset-editor-persistence.ts +300 -5
  74. package/src/components/asset-selection-section.tsx +83 -9
  75. package/src/components/asset-viewers/AudioViewer.tsx +4 -7
  76. package/src/components/asset-viewers/ModelViewer.tsx +168 -5
  77. package/src/components/asset-viewers/OnlineAssetDetail.tsx +52 -12
  78. package/src/components/asset-viewers/SourceAssetViewer.tsx +7 -26
  79. package/src/components/asset-workflow.css +30 -3
  80. package/src/components/primitives/editor-icons.ts +1 -0
  81. package/src/components/project-tool-documents.tsx +298 -0
  82. package/src/components/status-contributions.tsx +38 -0
  83. package/src/components/tool-documents.tsx +20 -17
  84. package/src/components/tool-schema-form.tsx +258 -0
  85. package/src/components/workspace-dock.css +16 -0
  86. package/src/editor-api.ts +159 -22
  87. package/src/editor-store.ts +6 -5
  88. package/src/editor-viewport.ts +12 -7
  89. package/src/game-viewport-state.ts +28 -0
  90. package/src/model-thumbnail.ts +97 -9
  91. package/src/play-mode.ts +30 -3
  92. package/src/project-tool-discovery.ts +5 -5
  93. package/src/{project-operations.ts → project-tools.ts} +39 -26
  94. package/src/scene-sync.ts +0 -67
  95. package/src/storage/handle-storage.ts +5 -1
  96. package/src/storage/http-storage.ts +7 -2
  97. package/src/storage/mem-storage.ts +7 -1
  98. package/src/storage/types.ts +2 -0
  99. package/src/theme-css.ts +17 -7
  100. package/src/tool-loader.ts +152 -241
  101. package/src/viewport-shading-boundary.ts +15 -0
  102. package/src/widgets/index.ts +2 -1
  103. package/src/workspace-dock-controller.ts +8 -0
  104. package/src/workspace-document-registry.ts +4 -4
  105. package/src/workspace-state-persistence.ts +36 -10
  106. package/template/.claude/skills/editor/SKILL.md +13 -3
  107. package/template/AGENTS.md +100 -83
  108. package/template/package.json +16 -0
  109. package/template/public/scenes/default.vscn.json +1 -2
  110. package/template/scripts/playtest.ts +7 -37
  111. package/template/server/rooms/game-room.ts +2 -2
  112. package/template/src/data/README.md +3 -2
  113. package/template/src/data/tuning.data.json +1 -3
  114. package/template/src/data/tuning.schema.json +0 -12
  115. package/template/src/data/tuning.schema.ts +2 -9
  116. package/template/src/data/tuning.ts +1 -1
  117. package/template/src/scripts/registry.ts +0 -3
  118. package/template/src/tools/README.md +151 -16
  119. package/template/src/tools/autoplay.tool.ts +31 -0
  120. package/template/src/tools/autoplay.ts +15 -0
  121. package/template/src/tools/tuning.document.tsx +107 -0
  122. package/template/src/tools/tuning.tool.ts +24 -0
  123. package/template/src/tools/tuning.ts +9 -0
  124. package/template/src/ui/Button.stories.tsx +1 -1
  125. package/template/tests/acceptance/example.spec.ts +4 -59
  126. package/template/vgai.game.json +1 -1
  127. package/dist/assets/index-bAGhEnnO.css +0 -1
  128. package/dist/assets/webworkerAll-BA4aq7xq.js +0 -1
  129. package/dist/project-scripts.bundle.js +0 -3917
  130. package/server/project-operations.ts +0 -343
  131. package/src/components/project-operation-documents.tsx +0 -226
  132. package/template/src/operations/README.md +0 -88
  133. package/template/src/scripts/components/data-spinner.ts +0 -86
  134. package/template/src/scripts/components/spin-lap-math.ts +0 -27
  135. package/template/src/tools/example.tool.tsx +0 -97
  136. package/template/src/tools/spin.tool.tsx +0 -101
  137. package/template/tests/logic/example.test.ts +0 -31
@@ -26,6 +26,7 @@ import {
26
26
  } from '../harness-chat-layout';
27
27
  import type { HotkeyScope } from '../hotkeys';
28
28
  import {
29
+ closeWorkspaceUtility,
29
30
  installWorkspaceDockCommands,
30
31
  type WorkspaceDockCommands,
31
32
  } from '../workspace-dock-controller';
@@ -80,6 +81,20 @@ type WorkspacePanelParams =
80
81
  | { kind: 'document'; documentId: string; closeable: boolean }
81
82
  | { kind: 'utility'; utilityId: string; closeable: boolean };
82
83
 
84
+ type UtilityRevealOrigin = { previousPanelId: string | null; wasCollapsed: boolean };
85
+
86
+ function rememberUtilityReveal(
87
+ panel: NonNullable<ReturnType<DockviewApi['getPanel']>>,
88
+ origins: Map<string, UtilityRevealOrigin>,
89
+ utilityId: string,
90
+ ): void {
91
+ if (panel.api.isActive) return;
92
+ origins.set(utilityId, {
93
+ previousPanelId: panel.api.group.activePanel?.id ?? null,
94
+ wasCollapsed: panel.api.location.type === 'edge' && panel.api.group.api.isCollapsed(),
95
+ });
96
+ }
97
+
83
98
  function hotkeyScopeForPanel(params: WorkspacePanelParams): HotkeyScope {
84
99
  if (params.kind === 'utility') return 'workspace';
85
100
  if (params.kind === 'document') {
@@ -144,6 +159,7 @@ function WorkspacePanel({ api, params }: IDockviewPanelProps<WorkspacePanelParam
144
159
  workspaceUtilityRegistryVersion,
145
160
  workspaceUtilityRegistryVersion,
146
161
  );
162
+ useSyncExternalStore(subscribeAvailabilityTick, availabilityTickVersion, availabilityTickVersion);
147
163
 
148
164
  if (params.kind === 'static') {
149
165
  return (
@@ -160,7 +176,7 @@ function WorkspacePanel({ api, params }: IDockviewPanelProps<WorkspacePanelParam
160
176
  if (params.kind === 'utility') {
161
177
  const utility = availableWorkspaceUtilities().find((item) => item.id === params.utilityId);
162
178
  if (!utility) {
163
- return <div className="vgai-dock-empty">This utility is not available in this session.</div>;
179
+ return <UnavailableUtilityPanel utilityId={params.utilityId} />;
164
180
  }
165
181
  const Content = utility.Content;
166
182
  return (
@@ -204,6 +220,13 @@ function WorkspacePanel({ api, params }: IDockviewPanelProps<WorkspacePanelParam
204
220
  );
205
221
  }
206
222
 
223
+ function UnavailableUtilityPanel({ utilityId }: { utilityId: string }) {
224
+ useEffect(() => {
225
+ closeWorkspaceUtility(utilityId);
226
+ }, [utilityId]);
227
+ return null;
228
+ }
229
+
207
230
  function workspaceTabTestId(params: WorkspacePanelParams, panelId: string): string {
208
231
  if (params.kind === 'document') return `workspace-tab:${params.documentId}`;
209
232
  if (params.kind === 'utility') return `bottom-tab-utility-${params.utilityId}`;
@@ -278,6 +301,7 @@ function closeDockviewDocumentTab(
278
301
  return true;
279
302
  }
280
303
 
304
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: one tab renderer owns Dockview activation, accessible close, dirty-save confirmation, badges, and resource-family metadata.
281
305
  function WorkspaceTab(props: IDockviewPanelHeaderProps<WorkspacePanelParams>) {
282
306
  const { api, params } = props;
283
307
  const [confirmingClose, setConfirmingClose] = useState(false);
@@ -300,16 +324,18 @@ function WorkspaceTab(props: IDockviewPanelHeaderProps<WorkspacePanelParams>) {
300
324
  const badge = useWorkspaceUtilityBadge(params.kind === 'utility' ? params.utilityId : null);
301
325
  const testId = workspaceTabTestId(params, api.id);
302
326
  const tabFamily = documentTabFamily(params);
327
+ const requestDocumentClose = () => {
328
+ if (params.kind !== 'document') return;
329
+ if (document?.dirty) {
330
+ setConfirmingClose(true);
331
+ return;
332
+ }
333
+ closeDockviewDocumentTab(props, params.documentId);
334
+ };
303
335
  const closeProps =
304
336
  params.kind === 'document' && params.closeable
305
337
  ? {
306
- closeActionOverride: () => {
307
- if (document?.dirty) {
308
- setConfirmingClose(true);
309
- return;
310
- }
311
- closeDockviewDocumentTab(props, params.documentId);
312
- },
338
+ closeActionOverride: requestDocumentClose,
313
339
  }
314
340
  : params.kind === 'static' && params.staticKind === 'agent'
315
341
  ? {
@@ -364,8 +390,35 @@ function WorkspaceTab(props: IDockviewPanelHeaderProps<WorkspacePanelParams>) {
364
390
  data-dirty={document?.dirty || undefined}
365
391
  data-document-family={tabFamily}
366
392
  data-editor-hotkey-scope={hotkeyScopeForPanel(params)}
393
+ onPointerDownCapture={() => {
394
+ // A bottom edge panel can remain Dockview's globally active panel
395
+ // while a center-group tab visibly changes. Select the semantic
396
+ // document at the tab boundary so its contributed context always
397
+ // follows the tab the user actually chose.
398
+ if (params.kind === 'document') activateWorkspaceDocument(params.documentId);
399
+ }}
367
400
  >
368
- <DockviewDefaultTab {...props} hideClose={!params.closeable} {...closeProps} />
401
+ <DockviewDefaultTab
402
+ {...props}
403
+ hideClose={params.kind === 'document' || !params.closeable}
404
+ {...closeProps}
405
+ />
406
+ {params.kind === 'document' && params.closeable && (
407
+ <Button
408
+ type="button"
409
+ variant="ghost"
410
+ size="compact"
411
+ className="vgai-dock-tab-close"
412
+ aria-label={`Close ${document?.title ?? params.documentId}`}
413
+ onPointerDown={(event) => event.stopPropagation()}
414
+ onClick={(event) => {
415
+ event.stopPropagation();
416
+ requestDocumentClose();
417
+ }}
418
+ >
419
+ <span aria-hidden="true">×</span>
420
+ </Button>
421
+ )}
369
422
  {document?.dirty && (
370
423
  <span className="vgai-dock-tab-dirty" title="Unsaved" aria-hidden="true" />
371
424
  )}
@@ -395,7 +448,6 @@ function addStaticPanel(
395
448
  title: options.title,
396
449
  component: COMPONENT_ID,
397
450
  tabComponent: TAB_COMPONENT_ID,
398
- renderer: 'always',
399
451
  inactive: options.inactive ?? false,
400
452
  position: { referenceGroup: options.groupId },
401
453
  params: {
@@ -680,11 +732,10 @@ function ensureUtilityPanel(
680
732
 
681
733
  function reconcileUtilityPanels(api: DockviewApi): void {
682
734
  const utilities = availableWorkspaceUtilities();
683
- const utilityIds = new Set(utilities.map((item) => item.id));
684
- for (const panel of api.panels) {
685
- const params = panel.api.getParameters<WorkspacePanelParams>();
686
- if (params.kind === 'utility' && !utilityIds.has(params.utilityId)) api.removePanel(panel);
687
- }
735
+ // Every panel uses renderer:'always', so an unavailable registration's
736
+ // `UnavailableUtilityPanel` closes through the semantic controller. That
737
+ // path can restore the previous tab/collapsed state; deleting it here would
738
+ // discard that lifecycle information and leave an expanded empty drawer.
688
739
  for (const utility of utilities) {
689
740
  if (utility.visibleByDefault !== false) ensureUtilityPanel(api, utility);
690
741
  }
@@ -701,9 +752,11 @@ function reconcileWorkspace(api: DockviewApi): void {
701
752
 
702
753
  export function WorkspaceDock({ onOpenScene }: { onOpenScene: (assetPath: string) => void }) {
703
754
  const store = useEditorStore();
755
+ const dockRootRef = useRef<HTMLElement | null>(null);
704
756
  const apiRef = useRef<DockviewApi | null>(null);
705
757
  const readyCleanupRef = useRef<(() => void) | null>(null);
706
758
  const lastSyncedDocumentRef = useRef<string | null>(null);
759
+ const utilityRevealOriginsRef = useRef(new Map<string, UtilityRevealOrigin>());
707
760
  const documentVersion = useSyncExternalStore(
708
761
  subscribeWorkspaceDocuments,
709
762
  workspaceDocumentRegistryVersion,
@@ -766,6 +819,15 @@ export function WorkspaceDock({ onOpenScene }: { onOpenScene: (assetPath: string
766
819
  ({ api }: { api: DockviewApi }) => {
767
820
  apiRef.current = api;
768
821
  reconcileWorkspace(api);
822
+ const dockRoot = dockRootRef.current;
823
+ const resizeObserver =
824
+ dockRoot && typeof ResizeObserver !== 'undefined'
825
+ ? new ResizeObserver(([entry]) => {
826
+ if (!entry) return;
827
+ api.layout(entry.contentRect.width, entry.contentRect.height, true);
828
+ })
829
+ : null;
830
+ if (dockRoot) resizeObserver?.observe(dockRoot);
769
831
  const uninstallPersistence = installWorkspaceStatePersistence(store, api, () =>
770
832
  reconcileWorkspace(api),
771
833
  );
@@ -774,8 +836,12 @@ export function WorkspaceDock({ onOpenScene }: { onOpenScene: (assetPath: string
774
836
  if (initialDocumentId) api.getPanel(initialDocumentId)?.api.setActive();
775
837
 
776
838
  const activeDisposable = api.onDidActivePanelChange((event) => {
777
- if (event.origin !== 'user' || !event.panel) return;
839
+ if (!event.panel) return;
778
840
  const params = event.panel.api.getParameters<WorkspacePanelParams>();
841
+ // Dockview does not report every tab click with `origin: 'user'`
842
+ // (notably after an edge utility owned focus). The panel kind is the
843
+ // semantic boundary we actually need: document panels select the
844
+ // active document, while utilities/navigators leave it untouched.
779
845
  if (params.kind === 'document') activateWorkspaceDocument(params.documentId);
780
846
  });
781
847
 
@@ -797,6 +863,7 @@ export function WorkspaceDock({ onOpenScene }: { onOpenScene: (assetPath: string
797
863
  ensureUtilityPanel(api, utility, false);
798
864
  const panel = api.getPanel(`utility:${id}`);
799
865
  if (!panel) return;
866
+ rememberUtilityReveal(panel, utilityRevealOriginsRef.current, id);
800
867
  panel.api.setActive();
801
868
  if (panel.api.location.type === 'edge') panel.api.group.api.expand();
802
869
  },
@@ -811,10 +878,27 @@ export function WorkspaceDock({ onOpenScene }: { onOpenScene: (assetPath: string
811
878
  if (panel.api.isActive && panel.api.location.type === 'edge' && !group.isCollapsed()) {
812
879
  group.collapse();
813
880
  } else {
881
+ rememberUtilityReveal(panel, utilityRevealOriginsRef.current, id);
814
882
  panel.api.setActive();
815
883
  if (panel.api.location.type === 'edge') group.expand();
816
884
  }
817
885
  },
886
+ closeUtility(id) {
887
+ const panel = api.getPanel(`utility:${id}`);
888
+ const origin = utilityRevealOriginsRef.current.get(id);
889
+ utilityRevealOriginsRef.current.delete(id);
890
+ if (!panel) return;
891
+ const wasActive = panel.api.isActive;
892
+ const group = panel.api.group;
893
+ const wasEdgePanel = panel.api.location.type === 'edge';
894
+ panel.api.close();
895
+ if (!wasActive) return;
896
+ const previous = origin?.previousPanelId
897
+ ? api.getPanel(origin.previousPanelId)
898
+ : undefined;
899
+ previous?.api.setActive();
900
+ if (wasEdgePanel && (!previous || origin?.wasCollapsed !== false)) group.api.collapse();
901
+ },
818
902
  showAuxiliary(id) {
819
903
  if (id !== 'undo-history') return;
820
904
  if (!api.getPanel(HISTORY_PANEL_ID)) {
@@ -847,6 +931,7 @@ export function WorkspaceDock({ onOpenScene }: { onOpenScene: (assetPath: string
847
931
  const uninstallCommands = installWorkspaceDockCommands(commands);
848
932
  readyCleanupRef.current?.();
849
933
  readyCleanupRef.current = () => {
934
+ resizeObserver?.disconnect();
850
935
  activeDisposable.dispose();
851
936
  uninstallPersistence();
852
937
  uninstallCommands();
@@ -861,6 +946,7 @@ export function WorkspaceDock({ onOpenScene }: { onOpenScene: (assetPath: string
861
946
 
862
947
  return (
863
948
  <main
949
+ ref={dockRootRef}
864
950
  className="vgai-workspace-dock"
865
951
  aria-label="Editor workspace"
866
952
  data-testid="workspace-dock"
@@ -873,7 +959,7 @@ export function WorkspaceDock({ onOpenScene }: { onOpenScene: (assetPath: string
873
959
  tabComponents={tabs}
874
960
  onReady={onReady}
875
961
  theme={vgaiDockviewTheme}
876
- defaultRenderer="always"
962
+ defaultRenderer="onlyWhenVisible"
877
963
  keyboardNavigation
878
964
  disableFloatingGroups={false}
879
965
  singleTabMode="fullwidth"
@@ -12,7 +12,8 @@
12
12
  * open-asset`) keeps its meaning unchanged:
13
13
  *
14
14
  * - project asset → the serving path itself (`/textures/crate.png`)
15
- * - legacy entity editor key → `asset-lab:entity:<entityId>`
15
+ * - Asset Editor → `asset-editor:entity:<entityId>`; the legacy entity editor key
16
+ * `asset-lab:entity:<entityId>` remains an input compatibility alias
16
17
  * - online asset → `online:<source>:<id>`
17
18
  *
18
19
  * Titles are source-owned file names as authored (§8 — `crate.png`, never a
@@ -60,11 +61,13 @@ import './asset-workflow.css';
60
61
  export interface AssetDocumentSpec {
61
62
  readonly assetPath: string;
62
63
  readonly kind: AssetKind;
63
- /** Entity source for a session-only live-object Asset Editor. Never serialized. */
64
+ /** Entity source for a session-only Asset Editor document. Never serialized. */
64
65
  readonly entityId?: string;
65
66
  /** Present while this document shows an online (not-yet-downloaded) asset. */
66
67
  readonly online?: OnlineAssetInfo;
67
68
  readonly unavailableReason?: string;
69
+ /** Incremented when the file bytes change at the same path. */
70
+ readonly revision?: number;
68
71
  }
69
72
 
70
73
  export interface AssetDocumentMutation {
@@ -81,6 +84,7 @@ const _specs =
81
84
  const _pathAliases =
82
85
  (hotData?.['vgai:asset-document-path-aliases'] as Map<string, string> | undefined) ??
83
86
  new Map<string, string>();
87
+ const _focusReturnTargets = new Map<string, HTMLElement>();
84
88
  if (hotData) {
85
89
  // Workspace descriptors intentionally survive compatible Fast Refreshes.
86
90
  // Their specs must survive with them or the next persistence snapshot drops
@@ -111,13 +115,20 @@ export function assetDocumentsVersion(): number {
111
115
 
112
116
  /** The spec behind an open asset document id, or `undefined`. */
113
117
  export function assetDocumentSpec(id: string): AssetDocumentSpec | undefined {
114
- return _specs.get(id);
118
+ return _specs.get(migrateAssetEditorDocumentId(id));
119
+ }
120
+
121
+ /** Compatibility window: old session/control ids resolve to the canonical Asset Editor id. */
122
+ export function migrateAssetEditorDocumentId(id: string): string {
123
+ return id.startsWith('asset-lab:entity:')
124
+ ? `asset-editor:entity:${id.slice('asset-lab:entity:'.length)}`
125
+ : id;
115
126
  }
116
127
 
117
128
  /** Whether `id` is one of this module's asset documents (used by
118
129
  * `command-listener.ts` to report the legacy `activeTabKey` facet). */
119
130
  export function isAssetDocumentId(id: string): boolean {
120
- return _specs.has(id);
131
+ return _specs.has(migrateAssetEditorDocumentId(id));
121
132
  }
122
133
 
123
134
  /** The narrow store surface asset documents need (T6.3 gate hand-off). */
@@ -125,7 +136,7 @@ export interface AssetDocumentStore {
125
136
  setActiveViewportTab(tab: 'scene' | 'game'): void;
126
137
  }
127
138
 
128
- /** A live-entity Asset Editor additionally needs the object map for its §8 title. */
139
+ /** The Asset Editor additionally needs the live object map for its §8 title. */
129
140
  export interface EntityAssetDocumentStore extends AssetDocumentStore {
130
141
  readonly objectMap: Map<string, THREE.Object3D>;
131
142
  }
@@ -137,6 +148,9 @@ function openSpecDocument(
137
148
  spec: AssetDocumentSpec,
138
149
  provenance?: WorkspaceDocumentDescriptor['provenance'],
139
150
  ): string {
151
+ if (typeof document !== 'undefined' && document.activeElement instanceof HTMLElement) {
152
+ _focusReturnTargets.set(id, document.activeElement);
153
+ }
140
154
  if (!_specs.has(id)) {
141
155
  _specs.set(id, spec);
142
156
  notifyChanged();
@@ -151,6 +165,9 @@ function openSpecDocument(
151
165
  onActivate: () => store.setActiveViewportTab('scene'),
152
166
  onDispose: (documentId) => {
153
167
  if (_specs.delete(documentId)) notifyChanged();
168
+ const focusTarget = _focusReturnTargets.get(documentId);
169
+ _focusReturnTargets.delete(documentId);
170
+ if (focusTarget?.isConnected) setTimeout(() => focusTarget.focus(), 0);
154
171
  },
155
172
  };
156
173
  return openWorkspaceDocument(descriptor);
@@ -171,7 +188,7 @@ export function openAssetDocument(
171
188
  return openSpecDocument(store, id, title, { assetPath, kind }, { sourcePath: assetPath });
172
189
  }
173
190
 
174
- /** Open the typed Asset Editor for a live entity (legacy id retained for API compatibility). */
191
+ /** Open the Asset Editor document for a live entity. */
175
192
  export function openEntityAssetDocument(store: EntityAssetDocumentStore, entityId: string): void {
176
193
  const object = store.objectMap.get(entityId);
177
194
  if (!object) return;
@@ -182,7 +199,7 @@ export function openEntityAssetDocument(store: EntityAssetDocumentStore, entityI
182
199
  const title = entity?.name ?? object.name ?? entityId;
183
200
  openSpecDocument(
184
201
  store,
185
- `asset-lab:entity:${entityId}`,
202
+ `asset-editor:entity:${entityId}`,
186
203
  title,
187
204
  { assetPath: '', kind: 'model', entityId },
188
205
  { origin: `entity:${entityId}` },
@@ -221,6 +238,7 @@ export function resolveOnlineAssetDocument(id: string, localPath: string): void
221
238
  export function __resetAssetDocumentsForTest(): void {
222
239
  _specs.clear();
223
240
  _pathAliases.clear();
241
+ _focusReturnTargets.clear();
224
242
  notifyChanged();
225
243
  }
226
244
 
@@ -252,6 +270,12 @@ export function applyAssetDocumentMutation(
252
270
  unavailableReason: `The source ${spec.assetPath} was deleted. Undo the deletion or close this editor.`,
253
271
  });
254
272
  notifyChanged();
273
+ } else if (
274
+ detail.type === 'change' &&
275
+ detail.paths?.some((path) => normalizeAssetPath(path) === current)
276
+ ) {
277
+ _specs.set(documentId, { ...spec, revision: (spec.revision ?? 0) + 1 });
278
+ notifyChanged();
255
279
  }
256
280
  }
257
281
 
@@ -321,26 +345,29 @@ function AssetViewerBody({
321
345
  );
322
346
  }
323
347
  const title = spec.assetPath.split('/').pop() || spec.entityId || 'Asset';
348
+ const viewerKey = `${id}:${spec.revision ?? 0}`;
324
349
  if (route === 'model')
325
350
  return (
326
351
  <ModelViewer
327
352
  documentId={id}
353
+ key={viewerKey}
328
354
  assetPath={spec.assetPath || undefined}
329
355
  entityId={spec.entityId}
330
356
  active={active}
331
357
  />
332
358
  );
333
- if (route === 'source') return <SourceAssetViewer assetPath={spec.assetPath} active={active} />;
359
+ if (route === 'source')
360
+ return <SourceAssetViewer key={viewerKey} assetPath={spec.assetPath} active={active} />;
334
361
  let viewer: ReactNode;
335
362
  switch (route) {
336
363
  case 'image':
337
- viewer = <ImageViewer assetPath={spec.assetPath} />;
364
+ viewer = <ImageViewer key={viewerKey} assetPath={spec.assetPath} />;
338
365
  break;
339
366
  case 'audio':
340
- viewer = <AudioViewer assetPath={spec.assetPath} />;
367
+ viewer = <AudioViewer key={viewerKey} assetPath={spec.assetPath} />;
341
368
  break;
342
369
  case 'material':
343
- viewer = <MaterialAssetEditor assetPath={spec.assetPath} documentId={id} />;
370
+ viewer = <MaterialAssetEditor key={viewerKey} assetPath={spec.assetPath} documentId={id} />;
344
371
  break;
345
372
  case 'input-map':
346
373
  viewer = <InputMapViewer assetPath={spec.assetPath} documentId={id} />;