@vgai/editor 0.2.0 → 0.4.0-canary.20260715.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 (430) hide show
  1. package/README.md +24 -0
  2. package/dist/assets/axe-C7YBtOuM.js +30 -0
  3. package/dist/assets/browserAll-DDtGGE48.js +1 -0
  4. package/dist/assets/create-runtime-DbTlBlKG.js +1 -0
  5. package/dist/assets/{game-BI111o5f.js → game-BE0r7DMI.js} +3 -3
  6. package/dist/assets/game-BdTHR2_X.js +1 -0
  7. package/dist/assets/{game-pt_1lQLv.js → game-C3iQGSqo.js} +1 -1
  8. package/dist/assets/game-CcHfYxjP.js +1 -0
  9. package/dist/assets/game-Cqzj_lpD.js +1 -0
  10. package/dist/assets/game-DSZdeCHu.js +1 -0
  11. package/dist/assets/game-Dch9sAPc.js +1 -0
  12. package/dist/assets/{game-xsOho_wg.js → game-SAFn90pA.js} +3 -3
  13. package/dist/assets/game-loop-DMIgSMV-.js +1 -0
  14. package/dist/assets/{game-source-CvidN7AB.js → game-source-Dk83w26t.js} +1 -1
  15. package/dist/assets/{index-Bp5cecD6.js → index-4idIsHHJ.js} +2 -2
  16. package/dist/assets/{index-DL7RcQ0n.js → index-C-LhOTBN.js} +1 -1
  17. package/dist/assets/{index-Cs8xwv3e.js → index-CDkxceqE.js} +1 -1
  18. package/dist/assets/index-CQEfi217.js +15412 -0
  19. package/dist/assets/index-Dvi-V0NE.css +1 -0
  20. package/dist/assets/ingest-siblings-s0P5I1Ub.js +1 -0
  21. package/dist/assets/mount-manifest-DXMOlF79.js +1 -0
  22. package/dist/assets/react-18-C9eHn8x1.js +1 -0
  23. package/dist/assets/webworkerAll-Fn0e-ouF.js +1 -0
  24. package/dist/assets/world-documents-GM0a9Kv7.js +1 -0
  25. package/dist/index.html +15 -13
  26. package/dist/ingest/racing-game/vgai.game.json +5 -4
  27. package/dist/ingest/simcity/vgai.game.json +5 -4
  28. package/dist/ingest/tanks/vgai.game.json +5 -4
  29. package/dist/project-scripts.bundle.js +3917 -0
  30. package/dist/scenes/untitled.vscn.json +2 -0
  31. package/dist-server/packaged.mjs +7697 -3435
  32. package/package.json +31 -4
  33. package/scripts/probe-reporter.ts +349 -0
  34. package/scripts/run-probe.ts +2260 -0
  35. package/server/asset-catalog-audit.ts +268 -0
  36. package/server/asset-catalog-v2.ts +146 -0
  37. package/server/asset-library-routes.ts +276 -58
  38. package/server/canonical-path.ts +1 -1
  39. package/server/dev.ts +148 -44
  40. package/server/editor-server.ts +888 -61
  41. package/server/editor-sse.ts +115 -4
  42. package/server/harness-chat-service.ts +796 -0
  43. package/server/local-asset-catalog.ts +157 -22
  44. package/server/packaged.ts +51 -21
  45. package/server/process-shutdown.ts +136 -0
  46. package/server/prod.ts +9 -6
  47. package/server/project-hmr-files.ts +41 -0
  48. package/server/project-operations.ts +343 -0
  49. package/server/project-optimize-deps-entries.ts +4 -4
  50. package/server/project-output-writer.ts +156 -0
  51. package/server/project-validation.ts +140 -0
  52. package/server/project-view.ts +8 -50
  53. package/server/server-utils.ts +75 -10
  54. package/server/session-registry.ts +12 -0
  55. package/src/EditorContext.tsx +107 -3
  56. package/src/action-registry.ts +115 -17
  57. package/src/adapter-resolver.ts +213 -478
  58. package/src/asset-events.ts +64 -1
  59. package/src/asset-preview.ts +906 -0
  60. package/src/asset-selection.ts +121 -0
  61. package/src/asset-workflow/asset-capabilities.ts +259 -0
  62. package/src/asset-workflow/asset-editor-errors.ts +46 -0
  63. package/src/asset-workflow/asset-import-pipeline.ts +176 -0
  64. package/src/asset-workflow/asset-library-query.ts +107 -0
  65. package/src/asset-workflow/asset-types.ts +192 -0
  66. package/src/asset-workflow/asset-workflow-quality.ts +65 -0
  67. package/src/asset-workflow/import-contract.ts +113 -0
  68. package/src/asset-workflow/model-conversion.ts +126 -0
  69. package/src/asset-workflow/model-inspection.ts +185 -0
  70. package/src/asset-workflow/project-asset-commands.ts +23 -0
  71. package/src/asset-workflow/project-asset-health.ts +153 -0
  72. package/src/asset-workflow/project-asset-operations.ts +119 -0
  73. package/src/asset-workflow/thumbnail-system.ts +172 -0
  74. package/src/authoring/active-systems.ts +15 -0
  75. package/src/authoring/boundary-authoring-adapter.ts +16 -5
  76. package/src/authoring/composite-authoring-adapter.ts +305 -124
  77. package/src/authoring/design-time-layers.ts +300 -117
  78. package/src/authoring/edit-mode-authoring.ts +278 -293
  79. package/src/authoring/embed-only-adapter.ts +4 -2
  80. package/src/authoring/first-party-hierarchy-menu.ts +7 -2
  81. package/src/authoring/first-party-inspector/AddSectionMenu.tsx +5 -12
  82. package/src/authoring/first-party-inspector/EnvironmentSection.tsx +53 -126
  83. package/src/authoring/first-party-inspector/HeaderSection.tsx +54 -65
  84. package/src/authoring/first-party-inspector/is-first-party-selection.ts +30 -11
  85. package/src/authoring/first-party-inspector/register.tsx +13 -2
  86. package/src/authoring/ingest-authoring-adapter.ts +142 -45
  87. package/src/authoring/mount-failure-report.ts +5 -5
  88. package/src/authoring/provenance.ts +41 -0
  89. package/src/authoring/react-dom-authoring-adapter.ts +92 -92
  90. package/src/authoring/react-inspector-section.tsx +489 -51
  91. package/src/authoring/react-world-authoring-adapter.ts +541 -306
  92. package/src/authoring/story-overlay.ts +1 -1
  93. package/src/authoring/story-types.ts +11 -0
  94. package/src/authoring/vgai-scene-authoring-adapter.ts +54 -25
  95. package/src/authoring/viewport-tool-context.ts +78 -0
  96. package/src/authoring/world-canvas-viewport-state.ts +35 -0
  97. package/src/authoring/world-hidden-viewport.ts +186 -0
  98. package/src/authoring/world-pan-state.ts +33 -17
  99. package/src/authoring/world-session-state.ts +1 -1
  100. package/src/authoring/world2d-authoring-adapter.ts +109 -52
  101. package/src/availability-tick.ts +56 -0
  102. package/src/browser-play-entry.ts +5 -5
  103. package/src/browser-project-scripts.ts +1 -0
  104. package/src/build-identity.ts +15 -0
  105. package/src/command-listener.ts +893 -19
  106. package/src/components/AlignToolbar.tsx +74 -63
  107. package/src/components/AppRoot.tsx +193 -26
  108. package/src/components/ApplicationMenus.tsx +272 -0
  109. package/src/components/AssetBrowser.tsx +1450 -293
  110. package/src/components/AssetEditorShell.tsx +141 -0
  111. package/src/components/AssetImportDetails.tsx +218 -0
  112. package/src/components/AssetLibraryPanel.tsx +14 -0
  113. package/src/components/CameraInfo.tsx +11 -20
  114. package/src/components/CenterDocuments.tsx +585 -0
  115. package/src/components/CommandPalette.tsx +75 -30
  116. package/src/components/ConsolePanel.css +55 -0
  117. package/src/components/ConsolePanel.tsx +57 -112
  118. package/src/components/DebugConsolePanel.tsx +461 -0
  119. package/src/components/DebuggerPanel.tsx +165 -0
  120. package/src/components/DefaultEditorLayout.tsx +167 -231
  121. package/src/components/GameHierarchy.tsx +873 -290
  122. package/src/components/GamePerformanceOverlay.tsx +46 -0
  123. package/src/components/HarnessChatPanel.tsx +2352 -0
  124. package/src/components/HistoryPanel.tsx +226 -0
  125. package/src/components/Inspector.tsx +153 -99
  126. package/src/components/InspectorToolSection.tsx +6 -4
  127. package/src/components/MountFailureBanner.tsx +31 -48
  128. package/src/components/NetworkSection.tsx +25 -14
  129. package/src/components/OnlineAssetBrowser.tsx +794 -343
  130. package/src/components/OverlayReportBanner.tsx +38 -64
  131. package/src/components/PerformancePanel.tsx +206 -0
  132. package/src/components/PlayBar.tsx +93 -263
  133. package/src/components/PrefabSaveDialog.tsx +55 -127
  134. package/src/components/ProjectHeader.css +60 -0
  135. package/src/components/ProjectHeader.tsx +170 -105
  136. package/src/components/ProjectScreen.tsx +246 -184
  137. package/src/components/ReactCanvasControls.tsx +185 -0
  138. package/src/components/ResolutionPicker.tsx +85 -0
  139. package/src/components/RootActivateDialog.tsx +39 -68
  140. package/src/components/SaveDialog.tsx +55 -127
  141. package/src/components/SaveStatus.tsx +11 -29
  142. package/src/components/StartupLoadingScreen.tsx +96 -0
  143. package/src/components/StateWatchPanel.tsx +302 -0
  144. package/src/components/StatsOverlay.tsx +12 -5
  145. package/src/components/StatusBar.tsx +64 -0
  146. package/src/components/ThemeManagerDialog.css +89 -0
  147. package/src/components/ThemeManagerDialog.tsx +371 -0
  148. package/src/components/ToolHost.tsx +26 -15
  149. package/src/components/Toolbar.tsx +117 -214
  150. package/src/components/VgaiVoxelLogo.tsx +89 -0
  151. package/src/components/ViewportOverlay.tsx +97 -172
  152. package/src/components/ViewportPanel.tsx +130 -31
  153. package/src/components/WorkspaceDock.tsx +891 -0
  154. package/src/components/WorldSelectionOverlay.tsx +51 -63
  155. package/src/components/WorldTextEditor.tsx +9 -5
  156. package/src/components/asset-documents.tsx +390 -0
  157. package/src/components/asset-editor-persistence.ts +44 -0
  158. package/src/components/asset-selection-section.tsx +338 -0
  159. package/src/components/asset-viewers/AudioViewer.tsx +73 -21
  160. package/src/components/asset-viewers/GenericJsonViewer.tsx +26 -11
  161. package/src/components/asset-viewers/ImageViewer.tsx +43 -11
  162. package/src/components/asset-viewers/InputMapViewer.tsx +512 -134
  163. package/src/components/asset-viewers/MaterialAssetEditor.tsx +540 -0
  164. package/src/components/asset-viewers/ModelViewer.tsx +805 -121
  165. package/src/components/asset-viewers/OnlineAssetDetail.tsx +177 -60
  166. package/src/components/asset-viewers/SourceAssetViewer.tsx +121 -0
  167. package/src/components/asset-workflow.css +506 -0
  168. package/src/components/build-documents.tsx +696 -0
  169. package/src/components/core-utilities.tsx +75 -0
  170. package/src/components/data-documents.tsx +364 -0
  171. package/src/components/{DataPanel.tsx → data-editor.tsx} +138 -288
  172. package/src/components/debugger-panel-state.ts +57 -0
  173. package/src/components/inspector-widgets/AlignmentGrid.tsx +8 -14
  174. package/src/components/inspector-widgets/BorderEditor.tsx +41 -33
  175. package/src/components/inspector-widgets/ColorPicker.tsx +39 -39
  176. package/src/components/inspector-widgets/FontPicker.tsx +13 -21
  177. package/src/components/inspector-widgets/GradientEditor.tsx +11 -19
  178. package/src/components/inspector-widgets/ScrubbableInput.tsx +6 -3
  179. package/src/components/inspector-widgets/ShadowEditor.tsx +20 -33
  180. package/src/components/inspector-widgets/shared.tsx +90 -49
  181. package/src/components/inspectors/AnimationSection.tsx +39 -40
  182. package/src/components/inspectors/AudioSection.tsx +28 -39
  183. package/src/components/inspectors/BoneAttachmentSection.tsx +19 -24
  184. package/src/components/inspectors/CameraSection.tsx +14 -23
  185. package/src/components/inspectors/ComponentsSection.tsx +39 -43
  186. package/src/components/inspectors/JointsSection.tsx +34 -43
  187. package/src/components/inspectors/LightSection.tsx +90 -35
  188. package/src/components/inspectors/MaterialSection.tsx +137 -174
  189. package/src/components/inspectors/MeshSection.tsx +9 -17
  190. package/src/components/inspectors/NavigationSection.tsx +8 -15
  191. package/src/components/inspectors/ParticleSection.tsx +91 -76
  192. package/src/components/inspectors/PhysicsSection.tsx +42 -46
  193. package/src/components/inspectors/PostProcessingSection.tsx +28 -36
  194. package/src/components/inspectors/RenderingSection.tsx +12 -20
  195. package/src/components/inspectors/ShadowSection.tsx +7 -8
  196. package/src/components/inspectors/SplineSection.tsx +31 -42
  197. package/src/components/inspectors/TransformSection.tsx +14 -22
  198. package/src/components/patterns/Dialog.tsx +95 -0
  199. package/src/components/patterns/Fields.tsx +44 -0
  200. package/src/components/patterns/List.tsx +25 -0
  201. package/src/components/patterns/StateSurface.tsx +40 -0
  202. package/src/components/patterns/Surfaces.tsx +115 -0
  203. package/src/components/patterns/Tabs.tsx +80 -0
  204. package/src/components/patterns/Toolbar.tsx +51 -0
  205. package/src/components/patterns/Tree.tsx +72 -0
  206. package/src/components/primitives/Button.tsx +46 -0
  207. package/src/components/primitives/ColorInput.tsx +11 -47
  208. package/src/components/primitives/EditorIcon.tsx +99 -0
  209. package/src/components/primitives/FormControls.tsx +71 -0
  210. package/src/components/primitives/JsonInput.tsx +15 -2
  211. package/src/components/primitives/Layout.tsx +100 -0
  212. package/src/components/primitives/Menu.tsx +105 -0
  213. package/src/components/primitives/NumberInput.tsx +8 -35
  214. package/src/components/primitives/Panel.tsx +49 -44
  215. package/src/components/primitives/SectionHeader.tsx +10 -28
  216. package/src/components/primitives/Text.tsx +46 -0
  217. package/src/components/primitives/Tooltip.tsx +20 -12
  218. package/src/components/primitives/Vec3Input.tsx +3 -0
  219. package/src/components/primitives/banner-tones.ts +33 -0
  220. package/src/components/primitives/editor-icons.ts +102 -0
  221. package/src/components/primitives/panel-header-styles.ts +30 -0
  222. package/src/components/project-operation-documents.tsx +226 -0
  223. package/src/components/status-contributions.tsx +175 -0
  224. package/src/components/story-documents-register.ts +15 -0
  225. package/src/components/story-documents.tsx +1185 -0
  226. package/src/components/tool-documents.tsx +110 -0
  227. package/src/components/workspace-dock.css +293 -0
  228. package/src/components/workspace-history.ts +33 -0
  229. package/src/components/world-documents.tsx +145 -0
  230. package/src/components/xstate-documents.tsx +209 -0
  231. package/src/composite-screenshot.ts +248 -0
  232. package/src/design-system/index.ts +70 -0
  233. package/src/design-system-stories/Foundation.stories.tsx +183 -0
  234. package/src/design-system-stories/Patterns.stories.tsx +251 -0
  235. package/src/design-system-stories/Workspace.stories.tsx +538 -0
  236. package/src/doctor/report.ts +57 -13
  237. package/src/editor-api.ts +295 -31
  238. package/src/editor-compatibility.ts +200 -0
  239. package/src/editor-console.ts +35 -17
  240. package/src/editor-hotkeys.ts +85 -33
  241. package/src/editor-store.ts +538 -233
  242. package/src/editor-viewport.ts +160 -34
  243. package/src/entity-factory.ts +14 -3
  244. package/src/gizmo-registry.ts +21 -5
  245. package/src/harness-chat-editor-context.ts +37 -0
  246. package/src/harness-chat-indicator.ts +68 -0
  247. package/src/harness-chat-layout.ts +53 -0
  248. package/src/harness-chat-lifecycle.ts +170 -0
  249. package/src/harness-chat-reconnect.ts +85 -0
  250. package/src/harness-chat-types.ts +409 -0
  251. package/src/hierarchy-drop.ts +50 -0
  252. package/src/hierarchy-expansion-state.ts +100 -0
  253. package/src/hierarchy-menu-registry.ts +1 -1
  254. package/src/hierarchy-projection.ts +76 -0
  255. package/src/history/editor-session.ts +21 -0
  256. package/src/history/history-commands.ts +147 -0
  257. package/src/history/history-service.ts +904 -0
  258. package/src/history/json-history-resource.ts +165 -0
  259. package/src/history/persistence-coordinator.ts +35 -0
  260. package/src/history/project-file-history.ts +266 -0
  261. package/src/history/project-root-history-backends.ts +53 -0
  262. package/src/history/resource-registry.ts +209 -0
  263. package/src/history/snapshot-store.ts +108 -0
  264. package/src/history/source-history-backend.ts +374 -0
  265. package/src/history/types.ts +100 -0
  266. package/src/hmr-registration-group.ts +67 -0
  267. package/src/hmr-stable-react-context.ts +23 -0
  268. package/src/hosted-example.ts +12 -2
  269. package/src/hotkeys.ts +89 -18
  270. package/src/ingest/discovery-react.ts +9 -10
  271. package/src/ingest/discovery.ts +4 -4
  272. package/src/ingest/discovery2d.ts +5 -5
  273. package/src/ingest/games/bloom-composer/vgai.game.json +3 -3
  274. package/src/ingest/games/embed-bundle/vgai.game.json +3 -3
  275. package/src/ingest/games/games-fps/vgai.game.json +3 -3
  276. package/src/ingest/games/iframe-reachable/vgai.game.json +3 -3
  277. package/src/ingest/games/interactive-cubes/vgai.game.json +3 -3
  278. package/src/ingest/games/mismatched-three/vgai.game.json +3 -3
  279. package/src/ingest/games/own-three-bundled/vgai.game.json +3 -3
  280. package/src/ingest/games/pointerlock-maze/vgai.game.json +7 -4
  281. package/src/ingest/games-2d/bubbo-bubbo/vgai.game.json +3 -3
  282. package/src/ingest/games-2d/bunnymark/vgai.game.json +3 -3
  283. package/src/ingest/games-2d/flappy/vgai.game.json +3 -3
  284. package/src/ingest/games-2d/flappy-pixi/vgai.game.json +3 -3
  285. package/src/ingest/games-2d/puzzling-potions/vgai.game.json +3 -3
  286. package/src/ingest/games-2d/tilemap-camera/vgai.game.json +3 -3
  287. package/src/ingest/games-react/react-rpg/vgai.game.json +3 -3
  288. package/src/ingest/types.ts +1 -1
  289. package/src/ingest-mode.ts +47 -52
  290. package/src/ingest-siblings.ts +23 -33
  291. package/src/initial-scene.ts +1 -2
  292. package/src/main.tsx +16 -8
  293. package/src/manifest-project.ts +10 -12
  294. package/src/model-thumbnail.ts +261 -26
  295. package/src/module-mode.ts +44 -50
  296. package/src/play-log-events.ts +26 -0
  297. package/src/play-mode.ts +495 -401
  298. package/src/play-scene-adoption.ts +23 -0
  299. package/src/playground/main.ts +68 -24
  300. package/src/playwright-shim.ts +473 -0
  301. package/src/project-manager.ts +90 -10
  302. package/src/project-operations.ts +112 -0
  303. package/src/project-story-discovery.ts +25 -0
  304. package/src/project-tool-discovery.ts +43 -0
  305. package/src/scene-serializer.ts +0 -6
  306. package/src/scene-sync.ts +7 -2
  307. package/src/script-loader.ts +3 -3
  308. package/src/skeleton-helper.ts +57 -0
  309. package/src/storage/seed.ts +68 -8
  310. package/src/stories/StoryPreviewMount.tsx +265 -0
  311. package/src/stories/action-stories.fixture.tsx +44 -0
  312. package/src/stories/button-stories.fixture.tsx +50 -0
  313. package/src/stories/compose-project-stories.ts +182 -0
  314. package/src/stories/loader-stories.fixture.tsx +53 -0
  315. package/src/stories/story-discovery.ts +130 -0
  316. package/src/stories/story-registry.ts +146 -0
  317. package/src/theme-css.ts +1049 -0
  318. package/src/theme-library.ts +238 -0
  319. package/src/theme-preference.ts +130 -0
  320. package/src/theme.ts +527 -0
  321. package/src/tool-loader.ts +80 -24
  322. package/src/ui-source/inspect.ts +27 -4
  323. package/src/ui-source/source-write-backend.ts +83 -22
  324. package/src/widgets/index.ts +9 -18
  325. package/src/workspace-aux-commands.ts +11 -0
  326. package/src/workspace-dock-controller.ts +64 -0
  327. package/src/workspace-document-ids.ts +5 -0
  328. package/src/workspace-document-registry.ts +367 -0
  329. package/src/workspace-state-persistence.ts +455 -0
  330. package/src/workspace-status-registry.ts +110 -0
  331. package/src/workspace-utility-commands.ts +49 -0
  332. package/src/workspace-utility-registry.ts +161 -0
  333. package/src/world-document-routing.ts +16 -0
  334. package/src/xstate-inspect/XStateMachineInspector.tsx +544 -0
  335. package/src/xstate-inspect/character-animation-machine.fixture.ts +74 -0
  336. package/src/xstate-inspect/use-live-actor-state.ts +39 -0
  337. package/src/xstate-inspect/xstate-graph.ts +265 -0
  338. package/src/xstate-inspect/xstate-layout.ts +76 -0
  339. package/src/z-index.ts +26 -0
  340. package/template/.claude/skills/editor/SKILL.md +129 -90
  341. package/template/AGENTS.md +1110 -0
  342. package/template/CLAUDE.md +1 -512
  343. package/template/IDIOMS.md +50 -0
  344. package/template/check-idioms.ts +706 -0
  345. package/template/manifest-entry-modules-plugin.ts +53 -0
  346. package/template/package.json +13 -4
  347. package/template/public/scenes/default.scn2d.json +6 -0
  348. package/template/public/scenes/default.vscn.json +7 -1
  349. package/template/public/scenes/untitled.vscn.json +10 -0
  350. package/template/scripts/playtest.ts +40 -0
  351. package/template/server/loopback.ts +75 -0
  352. package/template/server/rooms/arena-room.ts +69 -1
  353. package/template/server/rooms/game-room.ts +68 -1
  354. package/template/src/data/README.md +9 -4
  355. package/template/src/data/data-ref.ts +35 -0
  356. package/template/src/data/tuning.schema.ts +1 -1
  357. package/template/src/main.ts +41 -62
  358. package/template/src/manifest-entry-modules.d.ts +3 -0
  359. package/template/src/operations/README.md +88 -0
  360. package/template/src/runtime/pixi-adapter.ts +24 -0
  361. package/template/src/scripts/components/data-spinner.ts +43 -2
  362. package/template/src/scripts/components/spin-lap-math.ts +27 -0
  363. package/template/src/tools/README.md +5 -3
  364. package/template/src/tools/example.tool.tsx +13 -7
  365. package/template/src/ui/Button.stories.tsx +66 -0
  366. package/template/src/ui/RebindPanel.tsx +2 -6
  367. package/template/src/ui/game.tsx +17 -0
  368. package/template/tests/acceptance/example.spec.ts +64 -0
  369. package/template/tests/logic/example.test.ts +31 -0
  370. package/template/tests/logic/room.test.ts +100 -0
  371. package/template/tests/tsconfig.json +30 -0
  372. package/template/validate-manifest.ts +5 -5
  373. package/template/validate-scenes.ts +50 -18
  374. package/template/vgai.game.json +20 -13
  375. package/template/vite.config.ts +77 -16
  376. package/vite-plugin-ui-oid.ts +261 -17
  377. package/dist/assets/CCDIKSolver-Dz-ysxZF.js +0 -1
  378. package/dist/assets/SelectionBox-BXsv_6aH.js +0 -1
  379. package/dist/assets/browser-project-scripts-DOru4L1x.js +0 -2
  380. package/dist/assets/browserAll-CEkWYlFk.js +0 -1
  381. package/dist/assets/create-runtime-DYIWr3va.js +0 -1
  382. package/dist/assets/data-asset-Bb9sIM7i.js +0 -5
  383. package/dist/assets/game-8SDZPPgH.js +0 -1
  384. package/dist/assets/game-CevorxyV.js +0 -1
  385. package/dist/assets/game-Di7XlywD.js +0 -1
  386. package/dist/assets/game-DjnCaw8R.js +0 -1
  387. package/dist/assets/game-Q9A0aXSV.js +0 -1
  388. package/dist/assets/index-CPVV6pjw.js +0 -1
  389. package/dist/assets/index-CpOnYyeD.js +0 -213
  390. package/dist/assets/index-Dr8H_6Rl.js +0 -1
  391. package/dist/assets/index-_bCaipUk.js +0 -13650
  392. package/dist/assets/ingest-siblings-Dz8eEqrV.js +0 -1
  393. package/dist/assets/mount-manifest-Bhk0ohsl.js +0 -1
  394. package/dist/assets/scene-query-BivAHhJu.js +0 -1
  395. package/dist/assets/webworkerAll-BLrH0F6F.js +0 -1
  396. package/dist/assets/xstate-animation-binding-1NGLmw6y.js +0 -1
  397. package/dist/assets/xstate.esm-BgUD33nl.js +0 -3
  398. package/src/authoring/scene-ui-layer.ts +0 -485
  399. package/src/authoring/ui-authoring-adapter.ts +0 -325
  400. package/src/authoring/ui-hierarchy-menu.ts +0 -40
  401. package/src/authoring/ui-inspector-sections.tsx +0 -156
  402. package/src/authoring/undo-timeline.ts +0 -145
  403. package/src/components/BottomPanel.tsx +0 -326
  404. package/src/components/BuildPanel.tsx +0 -455
  405. package/src/components/FolderTree.tsx +0 -179
  406. package/src/components/RightPanel.tsx +0 -166
  407. package/src/components/WorldContextMenu.tsx +0 -257
  408. package/src/components/asset-viewers/AssetViewer.tsx +0 -38
  409. package/src/ingest/games-react/react-rpg/src/ui/game-state.tsx +0 -129
  410. package/src/scene-ui-mode/active-scene-ui.ts +0 -58
  411. package/src/scene-ui-mode/scene-ui-palette-panel.tsx +0 -152
  412. package/src/scene-ui-mode/scene-ui-timeline-tab.tsx +0 -29
  413. package/src/scene-ui-mode/ui-prefab-dialog-host.tsx +0 -74
  414. package/src/scene-ui-mode/ui-prefab-hierarchy-menu.ts +0 -49
  415. package/src/scene-ui-mode/ui-prefab-io.ts +0 -76
  416. package/src/ui-editor/inspector.tsx +0 -563
  417. package/src/ui-editor/node-ops.ts +0 -266
  418. package/src/ui-editor/overlay.tsx +0 -380
  419. package/src/ui-editor/palette.tsx +0 -114
  420. package/src/ui-editor/react-store.ts +0 -78
  421. package/src/ui-editor/timeline-panel.tsx +0 -443
  422. package/src/ui-editor/ui-edit-store.ts +0 -552
  423. package/src/ui-editor/ui-prefab.ts +0 -95
  424. package/template/project.json +0 -9
  425. package/template/src/scripts/main.ts +0 -94
  426. package/template/src/ui/DefaultHud.tsx +0 -40
  427. package/template/src/ui/game-state.tsx +0 -37
  428. package/template/src/ui/mount-default-hud.tsx +0 -19
  429. package/template/src/ui/mount-ui.ts +0 -15
  430. package/vite-plugin-react-world-provider.ts +0 -81
