@dfosco/storyboard 0.5.0-alpha.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.
- package/commandpalette.config.json +152 -0
- package/dist/storyboard-ui.css +1 -0
- package/dist/storyboard-ui.js +21328 -0
- package/dist/storyboard-ui.js.map +1 -0
- package/dist/tailwind.css +2 -0
- package/dist/tiny-canvas.css +1 -0
- package/dist/tiny-canvas.js +389 -0
- package/package.json +121 -0
- package/paste.config.json +67 -0
- package/scaffold/AGENTS.md +432 -0
- package/scaffold/agents/prompt-agent.agent.md +181 -0
- package/scaffold/agents/terminal-agent.agent.md +351 -0
- package/scaffold/codex/config.toml +246 -0
- package/scaffold/deploy.yml +103 -0
- package/scaffold/githooks/pre-push +114 -0
- package/scaffold/gitignore +64 -0
- package/scaffold/manifest.json +56 -0
- package/scaffold/preview.yml +181 -0
- package/scaffold/scripts/link.sh +26 -0
- package/scaffold/scripts/unlink.sh +10 -0
- package/scaffold/skills/agent-browser/SKILL.md +260 -0
- package/scaffold/skills/canvas/SKILL.md +364 -0
- package/scaffold/skills/create/SKILL.md +501 -0
- package/scaffold/skills/ship/SKILL.md +237 -0
- package/scaffold/skills/storyboard/SKILL.md +360 -0
- package/scaffold/skills/update-storyboard/SKILL.md +16 -0
- package/scaffold/skills/update-storyboard/update-storyboard-packages.sh +26 -0
- package/scaffold/skills/vitest/GENERATION.md +5 -0
- package/scaffold/skills/vitest/SKILL.md +52 -0
- package/scaffold/skills/vitest/references/advanced-environments.md +264 -0
- package/scaffold/skills/vitest/references/advanced-projects.md +300 -0
- package/scaffold/skills/vitest/references/advanced-type-testing.md +237 -0
- package/scaffold/skills/vitest/references/advanced-vi.md +249 -0
- package/scaffold/skills/vitest/references/core-cli.md +166 -0
- package/scaffold/skills/vitest/references/core-config.md +174 -0
- package/scaffold/skills/vitest/references/core-describe.md +193 -0
- package/scaffold/skills/vitest/references/core-expect.md +219 -0
- package/scaffold/skills/vitest/references/core-hooks.md +244 -0
- package/scaffold/skills/vitest/references/core-test-api.md +233 -0
- package/scaffold/skills/vitest/references/features-concurrency.md +250 -0
- package/scaffold/skills/vitest/references/features-context.md +238 -0
- package/scaffold/skills/vitest/references/features-coverage.md +207 -0
- package/scaffold/skills/vitest/references/features-filtering.md +211 -0
- package/scaffold/skills/vitest/references/features-mocking.md +265 -0
- package/scaffold/skills/vitest/references/features-snapshots.md +207 -0
- package/scaffold/skills/worktree/SKILL.md +93 -0
- package/scaffold/storyboard.config.json +44 -0
- package/src/canvas/Canvas.jsx +78 -0
- package/src/canvas/Draggable.jsx +235 -0
- package/src/canvas/index.d.ts +41 -0
- package/src/canvas/index.js +6 -0
- package/src/canvas/style.css +118 -0
- package/src/canvas/useResetCanvas.js +17 -0
- package/src/canvas/utils.js +136 -0
- package/src/core/assets/fonts/IoskeleyMono-Bold.woff2 +0 -0
- package/src/core/assets/fonts/IoskeleyMono-Italic.woff2 +0 -0
- package/src/core/assets/fonts/IoskeleyMono-Medium.woff2 +0 -0
- package/src/core/assets/fonts/IoskeleyMono-Regular.woff2 +0 -0
- package/src/core/assets/fonts/IoskeleyMono-SemiBold.woff2 +0 -0
- package/src/core/autosync/server.js +714 -0
- package/src/core/autosync/server.test.js +158 -0
- package/src/core/canvas/__tests__/agent-integration.test.js +596 -0
- package/src/core/canvas/__tests__/helpers/browser.js +95 -0
- package/src/core/canvas/__tests__/helpers/canvas-api.js +129 -0
- package/src/core/canvas/__tests__/helpers/perf.js +118 -0
- package/src/core/canvas/__tests__/helpers/setup.js +176 -0
- package/src/core/canvas/__tests__/helpers/tmux.js +130 -0
- package/src/core/canvas/__tests__/helpers/transcript.js +132 -0
- package/src/core/canvas/__tests__/terminal-integration.test.js +177 -0
- package/src/core/canvas/collision.js +292 -0
- package/src/core/canvas/collision.test.js +371 -0
- package/src/core/canvas/compact.js +83 -0
- package/src/core/canvas/deriveCanvasId.test.js +40 -0
- package/src/core/canvas/githubEmbeds.js +527 -0
- package/src/core/canvas/githubEmbeds.test.js +302 -0
- package/src/core/canvas/hot-pool.js +766 -0
- package/src/core/canvas/identity.js +107 -0
- package/src/core/canvas/identity.test.js +100 -0
- package/src/core/canvas/materializer.js +259 -0
- package/src/core/canvas/materializer.test.js +356 -0
- package/src/core/canvas/selectedWidgets.js +270 -0
- package/src/core/canvas/selectedWidgets.test.js +321 -0
- package/src/core/canvas/server.js +3134 -0
- package/src/core/canvas/server.test.js +379 -0
- package/src/core/canvas/terminal-config.js +330 -0
- package/src/core/canvas/terminal-registry.js +465 -0
- package/src/core/canvas/terminal-server.js +1436 -0
- package/src/core/canvas/writeGuard.js +53 -0
- package/src/core/cli/agent.js +85 -0
- package/src/core/cli/branch.js +386 -0
- package/src/core/cli/canvasAdd.js +241 -0
- package/src/core/cli/canvasBatch.js +98 -0
- package/src/core/cli/canvasBounds.js +160 -0
- package/src/core/cli/canvasRead.js +236 -0
- package/src/core/cli/canvasUpdate.js +179 -0
- package/src/core/cli/code.js +67 -0
- package/src/core/cli/compact.js +62 -0
- package/src/core/cli/create.js +674 -0
- package/src/core/cli/dev-helpers.js +53 -0
- package/src/core/cli/dev-helpers.test.js +53 -0
- package/src/core/cli/dev.js +430 -0
- package/src/core/cli/exit.js +38 -0
- package/src/core/cli/flags.js +174 -0
- package/src/core/cli/flags.test.js +155 -0
- package/src/core/cli/index.js +233 -0
- package/src/core/cli/intro.js +37 -0
- package/src/core/cli/proxy.js +319 -0
- package/src/core/cli/proxy.test.js +63 -0
- package/src/core/cli/schemas.js +223 -0
- package/src/core/cli/server.js +192 -0
- package/src/core/cli/serverUrl.js +61 -0
- package/src/core/cli/sessions.js +459 -0
- package/src/core/cli/setup.js +404 -0
- package/src/core/cli/terminal-commands.js +287 -0
- package/src/core/cli/terminal-messaging.js +231 -0
- package/src/core/cli/terminal-welcome.js +515 -0
- package/src/core/cli/updateVersion.js +124 -0
- package/src/core/comments/api.js +284 -0
- package/src/core/comments/api.test.js +282 -0
- package/src/core/comments/auth.js +151 -0
- package/src/core/comments/auth.test.js +167 -0
- package/src/core/comments/commentCache.js +109 -0
- package/src/core/comments/commentCache.test.js +48 -0
- package/src/core/comments/commentDrafts.js +68 -0
- package/src/core/comments/commentMode.js +63 -0
- package/src/core/comments/commentMode.test.js +90 -0
- package/src/core/comments/config.js +47 -0
- package/src/core/comments/config.test.js +77 -0
- package/src/core/comments/graphql.js +65 -0
- package/src/core/comments/graphql.test.js +95 -0
- package/src/core/comments/index.js +42 -0
- package/src/core/comments/metadata.js +52 -0
- package/src/core/comments/metadata.test.js +110 -0
- package/src/core/comments/queries.js +245 -0
- package/src/core/comments/ui/AuthModal.jsx +114 -0
- package/src/core/comments/ui/CommentOverlay.js +52 -0
- package/src/core/comments/ui/CommentWindow.jsx +329 -0
- package/src/core/comments/ui/CommentsDrawer.jsx +102 -0
- package/src/core/comments/ui/Composer.jsx +64 -0
- package/src/core/comments/ui/authModal.js +66 -0
- package/src/core/comments/ui/authModal.test.js +76 -0
- package/src/core/comments/ui/comment-cursor-dark.svg +1 -0
- package/src/core/comments/ui/comment-cursor.svg +1 -0
- package/src/core/comments/ui/comment-layout.css +142 -0
- package/src/core/comments/ui/commentWindow.js +121 -0
- package/src/core/comments/ui/comments.css +242 -0
- package/src/core/comments/ui/commentsDrawer.js +84 -0
- package/src/core/comments/ui/composer.js +136 -0
- package/src/core/comments/ui/index.js +14 -0
- package/src/core/comments/ui/mount.js +687 -0
- package/src/core/comments/ui/mount.test.js +336 -0
- package/src/core/data/dotPath.js +53 -0
- package/src/core/data/dotPath.test.js +114 -0
- package/src/core/data/loader.js +409 -0
- package/src/core/data/loader.test.js +599 -0
- package/src/core/data/viewfinder.js +363 -0
- package/src/core/data/viewfinder.test.js +456 -0
- package/src/core/devtools/devtools-consumer.js +28 -0
- package/src/core/devtools/devtools.js +144 -0
- package/src/core/devtools/devtools.test.js +75 -0
- package/src/core/devtools/sceneDebug.js +112 -0
- package/src/core/devtools/sceneDebug.test.js +141 -0
- package/src/core/index.js +124 -0
- package/src/core/inspector/fiberWalker.js +239 -0
- package/src/core/inspector/highlighter.js +275 -0
- package/src/core/inspector/mouseMode.js +259 -0
- package/src/core/lib/components/ui/alert/alert-action.jsx +11 -0
- package/src/core/lib/components/ui/alert/alert-description.jsx +11 -0
- package/src/core/lib/components/ui/alert/alert-title.jsx +11 -0
- package/src/core/lib/components/ui/alert/alert.jsx +25 -0
- package/src/core/lib/components/ui/alert/index.js +17 -0
- package/src/core/lib/components/ui/avatar/avatar-badge.jsx +22 -0
- package/src/core/lib/components/ui/avatar/avatar-fallback.jsx +18 -0
- package/src/core/lib/components/ui/avatar/avatar-group-count.jsx +19 -0
- package/src/core/lib/components/ui/avatar/avatar-group.jsx +19 -0
- package/src/core/lib/components/ui/avatar/avatar-image.jsx +15 -0
- package/src/core/lib/components/ui/avatar/avatar.jsx +19 -0
- package/src/core/lib/components/ui/avatar/index.js +22 -0
- package/src/core/lib/components/ui/badge/badge.jsx +31 -0
- package/src/core/lib/components/ui/badge/index.js +2 -0
- package/src/core/lib/components/ui/button/button.jsx +100 -0
- package/src/core/lib/components/ui/button/index.js +12 -0
- package/src/core/lib/components/ui/card/card-action.jsx +11 -0
- package/src/core/lib/components/ui/card/card-content.jsx +11 -0
- package/src/core/lib/components/ui/card/card-description.jsx +11 -0
- package/src/core/lib/components/ui/card/card-footer.jsx +11 -0
- package/src/core/lib/components/ui/card/card-header.jsx +19 -0
- package/src/core/lib/components/ui/card/card-title.jsx +11 -0
- package/src/core/lib/components/ui/card/card.jsx +17 -0
- package/src/core/lib/components/ui/card/index.js +25 -0
- package/src/core/lib/components/ui/checkbox/checkbox.jsx +29 -0
- package/src/core/lib/components/ui/checkbox/index.js +6 -0
- package/src/core/lib/components/ui/collapsible/collapsible-content.jsx +7 -0
- package/src/core/lib/components/ui/collapsible/collapsible-trigger.jsx +7 -0
- package/src/core/lib/components/ui/collapsible/collapsible.jsx +7 -0
- package/src/core/lib/components/ui/collapsible/index.js +13 -0
- package/src/core/lib/components/ui/dialog/dialog-close.jsx +7 -0
- package/src/core/lib/components/ui/dialog/dialog-content.jsx +34 -0
- package/src/core/lib/components/ui/dialog/dialog-description.jsx +15 -0
- package/src/core/lib/components/ui/dialog/dialog-footer.jsx +23 -0
- package/src/core/lib/components/ui/dialog/dialog-header.jsx +11 -0
- package/src/core/lib/components/ui/dialog/dialog-overlay.jsx +15 -0
- package/src/core/lib/components/ui/dialog/dialog-portal.jsx +4 -0
- package/src/core/lib/components/ui/dialog/dialog-title.jsx +15 -0
- package/src/core/lib/components/ui/dialog/dialog-trigger.jsx +7 -0
- package/src/core/lib/components/ui/dialog/dialog.jsx +4 -0
- package/src/core/lib/components/ui/dialog/index.js +34 -0
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-group.jsx +8 -0
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.jsx +30 -0
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-content.jsx +22 -0
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.jsx +16 -0
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-group.jsx +7 -0
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-item.jsx +20 -0
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-label.jsx +17 -0
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-portal.jsx +4 -0
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.jsx +7 -0
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.jsx +29 -0
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-separator.jsx +15 -0
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.jsx +16 -0
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.jsx +15 -0
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.jsx +23 -0
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-sub.jsx +4 -0
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-trigger.jsx +7 -0
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu.jsx +4 -0
- package/src/core/lib/components/ui/dropdown-menu/index.js +54 -0
- package/src/core/lib/components/ui/input/index.js +7 -0
- package/src/core/lib/components/ui/input/input.jsx +19 -0
- package/src/core/lib/components/ui/label/index.js +7 -0
- package/src/core/lib/components/ui/label/label.jsx +19 -0
- package/src/core/lib/components/ui/panel/index.js +24 -0
- package/src/core/lib/components/ui/panel/panel-body.jsx +11 -0
- package/src/core/lib/components/ui/panel/panel-close.jsx +16 -0
- package/src/core/lib/components/ui/panel/panel-content.jsx +29 -0
- package/src/core/lib/components/ui/panel/panel-footer.jsx +11 -0
- package/src/core/lib/components/ui/panel/panel-header.jsx +11 -0
- package/src/core/lib/components/ui/panel/panel-title.jsx +12 -0
- package/src/core/lib/components/ui/panel/panel.jsx +4 -0
- package/src/core/lib/components/ui/popover/index.js +28 -0
- package/src/core/lib/components/ui/popover/popover-close.jsx +7 -0
- package/src/core/lib/components/ui/popover/popover-content.jsx +22 -0
- package/src/core/lib/components/ui/popover/popover-description.jsx +11 -0
- package/src/core/lib/components/ui/popover/popover-header.jsx +11 -0
- package/src/core/lib/components/ui/popover/popover-portal.jsx +4 -0
- package/src/core/lib/components/ui/popover/popover-title.jsx +11 -0
- package/src/core/lib/components/ui/popover/popover-trigger.jsx +8 -0
- package/src/core/lib/components/ui/popover/popover.jsx +4 -0
- package/src/core/lib/components/ui/searchable-list.jsx +160 -0
- package/src/core/lib/components/ui/select/index.js +37 -0
- package/src/core/lib/components/ui/select/select-content.jsx +30 -0
- package/src/core/lib/components/ui/select/select-group-heading.jsx +17 -0
- package/src/core/lib/components/ui/select/select-group.jsx +15 -0
- package/src/core/lib/components/ui/select/select-item.jsx +26 -0
- package/src/core/lib/components/ui/select/select-label.jsx +11 -0
- package/src/core/lib/components/ui/select/select-portal.jsx +4 -0
- package/src/core/lib/components/ui/select/select-scroll-down-button.jsx +18 -0
- package/src/core/lib/components/ui/select/select-scroll-up-button.jsx +18 -0
- package/src/core/lib/components/ui/select/select-separator.jsx +15 -0
- package/src/core/lib/components/ui/select/select-trigger.jsx +25 -0
- package/src/core/lib/components/ui/select/select.jsx +4 -0
- package/src/core/lib/components/ui/separator/index.js +7 -0
- package/src/core/lib/components/ui/separator/separator.jsx +22 -0
- package/src/core/lib/components/ui/sheet/index.js +34 -0
- package/src/core/lib/components/ui/sheet/sheet-close.jsx +7 -0
- package/src/core/lib/components/ui/sheet/sheet-content.jsx +35 -0
- package/src/core/lib/components/ui/sheet/sheet-description.jsx +15 -0
- package/src/core/lib/components/ui/sheet/sheet-footer.jsx +11 -0
- package/src/core/lib/components/ui/sheet/sheet-header.jsx +11 -0
- package/src/core/lib/components/ui/sheet/sheet-overlay.jsx +15 -0
- package/src/core/lib/components/ui/sheet/sheet-portal.jsx +4 -0
- package/src/core/lib/components/ui/sheet/sheet-title.jsx +15 -0
- package/src/core/lib/components/ui/sheet/sheet-trigger.jsx +7 -0
- package/src/core/lib/components/ui/sheet/sheet.jsx +4 -0
- package/src/core/lib/components/ui/textarea/index.js +7 -0
- package/src/core/lib/components/ui/textarea/textarea.jsx +18 -0
- package/src/core/lib/components/ui/toggle/index.js +8 -0
- package/src/core/lib/components/ui/toggle/toggle.jsx +36 -0
- package/src/core/lib/components/ui/toggle-group/index.js +10 -0
- package/src/core/lib/components/ui/toggle-group/toggle-group-item.jsx +29 -0
- package/src/core/lib/components/ui/toggle-group/toggle-group.jsx +43 -0
- package/src/core/lib/components/ui/tooltip/index.js +3 -0
- package/src/core/lib/components/ui/tooltip/tooltip-content.jsx +21 -0
- package/src/core/lib/components/ui/tooltip/tooltip-trigger.jsx +23 -0
- package/src/core/lib/components/ui/tooltip/tooltip.jsx +11 -0
- package/src/core/lib/components/ui/trigger-button/index.js +6 -0
- package/src/core/lib/components/ui/trigger-button/trigger-button.css +38 -0
- package/src/core/lib/components/ui/trigger-button/trigger-button.jsx +63 -0
- package/src/core/lib/utils/index.js +6 -0
- package/src/core/logger/devLogger.js +238 -0
- package/src/core/logger/devLogger.test.js +193 -0
- package/src/core/modes/modes.css +98 -0
- package/src/core/modes/modes.js +492 -0
- package/src/core/modes/modes.test.js +562 -0
- package/src/core/mountStoryboardCore.js +478 -0
- package/src/core/rename-watcher/config.json +23 -0
- package/src/core/rename-watcher/watcher.js +531 -0
- package/src/core/scaffold.js +100 -0
- package/src/core/server/index.js +391 -0
- package/src/core/session/bodyClasses.js +128 -0
- package/src/core/session/bodyClasses.test.js +192 -0
- package/src/core/session/hashSubscribe.js +19 -0
- package/src/core/session/hashSubscribe.test.js +62 -0
- package/src/core/session/hideMode.js +424 -0
- package/src/core/session/hideMode.test.js +268 -0
- package/src/core/session/interceptHideParams.js +35 -0
- package/src/core/session/interceptHideParams.test.js +90 -0
- package/src/core/session/localStorage.js +134 -0
- package/src/core/session/localStorage.test.js +148 -0
- package/src/core/session/session.js +76 -0
- package/src/core/session/session.test.js +91 -0
- package/src/core/stores/canvasConfig.js +134 -0
- package/src/core/stores/canvasConfig.test.js +120 -0
- package/src/core/stores/commandActions.js +284 -0
- package/src/core/stores/commandPaletteConfig.js +31 -0
- package/src/core/stores/configSchema.js +232 -0
- package/src/core/stores/configSchema.test.js +72 -0
- package/src/core/stores/configStore.js +161 -0
- package/src/core/stores/customerModeConfig.js +30 -0
- package/src/core/stores/featureFlags.js +127 -0
- package/src/core/stores/paletteProviders.js +360 -0
- package/src/core/stores/paletteProviders.test.js +186 -0
- package/src/core/stores/plugins.js +40 -0
- package/src/core/stores/plugins.test.js +68 -0
- package/src/core/stores/recentArtifacts.js +68 -0
- package/src/core/stores/recentArtifacts.test.js +71 -0
- package/src/core/stores/sidePanelStore.ts +143 -0
- package/src/core/stores/themeStore.ts +291 -0
- package/src/core/stores/toolRegistry.js +227 -0
- package/src/core/stores/toolStateStore.js +183 -0
- package/src/core/stores/toolStateStore.test.js +220 -0
- package/src/core/stores/toolbarConfigStore.js +165 -0
- package/src/core/stores/uiConfig.js +64 -0
- package/src/core/stores/uiConfig.test.js +63 -0
- package/src/core/styles/tailwind.css +204 -0
- package/src/core/tools/handlers/autosync.js +12 -0
- package/src/core/tools/handlers/canvasAddWidget.js +11 -0
- package/src/core/tools/handlers/canvasAgents.js +20 -0
- package/src/core/tools/handlers/canvasToolbar.js +56 -0
- package/src/core/tools/handlers/commandPalette.js +9 -0
- package/src/core/tools/handlers/comments.js +16 -0
- package/src/core/tools/handlers/create.js +39 -0
- package/src/core/tools/handlers/devtools.js +122 -0
- package/src/core/tools/handlers/devtools.test.js +87 -0
- package/src/core/tools/handlers/featureFlags.js +21 -0
- package/src/core/tools/handlers/flows.js +68 -0
- package/src/core/tools/handlers/hideChrome.js +9 -0
- package/src/core/tools/handlers/hideToolbars.js +25 -0
- package/src/core/tools/handlers/inspector.js +19 -0
- package/src/core/tools/handlers/paletteTheme.js +35 -0
- package/src/core/tools/handlers/theme.js +9 -0
- package/src/core/tools/registry.js +26 -0
- package/src/core/tools/surfaces/canvasToolbar.js +10 -0
- package/src/core/tools/surfaces/commandList.js +10 -0
- package/src/core/tools/surfaces/mainToolbar.js +11 -0
- package/src/core/tools/surfaces/registry.js +19 -0
- package/src/core/ui/ActionMenuButton.jsx +114 -0
- package/src/core/ui/AutosyncMenuButton.css +67 -0
- package/src/core/ui/AutosyncMenuButton.jsx +242 -0
- package/src/core/ui/BranchSelect.jsx +29 -0
- package/src/core/ui/BranchSelect.module.css +30 -0
- package/src/core/ui/CanvasAgentsMenu.jsx +89 -0
- package/src/core/ui/CanvasCreateMenu.jsx +611 -0
- package/src/core/ui/CanvasSnap.css +27 -0
- package/src/core/ui/CanvasSnap.jsx +51 -0
- package/src/core/ui/CanvasUndoRedo.css +36 -0
- package/src/core/ui/CanvasUndoRedo.jsx +62 -0
- package/src/core/ui/CanvasZoomControl.css +53 -0
- package/src/core/ui/CanvasZoomControl.jsx +49 -0
- package/src/core/ui/CanvasZoomToFit.css +18 -0
- package/src/core/ui/CanvasZoomToFit.jsx +26 -0
- package/src/core/ui/CommandMenu.css +8 -0
- package/src/core/ui/CommandMenu.jsx +287 -0
- package/src/core/ui/CommandPalette.jsx +35 -0
- package/src/core/ui/CommandPaletteTrigger.jsx +25 -0
- package/src/core/ui/CommentsMenuButton.jsx +40 -0
- package/src/core/ui/CoreUIBar.css +47 -0
- package/src/core/ui/CoreUIBar.jsx +905 -0
- package/src/core/ui/CreateMenuButton.jsx +117 -0
- package/src/core/ui/HideChromeTrigger.jsx +48 -0
- package/src/core/ui/Icon.jsx +279 -0
- package/src/core/ui/InspectorPanel.css +109 -0
- package/src/core/ui/InspectorPanel.jsx +632 -0
- package/src/core/ui/PwaInstallBanner.css +42 -0
- package/src/core/ui/PwaInstallBanner.jsx +124 -0
- package/src/core/ui/SidePanel.jsx +261 -0
- package/src/core/ui/ThemeMenuButton.jsx +139 -0
- package/src/core/ui/core-ui-colors.css +129 -0
- package/src/core/ui/design-modes.ts +7 -0
- package/src/core/ui/sidepanel.css +301 -0
- package/src/core/ui/viewfinder.ts +7 -0
- package/src/core/ui-entry.js +30 -0
- package/src/core/utils/fuzzySearch.js +117 -0
- package/src/core/utils/fuzzySearch.test.js +119 -0
- package/src/core/utils/mobileViewport.js +57 -0
- package/src/core/utils/mobileViewport.test.js +68 -0
- package/src/core/utils/prodMode.js +38 -0
- package/src/core/utils/smoothCorners.js +20 -0
- package/src/core/vite/docs-handler.js +155 -0
- package/src/core/vite/server-plugin.js +797 -0
- package/src/core/workshop/features/createCanvas/CreateCanvasForm.jsx +260 -0
- package/src/core/workshop/features/createCanvas/index.js +14 -0
- package/src/core/workshop/features/createFlow/CreateFlowForm.jsx +334 -0
- package/src/core/workshop/features/createFlow/index.js +19 -0
- package/src/core/workshop/features/createFlow/server.js +663 -0
- package/src/core/workshop/features/createPage/CreatePageForm.jsx +304 -0
- package/src/core/workshop/features/createPage/index.js +11 -0
- package/src/core/workshop/features/createPrototype/CreatePrototypeForm.jsx +289 -0
- package/src/core/workshop/features/createPrototype/index.js +19 -0
- package/src/core/workshop/features/createPrototype/server.js +433 -0
- package/src/core/workshop/features/createStory/CreateStoryForm.jsx +208 -0
- package/src/core/workshop/features/createStory/index.js +14 -0
- package/src/core/workshop/features/registry-server.js +22 -0
- package/src/core/workshop/features/registry.js +28 -0
- package/src/core/workshop/features/templateIndex.js +155 -0
- package/src/core/workshop/ui/WorkshopPanel.jsx +98 -0
- package/src/core/workshop/ui/mount.ts +6 -0
- package/src/core/worktree/port.js +268 -0
- package/src/core/worktree/port.test.js +222 -0
- package/src/core/worktree/serverRegistry.js +120 -0
- package/src/internals/AuthModal/AuthModal.jsx +132 -0
- package/src/internals/AuthModal/AuthModal.module.css +221 -0
- package/src/internals/BranchBar/BranchBar.jsx +87 -0
- package/src/internals/BranchBar/BranchBar.module.css +247 -0
- package/src/internals/BranchBar/useBranches.js +93 -0
- package/src/internals/BranchBar/useBranches.test.js +68 -0
- package/src/internals/CommandPalette/CommandPalette.jsx +1361 -0
- package/src/internals/CommandPalette/CreateDialog.jsx +219 -0
- package/src/internals/CommandPalette/command-palette.css +180 -0
- package/src/internals/FlowError.module.css +30 -0
- package/src/internals/Icon.jsx +279 -0
- package/src/internals/StoryboardContext.js +3 -0
- package/src/internals/Viewfinder.jsx +1479 -0
- package/src/internals/Viewfinder.module.css +1540 -0
- package/src/internals/Workspace.jsx +7 -0
- package/src/internals/__mocks__/virtual-storyboard-data-index.js +4 -0
- package/src/internals/canvas/CanvasControls.jsx +112 -0
- package/src/internals/canvas/CanvasControls.module.css +135 -0
- package/src/internals/canvas/CanvasPage.bridge.test.jsx +387 -0
- package/src/internals/canvas/CanvasPage.dragdrop.test.jsx +350 -0
- package/src/internals/canvas/CanvasPage.jsx +3092 -0
- package/src/internals/canvas/CanvasPage.module.css +187 -0
- package/src/internals/canvas/CanvasPage.multiselect.test.jsx +358 -0
- package/src/internals/canvas/CanvasToolbar.jsx +73 -0
- package/src/internals/canvas/CanvasToolbar.module.css +92 -0
- package/src/internals/canvas/ComponentErrorBoundary.jsx +50 -0
- package/src/internals/canvas/ConnectorLayer.jsx +208 -0
- package/src/internals/canvas/ConnectorLayer.module.css +129 -0
- package/src/internals/canvas/MarqueeOverlay.jsx +20 -0
- package/src/internals/canvas/PageSelector.jsx +587 -0
- package/src/internals/canvas/PageSelector.module.css +261 -0
- package/src/internals/canvas/PageSelector.test.jsx +113 -0
- package/src/internals/canvas/WebGLContextPool.jsx +292 -0
- package/src/internals/canvas/WebGLContextPool.test.jsx +165 -0
- package/src/internals/canvas/canvasApi.js +164 -0
- package/src/internals/canvas/canvasReloadGuard.js +37 -0
- package/src/internals/canvas/canvasReloadGuard.test.js +27 -0
- package/src/internals/canvas/canvasTheme.js +118 -0
- package/src/internals/canvas/componentIsolate.jsx +165 -0
- package/src/internals/canvas/componentSetIsolate.jsx +257 -0
- package/src/internals/canvas/computeCanvasBounds.test.js +121 -0
- package/src/internals/canvas/connectorGeometry.js +132 -0
- package/src/internals/canvas/hotPoolDevLogs.js +25 -0
- package/src/internals/canvas/textSelection.js +10 -0
- package/src/internals/canvas/textSelection.test.js +26 -0
- package/src/internals/canvas/useCanvas.js +126 -0
- package/src/internals/canvas/useCanvas.test.js +26 -0
- package/src/internals/canvas/useMarqueeSelect.js +213 -0
- package/src/internals/canvas/useMarqueeSelect.test.js +78 -0
- package/src/internals/canvas/useUndoRedo.js +86 -0
- package/src/internals/canvas/useUndoRedo.test.js +231 -0
- package/src/internals/canvas/widgets/CodePenEmbed.jsx +293 -0
- package/src/internals/canvas/widgets/CodePenEmbed.module.css +161 -0
- package/src/internals/canvas/widgets/ComponentSetWidget.jsx +2 -0
- package/src/internals/canvas/widgets/ComponentSetWidget.module.css +89 -0
- package/src/internals/canvas/widgets/ComponentWidget.jsx +14 -0
- package/src/internals/canvas/widgets/ComponentWidget.module.css +0 -0
- package/src/internals/canvas/widgets/CropOverlay.jsx +179 -0
- package/src/internals/canvas/widgets/CropOverlay.module.css +154 -0
- package/src/internals/canvas/widgets/ExpandedPane.jsx +474 -0
- package/src/internals/canvas/widgets/ExpandedPane.module.css +179 -0
- package/src/internals/canvas/widgets/ExpandedPane.test.jsx +240 -0
- package/src/internals/canvas/widgets/ExpandedPaneTopBar.jsx +111 -0
- package/src/internals/canvas/widgets/ExpandedPaneTopBar.module.css +59 -0
- package/src/internals/canvas/widgets/ExpandedPaneTopBar.test.jsx +45 -0
- package/src/internals/canvas/widgets/FigmaEmbed.jsx +296 -0
- package/src/internals/canvas/widgets/FigmaEmbed.module.css +222 -0
- package/src/internals/canvas/widgets/FrozenTerminalOverlay.jsx +151 -0
- package/src/internals/canvas/widgets/FrozenTerminalOverlay.module.css +83 -0
- package/src/internals/canvas/widgets/ImageWidget.jsx +287 -0
- package/src/internals/canvas/widgets/ImageWidget.module.css +81 -0
- package/src/internals/canvas/widgets/LinkPreview.jsx +439 -0
- package/src/internals/canvas/widgets/LinkPreview.module.css +585 -0
- package/src/internals/canvas/widgets/LinkPreview.test.jsx +193 -0
- package/src/internals/canvas/widgets/MarkdownBlock.jsx +354 -0
- package/src/internals/canvas/widgets/MarkdownBlock.module.css +377 -0
- package/src/internals/canvas/widgets/MarkdownBlock.test.jsx +92 -0
- package/src/internals/canvas/widgets/PromptWidget.jsx +428 -0
- package/src/internals/canvas/widgets/PromptWidget.module.css +273 -0
- package/src/internals/canvas/widgets/PrototypeEmbed.jsx +463 -0
- package/src/internals/canvas/widgets/PrototypeEmbed.module.css +579 -0
- package/src/internals/canvas/widgets/PrototypeEmbed.test.jsx +10 -0
- package/src/internals/canvas/widgets/ResizeHandle.jsx +67 -0
- package/src/internals/canvas/widgets/ResizeHandle.module.css +29 -0
- package/src/internals/canvas/widgets/StickyNote.jsx +92 -0
- package/src/internals/canvas/widgets/StickyNote.module.css +70 -0
- package/src/internals/canvas/widgets/StickyNote.test.jsx +116 -0
- package/src/internals/canvas/widgets/StorySetWidget.jsx +208 -0
- package/src/internals/canvas/widgets/StorySetWidget.module.css +89 -0
- package/src/internals/canvas/widgets/StoryWidget.jsx +334 -0
- package/src/internals/canvas/widgets/StoryWidget.module.css +211 -0
- package/src/internals/canvas/widgets/TerminalReadWidget.jsx +146 -0
- package/src/internals/canvas/widgets/TerminalReadWidget.module.css +94 -0
- package/src/internals/canvas/widgets/TerminalWidget.jsx +704 -0
- package/src/internals/canvas/widgets/TerminalWidget.module.css +444 -0
- package/src/internals/canvas/widgets/TilesWidget.jsx +300 -0
- package/src/internals/canvas/widgets/TilesWidget.module.css +133 -0
- package/src/internals/canvas/widgets/WidgetChrome.jsx +580 -0
- package/src/internals/canvas/widgets/WidgetChrome.module.css +421 -0
- package/src/internals/canvas/widgets/WidgetWrapper.jsx +15 -0
- package/src/internals/canvas/widgets/WidgetWrapper.module.css +25 -0
- package/src/internals/canvas/widgets/codepenUrl.js +75 -0
- package/src/internals/canvas/widgets/codepenUrl.test.js +76 -0
- package/src/internals/canvas/widgets/embedInteraction.test.jsx +173 -0
- package/src/internals/canvas/widgets/embedOverlay.module.css +35 -0
- package/src/internals/canvas/widgets/embedTheme.js +148 -0
- package/src/internals/canvas/widgets/expandUtils.js +559 -0
- package/src/internals/canvas/widgets/expandUtils.test.js +155 -0
- package/src/internals/canvas/widgets/figmaUrl.js +118 -0
- package/src/internals/canvas/widgets/figmaUrl.test.js +139 -0
- package/src/internals/canvas/widgets/githubUrl.js +82 -0
- package/src/internals/canvas/widgets/githubUrl.test.js +74 -0
- package/src/internals/canvas/widgets/iframeDevLogs.js +49 -0
- package/src/internals/canvas/widgets/iframeDevLogs.test.jsx +81 -0
- package/src/internals/canvas/widgets/index.js +42 -0
- package/src/internals/canvas/widgets/pasteRules.js +295 -0
- package/src/internals/canvas/widgets/pasteRules.test.js +474 -0
- package/src/internals/canvas/widgets/snapshotDisplay.test.jsx +211 -0
- package/src/internals/canvas/widgets/tilePool.js +23 -0
- package/src/internals/canvas/widgets/tiles/diagonal-bl.png +0 -0
- package/src/internals/canvas/widgets/tiles/diagonal-br.png +0 -0
- package/src/internals/canvas/widgets/tiles/diagonal-tl.png +0 -0
- package/src/internals/canvas/widgets/tiles/leaf.png +0 -0
- package/src/internals/canvas/widgets/tiles/quarter-tl.png +0 -0
- package/src/internals/canvas/widgets/tiles/quarter-tr.png +0 -0
- package/src/internals/canvas/widgets/tiles/solid-a.png +0 -0
- package/src/internals/canvas/widgets/tiles/solid-b.png +0 -0
- package/src/internals/canvas/widgets/widgetConfig.js +291 -0
- package/src/internals/canvas/widgets/widgetConfig.test.js +68 -0
- package/src/internals/canvas/widgets/widgetIcons.jsx +190 -0
- package/src/internals/canvas/widgets/widgetProps.js +133 -0
- package/src/internals/context/FormContext.js +13 -0
- package/src/internals/context/FormContext.test.js +48 -0
- package/src/internals/context.jsx +481 -0
- package/src/internals/context.test.jsx +296 -0
- package/src/internals/hashPreserver.js +73 -0
- package/src/internals/hashPreserver.test.js +107 -0
- package/src/internals/hooks/useConfig.js +14 -0
- package/src/internals/hooks/useFeatureFlag.js +14 -0
- package/src/internals/hooks/useFlows.js +50 -0
- package/src/internals/hooks/useFlows.test.js +134 -0
- package/src/internals/hooks/useHideMode.js +31 -0
- package/src/internals/hooks/useHideMode.test.js +43 -0
- package/src/internals/hooks/useLocalStorage.js +57 -0
- package/src/internals/hooks/useLocalStorage.test.js +75 -0
- package/src/internals/hooks/useMode.js +43 -0
- package/src/internals/hooks/useObject.js +101 -0
- package/src/internals/hooks/useObject.test.js +74 -0
- package/src/internals/hooks/useOverride.js +84 -0
- package/src/internals/hooks/useOverride.test.js +71 -0
- package/src/internals/hooks/usePrototypeReloadGuard.js +64 -0
- package/src/internals/hooks/useRecord.js +158 -0
- package/src/internals/hooks/useRecord.test.js +221 -0
- package/src/internals/hooks/useScene.js +38 -0
- package/src/internals/hooks/useScene.test.js +66 -0
- package/src/internals/hooks/useSceneData.js +108 -0
- package/src/internals/hooks/useSceneData.test.js +136 -0
- package/src/internals/hooks/useSession.js +4 -0
- package/src/internals/hooks/useSession.test.js +8 -0
- package/src/internals/hooks/useThemeState.js +61 -0
- package/src/internals/hooks/useThemeState.test.js +66 -0
- package/src/internals/hooks/useUndoRedo.js +28 -0
- package/src/internals/hooks/useUndoRedo.test.js +64 -0
- package/src/internals/index.js +58 -0
- package/src/internals/story/ComponentSetPage.jsx +198 -0
- package/src/internals/story/ComponentSetPage.module.css +129 -0
- package/src/internals/story/StoryPage.jsx +147 -0
- package/src/internals/story/StoryPage.module.css +18 -0
- package/src/internals/test-utils.js +45 -0
- package/src/internals/vite/data-plugin.js +1508 -0
- package/src/internals/vite/data-plugin.test.js +1223 -0
- package/src/test-utils.js +44 -0
- package/toolbar.config.json +271 -0
- package/widgets.config.json +1537 -0
|
@@ -0,0 +1,3134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canvas Server API — CRUD operations for .canvas.jsonl files.
|
|
3
|
+
*
|
|
4
|
+
* Canvas data is stored as an append-only JSONL event stream.
|
|
5
|
+
* Each line is a JSON event object. The first line is always a
|
|
6
|
+
* `canvas_created` event containing the full initial state.
|
|
7
|
+
* Subsequent lines are atomic change events. Current state is
|
|
8
|
+
* derived by replaying the stream via the materializer.
|
|
9
|
+
*
|
|
10
|
+
* Routes (mounted at /_storyboard/canvas/):
|
|
11
|
+
* GET /read — read materialized canvas state
|
|
12
|
+
* GET /list — list all canvases
|
|
13
|
+
* GET /folders — list canvas folders
|
|
14
|
+
* PUT /update — append update events (widgets, sources, settings)
|
|
15
|
+
* PUT /rename-page — rename a canvas page file
|
|
16
|
+
* PUT /reorder-pages — save page order for a canvas folder
|
|
17
|
+
* GET /page-order — read page order for a folder
|
|
18
|
+
* PUT /update-folder-meta — update folder .meta.json title
|
|
19
|
+
* POST /widget — append a widget_added event
|
|
20
|
+
* PATCH /widget — update a single widget's props/position
|
|
21
|
+
* DELETE /widget — append a widget_removed event
|
|
22
|
+
* POST /connector — append a connector_added event
|
|
23
|
+
* DELETE /connector — append a connector_removed event
|
|
24
|
+
* POST /batch — execute multiple operations in one request (refs, single HMR push)
|
|
25
|
+
* POST /create — create a new .canvas.jsonl file
|
|
26
|
+
* GET /stories — list all .story.{jsx,tsx} files with exports
|
|
27
|
+
* POST /create-story — scaffold a new .story.{jsx,tsx} file
|
|
28
|
+
* GET /github/available — check if local gh CLI is installed
|
|
29
|
+
* POST /github/embed — fetch GitHub issue/discussion/PR/comment metadata via gh
|
|
30
|
+
* POST /image — upload a pasted image to src/canvas/images/
|
|
31
|
+
* GET /images/* — serve an image file from src/canvas/images/
|
|
32
|
+
* POST /image/toggle-private — toggle ~prefix on image filename
|
|
33
|
+
* GET /terminal-buffer/:id — read private terminal buffer (with ?length=N)
|
|
34
|
+
* GET /terminal-snapshot/:id — read public terminal snapshot
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import fs from 'node:fs'
|
|
38
|
+
import path from 'node:path'
|
|
39
|
+
import { Buffer } from 'node:buffer'
|
|
40
|
+
import { materializeFromText, serializeEvent } from './materializer.js'
|
|
41
|
+
import { toCanvasId, parseCanvasId } from './identity.js'
|
|
42
|
+
import {
|
|
43
|
+
GH_INSTALL_URL,
|
|
44
|
+
GitHubEmbedError,
|
|
45
|
+
fetchGitHubEmbedSnapshot,
|
|
46
|
+
isGhCliAvailable,
|
|
47
|
+
isGitHubEmbedUrl,
|
|
48
|
+
} from './githubEmbeds.js'
|
|
49
|
+
import { stampBounds, stampBoundsAll, resolvePosition, getWidgetBounds } from './collision.js'
|
|
50
|
+
import { markCanvasWrite, unmarkCanvasWrite } from './writeGuard.js'
|
|
51
|
+
import { devLog } from '../logger/devLogger.js'
|
|
52
|
+
import widgetsConfig from '../../../widgets.config.json' with { type: 'json' }
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Read the prompt widget's execution config from widgets.config.json.
|
|
56
|
+
* Returns { default, agents } where each agent has a command template.
|
|
57
|
+
*/
|
|
58
|
+
function getPromptExecution() {
|
|
59
|
+
return widgetsConfig?.widgets?.prompt?.execution || null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Build the CLI command for a prompt spawn.
|
|
64
|
+
* Reads the prompt widget's execution.agents config and interpolates ${prompt}.
|
|
65
|
+
*/
|
|
66
|
+
function buildPromptCmd({ prompt, envFile, agentId }) {
|
|
67
|
+
const execution = getPromptExecution()
|
|
68
|
+
|
|
69
|
+
if (!execution) {
|
|
70
|
+
// Bare fallback — no execution config found
|
|
71
|
+
const escaped = prompt.replace(/"/g, '\\"')
|
|
72
|
+
return `source ${envFile} && copilot -p "${escaped}" --allow-all`
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const id = agentId || execution.default
|
|
76
|
+
const agent = execution.agents?.[id]
|
|
77
|
+
|
|
78
|
+
if (!agent?.command) {
|
|
79
|
+
return null // This agent doesn't have a prompt command
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const escaped = prompt.replace(/"/g, '\\"')
|
|
83
|
+
const cmd = agent.command.replace('${prompt}', escaped)
|
|
84
|
+
return `source ${envFile} && ${cmd}`
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Scan src/canvas/ for directories containing .meta.json files.
|
|
89
|
+
* Returns an object keyed by directory name (without .folder suffix).
|
|
90
|
+
*/
|
|
91
|
+
function findCanvasMeta(root) {
|
|
92
|
+
const canvasDir = path.join(root, 'src', 'canvas')
|
|
93
|
+
const groups = {}
|
|
94
|
+
if (!fs.existsSync(canvasDir)) return groups
|
|
95
|
+
|
|
96
|
+
const entries = fs.readdirSync(canvasDir, { withFileTypes: true })
|
|
97
|
+
for (const entry of entries) {
|
|
98
|
+
if (!entry.isDirectory()) continue
|
|
99
|
+
const dirName = entry.name.replace(/\.folder$/, '')
|
|
100
|
+
const metaPath = path.join(canvasDir, entry.name, `${dirName}.meta.json`)
|
|
101
|
+
if (fs.existsSync(metaPath)) {
|
|
102
|
+
try {
|
|
103
|
+
const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'))
|
|
104
|
+
groups[dirName] = meta
|
|
105
|
+
} catch { /* skip invalid meta */ }
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return groups
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Read .meta.json from a canvas folder directory.
|
|
113
|
+
*/
|
|
114
|
+
function readFolderMeta(folderDir) {
|
|
115
|
+
const dirName = path.basename(folderDir).replace(/\.folder$/, '')
|
|
116
|
+
const metaPath = path.join(folderDir, `${dirName}.meta.json`)
|
|
117
|
+
if (fs.existsSync(metaPath)) {
|
|
118
|
+
try { return JSON.parse(fs.readFileSync(metaPath, 'utf-8')) } catch { /* ignore */ }
|
|
119
|
+
}
|
|
120
|
+
return {}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Write .meta.json to a canvas folder directory.
|
|
125
|
+
*/
|
|
126
|
+
function writeFolderMeta(folderDir, meta) {
|
|
127
|
+
const dirName = path.basename(folderDir).replace(/\.folder$/, '')
|
|
128
|
+
const metaPath = path.join(folderDir, `${dirName}.meta.json`)
|
|
129
|
+
fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2) + '\n', 'utf-8')
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Recursively find all .canvas.jsonl files in the project.
|
|
134
|
+
*/
|
|
135
|
+
function findCanvasFiles(root) {
|
|
136
|
+
const results = []
|
|
137
|
+
const ignore = new Set(['node_modules', 'dist', '.git', '.worktrees'])
|
|
138
|
+
|
|
139
|
+
function walk(dir, rel) {
|
|
140
|
+
let entries
|
|
141
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }
|
|
142
|
+
for (const entry of entries) {
|
|
143
|
+
if (ignore.has(entry.name)) continue
|
|
144
|
+
const fullPath = path.join(dir, entry.name)
|
|
145
|
+
const relPath = rel ? `${rel}/${entry.name}` : entry.name
|
|
146
|
+
if (entry.isDirectory()) {
|
|
147
|
+
walk(fullPath, relPath)
|
|
148
|
+
} else if (entry.name.endsWith('.canvas.jsonl')) {
|
|
149
|
+
results.push(relPath)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
walk(root, '')
|
|
155
|
+
return results
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Recursively find all .story.{jsx,tsx} files in routable directories
|
|
160
|
+
* (src/canvas/ and src/components/) and extract their named exports.
|
|
161
|
+
*/
|
|
162
|
+
function findStoryFiles(root) {
|
|
163
|
+
const results = []
|
|
164
|
+
const ignore = new Set(['node_modules', 'dist', '.git', '.worktrees'])
|
|
165
|
+
const ROUTABLE_DIRS = ['src/canvas', 'src/components']
|
|
166
|
+
|
|
167
|
+
function walk(dir, rel) {
|
|
168
|
+
let entries
|
|
169
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }
|
|
170
|
+
for (const entry of entries) {
|
|
171
|
+
if (ignore.has(entry.name)) continue
|
|
172
|
+
if (entry.name.startsWith('_')) continue
|
|
173
|
+
const fullPath = path.join(dir, entry.name)
|
|
174
|
+
const relPath = rel ? `${rel}/${entry.name}` : entry.name
|
|
175
|
+
if (entry.isDirectory()) {
|
|
176
|
+
walk(fullPath, relPath)
|
|
177
|
+
} else if (/\.story\.(jsx|tsx)$/.test(entry.name)) {
|
|
178
|
+
const name = entry.name.replace(/\.story\.(jsx|tsx)$/, '')
|
|
179
|
+
const exports = parseExportNames(fullPath)
|
|
180
|
+
results.push({ name, path: relPath, exports })
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
for (const dir of ROUTABLE_DIRS) {
|
|
186
|
+
const absDir = path.join(root, dir)
|
|
187
|
+
if (fs.existsSync(absDir)) {
|
|
188
|
+
walk(absDir, dir)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return results
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Parse named function/const exports from a JSX/TSX file.
|
|
196
|
+
*/
|
|
197
|
+
function parseExportNames(filePath) {
|
|
198
|
+
try {
|
|
199
|
+
const src = fs.readFileSync(filePath, 'utf-8')
|
|
200
|
+
const names = []
|
|
201
|
+
const re = /export\s+(?:function|const|class)\s+([A-Z]\w*)/g
|
|
202
|
+
let m
|
|
203
|
+
while ((m = re.exec(src)) !== null) names.push(m[1])
|
|
204
|
+
return names
|
|
205
|
+
} catch { return [] }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Find a canvas JSONL file by canonical ID.
|
|
210
|
+
* Only matches canonical path-based IDs from toCanvasId().
|
|
211
|
+
*/
|
|
212
|
+
function findCanvasPath(root, canvasId) {
|
|
213
|
+
const files = findCanvasFiles(root)
|
|
214
|
+
|
|
215
|
+
for (const file of files) {
|
|
216
|
+
const id = toCanvasId(file)
|
|
217
|
+
if (id === canvasId) {
|
|
218
|
+
return path.resolve(root, file)
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return null
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Read a .canvas.jsonl file and materialize its current state.
|
|
227
|
+
*/
|
|
228
|
+
function readCanvas(filePath) {
|
|
229
|
+
const raw = fs.readFileSync(filePath, 'utf-8')
|
|
230
|
+
return materializeFromText(raw)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Append a single event line to a .canvas.jsonl file.
|
|
235
|
+
*/
|
|
236
|
+
function appendEventRaw(filePath, event) {
|
|
237
|
+
fs.appendFileSync(filePath, serializeEvent(event) + '\n', 'utf-8')
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Generate a unique widget ID.
|
|
242
|
+
*/
|
|
243
|
+
function generateWidgetId(type) {
|
|
244
|
+
const suffix = Math.random().toString(36).slice(2, 8)
|
|
245
|
+
return `${type}-${suffix}`
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Create the canvas API route handler.
|
|
250
|
+
*/
|
|
251
|
+
export function createCanvasHandler(ctx) {
|
|
252
|
+
const { root, sendJson, hotPool } = ctx
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Compute a target position relative to a reference widget.
|
|
256
|
+
* @param {object} refWidget — widget to position near (must have position + type/props)
|
|
257
|
+
* @param {string} direction — 'right' | 'left' | 'above' | 'below'
|
|
258
|
+
* @param {string} newType — type of the widget being created (for size defaults)
|
|
259
|
+
* @param {object} newProps — props of the widget being created
|
|
260
|
+
* @param {number} gap — spacing between widgets (default 40)
|
|
261
|
+
* @returns {{ x: number, y: number }}
|
|
262
|
+
*/
|
|
263
|
+
function computeNearPosition(refWidget, direction = 'right', newType = 'sticky-note', newProps = {}, gap = 40) {
|
|
264
|
+
const refBounds = getWidgetBounds(refWidget)
|
|
265
|
+
const newDefaults = getWidgetBounds({ type: newType, props: newProps, position: { x: 0, y: 0 } })
|
|
266
|
+
switch (direction) {
|
|
267
|
+
case 'left':
|
|
268
|
+
return { x: refBounds.x - newDefaults.width - gap, y: refBounds.y }
|
|
269
|
+
case 'above':
|
|
270
|
+
return { x: refBounds.x, y: refBounds.y - newDefaults.height - gap }
|
|
271
|
+
case 'below':
|
|
272
|
+
return { x: refBounds.x, y: refBounds.y + refBounds.height + gap }
|
|
273
|
+
case 'right':
|
|
274
|
+
default:
|
|
275
|
+
return { x: refBounds.x + refBounds.width + gap, y: refBounds.y }
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Compute a smart default position when no --near or explicit x,y is given.
|
|
281
|
+
* Priority chain:
|
|
282
|
+
* 1. Active agent/terminal (source widget ID from request)
|
|
283
|
+
* 2. User-selected widget (from .selectedwidgets.json, same canvas)
|
|
284
|
+
* 3. Viewport center (from .selectedwidgets.json)
|
|
285
|
+
* 4. Last widget on canvas
|
|
286
|
+
* 5. Origin (0, 0) — empty canvas, no viewport
|
|
287
|
+
*
|
|
288
|
+
* @param {object[]} canvasWidgets — current widgets on the canvas
|
|
289
|
+
* @param {string} type — widget type being created
|
|
290
|
+
* @param {object} props — widget props
|
|
291
|
+
* @param {string} projectRoot — project root directory
|
|
292
|
+
* @param {string|null} canvasName — canvas ID for matching selectedwidgets context
|
|
293
|
+
* @param {string|null} sourceWidgetId — caller's widget ID (agent/terminal creating this widget)
|
|
294
|
+
*/
|
|
295
|
+
async function computeAutoPosition(canvasWidgets, type, props, projectRoot, canvasName, sourceWidgetId) {
|
|
296
|
+
const widgetMap = new Map((canvasWidgets || []).map(w => [w.id, w]))
|
|
297
|
+
|
|
298
|
+
// 1. Place near the source agent/terminal widget
|
|
299
|
+
if (sourceWidgetId && widgetMap.has(sourceWidgetId)) {
|
|
300
|
+
return computeNearPosition(widgetMap.get(sourceWidgetId), 'right', type, props)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// 2–3. Read .selectedwidgets.json for selection + viewport context
|
|
304
|
+
try {
|
|
305
|
+
const { readSelectedWidgets } = await import('./selectedWidgets.js')
|
|
306
|
+
const sw = readSelectedWidgets(projectRoot)
|
|
307
|
+
if (sw && sw.canvasId === canvasName) {
|
|
308
|
+
// 2. Place near the selected widget
|
|
309
|
+
if (sw.selectedWidgetIds?.length > 0) {
|
|
310
|
+
const selectedId = sw.selectedWidgetIds[0]
|
|
311
|
+
if (widgetMap.has(selectedId)) {
|
|
312
|
+
return computeNearPosition(widgetMap.get(selectedId), 'right', type, props)
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// 3. Place at viewport center
|
|
317
|
+
const vp = sw.viewport
|
|
318
|
+
if (vp && vp.centerX != null && vp.centerY != null) {
|
|
319
|
+
return { x: Math.round(vp.centerX / 24) * 24, y: Math.round(vp.centerY / 24) * 24 }
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
} catch { /* selectedWidgets bridge may not be initialized */ }
|
|
323
|
+
|
|
324
|
+
// 4. Place near the last widget on the canvas
|
|
325
|
+
if (canvasWidgets && canvasWidgets.length > 0) {
|
|
326
|
+
const lastWidget = canvasWidgets[canvasWidgets.length - 1]
|
|
327
|
+
return computeNearPosition(lastWidget, 'right', type, props)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// 5. Truly empty canvas, no viewport
|
|
331
|
+
return { x: 0, y: 0 }
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Update terminal configs when connectors change.
|
|
336
|
+
* Finds all terminal widgets in the canvas, computes their connected widget IDs
|
|
337
|
+
* from the current connector list, and updates their config files.
|
|
338
|
+
*/
|
|
339
|
+
async function updateTerminalConnectionsForCanvas(root, canvasName, canvasData, connectors) {
|
|
340
|
+
try {
|
|
341
|
+
const { updateTerminalConnections, initTerminalConfig } = await import('./terminal-config.js')
|
|
342
|
+
const { execSync } = await import('node:child_process')
|
|
343
|
+
initTerminalConfig(root)
|
|
344
|
+
|
|
345
|
+
let branch = 'unknown'
|
|
346
|
+
try {
|
|
347
|
+
branch = execSync('git branch --show-current', { encoding: 'utf8', cwd: root }).trim()
|
|
348
|
+
} catch { /* empty */ }
|
|
349
|
+
|
|
350
|
+
const widgets = canvasData.widgets || []
|
|
351
|
+
const widgetMap = new Map(widgets.map(w => [w.id, w]))
|
|
352
|
+
const terminalWidgets = widgets.filter((w) => w.type === 'terminal' || w.type === 'agent' || w.type === 'prompt')
|
|
353
|
+
|
|
354
|
+
for (const tw of terminalWidgets) {
|
|
355
|
+
const connectedIds = new Set()
|
|
356
|
+
const messagingPeers = []
|
|
357
|
+
for (const conn of connectors) {
|
|
358
|
+
let peerId = null
|
|
359
|
+
let direction = null
|
|
360
|
+
if (conn.start?.widgetId === tw.id) {
|
|
361
|
+
peerId = conn.end?.widgetId
|
|
362
|
+
direction = 'outgoing' // tw → peer
|
|
363
|
+
}
|
|
364
|
+
if (conn.end?.widgetId === tw.id) {
|
|
365
|
+
peerId = conn.start?.widgetId
|
|
366
|
+
direction = 'incoming' // peer → tw
|
|
367
|
+
}
|
|
368
|
+
if (peerId) {
|
|
369
|
+
connectedIds.add(peerId)
|
|
370
|
+
const mode = conn.meta?.messagingMode || 'none'
|
|
371
|
+
if (mode !== 'none') {
|
|
372
|
+
const peerWidget = widgetMap.get(peerId)
|
|
373
|
+
if (peerWidget && (peerWidget.type === 'terminal' || peerWidget.type === 'agent')) {
|
|
374
|
+
const canSend = mode === 'two-way' || (mode === 'one-way' && direction === 'outgoing')
|
|
375
|
+
const canReceive = mode === 'two-way' || (mode === 'one-way' && direction === 'incoming')
|
|
376
|
+
messagingPeers.push({
|
|
377
|
+
widgetId: peerId,
|
|
378
|
+
displayName: peerWidget.props?.prettyName || peerId,
|
|
379
|
+
configPath: `.storyboard/terminals/${peerId}.json`,
|
|
380
|
+
type: peerWidget.type,
|
|
381
|
+
canSend,
|
|
382
|
+
canReceive,
|
|
383
|
+
mode,
|
|
384
|
+
})
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
connectedIds.delete(undefined)
|
|
390
|
+
connectedIds.delete(null)
|
|
391
|
+
|
|
392
|
+
// Resolve full widget objects for connected widgets
|
|
393
|
+
const connectedWidgets = [...connectedIds]
|
|
394
|
+
.map(id => widgetMap.get(id))
|
|
395
|
+
.filter(Boolean)
|
|
396
|
+
.map(w => ({ id: w.id, type: w.type, props: w.props, position: w.position }))
|
|
397
|
+
|
|
398
|
+
// Build messaging section if there are messaging-enabled peers
|
|
399
|
+
const messaging = messagingPeers.length > 0 ? { peers: messagingPeers } : null
|
|
400
|
+
|
|
401
|
+
updateTerminalConnections({
|
|
402
|
+
branch,
|
|
403
|
+
canvasId: canvasName,
|
|
404
|
+
widgetId: tw.id,
|
|
405
|
+
connectedWidgets,
|
|
406
|
+
widgetProps: tw.props || null,
|
|
407
|
+
messaging,
|
|
408
|
+
})
|
|
409
|
+
}
|
|
410
|
+
} catch (err) {
|
|
411
|
+
devLog().logEvent('warn', 'Failed to update terminal connections', { error: err.message })
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Append an event to an existing canvas file.
|
|
416
|
+
// Marks the file in the write guard so the data plugin's watcher handler
|
|
417
|
+
// skips sending a duplicate HMR event (the server pushes its own via
|
|
418
|
+
// pushCanvasUpdate after the write).
|
|
419
|
+
function appendEvent(filePath, event) {
|
|
420
|
+
markCanvasWrite(filePath)
|
|
421
|
+
appendEventRaw(filePath, event)
|
|
422
|
+
// Unmark after enough time for the watcher to fire and be suppressed.
|
|
423
|
+
// macOS FSEvents latency is typically 100-500ms; 1s covers edge cases.
|
|
424
|
+
setTimeout(() => unmarkCanvasWrite(filePath), 1000)
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Prepare a terminal/agent widget: auto-assign displayName and pre-reserve identity.
|
|
429
|
+
* Shared by POST /widget and batch create-widget.
|
|
430
|
+
* @param {{ type: string, props: Object }} opts
|
|
431
|
+
* @param {string} widgetId
|
|
432
|
+
* @param {string} canvasName
|
|
433
|
+
* @param {import('node:http').IncomingMessage} [req]
|
|
434
|
+
*/
|
|
435
|
+
async function prepareTerminalWidget({ type, props, widgetId, canvasName, req }) {
|
|
436
|
+
if (type !== 'terminal' && type !== 'agent') return
|
|
437
|
+
|
|
438
|
+
if (!props.prettyName) {
|
|
439
|
+
try {
|
|
440
|
+
const { generateFriendlyName } = await import('./terminal-registry.js')
|
|
441
|
+
props.prettyName = generateFriendlyName()
|
|
442
|
+
} catch { /* registry not initialized yet */ }
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
try {
|
|
446
|
+
const { preReserveTerminalIdentity, initTerminalConfig } = await import('./terminal-config.js')
|
|
447
|
+
initTerminalConfig(root)
|
|
448
|
+
let branch = 'unknown'
|
|
449
|
+
try {
|
|
450
|
+
const { execSync } = await import('node:child_process')
|
|
451
|
+
branch = execSync('git branch --show-current', { encoding: 'utf8', cwd: root }).trim()
|
|
452
|
+
} catch { /* empty */ }
|
|
453
|
+
const serverUrl = `http://localhost:${req?.socket?.localPort || 1234}`
|
|
454
|
+
preReserveTerminalIdentity({
|
|
455
|
+
widgetId,
|
|
456
|
+
preDisplayName: props.prettyName || null,
|
|
457
|
+
canvasId: canvasName,
|
|
458
|
+
branch,
|
|
459
|
+
serverUrl,
|
|
460
|
+
})
|
|
461
|
+
} catch { /* best effort */ }
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Resolve which hot pool to use for a widget type + props.
|
|
466
|
+
* Agent widgets use their agentId as pool ID; terminals use 'terminal'.
|
|
467
|
+
*/
|
|
468
|
+
function resolvePoolId(type, props) {
|
|
469
|
+
if (type === 'agent' && props?.agentId) return props.agentId
|
|
470
|
+
return 'terminal'
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Try to acquire a warm session from the hot pool.
|
|
475
|
+
* @param {Object|null} hotPool — HotPoolManager instance
|
|
476
|
+
* @param {string} poolId — pool to acquire from
|
|
477
|
+
* @param {string} [mode] — 'auto' (default), 'hot', or 'cold'
|
|
478
|
+
* @returns {Object|null} acquired session or null
|
|
479
|
+
*/
|
|
480
|
+
function acquireFromPool(hotPool, poolId, mode) {
|
|
481
|
+
if (!hotPool || mode === 'cold') return null
|
|
482
|
+
const effectiveMode = mode || 'auto'
|
|
483
|
+
if (effectiveMode === 'cold') return null
|
|
484
|
+
if (!hotPool.has(poolId)) return null
|
|
485
|
+
return hotPool.acquire(poolId) || null
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Push live canvas update to connected clients via Vite HMR.
|
|
490
|
+
* Reads the full materialized state from disk and sends it as a custom
|
|
491
|
+
* event so useCanvas can update in-place without a page refresh.
|
|
492
|
+
*/
|
|
493
|
+
function pushCanvasUpdate(canvasName, filePath, viteWs) {
|
|
494
|
+
if (!viteWs) return
|
|
495
|
+
try {
|
|
496
|
+
const data = readCanvas(filePath)
|
|
497
|
+
viteWs.send({
|
|
498
|
+
type: 'custom',
|
|
499
|
+
event: 'storyboard:canvas-file-changed',
|
|
500
|
+
data: { canvasId: canvasName, name: canvasName, metadata: data },
|
|
501
|
+
})
|
|
502
|
+
|
|
503
|
+
// Refresh terminal config files on every canvas change so agents
|
|
504
|
+
// always see up-to-date connectedWidgets and widget props.
|
|
505
|
+
updateTerminalConnectionsForCanvas(root, canvasName, data, data.connectors || [])
|
|
506
|
+
} catch { /* best effort — watcher will catch it eventually */ }
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Write a new JSONL file with a single creation event.
|
|
510
|
+
// New files are detected naturally by Vite's watcher as an `add` event,
|
|
511
|
+
// which correctly triggers a full reload to register new routes.
|
|
512
|
+
function writeNewCanvas(filePath, event) {
|
|
513
|
+
fs.writeFileSync(filePath, serializeEvent(event) + '\n', 'utf-8')
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
return async (req, res, { body, path: routePath, method, __viteWs }) => {
|
|
517
|
+
// GET /folders — list available canvas folders
|
|
518
|
+
if (routePath === '/folders' && method === 'GET') {
|
|
519
|
+
const canvasDir = path.join(root, 'src', 'canvas')
|
|
520
|
+
let folders = []
|
|
521
|
+
try {
|
|
522
|
+
if (fs.existsSync(canvasDir)) {
|
|
523
|
+
const entries = fs.readdirSync(canvasDir, { withFileTypes: true })
|
|
524
|
+
// .folder directories (existing behavior)
|
|
525
|
+
const folderDirs = entries
|
|
526
|
+
.filter((d) => d.isDirectory() && d.name.endsWith('.folder'))
|
|
527
|
+
.map((d) => d.name.replace('.folder', ''))
|
|
528
|
+
// Plain directories containing .canvas.jsonl files
|
|
529
|
+
const plainDirs = entries
|
|
530
|
+
.filter((d) => {
|
|
531
|
+
if (!d.isDirectory() || d.name.endsWith('.folder') || d.name.startsWith('_')) return false
|
|
532
|
+
const files = fs.readdirSync(path.join(canvasDir, d.name))
|
|
533
|
+
return files.some((f) => f.endsWith('.canvas.jsonl'))
|
|
534
|
+
})
|
|
535
|
+
.map((d) => d.name)
|
|
536
|
+
folders = [...folderDirs, ...plainDirs]
|
|
537
|
+
}
|
|
538
|
+
} catch { /* empty */ }
|
|
539
|
+
sendJson(res, 200, { folders })
|
|
540
|
+
return
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// GET /read?name=... — read materialized canvas data from disk
|
|
544
|
+
if (routePath.startsWith('/read') && method === 'GET') {
|
|
545
|
+
const url = new URL(routePath, 'http://localhost')
|
|
546
|
+
const name = url.searchParams.get('name')
|
|
547
|
+
if (!name) {
|
|
548
|
+
sendJson(res, 400, { error: 'Canvas name is required (?name=...)' })
|
|
549
|
+
return
|
|
550
|
+
}
|
|
551
|
+
const filePath = findCanvasPath(root, name)
|
|
552
|
+
if (!filePath) {
|
|
553
|
+
sendJson(res, 404, { error: `Canvas "${name}" not found` })
|
|
554
|
+
return
|
|
555
|
+
}
|
|
556
|
+
try {
|
|
557
|
+
const data = readCanvas(filePath)
|
|
558
|
+
const widgetFilter = url.searchParams.get('widget')
|
|
559
|
+
if (widgetFilter) {
|
|
560
|
+
const widget = (data.widgets || []).find((w) => w.id === widgetFilter)
|
|
561
|
+
if (!widget) {
|
|
562
|
+
sendJson(res, 404, { error: `Widget "${widgetFilter}" not found in canvas "${name}"` })
|
|
563
|
+
return
|
|
564
|
+
}
|
|
565
|
+
sendJson(res, 200, { ...data, widgets: [widget] })
|
|
566
|
+
} else {
|
|
567
|
+
sendJson(res, 200, data)
|
|
568
|
+
}
|
|
569
|
+
} catch (err) {
|
|
570
|
+
sendJson(res, 500, { error: `Failed to read canvas: ${err.message}` })
|
|
571
|
+
}
|
|
572
|
+
return
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// GET /list — list all canvases
|
|
576
|
+
if (routePath === '/list' && method === 'GET') {
|
|
577
|
+
const files = findCanvasFiles(root)
|
|
578
|
+
const canvases = files.map((file) => {
|
|
579
|
+
const id = toCanvasId(file)
|
|
580
|
+
if (!id) return null
|
|
581
|
+
const { segments } = parseCanvasId(id)
|
|
582
|
+
const group = segments.length > 1 ? segments.slice(0, -1).join('/') : null
|
|
583
|
+
try {
|
|
584
|
+
const data = readCanvas(path.resolve(root, file))
|
|
585
|
+
return {
|
|
586
|
+
name: id,
|
|
587
|
+
title: data.title || segments[segments.length - 1],
|
|
588
|
+
path: file,
|
|
589
|
+
widgetCount: (data.widgets || []).length + (data.sources || []).length,
|
|
590
|
+
group,
|
|
591
|
+
}
|
|
592
|
+
} catch {
|
|
593
|
+
return { name: id, title: segments[segments.length - 1], path: file, widgetCount: 0, group }
|
|
594
|
+
}
|
|
595
|
+
}).filter(Boolean)
|
|
596
|
+
const groups = findCanvasMeta(root)
|
|
597
|
+
|
|
598
|
+
// Sort canvases within each group by saved pageOrder from .meta.json
|
|
599
|
+
const groupOrderMaps = new Map()
|
|
600
|
+
for (const [groupName, meta] of Object.entries(groups)) {
|
|
601
|
+
if (Array.isArray(meta.pageOrder)) {
|
|
602
|
+
const orderMap = new Map()
|
|
603
|
+
meta.pageOrder.forEach((entry, idx) => {
|
|
604
|
+
if (typeof entry === 'string' && !entry.startsWith('sep-')) orderMap.set(entry, idx)
|
|
605
|
+
})
|
|
606
|
+
groupOrderMaps.set(groupName, orderMap)
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
if (groupOrderMaps.size > 0) {
|
|
610
|
+
canvases.sort((a, b) => {
|
|
611
|
+
if (a.group !== b.group) return 0
|
|
612
|
+
const orderMap = a.group ? groupOrderMaps.get(a.group) : null
|
|
613
|
+
if (!orderMap) return 0
|
|
614
|
+
const ai = orderMap.has(a.name) ? orderMap.get(a.name) : Infinity
|
|
615
|
+
const bi = orderMap.has(b.name) ? orderMap.get(b.name) : Infinity
|
|
616
|
+
return ai - bi
|
|
617
|
+
})
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
sendJson(res, 200, { canvases, groups })
|
|
621
|
+
return
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// PUT /update — append update events to the canvas stream
|
|
625
|
+
if (routePath === '/update' && method === 'PUT') {
|
|
626
|
+
const { name, widgets, sources, settings, connectors } = body
|
|
627
|
+
|
|
628
|
+
if (!name) {
|
|
629
|
+
sendJson(res, 400, { error: 'Canvas name is required' })
|
|
630
|
+
return
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
const filePath = findCanvasPath(root, name)
|
|
634
|
+
if (!filePath) {
|
|
635
|
+
sendJson(res, 404, { error: `Canvas "${name}" not found` })
|
|
636
|
+
return
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
try {
|
|
640
|
+
const ts = new Date().toISOString()
|
|
641
|
+
|
|
642
|
+
if (widgets) {
|
|
643
|
+
// Guard against accidental canvas wipes: if the incoming widget count
|
|
644
|
+
// is much smaller than the current canvas, reject unless explicitly confirmed.
|
|
645
|
+
// This protects against agents/scripts that accidentally send a partial widget
|
|
646
|
+
// array to the widgets_replaced endpoint (which replaces ALL widgets).
|
|
647
|
+
const current = readCanvas(filePath)
|
|
648
|
+
const currentCount = (current.widgets || []).length
|
|
649
|
+
if (currentCount > 1 && widgets.length < currentCount * 0.5 && body.replaceAll !== true) {
|
|
650
|
+
sendJson(res, 400, {
|
|
651
|
+
error: `Refusing to replace ${currentCount} widgets with ${widgets.length}. `
|
|
652
|
+
+ `This would delete ${currentCount - widgets.length} widgets. `
|
|
653
|
+
+ `Use PATCH /_storyboard/canvas/widget to update individual widgets, `
|
|
654
|
+
+ `or pass "replaceAll": true to confirm full replacement.`,
|
|
655
|
+
})
|
|
656
|
+
return
|
|
657
|
+
}
|
|
658
|
+
const stamped = stampBoundsAll(widgets)
|
|
659
|
+
appendEvent(filePath, { event: 'widgets_replaced', timestamp: ts, widgets: stamped })
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
if (sources) {
|
|
663
|
+
appendEvent(filePath, { event: 'source_updated', timestamp: ts, sources })
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
if (connectors) {
|
|
667
|
+
appendEvent(filePath, { event: 'connectors_replaced', timestamp: ts, connectors })
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
if (settings) {
|
|
671
|
+
const filtered = {}
|
|
672
|
+
for (const [key, value] of Object.entries(settings)) {
|
|
673
|
+
if (['title', 'description', 'grid', 'gridSize', 'colorMode', 'dotted', 'centered', 'author', 'snapToGrid'].includes(key)) {
|
|
674
|
+
filtered[key] = value
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
if (Object.keys(filtered).length > 0) {
|
|
678
|
+
appendEvent(filePath, { event: 'settings_updated', timestamp: ts, settings: filtered })
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
sendJson(res, 200, { success: true, name })
|
|
683
|
+
pushCanvasUpdate(name, filePath, __viteWs)
|
|
684
|
+
} catch (err) {
|
|
685
|
+
sendJson(res, 500, { error: `Failed to update canvas: ${err.message}` })
|
|
686
|
+
}
|
|
687
|
+
return
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// POST /widget — append a widget_added event
|
|
691
|
+
if (routePath === '/widget' && method === 'POST') {
|
|
692
|
+
const { name, type, props = {}, pool, near, direction, resolve, source } = body
|
|
693
|
+
let position = body.position || { x: 0, y: 0 }
|
|
694
|
+
|
|
695
|
+
// Detect whether the caller provided an explicit position.
|
|
696
|
+
// `near === false` is the explicit opt-out ("put it exactly here").
|
|
697
|
+
const hasExplicitPosition = body.position && (body.position.x !== 0 || body.position.y !== 0)
|
|
698
|
+
const hasNearOptOut = near === false
|
|
699
|
+
const needsAutoPosition = !near && !hasExplicitPosition && !hasNearOptOut
|
|
700
|
+
|
|
701
|
+
if (!name) {
|
|
702
|
+
sendJson(res, 400, { error: 'Canvas name is required' })
|
|
703
|
+
return
|
|
704
|
+
}
|
|
705
|
+
if (!type) {
|
|
706
|
+
sendJson(res, 400, { error: 'Widget type is required' })
|
|
707
|
+
return
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
const filePath = findCanvasPath(root, name)
|
|
711
|
+
if (!filePath) {
|
|
712
|
+
sendJson(res, 404, { error: `Canvas "${name}" not found` })
|
|
713
|
+
return
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
try {
|
|
717
|
+
// Always read canvas when we need near, resolve, or auto-positioning
|
|
718
|
+
const needsCanvasRead = near || resolve || needsAutoPosition
|
|
719
|
+
let canvasWidgets = null
|
|
720
|
+
let canvasData = null
|
|
721
|
+
if (needsCanvasRead) {
|
|
722
|
+
canvasData = readCanvas(filePath)
|
|
723
|
+
canvasWidgets = canvasData.widgets || []
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
if (near) {
|
|
727
|
+
const refWidget = canvasWidgets.find((w) => w.id === near)
|
|
728
|
+
if (!refWidget) {
|
|
729
|
+
sendJson(res, 400, { error: `Widget "${near}" not found (--near)` })
|
|
730
|
+
return
|
|
731
|
+
}
|
|
732
|
+
position = computeNearPosition(refWidget, direction || 'right', type, props)
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// Auto-position: no --near, no explicit x,y → smart default
|
|
736
|
+
if (needsAutoPosition && !near) {
|
|
737
|
+
position = await computeAutoPosition(canvasWidgets, type, props, root, name, source || null)
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
if (near || resolve || needsAutoPosition) {
|
|
741
|
+
const resolved = resolvePosition({
|
|
742
|
+
x: position.x, y: position.y, type, props,
|
|
743
|
+
widgets: canvasWidgets,
|
|
744
|
+
gridSize: (canvasData && canvasData.gridSize) || 24,
|
|
745
|
+
})
|
|
746
|
+
position = { x: resolved.x, y: resolved.y }
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
const widgetId = generateWidgetId(type)
|
|
750
|
+
|
|
751
|
+
await prepareTerminalWidget({ type, props, widgetId, canvasName: name, req })
|
|
752
|
+
|
|
753
|
+
// Hot pool acquisition for terminal/agent widgets
|
|
754
|
+
let hotSession = null
|
|
755
|
+
if ((type === 'terminal' || type === 'agent') && pool !== 'cold') {
|
|
756
|
+
const poolId = resolvePoolId(type, props)
|
|
757
|
+
hotSession = acquireFromPool(hotPool, poolId, pool)
|
|
758
|
+
if (!hotSession && pool === 'hot') {
|
|
759
|
+
sendJson(res, 409, { error: `No warm sessions available in pool "${poolId}"` })
|
|
760
|
+
return
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
const widget = stampBounds({ id: widgetId, type, position, props })
|
|
765
|
+
|
|
766
|
+
appendEvent(filePath, {
|
|
767
|
+
event: 'widget_added',
|
|
768
|
+
timestamp: new Date().toISOString(),
|
|
769
|
+
widget,
|
|
770
|
+
})
|
|
771
|
+
|
|
772
|
+
const response = { success: true, widget }
|
|
773
|
+
if (hotSession) response.hotSession = { id: hotSession.id, tmuxName: hotSession.tmuxName || null, webglReady: !!hotSession.webglReady }
|
|
774
|
+
sendJson(res, 201, response)
|
|
775
|
+
pushCanvasUpdate(name, filePath, __viteWs)
|
|
776
|
+
} catch (err) {
|
|
777
|
+
sendJson(res, 500, { error: `Failed to add widget: ${err.message}` })
|
|
778
|
+
}
|
|
779
|
+
return
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// DELETE /widget — append a widget_removed event
|
|
783
|
+
if (routePath === '/widget' && method === 'DELETE') {
|
|
784
|
+
const { name, widgetId } = body
|
|
785
|
+
|
|
786
|
+
if (!name || !widgetId) {
|
|
787
|
+
sendJson(res, 400, { error: 'Canvas name and widgetId are required' })
|
|
788
|
+
return
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
const filePath = findCanvasPath(root, name)
|
|
792
|
+
if (!filePath) {
|
|
793
|
+
sendJson(res, 404, { error: `Canvas "${name}" not found` })
|
|
794
|
+
return
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
try {
|
|
798
|
+
// Verify the widget exists before appending the removal event
|
|
799
|
+
const data = readCanvas(filePath)
|
|
800
|
+
const widget = (data.widgets || []).find((w) => w.id === widgetId)
|
|
801
|
+
if (!widget) {
|
|
802
|
+
sendJson(res, 404, { error: `Widget "${widgetId}" not found in canvas "${name}"` })
|
|
803
|
+
return
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
appendEvent(filePath, {
|
|
807
|
+
event: 'widget_removed',
|
|
808
|
+
timestamp: new Date().toISOString(),
|
|
809
|
+
widgetId,
|
|
810
|
+
})
|
|
811
|
+
|
|
812
|
+
// Orphan terminal session when a terminal widget is deleted (not killed)
|
|
813
|
+
if (widget.type === 'terminal' || widget.type === 'agent') {
|
|
814
|
+
try {
|
|
815
|
+
const { orphanTerminalSession } = await import('./terminal-server.js')
|
|
816
|
+
orphanTerminalSession(widgetId)
|
|
817
|
+
} catch (err) {
|
|
818
|
+
devLog().logEvent('warn', `Failed to orphan terminal session for ${widgetId}`, { widgetId, error: err.message })
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
sendJson(res, 200, { success: true, removed: 1 })
|
|
823
|
+
pushCanvasUpdate(name, filePath, __viteWs)
|
|
824
|
+
} catch (err) {
|
|
825
|
+
sendJson(res, 500, { error: `Failed to remove widget: ${err.message}` })
|
|
826
|
+
}
|
|
827
|
+
return
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// PATCH /widget — update a single widget's props
|
|
831
|
+
if (routePath === '/widget' && method === 'PATCH') {
|
|
832
|
+
const { name, widgetId, props, position } = body
|
|
833
|
+
|
|
834
|
+
if (!name || !widgetId) {
|
|
835
|
+
sendJson(res, 400, { error: 'Canvas name and widgetId are required' })
|
|
836
|
+
return
|
|
837
|
+
}
|
|
838
|
+
if (!props && !position) {
|
|
839
|
+
sendJson(res, 400, { error: 'At least one of props or position is required' })
|
|
840
|
+
return
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
const filePath = findCanvasPath(root, name)
|
|
844
|
+
if (!filePath) {
|
|
845
|
+
sendJson(res, 404, { error: `Canvas "${name}" not found` })
|
|
846
|
+
return
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
try {
|
|
850
|
+
const data = readCanvas(filePath)
|
|
851
|
+
const widget = (data.widgets || []).find((w) => w.id === widgetId)
|
|
852
|
+
if (!widget) {
|
|
853
|
+
sendJson(res, 404, { error: `Widget "${widgetId}" not found in canvas "${name}"` })
|
|
854
|
+
return
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
const ts = new Date().toISOString()
|
|
858
|
+
|
|
859
|
+
if (props) {
|
|
860
|
+
appendEvent(filePath, {
|
|
861
|
+
event: 'widget_updated',
|
|
862
|
+
timestamp: ts,
|
|
863
|
+
widgetId,
|
|
864
|
+
props,
|
|
865
|
+
})
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
if (position) {
|
|
869
|
+
// Merge with existing position so partial updates (only --x or --y) are safe
|
|
870
|
+
const mergedPosition = { ...widget.position, ...position }
|
|
871
|
+
appendEvent(filePath, {
|
|
872
|
+
event: 'widget_moved',
|
|
873
|
+
timestamp: ts,
|
|
874
|
+
widgetId,
|
|
875
|
+
position: mergedPosition,
|
|
876
|
+
})
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
// Return the merged widget for convenience
|
|
880
|
+
const merged = {
|
|
881
|
+
...widget,
|
|
882
|
+
props: { ...widget.props, ...(props || {}) },
|
|
883
|
+
position: position ? { ...widget.position, ...position } : widget.position,
|
|
884
|
+
}
|
|
885
|
+
sendJson(res, 200, { success: true, widget: merged })
|
|
886
|
+
pushCanvasUpdate(name, filePath, __viteWs)
|
|
887
|
+
} catch (err) {
|
|
888
|
+
sendJson(res, 500, { error: `Failed to update widget: ${err.message}` })
|
|
889
|
+
}
|
|
890
|
+
return
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
// POST /connector — append a connector_added event
|
|
894
|
+
if (routePath === '/connector' && method === 'POST') {
|
|
895
|
+
const { name, startWidgetId, startAnchor, endWidgetId, endAnchor, connectorType = 'default' } = body
|
|
896
|
+
|
|
897
|
+
if (!name) {
|
|
898
|
+
sendJson(res, 400, { error: 'Canvas name is required' })
|
|
899
|
+
return
|
|
900
|
+
}
|
|
901
|
+
if (!startWidgetId || !endWidgetId) {
|
|
902
|
+
sendJson(res, 400, { error: 'startWidgetId and endWidgetId are required' })
|
|
903
|
+
return
|
|
904
|
+
}
|
|
905
|
+
const validAnchors = ['top', 'bottom', 'left', 'right']
|
|
906
|
+
if (!validAnchors.includes(startAnchor) || !validAnchors.includes(endAnchor)) {
|
|
907
|
+
sendJson(res, 400, { error: `Anchors must be one of: ${validAnchors.join(', ')}` })
|
|
908
|
+
return
|
|
909
|
+
}
|
|
910
|
+
if (startWidgetId === endWidgetId) {
|
|
911
|
+
sendJson(res, 400, { error: 'Cannot connect a widget to itself' })
|
|
912
|
+
return
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
const filePath = findCanvasPath(root, name)
|
|
916
|
+
if (!filePath) {
|
|
917
|
+
sendJson(res, 404, { error: `Canvas "${name}" not found` })
|
|
918
|
+
return
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
try {
|
|
922
|
+
const data = readCanvas(filePath)
|
|
923
|
+
const widgetIds = new Set((data.widgets || []).map((w) => w.id))
|
|
924
|
+
if (!widgetIds.has(startWidgetId)) {
|
|
925
|
+
sendJson(res, 404, { error: `Widget "${startWidgetId}" not found` })
|
|
926
|
+
return
|
|
927
|
+
}
|
|
928
|
+
if (!widgetIds.has(endWidgetId)) {
|
|
929
|
+
sendJson(res, 404, { error: `Widget "${endWidgetId}" not found` })
|
|
930
|
+
return
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
const connectorId = generateWidgetId('connector')
|
|
934
|
+
const connector = {
|
|
935
|
+
id: connectorId,
|
|
936
|
+
type: 'connector',
|
|
937
|
+
connectorType,
|
|
938
|
+
start: { widgetId: startWidgetId, anchor: startAnchor },
|
|
939
|
+
end: { widgetId: endWidgetId, anchor: endAnchor },
|
|
940
|
+
meta: {},
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
appendEvent(filePath, {
|
|
944
|
+
event: 'connector_added',
|
|
945
|
+
timestamp: new Date().toISOString(),
|
|
946
|
+
connector,
|
|
947
|
+
})
|
|
948
|
+
|
|
949
|
+
sendJson(res, 201, { success: true, connector })
|
|
950
|
+
pushCanvasUpdate(name, filePath, __viteWs)
|
|
951
|
+
} catch (err) {
|
|
952
|
+
sendJson(res, 500, { error: `Failed to add connector: ${err.message}` })
|
|
953
|
+
}
|
|
954
|
+
return
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
// PATCH /connector — update connector anchors and/or meta
|
|
958
|
+
if (routePath === '/connector' && method === 'PATCH') {
|
|
959
|
+
const { name, connectorId, meta, startAnchor, endAnchor } = body
|
|
960
|
+
|
|
961
|
+
if (!name || !connectorId) {
|
|
962
|
+
sendJson(res, 400, { error: 'Canvas name and connectorId are required' })
|
|
963
|
+
return
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
const filePath = findCanvasPath(root, name)
|
|
967
|
+
if (!filePath) {
|
|
968
|
+
sendJson(res, 404, { error: `Canvas "${name}" not found` })
|
|
969
|
+
return
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
try {
|
|
973
|
+
const data = readCanvas(filePath)
|
|
974
|
+
const connector = (data.connectors || []).find((c) => c.id === connectorId)
|
|
975
|
+
if (!connector) {
|
|
976
|
+
sendJson(res, 404, { error: `Connector "${connectorId}" not found in canvas "${name}"` })
|
|
977
|
+
return
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
const validAnchors = ['top', 'right', 'bottom', 'left']
|
|
981
|
+
if (startAnchor && !validAnchors.includes(startAnchor)) {
|
|
982
|
+
sendJson(res, 400, { error: `Invalid startAnchor "${startAnchor}". Must be one of: ${validAnchors.join(', ')}` })
|
|
983
|
+
return
|
|
984
|
+
}
|
|
985
|
+
if (endAnchor && !validAnchors.includes(endAnchor)) {
|
|
986
|
+
sendJson(res, 400, { error: `Invalid endAnchor "${endAnchor}". Must be one of: ${validAnchors.join(', ')}` })
|
|
987
|
+
return
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
const updates = {}
|
|
991
|
+
if (meta) updates.meta = { ...meta }
|
|
992
|
+
if (startAnchor) updates.startAnchor = startAnchor
|
|
993
|
+
if (endAnchor) updates.endAnchor = endAnchor
|
|
994
|
+
|
|
995
|
+
appendEvent(filePath, {
|
|
996
|
+
event: 'connector_updated',
|
|
997
|
+
timestamp: new Date().toISOString(),
|
|
998
|
+
connectorId,
|
|
999
|
+
updates,
|
|
1000
|
+
})
|
|
1001
|
+
|
|
1002
|
+
sendJson(res, 200, { success: true })
|
|
1003
|
+
pushCanvasUpdate(name, filePath, __viteWs)
|
|
1004
|
+
|
|
1005
|
+
// Inject messaging skill into both terminals when mode changes
|
|
1006
|
+
if (meta?.messagingMode || meta?.messaging) {
|
|
1007
|
+
const widgets = data.widgets || []
|
|
1008
|
+
const startWidget = widgets.find((w) => w.id === connector.start?.widgetId)
|
|
1009
|
+
const endWidget = widgets.find((w) => w.id === connector.end?.widgetId)
|
|
1010
|
+
const isTerminalType = (w) => w && (w.type === 'terminal' || w.type === 'agent')
|
|
1011
|
+
|
|
1012
|
+
if (isTerminalType(startWidget) && isTerminalType(endWidget)) {
|
|
1013
|
+
try {
|
|
1014
|
+
const { execSync } = await import('node:child_process')
|
|
1015
|
+
const { findTmuxNameForWidget } = await import('./terminal-registry.js')
|
|
1016
|
+
|
|
1017
|
+
// Resolve effective mode per widget
|
|
1018
|
+
const getMode = (w) => {
|
|
1019
|
+
if (meta.messagingMode === 'two-way') return 'two-way'
|
|
1020
|
+
if (meta.messaging?.[w.id]) return meta.messaging[w.id]
|
|
1021
|
+
return 'none'
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
const pairs = [
|
|
1025
|
+
{ widget: startWidget, peer: endWidget },
|
|
1026
|
+
{ widget: endWidget, peer: startWidget },
|
|
1027
|
+
]
|
|
1028
|
+
|
|
1029
|
+
for (const { widget: w, peer } of pairs) {
|
|
1030
|
+
const tmuxName = findTmuxNameForWidget(w.id)
|
|
1031
|
+
if (!tmuxName) continue
|
|
1032
|
+
|
|
1033
|
+
const peerName = peer.props?.prettyName || peer.id
|
|
1034
|
+
const mode = getMode(w)
|
|
1035
|
+
let skillMsg
|
|
1036
|
+
|
|
1037
|
+
if (mode === 'two-way') {
|
|
1038
|
+
skillMsg = `📡 [Two-way messaging ACTIVE with ${peerName}]\n\nYou are in a live conversation with ${peerName} (${peer.id}). Messages appear as: 📩 ${peerName}: <their message>\n\nWhen you receive a message:\n1. Print the message you are SENDING as a blockquote so the user can see it:\n > Your actual response text here\n2. Then save and send in one step:\n npx storyboard terminal output --summary "summary" --content "your response" && npx storyboard terminal send ${peer.id} "your response"\n\nIMPORTANT: Always show your actual response text as a > blockquote BEFORE the shell command. The user needs to see what you said, not just "I fired back with a joke."\n\nDo NOT ask for permission. Respond immediately. Keep it concise.`
|
|
1039
|
+
} else if (mode === 'one-way') {
|
|
1040
|
+
skillMsg = `📡 [One-way messaging → to ${peerName}]\nYou can send messages to ${peerName} (${peer.id}).\n\nAfter EVERY response, save your output:\n npx storyboard terminal output --summary "summary" --content "your full response"\n\nTo send a message:\n npx storyboard terminal send ${peer.id} "message"`
|
|
1041
|
+
} else {
|
|
1042
|
+
skillMsg = `📡 [Messaging with ${peerName} disabled]`
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
try {
|
|
1046
|
+
execSync(`tmux send-keys -t "${tmuxName}" -l ${JSON.stringify(skillMsg)}`, { stdio: 'ignore' })
|
|
1047
|
+
execSync(`tmux send-keys -t "${tmuxName}" Enter`, { stdio: 'ignore' })
|
|
1048
|
+
} catch { /* tmux session may not be active */ }
|
|
1049
|
+
}
|
|
1050
|
+
} catch { /* best effort */ }
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
} catch (err) {
|
|
1054
|
+
sendJson(res, 500, { error: `Failed to update connector: ${err.message}` })
|
|
1055
|
+
}
|
|
1056
|
+
return
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
// DELETE /connector — append a connector_removed event
|
|
1060
|
+
if (routePath === '/connector' && method === 'DELETE') {
|
|
1061
|
+
const { name, connectorId } = body
|
|
1062
|
+
|
|
1063
|
+
if (!name || !connectorId) {
|
|
1064
|
+
sendJson(res, 400, { error: 'Canvas name and connectorId are required' })
|
|
1065
|
+
return
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
const filePath = findCanvasPath(root, name)
|
|
1069
|
+
if (!filePath) {
|
|
1070
|
+
sendJson(res, 404, { error: `Canvas "${name}" not found` })
|
|
1071
|
+
return
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
try {
|
|
1075
|
+
const data = readCanvas(filePath)
|
|
1076
|
+
const exists = (data.connectors || []).some((c) => c.id === connectorId)
|
|
1077
|
+
if (!exists) {
|
|
1078
|
+
sendJson(res, 404, { error: `Connector "${connectorId}" not found in canvas "${name}"` })
|
|
1079
|
+
return
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
appendEvent(filePath, {
|
|
1083
|
+
event: 'connector_removed',
|
|
1084
|
+
timestamp: new Date().toISOString(),
|
|
1085
|
+
connectorId,
|
|
1086
|
+
})
|
|
1087
|
+
|
|
1088
|
+
sendJson(res, 200, { success: true, removed: 1 })
|
|
1089
|
+
pushCanvasUpdate(name, filePath, __viteWs)
|
|
1090
|
+
} catch (err) {
|
|
1091
|
+
sendJson(res, 500, { error: `Failed to remove connector: ${err.message}` })
|
|
1092
|
+
}
|
|
1093
|
+
return
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
// POST /broadcast — toggle broadcast messaging for a widget and its connections.
|
|
1097
|
+
// Default: direct neighbors only. passThrough: true → BFS full connected component.
|
|
1098
|
+
if (routePath === '/broadcast' && method === 'POST') {
|
|
1099
|
+
const { name, widgetId, mode = 'two-way', passThrough = false } = body
|
|
1100
|
+
|
|
1101
|
+
if (!name || !widgetId) {
|
|
1102
|
+
sendJson(res, 400, { error: 'Canvas name and widgetId are required' })
|
|
1103
|
+
return
|
|
1104
|
+
}
|
|
1105
|
+
if (mode !== 'two-way' && mode !== 'one-way' && mode !== 'none') {
|
|
1106
|
+
sendJson(res, 400, { error: 'mode must be "two-way", "one-way", or "none"' })
|
|
1107
|
+
return
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
const filePath = findCanvasPath(root, name)
|
|
1111
|
+
if (!filePath) {
|
|
1112
|
+
sendJson(res, 404, { error: `Canvas "${name}" not found` })
|
|
1113
|
+
return
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
try {
|
|
1117
|
+
const data = readCanvas(filePath)
|
|
1118
|
+
const widgets = data.widgets || []
|
|
1119
|
+
const connectors = data.connectors || []
|
|
1120
|
+
const widgetMap = new Map(widgets.map((w) => [w.id, w]))
|
|
1121
|
+
|
|
1122
|
+
const sourceWidget = widgetMap.get(widgetId)
|
|
1123
|
+
if (!sourceWidget) {
|
|
1124
|
+
sendJson(res, 404, { error: `Widget "${widgetId}" not found` })
|
|
1125
|
+
return
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
const isTerminalType = (w) => w && (w.type === 'terminal' || w.type === 'agent')
|
|
1129
|
+
|
|
1130
|
+
// Find connectors to update via BFS (or direct neighbors only)
|
|
1131
|
+
const affectedConnectorIds = new Set()
|
|
1132
|
+
const affectedWidgetIds = new Set([widgetId])
|
|
1133
|
+
|
|
1134
|
+
if (passThrough) {
|
|
1135
|
+
// BFS: traverse entire connected component of terminal/agent widgets
|
|
1136
|
+
const visited = new Set([widgetId])
|
|
1137
|
+
const queue = [widgetId]
|
|
1138
|
+
while (queue.length > 0) {
|
|
1139
|
+
const current = queue.shift()
|
|
1140
|
+
for (const conn of connectors) {
|
|
1141
|
+
let peerId = null
|
|
1142
|
+
if (conn.start?.widgetId === current && conn.end?.widgetId) peerId = conn.end.widgetId
|
|
1143
|
+
if (conn.end?.widgetId === current && conn.start?.widgetId) peerId = conn.start.widgetId
|
|
1144
|
+
if (!peerId || visited.has(peerId)) continue
|
|
1145
|
+
const peer = widgetMap.get(peerId)
|
|
1146
|
+
if (!isTerminalType(peer)) continue
|
|
1147
|
+
affectedConnectorIds.add(conn.id)
|
|
1148
|
+
affectedWidgetIds.add(peerId)
|
|
1149
|
+
visited.add(peerId)
|
|
1150
|
+
queue.push(peerId)
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
} else {
|
|
1154
|
+
// Direct neighbors only
|
|
1155
|
+
for (const conn of connectors) {
|
|
1156
|
+
let peerId = null
|
|
1157
|
+
if (conn.start?.widgetId === widgetId && conn.end?.widgetId) peerId = conn.end.widgetId
|
|
1158
|
+
if (conn.end?.widgetId === widgetId && conn.start?.widgetId) peerId = conn.start.widgetId
|
|
1159
|
+
if (!peerId) continue
|
|
1160
|
+
const peer = widgetMap.get(peerId)
|
|
1161
|
+
if (!isTerminalType(peer)) continue
|
|
1162
|
+
affectedConnectorIds.add(conn.id)
|
|
1163
|
+
affectedWidgetIds.add(peerId)
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
// Update all affected connectors
|
|
1168
|
+
const ts = new Date().toISOString()
|
|
1169
|
+
const messagingMode = mode === 'none' ? null : mode
|
|
1170
|
+
for (const connId of affectedConnectorIds) {
|
|
1171
|
+
appendEvent(filePath, {
|
|
1172
|
+
event: 'connector_updated',
|
|
1173
|
+
timestamp: ts,
|
|
1174
|
+
connectorId: connId,
|
|
1175
|
+
updates: { meta: { messagingMode } },
|
|
1176
|
+
})
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
sendJson(res, 200, {
|
|
1180
|
+
success: true,
|
|
1181
|
+
affectedConnectors: [...affectedConnectorIds],
|
|
1182
|
+
affectedWidgets: [...affectedWidgetIds],
|
|
1183
|
+
})
|
|
1184
|
+
pushCanvasUpdate(name, filePath, __viteWs)
|
|
1185
|
+
|
|
1186
|
+
// Inject messaging skill into affected terminals
|
|
1187
|
+
if (affectedConnectorIds.size > 0) {
|
|
1188
|
+
try {
|
|
1189
|
+
const { execSync } = await import('node:child_process')
|
|
1190
|
+
const { findTmuxNameForWidget } = await import('./terminal-registry.js')
|
|
1191
|
+
|
|
1192
|
+
for (const wId of affectedWidgetIds) {
|
|
1193
|
+
const w = widgetMap.get(wId)
|
|
1194
|
+
if (!isTerminalType(w)) continue
|
|
1195
|
+
const tmuxName = findTmuxNameForWidget(wId)
|
|
1196
|
+
if (!tmuxName) continue
|
|
1197
|
+
|
|
1198
|
+
// Build peer list for this widget
|
|
1199
|
+
const peers = []
|
|
1200
|
+
for (const conn of connectors) {
|
|
1201
|
+
let peerId = null
|
|
1202
|
+
if (conn.start?.widgetId === wId) peerId = conn.end?.widgetId
|
|
1203
|
+
if (conn.end?.widgetId === wId) peerId = conn.start?.widgetId
|
|
1204
|
+
if (peerId && affectedWidgetIds.has(peerId) && peerId !== wId) {
|
|
1205
|
+
const peer = widgetMap.get(peerId)
|
|
1206
|
+
if (peer) peers.push(peer)
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
if (mode === 'none') {
|
|
1211
|
+
const msg = '📡 [Broadcast disabled]'
|
|
1212
|
+
try {
|
|
1213
|
+
execSync(`tmux send-keys -t "${tmuxName}" -l ${JSON.stringify(msg)}`, { stdio: 'ignore' })
|
|
1214
|
+
execSync(`tmux send-keys -t "${tmuxName}" Enter`, { stdio: 'ignore' })
|
|
1215
|
+
} catch { /* session may not be active */ }
|
|
1216
|
+
} else {
|
|
1217
|
+
const peerNames = peers.map((p) => p.props?.prettyName || p.id).join(', ')
|
|
1218
|
+
const msg = `📡 [Broadcast ${mode} ACTIVE with ${peerNames}]`
|
|
1219
|
+
try {
|
|
1220
|
+
execSync(`tmux send-keys -t "${tmuxName}" -l ${JSON.stringify(msg)}`, { stdio: 'ignore' })
|
|
1221
|
+
execSync(`tmux send-keys -t "${tmuxName}" Enter`, { stdio: 'ignore' })
|
|
1222
|
+
} catch { /* session may not be active */ }
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
} catch { /* best effort */ }
|
|
1226
|
+
}
|
|
1227
|
+
} catch (err) {
|
|
1228
|
+
sendJson(res, 500, { error: `Failed to update broadcast: ${err.message}` })
|
|
1229
|
+
}
|
|
1230
|
+
return
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
// POST /batch — execute multiple canvas operations in a single request.
|
|
1234
|
+
// Reads the canvas once, appends all events, pushes ONE HMR update at the end.
|
|
1235
|
+
// Operations reference earlier results via $index (auto) or $refName (opt-in).
|
|
1236
|
+
if (routePath === '/batch' && method === 'POST') {
|
|
1237
|
+
const { name, operations } = body
|
|
1238
|
+
|
|
1239
|
+
if (!name) {
|
|
1240
|
+
sendJson(res, 400, { error: 'Canvas name is required' })
|
|
1241
|
+
return
|
|
1242
|
+
}
|
|
1243
|
+
if (!Array.isArray(operations) || operations.length === 0) {
|
|
1244
|
+
sendJson(res, 400, { error: 'operations must be a non-empty array' })
|
|
1245
|
+
return
|
|
1246
|
+
}
|
|
1247
|
+
if (operations.length > 200) {
|
|
1248
|
+
sendJson(res, 400, { error: 'Maximum 200 operations per batch' })
|
|
1249
|
+
return
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
const filePath = findCanvasPath(root, name)
|
|
1253
|
+
if (!filePath) {
|
|
1254
|
+
sendJson(res, 404, { error: `Canvas "${name}" not found` })
|
|
1255
|
+
return
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
try {
|
|
1259
|
+
const canvasData = readCanvas(filePath)
|
|
1260
|
+
const widgetIds = new Set((canvasData.widgets || []).map((w) => w.id))
|
|
1261
|
+
const connectorIds = new Set((canvasData.connectors || []).map((c) => c.id))
|
|
1262
|
+
const widgetMap = new Map((canvasData.widgets || []).map((w) => [w.id, { ...w }]))
|
|
1263
|
+
const connectorMap = new Map((canvasData.connectors || []).map((c) => [c.id, { ...c }]))
|
|
1264
|
+
|
|
1265
|
+
const refs = {}
|
|
1266
|
+
const results = []
|
|
1267
|
+
const validAnchors = ['top', 'bottom', 'left', 'right']
|
|
1268
|
+
|
|
1269
|
+
// Resolve $ref strings — "$0", "$myName", etc.
|
|
1270
|
+
function resolveRef(val) {
|
|
1271
|
+
if (typeof val !== 'string' || !val.startsWith('$')) return val
|
|
1272
|
+
const refName = val.slice(1)
|
|
1273
|
+
if (refs[refName] !== undefined) return refs[refName]
|
|
1274
|
+
throw new Error(`Unknown ref "${val}"`)
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
for (let i = 0; i < operations.length; i++) {
|
|
1278
|
+
const op = operations[i]
|
|
1279
|
+
const ts = new Date().toISOString()
|
|
1280
|
+
|
|
1281
|
+
try {
|
|
1282
|
+
switch (op.op) {
|
|
1283
|
+
case 'create-widget': {
|
|
1284
|
+
const { type, props = {}, ref, pool, near, direction, resolve: doResolve, source: opSource } = op
|
|
1285
|
+
let position = op.position || { x: 0, y: 0 }
|
|
1286
|
+
if (!type) throw new Error('type is required')
|
|
1287
|
+
|
|
1288
|
+
// Detect whether an explicit position was provided
|
|
1289
|
+
const hasExplicitPos = op.position && (op.position.x !== 0 || op.position.y !== 0)
|
|
1290
|
+
const hasNearOptOut = near === false
|
|
1291
|
+
const needsAuto = !near && !hasExplicitPos && !hasNearOptOut
|
|
1292
|
+
|
|
1293
|
+
// --near: compute position relative to a reference widget
|
|
1294
|
+
if (near) {
|
|
1295
|
+
const nearId = resolveRef(near)
|
|
1296
|
+
const refWidget = widgetMap.get(nearId)
|
|
1297
|
+
if (!refWidget) throw new Error(`Widget "${nearId}" not found (near)`)
|
|
1298
|
+
position = computeNearPosition(refWidget, direction || 'right', type, props)
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
// Auto-position: no --near, no explicit x,y → smart default
|
|
1302
|
+
if (needsAuto && !near) {
|
|
1303
|
+
const currentWidgets = Array.from(widgetMap.values())
|
|
1304
|
+
position = await computeAutoPosition(currentWidgets, type, props, root, name, opSource || null)
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
// Collision resolution: uses live widgetMap (includes earlier batch creates)
|
|
1308
|
+
if (near || doResolve || needsAuto) {
|
|
1309
|
+
const resolved = resolvePosition({
|
|
1310
|
+
x: position.x, y: position.y, type, props,
|
|
1311
|
+
widgets: Array.from(widgetMap.values()),
|
|
1312
|
+
gridSize: canvasData.gridSize || 24,
|
|
1313
|
+
})
|
|
1314
|
+
position = { x: resolved.x, y: resolved.y }
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
const widgetId = generateWidgetId(type)
|
|
1318
|
+
await prepareTerminalWidget({ type, props, widgetId, canvasName: name, req })
|
|
1319
|
+
|
|
1320
|
+
let hotSession = null
|
|
1321
|
+
if ((type === 'terminal' || type === 'agent') && pool !== 'cold') {
|
|
1322
|
+
const poolId = resolvePoolId(type, props)
|
|
1323
|
+
hotSession = acquireFromPool(hotPool, poolId, pool)
|
|
1324
|
+
if (!hotSession && pool === 'hot') throw new Error(`No warm sessions available in pool "${poolId}"`)
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
const widget = stampBounds({ id: widgetId, type, position, props })
|
|
1328
|
+
|
|
1329
|
+
appendEvent(filePath, { event: 'widget_added', timestamp: ts, widget })
|
|
1330
|
+
|
|
1331
|
+
widgetIds.add(widgetId)
|
|
1332
|
+
widgetMap.set(widgetId, widget)
|
|
1333
|
+
refs[String(i)] = widgetId
|
|
1334
|
+
if (ref) refs[ref] = widgetId
|
|
1335
|
+
|
|
1336
|
+
const result = { index: i, op: 'create-widget', ref: ref || undefined, widgetId, widget }
|
|
1337
|
+
if (hotSession) result.hotSession = { id: hotSession.id, tmuxName: hotSession.tmuxName || null, webglReady: !!hotSession.webglReady }
|
|
1338
|
+
results.push(result)
|
|
1339
|
+
break
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
case 'update-widget': {
|
|
1343
|
+
const widgetId = resolveRef(op.widgetId)
|
|
1344
|
+
const { props } = op
|
|
1345
|
+
if (!widgetId) throw new Error('widgetId is required')
|
|
1346
|
+
if (!props) throw new Error('props is required')
|
|
1347
|
+
if (!widgetIds.has(widgetId)) throw new Error(`Widget "${widgetId}" not found`)
|
|
1348
|
+
|
|
1349
|
+
appendEvent(filePath, { event: 'widget_updated', timestamp: ts, widgetId, props })
|
|
1350
|
+
|
|
1351
|
+
const existing = widgetMap.get(widgetId)
|
|
1352
|
+
if (existing) existing.props = { ...existing.props, ...props }
|
|
1353
|
+
|
|
1354
|
+
results.push({ index: i, op: 'update-widget', widgetId, success: true })
|
|
1355
|
+
break
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
case 'move-widget': {
|
|
1359
|
+
const widgetId = resolveRef(op.widgetId)
|
|
1360
|
+
const { position } = op
|
|
1361
|
+
if (!widgetId) throw new Error('widgetId is required')
|
|
1362
|
+
if (!position) throw new Error('position is required')
|
|
1363
|
+
if (!widgetIds.has(widgetId)) throw new Error(`Widget "${widgetId}" not found`)
|
|
1364
|
+
|
|
1365
|
+
const existing = widgetMap.get(widgetId)
|
|
1366
|
+
const mergedPosition = { ...(existing?.position || {}), ...position }
|
|
1367
|
+
|
|
1368
|
+
appendEvent(filePath, { event: 'widget_moved', timestamp: ts, widgetId, position: mergedPosition })
|
|
1369
|
+
|
|
1370
|
+
if (existing) existing.position = mergedPosition
|
|
1371
|
+
|
|
1372
|
+
results.push({ index: i, op: 'move-widget', widgetId, success: true })
|
|
1373
|
+
break
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
case 'delete-widget': {
|
|
1377
|
+
const widgetId = resolveRef(op.widgetId)
|
|
1378
|
+
if (!widgetId) throw new Error('widgetId is required')
|
|
1379
|
+
if (!widgetIds.has(widgetId)) throw new Error(`Widget "${widgetId}" not found`)
|
|
1380
|
+
|
|
1381
|
+
appendEvent(filePath, { event: 'widget_removed', timestamp: ts, widgetId })
|
|
1382
|
+
|
|
1383
|
+
widgetIds.delete(widgetId)
|
|
1384
|
+
widgetMap.delete(widgetId)
|
|
1385
|
+
|
|
1386
|
+
results.push({ index: i, op: 'delete-widget', widgetId, success: true })
|
|
1387
|
+
break
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
case 'create-connector': {
|
|
1391
|
+
const startWidgetId = resolveRef(op.startWidgetId)
|
|
1392
|
+
const endWidgetId = resolveRef(op.endWidgetId)
|
|
1393
|
+
const { startAnchor = 'right', endAnchor = 'left', connectorType = 'default', ref } = op
|
|
1394
|
+
|
|
1395
|
+
if (!startWidgetId || !endWidgetId) throw new Error('startWidgetId and endWidgetId are required')
|
|
1396
|
+
if (!validAnchors.includes(startAnchor) || !validAnchors.includes(endAnchor)) {
|
|
1397
|
+
throw new Error(`Anchors must be one of: ${validAnchors.join(', ')}`)
|
|
1398
|
+
}
|
|
1399
|
+
if (startWidgetId === endWidgetId) throw new Error('Cannot connect a widget to itself')
|
|
1400
|
+
if (!widgetIds.has(startWidgetId)) throw new Error(`Widget "${startWidgetId}" not found`)
|
|
1401
|
+
if (!widgetIds.has(endWidgetId)) throw new Error(`Widget "${endWidgetId}" not found`)
|
|
1402
|
+
|
|
1403
|
+
const connectorId = generateWidgetId('connector')
|
|
1404
|
+
const connector = {
|
|
1405
|
+
id: connectorId,
|
|
1406
|
+
type: 'connector',
|
|
1407
|
+
connectorType,
|
|
1408
|
+
start: { widgetId: startWidgetId, anchor: startAnchor },
|
|
1409
|
+
end: { widgetId: endWidgetId, anchor: endAnchor },
|
|
1410
|
+
meta: {},
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
appendEvent(filePath, { event: 'connector_added', timestamp: ts, connector })
|
|
1414
|
+
|
|
1415
|
+
connectorIds.add(connectorId)
|
|
1416
|
+
connectorMap.set(connectorId, connector)
|
|
1417
|
+
refs[String(i)] = connectorId
|
|
1418
|
+
if (ref) refs[ref] = connectorId
|
|
1419
|
+
|
|
1420
|
+
results.push({ index: i, op: 'create-connector', ref: ref || undefined, connectorId, success: true })
|
|
1421
|
+
break
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
case 'delete-connector': {
|
|
1425
|
+
const connectorId = resolveRef(op.connectorId)
|
|
1426
|
+
if (!connectorId) throw new Error('connectorId is required')
|
|
1427
|
+
if (!connectorIds.has(connectorId)) throw new Error(`Connector "${connectorId}" not found`)
|
|
1428
|
+
|
|
1429
|
+
appendEvent(filePath, { event: 'connector_removed', timestamp: ts, connectorId })
|
|
1430
|
+
connectorIds.delete(connectorId)
|
|
1431
|
+
connectorMap.delete(connectorId)
|
|
1432
|
+
|
|
1433
|
+
results.push({ index: i, op: 'delete-connector', connectorId, success: true })
|
|
1434
|
+
break
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
case 'update-connector': {
|
|
1438
|
+
const connectorId = resolveRef(op.connectorId)
|
|
1439
|
+
const { meta } = op
|
|
1440
|
+
if (!connectorId) throw new Error('connectorId is required')
|
|
1441
|
+
if (!meta) throw new Error('meta is required')
|
|
1442
|
+
if (!connectorIds.has(connectorId)) throw new Error(`Connector "${connectorId}" not found`)
|
|
1443
|
+
|
|
1444
|
+
appendEvent(filePath, { event: 'connector_updated', timestamp: ts, connectorId, updates: { meta } })
|
|
1445
|
+
|
|
1446
|
+
const existing = connectorMap.get(connectorId)
|
|
1447
|
+
if (existing) existing.meta = { ...(existing.meta || {}), ...meta }
|
|
1448
|
+
|
|
1449
|
+
results.push({ index: i, op: 'update-connector', connectorId, success: true })
|
|
1450
|
+
break
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
case 'broadcast': {
|
|
1454
|
+
const wId = resolveRef(op.widgetId)
|
|
1455
|
+
const mode = op.mode || 'two-way'
|
|
1456
|
+
const passThrough = !!op.passThrough
|
|
1457
|
+
if (!wId) throw new Error('widgetId is required')
|
|
1458
|
+
if (!widgetIds.has(wId)) throw new Error(`Widget "${wId}" not found`)
|
|
1459
|
+
|
|
1460
|
+
const isTerminalType = (w) => w && (w.type === 'terminal' || w.type === 'agent')
|
|
1461
|
+
const allConnectors = [...connectorMap.values()]
|
|
1462
|
+
const affectedConnectorIds = new Set()
|
|
1463
|
+
const affectedWidgetIds = new Set([wId])
|
|
1464
|
+
|
|
1465
|
+
if (passThrough) {
|
|
1466
|
+
const visited = new Set([wId])
|
|
1467
|
+
const queue = [wId]
|
|
1468
|
+
while (queue.length > 0) {
|
|
1469
|
+
const current = queue.shift()
|
|
1470
|
+
for (const conn of allConnectors) {
|
|
1471
|
+
let peerId = null
|
|
1472
|
+
if (conn.start?.widgetId === current && conn.end?.widgetId) peerId = conn.end.widgetId
|
|
1473
|
+
if (conn.end?.widgetId === current && conn.start?.widgetId) peerId = conn.start.widgetId
|
|
1474
|
+
if (!peerId || visited.has(peerId)) continue
|
|
1475
|
+
const peer = widgetMap.get(peerId)
|
|
1476
|
+
if (!isTerminalType(peer)) continue
|
|
1477
|
+
affectedConnectorIds.add(conn.id)
|
|
1478
|
+
affectedWidgetIds.add(peerId)
|
|
1479
|
+
visited.add(peerId)
|
|
1480
|
+
queue.push(peerId)
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
} else {
|
|
1484
|
+
for (const conn of allConnectors) {
|
|
1485
|
+
let peerId = null
|
|
1486
|
+
if (conn.start?.widgetId === wId && conn.end?.widgetId) peerId = conn.end.widgetId
|
|
1487
|
+
if (conn.end?.widgetId === wId && conn.start?.widgetId) peerId = conn.start.widgetId
|
|
1488
|
+
if (!peerId) continue
|
|
1489
|
+
const peer = widgetMap.get(peerId)
|
|
1490
|
+
if (!isTerminalType(peer)) continue
|
|
1491
|
+
affectedConnectorIds.add(conn.id)
|
|
1492
|
+
affectedWidgetIds.add(peerId)
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
const messagingMode = mode === 'none' ? null : mode
|
|
1497
|
+
for (const connId of affectedConnectorIds) {
|
|
1498
|
+
appendEvent(filePath, { event: 'connector_updated', timestamp: ts, connectorId: connId, updates: { meta: { messagingMode } } })
|
|
1499
|
+
const conn = connectorMap.get(connId)
|
|
1500
|
+
if (conn) conn.meta = { ...(conn.meta || {}), messagingMode }
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
results.push({
|
|
1504
|
+
index: i, op: 'broadcast',
|
|
1505
|
+
affectedConnectors: [...affectedConnectorIds],
|
|
1506
|
+
affectedWidgets: [...affectedWidgetIds],
|
|
1507
|
+
success: true,
|
|
1508
|
+
})
|
|
1509
|
+
break
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
default:
|
|
1513
|
+
throw new Error(`Unknown operation "${op.op}"`)
|
|
1514
|
+
}
|
|
1515
|
+
} catch (opErr) {
|
|
1516
|
+
// Fail-fast: push what we have so far, then return the error
|
|
1517
|
+
pushCanvasUpdate(name, filePath, __viteWs)
|
|
1518
|
+
sendJson(res, 400, {
|
|
1519
|
+
success: false,
|
|
1520
|
+
error: `Operation ${i} (${op.op}) failed: ${opErr.message}`,
|
|
1521
|
+
failedAt: i,
|
|
1522
|
+
results,
|
|
1523
|
+
refs,
|
|
1524
|
+
})
|
|
1525
|
+
return
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
sendJson(res, 200, { success: true, results, refs })
|
|
1530
|
+
pushCanvasUpdate(name, filePath, __viteWs)
|
|
1531
|
+
} catch (err) {
|
|
1532
|
+
sendJson(res, 500, { error: `Batch failed: ${err.message}` })
|
|
1533
|
+
}
|
|
1534
|
+
return
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
// PUT /rename-page — rename a canvas page file
|
|
1538
|
+
if (routePath === '/rename-page' && method === 'PUT') {
|
|
1539
|
+
const { name, newTitle } = body
|
|
1540
|
+
|
|
1541
|
+
if (!name || !newTitle) {
|
|
1542
|
+
sendJson(res, 400, { error: 'Canvas name and newTitle are required' })
|
|
1543
|
+
return
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
const filePath = findCanvasPath(root, name)
|
|
1547
|
+
if (!filePath) {
|
|
1548
|
+
sendJson(res, 404, { error: `Canvas "${name}" not found` })
|
|
1549
|
+
return
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
const kebab = newTitle
|
|
1553
|
+
.replace(/[^a-zA-Z0-9\s_-]/g, '')
|
|
1554
|
+
.trim()
|
|
1555
|
+
.replace(/[\s_]+/g, '-')
|
|
1556
|
+
.toLowerCase()
|
|
1557
|
+
.replace(/-+/g, '-')
|
|
1558
|
+
.replace(/^-|-$/g, '')
|
|
1559
|
+
|
|
1560
|
+
if (!kebab) {
|
|
1561
|
+
sendJson(res, 400, { error: 'newTitle must contain at least one alphanumeric character' })
|
|
1562
|
+
return
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
try {
|
|
1566
|
+
const dir = path.dirname(filePath)
|
|
1567
|
+
const newFilename = `${kebab}.canvas.jsonl`
|
|
1568
|
+
const newPath = path.join(dir, newFilename)
|
|
1569
|
+
|
|
1570
|
+
if (newPath !== filePath && fs.existsSync(newPath)) {
|
|
1571
|
+
sendJson(res, 409, { error: `A canvas file named "${newFilename}" already exists in this directory` })
|
|
1572
|
+
return
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
fs.renameSync(filePath, newPath)
|
|
1576
|
+
|
|
1577
|
+
const newCanonicalId = toCanvasId(path.relative(root, newPath).replace(/\\/g, '/'))
|
|
1578
|
+
|
|
1579
|
+
appendEvent(newPath, {
|
|
1580
|
+
event: 'settings_updated',
|
|
1581
|
+
timestamp: new Date().toISOString(),
|
|
1582
|
+
settings: { title: newTitle },
|
|
1583
|
+
})
|
|
1584
|
+
|
|
1585
|
+
// Update pageOrder in .meta.json if it exists
|
|
1586
|
+
const metaForOrder = readFolderMeta(dir)
|
|
1587
|
+
if (metaForOrder?.pageOrder) {
|
|
1588
|
+
try {
|
|
1589
|
+
const updated = metaForOrder.pageOrder.map((entry) =>
|
|
1590
|
+
typeof entry === 'string' && entry === name ? newCanonicalId : entry
|
|
1591
|
+
)
|
|
1592
|
+
metaForOrder.pageOrder = updated
|
|
1593
|
+
writeFolderMeta(dir, metaForOrder)
|
|
1594
|
+
} catch { /* skip */ }
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
sendJson(res, 200, { success: true, name: newCanonicalId, route: '/canvas/' + newCanonicalId })
|
|
1598
|
+
} catch (err) {
|
|
1599
|
+
sendJson(res, 500, { error: `Failed to rename page: ${err.message}` })
|
|
1600
|
+
}
|
|
1601
|
+
return
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
// PUT /reorder-pages — save page order for a canvas folder
|
|
1605
|
+
if (routePath === '/reorder-pages' && method === 'PUT') {
|
|
1606
|
+
const { folder, order } = body
|
|
1607
|
+
|
|
1608
|
+
if (!folder || !Array.isArray(order)) {
|
|
1609
|
+
sendJson(res, 400, { error: 'folder (string) and order (array) are required' })
|
|
1610
|
+
return
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
const canvasDir = path.join(root, 'src', 'canvas')
|
|
1614
|
+
const folderDir = fs.existsSync(path.join(canvasDir, `${folder}.folder`))
|
|
1615
|
+
? path.join(canvasDir, `${folder}.folder`)
|
|
1616
|
+
: fs.existsSync(path.join(canvasDir, folder))
|
|
1617
|
+
? path.join(canvasDir, folder)
|
|
1618
|
+
: null
|
|
1619
|
+
|
|
1620
|
+
if (!folderDir) {
|
|
1621
|
+
sendJson(res, 404, { error: `Folder "${folder}" not found` })
|
|
1622
|
+
return
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
try {
|
|
1626
|
+
const meta = readFolderMeta(folderDir)
|
|
1627
|
+
meta.pageOrder = order
|
|
1628
|
+
writeFolderMeta(folderDir, meta)
|
|
1629
|
+
sendJson(res, 200, { success: true })
|
|
1630
|
+
} catch (err) {
|
|
1631
|
+
sendJson(res, 500, { error: `Failed to save page order: ${err.message}` })
|
|
1632
|
+
}
|
|
1633
|
+
return
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
// GET /page-order?folder=... — read page order for a folder
|
|
1637
|
+
if (routePath.startsWith('/page-order') && method === 'GET') {
|
|
1638
|
+
const pageOrderUrl = new URL(routePath, 'http://localhost')
|
|
1639
|
+
const folder = pageOrderUrl.searchParams.get('folder')
|
|
1640
|
+
|
|
1641
|
+
if (!folder) {
|
|
1642
|
+
sendJson(res, 400, { error: 'folder query parameter is required' })
|
|
1643
|
+
return
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
const canvasDir = path.join(root, 'src', 'canvas')
|
|
1647
|
+
const folderDir = fs.existsSync(path.join(canvasDir, `${folder}.folder`))
|
|
1648
|
+
? path.join(canvasDir, `${folder}.folder`)
|
|
1649
|
+
: fs.existsSync(path.join(canvasDir, folder))
|
|
1650
|
+
? path.join(canvasDir, folder)
|
|
1651
|
+
: null
|
|
1652
|
+
|
|
1653
|
+
if (!folderDir) {
|
|
1654
|
+
sendJson(res, 404, { error: `Folder "${folder}" not found` })
|
|
1655
|
+
return
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
try {
|
|
1659
|
+
const meta = readFolderMeta(folderDir)
|
|
1660
|
+
sendJson(res, 200, { order: meta?.pageOrder || null })
|
|
1661
|
+
} catch (err) {
|
|
1662
|
+
sendJson(res, 500, { error: `Failed to read page order: ${err.message}` })
|
|
1663
|
+
}
|
|
1664
|
+
return
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
// PUT /update-folder-meta — update folder .meta.json title
|
|
1668
|
+
if (routePath === '/update-folder-meta' && method === 'PUT') {
|
|
1669
|
+
const { folder, title } = body
|
|
1670
|
+
|
|
1671
|
+
if (!folder || !title) {
|
|
1672
|
+
sendJson(res, 400, { error: 'folder and title are required' })
|
|
1673
|
+
return
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
const kebab = title
|
|
1677
|
+
.replace(/[^a-zA-Z0-9\s_-]/g, '')
|
|
1678
|
+
.trim()
|
|
1679
|
+
.replace(/[\s_]+/g, '-')
|
|
1680
|
+
.toLowerCase()
|
|
1681
|
+
.replace(/-+/g, '-')
|
|
1682
|
+
.replace(/^-|-$/g, '')
|
|
1683
|
+
|
|
1684
|
+
if (!kebab) {
|
|
1685
|
+
sendJson(res, 400, { error: 'title must contain at least one alphanumeric character' })
|
|
1686
|
+
return
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
const canvasDir = path.join(root, 'src', 'canvas')
|
|
1690
|
+
const isFolderSuffix = fs.existsSync(path.join(canvasDir, `${folder}.folder`))
|
|
1691
|
+
const folderDir = isFolderSuffix
|
|
1692
|
+
? path.join(canvasDir, `${folder}.folder`)
|
|
1693
|
+
: fs.existsSync(path.join(canvasDir, folder))
|
|
1694
|
+
? path.join(canvasDir, folder)
|
|
1695
|
+
: null
|
|
1696
|
+
|
|
1697
|
+
if (!folderDir) {
|
|
1698
|
+
sendJson(res, 404, { error: `Folder "${folder}" not found` })
|
|
1699
|
+
return
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
try {
|
|
1703
|
+
const meta = readFolderMeta(folderDir)
|
|
1704
|
+
const dirName = path.basename(folderDir).replace(/\.folder$/, '')
|
|
1705
|
+
meta.title = title
|
|
1706
|
+
|
|
1707
|
+
// Rename folder directory if the kebab name differs
|
|
1708
|
+
const needsRename = kebab !== dirName
|
|
1709
|
+
let newDirName = dirName
|
|
1710
|
+
|
|
1711
|
+
if (needsRename) {
|
|
1712
|
+
const suffix = isFolderSuffix ? '.folder' : ''
|
|
1713
|
+
const newFolderDir = path.join(canvasDir, `${kebab}${suffix}`)
|
|
1714
|
+
if (fs.existsSync(newFolderDir)) {
|
|
1715
|
+
sendJson(res, 409, { error: `A folder named "${kebab}" already exists` })
|
|
1716
|
+
return
|
|
1717
|
+
}
|
|
1718
|
+
// Write updated meta, rename file to match new dir name, rename dir
|
|
1719
|
+
writeFolderMeta(folderDir, meta)
|
|
1720
|
+
const metaPath = path.join(folderDir, `${dirName}.meta.json`)
|
|
1721
|
+
const newMetaPath = path.join(folderDir, `${kebab}.meta.json`)
|
|
1722
|
+
if (newMetaPath !== metaPath) {
|
|
1723
|
+
fs.renameSync(metaPath, newMetaPath)
|
|
1724
|
+
}
|
|
1725
|
+
fs.renameSync(folderDir, newFolderDir)
|
|
1726
|
+
newDirName = kebab
|
|
1727
|
+
} else {
|
|
1728
|
+
writeFolderMeta(folderDir, meta)
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
sendJson(res, 200, { success: true, folder: newDirName, renamed: needsRename })
|
|
1732
|
+
} catch (err) {
|
|
1733
|
+
sendJson(res, 500, { error: `Failed to update folder meta: ${err.message}` })
|
|
1734
|
+
}
|
|
1735
|
+
return
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
// POST /duplicate — duplicate an existing canvas page with its widgets
|
|
1739
|
+
if (routePath === '/duplicate' && method === 'POST') {
|
|
1740
|
+
const { name, newTitle } = body
|
|
1741
|
+
|
|
1742
|
+
if (!name || !newTitle) {
|
|
1743
|
+
sendJson(res, 400, { error: 'Canvas name and newTitle are required' })
|
|
1744
|
+
return
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
const filePath = findCanvasPath(root, name)
|
|
1748
|
+
if (!filePath) {
|
|
1749
|
+
sendJson(res, 404, { error: `Canvas "${name}" not found` })
|
|
1750
|
+
return
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
const kebab = newTitle
|
|
1754
|
+
.replace(/[^a-zA-Z0-9\s_-]/g, '')
|
|
1755
|
+
.trim()
|
|
1756
|
+
.replace(/[\s_]+/g, '-')
|
|
1757
|
+
.toLowerCase()
|
|
1758
|
+
.replace(/-+/g, '-')
|
|
1759
|
+
.replace(/^-|-$/g, '')
|
|
1760
|
+
|
|
1761
|
+
if (!kebab) {
|
|
1762
|
+
sendJson(res, 400, { error: 'newTitle must contain at least one alphanumeric character' })
|
|
1763
|
+
return
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
try {
|
|
1767
|
+
const sourceData = readCanvas(filePath)
|
|
1768
|
+
const dir = path.dirname(filePath)
|
|
1769
|
+
const newFilename = `${kebab}.canvas.jsonl`
|
|
1770
|
+
const newPath = path.join(dir, newFilename)
|
|
1771
|
+
|
|
1772
|
+
if (fs.existsSync(newPath)) {
|
|
1773
|
+
sendJson(res, 409, { error: `A canvas file named "${newFilename}" already exists` })
|
|
1774
|
+
return
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
// Re-ID all widgets to avoid collisions
|
|
1778
|
+
const widgets = (sourceData.widgets || []).map(w => ({
|
|
1779
|
+
...w,
|
|
1780
|
+
id: generateWidgetId(w.type || 'widget'),
|
|
1781
|
+
}))
|
|
1782
|
+
|
|
1783
|
+
const creationEvent = {
|
|
1784
|
+
event: 'canvas_created',
|
|
1785
|
+
timestamp: new Date().toISOString(),
|
|
1786
|
+
title: newTitle,
|
|
1787
|
+
grid: sourceData.grid ?? true,
|
|
1788
|
+
gridSize: sourceData.gridSize ?? 24,
|
|
1789
|
+
colorMode: sourceData.colorMode ?? 'auto',
|
|
1790
|
+
widgets,
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
writeNewCanvas(newPath, creationEvent)
|
|
1794
|
+
|
|
1795
|
+
const relPath = path.relative(root, newPath).replace(/\\/g, '/')
|
|
1796
|
+
const canonicalName = toCanvasId(relPath) || kebab
|
|
1797
|
+
|
|
1798
|
+
sendJson(res, 201, {
|
|
1799
|
+
success: true,
|
|
1800
|
+
name: canonicalName,
|
|
1801
|
+
path: relPath,
|
|
1802
|
+
route: `/canvas/${canonicalName}`,
|
|
1803
|
+
})
|
|
1804
|
+
} catch (err) {
|
|
1805
|
+
sendJson(res, 500, { error: `Failed to duplicate canvas: ${err.message}` })
|
|
1806
|
+
}
|
|
1807
|
+
return
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
// POST /create — create a new .canvas.jsonl file
|
|
1811
|
+
// Supports `convertFrom` to convert a single-page canvas into a multi-page folder.
|
|
1812
|
+
if (routePath === '/create' && method === 'POST') {
|
|
1813
|
+
const {
|
|
1814
|
+
name,
|
|
1815
|
+
title,
|
|
1816
|
+
folder,
|
|
1817
|
+
convertFrom,
|
|
1818
|
+
author,
|
|
1819
|
+
description,
|
|
1820
|
+
meta,
|
|
1821
|
+
grid = true,
|
|
1822
|
+
gridSize = 24,
|
|
1823
|
+
colorMode = 'auto',
|
|
1824
|
+
} = body
|
|
1825
|
+
|
|
1826
|
+
if (!name || typeof name !== 'string') {
|
|
1827
|
+
sendJson(res, 400, { error: 'Canvas name is required' })
|
|
1828
|
+
return
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
const kebab = name
|
|
1832
|
+
.replace(/[^a-zA-Z0-9\s_-]/g, '')
|
|
1833
|
+
.trim()
|
|
1834
|
+
.replace(/[\s_]+/g, '-')
|
|
1835
|
+
.toLowerCase()
|
|
1836
|
+
.replace(/-+/g, '-')
|
|
1837
|
+
.replace(/^-|-$/g, '')
|
|
1838
|
+
|
|
1839
|
+
if (!kebab) {
|
|
1840
|
+
sendJson(res, 400, { error: 'Name must contain at least one alphanumeric character' })
|
|
1841
|
+
return
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
// ── Convert single-page canvas to multi-page folder ──────────────
|
|
1845
|
+
if (convertFrom && typeof convertFrom === 'string') {
|
|
1846
|
+
// Only allow flat root canvases (no path segments, no proto:)
|
|
1847
|
+
if (convertFrom.includes('/') || convertFrom.startsWith('proto:')) {
|
|
1848
|
+
sendJson(res, 400, { error: 'convertFrom only supports flat root canvases (no path segments or proto: prefix)' })
|
|
1849
|
+
return
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
const canvasDir = path.join(root, 'src', 'canvas')
|
|
1853
|
+
const existingPath = findCanvasPath(root, convertFrom)
|
|
1854
|
+
if (!existingPath) {
|
|
1855
|
+
sendJson(res, 404, { error: `Canvas "${convertFrom}" not found` })
|
|
1856
|
+
return
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
// Verify it's actually a flat file in src/canvas/ (not already in a folder)
|
|
1860
|
+
const existingRel = path.relative(canvasDir, existingPath).replace(/\\/g, '/')
|
|
1861
|
+
if (existingRel.includes('/')) {
|
|
1862
|
+
sendJson(res, 400, { error: `Canvas "${convertFrom}" is already inside a folder` })
|
|
1863
|
+
return
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
const newDir = path.join(canvasDir, convertFrom)
|
|
1867
|
+
const dotFolderDir = path.join(canvasDir, `${convertFrom}.folder`)
|
|
1868
|
+
|
|
1869
|
+
// Preflight: check for collisions
|
|
1870
|
+
if (fs.existsSync(newDir)) {
|
|
1871
|
+
sendJson(res, 409, { error: `Directory "${convertFrom}" already exists in src/canvas/` })
|
|
1872
|
+
return
|
|
1873
|
+
}
|
|
1874
|
+
if (fs.existsSync(dotFolderDir)) {
|
|
1875
|
+
sendJson(res, 409, { error: `Directory "${convertFrom}.folder" already exists in src/canvas/` })
|
|
1876
|
+
return
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
// Read the existing canvas to extract metadata for .meta.json
|
|
1880
|
+
let existingData
|
|
1881
|
+
try {
|
|
1882
|
+
existingData = readCanvas(existingPath)
|
|
1883
|
+
} catch (err) {
|
|
1884
|
+
sendJson(res, 500, { error: `Failed to read existing canvas: ${err.message}` })
|
|
1885
|
+
return
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
const existingBasename = path.basename(existingPath)
|
|
1889
|
+
|
|
1890
|
+
const movedCanvasPath = path.join(newDir, existingBasename)
|
|
1891
|
+
const newPagePath = path.join(newDir, `${kebab}.canvas.jsonl`)
|
|
1892
|
+
|
|
1893
|
+
if (existingBasename === `${kebab}.canvas.jsonl`) {
|
|
1894
|
+
sendJson(res, 409, { error: `New page name "${kebab}" collides with existing canvas filename` })
|
|
1895
|
+
return
|
|
1896
|
+
}
|
|
1897
|
+
|
|
1898
|
+
// Perform the conversion with rollback on failure
|
|
1899
|
+
const rollbackOps = []
|
|
1900
|
+
try {
|
|
1901
|
+
// 1. Create the directory
|
|
1902
|
+
fs.mkdirSync(newDir, { recursive: true })
|
|
1903
|
+
rollbackOps.push(() => { try { fs.rmdirSync(newDir) } catch { /* ignore */ } })
|
|
1904
|
+
|
|
1905
|
+
// 2. Move the existing canvas file
|
|
1906
|
+
fs.renameSync(existingPath, movedCanvasPath)
|
|
1907
|
+
rollbackOps.push(() => { try { fs.renameSync(movedCanvasPath, existingPath) } catch { /* ignore */ } })
|
|
1908
|
+
|
|
1909
|
+
// 3. Write .meta.json with metadata from the existing canvas
|
|
1910
|
+
const metaObj = { title: existingData?.title || convertFrom }
|
|
1911
|
+
if (existingData?.description) metaObj.description = existingData.description
|
|
1912
|
+
if (existingData?.author) metaObj.author = existingData.author
|
|
1913
|
+
const metaPath = path.join(newDir, `${convertFrom}.meta.json`)
|
|
1914
|
+
fs.writeFileSync(metaPath, JSON.stringify(metaObj, null, 2) + '\n', 'utf-8')
|
|
1915
|
+
rollbackOps.push(() => { try { fs.unlinkSync(metaPath) } catch { /* ignore */ } })
|
|
1916
|
+
|
|
1917
|
+
// 4. Create the new page
|
|
1918
|
+
const creationEvent = {
|
|
1919
|
+
event: 'canvas_created',
|
|
1920
|
+
timestamp: new Date().toISOString(),
|
|
1921
|
+
title: title || kebab.split('-').map(s => s.charAt(0).toUpperCase() + s.slice(1)).join(' '),
|
|
1922
|
+
grid,
|
|
1923
|
+
gridSize,
|
|
1924
|
+
colorMode,
|
|
1925
|
+
widgets: [],
|
|
1926
|
+
}
|
|
1927
|
+
writeNewCanvas(newPagePath, creationEvent)
|
|
1928
|
+
|
|
1929
|
+
const relPath = path.relative(root, newPagePath).replace(/\\/g, '/')
|
|
1930
|
+
const canonicalName = toCanvasId(relPath) || kebab
|
|
1931
|
+
|
|
1932
|
+
sendJson(res, 201, {
|
|
1933
|
+
success: true,
|
|
1934
|
+
converted: true,
|
|
1935
|
+
name: canonicalName,
|
|
1936
|
+
path: relPath,
|
|
1937
|
+
route: `/canvas/${canonicalName}`,
|
|
1938
|
+
})
|
|
1939
|
+
} catch (err) {
|
|
1940
|
+
// Rollback in reverse order
|
|
1941
|
+
for (let i = rollbackOps.length - 1; i >= 0; i--) {
|
|
1942
|
+
rollbackOps[i]()
|
|
1943
|
+
}
|
|
1944
|
+
sendJson(res, 500, { error: `Failed to convert canvas to folder: ${err.message}` })
|
|
1945
|
+
}
|
|
1946
|
+
return
|
|
1947
|
+
}
|
|
1948
|
+
|
|
1949
|
+
// ── Standard canvas creation ─────────────────────────────────────
|
|
1950
|
+
// Determine target directory
|
|
1951
|
+
const canvasDir = path.join(root, 'src', 'canvas')
|
|
1952
|
+
let targetDir = canvasDir
|
|
1953
|
+
|
|
1954
|
+
if (folder) {
|
|
1955
|
+
const dotFolderDir = path.join(canvasDir, `${folder}.folder`)
|
|
1956
|
+
const plainDir = path.join(canvasDir, folder)
|
|
1957
|
+
|
|
1958
|
+
if (fs.existsSync(dotFolderDir)) {
|
|
1959
|
+
// Existing .folder/ directory
|
|
1960
|
+
targetDir = dotFolderDir
|
|
1961
|
+
} else if (fs.existsSync(plainDir) && fs.statSync(plainDir).isDirectory()) {
|
|
1962
|
+
// Existing plain directory
|
|
1963
|
+
targetDir = plainDir
|
|
1964
|
+
} else {
|
|
1965
|
+
// Create new plain directory
|
|
1966
|
+
try {
|
|
1967
|
+
fs.mkdirSync(plainDir, { recursive: true })
|
|
1968
|
+
// Write .meta.json if meta was provided
|
|
1969
|
+
if (meta && typeof meta === 'object') {
|
|
1970
|
+
const metaPath = path.join(plainDir, `${folder}.meta.json`)
|
|
1971
|
+
fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2) + '\n', 'utf-8')
|
|
1972
|
+
}
|
|
1973
|
+
} catch (err) {
|
|
1974
|
+
sendJson(res, 500, { error: `Failed to create directory: ${err.message}` })
|
|
1975
|
+
return
|
|
1976
|
+
}
|
|
1977
|
+
targetDir = plainDir
|
|
1978
|
+
}
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
const canvasPath = path.join(targetDir, `${kebab}.canvas.jsonl`)
|
|
1982
|
+
if (fs.existsSync(canvasPath)) {
|
|
1983
|
+
sendJson(res, 409, { error: `Canvas "${kebab}" already exists` })
|
|
1984
|
+
return
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1987
|
+
const creationEvent = {
|
|
1988
|
+
event: 'canvas_created',
|
|
1989
|
+
timestamp: new Date().toISOString(),
|
|
1990
|
+
title: title || kebab.split('-').map(s => s.charAt(0).toUpperCase() + s.slice(1)).join(' '),
|
|
1991
|
+
grid,
|
|
1992
|
+
gridSize,
|
|
1993
|
+
colorMode,
|
|
1994
|
+
widgets: [],
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
if (author) {
|
|
1998
|
+
creationEvent.author = author
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
if (description) {
|
|
2002
|
+
creationEvent.description = description
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
try {
|
|
2006
|
+
fs.mkdirSync(targetDir, { recursive: true })
|
|
2007
|
+
writeNewCanvas(canvasPath, creationEvent)
|
|
2008
|
+
|
|
2009
|
+
const relPath = path.relative(root, canvasPath).replace(/\\/g, '/')
|
|
2010
|
+
const canonicalName = toCanvasId(relPath) || kebab
|
|
2011
|
+
|
|
2012
|
+
const result = {
|
|
2013
|
+
success: true,
|
|
2014
|
+
name: canonicalName,
|
|
2015
|
+
path: relPath,
|
|
2016
|
+
route: `/canvas/${canonicalName}`,
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
sendJson(res, 201, result)
|
|
2020
|
+
} catch (err) {
|
|
2021
|
+
sendJson(res, 500, { error: `Failed to create canvas: ${err.message}` })
|
|
2022
|
+
}
|
|
2023
|
+
return
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
// ── Story routes ──────────────────────────────────────────────────
|
|
2027
|
+
|
|
2028
|
+
// GET /stories — list all .story.{jsx,tsx} files with their exports
|
|
2029
|
+
if (routePath === '/stories' && method === 'GET') {
|
|
2030
|
+
try {
|
|
2031
|
+
const storyFiles = findStoryFiles(root)
|
|
2032
|
+
sendJson(res, 200, { stories: storyFiles })
|
|
2033
|
+
} catch (err) {
|
|
2034
|
+
sendJson(res, 500, { error: `Failed to list stories: ${err.message}` })
|
|
2035
|
+
}
|
|
2036
|
+
return
|
|
2037
|
+
}
|
|
2038
|
+
|
|
2039
|
+
// POST /create-story — scaffold a new .story.jsx/.tsx file
|
|
2040
|
+
if (routePath === '/create-story' && method === 'POST') {
|
|
2041
|
+
const { name, location, format = 'jsx', canvasName: storyCanvasName } = body
|
|
2042
|
+
|
|
2043
|
+
if (!name || typeof name !== 'string') {
|
|
2044
|
+
sendJson(res, 400, { error: 'Component name is required' })
|
|
2045
|
+
return
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
const kebab = name
|
|
2049
|
+
.replace(/[^a-zA-Z0-9\s_-]/g, '')
|
|
2050
|
+
.trim()
|
|
2051
|
+
.replace(/[\s_]+/g, '-')
|
|
2052
|
+
.toLowerCase()
|
|
2053
|
+
.replace(/-+/g, '-')
|
|
2054
|
+
.replace(/^-|-$/g, '')
|
|
2055
|
+
|
|
2056
|
+
if (!kebab) {
|
|
2057
|
+
sendJson(res, 400, { error: 'Name must contain at least one alphanumeric character' })
|
|
2058
|
+
return
|
|
2059
|
+
}
|
|
2060
|
+
|
|
2061
|
+
const ext = format === 'tsx' ? 'tsx' : 'jsx'
|
|
2062
|
+
|
|
2063
|
+
// Resolve target directory from location + canvas name
|
|
2064
|
+
let targetDir
|
|
2065
|
+
if (location === 'components') {
|
|
2066
|
+
targetDir = path.join(root, 'src', 'components')
|
|
2067
|
+
} else if (storyCanvasName) {
|
|
2068
|
+
const canvasPath = findCanvasPath(root, storyCanvasName)
|
|
2069
|
+
targetDir = canvasPath ? path.dirname(canvasPath) : path.join(root, 'src', 'canvas')
|
|
2070
|
+
} else {
|
|
2071
|
+
targetDir = path.join(root, 'src', 'canvas')
|
|
2072
|
+
}
|
|
2073
|
+
|
|
2074
|
+
const storyPath = path.join(targetDir, `${kebab}.story.${ext}`)
|
|
2075
|
+
if (fs.existsSync(storyPath)) {
|
|
2076
|
+
sendJson(res, 409, { error: `Story "${kebab}.story.${ext}" already exists at ${path.relative(root, targetDir)}` })
|
|
2077
|
+
return
|
|
2078
|
+
}
|
|
2079
|
+
|
|
2080
|
+
// Check for duplicate story name anywhere in the project (Vite data plugin
|
|
2081
|
+
// enforces global uniqueness and would fail the build on duplicates)
|
|
2082
|
+
const existing = findStoryFiles(root)
|
|
2083
|
+
if (existing.some(s => s.name === kebab)) {
|
|
2084
|
+
sendJson(res, 409, { error: `A story named "${kebab}" already exists in the project` })
|
|
2085
|
+
return
|
|
2086
|
+
}
|
|
2087
|
+
|
|
2088
|
+
const componentName = kebab.split('-').map(s => s.charAt(0).toUpperCase() + s.slice(1)).join('')
|
|
2089
|
+
const content = `/**
|
|
2090
|
+
* ${componentName} component stories.
|
|
2091
|
+
* Each named export becomes a draggable widget on the canvas.
|
|
2092
|
+
*/
|
|
2093
|
+
|
|
2094
|
+
export function Default() {
|
|
2095
|
+
return (
|
|
2096
|
+
<div style={{ padding: '1.5rem', minWidth: 200 }}>
|
|
2097
|
+
<h3>${componentName}</h3>
|
|
2098
|
+
<p>Edit this file to build your component.</p>
|
|
2099
|
+
</div>
|
|
2100
|
+
)
|
|
2101
|
+
}
|
|
2102
|
+
`
|
|
2103
|
+
|
|
2104
|
+
try {
|
|
2105
|
+
fs.mkdirSync(targetDir, { recursive: true })
|
|
2106
|
+
fs.writeFileSync(storyPath, content, 'utf-8')
|
|
2107
|
+
|
|
2108
|
+
const relPath = path.relative(root, storyPath)
|
|
2109
|
+
sendJson(res, 201, {
|
|
2110
|
+
success: true,
|
|
2111
|
+
name: kebab,
|
|
2112
|
+
path: relPath,
|
|
2113
|
+
storyId: kebab,
|
|
2114
|
+
})
|
|
2115
|
+
} catch (err) {
|
|
2116
|
+
sendJson(res, 500, { error: `Failed to create story: ${err.message}` })
|
|
2117
|
+
}
|
|
2118
|
+
return
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
// GET /github/available — check if gh CLI is installed locally
|
|
2122
|
+
if (routePath === '/github/available' && method === 'GET') {
|
|
2123
|
+
sendJson(res, 200, {
|
|
2124
|
+
available: isGhCliAvailable(),
|
|
2125
|
+
installUrl: GH_INSTALL_URL,
|
|
2126
|
+
})
|
|
2127
|
+
return
|
|
2128
|
+
}
|
|
2129
|
+
|
|
2130
|
+
// POST /github/embed — fetch metadata for GitHub issue/discussion/comment links
|
|
2131
|
+
if (routePath === '/github/embed' && method === 'POST') {
|
|
2132
|
+
const rawUrl = typeof body?.url === 'string' ? body.url.trim() : ''
|
|
2133
|
+
|
|
2134
|
+
if (!rawUrl) {
|
|
2135
|
+
sendJson(res, 400, { code: 'invalid_url', error: 'url is required' })
|
|
2136
|
+
return
|
|
2137
|
+
}
|
|
2138
|
+
|
|
2139
|
+
if (!isGitHubEmbedUrl(rawUrl)) {
|
|
2140
|
+
sendJson(res, 400, {
|
|
2141
|
+
code: 'unsupported_url',
|
|
2142
|
+
error: 'Only GitHub issue, discussion, and comment URLs are supported.',
|
|
2143
|
+
})
|
|
2144
|
+
return
|
|
2145
|
+
}
|
|
2146
|
+
|
|
2147
|
+
try {
|
|
2148
|
+
const snapshot = fetchGitHubEmbedSnapshot(rawUrl)
|
|
2149
|
+
sendJson(res, 200, { success: true, snapshot })
|
|
2150
|
+
} catch (error) {
|
|
2151
|
+
if (error instanceof GitHubEmbedError) {
|
|
2152
|
+
sendJson(res, error.status ?? 500, {
|
|
2153
|
+
code: error.code,
|
|
2154
|
+
error: error.message,
|
|
2155
|
+
installUrl: error.code === 'gh_unavailable' ? GH_INSTALL_URL : undefined,
|
|
2156
|
+
})
|
|
2157
|
+
return
|
|
2158
|
+
}
|
|
2159
|
+
|
|
2160
|
+
sendJson(res, 500, {
|
|
2161
|
+
code: 'gh_fetch_failed',
|
|
2162
|
+
error: error?.message || 'Failed to fetch GitHub metadata.',
|
|
2163
|
+
})
|
|
2164
|
+
}
|
|
2165
|
+
return
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
// ── Image routes ──────────────────────────────────────────────────
|
|
2169
|
+
|
|
2170
|
+
const imagesDir = path.join(root, 'assets', 'canvas', 'images')
|
|
2171
|
+
const snapshotsDir = path.join(root, 'assets', 'canvas', 'snapshots')
|
|
2172
|
+
|
|
2173
|
+
const MIME_TO_EXT = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp', 'image/gif': 'gif' }
|
|
2174
|
+
const EXT_TO_MIME = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif' }
|
|
2175
|
+
const MAX_IMAGE_SIZE = 5 * 1024 * 1024 // 5 MB
|
|
2176
|
+
|
|
2177
|
+
// Route snapshot uploads (snapshot-* prefix) to the snapshots directory
|
|
2178
|
+
function resolveWriteDir(canvasName) {
|
|
2179
|
+
return canvasName?.startsWith('snapshot-') ? snapshotsDir : imagesDir
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
function resolveImagePath(filename) {
|
|
2183
|
+
// Check snapshots dir first, then images
|
|
2184
|
+
const snapshotPath = path.join(snapshotsDir, filename)
|
|
2185
|
+
if (fs.existsSync(snapshotPath)) return snapshotPath
|
|
2186
|
+
const imagePath = path.join(imagesDir, filename)
|
|
2187
|
+
if (fs.existsSync(imagePath)) return imagePath
|
|
2188
|
+
return null
|
|
2189
|
+
}
|
|
2190
|
+
|
|
2191
|
+
// POST /image — upload a pasted image (base64 data URL)
|
|
2192
|
+
if (routePath === '/image' && method === 'POST') {
|
|
2193
|
+
const { dataUrl, canvasName } = body
|
|
2194
|
+
|
|
2195
|
+
if (!dataUrl || typeof dataUrl !== 'string') {
|
|
2196
|
+
sendJson(res, 400, { error: 'dataUrl is required' })
|
|
2197
|
+
return
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
const match = dataUrl.match(/^data:(image\/[a-z+]+);base64,(.+)$/i)
|
|
2201
|
+
if (!match) {
|
|
2202
|
+
sendJson(res, 400, { error: 'Invalid data URL format' })
|
|
2203
|
+
return
|
|
2204
|
+
}
|
|
2205
|
+
|
|
2206
|
+
const mime = match[1].toLowerCase()
|
|
2207
|
+
const ext = MIME_TO_EXT[mime]
|
|
2208
|
+
if (!ext) {
|
|
2209
|
+
sendJson(res, 400, { error: `Unsupported image type: ${mime}` })
|
|
2210
|
+
return
|
|
2211
|
+
}
|
|
2212
|
+
|
|
2213
|
+
const base64 = match[2]
|
|
2214
|
+
const buffer = Buffer.from(base64, 'base64')
|
|
2215
|
+
|
|
2216
|
+
if (buffer.length > MAX_IMAGE_SIZE) {
|
|
2217
|
+
sendJson(res, 413, { error: `Image exceeds ${MAX_IMAGE_SIZE / 1024 / 1024}MB limit` })
|
|
2218
|
+
return
|
|
2219
|
+
}
|
|
2220
|
+
|
|
2221
|
+
const now = new Date()
|
|
2222
|
+
const pad = (n) => String(n).padStart(2, '0')
|
|
2223
|
+
const dateStr = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}--${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}`
|
|
2224
|
+
const suffix = `-${Math.random().toString(36).slice(2, 6)}`
|
|
2225
|
+
const prefix = canvasName ? `${canvasName.replace(/[/:]/g, '--')}--` : ''
|
|
2226
|
+
|
|
2227
|
+
// Support explicit filename for snapshot uploads (stable naming)
|
|
2228
|
+
// and cropped image uploads (user-initiated crop)
|
|
2229
|
+
const explicitName = body.filename
|
|
2230
|
+
let filename
|
|
2231
|
+
if (explicitName && /^snapshot-[a-z0-9_-]+--(latest|light|dark)\.webp$/i.test(explicitName)) {
|
|
2232
|
+
filename = explicitName
|
|
2233
|
+
} else if (explicitName && /--cropped--\d{4}-\d{2}-\d{2}--\d{2}-\d{2}-\d{2}\.\w+$/.test(explicitName)) {
|
|
2234
|
+
// Cropped image: validate format, strip path traversal
|
|
2235
|
+
const safeName = explicitName.replace(/[/\\]/g, '')
|
|
2236
|
+
if (safeName === explicitName && !explicitName.includes('..')) {
|
|
2237
|
+
filename = explicitName
|
|
2238
|
+
} else {
|
|
2239
|
+
filename = `${prefix}${dateStr}${suffix}.${ext}`
|
|
2240
|
+
}
|
|
2241
|
+
} else {
|
|
2242
|
+
filename = `${prefix}${dateStr}${suffix}.${ext}`
|
|
2243
|
+
}
|
|
2244
|
+
const targetDir = resolveWriteDir(canvasName || '')
|
|
2245
|
+
|
|
2246
|
+
try {
|
|
2247
|
+
fs.mkdirSync(targetDir, { recursive: true })
|
|
2248
|
+
fs.writeFileSync(path.join(targetDir, filename), buffer)
|
|
2249
|
+
sendJson(res, 201, { success: true, filename })
|
|
2250
|
+
} catch (err) {
|
|
2251
|
+
sendJson(res, 500, { error: `Failed to save image: ${err.message}` })
|
|
2252
|
+
}
|
|
2253
|
+
return
|
|
2254
|
+
}
|
|
2255
|
+
|
|
2256
|
+
// GET /images/<filename> — serve an image file
|
|
2257
|
+
if (routePath.startsWith('/images/') && method === 'GET') {
|
|
2258
|
+
// Strip query string (e.g. ?v=123 cache busters) from filename
|
|
2259
|
+
let filename = routePath.slice('/images/'.length)
|
|
2260
|
+
const qIdx = filename.indexOf('?')
|
|
2261
|
+
if (qIdx !== -1) filename = filename.slice(0, qIdx)
|
|
2262
|
+
|
|
2263
|
+
// Block path traversal
|
|
2264
|
+
if (!filename || filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
|
|
2265
|
+
sendJson(res, 400, { error: 'Invalid filename' })
|
|
2266
|
+
return
|
|
2267
|
+
}
|
|
2268
|
+
|
|
2269
|
+
const filePath = resolveImagePath(filename)
|
|
2270
|
+
if (!filePath) {
|
|
2271
|
+
sendJson(res, 404, { error: 'Image not found' })
|
|
2272
|
+
return
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
const ext = path.extname(filename).slice(1).toLowerCase()
|
|
2276
|
+
const contentType = EXT_TO_MIME[ext] || 'application/octet-stream'
|
|
2277
|
+
|
|
2278
|
+
try {
|
|
2279
|
+
const data = fs.readFileSync(filePath)
|
|
2280
|
+
res.writeHead(200, {
|
|
2281
|
+
'Content-Type': contentType,
|
|
2282
|
+
'Content-Length': data.length,
|
|
2283
|
+
'Cache-Control': 'no-cache',
|
|
2284
|
+
})
|
|
2285
|
+
res.end(data)
|
|
2286
|
+
} catch (err) {
|
|
2287
|
+
sendJson(res, 500, { error: `Failed to serve image: ${err.message}` })
|
|
2288
|
+
}
|
|
2289
|
+
return
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2292
|
+
// POST /image/duplicate — copy an image file with a new timestamped name
|
|
2293
|
+
if (routePath === '/image/duplicate' && method === 'POST') {
|
|
2294
|
+
const { filename } = body
|
|
2295
|
+
|
|
2296
|
+
if (!filename || typeof filename !== 'string') {
|
|
2297
|
+
sendJson(res, 400, { error: 'filename is required' })
|
|
2298
|
+
return
|
|
2299
|
+
}
|
|
2300
|
+
|
|
2301
|
+
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
|
|
2302
|
+
sendJson(res, 400, { error: 'Invalid filename' })
|
|
2303
|
+
return
|
|
2304
|
+
}
|
|
2305
|
+
|
|
2306
|
+
const sourcePath = resolveImagePath(filename)
|
|
2307
|
+
if (!sourcePath) {
|
|
2308
|
+
sendJson(res, 404, { error: 'Image not found' })
|
|
2309
|
+
return
|
|
2310
|
+
}
|
|
2311
|
+
|
|
2312
|
+
try {
|
|
2313
|
+
const ext = path.extname(filename)
|
|
2314
|
+
const now = new Date()
|
|
2315
|
+
const pad = (n) => String(n).padStart(2, '0')
|
|
2316
|
+
const dateStr = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}--${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}`
|
|
2317
|
+
// Preserve privacy prefix
|
|
2318
|
+
const prefix = filename.startsWith('~') ? '~' : ''
|
|
2319
|
+
const baseName = filename.replace(/^~/, '').replace(ext, '')
|
|
2320
|
+
// Extract canvas prefix (everything before the date pattern or the full base)
|
|
2321
|
+
const canvasMatch = baseName.match(/^(.+?--)\d{4}-/)
|
|
2322
|
+
const canvasPrefix = canvasMatch ? canvasMatch[1] : ''
|
|
2323
|
+
const newFilename = `${prefix}${canvasPrefix}${dateStr}${ext}`
|
|
2324
|
+
const targetDir = path.dirname(sourcePath)
|
|
2325
|
+
fs.copyFileSync(sourcePath, path.join(targetDir, newFilename))
|
|
2326
|
+
sendJson(res, 201, { success: true, filename: newFilename })
|
|
2327
|
+
} catch (err) {
|
|
2328
|
+
sendJson(res, 500, { error: `Failed to duplicate image: ${err.message}` })
|
|
2329
|
+
}
|
|
2330
|
+
return
|
|
2331
|
+
}
|
|
2332
|
+
|
|
2333
|
+
// POST /image/toggle-private — toggle tilde prefix on image filename
|
|
2334
|
+
if (routePath === '/image/toggle-private' && method === 'POST') {
|
|
2335
|
+
const { filename } = body
|
|
2336
|
+
|
|
2337
|
+
if (!filename || typeof filename !== 'string') {
|
|
2338
|
+
sendJson(res, 400, { error: 'filename is required' })
|
|
2339
|
+
return
|
|
2340
|
+
}
|
|
2341
|
+
|
|
2342
|
+
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
|
|
2343
|
+
sendJson(res, 400, { error: 'Invalid filename' })
|
|
2344
|
+
return
|
|
2345
|
+
}
|
|
2346
|
+
|
|
2347
|
+
const isPrivate = filename.startsWith('~')
|
|
2348
|
+
const newFilename = isPrivate ? filename.slice(1) : `~${filename}`
|
|
2349
|
+
const oldPath = resolveImagePath(filename)
|
|
2350
|
+
if (!oldPath) {
|
|
2351
|
+
sendJson(res, 404, { error: 'Image not found' })
|
|
2352
|
+
return
|
|
2353
|
+
}
|
|
2354
|
+
const parentDir = path.dirname(oldPath)
|
|
2355
|
+
const newPath = path.join(parentDir, newFilename)
|
|
2356
|
+
|
|
2357
|
+
try {
|
|
2358
|
+
fs.renameSync(oldPath, newPath)
|
|
2359
|
+
sendJson(res, 200, { success: true, filename: newFilename, private: !isPrivate })
|
|
2360
|
+
} catch (err) {
|
|
2361
|
+
sendJson(res, 500, { error: `Failed to toggle private: ${err.message}` })
|
|
2362
|
+
}
|
|
2363
|
+
return
|
|
2364
|
+
}
|
|
2365
|
+
|
|
2366
|
+
// ── Agent Signal API ──────────────────────────────────────────────────
|
|
2367
|
+
|
|
2368
|
+
// POST /agent/signal — agent signals status (done/error/running)
|
|
2369
|
+
if (routePath === '/agent/signal' && method === 'POST') {
|
|
2370
|
+
const { widgetId, canvasId, branch, status, message, data: payload } = body
|
|
2371
|
+
|
|
2372
|
+
if (!widgetId || !status) {
|
|
2373
|
+
sendJson(res, 400, { error: 'widgetId and status are required' })
|
|
2374
|
+
return
|
|
2375
|
+
}
|
|
2376
|
+
|
|
2377
|
+
const validStatuses = ['done', 'error', 'running']
|
|
2378
|
+
if (!validStatuses.includes(status)) {
|
|
2379
|
+
sendJson(res, 400, { error: `status must be one of: ${validStatuses.join(', ')}` })
|
|
2380
|
+
return
|
|
2381
|
+
}
|
|
2382
|
+
|
|
2383
|
+
try {
|
|
2384
|
+
const { updateAgentStatus, initTerminalConfig } = await import('./terminal-config.js')
|
|
2385
|
+
initTerminalConfig(root)
|
|
2386
|
+
updateAgentStatus({
|
|
2387
|
+
branch: branch || 'unknown',
|
|
2388
|
+
canvasId: canvasId || 'unknown',
|
|
2389
|
+
widgetId,
|
|
2390
|
+
status,
|
|
2391
|
+
message: message || null,
|
|
2392
|
+
data: payload || null,
|
|
2393
|
+
})
|
|
2394
|
+
|
|
2395
|
+
// Push status to canvas clients via Vite WS custom event
|
|
2396
|
+
if (__viteWs) {
|
|
2397
|
+
__viteWs.send({
|
|
2398
|
+
type: 'custom',
|
|
2399
|
+
event: 'storyboard:agent-status',
|
|
2400
|
+
data: { widgetId, canvasId, status, message, timestamp: new Date().toISOString() },
|
|
2401
|
+
})
|
|
2402
|
+
}
|
|
2403
|
+
|
|
2404
|
+
sendJson(res, 200, { success: true, status })
|
|
2405
|
+
} catch (err) {
|
|
2406
|
+
sendJson(res, 500, { error: `Failed to update agent status: ${err.message}` })
|
|
2407
|
+
}
|
|
2408
|
+
return
|
|
2409
|
+
}
|
|
2410
|
+
|
|
2411
|
+
// GET /agent/status — poll agent status for a widget
|
|
2412
|
+
if (routePath === '/agent/status' && method === 'GET') {
|
|
2413
|
+
const url = new URL(req.url, 'http://localhost')
|
|
2414
|
+
const widgetId = url.searchParams.get('widgetId')
|
|
2415
|
+
const canvasId = url.searchParams.get('canvasId') || 'unknown'
|
|
2416
|
+
const branch = url.searchParams.get('branch') || 'unknown'
|
|
2417
|
+
|
|
2418
|
+
if (!widgetId) {
|
|
2419
|
+
sendJson(res, 400, { error: 'widgetId query parameter is required' })
|
|
2420
|
+
return
|
|
2421
|
+
}
|
|
2422
|
+
|
|
2423
|
+
try {
|
|
2424
|
+
const { readTerminalConfig, initTerminalConfig } = await import('./terminal-config.js')
|
|
2425
|
+
initTerminalConfig(root)
|
|
2426
|
+
const config = readTerminalConfig({ branch, canvasId, widgetId })
|
|
2427
|
+
sendJson(res, 200, { agentStatus: config?.agentStatus || null })
|
|
2428
|
+
} catch (err) {
|
|
2429
|
+
sendJson(res, 500, { error: `Failed to read agent status: ${err.message}` })
|
|
2430
|
+
}
|
|
2431
|
+
return
|
|
2432
|
+
}
|
|
2433
|
+
|
|
2434
|
+
// POST /agent/spawn — spawn a headless agent session
|
|
2435
|
+
if (routePath === '/agent/spawn' && method === 'POST') {
|
|
2436
|
+
const { canvasId, widgetId, prompt, autopilot = true, branch: reqBranch } = body
|
|
2437
|
+
|
|
2438
|
+
if (!canvasId || !widgetId || !prompt) {
|
|
2439
|
+
sendJson(res, 400, { error: 'canvasId, widgetId, and prompt are required' })
|
|
2440
|
+
return
|
|
2441
|
+
}
|
|
2442
|
+
|
|
2443
|
+
try {
|
|
2444
|
+
const { execSync } = await import('node:child_process')
|
|
2445
|
+
const { writeTerminalConfig, updateAgentStatus, initTerminalConfig } = await import('./terminal-config.js')
|
|
2446
|
+
const { generateTmuxName, registerSession } = await import('./terminal-registry.js')
|
|
2447
|
+
const fsModule = await import('node:fs')
|
|
2448
|
+
|
|
2449
|
+
initTerminalConfig(root)
|
|
2450
|
+
|
|
2451
|
+
let branch = reqBranch || 'unknown'
|
|
2452
|
+
try {
|
|
2453
|
+
branch = execSync('git branch --show-current', { encoding: 'utf8', cwd: root }).trim()
|
|
2454
|
+
} catch { /* empty */ }
|
|
2455
|
+
|
|
2456
|
+
const tmuxName = generateTmuxName(branch, canvasId, widgetId)
|
|
2457
|
+
|
|
2458
|
+
// Register in session registry
|
|
2459
|
+
registerSession({ branch, canvasId, widgetId, prettyName: null })
|
|
2460
|
+
|
|
2461
|
+
// Write terminal config with connected widget context
|
|
2462
|
+
writeTerminalConfig({ branch, canvasId, widgetId })
|
|
2463
|
+
|
|
2464
|
+
// Mark as running
|
|
2465
|
+
updateAgentStatus({ branch, canvasId, widgetId, status: 'running', message: 'Agent spawning...' })
|
|
2466
|
+
|
|
2467
|
+
// Push running status to clients
|
|
2468
|
+
if (__viteWs) {
|
|
2469
|
+
__viteWs.send({
|
|
2470
|
+
type: 'custom',
|
|
2471
|
+
event: 'storyboard:agent-status',
|
|
2472
|
+
data: { widgetId, canvasId, status: 'running', timestamp: new Date().toISOString() },
|
|
2473
|
+
})
|
|
2474
|
+
}
|
|
2475
|
+
|
|
2476
|
+
// Build server URL for agent env vars
|
|
2477
|
+
const serverUrl = `http://localhost:${req.socket?.localPort || 1234}`
|
|
2478
|
+
|
|
2479
|
+
// Create headless tmux session
|
|
2480
|
+
try {
|
|
2481
|
+
execSync(`tmux new-session -d -s "${tmuxName}" -c "${root}"`, { stdio: 'ignore' })
|
|
2482
|
+
execSync(`tmux set-option -t "${tmuxName}" status off`, { stdio: 'ignore' })
|
|
2483
|
+
execSync(`tmux set-option -t "${tmuxName}" mouse on`, { stdio: 'ignore' })
|
|
2484
|
+
execSync(`tmux set-option -t "${tmuxName}" set-clipboard off`, { stdio: 'ignore' })
|
|
2485
|
+
} catch (err) {
|
|
2486
|
+
// Session may already exist
|
|
2487
|
+
devLog().logEvent('warn', 'tmux session create failed', { tmuxName, error: err.message })
|
|
2488
|
+
}
|
|
2489
|
+
|
|
2490
|
+
// Set environment variables at tmux session level (inherited by new panes)
|
|
2491
|
+
const envMap = {
|
|
2492
|
+
STORYBOARD_WIDGET_ID: widgetId,
|
|
2493
|
+
STORYBOARD_CANVAS_ID: canvasId,
|
|
2494
|
+
STORYBOARD_BRANCH: branch,
|
|
2495
|
+
STORYBOARD_SERVER_URL: serverUrl,
|
|
2496
|
+
}
|
|
2497
|
+
for (const [key, val] of Object.entries(envMap)) {
|
|
2498
|
+
execSync(`tmux setenv -t "${tmuxName}" ${key} "${val}"`, { stdio: 'ignore' })
|
|
2499
|
+
}
|
|
2500
|
+
|
|
2501
|
+
// Write env file for this terminal session — sourced before copilot launch
|
|
2502
|
+
// This avoids race conditions with tmux send-keys export
|
|
2503
|
+
const envFile = path.join(root, '.storyboard', 'terminals', `${tmuxName}.env`)
|
|
2504
|
+
const envContent = Object.entries(envMap).map(([k, v]) => `export ${k}=${JSON.stringify(v)}`).join('\n') + '\n'
|
|
2505
|
+
fsModule.writeFileSync(envFile, envContent)
|
|
2506
|
+
|
|
2507
|
+
// Build command from widgets.config.json (prompt mode) or storyboard.config.json (interactive)
|
|
2508
|
+
let copilotCmd
|
|
2509
|
+
if (autopilot) {
|
|
2510
|
+
copilotCmd = buildPromptCmd({ prompt, envFile })
|
|
2511
|
+
if (!copilotCmd) {
|
|
2512
|
+
const execution = getPromptExecution()
|
|
2513
|
+
sendJson(res, 400, { error: `Default agent "${execution?.default || 'unknown'}" has no prompt command configured` })
|
|
2514
|
+
return
|
|
2515
|
+
}
|
|
2516
|
+
} else {
|
|
2517
|
+
// Interactive mode — read startupCommand from storyboard.config.json
|
|
2518
|
+
let startupCmd = 'copilot'
|
|
2519
|
+
try {
|
|
2520
|
+
const configPath = path.join(root, 'storyboard.config.json')
|
|
2521
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
|
|
2522
|
+
const agents = config?.canvas?.agents || {}
|
|
2523
|
+
const defaultAgent = Object.values(agents).find(a => a.default) || Object.values(agents)[0]
|
|
2524
|
+
if (defaultAgent?.startupCommand) startupCmd = defaultAgent.startupCommand
|
|
2525
|
+
} catch { /* empty */ }
|
|
2526
|
+
copilotCmd = `source ${envFile} && ${startupCmd}`
|
|
2527
|
+
}
|
|
2528
|
+
|
|
2529
|
+
setTimeout(() => {
|
|
2530
|
+
try {
|
|
2531
|
+
execSync(`tmux send-keys -t "${tmuxName}" -l ${JSON.stringify(copilotCmd)}`, { stdio: 'ignore' })
|
|
2532
|
+
execSync(`tmux send-keys -t "${tmuxName}" Enter`, { stdio: 'ignore' })
|
|
2533
|
+
} catch (err) {
|
|
2534
|
+
devLog().logEvent('warn', 'Failed to launch copilot', { tmuxName, error: err.message })
|
|
2535
|
+
}
|
|
2536
|
+
// Poll for copilot readiness, then send /autopilot + Enter once
|
|
2537
|
+
let sent = false
|
|
2538
|
+
const poll = setInterval(() => {
|
|
2539
|
+
if (sent) { clearInterval(poll); return }
|
|
2540
|
+
try {
|
|
2541
|
+
const pane = execSync(`tmux capture-pane -t "${tmuxName}" -p`, { encoding: 'utf8', timeout: 1000 })
|
|
2542
|
+
if (pane.includes('Environment loaded:')) {
|
|
2543
|
+
sent = true
|
|
2544
|
+
clearInterval(poll)
|
|
2545
|
+
setTimeout(() => {
|
|
2546
|
+
try {
|
|
2547
|
+
execSync(`tmux send-keys -t "${tmuxName}" -l "/allow-all on"`, { stdio: 'ignore' })
|
|
2548
|
+
execSync(`tmux send-keys -t "${tmuxName}" Enter`, { stdio: 'ignore' })
|
|
2549
|
+
} catch { /* empty */ }
|
|
2550
|
+
}, 500)
|
|
2551
|
+
}
|
|
2552
|
+
} catch { /* empty */ }
|
|
2553
|
+
}, 1000)
|
|
2554
|
+
setTimeout(() => { if (!sent) { sent = true; clearInterval(poll) } }, 15000)
|
|
2555
|
+
}, 500)
|
|
2556
|
+
|
|
2557
|
+
// Set up idle timeout (5 minutes)
|
|
2558
|
+
const IDLE_TIMEOUT = 5 * 60 * 1000
|
|
2559
|
+
setTimeout(async () => {
|
|
2560
|
+
try {
|
|
2561
|
+
const { readTerminalConfig } = await import('./terminal-config.js')
|
|
2562
|
+
const config = readTerminalConfig({ branch, canvasId, widgetId })
|
|
2563
|
+
if (config?.agentStatus?.status === 'running') {
|
|
2564
|
+
updateAgentStatus({ branch, canvasId, widgetId, status: 'error', message: 'Agent timed out (5 min idle)' })
|
|
2565
|
+
if (__viteWs) {
|
|
2566
|
+
__viteWs.send({
|
|
2567
|
+
type: 'custom',
|
|
2568
|
+
event: 'storyboard:agent-status',
|
|
2569
|
+
data: { widgetId, canvasId, status: 'error', message: 'Agent timed out', timestamp: new Date().toISOString() },
|
|
2570
|
+
})
|
|
2571
|
+
}
|
|
2572
|
+
}
|
|
2573
|
+
} catch { /* empty */ }
|
|
2574
|
+
}, IDLE_TIMEOUT)
|
|
2575
|
+
|
|
2576
|
+
sendJson(res, 200, { success: true, tmuxName, status: 'running' })
|
|
2577
|
+
} catch (err) {
|
|
2578
|
+
sendJson(res, 500, { error: `Failed to spawn agent: ${err.message}` })
|
|
2579
|
+
}
|
|
2580
|
+
return
|
|
2581
|
+
}
|
|
2582
|
+
|
|
2583
|
+
// POST /agent/peek — reconnect a headless agent session to a visible terminal widget
|
|
2584
|
+
if (routePath === '/agent/peek' && method === 'POST') {
|
|
2585
|
+
const { widgetId, canvasId } = body
|
|
2586
|
+
|
|
2587
|
+
if (!widgetId) {
|
|
2588
|
+
sendJson(res, 400, { error: 'widgetId is required' })
|
|
2589
|
+
return
|
|
2590
|
+
}
|
|
2591
|
+
|
|
2592
|
+
try {
|
|
2593
|
+
const { execSync } = await import('node:child_process')
|
|
2594
|
+
const { generateTmuxName } = await import('./terminal-registry.js')
|
|
2595
|
+
|
|
2596
|
+
let branch = 'unknown'
|
|
2597
|
+
try {
|
|
2598
|
+
branch = execSync('git branch --show-current', { encoding: 'utf8', cwd: root }).trim()
|
|
2599
|
+
} catch { /* empty */ }
|
|
2600
|
+
|
|
2601
|
+
const tmuxName = generateTmuxName(branch, canvasId || 'unknown', widgetId)
|
|
2602
|
+
|
|
2603
|
+
// Check if the tmux session exists
|
|
2604
|
+
try {
|
|
2605
|
+
execSync(`tmux has-session -t "${tmuxName}"`, { stdio: 'ignore' })
|
|
2606
|
+
} catch {
|
|
2607
|
+
sendJson(res, 404, { error: `No tmux session found for widget ${widgetId}` })
|
|
2608
|
+
return
|
|
2609
|
+
}
|
|
2610
|
+
|
|
2611
|
+
// The session exists — return info so the client can create a terminal widget
|
|
2612
|
+
// that connects to it
|
|
2613
|
+
sendJson(res, 200, {
|
|
2614
|
+
success: true,
|
|
2615
|
+
tmuxName,
|
|
2616
|
+
widgetId,
|
|
2617
|
+
canvasId: canvasId || 'unknown',
|
|
2618
|
+
message: 'Session is alive. Create a terminal widget to connect.',
|
|
2619
|
+
})
|
|
2620
|
+
} catch (err) {
|
|
2621
|
+
sendJson(res, 500, { error: `Failed to peek agent session: ${err.message}` })
|
|
2622
|
+
}
|
|
2623
|
+
return
|
|
2624
|
+
}
|
|
2625
|
+
|
|
2626
|
+
// ── Terminal Messaging API ──────────────────────────────────────────
|
|
2627
|
+
|
|
2628
|
+
// POST /terminal/send — send a message to a terminal via tmux send-keys
|
|
2629
|
+
if (routePath === '/terminal/send' && method === 'POST') {
|
|
2630
|
+
const { widgetId: targetWidgetId, message, from: senderWidgetId } = body
|
|
2631
|
+
|
|
2632
|
+
if (!targetWidgetId || !message) {
|
|
2633
|
+
sendJson(res, 400, { error: 'widgetId and message are required' })
|
|
2634
|
+
return
|
|
2635
|
+
}
|
|
2636
|
+
|
|
2637
|
+
try {
|
|
2638
|
+
const { execSync } = await import('node:child_process')
|
|
2639
|
+
const { findTmuxNameForWidget } = await import('./terminal-registry.js')
|
|
2640
|
+
const { readTerminalConfigById, updatePendingMessages, initTerminalConfig } = await import('./terminal-config.js')
|
|
2641
|
+
|
|
2642
|
+
initTerminalConfig(root)
|
|
2643
|
+
|
|
2644
|
+
const tmuxName = findTmuxNameForWidget(targetWidgetId)
|
|
2645
|
+
if (!tmuxName) {
|
|
2646
|
+
sendJson(res, 404, { error: `No active session for widget ${targetWidgetId}` })
|
|
2647
|
+
return
|
|
2648
|
+
}
|
|
2649
|
+
|
|
2650
|
+
// Check session is live (widget open in browser)
|
|
2651
|
+
const { getSession } = await import('./terminal-registry.js')
|
|
2652
|
+
const session = getSession(tmuxName)
|
|
2653
|
+
const isLive = session?.status === 'live'
|
|
2654
|
+
|
|
2655
|
+
// Resolve sender display name
|
|
2656
|
+
let senderName = senderWidgetId || 'unknown'
|
|
2657
|
+
if (senderWidgetId) {
|
|
2658
|
+
try {
|
|
2659
|
+
const senderConfig = readTerminalConfigById(senderWidgetId)
|
|
2660
|
+
if (senderConfig?.displayName) senderName = senderConfig.displayName
|
|
2661
|
+
} catch { /* use widgetId as fallback */ }
|
|
2662
|
+
}
|
|
2663
|
+
|
|
2664
|
+
// Deterministic agent detection: get the pane's shell PID, then
|
|
2665
|
+
// check its child process. Known agent CLIs (copilot, claude) run
|
|
2666
|
+
// as direct children of the shell. We match against the process
|
|
2667
|
+
// name (comm) to identify which agent is running.
|
|
2668
|
+
let runningAgent = null // null = no agent, 'copilot' | 'claude' | 'codex' = which one
|
|
2669
|
+
try {
|
|
2670
|
+
const panePid = execSync(
|
|
2671
|
+
`tmux list-panes -t "${tmuxName}" -F '#{pane_pid}'`,
|
|
2672
|
+
{ encoding: 'utf8', timeout: 2000 }
|
|
2673
|
+
).trim()
|
|
2674
|
+
if (panePid && isLive) {
|
|
2675
|
+
const children = execSync(
|
|
2676
|
+
`ps -o comm= -p $(pgrep -P ${panePid} 2>/dev/null | tr '\\n' ',') 2>/dev/null || true`,
|
|
2677
|
+
{ encoding: 'utf8', timeout: 2000 }
|
|
2678
|
+
).trim().split('\n').map(s => s.trim()).filter(Boolean)
|
|
2679
|
+
for (const cmd of children) {
|
|
2680
|
+
if (cmd === 'copilot') { runningAgent = 'copilot'; break }
|
|
2681
|
+
if (cmd === 'claude') { runningAgent = 'claude'; break }
|
|
2682
|
+
if (cmd === 'codex') { runningAgent = 'codex'; break }
|
|
2683
|
+
}
|
|
2684
|
+
}
|
|
2685
|
+
} catch { /* tmux/ps not available */ }
|
|
2686
|
+
|
|
2687
|
+
const isAgentRunning = runningAgent !== null
|
|
2688
|
+
|
|
2689
|
+
if (isAgentRunning) {
|
|
2690
|
+
// Agent is running — send the full message directly (like a chat bubble)
|
|
2691
|
+
const formatted = `📩 ${senderName}: ${message}`
|
|
2692
|
+
|
|
2693
|
+
try {
|
|
2694
|
+
execSync(`tmux send-keys -t "${tmuxName}" -l ${JSON.stringify(formatted)}`, { stdio: 'ignore' })
|
|
2695
|
+
execSync(`tmux send-keys -t "${tmuxName}" Enter`, { stdio: 'ignore' })
|
|
2696
|
+
} catch (err) {
|
|
2697
|
+
sendJson(res, 500, { error: `Failed to send via tmux: ${err.message}` })
|
|
2698
|
+
return
|
|
2699
|
+
}
|
|
2700
|
+
|
|
2701
|
+
sendJson(res, 200, { success: true, delivered: true })
|
|
2702
|
+
} else {
|
|
2703
|
+
// Shell prompt or unknown — queue the message
|
|
2704
|
+
updatePendingMessages(targetWidgetId, {
|
|
2705
|
+
from: senderWidgetId || null,
|
|
2706
|
+
fromName: senderName,
|
|
2707
|
+
message,
|
|
2708
|
+
createdAt: new Date().toISOString(),
|
|
2709
|
+
})
|
|
2710
|
+
|
|
2711
|
+
sendJson(res, 200, { success: true, queued: true })
|
|
2712
|
+
}
|
|
2713
|
+
} catch (err) {
|
|
2714
|
+
sendJson(res, 500, { error: `Failed to send message: ${err.message}` })
|
|
2715
|
+
}
|
|
2716
|
+
return
|
|
2717
|
+
}
|
|
2718
|
+
|
|
2719
|
+
// POST /terminal/output — save latest output to terminal config
|
|
2720
|
+
if (routePath === '/terminal/output' && method === 'POST') {
|
|
2721
|
+
const { widgetId: outputWidgetId, content, summary } = body
|
|
2722
|
+
|
|
2723
|
+
if (!outputWidgetId) {
|
|
2724
|
+
sendJson(res, 400, { error: 'widgetId is required' })
|
|
2725
|
+
return
|
|
2726
|
+
}
|
|
2727
|
+
|
|
2728
|
+
try {
|
|
2729
|
+
const { updateLatestOutput, initTerminalConfig } = await import('./terminal-config.js')
|
|
2730
|
+
initTerminalConfig(root)
|
|
2731
|
+
|
|
2732
|
+
updateLatestOutput(outputWidgetId, {
|
|
2733
|
+
content: content || '',
|
|
2734
|
+
summary: summary || '',
|
|
2735
|
+
updatedAt: new Date().toISOString(),
|
|
2736
|
+
})
|
|
2737
|
+
|
|
2738
|
+
sendJson(res, 200, { success: true })
|
|
2739
|
+
} catch (err) {
|
|
2740
|
+
sendJson(res, 500, { error: `Failed to save output: ${err.message}` })
|
|
2741
|
+
}
|
|
2742
|
+
return
|
|
2743
|
+
}
|
|
2744
|
+
|
|
2745
|
+
// POST /prompt/spawn — spawn a prompt agent session (acquires from hot pool)
|
|
2746
|
+
if (routePath === '/prompt/spawn' && method === 'POST') {
|
|
2747
|
+
const { canvasId, widgetId, prompt } = body
|
|
2748
|
+
|
|
2749
|
+
if (!canvasId || !widgetId || !prompt) {
|
|
2750
|
+
sendJson(res, 400, { error: 'canvasId, widgetId, and prompt are required' })
|
|
2751
|
+
return
|
|
2752
|
+
}
|
|
2753
|
+
|
|
2754
|
+
// Try to acquire a warm tmux session from the prompt pool
|
|
2755
|
+
const warmSession = hotPool?.acquire('prompt') || null
|
|
2756
|
+
|
|
2757
|
+
// Delegate to agent/spawn — the prompt widget is just a specialized agent
|
|
2758
|
+
// We reuse the same tmux-based infrastructure
|
|
2759
|
+
try {
|
|
2760
|
+
const { execSync } = await import('node:child_process')
|
|
2761
|
+
const { writeTerminalConfig, updateAgentStatus, updateTerminalConnections, initTerminalConfig } = await import('./terminal-config.js')
|
|
2762
|
+
const { generateTmuxName, registerSession } = await import('./terminal-registry.js')
|
|
2763
|
+
const fsModule = await import('node:fs')
|
|
2764
|
+
|
|
2765
|
+
initTerminalConfig(root)
|
|
2766
|
+
|
|
2767
|
+
let branch = 'unknown'
|
|
2768
|
+
try {
|
|
2769
|
+
branch = execSync('git branch --show-current', { encoding: 'utf8', cwd: root }).trim()
|
|
2770
|
+
} catch { /* empty */ }
|
|
2771
|
+
|
|
2772
|
+
const serverUrl = `http://localhost:${req.socket?.localPort || 1234}`
|
|
2773
|
+
const tmuxName = generateTmuxName(branch, canvasId, widgetId)
|
|
2774
|
+
|
|
2775
|
+
registerSession({ branch, canvasId, widgetId, prettyName: null })
|
|
2776
|
+
writeTerminalConfig({ branch, canvasId, widgetId, serverUrl, tmuxName })
|
|
2777
|
+
updateAgentStatus({ branch, canvasId, widgetId, status: 'running', message: 'Prompt agent spawning...' })
|
|
2778
|
+
|
|
2779
|
+
// Resolve connected widgets so the terminal-agent has context
|
|
2780
|
+
try {
|
|
2781
|
+
const canvasFilePath = findCanvasPath(root, canvasId)
|
|
2782
|
+
if (canvasFilePath) {
|
|
2783
|
+
const canvasData = readCanvas(canvasFilePath)
|
|
2784
|
+
const widgetMap = new Map((canvasData.widgets || []).map(w => [w.id, w]))
|
|
2785
|
+
const connectors = canvasData.connectors || []
|
|
2786
|
+
const connectedIds = new Set()
|
|
2787
|
+
for (const conn of connectors) {
|
|
2788
|
+
if (conn.start?.widgetId === widgetId) connectedIds.add(conn.end?.widgetId)
|
|
2789
|
+
if (conn.end?.widgetId === widgetId) connectedIds.add(conn.start?.widgetId)
|
|
2790
|
+
}
|
|
2791
|
+
connectedIds.delete(undefined)
|
|
2792
|
+
connectedIds.delete(null)
|
|
2793
|
+
const connectedWidgets = [...connectedIds]
|
|
2794
|
+
.map(id => widgetMap.get(id))
|
|
2795
|
+
.filter(Boolean)
|
|
2796
|
+
.map(w => ({ id: w.id, type: w.type, props: w.props, position: w.position }))
|
|
2797
|
+
if (connectedWidgets.length > 0) {
|
|
2798
|
+
updateTerminalConnections({ branch, canvasId, widgetId, connectedWidgets })
|
|
2799
|
+
}
|
|
2800
|
+
}
|
|
2801
|
+
} catch (err) {
|
|
2802
|
+
devLog().logEvent('warn', 'Failed to resolve prompt connections', { error: err.message })
|
|
2803
|
+
}
|
|
2804
|
+
|
|
2805
|
+
if (__viteWs) {
|
|
2806
|
+
__viteWs.send({
|
|
2807
|
+
type: 'custom',
|
|
2808
|
+
event: 'storyboard:agent-status',
|
|
2809
|
+
data: { widgetId, canvasId, status: 'running', timestamp: new Date().toISOString() },
|
|
2810
|
+
})
|
|
2811
|
+
}
|
|
2812
|
+
|
|
2813
|
+
// If we got a warm tmux session, rename it to the canonical name.
|
|
2814
|
+
// Otherwise, create a fresh tmux session from scratch.
|
|
2815
|
+
let usedWarm = false
|
|
2816
|
+
if (warmSession?.tmuxName) {
|
|
2817
|
+
try {
|
|
2818
|
+
// Kill any existing session with the canonical name first
|
|
2819
|
+
try { execSync(`tmux kill-session -t "${tmuxName}" 2>/dev/null`, { stdio: 'ignore' }) } catch { /* empty */ }
|
|
2820
|
+
// Rename the warm session to the canonical name
|
|
2821
|
+
execSync(`tmux rename-session -t "${warmSession.tmuxName}" "${tmuxName}"`, { stdio: 'ignore' })
|
|
2822
|
+
usedWarm = true
|
|
2823
|
+
hotPool.consume('prompt', warmSession.id)
|
|
2824
|
+
} catch {
|
|
2825
|
+
// Rename failed — fall back to creating fresh session
|
|
2826
|
+
hotPool.release('prompt', warmSession.id)
|
|
2827
|
+
}
|
|
2828
|
+
}
|
|
2829
|
+
|
|
2830
|
+
if (!usedWarm) {
|
|
2831
|
+
// Fresh tmux session (cold path)
|
|
2832
|
+
try {
|
|
2833
|
+
execSync(`tmux -f /dev/null new-session -d -s "${tmuxName}" -c "${root}"`, { stdio: 'ignore' })
|
|
2834
|
+
execSync(`tmux set-option -t "${tmuxName}" status off`, { stdio: 'ignore' })
|
|
2835
|
+
execSync(`tmux set-option -t "${tmuxName}" set-clipboard off 2>/dev/null`, { stdio: 'ignore' })
|
|
2836
|
+
} catch { /* session may already exist */ }
|
|
2837
|
+
}
|
|
2838
|
+
|
|
2839
|
+
// Set env vars — use send-keys to export into the running shell
|
|
2840
|
+
const envMap = {
|
|
2841
|
+
STORYBOARD_WIDGET_ID: widgetId,
|
|
2842
|
+
STORYBOARD_CANVAS_ID: canvasId,
|
|
2843
|
+
STORYBOARD_BRANCH: branch,
|
|
2844
|
+
STORYBOARD_SERVER_URL: serverUrl,
|
|
2845
|
+
}
|
|
2846
|
+
|
|
2847
|
+
// Write env file for the copilot command to source
|
|
2848
|
+
const { join } = await import('node:path')
|
|
2849
|
+
const envFile = join(root, '.storyboard', 'terminals', `${tmuxName}.env`)
|
|
2850
|
+
const envContent = Object.entries(envMap).map(([k, v]) => `export ${k}=${JSON.stringify(v)}`).join('\n') + '\n'
|
|
2851
|
+
fsModule.writeFileSync(envFile, envContent)
|
|
2852
|
+
|
|
2853
|
+
const copilotCmd = buildPromptCmd({ prompt, envFile })
|
|
2854
|
+
if (!copilotCmd) {
|
|
2855
|
+
const execution = getPromptExecution()
|
|
2856
|
+
sendJson(res, 400, { error: `Default agent "${execution?.default || 'unknown'}" has no prompt command configured` })
|
|
2857
|
+
return
|
|
2858
|
+
}
|
|
2859
|
+
|
|
2860
|
+
// Send the copilot command — warm sessions have a shell ready, no delay needed
|
|
2861
|
+
const delay = usedWarm ? 0 : 500
|
|
2862
|
+
const displayName = (() => {
|
|
2863
|
+
try {
|
|
2864
|
+
const canvasFilePath = findCanvasPath(root, canvasId)
|
|
2865
|
+
if (!canvasFilePath) return null
|
|
2866
|
+
const canvasData = readCanvas(canvasFilePath)
|
|
2867
|
+
const w = (canvasData.widgets || []).find(w => w.id === widgetId)
|
|
2868
|
+
return w?.props?.prettyName || null
|
|
2869
|
+
} catch { return null }
|
|
2870
|
+
})()
|
|
2871
|
+
const sendCmd = () => {
|
|
2872
|
+
try {
|
|
2873
|
+
execSync(`tmux send-keys -t "${tmuxName}" -l ${JSON.stringify(copilotCmd)}`, { stdio: 'ignore' })
|
|
2874
|
+
execSync(`tmux send-keys -t "${tmuxName}" Enter`, { stdio: 'ignore' })
|
|
2875
|
+
} catch { /* empty */ }
|
|
2876
|
+
// Inject identity after the agent command starts
|
|
2877
|
+
setTimeout(() => {
|
|
2878
|
+
const configFile = `.storyboard/terminals/${widgetId}.json`
|
|
2879
|
+
const msg = `[System] Your terminal identity has been set. widgetId=${widgetId} displayName=${displayName || widgetId} canvasId=${canvasId} configFile=${configFile} serverUrl=${serverUrl} — this is a configuration step, no response needed.`
|
|
2880
|
+
try {
|
|
2881
|
+
execSync(`tmux send-keys -t "${tmuxName}" -l ${JSON.stringify(msg)}`, { stdio: 'ignore' })
|
|
2882
|
+
execSync(`tmux send-keys -t "${tmuxName}" Enter`, { stdio: 'ignore' })
|
|
2883
|
+
} catch { /* empty */ }
|
|
2884
|
+
}, 3000)
|
|
2885
|
+
}
|
|
2886
|
+
|
|
2887
|
+
if (delay > 0) {
|
|
2888
|
+
setTimeout(sendCmd, delay)
|
|
2889
|
+
} else {
|
|
2890
|
+
sendCmd()
|
|
2891
|
+
}
|
|
2892
|
+
|
|
2893
|
+
// Idle timeout (5 min)
|
|
2894
|
+
setTimeout(async () => {
|
|
2895
|
+
try {
|
|
2896
|
+
const { readTerminalConfig } = await import('./terminal-config.js')
|
|
2897
|
+
const cfg = readTerminalConfig({ branch, canvasId, widgetId })
|
|
2898
|
+
if (cfg?.agentStatus?.status === 'running') {
|
|
2899
|
+
updateAgentStatus({ branch, canvasId, widgetId, status: 'error', message: 'Prompt timed out (5 min)' })
|
|
2900
|
+
if (__viteWs) {
|
|
2901
|
+
__viteWs.send({
|
|
2902
|
+
type: 'custom',
|
|
2903
|
+
event: 'storyboard:agent-status',
|
|
2904
|
+
data: { widgetId, canvasId, status: 'error', message: 'Prompt timed out', timestamp: new Date().toISOString() },
|
|
2905
|
+
})
|
|
2906
|
+
}
|
|
2907
|
+
}
|
|
2908
|
+
} catch { /* empty */ }
|
|
2909
|
+
}, 5 * 60 * 1000)
|
|
2910
|
+
|
|
2911
|
+
sendJson(res, 200, { success: true, tmuxName, status: 'running', warm: usedWarm })
|
|
2912
|
+
} catch (err) {
|
|
2913
|
+
sendJson(res, 500, { error: `Failed to spawn prompt agent: ${err.message}` })
|
|
2914
|
+
}
|
|
2915
|
+
return
|
|
2916
|
+
}
|
|
2917
|
+
|
|
2918
|
+
// POST /terminal/kill — kill a terminal/prompt tmux session
|
|
2919
|
+
if (routePath === '/terminal/kill' && method === 'POST') {
|
|
2920
|
+
const { widgetId: targetWidgetId } = body
|
|
2921
|
+
|
|
2922
|
+
if (!targetWidgetId) {
|
|
2923
|
+
sendJson(res, 400, { error: 'widgetId is required' })
|
|
2924
|
+
return
|
|
2925
|
+
}
|
|
2926
|
+
|
|
2927
|
+
try {
|
|
2928
|
+
const { findTmuxNameForWidget, killSession } = await import('./terminal-registry.js')
|
|
2929
|
+
const { updateAgentStatus, initTerminalConfig } = await import('./terminal-config.js')
|
|
2930
|
+
|
|
2931
|
+
initTerminalConfig(root)
|
|
2932
|
+
|
|
2933
|
+
const tmuxName = findTmuxNameForWidget(targetWidgetId)
|
|
2934
|
+
if (!tmuxName) {
|
|
2935
|
+
sendJson(res, 404, { error: `No active session for widget ${targetWidgetId}` })
|
|
2936
|
+
return
|
|
2937
|
+
}
|
|
2938
|
+
|
|
2939
|
+
// Close any WS connections for this session
|
|
2940
|
+
const { orphanTerminalSession } = await import('./terminal-server.js')
|
|
2941
|
+
orphanTerminalSession(targetWidgetId)
|
|
2942
|
+
|
|
2943
|
+
// Kill the tmux session and clean up registry
|
|
2944
|
+
killSession(tmuxName)
|
|
2945
|
+
|
|
2946
|
+
// Update agent status
|
|
2947
|
+
const pathParts = req.url.split('/')
|
|
2948
|
+
const _canvasIdx = pathParts.indexOf('canvas')
|
|
2949
|
+
void _canvasIdx
|
|
2950
|
+
let branch = 'unknown'
|
|
2951
|
+
try {
|
|
2952
|
+
const { execSync } = await import('node:child_process')
|
|
2953
|
+
branch = execSync('git branch --show-current', { encoding: 'utf8', cwd: root }).trim()
|
|
2954
|
+
} catch { /* empty */ }
|
|
2955
|
+
|
|
2956
|
+
try {
|
|
2957
|
+
updateAgentStatus({ branch, canvasId: 'unknown', widgetId: targetWidgetId, status: 'cancelled', message: 'Cancelled by user' })
|
|
2958
|
+
} catch { /* empty */ }
|
|
2959
|
+
|
|
2960
|
+
// Notify via HMR
|
|
2961
|
+
if (__viteWs) {
|
|
2962
|
+
__viteWs.send({
|
|
2963
|
+
type: 'custom',
|
|
2964
|
+
event: 'storyboard:agent-status',
|
|
2965
|
+
data: { widgetId: targetWidgetId, status: 'cancelled', message: 'Cancelled by user', timestamp: new Date().toISOString() },
|
|
2966
|
+
})
|
|
2967
|
+
}
|
|
2968
|
+
|
|
2969
|
+
sendJson(res, 200, { success: true, killed: tmuxName })
|
|
2970
|
+
} catch (err) {
|
|
2971
|
+
sendJson(res, 500, { error: `Failed to kill session: ${err.message}` })
|
|
2972
|
+
}
|
|
2973
|
+
return
|
|
2974
|
+
}
|
|
2975
|
+
|
|
2976
|
+
// GET /terminal-buffer/:widgetId — read terminal buffer JSON
|
|
2977
|
+
// Accepts optional ?length=N query param to truncate scrollback
|
|
2978
|
+
if (routePath.startsWith('/terminal-buffer/') && method === 'GET') {
|
|
2979
|
+
const widgetId = routePath.slice('/terminal-buffer/'.length).split('?')[0]
|
|
2980
|
+
if (!widgetId || widgetId.includes('..') || widgetId.includes('/')) {
|
|
2981
|
+
sendJson(res, 400, { error: 'Invalid widgetId' })
|
|
2982
|
+
return
|
|
2983
|
+
}
|
|
2984
|
+
|
|
2985
|
+
const urlObj = new URL(req.url, 'http://localhost')
|
|
2986
|
+
const lengthParam = urlObj.searchParams.get('length')
|
|
2987
|
+
const maxLength = lengthParam ? parseInt(lengthParam, 10) : undefined
|
|
2988
|
+
|
|
2989
|
+
try {
|
|
2990
|
+
const { readTerminalBuffer } = await import('./terminal-server.js')
|
|
2991
|
+
const buffer = readTerminalBuffer(widgetId, { maxLength: maxLength || undefined })
|
|
2992
|
+
if (buffer) {
|
|
2993
|
+
sendJson(res, 200, buffer)
|
|
2994
|
+
return
|
|
2995
|
+
}
|
|
2996
|
+
sendJson(res, 404, { error: 'Buffer not found' })
|
|
2997
|
+
} catch (err) {
|
|
2998
|
+
sendJson(res, 500, { error: `Failed to read buffer: ${err.message}` })
|
|
2999
|
+
}
|
|
3000
|
+
return
|
|
3001
|
+
}
|
|
3002
|
+
|
|
3003
|
+
// GET /terminal-snapshot/:widgetId — read terminal snapshot JSON (new + legacy fallback)
|
|
3004
|
+
if (routePath.startsWith('/terminal-snapshot/') && method === 'GET') {
|
|
3005
|
+
const widgetId = routePath.slice('/terminal-snapshot/'.length)
|
|
3006
|
+
if (!widgetId || widgetId.includes('..') || widgetId.includes('/')) {
|
|
3007
|
+
sendJson(res, 400, { error: 'Invalid widgetId' })
|
|
3008
|
+
return
|
|
3009
|
+
}
|
|
3010
|
+
|
|
3011
|
+
try {
|
|
3012
|
+
const { readTerminalSnapshot } = await import('./terminal-server.js')
|
|
3013
|
+
|
|
3014
|
+
// Try new path first
|
|
3015
|
+
const snapshot = readTerminalSnapshot(widgetId)
|
|
3016
|
+
if (snapshot) {
|
|
3017
|
+
sendJson(res, 200, snapshot)
|
|
3018
|
+
return
|
|
3019
|
+
}
|
|
3020
|
+
|
|
3021
|
+
// Legacy fallback: .storyboard/terminal-snapshots/<canvasDir>/<widgetId>.json
|
|
3022
|
+
const snapshotsRoot = path.join(root, '.storyboard', 'terminal-snapshots')
|
|
3023
|
+
if (fs.existsSync(snapshotsRoot)) {
|
|
3024
|
+
const dirs = fs.readdirSync(snapshotsRoot, { withFileTypes: true })
|
|
3025
|
+
for (const d of dirs) {
|
|
3026
|
+
if (!d.isDirectory()) continue
|
|
3027
|
+
const filePath = path.join(snapshotsRoot, d.name, `${widgetId}.json`)
|
|
3028
|
+
if (fs.existsSync(filePath)) {
|
|
3029
|
+
const data = fs.readFileSync(filePath, 'utf8')
|
|
3030
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
3031
|
+
res.end(data)
|
|
3032
|
+
return
|
|
3033
|
+
}
|
|
3034
|
+
}
|
|
3035
|
+
}
|
|
3036
|
+
sendJson(res, 404, { error: 'Snapshot not found' })
|
|
3037
|
+
} catch (err) {
|
|
3038
|
+
sendJson(res, 500, { error: `Failed to read snapshot: ${err.message}` })
|
|
3039
|
+
}
|
|
3040
|
+
return
|
|
3041
|
+
}
|
|
3042
|
+
|
|
3043
|
+
// DELETE /delete-canvas — delete a canvas and its directory
|
|
3044
|
+
if (routePath === '/delete-canvas' && method === 'DELETE') {
|
|
3045
|
+
const { name } = body
|
|
3046
|
+
if (!name || typeof name !== 'string') {
|
|
3047
|
+
sendJson(res, 400, { error: 'Canvas name is required' })
|
|
3048
|
+
return
|
|
3049
|
+
}
|
|
3050
|
+
|
|
3051
|
+
const filePath = findCanvasPath(root, name)
|
|
3052
|
+
if (!filePath) {
|
|
3053
|
+
sendJson(res, 404, { error: `Canvas "${name}" not found` })
|
|
3054
|
+
return
|
|
3055
|
+
}
|
|
3056
|
+
|
|
3057
|
+
try {
|
|
3058
|
+
const dir = path.dirname(filePath)
|
|
3059
|
+
const canvasDir = path.join(root, 'src', 'canvas')
|
|
3060
|
+
|
|
3061
|
+
// Delete the canvas file
|
|
3062
|
+
fs.unlinkSync(filePath)
|
|
3063
|
+
|
|
3064
|
+
// If the parent directory is inside src/canvas/ and now empty (or only has .meta.json), remove it
|
|
3065
|
+
if (dir !== canvasDir) {
|
|
3066
|
+
const remaining = fs.readdirSync(dir).filter(f => !f.endsWith('.meta.json'))
|
|
3067
|
+
if (remaining.length === 0) {
|
|
3068
|
+
for (const f of fs.readdirSync(dir)) {
|
|
3069
|
+
fs.unlinkSync(path.join(dir, f))
|
|
3070
|
+
}
|
|
3071
|
+
fs.rmdirSync(dir)
|
|
3072
|
+
}
|
|
3073
|
+
}
|
|
3074
|
+
|
|
3075
|
+
sendJson(res, 200, { success: true, deleted: name })
|
|
3076
|
+
} catch (err) {
|
|
3077
|
+
sendJson(res, 500, { error: `Failed to delete canvas: ${err.message}` })
|
|
3078
|
+
}
|
|
3079
|
+
return
|
|
3080
|
+
}
|
|
3081
|
+
|
|
3082
|
+
// PUT /update-meta — update canvas metadata
|
|
3083
|
+
if (routePath === '/update-meta' && method === 'PUT') {
|
|
3084
|
+
const { name, title, description, author } = body
|
|
3085
|
+
if (!name || typeof name !== 'string') {
|
|
3086
|
+
sendJson(res, 400, { error: 'Canvas name is required' })
|
|
3087
|
+
return
|
|
3088
|
+
}
|
|
3089
|
+
|
|
3090
|
+
const filePath = findCanvasPath(root, name)
|
|
3091
|
+
if (!filePath) {
|
|
3092
|
+
sendJson(res, 404, { error: `Canvas "${name}" not found` })
|
|
3093
|
+
return
|
|
3094
|
+
}
|
|
3095
|
+
|
|
3096
|
+
try {
|
|
3097
|
+
// Try to find and update .meta.json first
|
|
3098
|
+
const dir = path.dirname(filePath)
|
|
3099
|
+
const dirName = path.basename(dir).replace(/\.folder$/, '')
|
|
3100
|
+
const metaPath = path.join(dir, `${dirName}.meta.json`)
|
|
3101
|
+
|
|
3102
|
+
if (fs.existsSync(metaPath)) {
|
|
3103
|
+
const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'))
|
|
3104
|
+
if (title !== undefined) meta.title = title
|
|
3105
|
+
if (description !== undefined) meta.description = description
|
|
3106
|
+
if (author !== undefined) meta.author = author
|
|
3107
|
+
fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2) + '\n', 'utf-8')
|
|
3108
|
+
} else {
|
|
3109
|
+
// Update the canvas JSONL's canvas_created event metadata
|
|
3110
|
+
const text = fs.readFileSync(filePath, 'utf-8')
|
|
3111
|
+
const lines = text.split('\n').filter(Boolean)
|
|
3112
|
+
if (lines.length > 0) {
|
|
3113
|
+
const firstEvent = JSON.parse(lines[0])
|
|
3114
|
+
if (title !== undefined) firstEvent.title = title
|
|
3115
|
+
if (description !== undefined) firstEvent.description = description
|
|
3116
|
+
if (author !== undefined) firstEvent.author = author
|
|
3117
|
+
lines[0] = JSON.stringify(firstEvent)
|
|
3118
|
+
fs.writeFileSync(filePath, lines.join('\n') + '\n', 'utf-8')
|
|
3119
|
+
}
|
|
3120
|
+
}
|
|
3121
|
+
|
|
3122
|
+
// Notify via WebSocket
|
|
3123
|
+
pushCanvasUpdate(name, filePath, __viteWs)
|
|
3124
|
+
|
|
3125
|
+
sendJson(res, 200, { success: true, updated: name })
|
|
3126
|
+
} catch (err) {
|
|
3127
|
+
sendJson(res, 500, { error: `Failed to update canvas metadata: ${err.message}` })
|
|
3128
|
+
}
|
|
3129
|
+
return
|
|
3130
|
+
}
|
|
3131
|
+
|
|
3132
|
+
sendJson(res, 404, { error: `Unknown route: ${method} ${routePath}` })
|
|
3133
|
+
}
|
|
3134
|
+
}
|