@@ -0,0 +1,2352 @@
1
+ import type { IconDefinition } from '@fortawesome/fontawesome-svg-core';
2
+ import {
3
+ faArrowDown,
4
+ faArrowUp,
5
+ faCheck,
6
+ faChevronDown,
7
+ faChevronLeft,
8
+ faCircleNotch,
9
+ faCode,
10
+ faCodeBranch,
11
+ faCopy,
12
+ faFileLines,
13
+ faGlobe,
14
+ faHistory,
15
+ faMagnifyingGlass,
16
+ faPen,
17
+ faPlus,
18
+ faRotate,
19
+ faStop,
20
+ faTerminal,
21
+ faWrench,
22
+ faXmark,
23
+ } from '@fortawesome/free-solid-svg-icons';
24
+ import {
25
+ type KeyboardEvent,
26
+ useCallback,
27
+ useEffect,
28
+ useMemo,
29
+ useRef,
30
+ useState,
31
+ useSyncExternalStore,
32
+ } from 'react';
33
+ import ReactMarkdown from 'react-markdown';
34
+ import remarkGfm from 'remark-gfm';
35
+ import { Button, Checkbox, EditorIcon, Select, TextInput, themeVars } from '../design-system';
36
+ import {
37
+ connectHarnessChatEvents,
38
+ getHarnessChatSnapshot,
39
+ HarnessChatRequestError,
40
+ performHarnessChatAction,
41
+ } from '../editor-api';
42
+ import { editorConsole } from '../editor-console';
43
+ import {
44
+ harnessChatEditorStore,
45
+ harnessChatEditorStoreVersion,
46
+ subscribeHarnessChatEditorStore,
47
+ } from '../harness-chat-editor-context';
48
+ import { updateHarnessChatIndicator } from '../harness-chat-indicator';
49
+ import {
50
+ harnessChat,
51
+ harnessChatLayoutVersion,
52
+ subscribeHarnessChatLayout,
53
+ toggleHarnessChatCollapsed,
54
+ } from '../harness-chat-layout';
55
+ import { harnessChatLifecycle } from '../harness-chat-lifecycle';
56
+ import { useHarnessChatReconnect } from '../harness-chat-reconnect';
57
+ import type {
58
+ HarnessChatAction,
59
+ HarnessChatConversationEntry,
60
+ HarnessChatHarness,
61
+ HarnessChatMessageEntry,
62
+ HarnessChatRequestEntry,
63
+ HarnessChatSession,
64
+ HarnessChatSnapshot,
65
+ HarnessChatToolEntry,
66
+ } from '../harness-chat-types';
67
+ import {
68
+ shouldAcceptHarnessChatSnapshot,
69
+ unavailableHarnessChatSnapshot,
70
+ } from '../harness-chat-types';
71
+ import {
72
+ activeWorkspaceDocument,
73
+ subscribeWorkspaceDocuments,
74
+ workspaceDocumentRegistryVersion,
75
+ } from '../workspace-document-registry';
76
+
77
+ const RESUME_WARNING_KEY = 'vgai:harness-chat:skip-resume-warning:v1';
78
+ const CHAT_GUTTER = 16;
79
+ const noopSubscribe = () => () => {};
80
+ const zeroSnapshot = () => 0;
81
+
82
+ function blankSnapshot(): HarnessChatSnapshot {
83
+ return { ...unavailableHarnessChatSnapshot(''), status: 'loading', error: null };
84
+ }
85
+
86
+ function relativeTime(timestamp: number | null): string {
87
+ if (!timestamp) return '';
88
+ const seconds = Math.max(0, Math.round((Date.now() - timestamp) / 1000));
89
+ if (seconds < 60) return `${seconds}s`;
90
+ const minutes = Math.round(seconds / 60);
91
+ if (minutes < 60) return `${minutes}m`;
92
+ const hours = Math.round(minutes / 60);
93
+ if (hours < 24) return `${hours}h`;
94
+ return `${Math.round(hours / 24)}d`;
95
+ }
96
+
97
+ function statusColor(snapshot: HarnessChatSnapshot): string {
98
+ if (snapshot.requests.some((request) => request.status === 'pending'))
99
+ return themeVars.semantic.warning;
100
+ if (snapshot.status === 'working') return themeVars.accent.default;
101
+ if (snapshot.status === 'error' || snapshot.status === 'unavailable')
102
+ return themeVars.semantic.danger;
103
+ if (snapshot.mode === 'observe') return themeVars.semantic.warning;
104
+ return themeVars.semantic.success;
105
+ }
106
+
107
+ function lifecycleColor(snapshot: HarnessChatSnapshot): string {
108
+ switch (harnessChatLifecycle(snapshot).kind) {
109
+ case 'live-terminal':
110
+ return themeVars.semantic.success;
111
+ case 'editor':
112
+ case 'resumed':
113
+ case 'branched':
114
+ return themeVars.accent.default;
115
+ case 'mirror':
116
+ case 'mirror-reconnecting':
117
+ return themeVars.semantic.warning;
118
+ case 'offline':
119
+ return themeVars.semantic.danger;
120
+ case 'idle':
121
+ return themeVars.content.dim;
122
+ }
123
+ }
124
+
125
+ function authSummary(harness: HarnessChatHarness): string {
126
+ switch (harness.auth) {
127
+ case 'ready':
128
+ return 'Signed in';
129
+ case 'configured':
130
+ return 'Credentials found';
131
+ case 'required':
132
+ return 'Sign-in required';
133
+ case 'unknown':
134
+ return 'Auth not verified';
135
+ }
136
+ }
137
+
138
+ function runtimeSummary(harness: HarnessChatHarness): string {
139
+ switch (harness.runtime) {
140
+ case 'ready':
141
+ return 'Runtime verified';
142
+ case 'degraded':
143
+ return 'Runtime needs verification';
144
+ case 'unavailable':
145
+ return 'Runtime unavailable';
146
+ }
147
+ }
148
+
149
+ function harnessReadinessColor(harness: HarnessChatHarness): string {
150
+ if (!harness.installed || harness.auth === 'required' || harness.runtime === 'unavailable') {
151
+ return themeVars.semantic.danger;
152
+ }
153
+ return harness.auth === 'ready' && harness.runtime === 'ready'
154
+ ? themeVars.semantic.success
155
+ : themeVars.semantic.warning;
156
+ }
157
+
158
+ function HarnessReadiness({ harness }: { harness: HarnessChatHarness | null }) {
159
+ if (!harness) return null;
160
+ return (
161
+ <div
162
+ data-testid="harness-chat-readiness"
163
+ style={{
164
+ marginBottom: 7,
165
+ padding: '7px 8px',
166
+ border: `1px solid ${themeVars.boundary.default}`,
167
+ borderRadius: themeVars.shape.small,
168
+ background: themeVars.surface.inset,
169
+ color: themeVars.content.muted,
170
+ fontSize: 'var(--vgai-font-sm)',
171
+ lineHeight: 1.45,
172
+ }}
173
+ >
174
+ <div
175
+ style={{ display: 'flex', alignItems: 'center', gap: 6, color: themeVars.content.primary }}
176
+ >
177
+ <span
178
+ aria-hidden
179
+ style={{
180
+ width: 6,
181
+ height: 6,
182
+ borderRadius: '50%',
183
+ background: harnessReadinessColor(harness),
184
+ }}
185
+ />
186
+ <strong>{harness.label}</strong>
187
+ <span style={{ color: themeVars.content.dim }}>
188
+ {authSummary(harness)} · {runtimeSummary(harness)}
189
+ </span>
190
+ </div>
191
+ <div style={{ marginTop: 3, color: themeVars.content.dim }}>
192
+ {harness.protocol}
193
+ {harness.reason ? ` · ${harness.reason}` : ''}
194
+ </div>
195
+ {harness.repair && (
196
+ <div style={{ marginTop: 3, color: themeVars.semantic.warning }}>{harness.repair}</div>
197
+ )}
198
+ </div>
199
+ );
200
+ }
201
+
202
+ function selectedHarness(
203
+ snapshot: HarnessChatSnapshot,
204
+ selected: string,
205
+ ): HarnessChatHarness | null {
206
+ return (
207
+ snapshot.harnesses.find((item) => item.id === selected) ??
208
+ snapshot.harnesses.find((item) => item.id === snapshot.activeHarness) ??
209
+ snapshot.harnesses.find((item) => item.availableActions.start) ??
210
+ snapshot.harnesses.find((item) => item.installed) ??
211
+ null
212
+ );
213
+ }
214
+
215
+ function visibleMessages(entries: HarnessChatConversationEntry[]): HarnessChatMessageEntry[] {
216
+ return entries.filter(
217
+ (entry): entry is HarnessChatMessageEntry =>
218
+ entry.kind === 'message' && entry.visibility === 'conversation',
219
+ );
220
+ }
221
+
222
+ function conversationTitle(
223
+ session: HarnessChatSession | null,
224
+ entries: HarnessChatConversationEntry[],
225
+ pendingNew: boolean,
226
+ ): string {
227
+ if (pendingNew) return 'New chat';
228
+ const firstPrompt = visibleMessages(entries)
229
+ .find((message) => message.role === 'user')
230
+ ?.text.trim();
231
+ const genericTitle =
232
+ !session ||
233
+ session.title === session.id ||
234
+ session.title.toLowerCase() === `${session.harness.toLowerCase()} chat`;
235
+ const value = genericTitle && firstPrompt ? firstPrompt : (session?.title ?? 'Agent');
236
+ return value.length > 72 ? `${value.slice(0, 69)}…` : value;
237
+ }
238
+
239
+ interface SessionPickerProps {
240
+ snapshot: HarnessChatSnapshot;
241
+ harness: HarnessChatHarness | null;
242
+ selectedHarnessId: string;
243
+ onHarness: (id: string) => void;
244
+ onNew: () => void;
245
+ onObserve: (sessionId: string) => void;
246
+ onClose: () => void;
247
+ }
248
+
249
+ function SessionPickerRow({
250
+ session,
251
+ activeSessionId,
252
+ locked,
253
+ onObserve,
254
+ }: {
255
+ session: HarnessChatSession;
256
+ activeSessionId: string | null;
257
+ locked: boolean;
258
+ onObserve: (sessionId: string) => void;
259
+ }) {
260
+ return (
261
+ <Button
262
+ type="button"
263
+ variant="ghost"
264
+ className="vgai-chat-session-row"
265
+ data-selected={session.id === activeSessionId || undefined}
266
+ disabled={locked}
267
+ title={
268
+ locked ? 'Release the editor-controlled session before opening another chat' : undefined
269
+ }
270
+ onClick={() => onObserve(session.id)}
271
+ >
272
+ <span style={{ minWidth: 0, flex: 1 }}>
273
+ <span
274
+ style={{
275
+ display: 'block',
276
+ overflow: 'hidden',
277
+ textOverflow: 'ellipsis',
278
+ whiteSpace: 'nowrap',
279
+ fontSize: 'var(--vgai-font-base)',
280
+ }}
281
+ >
282
+ {session.title}
283
+ </span>
284
+ <span style={{ color: themeVars.content.dim, fontSize: 'var(--vgai-font-sm)' }}>
285
+ {session.harness}
286
+ {session.model ? ` · ${session.model}` : ''}
287
+ {` · ${session.storage}`}
288
+ </span>
289
+ </span>
290
+ <span style={{ color: themeVars.content.dim, fontSize: 'var(--vgai-font-sm)' }}>
291
+ {relativeTime(session.updatedAt)}
292
+ </span>
293
+ {session.attachable && (
294
+ <span
295
+ style={{
296
+ padding: '2px 5px',
297
+ borderRadius: themeVars.shape.small,
298
+ color: themeVars.semantic.success,
299
+ background: themeVars.semantic.successMuted,
300
+ fontSize: 9,
301
+ fontWeight: 600,
302
+ }}
303
+ >
304
+ LIVE
305
+ </span>
306
+ )}
307
+ </Button>
308
+ );
309
+ }
310
+
311
+ function handleSessionDialogKey(
312
+ event: globalThis.KeyboardEvent,
313
+ dialog: HTMLDivElement | null,
314
+ onClose: () => void,
315
+ ): void {
316
+ if (event.key === 'Escape') {
317
+ onClose();
318
+ return;
319
+ }
320
+ if (event.key !== 'Tab') return;
321
+ const focusable = [
322
+ ...(dialog?.querySelectorAll<HTMLElement>(
323
+ 'button:not(:disabled), select:not(:disabled), input:not(:disabled)',
324
+ ) ?? []),
325
+ ];
326
+ const boundary = event.shiftKey ? focusable[0] : focusable.at(-1);
327
+ if (!boundary || document.activeElement !== boundary) return;
328
+ event.preventDefault();
329
+ (event.shiftKey ? focusable.at(-1) : focusable[0])?.focus();
330
+ }
331
+
332
+ function SessionPicker({
333
+ snapshot,
334
+ harness,
335
+ selectedHarnessId,
336
+ onHarness,
337
+ onNew,
338
+ onObserve,
339
+ onClose,
340
+ }: SessionPickerProps) {
341
+ const dialogRef = useRef<HTMLDivElement>(null);
342
+ const [query, setQuery] = useState('');
343
+ const normalized = query.trim().toLowerCase();
344
+ const sessions = snapshot.sessions.filter((session) =>
345
+ [session.title, session.harness, session.model ?? '', session.cwd ?? ''].some((value) =>
346
+ value.toLowerCase().includes(normalized),
347
+ ),
348
+ );
349
+
350
+ useEffect(() => {
351
+ const close = (event: globalThis.KeyboardEvent) =>
352
+ handleSessionDialogKey(event, dialogRef.current, onClose);
353
+ document.addEventListener('keydown', close);
354
+ const outside = (event: PointerEvent) => {
355
+ if (!dialogRef.current?.contains(event.target as Node)) onClose();
356
+ };
357
+ document.addEventListener('pointerdown', outside);
358
+ dialogRef.current?.querySelector<HTMLElement>('select, input, button')?.focus();
359
+ return () => {
360
+ document.removeEventListener('keydown', close);
361
+ document.removeEventListener('pointerdown', outside);
362
+ };
363
+ }, [onClose]);
364
+
365
+ return (
366
+ <div
367
+ ref={dialogRef}
368
+ role="dialog"
369
+ aria-modal="true"
370
+ aria-label="Coding sessions"
371
+ style={{
372
+ position: 'absolute',
373
+ top: 40,
374
+ left: 7,
375
+ right: 7,
376
+ maxHeight: 'min(500px, calc(100vh - 100px))',
377
+ overflow: 'auto',
378
+ zIndex: 30,
379
+ padding: 7,
380
+ background: themeVars.surface.chrome,
381
+ border: `1px solid ${themeVars.boundary.strong}`,
382
+ borderRadius: themeVars.shape.medium,
383
+ boxShadow: themeVars.elevation.medium,
384
+ }}
385
+ >
386
+ <div style={{ display: 'flex', gap: 6, paddingBottom: 7 }}>
387
+ <Select
388
+ aria-label="Harness for new session"
389
+ value={harness?.id ?? selectedHarnessId}
390
+ onChange={(event) => onHarness(event.target.value)}
391
+ style={{
392
+ minWidth: 0,
393
+ flex: 1,
394
+ height: 28,
395
+ padding: '0 6px',
396
+ color: themeVars.content.primary,
397
+ background: themeVars.surface.inset,
398
+ border: `1px solid ${themeVars.boundary.strong}`,
399
+ borderRadius: themeVars.shape.small,
400
+ fontSize: 'var(--vgai-font-sm)',
401
+ }}
402
+ >
403
+ {snapshot.harnesses.map((item) => (
404
+ <option key={item.id} value={item.id} disabled={!item.availableActions.start}>
405
+ {item.label}
406
+ {!item.installed
407
+ ? ' · not installed'
408
+ : item.auth === 'required'
409
+ ? ' · sign in'
410
+ : item.auth === 'configured'
411
+ ? ' · credentials found'
412
+ : item.auth === 'unknown'
413
+ ? ' · auth unverified'
414
+ : ''}
415
+ </option>
416
+ ))}
417
+ </Select>
418
+ <Button
419
+ type="button"
420
+ variant="primary"
421
+ size="compact"
422
+ disabled={!harness?.availableActions.start}
423
+ onClick={onNew}
424
+ >
425
+ <EditorIcon icon={faPlus} style={{ marginRight: 5 }} />
426
+ New
427
+ </Button>
428
+ <Button
429
+ type="button"
430
+ variant="ghost"
431
+ className="vgai-chat-icon"
432
+ aria-label="Close chat history"
433
+ onClick={onClose}
434
+ >
435
+ <EditorIcon icon={faXmark} />
436
+ </Button>
437
+ </div>
438
+ <HarnessReadiness harness={harness} />
439
+ <div style={{ position: 'relative', marginBottom: 6 }}>
440
+ <EditorIcon
441
+ icon={faMagnifyingGlass}
442
+ style={{
443
+ position: 'absolute',
444
+ left: 8,
445
+ top: 8,
446
+ color: themeVars.content.dim,
447
+ fontSize: 10,
448
+ }}
449
+ />
450
+ <TextInput
451
+ autoFocus
452
+ aria-label="Search coding sessions"
453
+ value={query}
454
+ onChange={(event) => setQuery(event.target.value)}
455
+ placeholder="Search chats"
456
+ style={{
457
+ boxSizing: 'border-box',
458
+ width: '100%',
459
+ height: 28,
460
+ padding: '0 8px 0 25px',
461
+ color: themeVars.content.primary,
462
+ background: themeVars.surface.inset,
463
+ border: `1px solid ${themeVars.boundary.default}`,
464
+ borderRadius: themeVars.shape.small,
465
+ outline: 'none',
466
+ }}
467
+ />
468
+ </div>
469
+ {sessions.length === 0 ? (
470
+ <div style={{ padding: '18px 8px', color: themeVars.content.dim, textAlign: 'center' }}>
471
+ {snapshot.sessions.length === 0
472
+ ? 'No conversations in this project'
473
+ : 'No matching chats'}
474
+ </div>
475
+ ) : (
476
+ sessions.map((session) => (
477
+ <SessionPickerRow
478
+ key={session.id}
479
+ session={session}
480
+ activeSessionId={snapshot.activeSessionId}
481
+ locked={snapshot.connection.ownsRuntime && session.id !== snapshot.activeSessionId}
482
+ onObserve={onObserve}
483
+ />
484
+ ))
485
+ )}
486
+ </div>
487
+ );
488
+ }
489
+
490
+ function MarkdownContent({ value }: { value: string }) {
491
+ return (
492
+ <div
493
+ style={{
494
+ overflowWrap: 'anywhere',
495
+ color: themeVars.content.primary,
496
+ lineHeight: 1.55,
497
+ fontSize: 'var(--vgai-font-md)',
498
+ }}
499
+ >
500
+ <ReactMarkdown
501
+ remarkPlugins={[remarkGfm]}
502
+ components={{
503
+ p: ({ children }) => <p style={{ margin: '0 0 8px' }}>{children}</p>,
504
+ a: ({ href, children }) => (
505
+ <a
506
+ href={href}
507
+ target="_blank"
508
+ rel="noreferrer"
509
+ style={{ color: themeVars.accent.default }}
510
+ >
511
+ {children}
512
+ </a>
513
+ ),
514
+ code: ({ children, className }) =>
515
+ className ? (
516
+ <code className={className}>{children}</code>
517
+ ) : (
518
+ <code
519
+ style={{
520
+ padding: '1px 4px',
521
+ borderRadius: themeVars.shape.small,
522
+ background: themeVars.surface.inset,
523
+ color: themeVars.content.primary,
524
+ fontFamily: themeVars.typography.mono,
525
+ fontSize: '0.92em',
526
+ }}
527
+ >
528
+ {children}
529
+ </code>
530
+ ),
531
+ pre: ({ children }) => (
532
+ <pre
533
+ style={{
534
+ margin: '8px 0',
535
+ padding: '9px 10px',
536
+ overflow: 'auto',
537
+ border: `1px solid ${themeVars.boundary.default}`,
538
+ borderRadius: themeVars.shape.medium,
539
+ background: themeVars.surface.inset,
540
+ color: themeVars.content.primary,
541
+ fontFamily: themeVars.typography.mono,
542
+ fontSize: 'var(--vgai-font-sm)',
543
+ }}
544
+ >
545
+ {children}
546
+ </pre>
547
+ ),
548
+ table: ({ children }) => (
549
+ <div style={{ overflowX: 'auto', margin: '8px 0' }}>
550
+ <table
551
+ style={{
552
+ borderCollapse: 'collapse',
553
+ width: '100%',
554
+ fontSize: 'var(--vgai-font-sm)',
555
+ }}
556
+ >
557
+ {children}
558
+ </table>
559
+ </div>
560
+ ),
561
+ th: ({ children }) => (
562
+ <th
563
+ style={{
564
+ border: `1px solid ${themeVars.boundary.default}`,
565
+ padding: 5,
566
+ textAlign: 'left',
567
+ }}
568
+ >
569
+ {children}
570
+ </th>
571
+ ),
572
+ td: ({ children }) => (
573
+ <td style={{ border: `1px solid ${themeVars.boundary.default}`, padding: 5 }}>
574
+ {children}
575
+ </td>
576
+ ),
577
+ }}
578
+ >
579
+ {value}
580
+ </ReactMarkdown>
581
+ </div>
582
+ );
583
+ }
584
+
585
+ type ToolCategory = 'read' | 'search' | 'edit' | 'command' | 'test' | 'web' | 'agent' | 'other';
586
+
587
+ function toolCategory(tool: Pick<HarnessChatToolEntry, 'name' | 'arguments'>): ToolCategory {
588
+ const value = tool.name?.toLowerCase() ?? '';
589
+ if (/read|view|open_file|list_dir/.test(value)) return 'read';
590
+ if (/search|find|grep|glob/.test(value)) return 'search';
591
+ if (/edit|write|patch|replace|create_file/.test(value)) return 'edit';
592
+ if (/test|typecheck|lint|build/.test(value)) return 'test';
593
+ if (/terminal|bash|shell|command|exec/.test(value)) {
594
+ return /(?:^|\s)(?:npm|pnpm|yarn|bun|cargo|go|pytest|vitest|jest|tsc).*?(?:test|typecheck|lint|build)|\b(?:test|typecheck|lint|build)\b/i.test(
595
+ tool.arguments ?? '',
596
+ )
597
+ ? 'test'
598
+ : 'command';
599
+ }
600
+ if (/browser|web|fetch|url/.test(value)) return 'web';
601
+ if (/subagent|spawn|task/.test(value)) return 'agent';
602
+ return 'other';
603
+ }
604
+
605
+ function toolLabel(name: string | null): string {
606
+ if (!name) return 'Tool';
607
+ const simple = name.split(/__|\//).at(-1) ?? name;
608
+ return simple.replace(/[_-]+/g, ' ').replace(/\b\w/g, (letter) => letter.toUpperCase());
609
+ }
610
+
611
+ function toolTarget(argumentsText: string | null): string {
612
+ if (!argumentsText) return '';
613
+ try {
614
+ const args = JSON.parse(argumentsText) as Record<string, unknown>;
615
+ for (const key of ['file_path', 'target_file', 'path', 'command', 'query', 'pattern', 'url']) {
616
+ const candidate = args[key];
617
+ if (typeof candidate === 'string') return candidate;
618
+ }
619
+ } catch {
620
+ return argumentsText.length > 90 ? `${argumentsText.slice(0, 87)}…` : argumentsText;
621
+ }
622
+ return '';
623
+ }
624
+
625
+ function compactToolTarget(target: string, workspace: string | null): string {
626
+ if (!target || !workspace) return target;
627
+ const prefix = workspace.endsWith('/') ? workspace : `${workspace}/`;
628
+ return target.startsWith(prefix) ? target.slice(prefix.length) : target;
629
+ }
630
+
631
+ function toolIcon(category: ToolCategory): IconDefinition {
632
+ if (category === 'read') return faFileLines;
633
+ if (category === 'edit') return faPen;
634
+ if (category === 'search') return faMagnifyingGlass;
635
+ if (category === 'command') return faTerminal;
636
+ if (category === 'web') return faGlobe;
637
+ if (category === 'test') return faCode;
638
+ return faWrench;
639
+ }
640
+
641
+ function ToolRow({ tool, workspace }: { tool: HarnessChatToolEntry; workspace: string | null }) {
642
+ const category = toolCategory(tool);
643
+ const fullTarget = toolTarget(tool.arguments);
644
+ const target = compactToolTarget(fullTarget, workspace);
645
+ const statusIcon =
646
+ tool.status === 'pending' ? faCircleNotch : tool.status === 'error' ? faXmark : faCheck;
647
+ const statusTone =
648
+ tool.status === 'pending'
649
+ ? themeVars.accent.default
650
+ : tool.status === 'error'
651
+ ? themeVars.semantic.danger
652
+ : themeVars.semantic.success;
653
+ const details = Boolean(tool.arguments || tool.resultText);
654
+ return (
655
+ <details
656
+ open={tool.status === 'error'}
657
+ style={{ borderTop: `1px solid ${themeVars.boundary.default}` }}
658
+ >
659
+ <summary
660
+ style={{
661
+ minHeight: 30,
662
+ display: 'flex',
663
+ alignItems: 'center',
664
+ gap: 6,
665
+ padding: '0 8px',
666
+ cursor: details ? 'pointer' : 'default',
667
+ listStyle: 'none',
668
+ fontSize: 'var(--vgai-font-sm)',
669
+ }}
670
+ >
671
+ <EditorIcon icon={toolIcon(category)} style={{ width: 12, color: themeVars.content.dim }} />
672
+ <span style={{ color: themeVars.content.primary, fontWeight: 600, whiteSpace: 'nowrap' }}>
673
+ {toolLabel(tool.name)}
674
+ </span>
675
+ {target && (
676
+ <span
677
+ title={fullTarget}
678
+ style={{
679
+ minWidth: 0,
680
+ overflow: 'hidden',
681
+ textOverflow: 'ellipsis',
682
+ whiteSpace: 'nowrap',
683
+ color: themeVars.content.dim,
684
+ }}
685
+ >
686
+ {target}
687
+ </span>
688
+ )}
689
+ <span style={{ flex: 1 }} />
690
+ <EditorIcon
691
+ icon={statusIcon}
692
+ spin={tool.status === 'pending'}
693
+ style={{ width: 11, color: statusTone }}
694
+ />
695
+ </summary>
696
+ {details && (
697
+ <div
698
+ style={{
699
+ maxHeight: 220,
700
+ overflow: 'auto',
701
+ padding: '7px 9px 9px',
702
+ background: themeVars.surface.inset,
703
+ }}
704
+ >
705
+ {tool.arguments && (
706
+ <pre
707
+ style={{
708
+ margin: 0,
709
+ color: themeVars.content.dim,
710
+ fontFamily: themeVars.typography.mono,
711
+ fontSize: 'var(--vgai-font-sm)',
712
+ whiteSpace: 'pre-wrap',
713
+ }}
714
+ >
715
+ {tool.arguments}
716
+ </pre>
717
+ )}
718
+ {tool.resultText && (
719
+ <pre
720
+ style={{
721
+ margin: tool.arguments ? '7px 0 0' : 0,
722
+ color:
723
+ tool.status === 'error' ? themeVars.semantic.danger : themeVars.content.muted,
724
+ fontFamily: themeVars.typography.mono,
725
+ fontSize: 'var(--vgai-font-sm)',
726
+ whiteSpace: 'pre-wrap',
727
+ overflowWrap: 'anywhere',
728
+ }}
729
+ >
730
+ {tool.resultText}
731
+ </pre>
732
+ )}
733
+ </div>
734
+ )}
735
+ </details>
736
+ );
737
+ }
738
+
739
+ function toolGroupSummary(tools: HarnessChatToolEntry[]): string {
740
+ const counts = new Map<ToolCategory, number>();
741
+ for (const tool of tools) {
742
+ const category = toolCategory(tool);
743
+ counts.set(category, (counts.get(category) ?? 0) + 1);
744
+ }
745
+ const onlyRoutine = [...counts.keys()].every(
746
+ (category) => category === 'read' || category === 'search',
747
+ );
748
+ if (onlyRoutine) {
749
+ const files = (counts.get('read') ?? 0) + (counts.get('search') ?? 0);
750
+ return `Explored ${files} ${files === 1 ? 'item' : 'items'}`;
751
+ }
752
+ const parts: string[] = [];
753
+ const labels: Array<[ToolCategory, string]> = [
754
+ ['edit', 'changed'],
755
+ ['test', 'tests/builds'],
756
+ ['command', 'commands'],
757
+ ['read', 'reads'],
758
+ ['search', 'searches'],
759
+ ['web', 'web'],
760
+ ['agent', 'agents'],
761
+ ['other', 'other'],
762
+ ];
763
+ for (const [category, label] of labels) {
764
+ const count = counts.get(category);
765
+ if (count) parts.push(`${count} ${label}`);
766
+ }
767
+ return parts.length ? `Activity · ${parts.join(' · ')}` : `Activity · ${tools.length} steps`;
768
+ }
769
+
770
+ function ToolGroup({
771
+ tools,
772
+ workspace,
773
+ }: {
774
+ tools: HarnessChatToolEntry[];
775
+ workspace: string | null;
776
+ }) {
777
+ const important = tools.some((tool) => {
778
+ const category = toolCategory(tool);
779
+ return tool.status !== 'completed' || category === 'edit' || category === 'test';
780
+ });
781
+ const [expanded, setExpanded] = useState(important);
782
+ useEffect(() => {
783
+ if (important) setExpanded(true);
784
+ }, [important]);
785
+ const completed = tools.filter((tool) => tool.status === 'completed').length;
786
+ return (
787
+ <details
788
+ open={expanded}
789
+ onToggle={(event) => setExpanded(event.currentTarget.open)}
790
+ style={{
791
+ margin: '6px 0 12px',
792
+ border: `1px solid ${themeVars.boundary.default}`,
793
+ borderRadius: themeVars.shape.medium,
794
+ background: themeVars.surface.chrome,
795
+ overflow: 'hidden',
796
+ }}
797
+ >
798
+ <summary
799
+ style={{
800
+ minHeight: 34,
801
+ display: 'flex',
802
+ alignItems: 'center',
803
+ gap: 7,
804
+ padding: '0 9px',
805
+ cursor: 'pointer',
806
+ listStyle: 'none',
807
+ color: themeVars.content.muted,
808
+ fontSize: 'var(--vgai-font-sm)',
809
+ }}
810
+ >
811
+ <EditorIcon
812
+ icon={tools.some((tool) => tool.status === 'pending') ? faCircleNotch : faWrench}
813
+ spin={tools.some((tool) => tool.status === 'pending')}
814
+ style={{ color: themeVars.content.dim }}
815
+ />
816
+ <span style={{ color: themeVars.content.primary, fontWeight: 600 }}>
817
+ {toolGroupSummary(tools)}
818
+ </span>
819
+ <span style={{ flex: 1 }} />
820
+ <span>
821
+ {completed}/{tools.length}
822
+ </span>
823
+ <EditorIcon icon={faChevronDown} style={{ fontSize: 8 }} />
824
+ </summary>
825
+ {expanded && (
826
+ <div>
827
+ {tools.map((tool) => (
828
+ <ToolRow key={tool.id} tool={tool} workspace={workspace} />
829
+ ))}
830
+ </div>
831
+ )}
832
+ </details>
833
+ );
834
+ }
835
+
836
+ function RequestCard({
837
+ request,
838
+ onRespond,
839
+ busy,
840
+ }: {
841
+ request: HarnessChatRequestEntry;
842
+ onRespond: (action: HarnessChatAction) => void;
843
+ busy: boolean;
844
+ }) {
845
+ if (request.status === 'responded') {
846
+ return (
847
+ <div
848
+ style={{
849
+ margin: '6px 0 10px',
850
+ color: themeVars.content.dim,
851
+ fontSize: 'var(--vgai-font-sm)',
852
+ }}
853
+ >
854
+ <EditorIcon icon={faCheck} style={{ marginRight: 6, color: themeVars.semantic.success }} />
855
+ Request answered · {request.resolution?.name ?? request.requestKind}
856
+ </div>
857
+ );
858
+ }
859
+ return (
860
+ <div
861
+ role="group"
862
+ aria-label="Agent needs input"
863
+ style={{
864
+ margin: '8px 0 13px',
865
+ padding: 10,
866
+ border: `1px solid ${themeVars.semantic.warning}`,
867
+ borderRadius: themeVars.shape.medium,
868
+ background: themeVars.surface.chrome,
869
+ }}
870
+ >
871
+ <div style={{ color: themeVars.content.primary, fontWeight: 600 }}>Agent needs input</div>
872
+ <div
873
+ style={{ marginTop: 3, color: themeVars.content.muted, fontSize: 'var(--vgai-font-sm)' }}
874
+ >
875
+ {request.requestKind}
876
+ </div>
877
+ <pre
878
+ style={{
879
+ maxHeight: 120,
880
+ overflow: 'auto',
881
+ color: themeVars.content.dim,
882
+ fontFamily: themeVars.typography.mono,
883
+ fontSize: 'var(--vgai-font-xs)',
884
+ whiteSpace: 'pre-wrap',
885
+ }}
886
+ >
887
+ {JSON.stringify(request.payload, null, 2)}
888
+ </pre>
889
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
890
+ {request.options.map((option) => (
891
+ <Button
892
+ type="button"
893
+ variant={option.kind.startsWith('reject') ? 'danger' : 'outline'}
894
+ size="compact"
895
+ disabled={busy}
896
+ key={option.optionId}
897
+ onClick={() =>
898
+ onRespond({
899
+ type: 'respond',
900
+ requestId: request.requestId,
901
+ optionId: option.optionId,
902
+ })
903
+ }
904
+ >
905
+ {option.name}
906
+ </Button>
907
+ ))}
908
+ {request.cancellable && (
909
+ <Button
910
+ type="button"
911
+ variant="outline"
912
+ size="compact"
913
+ disabled={busy}
914
+ onClick={() =>
915
+ onRespond({ type: 'respond', requestId: request.requestId, optionId: null })
916
+ }
917
+ >
918
+ Cancel
919
+ </Button>
920
+ )}
921
+ </div>
922
+ </div>
923
+ );
924
+ }
925
+
926
+ type ConversationBlock =
927
+ | Exclude<HarnessChatConversationEntry, HarnessChatToolEntry>
928
+ | { kind: 'tool-group'; id: string; tools: HarnessChatToolEntry[] };
929
+
930
+ function conversationBlocks(entries: HarnessChatConversationEntry[]): ConversationBlock[] {
931
+ const blocks: ConversationBlock[] = [];
932
+ for (const entry of entries) {
933
+ if (entry.kind !== 'tool') {
934
+ blocks.push(entry);
935
+ continue;
936
+ }
937
+ const previous = blocks.at(-1);
938
+ if (previous?.kind === 'tool-group') previous.tools.push(entry);
939
+ else blocks.push({ kind: 'tool-group', id: `tools:${entry.id}`, tools: [entry] });
940
+ }
941
+ return blocks;
942
+ }
943
+
944
+ function ConversationEntry({
945
+ entry,
946
+ workspace,
947
+ onRespond,
948
+ finalResponse,
949
+ busy,
950
+ }: {
951
+ entry: Exclude<ConversationBlock, { kind: 'tool-group' }>;
952
+ workspace: string | null;
953
+ onRespond: (action: HarnessChatAction) => void;
954
+ finalResponse: boolean;
955
+ busy: boolean;
956
+ }) {
957
+ const [copied, setCopied] = useState(false);
958
+ void workspace;
959
+ if (entry.kind === 'request')
960
+ return <RequestCard request={entry} onRespond={onRespond} busy={busy} />;
961
+ if (entry.kind === 'reasoning') {
962
+ return (
963
+ <details
964
+ style={{
965
+ margin: '5px 0 10px',
966
+ color: themeVars.content.dim,
967
+ fontSize: 'var(--vgai-font-sm)',
968
+ }}
969
+ >
970
+ <summary style={{ cursor: 'pointer' }}>
971
+ {entry.streaming ? 'Thinking…' : 'Reasoning'}
972
+ </summary>
973
+ <div style={{ padding: '6px 0 0 12px', whiteSpace: 'pre-wrap' }}>{entry.text}</div>
974
+ </details>
975
+ );
976
+ }
977
+ if (entry.kind === 'notice') {
978
+ return (
979
+ <div
980
+ style={{
981
+ margin: '9px 0',
982
+ color: themeVars.content.dim,
983
+ fontSize: 'var(--vgai-font-sm)',
984
+ textAlign: 'center',
985
+ }}
986
+ >
987
+ {entry.text}
988
+ </div>
989
+ );
990
+ }
991
+ if (entry.visibility === 'context') return null;
992
+ if (entry.role === 'system') return null;
993
+ if (entry.role === 'user') {
994
+ return (
995
+ <div
996
+ data-role="user"
997
+ style={{
998
+ width: '100%',
999
+ boxSizing: 'border-box',
1000
+ margin: '4px 0 17px',
1001
+ padding: '10px 11px',
1002
+ border: `1px solid ${themeVars.boundary.default}`,
1003
+ borderRadius: themeVars.shape.medium,
1004
+ background: themeVars.surface.chrome,
1005
+ }}
1006
+ >
1007
+ <MarkdownContent value={entry.text} />
1008
+ </div>
1009
+ );
1010
+ }
1011
+ return (
1012
+ <div data-role="assistant" style={{ margin: '3px 0 13px' }}>
1013
+ <MarkdownContent value={entry.text} />
1014
+ <Button
1015
+ type="button"
1016
+ variant="ghost"
1017
+ size="compact"
1018
+ className="vgai-chat-icon"
1019
+ aria-label={
1020
+ copied ? 'Response copied' : finalResponse ? 'Copy final response' : 'Copy response'
1021
+ }
1022
+ title={copied ? 'Copied' : finalResponse ? 'Copy final response' : 'Copy response'}
1023
+ onClick={() => {
1024
+ void navigator.clipboard?.writeText(entry.text);
1025
+ setCopied(true);
1026
+ setTimeout(() => setCopied(false), 1_500);
1027
+ }}
1028
+ style={{ marginTop: 3 }}
1029
+ >
1030
+ <EditorIcon icon={copied ? faCheck : faCopy} />
1031
+ </Button>
1032
+ </div>
1033
+ );
1034
+ }
1035
+
1036
+ function Conversation({
1037
+ snapshot,
1038
+ messagesRef,
1039
+ onRespond,
1040
+ onScroll,
1041
+ }: {
1042
+ snapshot: HarnessChatSnapshot;
1043
+ messagesRef: React.RefObject<HTMLDivElement | null>;
1044
+ onRespond: (action: HarnessChatAction) => void;
1045
+ onScroll: () => void;
1046
+ }) {
1047
+ const blocks = conversationBlocks(snapshot.conversation);
1048
+ const finalAssistantId =
1049
+ snapshot.turn.state === 'idle'
1050
+ ? [...snapshot.conversation]
1051
+ .reverse()
1052
+ .find(
1053
+ (entry): entry is HarnessChatMessageEntry =>
1054
+ entry.kind === 'message' &&
1055
+ entry.role === 'assistant' &&
1056
+ entry.visibility === 'conversation',
1057
+ )?.id
1058
+ : undefined;
1059
+ let content: React.ReactNode;
1060
+ if (snapshot.status === 'loading' && blocks.length === 0) {
1061
+ content = (
1062
+ <div style={{ color: themeVars.content.dim, textAlign: 'center', padding: 28 }}>
1063
+ Finding chats…
1064
+ </div>
1065
+ );
1066
+ } else if (blocks.length === 0) {
1067
+ content = (
1068
+ <div style={{ color: themeVars.content.muted, padding: '24px 6px', lineHeight: 1.5 }}>
1069
+ <div style={{ color: themeVars.content.primary, fontWeight: 600, marginBottom: 5 }}>
1070
+ What do you want to build?
1071
+ </div>
1072
+ Start here, or run any supported coding agent from this project. External conversations
1073
+ appear here automatically.
1074
+ </div>
1075
+ );
1076
+ } else {
1077
+ content = blocks.map((block) =>
1078
+ block.kind === 'tool-group' ? (
1079
+ <ToolGroup key={block.id} tools={block.tools} workspace={snapshot.workspace} />
1080
+ ) : (
1081
+ <ConversationEntry
1082
+ key={block.id}
1083
+ entry={block}
1084
+ workspace={snapshot.workspace}
1085
+ onRespond={onRespond}
1086
+ finalResponse={block.kind === 'message' && block.id === finalAssistantId}
1087
+ busy={snapshot.operation === 'respond'}
1088
+ />
1089
+ ),
1090
+ );
1091
+ }
1092
+ return (
1093
+ <div
1094
+ ref={messagesRef}
1095
+ data-testid="harness-chat-messages"
1096
+ onScroll={onScroll}
1097
+ style={{
1098
+ flex: 1,
1099
+ minHeight: 0,
1100
+ overflow: 'auto',
1101
+ padding: `${CHAT_GUTTER}px ${CHAT_GUTTER}px 24px`,
1102
+ }}
1103
+ >
1104
+ {content}
1105
+ {snapshot.turn.state !== 'idle' && (
1106
+ <div
1107
+ role="status"
1108
+ style={{
1109
+ display: 'flex',
1110
+ alignItems: 'center',
1111
+ gap: 6,
1112
+ color: themeVars.content.dim,
1113
+ fontSize: 'var(--vgai-font-sm)',
1114
+ padding: '2px 0 5px',
1115
+ }}
1116
+ >
1117
+ <EditorIcon icon={faCircleNotch} spin style={{ color: themeVars.accent.default }} />
1118
+ {snapshot.turn.state === 'reconciling'
1119
+ ? 'Reconciling…'
1120
+ : snapshot.turn.state === 'interrupting'
1121
+ ? 'Interrupting…'
1122
+ : 'Working…'}
1123
+ </div>
1124
+ )}
1125
+ </div>
1126
+ );
1127
+ }
1128
+
1129
+ interface ContextCandidate {
1130
+ id: string;
1131
+ label: string;
1132
+ detail: string;
1133
+ }
1134
+
1135
+ interface OutgoingPrompt {
1136
+ text: string;
1137
+ context: ContextCandidate[];
1138
+ }
1139
+
1140
+ function ContextChips({
1141
+ candidates,
1142
+ selected,
1143
+ onToggle,
1144
+ }: {
1145
+ candidates: ContextCandidate[];
1146
+ selected: Set<string>;
1147
+ onToggle: (id: string) => void;
1148
+ }) {
1149
+ if (candidates.length === 0) return null;
1150
+ return (
1151
+ <div style={{ display: 'flex', gap: 5, overflowX: 'auto', padding: '6px 8px 0' }}>
1152
+ {candidates.map((candidate) => (
1153
+ <Button
1154
+ type="button"
1155
+ variant="outline"
1156
+ size="compact"
1157
+ key={candidate.id}
1158
+ title={candidate.detail}
1159
+ aria-pressed={selected.has(candidate.id)}
1160
+ onClick={() => onToggle(candidate.id)}
1161
+ style={{ flexShrink: 0 }}
1162
+ >
1163
+ + {candidate.label}
1164
+ </Button>
1165
+ ))}
1166
+ </div>
1167
+ );
1168
+ }
1169
+
1170
+ function ObserveToolbar({
1171
+ snapshot,
1172
+ busy,
1173
+ onBranch,
1174
+ onAttach,
1175
+ onResume,
1176
+ }: {
1177
+ snapshot: HarnessChatSnapshot;
1178
+ busy: boolean;
1179
+ onBranch: () => void;
1180
+ onAttach: () => void;
1181
+ onResume: () => void;
1182
+ }) {
1183
+ const lifecycle = harnessChatLifecycle(snapshot);
1184
+ return (
1185
+ <div
1186
+ style={{ minHeight: 34, padding: '6px 7px 0', display: 'flex', alignItems: 'center', gap: 5 }}
1187
+ >
1188
+ <span
1189
+ style={{
1190
+ marginRight: 'auto',
1191
+ padding: '3px 6px',
1192
+ borderRadius: themeVars.shape.small,
1193
+ background: themeVars.surface.inset,
1194
+ color: themeVars.content.muted,
1195
+ fontSize: 'var(--vgai-font-sm)',
1196
+ }}
1197
+ >
1198
+ {lifecycle.label}
1199
+ </span>
1200
+ {snapshot.availableActions.attach ? (
1201
+ <Button type="button" variant="primary" size="compact" disabled={busy} onClick={onAttach}>
1202
+ Connect live
1203
+ </Button>
1204
+ ) : snapshot.availableActions.resume ? (
1205
+ <Button type="button" variant="primary" size="compact" disabled={busy} onClick={onResume}>
1206
+ Resume here
1207
+ </Button>
1208
+ ) : null}
1209
+ {snapshot.availableActions.branch && (
1210
+ <Button
1211
+ type="button"
1212
+ variant="outline"
1213
+ size="compact"
1214
+ disabled={busy}
1215
+ onClick={onBranch}
1216
+ title="Start an independent continuation"
1217
+ >
1218
+ <EditorIcon icon={faCodeBranch} />
1219
+ </Button>
1220
+ )}
1221
+ </div>
1222
+ );
1223
+ }
1224
+
1225
+ function SessionControlButton({
1226
+ snapshot,
1227
+ onDetach,
1228
+ onOpenTerminal,
1229
+ }: {
1230
+ snapshot: HarnessChatSnapshot;
1231
+ onDetach: () => void;
1232
+ onOpenTerminal: () => void;
1233
+ }) {
1234
+ const lifecycle = harnessChatLifecycle(snapshot);
1235
+ const action = lifecycle.controlAction;
1236
+ if (!action || !snapshot.availableActions[action]) return null;
1237
+ return (
1238
+ <Button
1239
+ type="button"
1240
+ variant="ghost"
1241
+ className="vgai-chat-icon"
1242
+ aria-label={lifecycle.controlActionLabel ?? 'Session action'}
1243
+ title={
1244
+ action === 'detach'
1245
+ ? 'Disconnect the editor; the terminal session keeps running'
1246
+ : 'Open this same live chat in a terminal'
1247
+ }
1248
+ onClick={action === 'detach' ? onDetach : onOpenTerminal}
1249
+ >
1250
+ <EditorIcon icon={faTerminal} />
1251
+ </Button>
1252
+ );
1253
+ }
1254
+
1255
+ function Composer({
1256
+ snapshot,
1257
+ harness,
1258
+ session,
1259
+ draft,
1260
+ busy,
1261
+ queued,
1262
+ contexts,
1263
+ selectedContexts,
1264
+ onToggleContext,
1265
+ onDraft,
1266
+ onSend,
1267
+ onQueue,
1268
+ onRemoveQueued,
1269
+ onInterruptAndSend,
1270
+ onInterrupt,
1271
+ onDetach,
1272
+ onOpenTerminal,
1273
+ onBranch,
1274
+ onResume,
1275
+ onAttach,
1276
+ onOpenSessions,
1277
+ }: {
1278
+ snapshot: HarnessChatSnapshot;
1279
+ harness: HarnessChatHarness | null;
1280
+ session: HarnessChatSession | null;
1281
+ draft: string;
1282
+ busy: boolean;
1283
+ queued: OutgoingPrompt[];
1284
+ contexts: ContextCandidate[];
1285
+ selectedContexts: Set<string>;
1286
+ onToggleContext: (id: string) => void;
1287
+ onDraft: (value: string) => void;
1288
+ onSend: () => void;
1289
+ onQueue: () => void;
1290
+ onRemoveQueued: (index: number) => void;
1291
+ onInterruptAndSend: () => void;
1292
+ onInterrupt: () => void;
1293
+ onDetach: () => void;
1294
+ onOpenTerminal: () => void;
1295
+ onBranch: () => void;
1296
+ onResume: () => void;
1297
+ onAttach: () => void;
1298
+ onOpenSessions: () => void;
1299
+ }) {
1300
+ const observing = snapshot.mode === 'observe';
1301
+ const working = snapshot.turn.state !== 'idle';
1302
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
1303
+ useEffect(() => {
1304
+ const textarea = textareaRef.current;
1305
+ if (!textarea) return;
1306
+ textarea.style.height = 'auto';
1307
+ textarea.style.height = `${Math.min(textarea.scrollHeight, 170)}px`;
1308
+ }, [draft]);
1309
+ const disabled =
1310
+ observing || (snapshot.mode === 'control' && !snapshot.availableActions.send && !working);
1311
+ const agentLabel = harness?.label ?? snapshot.activeHarness ?? 'Agent';
1312
+ return (
1313
+ <div data-testid="harness-chat-composer" style={{ padding: `0 ${CHAT_GUTTER}px 14px` }}>
1314
+ {queued.length > 0 && (
1315
+ <div
1316
+ role="status"
1317
+ style={{
1318
+ padding: '5px 3px',
1319
+ color: themeVars.content.dim,
1320
+ fontSize: 'var(--vgai-font-sm)',
1321
+ }}
1322
+ >
1323
+ <div style={{ marginBottom: 3 }}>
1324
+ {queued.length} queued message{queued.length === 1 ? '' : 's'}
1325
+ </div>
1326
+ {queued.map((message, index) => (
1327
+ <div
1328
+ key={`${index}:${message.text}`}
1329
+ style={{ display: 'flex', alignItems: 'center', gap: 5 }}
1330
+ >
1331
+ <span
1332
+ style={{
1333
+ minWidth: 0,
1334
+ flex: 1,
1335
+ overflow: 'hidden',
1336
+ textOverflow: 'ellipsis',
1337
+ whiteSpace: 'nowrap',
1338
+ }}
1339
+ >
1340
+ {message.text}
1341
+ </span>
1342
+ <Button
1343
+ type="button"
1344
+ variant="ghost"
1345
+ size="compact"
1346
+ className="vgai-chat-icon"
1347
+ aria-label={`Remove queued message ${index + 1}`}
1348
+ onClick={() => onRemoveQueued(index)}
1349
+ >
1350
+ <EditorIcon icon={faXmark} />
1351
+ </Button>
1352
+ </div>
1353
+ ))}
1354
+ </div>
1355
+ )}
1356
+ <div
1357
+ style={{
1358
+ border: `1px solid ${observing ? themeVars.boundary.default : themeVars.boundary.strong}`,
1359
+ borderRadius: themeVars.shape.large,
1360
+ background: themeVars.surface.chrome,
1361
+ boxShadow: observing ? 'none' : themeVars.elevation.small,
1362
+ overflow: 'hidden',
1363
+ }}
1364
+ >
1365
+ {observing && (
1366
+ <ObserveToolbar
1367
+ snapshot={snapshot}
1368
+ busy={busy}
1369
+ onBranch={onBranch}
1370
+ onAttach={onAttach}
1371
+ onResume={onResume}
1372
+ />
1373
+ )}
1374
+ {!observing && (
1375
+ <ContextChips
1376
+ candidates={contexts}
1377
+ selected={selectedContexts}
1378
+ onToggle={onToggleContext}
1379
+ />
1380
+ )}
1381
+ <textarea
1382
+ ref={textareaRef}
1383
+ aria-label="Message coding agent"
1384
+ placeholder={
1385
+ observing
1386
+ ? 'Connect or resume to reply'
1387
+ : working
1388
+ ? 'Queue a follow-up…'
1389
+ : 'Ask the agent to build something…'
1390
+ }
1391
+ value={draft}
1392
+ disabled={disabled}
1393
+ onChange={(event) => onDraft(event.target.value)}
1394
+ onKeyDown={(event: KeyboardEvent<HTMLTextAreaElement>) => {
1395
+ if (event.key !== 'Enter' || event.shiftKey) return;
1396
+ event.preventDefault();
1397
+ if ((event.metaKey || event.ctrlKey) && working) onInterruptAndSend();
1398
+ else if (event.altKey || working) onQueue();
1399
+ else onSend();
1400
+ }}
1401
+ style={{
1402
+ boxSizing: 'border-box',
1403
+ display: 'block',
1404
+ width: '100%',
1405
+ minHeight: observing ? 48 : 64,
1406
+ maxHeight: 170,
1407
+ overflowY: 'auto',
1408
+ resize: 'none',
1409
+ padding: '9px 11px 4px',
1410
+ color: themeVars.content.primary,
1411
+ background: 'transparent',
1412
+ border: 0,
1413
+ font: 'inherit',
1414
+ lineHeight: 1.45,
1415
+ outline: 'none',
1416
+ opacity: observing ? 0.65 : 1,
1417
+ }}
1418
+ />
1419
+ <div
1420
+ style={{
1421
+ minHeight: 34,
1422
+ padding: '3px 6px 6px 9px',
1423
+ display: 'flex',
1424
+ alignItems: 'center',
1425
+ gap: 5,
1426
+ }}
1427
+ >
1428
+ <Button
1429
+ type="button"
1430
+ variant="ghost"
1431
+ size="compact"
1432
+ className="vgai-chat-harness-trigger"
1433
+ aria-label="Change coding harness"
1434
+ title={
1435
+ harness
1436
+ ? `${harness.label}: ${authSummary(harness)}, ${runtimeSummary(harness)}`
1437
+ : 'Choose harness or chat'
1438
+ }
1439
+ onClick={onOpenSessions}
1440
+ >
1441
+ {harness && (
1442
+ <span
1443
+ aria-hidden
1444
+ style={{
1445
+ width: 6,
1446
+ height: 6,
1447
+ flexShrink: 0,
1448
+ borderRadius: '50%',
1449
+ background: harnessReadinessColor(harness),
1450
+ }}
1451
+ />
1452
+ )}
1453
+ <span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{agentLabel}</span>
1454
+ {session?.model && (
1455
+ <span
1456
+ style={{
1457
+ color: themeVars.content.dim,
1458
+ overflow: 'hidden',
1459
+ textOverflow: 'ellipsis',
1460
+ }}
1461
+ >
1462
+ · {session.model}
1463
+ </span>
1464
+ )}
1465
+ <span style={{ color: themeVars.content.dim }}>· YOLO</span>
1466
+ <EditorIcon
1467
+ icon={faChevronDown}
1468
+ style={{ color: themeVars.content.dim, fontSize: 8 }}
1469
+ />
1470
+ </Button>
1471
+ {snapshot.mode === 'control' && (
1472
+ <SessionControlButton
1473
+ snapshot={snapshot}
1474
+ onDetach={onDetach}
1475
+ onOpenTerminal={onOpenTerminal}
1476
+ />
1477
+ )}
1478
+ <span style={{ flex: 1 }} />
1479
+ {working && (
1480
+ <Button
1481
+ type="button"
1482
+ variant="secondary"
1483
+ className="vgai-chat-icon"
1484
+ aria-label="Stop agent"
1485
+ disabled={!snapshot.availableActions.interrupt}
1486
+ onClick={onInterrupt}
1487
+ >
1488
+ <EditorIcon icon={faStop} />
1489
+ </Button>
1490
+ )}
1491
+ {!observing && (
1492
+ <Button
1493
+ type="button"
1494
+ variant="primary"
1495
+ className="vgai-chat-icon"
1496
+ data-testid="harness-chat-send"
1497
+ aria-label={working ? 'Queue message' : 'Send message'}
1498
+ disabled={
1499
+ busy ||
1500
+ !draft.trim() ||
1501
+ !harness ||
1502
+ (snapshot.mode === 'none' && !harness.availableActions.start)
1503
+ }
1504
+ onClick={working ? onQueue : onSend}
1505
+ >
1506
+ <EditorIcon icon={faArrowUp} />
1507
+ </Button>
1508
+ )}
1509
+ </div>
1510
+ </div>
1511
+ </div>
1512
+ );
1513
+ }
1514
+
1515
+ function ResumeWarning({
1516
+ checked,
1517
+ onChecked,
1518
+ onCancel,
1519
+ onConfirm,
1520
+ }: {
1521
+ checked: boolean;
1522
+ onChecked: (checked: boolean) => void;
1523
+ onCancel: () => void;
1524
+ onConfirm: () => void;
1525
+ }) {
1526
+ return (
1527
+ <div
1528
+ role="alertdialog"
1529
+ aria-label="Resume coding session here"
1530
+ style={{
1531
+ margin: `0 ${CHAT_GUTTER}px 10px`,
1532
+ padding: 10,
1533
+ border: `1px solid ${themeVars.boundary.strong}`,
1534
+ borderRadius: themeVars.shape.medium,
1535
+ background: themeVars.surface.chrome,
1536
+ boxShadow: themeVars.elevation.small,
1537
+ }}
1538
+ >
1539
+ <div style={{ color: themeVars.content.primary, fontWeight: 600, marginBottom: 4 }}>
1540
+ Resume this chat here?
1541
+ </div>
1542
+ <div
1543
+ style={{
1544
+ color: themeVars.content.muted,
1545
+ fontSize: 'var(--vgai-font-sm)',
1546
+ lineHeight: 1.45,
1547
+ }}
1548
+ >
1549
+ This starts a local harness process from the saved conversation. If another client still
1550
+ controls it, both processes may edit the project.
1551
+ </div>
1552
+ <label
1553
+ style={{
1554
+ display: 'flex',
1555
+ alignItems: 'center',
1556
+ gap: 6,
1557
+ marginTop: 8,
1558
+ color: themeVars.content.muted,
1559
+ }}
1560
+ >
1561
+ <Checkbox checked={checked} onChange={(event) => onChecked(event.target.checked)} />
1562
+ Don’t show again
1563
+ </label>
1564
+ <div style={{ display: 'flex', justifyContent: 'flex-end', gap: 6, marginTop: 9 }}>
1565
+ <Button type="button" variant="outline" size="compact" onClick={onCancel}>
1566
+ Cancel
1567
+ </Button>
1568
+ <Button
1569
+ type="button"
1570
+ variant="primary"
1571
+ size="compact"
1572
+ data-testid="harness-chat-confirm-resume"
1573
+ onClick={onConfirm}
1574
+ >
1575
+ Resume here
1576
+ </Button>
1577
+ </div>
1578
+ </div>
1579
+ );
1580
+ }
1581
+
1582
+ function operationLabel(operation: HarnessChatSnapshot['operation']): string | null {
1583
+ if (!operation) return null;
1584
+ const labels: Record<Exclude<HarnessChatSnapshot['operation'], null>, string> = {
1585
+ refresh: 'Refreshing chats…',
1586
+ observe: 'Opening chat…',
1587
+ start: 'Starting agent…',
1588
+ resume: 'Resuming chat…',
1589
+ attach: 'Connecting live…',
1590
+ branch: 'Starting branch…',
1591
+ restore: 'Restoring chat…',
1592
+ detach: 'Disconnecting live chat…',
1593
+ openTerminal: 'Preparing terminal attachment…',
1594
+ send: 'Sending…',
1595
+ interrupt: 'Interrupting…',
1596
+ respond: 'Responding…',
1597
+ load: 'Loading…',
1598
+ import: 'Importing…',
1599
+ export: 'Exporting…',
1600
+ translate: 'Translating…',
1601
+ handoff: 'Preparing handoff…',
1602
+ workspace: 'Switching project…',
1603
+ close: 'Closing…',
1604
+ };
1605
+ return labels[operation];
1606
+ }
1607
+
1608
+ function completedOperationLabel(
1609
+ operation: Exclude<HarnessChatSnapshot['operation'], null>,
1610
+ ): string {
1611
+ const labels: Record<Exclude<HarnessChatSnapshot['operation'], null>, string> = {
1612
+ refresh: 'Chats refreshed',
1613
+ observe: 'Chat opened',
1614
+ start: 'Agent started',
1615
+ resume: 'Chat resumed',
1616
+ attach: 'Connected live',
1617
+ branch: 'Branch started',
1618
+ restore: 'Chat restored',
1619
+ detach: 'Live chat disconnected',
1620
+ openTerminal: 'Terminal attachment ready',
1621
+ send: 'Message sent',
1622
+ interrupt: 'Agent interrupted',
1623
+ respond: 'Response sent',
1624
+ load: 'Session loaded',
1625
+ import: 'Session imported',
1626
+ export: 'Session exported',
1627
+ translate: 'Session translated',
1628
+ handoff: 'Handoff prepared',
1629
+ workspace: 'Project switched',
1630
+ close: 'Session closed',
1631
+ };
1632
+ return labels[operation];
1633
+ }
1634
+
1635
+ function actionOperation(
1636
+ action: HarnessChatAction,
1637
+ ): Exclude<HarnessChatSnapshot['operation'], null> {
1638
+ if (action.type === 'create') return 'start';
1639
+ if (action.type === 'prompt') return 'send';
1640
+ return action.type;
1641
+ }
1642
+
1643
+ function outgoingPrompt(
1644
+ value: string,
1645
+ candidates: ContextCandidate[],
1646
+ selected: Set<string>,
1647
+ ): OutgoingPrompt {
1648
+ return {
1649
+ text: value,
1650
+ context: candidates.filter((candidate) => selected.has(candidate.id)),
1651
+ };
1652
+ }
1653
+
1654
+ interface OperationFeedback {
1655
+ operation: Exclude<HarnessChatSnapshot['operation'], null>;
1656
+ phase: 'running' | 'completed';
1657
+ }
1658
+
1659
+ export function HarnessChatPanel() {
1660
+ useSyncExternalStore(subscribeHarnessChatLayout, harnessChatLayoutVersion);
1661
+ useSyncExternalStore(subscribeWorkspaceDocuments, workspaceDocumentRegistryVersion);
1662
+ useSyncExternalStore(editorConsole.subscribe, editorConsole.getSnapshot);
1663
+ useSyncExternalStore(
1664
+ subscribeHarnessChatEditorStore,
1665
+ harnessChatEditorStoreVersion,
1666
+ harnessChatEditorStoreVersion,
1667
+ );
1668
+ const store = harnessChatEditorStore();
1669
+ useSyncExternalStore(
1670
+ store?.subscribe ?? noopSubscribe,
1671
+ store?.getSnapshot ?? zeroSnapshot,
1672
+ zeroSnapshot,
1673
+ );
1674
+ const layout = harnessChat();
1675
+ const [snapshot, setSnapshot] = useState<HarnessChatSnapshot>(blankSnapshot);
1676
+ const snapshotRef = useRef(snapshot);
1677
+ const [drafts, setDrafts] = useState<Record<string, string>>({});
1678
+ const [selectedHarnessId, setSelectedHarnessId] = useState('');
1679
+ const [sessionsOpen, setSessionsOpen] = useState(false);
1680
+ const [pendingNew, setPendingNew] = useState(false);
1681
+ const [queues, setQueues] = useState<Record<string, OutgoingPrompt[]>>({});
1682
+ const [interruptPrompts, setInterruptPrompts] = useState<Record<string, OutgoingPrompt | null>>(
1683
+ {},
1684
+ );
1685
+ const [contextsBySession, setContextsBySession] = useState<Record<string, string[]>>({});
1686
+ const [actionError, setActionError] = useState<string | null>(null);
1687
+ const [operationFeedback, setOperationFeedback] = useState<OperationFeedback | null>(null);
1688
+ const [dismissedErrorKey, setDismissedErrorKey] = useState<string | null>(null);
1689
+ const [actionInFlight, setActionInFlight] = useState(false);
1690
+ const [terminalCommandCopied, setTerminalCommandCopied] = useState(false);
1691
+ const [resumeCandidate, setResumeCandidate] = useState<string | null>(null);
1692
+ const [skipResumeWarning, setSkipResumeWarning] = useState(false);
1693
+ const [showJump, setShowJump] = useState(false);
1694
+ const messagesRef = useRef<HTMLDivElement>(null);
1695
+ const actionInFlightRef = useRef(false);
1696
+ const actionAttemptRef = useRef(0);
1697
+ const operationFeedbackTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
1698
+ const stickToBottomRef = useRef(true);
1699
+
1700
+ useEffect(
1701
+ () => () => {
1702
+ if (operationFeedbackTimerRef.current) clearTimeout(operationFeedbackTimerRef.current);
1703
+ },
1704
+ [],
1705
+ );
1706
+
1707
+ const acceptSnapshot = useCallback((next: HarnessChatSnapshot) => {
1708
+ if (!shouldAcceptHarnessChatSnapshot(snapshotRef.current, next)) return;
1709
+ snapshotRef.current = next;
1710
+ setSnapshot(next);
1711
+ }, []);
1712
+
1713
+ useEffect(() => {
1714
+ let live = true;
1715
+ void getHarnessChatSnapshot().then((next) => {
1716
+ if (live) acceptSnapshot(next);
1717
+ });
1718
+ const disconnect = connectHarnessChatEvents((next) => {
1719
+ if (live) acceptSnapshot(next);
1720
+ });
1721
+ return () => {
1722
+ live = false;
1723
+ disconnect();
1724
+ };
1725
+ }, [acceptSnapshot]);
1726
+
1727
+ useEffect(() => {
1728
+ if (!selectedHarnessId) {
1729
+ const candidate = selectedHarness(snapshot, '');
1730
+ if (candidate) setSelectedHarnessId(candidate.id);
1731
+ }
1732
+ }, [snapshot, selectedHarnessId]);
1733
+
1734
+ useEffect(() => updateHarnessChatIndicator(snapshot), [snapshot]);
1735
+
1736
+ useEffect(() => {
1737
+ actionAttemptRef.current += 1;
1738
+ actionInFlightRef.current = false;
1739
+ setActionInFlight(false);
1740
+ setActionError(null);
1741
+ if (operationFeedbackTimerRef.current) clearTimeout(operationFeedbackTimerRef.current);
1742
+ operationFeedbackTimerRef.current = null;
1743
+ setOperationFeedback(null);
1744
+ }, [snapshot.workspaceGeneration]);
1745
+
1746
+ const currentSession = useMemo(
1747
+ () => snapshot.sessions.find((item) => item.id === snapshot.activeSessionId) ?? null,
1748
+ [snapshot.sessions, snapshot.activeSessionId],
1749
+ );
1750
+ const newSessionHarness = selectedHarness(snapshot, selectedHarnessId);
1751
+ const activeHarness =
1752
+ snapshot.harnesses.find((item) => item.id === snapshot.activeHarness) ?? null;
1753
+ const harness =
1754
+ pendingNew || snapshot.mode === 'none'
1755
+ ? newSessionHarness
1756
+ : (activeHarness ?? newSessionHarness);
1757
+ const workspaceKey = snapshot.workspace ?? 'no-workspace';
1758
+ const sessionKey = pendingNew
1759
+ ? `new:${newSessionHarness?.id ?? 'agent'}`
1760
+ : (snapshot.activeSessionId ?? `new:${harness?.id ?? 'agent'}`);
1761
+ const draftKey = `${workspaceKey}:${sessionKey}`;
1762
+ const draft = drafts[draftKey] ?? '';
1763
+ const queued = queues[draftKey] ?? [];
1764
+ const interruptPrompt = interruptPrompts[draftKey] ?? null;
1765
+ const selectedContexts = new Set(contextsBySession[draftKey] ?? []);
1766
+ const busy = snapshot.operation !== null || actionInFlight;
1767
+ const lifecycle = harnessChatLifecycle(snapshot);
1768
+
1769
+ useEffect(() => {
1770
+ stickToBottomRef.current = true;
1771
+ setShowJump(false);
1772
+ const element = messagesRef.current;
1773
+ if (element) element.scrollTop = element.scrollHeight;
1774
+ }, [draftKey]);
1775
+
1776
+ useEffect(() => {
1777
+ const element = messagesRef.current;
1778
+ if (element && stickToBottomRef.current) element.scrollTop = element.scrollHeight;
1779
+ }, [snapshot.conversation, snapshot.turn.state]);
1780
+
1781
+ const contextCandidates = useMemo<ContextCandidate[]>(() => {
1782
+ const result: ContextCandidate[] = [];
1783
+ const entity = store?.selectedEntity;
1784
+ if (entity)
1785
+ result.push({
1786
+ id: 'selection',
1787
+ label: 'Selection',
1788
+ detail: `Selected entity: ${entity.name ?? 'Unnamed'} (${entity.id ?? 'no id'})`,
1789
+ });
1790
+ const document = activeWorkspaceDocument();
1791
+ if (document) {
1792
+ const path = document.descriptor.provenance?.sourcePath;
1793
+ result.push({
1794
+ id: 'document',
1795
+ label: 'Open document',
1796
+ detail: `Open document: ${document.title}${path ? ` (${path})` : ''}`,
1797
+ });
1798
+ }
1799
+ const lastError = [...editorConsole.getEntries()]
1800
+ .reverse()
1801
+ .find((entry) => entry.level === 'error');
1802
+ if (lastError)
1803
+ result.push({
1804
+ id: 'console-error',
1805
+ label: 'Console error',
1806
+ detail: `Console error: ${lastError.message}`,
1807
+ });
1808
+ if (store?.playState && store.playState !== 'stopped')
1809
+ result.push({
1810
+ id: 'play-state',
1811
+ label: 'Play state',
1812
+ detail: `Game play state: ${store.playState}`,
1813
+ });
1814
+ return result;
1815
+ }, [
1816
+ store,
1817
+ store?.selectedEntityId,
1818
+ store?.playState,
1819
+ workspaceDocumentRegistryVersion(),
1820
+ editorConsole.getSnapshot(),
1821
+ ]);
1822
+
1823
+ const setDraft = (value: string) => setDrafts((current) => ({ ...current, [draftKey]: value }));
1824
+ const setSelectedContexts = (next: Set<string>) =>
1825
+ setContextsBySession((current) => ({ ...current, [draftKey]: [...next] }));
1826
+ const setQueued = (update: (current: OutgoingPrompt[]) => OutgoingPrompt[]) =>
1827
+ setQueues((current) => ({ ...current, [draftKey]: update(current[draftKey] ?? []) }));
1828
+ const setInterruptPrompt = (value: OutgoingPrompt | null) =>
1829
+ setInterruptPrompts((current) => ({ ...current, [draftKey]: value }));
1830
+
1831
+ const runAction = useCallback(
1832
+ async (action: HarnessChatAction): Promise<HarnessChatSnapshot | null> => {
1833
+ if (actionInFlightRef.current) return null;
1834
+ const operation = actionOperation(action);
1835
+ const workspaceGeneration = snapshotRef.current.workspaceGeneration;
1836
+ const attempt = ++actionAttemptRef.current;
1837
+ const isCurrentAttempt = () =>
1838
+ actionAttemptRef.current === attempt &&
1839
+ snapshotRef.current.workspaceGeneration === workspaceGeneration;
1840
+ actionInFlightRef.current = true;
1841
+ setActionInFlight(true);
1842
+ setActionError(null);
1843
+ if (operationFeedbackTimerRef.current) {
1844
+ clearTimeout(operationFeedbackTimerRef.current);
1845
+ operationFeedbackTimerRef.current = null;
1846
+ }
1847
+ setOperationFeedback({ operation, phase: 'running' });
1848
+ try {
1849
+ const next = await performHarnessChatAction(action);
1850
+ if (!isCurrentAttempt()) return null;
1851
+ acceptSnapshot(next);
1852
+ setOperationFeedback({ operation, phase: 'completed' });
1853
+ operationFeedbackTimerRef.current = setTimeout(() => {
1854
+ setOperationFeedback(null);
1855
+ operationFeedbackTimerRef.current = null;
1856
+ }, 1_800);
1857
+ return next;
1858
+ } catch (error) {
1859
+ if (!isCurrentAttempt()) return null;
1860
+ if (error instanceof HarnessChatRequestError && error.snapshot) {
1861
+ acceptSnapshot(error.snapshot);
1862
+ }
1863
+ setActionError(error instanceof Error ? error.message : String(error));
1864
+ setOperationFeedback(null);
1865
+ return null;
1866
+ } finally {
1867
+ if (actionAttemptRef.current === attempt) {
1868
+ actionInFlightRef.current = false;
1869
+ setActionInFlight(false);
1870
+ }
1871
+ }
1872
+ },
1873
+ [acceptSnapshot],
1874
+ );
1875
+
1876
+ useHarnessChatReconnect(snapshot, currentSession, actionInFlight, runAction);
1877
+
1878
+ const sendValue = useCallback(
1879
+ async (value: OutgoingPrompt): Promise<boolean> => {
1880
+ if (!value.text.trim() || !harness) return false;
1881
+ if (
1882
+ (pendingNew || snapshotRef.current.mode === 'none') &&
1883
+ !(await runAction({ type: 'create', harness: harness.id }))
1884
+ )
1885
+ return false;
1886
+ const sent = await runAction({
1887
+ type: 'prompt',
1888
+ text: value.text,
1889
+ ...(value.context.length > 0 ? { context: value.context } : {}),
1890
+ });
1891
+ if (sent) setPendingNew(false);
1892
+ return Boolean(sent);
1893
+ },
1894
+ [harness, pendingNew, runAction],
1895
+ );
1896
+
1897
+ const send = async () => {
1898
+ const value = outgoingPrompt(draft.trim(), contextCandidates, selectedContexts);
1899
+ if (await sendValue(value)) {
1900
+ setDraft('');
1901
+ setSelectedContexts(new Set());
1902
+ }
1903
+ };
1904
+
1905
+ const queueDraft = () => {
1906
+ const value = outgoingPrompt(draft.trim(), contextCandidates, selectedContexts);
1907
+ if (!value.text) return;
1908
+ setQueued((current) => [...current, value]);
1909
+ setDraft('');
1910
+ setSelectedContexts(new Set());
1911
+ };
1912
+
1913
+ const interruptAndSend = () => {
1914
+ const value = outgoingPrompt(draft.trim(), contextCandidates, selectedContexts);
1915
+ if (!value.text || !snapshot.availableActions.interrupt) return;
1916
+ setInterruptPrompt(value);
1917
+ setDraft('');
1918
+ setSelectedContexts(new Set());
1919
+ void runAction({ type: 'interrupt' });
1920
+ };
1921
+
1922
+ useEffect(() => {
1923
+ if (snapshot.turn.state !== 'idle' || busy || snapshot.mode !== 'control') return;
1924
+ const next = interruptPrompt ?? queued[0];
1925
+ if (!next) return;
1926
+ let active = true;
1927
+ void sendValue(next).then((sent) => {
1928
+ if (!active || !sent) return;
1929
+ if (interruptPrompt) setInterruptPrompt(null);
1930
+ else setQueued((current) => current.slice(1));
1931
+ });
1932
+ return () => {
1933
+ active = false;
1934
+ };
1935
+ }, [snapshot.turn.state, snapshot.mode, busy, interruptPrompt, queued, sendValue]);
1936
+
1937
+ const requestResume = (sessionId: string) => {
1938
+ let skip = false;
1939
+ try {
1940
+ skip = localStorage.getItem(RESUME_WARNING_KEY) === '1';
1941
+ } catch {
1942
+ /* optional */
1943
+ }
1944
+ if (skip) void runAction({ type: 'resume', sessionId });
1945
+ else setResumeCandidate(sessionId);
1946
+ };
1947
+
1948
+ const confirmResume = async () => {
1949
+ if (!resumeCandidate) return;
1950
+ if (skipResumeWarning) {
1951
+ try {
1952
+ localStorage.setItem(RESUME_WARNING_KEY, '1');
1953
+ } catch {
1954
+ /* optional */
1955
+ }
1956
+ }
1957
+ const sessionId = resumeCandidate;
1958
+ setResumeCandidate(null);
1959
+ await runAction({ type: 'resume', sessionId });
1960
+ };
1961
+
1962
+ if (layout.collapsed) return null;
1963
+
1964
+ const startNew = () => {
1965
+ setSessionsOpen(false);
1966
+ setPendingNew(true);
1967
+ setSelectedContexts(new Set());
1968
+ };
1969
+ const observe = (sessionId: string) => {
1970
+ setSessionsOpen(false);
1971
+ setPendingNew(false);
1972
+ void runAction({ type: 'observe', sessionId });
1973
+ };
1974
+ const immediateOperation =
1975
+ operationFeedback?.phase === 'running' ? operationFeedback.operation : null;
1976
+ const opLabel = operationLabel(snapshot.operation ?? immediateOperation);
1977
+ const completedOpLabel =
1978
+ !snapshot.operation && operationFeedback?.phase === 'completed'
1979
+ ? completedOperationLabel(operationFeedback.operation)
1980
+ : null;
1981
+ const snapshotErrorKey = snapshot.error
1982
+ ? `${snapshot.workspaceGeneration}:${snapshot.error.code}:${snapshot.error.operation}:${snapshot.error.message}`
1983
+ : null;
1984
+ const visibleSnapshotError =
1985
+ snapshot.error && snapshotErrorKey !== dismissedErrorKey ? snapshot.error : null;
1986
+ const canReconnectLive =
1987
+ snapshot.mode === 'observe' &&
1988
+ Boolean(currentSession?.attachable) &&
1989
+ snapshot.availableActions.attach;
1990
+
1991
+ return (
1992
+ <div
1993
+ data-testid="harness-chat"
1994
+ style={{
1995
+ width: '100%',
1996
+ height: '100%',
1997
+ minHeight: 0,
1998
+ display: 'flex',
1999
+ background: themeVars.surface.panel,
2000
+ pointerEvents: 'auto',
2001
+ position: 'relative',
2002
+ }}
2003
+ >
2004
+ <div
2005
+ style={{
2006
+ flex: 1,
2007
+ minWidth: 0,
2008
+ minHeight: 0,
2009
+ display: 'flex',
2010
+ flexDirection: 'column',
2011
+ position: 'relative',
2012
+ }}
2013
+ >
2014
+ <header
2015
+ style={{
2016
+ height: 40,
2017
+ padding: '0 8px 0 12px',
2018
+ display: 'flex',
2019
+ alignItems: 'center',
2020
+ gap: 2,
2021
+ borderBottom: `1px solid ${themeVars.boundary.default}`,
2022
+ background: themeVars.surface.chrome,
2023
+ position: 'relative',
2024
+ }}
2025
+ >
2026
+ <Button
2027
+ type="button"
2028
+ variant="ghost"
2029
+ className="vgai-chat-header-trigger"
2030
+ onClick={() => setSessionsOpen((open) => !open)}
2031
+ aria-expanded={sessionsOpen}
2032
+ title="Chats"
2033
+ >
2034
+ <span
2035
+ aria-hidden
2036
+ style={{
2037
+ width: 6,
2038
+ height: 6,
2039
+ borderRadius: '50%',
2040
+ background: statusColor(snapshot),
2041
+ }}
2042
+ />
2043
+ <span
2044
+ style={{
2045
+ minWidth: 0,
2046
+ flex: 1,
2047
+ overflow: 'hidden',
2048
+ textOverflow: 'ellipsis',
2049
+ whiteSpace: 'nowrap',
2050
+ fontSize: 'var(--vgai-font-base)',
2051
+ fontWeight: 600,
2052
+ }}
2053
+ >
2054
+ {conversationTitle(currentSession, snapshot.conversation, pendingNew)}
2055
+ </span>
2056
+ {!pendingNew && snapshot.activeSessionId && (
2057
+ <span
2058
+ data-testid="harness-chat-lifecycle"
2059
+ title={lifecycle.detail}
2060
+ style={{
2061
+ flexShrink: 0,
2062
+ padding: '2px 5px',
2063
+ borderRadius: themeVars.shape.small,
2064
+ color: lifecycleColor(snapshot),
2065
+ background: themeVars.surface.inset,
2066
+ fontSize: 9,
2067
+ fontWeight: 600,
2068
+ letterSpacing: '0.02em',
2069
+ }}
2070
+ >
2071
+ {lifecycle.label}
2072
+ </span>
2073
+ )}
2074
+ <EditorIcon
2075
+ icon={faChevronDown}
2076
+ style={{ color: themeVars.content.dim, fontSize: 9 }}
2077
+ />
2078
+ </Button>
2079
+ <Button
2080
+ type="button"
2081
+ variant="ghost"
2082
+ className="vgai-chat-icon"
2083
+ aria-label="New chat"
2084
+ title="New chat"
2085
+ disabled={!newSessionHarness?.availableActions.start}
2086
+ onClick={startNew}
2087
+ >
2088
+ <EditorIcon icon={faPlus} />
2089
+ </Button>
2090
+ <Button
2091
+ type="button"
2092
+ variant="ghost"
2093
+ className="vgai-chat-icon"
2094
+ aria-label="Chat history"
2095
+ title="Chat history"
2096
+ onClick={() => setSessionsOpen((open) => !open)}
2097
+ >
2098
+ <EditorIcon icon={faHistory} />
2099
+ </Button>
2100
+ <Button
2101
+ type="button"
2102
+ variant="ghost"
2103
+ className="vgai-chat-icon"
2104
+ aria-label="Refresh coding sessions"
2105
+ title="Refresh chats"
2106
+ disabled={!snapshot.availableActions.refresh}
2107
+ onClick={() => void runAction({ type: 'refresh' })}
2108
+ >
2109
+ <EditorIcon icon={faRotate} />
2110
+ </Button>
2111
+ <Button
2112
+ type="button"
2113
+ variant="ghost"
2114
+ className="vgai-chat-icon"
2115
+ data-testid="harness-chat-collapse"
2116
+ aria-label="Collapse Chat"
2117
+ title="Collapse Chat (session keeps running)"
2118
+ onClick={toggleHarnessChatCollapsed}
2119
+ >
2120
+ <EditorIcon icon={faChevronLeft} />
2121
+ </Button>
2122
+ {sessionsOpen && (
2123
+ <SessionPicker
2124
+ snapshot={snapshot}
2125
+ harness={newSessionHarness}
2126
+ selectedHarnessId={selectedHarnessId}
2127
+ onHarness={setSelectedHarnessId}
2128
+ onNew={startNew}
2129
+ onObserve={observe}
2130
+ onClose={() => setSessionsOpen(false)}
2131
+ />
2132
+ )}
2133
+ </header>
2134
+
2135
+ {(opLabel || completedOpLabel) && (
2136
+ <div
2137
+ role="status"
2138
+ data-testid="harness-chat-operation-status"
2139
+ style={{
2140
+ padding: '5px 10px',
2141
+ color: completedOpLabel ? themeVars.semantic.success : themeVars.content.muted,
2142
+ fontSize: 'var(--vgai-font-sm)',
2143
+ background: themeVars.surface.chrome,
2144
+ }}
2145
+ >
2146
+ <EditorIcon
2147
+ icon={completedOpLabel ? faCheck : faCircleNotch}
2148
+ spin={!completedOpLabel}
2149
+ style={{
2150
+ marginRight: 6,
2151
+ color: completedOpLabel ? themeVars.semantic.success : themeVars.accent.default,
2152
+ }}
2153
+ />
2154
+ {completedOpLabel ?? opLabel}
2155
+ </div>
2156
+ )}
2157
+ {(actionError || visibleSnapshotError) && (
2158
+ <div
2159
+ role="alert"
2160
+ style={{
2161
+ display: 'flex',
2162
+ alignItems: 'center',
2163
+ gap: 6,
2164
+ padding: '6px 10px',
2165
+ color: themeVars.semantic.danger,
2166
+ fontSize: 'var(--vgai-font-sm)',
2167
+ }}
2168
+ >
2169
+ <span style={{ minWidth: 0, flex: 1 }}>
2170
+ {actionError ?? visibleSnapshotError?.message}
2171
+ </span>
2172
+ {visibleSnapshotError?.recoverable && (
2173
+ <Button
2174
+ type="button"
2175
+ variant="outline"
2176
+ size="compact"
2177
+ onClick={() =>
2178
+ void runAction(
2179
+ canReconnectLive && snapshot.activeSessionId
2180
+ ? { type: 'attach', sessionId: snapshot.activeSessionId }
2181
+ : { type: 'refresh' },
2182
+ )
2183
+ }
2184
+ >
2185
+ {canReconnectLive ? 'Reconnect live' : 'Retry'}
2186
+ </Button>
2187
+ )}
2188
+ <Button
2189
+ type="button"
2190
+ variant="ghost"
2191
+ size="compact"
2192
+ className="vgai-chat-icon"
2193
+ aria-label="Dismiss agent error"
2194
+ onClick={() => {
2195
+ setActionError(null);
2196
+ setDismissedErrorKey(snapshotErrorKey);
2197
+ }}
2198
+ >
2199
+ <EditorIcon icon={faXmark} />
2200
+ </Button>
2201
+ </div>
2202
+ )}
2203
+ {harness?.repair && (!harness.installed || harness.auth !== 'ready') && (
2204
+ <div
2205
+ role={harness.auth === 'required' ? 'alert' : 'status'}
2206
+ style={{
2207
+ padding: '6px 10px',
2208
+ color: themeVars.semantic.warning,
2209
+ fontSize: 'var(--vgai-font-sm)',
2210
+ }}
2211
+ >
2212
+ <strong>
2213
+ {!harness.installed
2214
+ ? `${harness.label} is not installed. `
2215
+ : harness.auth === 'required'
2216
+ ? `${harness.label} sign-in required. `
2217
+ : `${harness.label} sign-in is not verified. `}
2218
+ </strong>
2219
+ {harness.repair}
2220
+ </div>
2221
+ )}
2222
+ {snapshot.terminalCommand && (
2223
+ <div
2224
+ style={{
2225
+ margin: `8px ${CHAT_GUTTER}px 0`,
2226
+ color: themeVars.content.muted,
2227
+ fontSize: 'var(--vgai-font-sm)',
2228
+ }}
2229
+ >
2230
+ <div style={{ display: 'flex', alignItems: 'center' }}>
2231
+ <span style={{ flex: 1 }}>Open this chat in terminal:</span>
2232
+ <Button
2233
+ type="button"
2234
+ variant="ghost"
2235
+ size="compact"
2236
+ className="vgai-chat-icon"
2237
+ aria-label={terminalCommandCopied ? 'Command copied' : 'Copy terminal command'}
2238
+ onClick={() => {
2239
+ void navigator.clipboard?.writeText(snapshot.terminalCommand!);
2240
+ setTerminalCommandCopied(true);
2241
+ setTimeout(() => setTerminalCommandCopied(false), 1_500);
2242
+ }}
2243
+ >
2244
+ <EditorIcon icon={terminalCommandCopied ? faCheck : faCopy} />
2245
+ </Button>
2246
+ </div>
2247
+ <code
2248
+ style={{
2249
+ display: 'block',
2250
+ marginTop: 4,
2251
+ padding: 7,
2252
+ borderRadius: themeVars.shape.small,
2253
+ background: themeVars.surface.inset,
2254
+ color: themeVars.content.primary,
2255
+ fontFamily: themeVars.typography.mono,
2256
+ overflowWrap: 'anywhere',
2257
+ }}
2258
+ >
2259
+ {snapshot.terminalCommand}
2260
+ </code>
2261
+ </div>
2262
+ )}
2263
+
2264
+ <Conversation
2265
+ snapshot={
2266
+ pendingNew
2267
+ ? { ...snapshot, conversation: [], requests: [], activeSessionId: null, mode: 'none' }
2268
+ : snapshot
2269
+ }
2270
+ messagesRef={messagesRef}
2271
+ onRespond={(action) => void runAction(action)}
2272
+ onScroll={() => {
2273
+ const element = messagesRef.current;
2274
+ if (!element) return;
2275
+ const nearBottom = element.scrollHeight - element.scrollTop - element.clientHeight < 36;
2276
+ stickToBottomRef.current = nearBottom;
2277
+ setShowJump(!nearBottom);
2278
+ }}
2279
+ />
2280
+
2281
+ {showJump && (
2282
+ <Button
2283
+ type="button"
2284
+ variant="secondary"
2285
+ size="compact"
2286
+ onClick={() => {
2287
+ const element = messagesRef.current;
2288
+ if (element) element.scrollTop = element.scrollHeight;
2289
+ stickToBottomRef.current = true;
2290
+ setShowJump(false);
2291
+ }}
2292
+ style={{ position: 'absolute', right: 14, bottom: 144, zIndex: 4 }}
2293
+ >
2294
+ <EditorIcon icon={faArrowDown} style={{ marginRight: 5 }} />
2295
+ Latest
2296
+ </Button>
2297
+ )}
2298
+
2299
+ {resumeCandidate && (
2300
+ <ResumeWarning
2301
+ checked={skipResumeWarning}
2302
+ onChecked={setSkipResumeWarning}
2303
+ onCancel={() => setResumeCandidate(null)}
2304
+ onConfirm={() => void confirmResume()}
2305
+ />
2306
+ )}
2307
+
2308
+ <Composer
2309
+ snapshot={pendingNew ? { ...snapshot, mode: 'none', activeSessionId: null } : snapshot}
2310
+ harness={harness}
2311
+ session={pendingNew ? null : currentSession}
2312
+ draft={draft}
2313
+ busy={busy}
2314
+ queued={queued}
2315
+ contexts={contextCandidates}
2316
+ selectedContexts={selectedContexts}
2317
+ onToggleContext={(id) => {
2318
+ const next = new Set(selectedContexts);
2319
+ if (next.has(id)) next.delete(id);
2320
+ else next.add(id);
2321
+ setSelectedContexts(next);
2322
+ }}
2323
+ onDraft={setDraft}
2324
+ onSend={() => void send()}
2325
+ onQueue={queueDraft}
2326
+ onRemoveQueued={(index) =>
2327
+ setQueued((current) => current.filter((_, itemIndex) => itemIndex !== index))
2328
+ }
2329
+ onInterruptAndSend={interruptAndSend}
2330
+ onInterrupt={() => void runAction({ type: 'interrupt' })}
2331
+ onDetach={() => void runAction({ type: 'detach' })}
2332
+ onOpenTerminal={() => void runAction({ type: 'openTerminal' })}
2333
+ onBranch={() => {
2334
+ if (snapshot.activeSessionId)
2335
+ void runAction({ type: 'branch', sessionId: snapshot.activeSessionId });
2336
+ }}
2337
+ onResume={() => {
2338
+ if (snapshot.activeSessionId) requestResume(snapshot.activeSessionId);
2339
+ }}
2340
+ onAttach={() => {
2341
+ if (snapshot.activeSessionId && currentSession?.attachable)
2342
+ void runAction({
2343
+ type: 'attach',
2344
+ sessionId: snapshot.activeSessionId,
2345
+ });
2346
+ }}
2347
+ onOpenSessions={() => setSessionsOpen(true)}
2348
+ />
2349
+ </div>
2350
+ </div>
2351
+ );
2352
+ }