@myrialabs/clopen 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +6 -0
- package/.github/workflows/release.yml +60 -0
- package/.github/workflows/test.yml +40 -0
- package/CONTRIBUTING.md +499 -0
- package/LICENSE +21 -0
- package/README.md +209 -0
- package/backend/index.ts +156 -0
- package/backend/lib/chat/helpers.ts +42 -0
- package/backend/lib/chat/index.ts +2 -0
- package/backend/lib/chat/stream-manager.ts +1126 -0
- package/backend/lib/database/README.md +77 -0
- package/backend/lib/database/index.ts +119 -0
- package/backend/lib/database/migrations/001_create_projects_table.ts +31 -0
- package/backend/lib/database/migrations/002_create_chat_sessions_table.ts +33 -0
- package/backend/lib/database/migrations/003_create_messages_table.ts +32 -0
- package/backend/lib/database/migrations/004_create_prompt_templates_table.ts +34 -0
- package/backend/lib/database/migrations/005_create_settings_table.ts +24 -0
- package/backend/lib/database/migrations/006_add_user_to_messages.ts +58 -0
- package/backend/lib/database/migrations/007_create_stream_states_table.ts +41 -0
- package/backend/lib/database/migrations/008_create_message_snapshots_table.ts +62 -0
- package/backend/lib/database/migrations/009_add_delta_snapshot_fields.ts +41 -0
- package/backend/lib/database/migrations/010_add_soft_delete_and_branch_support.ts +70 -0
- package/backend/lib/database/migrations/011_git_like_commit_graph.ts +156 -0
- package/backend/lib/database/migrations/012_add_file_change_statistics.ts +41 -0
- package/backend/lib/database/migrations/013_checkpoint_tree_state.ts +118 -0
- package/backend/lib/database/migrations/014_add_engine_to_sessions.ts +18 -0
- package/backend/lib/database/migrations/015_add_model_to_sessions.ts +18 -0
- package/backend/lib/database/migrations/016_create_user_projects_table.ts +34 -0
- package/backend/lib/database/migrations/017_add_current_session_to_user_projects.ts +32 -0
- package/backend/lib/database/migrations/018_create_claude_accounts_table.ts +24 -0
- package/backend/lib/database/migrations/019_add_claude_account_to_sessions.ts +18 -0
- package/backend/lib/database/migrations/020_add_snapshot_tree_hash.ts +32 -0
- package/backend/lib/database/migrations/021_drop_prompt_templates_table.ts +33 -0
- package/backend/lib/database/migrations/index.ts +154 -0
- package/backend/lib/database/queries/checkpoint-queries.ts +87 -0
- package/backend/lib/database/queries/engine-queries.ts +75 -0
- package/backend/lib/database/queries/index.ts +9 -0
- package/backend/lib/database/queries/message-queries.ts +472 -0
- package/backend/lib/database/queries/project-queries.ts +117 -0
- package/backend/lib/database/queries/session-queries.ts +271 -0
- package/backend/lib/database/queries/settings-queries.ts +34 -0
- package/backend/lib/database/queries/snapshot-queries.ts +326 -0
- package/backend/lib/database/queries/utils-queries.ts +59 -0
- package/backend/lib/database/seeders/index.ts +13 -0
- package/backend/lib/database/seeders/settings_seeder.ts +84 -0
- package/backend/lib/database/utils/connection.ts +174 -0
- package/backend/lib/database/utils/index.ts +4 -0
- package/backend/lib/database/utils/migration-runner.ts +118 -0
- package/backend/lib/database/utils/seeder-runner.ts +121 -0
- package/backend/lib/engine/adapters/claude/environment.ts +164 -0
- package/backend/lib/engine/adapters/claude/error-handler.ts +60 -0
- package/backend/lib/engine/adapters/claude/index.ts +1 -0
- package/backend/lib/engine/adapters/claude/path-utils.ts +38 -0
- package/backend/lib/engine/adapters/claude/stream.ts +177 -0
- package/backend/lib/engine/adapters/opencode/index.ts +2 -0
- package/backend/lib/engine/adapters/opencode/message-converter.ts +862 -0
- package/backend/lib/engine/adapters/opencode/server.ts +104 -0
- package/backend/lib/engine/adapters/opencode/stream.ts +755 -0
- package/backend/lib/engine/index.ts +196 -0
- package/backend/lib/engine/types.ts +58 -0
- package/backend/lib/files/file-operations.ts +478 -0
- package/backend/lib/files/file-reading.ts +308 -0
- package/backend/lib/files/file-watcher.ts +383 -0
- package/backend/lib/files/path-browsing.ts +382 -0
- package/backend/lib/git/git-executor.ts +88 -0
- package/backend/lib/git/git-parser.ts +411 -0
- package/backend/lib/git/git-service.ts +505 -0
- package/backend/lib/mcp/README.md +1144 -0
- package/backend/lib/mcp/config.ts +316 -0
- package/backend/lib/mcp/index.ts +35 -0
- package/backend/lib/mcp/project-context.ts +236 -0
- package/backend/lib/mcp/servers/browser-automation/actions.ts +156 -0
- package/backend/lib/mcp/servers/browser-automation/browser.ts +419 -0
- package/backend/lib/mcp/servers/browser-automation/index.ts +791 -0
- package/backend/lib/mcp/servers/browser-automation/inspection.ts +501 -0
- package/backend/lib/mcp/servers/helper.ts +143 -0
- package/backend/lib/mcp/servers/index.ts +45 -0
- package/backend/lib/mcp/servers/weather/get-temperature.ts +56 -0
- package/backend/lib/mcp/servers/weather/index.ts +31 -0
- package/backend/lib/mcp/stdio-server.ts +103 -0
- package/backend/lib/mcp/types.ts +65 -0
- package/backend/lib/preview/browser/browser-audio-capture.ts +86 -0
- package/backend/lib/preview/browser/browser-console-manager.ts +263 -0
- package/backend/lib/preview/browser/browser-dialog-handler.ts +222 -0
- package/backend/lib/preview/browser/browser-interaction-handler.ts +421 -0
- package/backend/lib/preview/browser/browser-mcp-control.ts +415 -0
- package/backend/lib/preview/browser/browser-native-ui-handler.ts +512 -0
- package/backend/lib/preview/browser/browser-navigation-tracker.ts +104 -0
- package/backend/lib/preview/browser/browser-pool.ts +357 -0
- package/backend/lib/preview/browser/browser-preview-service.ts +882 -0
- package/backend/lib/preview/browser/browser-tab-manager.ts +935 -0
- package/backend/lib/preview/browser/browser-video-capture.ts +695 -0
- package/backend/lib/preview/browser/scripts/audio-stream.ts +292 -0
- package/backend/lib/preview/browser/scripts/cursor-tracking.ts +85 -0
- package/backend/lib/preview/browser/scripts/video-stream.ts +438 -0
- package/backend/lib/preview/browser/types.ts +359 -0
- package/backend/lib/preview/index.ts +24 -0
- package/backend/lib/project/index.ts +2 -0
- package/backend/lib/project/status-manager.ts +182 -0
- package/backend/lib/shared/index.ts +2 -0
- package/backend/lib/shared/port-utils.ts +25 -0
- package/backend/lib/shared/process-manager.ts +281 -0
- package/backend/lib/snapshot/blob-store.ts +227 -0
- package/backend/lib/snapshot/gitignore.ts +307 -0
- package/backend/lib/snapshot/helpers.ts +397 -0
- package/backend/lib/snapshot/snapshot-service.ts +483 -0
- package/backend/lib/terminal/helpers.ts +14 -0
- package/backend/lib/terminal/index.ts +8 -0
- package/backend/lib/terminal/pty-manager.ts +4 -0
- package/backend/lib/terminal/pty-session-manager.ts +387 -0
- package/backend/lib/terminal/shell-utils.ts +313 -0
- package/backend/lib/terminal/stream-manager.ts +293 -0
- package/backend/lib/tunnel/global-tunnel-manager.ts +243 -0
- package/backend/lib/tunnel/project-tunnel-manager.ts +311 -0
- package/backend/lib/user/helpers.ts +87 -0
- package/backend/lib/utils/ws.ts +944 -0
- package/backend/lib/vite-dev.ts +353 -0
- package/backend/middleware/cors.ts +15 -0
- package/backend/middleware/error-handler.ts +49 -0
- package/backend/middleware/logger.ts +9 -0
- package/backend/types/api.ts +24 -0
- package/backend/ws/README.md +1505 -0
- package/backend/ws/chat/background.ts +198 -0
- package/backend/ws/chat/index.ts +21 -0
- package/backend/ws/chat/stream.ts +707 -0
- package/backend/ws/engine/claude/accounts.ts +401 -0
- package/backend/ws/engine/claude/index.ts +13 -0
- package/backend/ws/engine/claude/status.ts +43 -0
- package/backend/ws/engine/index.ts +14 -0
- package/backend/ws/engine/opencode/index.ts +11 -0
- package/backend/ws/engine/opencode/status.ts +30 -0
- package/backend/ws/engine/utils.ts +36 -0
- package/backend/ws/files/index.ts +30 -0
- package/backend/ws/files/read.ts +189 -0
- package/backend/ws/files/search.ts +453 -0
- package/backend/ws/files/watch.ts +124 -0
- package/backend/ws/files/write.ts +143 -0
- package/backend/ws/git/branch.ts +106 -0
- package/backend/ws/git/commit.ts +39 -0
- package/backend/ws/git/conflict.ts +68 -0
- package/backend/ws/git/diff.ts +69 -0
- package/backend/ws/git/index.ts +24 -0
- package/backend/ws/git/log.ts +41 -0
- package/backend/ws/git/remote.ts +214 -0
- package/backend/ws/git/staging.ts +84 -0
- package/backend/ws/git/status.ts +90 -0
- package/backend/ws/index.ts +69 -0
- package/backend/ws/mcp/index.ts +61 -0
- package/backend/ws/messages/crud.ts +74 -0
- package/backend/ws/messages/index.ts +14 -0
- package/backend/ws/preview/browser/cleanup.ts +129 -0
- package/backend/ws/preview/browser/console.ts +114 -0
- package/backend/ws/preview/browser/interact.ts +513 -0
- package/backend/ws/preview/browser/mcp.ts +129 -0
- package/backend/ws/preview/browser/native-ui.ts +235 -0
- package/backend/ws/preview/browser/stats.ts +55 -0
- package/backend/ws/preview/browser/tab-info.ts +126 -0
- package/backend/ws/preview/browser/tab.ts +166 -0
- package/backend/ws/preview/browser/webcodecs.ts +293 -0
- package/backend/ws/preview/index.ts +146 -0
- package/backend/ws/projects/crud.ts +113 -0
- package/backend/ws/projects/index.ts +25 -0
- package/backend/ws/projects/presence.ts +46 -0
- package/backend/ws/projects/status.ts +116 -0
- package/backend/ws/sessions/crud.ts +327 -0
- package/backend/ws/sessions/index.ts +33 -0
- package/backend/ws/settings/crud.ts +112 -0
- package/backend/ws/settings/index.ts +14 -0
- package/backend/ws/snapshot/index.ts +17 -0
- package/backend/ws/snapshot/restore.ts +173 -0
- package/backend/ws/snapshot/timeline.ts +141 -0
- package/backend/ws/system/index.ts +14 -0
- package/backend/ws/system/operations.ts +49 -0
- package/backend/ws/terminal/index.ts +40 -0
- package/backend/ws/terminal/persistence.ts +153 -0
- package/backend/ws/terminal/session.ts +382 -0
- package/backend/ws/terminal/stream.ts +79 -0
- package/backend/ws/tunnel/index.ts +14 -0
- package/backend/ws/tunnel/operations.ts +91 -0
- package/backend/ws/types.ts +20 -0
- package/backend/ws/user/crud.ts +156 -0
- package/backend/ws/user/index.ts +14 -0
- package/bin/clopen.ts +307 -0
- package/bun.lock +1352 -0
- package/frontend/App.svelte +34 -0
- package/frontend/app.css +313 -0
- package/frontend/lib/app-environment.ts +10 -0
- package/frontend/lib/components/chat/ChatInterface.svelte +407 -0
- package/frontend/lib/components/chat/formatters/ErrorMessage.svelte +57 -0
- package/frontend/lib/components/chat/formatters/MessageFormatter.svelte +224 -0
- package/frontend/lib/components/chat/formatters/TextMessage.svelte +395 -0
- package/frontend/lib/components/chat/formatters/Tools.svelte +70 -0
- package/frontend/lib/components/chat/formatters/index.ts +3 -0
- package/frontend/lib/components/chat/input/ChatInput.svelte +421 -0
- package/frontend/lib/components/chat/input/components/ChatInputActions.svelte +78 -0
- package/frontend/lib/components/chat/input/components/DragDropOverlay.svelte +30 -0
- package/frontend/lib/components/chat/input/components/EditModeIndicator.svelte +33 -0
- package/frontend/lib/components/chat/input/components/EngineModelPicker.svelte +619 -0
- package/frontend/lib/components/chat/input/components/FileAttachmentPreview.svelte +48 -0
- package/frontend/lib/components/chat/input/components/LoadingIndicator.svelte +31 -0
- package/frontend/lib/components/chat/input/composables/use-animations.svelte.ts +201 -0
- package/frontend/lib/components/chat/input/composables/use-chat-actions.svelte.ts +148 -0
- package/frontend/lib/components/chat/input/composables/use-file-handling.svelte.ts +216 -0
- package/frontend/lib/components/chat/input/composables/use-input-state.svelte.ts +357 -0
- package/frontend/lib/components/chat/input/composables/use-textarea-resize.svelte.ts +57 -0
- package/frontend/lib/components/chat/message/ChatMessage.svelte +478 -0
- package/frontend/lib/components/chat/message/ChatMessages.svelte +541 -0
- package/frontend/lib/components/chat/message/DateSeparator.svelte +86 -0
- package/frontend/lib/components/chat/message/MessageBubble.svelte +86 -0
- package/frontend/lib/components/chat/message/MessageHeader.svelte +157 -0
- package/frontend/lib/components/chat/modal/DebugModal.svelte +59 -0
- package/frontend/lib/components/chat/modal/TokenUsageModal.svelte +124 -0
- package/frontend/lib/components/chat/shared/index.ts +2 -0
- package/frontend/lib/components/chat/shared/utils.ts +116 -0
- package/frontend/lib/components/chat/tools/BashOutputTool.svelte +35 -0
- package/frontend/lib/components/chat/tools/BashTool.svelte +46 -0
- package/frontend/lib/components/chat/tools/CustomMcpTool.svelte +139 -0
- package/frontend/lib/components/chat/tools/EditTool.svelte +48 -0
- package/frontend/lib/components/chat/tools/ExitPlanModeTool.svelte +32 -0
- package/frontend/lib/components/chat/tools/GlobTool.svelte +51 -0
- package/frontend/lib/components/chat/tools/GrepTool.svelte +90 -0
- package/frontend/lib/components/chat/tools/KillShellTool.svelte +26 -0
- package/frontend/lib/components/chat/tools/ListMcpResourcesTool.svelte +31 -0
- package/frontend/lib/components/chat/tools/NotebookEditTool.svelte +38 -0
- package/frontend/lib/components/chat/tools/ReadMcpResourceTool.svelte +34 -0
- package/frontend/lib/components/chat/tools/ReadTool.svelte +41 -0
- package/frontend/lib/components/chat/tools/TaskTool.svelte +64 -0
- package/frontend/lib/components/chat/tools/TodoWriteTool.svelte +75 -0
- package/frontend/lib/components/chat/tools/WebFetchTool.svelte +35 -0
- package/frontend/lib/components/chat/tools/WebSearchTool.svelte +84 -0
- package/frontend/lib/components/chat/tools/WriteTool.svelte +33 -0
- package/frontend/lib/components/chat/tools/components/CodeBlock.svelte +79 -0
- package/frontend/lib/components/chat/tools/components/DiffBlock.svelte +408 -0
- package/frontend/lib/components/chat/tools/components/FileHeader.svelte +45 -0
- package/frontend/lib/components/chat/tools/components/InfoLine.svelte +19 -0
- package/frontend/lib/components/chat/tools/components/StatsBadges.svelte +27 -0
- package/frontend/lib/components/chat/tools/components/TerminalCommand.svelte +54 -0
- package/frontend/lib/components/chat/tools/components/index.ts +7 -0
- package/frontend/lib/components/chat/tools/index.ts +26 -0
- package/frontend/lib/components/chat/widgets/FloatingTodoList.svelte +249 -0
- package/frontend/lib/components/chat/widgets/TokenUsage.svelte +78 -0
- package/frontend/lib/components/checkpoint/TimelineModal.svelte +391 -0
- package/frontend/lib/components/checkpoint/timeline/TimelineEdge.svelte +26 -0
- package/frontend/lib/components/checkpoint/timeline/TimelineGraph.svelte +87 -0
- package/frontend/lib/components/checkpoint/timeline/TimelineNode.svelte +108 -0
- package/frontend/lib/components/checkpoint/timeline/TimelineVersionGroup.svelte +59 -0
- package/frontend/lib/components/checkpoint/timeline/animation.ts +168 -0
- package/frontend/lib/components/checkpoint/timeline/config.ts +44 -0
- package/frontend/lib/components/checkpoint/timeline/graph-builder.ts +304 -0
- package/frontend/lib/components/checkpoint/timeline/types.ts +65 -0
- package/frontend/lib/components/checkpoint/timeline/utils.ts +53 -0
- package/frontend/lib/components/common/Alert.svelte +139 -0
- package/frontend/lib/components/common/AvatarBubble.svelte +56 -0
- package/frontend/lib/components/common/Button.svelte +71 -0
- package/frontend/lib/components/common/Card.svelte +102 -0
- package/frontend/lib/components/common/Checkbox.svelte +48 -0
- package/frontend/lib/components/common/Dialog.svelte +249 -0
- package/frontend/lib/components/common/FolderBrowser.svelte +843 -0
- package/frontend/lib/components/common/Icon.svelte +58 -0
- package/frontend/lib/components/common/Input.svelte +72 -0
- package/frontend/lib/components/common/Lightbox.svelte +233 -0
- package/frontend/lib/components/common/LoadingScreen.svelte +52 -0
- package/frontend/lib/components/common/LoadingSpinner.svelte +48 -0
- package/frontend/lib/components/common/Modal.svelte +177 -0
- package/frontend/lib/components/common/ModalProvider.svelte +28 -0
- package/frontend/lib/components/common/ModelSelector.svelte +110 -0
- package/frontend/lib/components/common/MonacoEditor.svelte +569 -0
- package/frontend/lib/components/common/NotificationToast.svelte +113 -0
- package/frontend/lib/components/common/PageTemplate.svelte +76 -0
- package/frontend/lib/components/common/ProjectUserAvatars.svelte +79 -0
- package/frontend/lib/components/common/Select.svelte +98 -0
- package/frontend/lib/components/common/Textarea.svelte +80 -0
- package/frontend/lib/components/common/ThemeToggle.svelte +44 -0
- package/frontend/lib/components/common/lucide-icons.ts +1642 -0
- package/frontend/lib/components/common/material-icons.ts +1082 -0
- package/frontend/lib/components/common/xterm/XTerm.svelte +796 -0
- package/frontend/lib/components/common/xterm/index.ts +16 -0
- package/frontend/lib/components/common/xterm/terminal-config.ts +68 -0
- package/frontend/lib/components/common/xterm/types.ts +31 -0
- package/frontend/lib/components/common/xterm/xterm-service.ts +353 -0
- package/frontend/lib/components/files/FileNode.svelte +384 -0
- package/frontend/lib/components/files/FileTree.svelte +681 -0
- package/frontend/lib/components/files/FileViewer.svelte +728 -0
- package/frontend/lib/components/files/SearchResults.svelte +303 -0
- package/frontend/lib/components/git/BranchManager.svelte +458 -0
- package/frontend/lib/components/git/ChangesSection.svelte +107 -0
- package/frontend/lib/components/git/CommitForm.svelte +76 -0
- package/frontend/lib/components/git/ConflictResolver.svelte +158 -0
- package/frontend/lib/components/git/DiffViewer.svelte +364 -0
- package/frontend/lib/components/git/FileChangeItem.svelte +97 -0
- package/frontend/lib/components/git/GitButton.svelte +33 -0
- package/frontend/lib/components/git/GitLog.svelte +361 -0
- package/frontend/lib/components/git/GitModal.svelte +80 -0
- package/frontend/lib/components/history/HistoryModal.svelte +563 -0
- package/frontend/lib/components/history/HistoryView.svelte +615 -0
- package/frontend/lib/components/index.ts +34 -0
- package/frontend/lib/components/preview/browser/BrowserPreview.svelte +549 -0
- package/frontend/lib/components/preview/browser/components/Canvas.svelte +1058 -0
- package/frontend/lib/components/preview/browser/components/ConsolePanel.svelte +757 -0
- package/frontend/lib/components/preview/browser/components/Container.svelte +450 -0
- package/frontend/lib/components/preview/browser/components/ContextMenu.svelte +236 -0
- package/frontend/lib/components/preview/browser/components/SelectDropdown.svelte +224 -0
- package/frontend/lib/components/preview/browser/components/Toolbar.svelte +339 -0
- package/frontend/lib/components/preview/browser/components/VirtualCursor.svelte +36 -0
- package/frontend/lib/components/preview/browser/core/cleanup.svelte.ts +155 -0
- package/frontend/lib/components/preview/browser/core/coordinator.svelte.ts +837 -0
- package/frontend/lib/components/preview/browser/core/interactions.svelte.ts +113 -0
- package/frontend/lib/components/preview/browser/core/mcp-handlers.svelte.ts +296 -0
- package/frontend/lib/components/preview/browser/core/native-ui-handlers.svelte.ts +391 -0
- package/frontend/lib/components/preview/browser/core/stream-handler.svelte.ts +231 -0
- package/frontend/lib/components/preview/browser/core/tab-manager.svelte.ts +210 -0
- package/frontend/lib/components/preview/browser/core/tab-operations.svelte.ts +239 -0
- package/frontend/lib/components/preview/index.ts +2 -0
- package/frontend/lib/components/settings/SettingsModal.svelte +235 -0
- package/frontend/lib/components/settings/SettingsView.svelte +36 -0
- package/frontend/lib/components/settings/appearance/AppearanceSettings.svelte +51 -0
- package/frontend/lib/components/settings/appearance/LayoutPresetSettings.svelte +160 -0
- package/frontend/lib/components/settings/appearance/LayoutPreview.svelte +76 -0
- package/frontend/lib/components/settings/engines/AIEnginesSettings.svelte +917 -0
- package/frontend/lib/components/settings/general/AdvancedSettings.svelte +187 -0
- package/frontend/lib/components/settings/general/DataManagementSettings.svelte +203 -0
- package/frontend/lib/components/settings/general/GeneralSettings.svelte +10 -0
- package/frontend/lib/components/settings/model/ModelSettings.svelte +357 -0
- package/frontend/lib/components/settings/notifications/NotificationSettings.svelte +205 -0
- package/frontend/lib/components/settings/user/UserSettings.svelte +197 -0
- package/frontend/lib/components/terminal/Terminal.svelte +368 -0
- package/frontend/lib/components/terminal/TerminalTabs.svelte +87 -0
- package/frontend/lib/components/terminal/TerminalView.svelte +55 -0
- package/frontend/lib/components/tunnel/TunnelActive.svelte +142 -0
- package/frontend/lib/components/tunnel/TunnelButton.svelte +54 -0
- package/frontend/lib/components/tunnel/TunnelInactive.svelte +284 -0
- package/frontend/lib/components/tunnel/TunnelModal.svelte +47 -0
- package/frontend/lib/components/tunnel/TunnelQRCode.svelte +49 -0
- package/frontend/lib/components/workspace/DesktopNavigator.svelte +382 -0
- package/frontend/lib/components/workspace/MobileNavigator.svelte +403 -0
- package/frontend/lib/components/workspace/PanelContainer.svelte +100 -0
- package/frontend/lib/components/workspace/PanelHeader.svelte +505 -0
- package/frontend/lib/components/workspace/ViewMenu.svelte +162 -0
- package/frontend/lib/components/workspace/WorkspaceLayout.svelte +169 -0
- package/frontend/lib/components/workspace/layout/DesktopLayout.svelte +15 -0
- package/frontend/lib/components/workspace/layout/MobileLayout.svelte +17 -0
- package/frontend/lib/components/workspace/layout/split-pane/Container.svelte +42 -0
- package/frontend/lib/components/workspace/layout/split-pane/Handle.svelte +85 -0
- package/frontend/lib/components/workspace/layout/split-pane/Layout.svelte +37 -0
- package/frontend/lib/components/workspace/panels/ChatPanel.svelte +274 -0
- package/frontend/lib/components/workspace/panels/FilesPanel.svelte +1261 -0
- package/frontend/lib/components/workspace/panels/GitPanel.svelte +1560 -0
- package/frontend/lib/components/workspace/panels/PreviewPanel.svelte +150 -0
- package/frontend/lib/components/workspace/panels/TerminalPanel.svelte +73 -0
- package/frontend/lib/constants/preview.ts +45 -0
- package/frontend/lib/services/chat/chat.service.ts +704 -0
- package/frontend/lib/services/chat/index.ts +7 -0
- package/frontend/lib/services/notification/global-stream-monitor.ts +86 -0
- package/frontend/lib/services/notification/index.ts +8 -0
- package/frontend/lib/services/notification/push.service.ts +144 -0
- package/frontend/lib/services/notification/sound.service.ts +127 -0
- package/frontend/lib/services/preview/browser/browser-console.service.ts +61 -0
- package/frontend/lib/services/preview/browser/browser-webcodecs.service.ts +1499 -0
- package/frontend/lib/services/preview/browser/mcp-integration.svelte.ts +67 -0
- package/frontend/lib/services/preview/index.ts +23 -0
- package/frontend/lib/services/project/index.ts +8 -0
- package/frontend/lib/services/project/status.service.ts +159 -0
- package/frontend/lib/services/snapshot/snapshot.service.ts +47 -0
- package/frontend/lib/services/terminal/background/index.ts +130 -0
- package/frontend/lib/services/terminal/background/session-restore.ts +274 -0
- package/frontend/lib/services/terminal/background/stream-manager.ts +286 -0
- package/frontend/lib/services/terminal/index.ts +14 -0
- package/frontend/lib/services/terminal/persistence.service.ts +260 -0
- package/frontend/lib/services/terminal/project.service.ts +953 -0
- package/frontend/lib/services/terminal/session.service.ts +364 -0
- package/frontend/lib/services/terminal/terminal.service.ts +369 -0
- package/frontend/lib/stores/core/app.svelte.ts +117 -0
- package/frontend/lib/stores/core/files.svelte.ts +73 -0
- package/frontend/lib/stores/core/presence.svelte.ts +48 -0
- package/frontend/lib/stores/core/projects.svelte.ts +317 -0
- package/frontend/lib/stores/core/sessions.svelte.ts +383 -0
- package/frontend/lib/stores/features/claude-accounts.svelte.ts +58 -0
- package/frontend/lib/stores/features/models.svelte.ts +89 -0
- package/frontend/lib/stores/features/settings.svelte.ts +87 -0
- package/frontend/lib/stores/features/terminal.svelte.ts +701 -0
- package/frontend/lib/stores/features/tunnel.svelte.ts +161 -0
- package/frontend/lib/stores/features/user.svelte.ts +96 -0
- package/frontend/lib/stores/ui/chat-input.svelte.ts +57 -0
- package/frontend/lib/stores/ui/chat-model.svelte.ts +61 -0
- package/frontend/lib/stores/ui/dialog.svelte.ts +59 -0
- package/frontend/lib/stores/ui/edit-mode.svelte.ts +214 -0
- package/frontend/lib/stores/ui/notification.svelte.ts +166 -0
- package/frontend/lib/stores/ui/settings-modal.svelte.ts +88 -0
- package/frontend/lib/stores/ui/theme.svelte.ts +179 -0
- package/frontend/lib/stores/ui/workspace.svelte.ts +754 -0
- package/frontend/lib/types/native-ui.ts +73 -0
- package/frontend/lib/utils/chat/date-separator.ts +39 -0
- package/frontend/lib/utils/chat/message-grouper.ts +219 -0
- package/frontend/lib/utils/chat/message-processor.ts +135 -0
- package/frontend/lib/utils/chat/tool-handler.ts +161 -0
- package/frontend/lib/utils/chat/virtual-scroll.svelte.ts +142 -0
- package/frontend/lib/utils/click-outside.ts +20 -0
- package/frontend/lib/utils/context-manager.ts +257 -0
- package/frontend/lib/utils/file-icon-mappings.ts +769 -0
- package/frontend/lib/utils/folder-icon-mappings.ts +1030 -0
- package/frontend/lib/utils/git-status.ts +68 -0
- package/frontend/lib/utils/platform.ts +113 -0
- package/frontend/lib/utils/port-check.ts +65 -0
- package/frontend/lib/utils/terminalFormatter.ts +207 -0
- package/frontend/lib/utils/theme.ts +6 -0
- package/frontend/lib/utils/tree-visualizer.ts +320 -0
- package/frontend/lib/utils/ws.ts +44 -0
- package/frontend/main.ts +13 -0
- package/index.html +70 -0
- package/package.json +111 -0
- package/scripts/generate-icons.ts +87 -0
- package/scripts/pre-publish-check.sh +142 -0
- package/scripts/setup-hooks.sh +134 -0
- package/scripts/validate-branch-name.sh +47 -0
- package/scripts/validate-commit-msg.sh +42 -0
- package/shared/constants/engines.ts +134 -0
- package/shared/types/database/connection.ts +16 -0
- package/shared/types/database/index.ts +6 -0
- package/shared/types/database/schema.ts +141 -0
- package/shared/types/engine/index.ts +45 -0
- package/shared/types/filesystem/index.ts +22 -0
- package/shared/types/git.ts +171 -0
- package/shared/types/messaging/index.ts +239 -0
- package/shared/types/messaging/tool.ts +526 -0
- package/shared/types/network/api.ts +18 -0
- package/shared/types/network/index.ts +5 -0
- package/shared/types/stores/app.ts +23 -0
- package/shared/types/stores/dialog.ts +21 -0
- package/shared/types/stores/index.ts +3 -0
- package/shared/types/stores/settings.ts +15 -0
- package/shared/types/terminal/index.ts +44 -0
- package/shared/types/ui/components.ts +61 -0
- package/shared/types/ui/icons.ts +23 -0
- package/shared/types/ui/index.ts +22 -0
- package/shared/types/ui/notifications.ts +14 -0
- package/shared/types/ui/theme.ts +12 -0
- package/shared/types/websocket/index.ts +43 -0
- package/shared/types/window.d.ts +13 -0
- package/shared/utils/anonymous-user.ts +168 -0
- package/shared/utils/async.ts +10 -0
- package/shared/utils/diff-calculator.ts +184 -0
- package/shared/utils/file-type-detection.ts +166 -0
- package/shared/utils/logger.ts +158 -0
- package/shared/utils/message-formatter.ts +79 -0
- package/shared/utils/path.ts +47 -0
- package/shared/utils/ws-client.ts +768 -0
- package/shared/utils/ws-server.ts +660 -0
- package/static/audio/notification.ogg +0 -0
- package/static/favicon.svg +8 -0
- package/static/fonts/dm-sans/dm-sans-italic-latin-ext.woff2 +0 -0
- package/static/fonts/dm-sans/dm-sans-italic-latin.woff2 +0 -0
- package/static/fonts/dm-sans/dm-sans-normal-latin-ext.woff2 +0 -0
- package/static/fonts/dm-sans/dm-sans-normal-latin.woff2 +0 -0
- package/static/fonts/dm-sans.css +96 -0
- package/svelte.config.js +20 -0
- package/vite.config.ts +33 -0
|
@@ -0,0 +1,1058 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onDestroy } from 'svelte';
|
|
3
|
+
import { getViewportDimensions, type DeviceSize, type Rotation } from '$frontend/lib/constants/preview';
|
|
4
|
+
import { BrowserWebCodecsService, type BrowserWebCodecsStreamStats } from '$frontend/lib/services/preview/browser/browser-webcodecs.service';
|
|
5
|
+
import { debug } from '$shared/utils/logger';
|
|
6
|
+
|
|
7
|
+
let {
|
|
8
|
+
projectId = '', // REQUIRED for project isolation (read-only from parent)
|
|
9
|
+
sessionId = $bindable<string | null>(null),
|
|
10
|
+
sessionInfo = $bindable<any>(null),
|
|
11
|
+
deviceSize = $bindable<DeviceSize>('laptop'),
|
|
12
|
+
rotation = $bindable<Rotation>('portrait'),
|
|
13
|
+
currentCursor = $bindable('default'),
|
|
14
|
+
canvasAPI = $bindable<any>(null),
|
|
15
|
+
lastFrameData = $bindable<any>(null),
|
|
16
|
+
isConnected = $bindable(false),
|
|
17
|
+
latencyMs = $bindable<number>(0),
|
|
18
|
+
isStreamReady = $bindable(false), // Exposed: true when first frame received
|
|
19
|
+
isNavigating = $bindable(false), // Track if page is navigating (from parent)
|
|
20
|
+
isReconnecting = $bindable(false), // Track if reconnecting after navigation (prevents loading overlay)
|
|
21
|
+
|
|
22
|
+
// Callbacks for interactions
|
|
23
|
+
onInteraction = $bindable<(action: any) => void>(() => {}),
|
|
24
|
+
onCursorUpdate = $bindable<(cursor: string) => void>(() => {}),
|
|
25
|
+
onFrameUpdate = $bindable<(data: any) => void>(() => {}),
|
|
26
|
+
onStatsUpdate = $bindable<(stats: BrowserWebCodecsStreamStats | null) => void>(() => {}),
|
|
27
|
+
onRequestScreencastRefresh = $bindable<() => void>(() => {}) // Called when stream is stuck
|
|
28
|
+
} = $props();
|
|
29
|
+
|
|
30
|
+
// WebCodecs service instance
|
|
31
|
+
let webCodecsService: BrowserWebCodecsService | null = null;
|
|
32
|
+
let isWebCodecsActive = $state(false);
|
|
33
|
+
let activeStreamingSessionId: string | null = null; // Track which session is currently streaming
|
|
34
|
+
let isStartingStream = false; // Prevent concurrent start attempts
|
|
35
|
+
let lastStartRequestId: string | null = null; // Track the last start request to prevent duplicates
|
|
36
|
+
|
|
37
|
+
let canvasElement = $state<HTMLCanvasElement | undefined>();
|
|
38
|
+
let setupCanvasTimeout: ReturnType<typeof setTimeout> | undefined;
|
|
39
|
+
|
|
40
|
+
// Health check and recovery - EVENT-DRIVEN, not timeout-based
|
|
41
|
+
let healthCheckInterval: ReturnType<typeof setInterval> | undefined;
|
|
42
|
+
let initialFrameCheckInterval: ReturnType<typeof setInterval> | undefined;
|
|
43
|
+
let lastFrameTime = 0;
|
|
44
|
+
let consecutiveFailures = $state(0); // Made reactive for UI
|
|
45
|
+
let hasReceivedFirstFrame = $state(false); // Made reactive for UI
|
|
46
|
+
let isStreamStarting = $state(false); // Track when stream is being started
|
|
47
|
+
let isRecovering = $state(false); // Track recovery attempts
|
|
48
|
+
let connectionFailed = $state(false); // Track if connection actually failed (not just slow)
|
|
49
|
+
let hasRequestedScreencastRefresh = false; // Track if we've already requested refresh for this stream
|
|
50
|
+
let navigationJustCompleted = false; // Track if navigation just completed (for fast refresh)
|
|
51
|
+
|
|
52
|
+
// Recovery is only triggered by ACTUAL failures, not timeouts
|
|
53
|
+
// - ICE connection failed
|
|
54
|
+
// - WebCodecs connection closed unexpectedly
|
|
55
|
+
// - Explicit errors
|
|
56
|
+
const MAX_CONSECUTIVE_FAILURES = 2;
|
|
57
|
+
const HEALTH_CHECK_INTERVAL = 2000; // Check every 2 seconds for connection health
|
|
58
|
+
const FRAME_CHECK_INTERVAL = 500; // Check for first frame every 500ms (just for UI update, not recovery)
|
|
59
|
+
const STUCK_STREAM_TIMEOUT = 5000; // Fallback: Request screencast refresh after 5 seconds of connected but no frame
|
|
60
|
+
const NAVIGATION_FAST_REFRESH_DELAY = 500; // Fast refresh after navigation: 500ms
|
|
61
|
+
|
|
62
|
+
// Sync isStreamReady with hasReceivedFirstFrame for parent component
|
|
63
|
+
$effect(() => {
|
|
64
|
+
isStreamReady = hasReceivedFirstFrame;
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// Watch projectId changes and recreate WebCodecs service
|
|
68
|
+
let lastProjectId = '';
|
|
69
|
+
$effect(() => {
|
|
70
|
+
const currentProjectId = projectId;
|
|
71
|
+
|
|
72
|
+
// Project changed - destroy and recreate service
|
|
73
|
+
if (lastProjectId && currentProjectId && lastProjectId !== currentProjectId) {
|
|
74
|
+
debug.log('webcodecs', `🔄 Project changed (${lastProjectId} → ${currentProjectId}), destroying old WebCodecs service`);
|
|
75
|
+
|
|
76
|
+
// Destroy old service
|
|
77
|
+
if (webCodecsService) {
|
|
78
|
+
webCodecsService.destroy();
|
|
79
|
+
webCodecsService = null;
|
|
80
|
+
activeStreamingSessionId = null;
|
|
81
|
+
isWebCodecsActive = false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
lastProjectId = currentProjectId;
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// Sync navigation state with webCodecsService
|
|
89
|
+
// This prevents recovery when DataChannel closes during navigation
|
|
90
|
+
$effect(() => {
|
|
91
|
+
if (webCodecsService) {
|
|
92
|
+
webCodecsService.setNavigating(isNavigating);
|
|
93
|
+
if (isNavigating) {
|
|
94
|
+
debug.log('webcodecs', 'Navigation started - recovery will be suppressed');
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// Convert CSS cursor values to canvas cursor styles
|
|
100
|
+
function mapCursorStyle(browserCursor: string): string {
|
|
101
|
+
const cursorMap: Record<string, string> = {
|
|
102
|
+
'default': 'default',
|
|
103
|
+
'auto': 'default',
|
|
104
|
+
'pointer': 'pointer',
|
|
105
|
+
'text': 'text',
|
|
106
|
+
'wait': 'wait',
|
|
107
|
+
'crosshair': 'crosshair',
|
|
108
|
+
'help': 'help',
|
|
109
|
+
'move': 'move',
|
|
110
|
+
'n-resize': 'n-resize',
|
|
111
|
+
's-resize': 's-resize',
|
|
112
|
+
'e-resize': 'e-resize',
|
|
113
|
+
'w-resize': 'w-resize',
|
|
114
|
+
'ne-resize': 'ne-resize',
|
|
115
|
+
'nw-resize': 'nw-resize',
|
|
116
|
+
'se-resize': 'se-resize',
|
|
117
|
+
'sw-resize': 'sw-resize',
|
|
118
|
+
'ew-resize': 'ew-resize',
|
|
119
|
+
'ns-resize': 'ns-resize',
|
|
120
|
+
'nesw-resize': 'nesw-resize',
|
|
121
|
+
'nwse-resize': 'nwse-resize',
|
|
122
|
+
'grab': 'grab',
|
|
123
|
+
'grabbing': 'grabbing',
|
|
124
|
+
'not-allowed': 'not-allowed',
|
|
125
|
+
'no-drop': 'no-drop',
|
|
126
|
+
'copy': 'copy',
|
|
127
|
+
'alias': 'alias',
|
|
128
|
+
'context-menu': 'context-menu',
|
|
129
|
+
'cell': 'cell',
|
|
130
|
+
'vertical-text': 'vertical-text',
|
|
131
|
+
'all-scroll': 'all-scroll',
|
|
132
|
+
'col-resize': 'col-resize',
|
|
133
|
+
'row-resize': 'row-resize',
|
|
134
|
+
'zoom-in': 'zoom-in',
|
|
135
|
+
'zoom-out': 'zoom-out'
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
return cursorMap[browserCursor] || 'default';
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Update canvas cursor style
|
|
142
|
+
function updateCanvasCursor(newCursor: string) {
|
|
143
|
+
if (canvasElement && newCursor !== currentCursor) {
|
|
144
|
+
const mappedCursor = mapCursorStyle(newCursor);
|
|
145
|
+
canvasElement.style.cursor = mappedCursor;
|
|
146
|
+
currentCursor = newCursor;
|
|
147
|
+
onCursorUpdate(newCursor);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Interactive canvas functions
|
|
152
|
+
async function sendInteraction(action: any) {
|
|
153
|
+
if (!sessionId) return;
|
|
154
|
+
onInteraction(action);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Utility function to convert canvas display coordinates to browser coordinates
|
|
158
|
+
function getCanvasCoordinates(event: MouseEvent | TouchEvent, canvas: HTMLCanvasElement): { x: number, y: number } {
|
|
159
|
+
const rect = canvas.getBoundingClientRect();
|
|
160
|
+
const scaleX = canvas.width / rect.width;
|
|
161
|
+
const scaleY = canvas.height / rect.height;
|
|
162
|
+
|
|
163
|
+
let clientX: number, clientY: number;
|
|
164
|
+
|
|
165
|
+
if (event instanceof MouseEvent) {
|
|
166
|
+
clientX = event.clientX;
|
|
167
|
+
clientY = event.clientY;
|
|
168
|
+
} else {
|
|
169
|
+
// Touch event - use first touch
|
|
170
|
+
const touch = event.touches[0] || event.changedTouches[0];
|
|
171
|
+
clientX = touch.clientX;
|
|
172
|
+
clientY = touch.clientY;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const x = (clientX - rect.left) * scaleX;
|
|
176
|
+
const y = (clientY - rect.top) * scaleY;
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
x: Math.round(x),
|
|
180
|
+
y: Math.round(y)
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
function handleCanvasMouseMove(event: MouseEvent, canvas: HTMLCanvasElement) {
|
|
186
|
+
if (!sessionId) return;
|
|
187
|
+
|
|
188
|
+
const coords = getCanvasCoordinates(event, canvas);
|
|
189
|
+
|
|
190
|
+
if (isMouseDown && dragStartPos) {
|
|
191
|
+
dragCurrentPos = { x: coords.x, y: coords.y };
|
|
192
|
+
|
|
193
|
+
const dragDistance = Math.sqrt(
|
|
194
|
+
Math.pow(coords.x - dragStartPos.x, 2) + Math.pow(coords.y - dragStartPos.y, 2)
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
// Start drag when distance exceeds threshold
|
|
198
|
+
if (dragDistance > 10) {
|
|
199
|
+
// Send mousedown on first drag detection
|
|
200
|
+
if (!dragStarted) {
|
|
201
|
+
sendInteraction({
|
|
202
|
+
type: 'mousedown',
|
|
203
|
+
x: dragStartPos.x,
|
|
204
|
+
y: dragStartPos.y,
|
|
205
|
+
button: event.button === 2 ? 'right' : 'left'
|
|
206
|
+
});
|
|
207
|
+
dragStarted = true;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
isDragging = true;
|
|
211
|
+
// Send mousemove to continue dragging (mouse is already down)
|
|
212
|
+
sendInteraction({
|
|
213
|
+
type: 'mousemove',
|
|
214
|
+
x: coords.x,
|
|
215
|
+
y: coords.y
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
} else if (!isMouseDown) {
|
|
219
|
+
sendInteraction({
|
|
220
|
+
type: 'mousemove',
|
|
221
|
+
x: coords.x,
|
|
222
|
+
y: coords.y
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function handleCanvasDoubleClick(event: MouseEvent, canvas: HTMLCanvasElement) {
|
|
228
|
+
if (!sessionId) return;
|
|
229
|
+
const coords = getCanvasCoordinates(event, canvas);
|
|
230
|
+
sendInteraction({ type: 'doubleclick', x: coords.x, y: coords.y });
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function handleCanvasRightClick(event: MouseEvent, canvas: HTMLCanvasElement) {
|
|
234
|
+
event.preventDefault();
|
|
235
|
+
if (!sessionId) return;
|
|
236
|
+
const coords = getCanvasCoordinates(event, canvas);
|
|
237
|
+
sendInteraction({ type: 'rightclick', x: coords.x, y: coords.y });
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function handleCanvasWheel(event: WheelEvent, canvas: HTMLCanvasElement) {
|
|
241
|
+
event.preventDefault();
|
|
242
|
+
if (!sessionId) return;
|
|
243
|
+
sendInteraction({ type: 'scroll', deltaX: event.deltaX, deltaY: event.deltaY });
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function handleCanvasKeydown(event: KeyboardEvent) {
|
|
247
|
+
if (!sessionId) return;
|
|
248
|
+
|
|
249
|
+
// Prevent default for all keyboard events to avoid affecting parent page
|
|
250
|
+
// This prevents Ctrl+A, Ctrl+C, arrow keys, etc. from affecting the parent
|
|
251
|
+
event.preventDefault();
|
|
252
|
+
event.stopPropagation();
|
|
253
|
+
|
|
254
|
+
const isNavigationKey = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Tab', 'Enter', 'Escape'].includes(event.key);
|
|
255
|
+
const isModifierKey = event.ctrlKey || event.metaKey || event.altKey || event.shiftKey;
|
|
256
|
+
|
|
257
|
+
if (isNavigationKey) {
|
|
258
|
+
sendInteraction({
|
|
259
|
+
type: 'keynav',
|
|
260
|
+
key: event.key,
|
|
261
|
+
ctrlKey: event.ctrlKey,
|
|
262
|
+
metaKey: event.metaKey,
|
|
263
|
+
altKey: event.altKey,
|
|
264
|
+
shiftKey: event.shiftKey
|
|
265
|
+
});
|
|
266
|
+
} else if (event.key.length === 1 && !isModifierKey) {
|
|
267
|
+
sendInteraction({ type: 'type', text: event.key, delay: 50 });
|
|
268
|
+
} else {
|
|
269
|
+
sendInteraction({
|
|
270
|
+
type: 'key',
|
|
271
|
+
key: event.key,
|
|
272
|
+
ctrlKey: event.ctrlKey,
|
|
273
|
+
metaKey: event.metaKey,
|
|
274
|
+
altKey: event.altKey,
|
|
275
|
+
shiftKey: event.shiftKey
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// State variables for drag-drop functionality
|
|
281
|
+
let isDragging = $state(false);
|
|
282
|
+
let isMouseDown = $state(false);
|
|
283
|
+
let dragStartPos = $state<{x: number, y: number} | null>(null);
|
|
284
|
+
let dragCurrentPos = $state<{x: number, y: number} | null>(null);
|
|
285
|
+
let mouseDownTime = $state(0);
|
|
286
|
+
let dragStarted = $state(false); // Track if we've sent mousedown for drag
|
|
287
|
+
|
|
288
|
+
function handleCanvasMouseDown(event: MouseEvent, canvas: HTMLCanvasElement) {
|
|
289
|
+
if (!sessionId) return;
|
|
290
|
+
|
|
291
|
+
const coords = getCanvasCoordinates(event, canvas);
|
|
292
|
+
|
|
293
|
+
isMouseDown = true;
|
|
294
|
+
mouseDownTime = Date.now();
|
|
295
|
+
dragStartPos = { x: coords.x, y: coords.y };
|
|
296
|
+
dragCurrentPos = { x: coords.x, y: coords.y };
|
|
297
|
+
dragStarted = false; // Reset drag started flag
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function handleCanvasMouseUp(event: MouseEvent, canvas: HTMLCanvasElement) {
|
|
301
|
+
if (!sessionId || !isMouseDown) return;
|
|
302
|
+
|
|
303
|
+
const coords = getCanvasCoordinates(event, canvas);
|
|
304
|
+
|
|
305
|
+
if (dragStartPos) {
|
|
306
|
+
// If drag was started (mousedown was sent), send mouseup
|
|
307
|
+
if (dragStarted) {
|
|
308
|
+
sendInteraction({
|
|
309
|
+
type: 'mouseup',
|
|
310
|
+
x: coords.x,
|
|
311
|
+
y: coords.y,
|
|
312
|
+
button: event.button === 2 ? 'right' : 'left'
|
|
313
|
+
});
|
|
314
|
+
} else {
|
|
315
|
+
// No drag occurred, this is a click
|
|
316
|
+
// IMPORTANT: Only send click for left mouse button (button === 0)
|
|
317
|
+
// Right-click (button === 2) is handled by contextmenu event
|
|
318
|
+
if (event.button === 0) {
|
|
319
|
+
sendInteraction({ type: 'click', x: dragStartPos.x, y: dragStartPos.y });
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
isMouseDown = false;
|
|
325
|
+
isDragging = false;
|
|
326
|
+
dragStartPos = null;
|
|
327
|
+
dragCurrentPos = null;
|
|
328
|
+
dragStarted = false;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function setupCanvasInternal() {
|
|
332
|
+
if (!sessionInfo) return;
|
|
333
|
+
|
|
334
|
+
// IMPORTANT: Use props as primary source of truth, fallback to sessionInfo
|
|
335
|
+
// Props are reactive and updated when user changes device/rotation
|
|
336
|
+
// sessionInfo may be stale (snapshot from launch time)
|
|
337
|
+
const currentDevice: DeviceSize = deviceSize || sessionInfo?.deviceSize || 'laptop';
|
|
338
|
+
const currentRotation: Rotation = rotation || sessionInfo?.rotation || 'landscape';
|
|
339
|
+
|
|
340
|
+
// Use getViewportDimensions helper for consistent viewport calculation
|
|
341
|
+
// This ensures portrait = height > width, landscape = width > height
|
|
342
|
+
const { width: canvasWidth, height: canvasHeight } = getViewportDimensions(currentDevice, currentRotation);
|
|
343
|
+
|
|
344
|
+
debug.log('webcodecs', `setupCanvasInternal: device=${currentDevice}, rotation=${currentRotation}, canvas=${canvasWidth}x${canvasHeight}`);
|
|
345
|
+
|
|
346
|
+
// Get scale from parent (BrowserPreviewContainer calculates this)
|
|
347
|
+
// This is provided via previewDimensions binding
|
|
348
|
+
const currentScale = 1; // We keep canvas at original size, scaling handled by CSS
|
|
349
|
+
|
|
350
|
+
if (canvasElement) {
|
|
351
|
+
// Canvas dimensions stay at original viewport size
|
|
352
|
+
// Scaling is handled by CSS transform in parent container
|
|
353
|
+
if (canvasElement.width === canvasWidth && canvasElement.height === canvasHeight) {
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
canvasElement.width = canvasWidth;
|
|
358
|
+
canvasElement.height = canvasHeight;
|
|
359
|
+
canvasElement.style.width = '100%';
|
|
360
|
+
canvasElement.style.height = '100%';
|
|
361
|
+
// Use same background as loading overlay to avoid flash of black
|
|
362
|
+
// This will be covered by overlay until stream is ready anyway
|
|
363
|
+
canvasElement.style.backgroundColor = 'transparent';
|
|
364
|
+
canvasElement.style.cursor = 'default';
|
|
365
|
+
|
|
366
|
+
// Get context with low-latency optimizations
|
|
367
|
+
const ctx = canvasElement.getContext('2d', {
|
|
368
|
+
alpha: false, // No transparency needed - faster
|
|
369
|
+
desynchronized: true, // Low latency rendering hint
|
|
370
|
+
willReadFrequently: false // We won't read pixels back
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
// Fill with neutral gray (works for both light/dark mode)
|
|
374
|
+
// This matches the loading overlay background roughly
|
|
375
|
+
if (ctx) {
|
|
376
|
+
ctx.imageSmoothingEnabled = true;
|
|
377
|
+
ctx.imageSmoothingQuality = 'low'; // Faster rendering
|
|
378
|
+
ctx.fillStyle = '#f1f5f9'; // slate-100 - neutral light color
|
|
379
|
+
ctx.fillRect(0, 0, canvasElement.width, canvasElement.height);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function setupCanvas() {
|
|
385
|
+
if (setupCanvasTimeout) {
|
|
386
|
+
clearTimeout(setupCanvasTimeout);
|
|
387
|
+
}
|
|
388
|
+
setupCanvasTimeout = setTimeout(() => {
|
|
389
|
+
setupCanvasInternal();
|
|
390
|
+
}, 5);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Start WebCodecs streaming
|
|
394
|
+
async function startStreaming() {
|
|
395
|
+
if (!sessionId || !canvasElement) return;
|
|
396
|
+
|
|
397
|
+
// Prevent concurrent start attempts
|
|
398
|
+
if (isStartingStream) {
|
|
399
|
+
debug.log('webcodecs', 'Already starting stream, skipping duplicate call');
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// If already streaming same session, skip
|
|
404
|
+
if (isWebCodecsActive && activeStreamingSessionId === sessionId) {
|
|
405
|
+
debug.log('webcodecs', 'Already streaming same session, skipping');
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Prevent duplicate requests for same session
|
|
410
|
+
const requestId = `${sessionId}-${Date.now()}`;
|
|
411
|
+
if (lastStartRequestId && lastStartRequestId.startsWith(sessionId)) {
|
|
412
|
+
debug.log('webcodecs', `Duplicate start request for ${sessionId}, skipping`);
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
lastStartRequestId = requestId;
|
|
416
|
+
|
|
417
|
+
isStartingStream = true;
|
|
418
|
+
isStreamStarting = true; // Show loading overlay
|
|
419
|
+
hasReceivedFirstFrame = false; // Reset first frame state
|
|
420
|
+
|
|
421
|
+
try {
|
|
422
|
+
// If streaming a different session, stop first
|
|
423
|
+
if (isWebCodecsActive && activeStreamingSessionId !== sessionId) {
|
|
424
|
+
debug.log('webcodecs', `Session mismatch (active: ${activeStreamingSessionId}, requested: ${sessionId}), stopping old stream first`);
|
|
425
|
+
await stopStreaming();
|
|
426
|
+
// Small delay to ensure cleanup is complete
|
|
427
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Create WebCodecs service if not exists
|
|
431
|
+
if (!webCodecsService) {
|
|
432
|
+
if (!projectId) {
|
|
433
|
+
debug.error('webcodecs', 'Cannot start streaming: projectId is required');
|
|
434
|
+
isStartingStream = false;
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
webCodecsService = new BrowserWebCodecsService(projectId);
|
|
438
|
+
|
|
439
|
+
// Setup error handler
|
|
440
|
+
webCodecsService.setErrorHandler((error: Error) => {
|
|
441
|
+
debug.error('webcodecs', 'Error:', error);
|
|
442
|
+
isStartingStream = false;
|
|
443
|
+
connectionFailed = true;
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
// Setup connection change handler
|
|
447
|
+
webCodecsService.setConnectionChangeHandler((connected: boolean) => {
|
|
448
|
+
isWebCodecsActive = connected;
|
|
449
|
+
isConnected = connected;
|
|
450
|
+
if (!connected) {
|
|
451
|
+
activeStreamingSessionId = null;
|
|
452
|
+
}
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
// Setup connection FAILED handler - this triggers recovery
|
|
456
|
+
// Only called on actual failures (ICE failed, connection failed)
|
|
457
|
+
// NOT called on timeouts or slow loading
|
|
458
|
+
webCodecsService.setConnectionFailedHandler(() => {
|
|
459
|
+
debug.warn('webcodecs', 'Connection failed - attempting recovery');
|
|
460
|
+
connectionFailed = true;
|
|
461
|
+
attemptRecovery();
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
// Setup navigation reconnect handler - FAST path without delay
|
|
465
|
+
// Called when DataChannel closes during navigation, backend already restarted
|
|
466
|
+
webCodecsService.setNavigationReconnectHandler(() => {
|
|
467
|
+
debug.log('webcodecs', '🚀 Navigation reconnect - fast path (no delay)');
|
|
468
|
+
fastReconnect();
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
// Setup reconnecting start handler - fires IMMEDIATELY when DataChannel closes during navigation
|
|
472
|
+
// This ensures isReconnecting is set before the 700ms delay, keeping progress bar visible
|
|
473
|
+
webCodecsService.setReconnectingStartHandler(() => {
|
|
474
|
+
debug.log('webcodecs', '🔄 Reconnecting state started (immediate)');
|
|
475
|
+
isReconnecting = true;
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
// Setup stats handler
|
|
479
|
+
webCodecsService.setStatsHandler((stats: BrowserWebCodecsStreamStats) => {
|
|
480
|
+
onStatsUpdate(stats);
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
// Setup cursor change handler
|
|
484
|
+
webCodecsService.setOnCursorChange((cursor: string) => {
|
|
485
|
+
updateCanvasCursor(cursor);
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Start streaming with retry for session not ready cases
|
|
490
|
+
debug.log('webcodecs', `Starting streaming for session: ${sessionId}`);
|
|
491
|
+
|
|
492
|
+
let success = false;
|
|
493
|
+
let retries = 0;
|
|
494
|
+
const maxRetries = 5; // Increased for rapid tab switching
|
|
495
|
+
const retryDelay = 500; // Increased delay for backend cleanup
|
|
496
|
+
|
|
497
|
+
while (!success && retries < maxRetries) {
|
|
498
|
+
try {
|
|
499
|
+
success = await webCodecsService.startStreaming(sessionId, canvasElement);
|
|
500
|
+
if (success) {
|
|
501
|
+
isWebCodecsActive = true;
|
|
502
|
+
isConnected = true;
|
|
503
|
+
activeStreamingSessionId = sessionId;
|
|
504
|
+
consecutiveFailures = 0; // Reset failure counter on success
|
|
505
|
+
startHealthCheck(); // Start monitoring for stuck streams
|
|
506
|
+
debug.log('webcodecs', 'Streaming started successfully');
|
|
507
|
+
break;
|
|
508
|
+
}
|
|
509
|
+
} catch (error: any) {
|
|
510
|
+
// Check if it's a retriable error - might just need more time
|
|
511
|
+
const isRetriable = error?.message?.includes('not found') ||
|
|
512
|
+
error?.message?.includes('invalid') ||
|
|
513
|
+
error?.message?.includes('Failed to start') ||
|
|
514
|
+
error?.message?.includes('No offer');
|
|
515
|
+
|
|
516
|
+
if (isRetriable) {
|
|
517
|
+
retries++;
|
|
518
|
+
if (retries < maxRetries) {
|
|
519
|
+
debug.log('webcodecs', `Streaming not ready, retrying in ${retryDelay}ms (${retries}/${maxRetries})`);
|
|
520
|
+
await new Promise(resolve => setTimeout(resolve, retryDelay));
|
|
521
|
+
} else {
|
|
522
|
+
debug.error('webcodecs', 'Max retries reached, streaming still not ready');
|
|
523
|
+
// Don't throw - just fail silently and let user retry manually
|
|
524
|
+
break;
|
|
525
|
+
}
|
|
526
|
+
} else {
|
|
527
|
+
// Other errors - don't retry, just log
|
|
528
|
+
debug.error('webcodecs', 'Streaming error:', error);
|
|
529
|
+
break;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
} finally {
|
|
534
|
+
isStartingStream = false;
|
|
535
|
+
isStreamStarting = false; // Hide "Launching browser..." (but may still show "Connecting..." until first frame)
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// Clear canvas to prevent showing stale frames
|
|
540
|
+
// Use light neutral color that works with loading overlay
|
|
541
|
+
function clearCanvas() {
|
|
542
|
+
if (canvasElement) {
|
|
543
|
+
const ctx = canvasElement.getContext('2d');
|
|
544
|
+
if (ctx) {
|
|
545
|
+
ctx.fillStyle = '#f1f5f9'; // slate-100 - same as setup, works with overlay
|
|
546
|
+
ctx.fillRect(0, 0, canvasElement.width, canvasElement.height);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// EVENT-DRIVEN health check - no timeout-based recovery
|
|
552
|
+
// We only check for first frame to update UI, not to trigger recovery
|
|
553
|
+
// Recovery is triggered by actual connection failures (ICE failed, connection closed)
|
|
554
|
+
// skipFirstFrameReset: When true, don't reset hasReceivedFirstFrame (used during fast reconnect to keep overlay stable)
|
|
555
|
+
function startHealthCheck(skipFirstFrameReset = false) {
|
|
556
|
+
// Stop existing intervals without resetting hasReceivedFirstFrame if skipFirstFrameReset is true
|
|
557
|
+
stopHealthCheck(skipFirstFrameReset);
|
|
558
|
+
lastFrameTime = Date.now();
|
|
559
|
+
if (!skipFirstFrameReset) {
|
|
560
|
+
hasReceivedFirstFrame = false;
|
|
561
|
+
}
|
|
562
|
+
connectionFailed = false;
|
|
563
|
+
hasRequestedScreencastRefresh = false; // Reset for new stream
|
|
564
|
+
|
|
565
|
+
const startTime = Date.now();
|
|
566
|
+
|
|
567
|
+
// Check for first frame periodically (for UI update only, NOT recovery)
|
|
568
|
+
initialFrameCheckInterval = setInterval(() => {
|
|
569
|
+
if (!isWebCodecsActive || !sessionId) {
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
const stats = webCodecsService?.getStats();
|
|
574
|
+
const now = Date.now();
|
|
575
|
+
const elapsed = now - startTime;
|
|
576
|
+
|
|
577
|
+
// Log connection state periodically for debugging
|
|
578
|
+
if (elapsed > 0 && elapsed % 5000 < FRAME_CHECK_INTERVAL) {
|
|
579
|
+
debug.log('webcodecs', `Status: connected=${stats?.isConnected}, firstFrame=${stats?.firstFrameRendered}, elapsed=${elapsed}ms`);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// Check if we received the first frame
|
|
583
|
+
if (stats && stats.firstFrameRendered) {
|
|
584
|
+
debug.log('webcodecs', `First frame rendered after ${elapsed}ms`);
|
|
585
|
+
hasReceivedFirstFrame = true;
|
|
586
|
+
lastFrameTime = now;
|
|
587
|
+
consecutiveFailures = 0;
|
|
588
|
+
connectionFailed = false;
|
|
589
|
+
hasRequestedScreencastRefresh = false; // Reset on success
|
|
590
|
+
|
|
591
|
+
// Reset reconnecting state after successful frame reception
|
|
592
|
+
// This completes the fast reconnect cycle
|
|
593
|
+
// Add small delay to allow page to render a bit more before hiding overlay
|
|
594
|
+
if (isReconnecting) {
|
|
595
|
+
debug.log('webcodecs', 'First frame received during reconnect, will reset isReconnecting after delay');
|
|
596
|
+
setTimeout(() => {
|
|
597
|
+
debug.log('webcodecs', 'Resetting isReconnecting after first frame + delay');
|
|
598
|
+
isReconnecting = false;
|
|
599
|
+
}, 300); // 300ms delay to let page render more
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// Stop initial check, start regular health check
|
|
603
|
+
if (initialFrameCheckInterval) {
|
|
604
|
+
clearInterval(initialFrameCheckInterval);
|
|
605
|
+
initialFrameCheckInterval = undefined;
|
|
606
|
+
}
|
|
607
|
+
startRegularHealthCheck();
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// FAST REFRESH AFTER NAVIGATION: If navigation just completed and we're
|
|
612
|
+
// connected but no frame, trigger refresh quickly (don't wait 5 seconds)
|
|
613
|
+
if (navigationJustCompleted && stats?.isConnected && !stats?.firstFrameRendered && elapsed >= NAVIGATION_FAST_REFRESH_DELAY && !hasRequestedScreencastRefresh) {
|
|
614
|
+
debug.log('webcodecs', `Navigation completed, fast-refreshing screencast (connected but no frame for ${elapsed}ms)`);
|
|
615
|
+
hasRequestedScreencastRefresh = true;
|
|
616
|
+
navigationJustCompleted = false;
|
|
617
|
+
onRequestScreencastRefresh();
|
|
618
|
+
return; // Skip regular stuck check
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// STUCK STREAM DETECTION (FALLBACK): If connected but no first frame for too long,
|
|
622
|
+
// request screencast refresh (hot-swap) to restart CDP screencast.
|
|
623
|
+
// This handles cases where WebRTC is connected but CDP frames aren't flowing.
|
|
624
|
+
if (stats?.isConnected && !stats?.firstFrameRendered && elapsed >= STUCK_STREAM_TIMEOUT && !hasRequestedScreencastRefresh) {
|
|
625
|
+
debug.warn('webcodecs', `Stream appears stuck (connected but no frame for ${elapsed}ms), requesting screencast refresh`);
|
|
626
|
+
hasRequestedScreencastRefresh = true;
|
|
627
|
+
onRequestScreencastRefresh();
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
}, FRAME_CHECK_INTERVAL);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// Regular health check (after first frame received)
|
|
634
|
+
// Only monitors connection health, doesn't trigger timeout-based recovery
|
|
635
|
+
function startRegularHealthCheck() {
|
|
636
|
+
if (healthCheckInterval) return; // Already running
|
|
637
|
+
|
|
638
|
+
healthCheckInterval = setInterval(() => {
|
|
639
|
+
if (!isWebCodecsActive || !sessionId) {
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
const stats = webCodecsService?.getStats();
|
|
644
|
+
const now = Date.now();
|
|
645
|
+
|
|
646
|
+
// Update last frame time if we're receiving frames
|
|
647
|
+
if (stats && (stats.firstFrameRendered || stats.videoFramesReceived > 0)) {
|
|
648
|
+
lastFrameTime = now;
|
|
649
|
+
consecutiveFailures = 0;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// NO TIMEOUT-BASED RECOVERY
|
|
653
|
+
// We only log for debugging purposes
|
|
654
|
+
// Recovery is triggered by actual connection state changes (handled in WebCodecs service)
|
|
655
|
+
|
|
656
|
+
}, HEALTH_CHECK_INTERVAL);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// Stop health check intervals
|
|
660
|
+
// skipFirstFrameReset: When true, don't reset hasReceivedFirstFrame (used during navigation reconnect)
|
|
661
|
+
function stopHealthCheck(skipFirstFrameReset = false) {
|
|
662
|
+
if (initialFrameCheckInterval) {
|
|
663
|
+
clearInterval(initialFrameCheckInterval);
|
|
664
|
+
initialFrameCheckInterval = undefined;
|
|
665
|
+
}
|
|
666
|
+
if (healthCheckInterval) {
|
|
667
|
+
clearInterval(healthCheckInterval);
|
|
668
|
+
healthCheckInterval = undefined;
|
|
669
|
+
}
|
|
670
|
+
// Only reset hasReceivedFirstFrame if not skipping (preserves overlay during navigation)
|
|
671
|
+
if (!skipFirstFrameReset) {
|
|
672
|
+
hasReceivedFirstFrame = false;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// Attempt to recover stuck stream
|
|
677
|
+
async function attemptRecovery() {
|
|
678
|
+
if (isStartingStream || isRecovering) {
|
|
679
|
+
debug.log('webcodecs', 'Recovery skipped - already starting or recovering');
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
consecutiveFailures++;
|
|
684
|
+
debug.log('webcodecs', `Recovery attempt ${consecutiveFailures}/${MAX_CONSECUTIVE_FAILURES} for session ${sessionId}`);
|
|
685
|
+
|
|
686
|
+
if (consecutiveFailures > MAX_CONSECUTIVE_FAILURES) {
|
|
687
|
+
debug.error('webcodecs', 'Max recovery attempts reached, giving up');
|
|
688
|
+
isRecovering = false;
|
|
689
|
+
await stopStreaming();
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// Stop and restart streaming
|
|
694
|
+
try {
|
|
695
|
+
isRecovering = true; // Show "Reconnecting..." overlay
|
|
696
|
+
hasReceivedFirstFrame = false; // Reset for recovery
|
|
697
|
+
await stopStreaming();
|
|
698
|
+
lastStartRequestId = null; // Clear to allow new start request
|
|
699
|
+
await new Promise(resolve => setTimeout(resolve, 500)); // Wait for cleanup
|
|
700
|
+
await startStreaming();
|
|
701
|
+
} catch (error) {
|
|
702
|
+
debug.error('webcodecs', 'Recovery failed:', error);
|
|
703
|
+
} finally {
|
|
704
|
+
isRecovering = false;
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// Fast reconnect after navigation - NO DELAY because backend already restarted
|
|
709
|
+
// Uses reconnectToExistingStream which does NOT tell backend to stop
|
|
710
|
+
async function fastReconnect() {
|
|
711
|
+
if (isStartingStream || isRecovering) {
|
|
712
|
+
debug.log('webcodecs', 'Fast reconnect skipped - already starting or recovering');
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
if (!sessionId || !canvasElement || !webCodecsService) {
|
|
717
|
+
debug.warn('webcodecs', 'Fast reconnect skipped - missing session, canvas, or service');
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
debug.log('webcodecs', `🚀 Fast reconnect for session ${sessionId} (reconnect only, no backend stop)`);
|
|
722
|
+
|
|
723
|
+
try {
|
|
724
|
+
isRecovering = true;
|
|
725
|
+
isStartingStream = true;
|
|
726
|
+
|
|
727
|
+
// Set isReconnecting to prevent loading overlay during reconnect
|
|
728
|
+
// This ensures the last frame stays visible instead of "Loading preview..."
|
|
729
|
+
isReconnecting = true;
|
|
730
|
+
|
|
731
|
+
// Don't reset hasReceivedFirstFrame - keep showing last frame during reconnect
|
|
732
|
+
|
|
733
|
+
// Use reconnectToExistingStream which does NOT stop backend streaming
|
|
734
|
+
const success = await webCodecsService.reconnectToExistingStream(sessionId, canvasElement);
|
|
735
|
+
|
|
736
|
+
if (success) {
|
|
737
|
+
isWebCodecsActive = true;
|
|
738
|
+
isConnected = true;
|
|
739
|
+
activeStreamingSessionId = sessionId;
|
|
740
|
+
consecutiveFailures = 0;
|
|
741
|
+
startHealthCheck(true); // Skip resetting hasReceivedFirstFrame to keep overlay stable
|
|
742
|
+
debug.log('webcodecs', '✅ Fast reconnect successful');
|
|
743
|
+
} else {
|
|
744
|
+
throw new Error('Reconnect returned false');
|
|
745
|
+
}
|
|
746
|
+
} catch (error) {
|
|
747
|
+
debug.error('webcodecs', 'Fast reconnect failed:', error);
|
|
748
|
+
// Fall back to regular recovery on failure
|
|
749
|
+
consecutiveFailures++;
|
|
750
|
+
isStartingStream = false;
|
|
751
|
+
isReconnecting = false; // Reset on failure
|
|
752
|
+
attemptRecovery();
|
|
753
|
+
} finally {
|
|
754
|
+
isRecovering = false;
|
|
755
|
+
isStartingStream = false;
|
|
756
|
+
// Note: isReconnecting will be reset when first frame is received
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// Stop WebCodecs streaming
|
|
761
|
+
async function stopStreaming() {
|
|
762
|
+
stopHealthCheck(); // Stop health monitoring
|
|
763
|
+
if (webCodecsService) {
|
|
764
|
+
await webCodecsService.stopStreaming();
|
|
765
|
+
isWebCodecsActive = false;
|
|
766
|
+
isConnected = false;
|
|
767
|
+
latencyMs = 0;
|
|
768
|
+
activeStreamingSessionId = null;
|
|
769
|
+
isStartingStream = false;
|
|
770
|
+
lastStartRequestId = null; // Clear to allow new requests
|
|
771
|
+
// Note: Don't reset hasReceivedFirstFrame here - let startStreaming do it
|
|
772
|
+
// This prevents flashing when switching tabs
|
|
773
|
+
// Clear canvas to prevent stale frames, BUT keep last frame during navigation
|
|
774
|
+
if (!isNavigating) {
|
|
775
|
+
clearCanvas();
|
|
776
|
+
} else {
|
|
777
|
+
debug.log('webcodecs', 'Skipping canvas clear during navigation - keeping last frame');
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// Reactive setup when sessionInfo changes
|
|
783
|
+
$effect(() => {
|
|
784
|
+
if (sessionInfo && canvasElement) {
|
|
785
|
+
setupCanvasInternal();
|
|
786
|
+
}
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
// Track deviceSize and rotation changes to update canvas dimensions
|
|
790
|
+
// This is critical for hot-swap viewport changes without reconnection
|
|
791
|
+
$effect(() => {
|
|
792
|
+
if (canvasElement && sessionInfo) {
|
|
793
|
+
// Access reactive values to track changes
|
|
794
|
+
const currentDevice = deviceSize;
|
|
795
|
+
const currentRotation = rotation;
|
|
796
|
+
|
|
797
|
+
debug.log('webcodecs', `Device/rotation changed: ${currentDevice}/${currentRotation}, reconfiguring canvas`);
|
|
798
|
+
setupCanvasInternal();
|
|
799
|
+
}
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
// Start/restart streaming when session is ready
|
|
803
|
+
// This handles both initial start and session changes (viewport switch, etc.)
|
|
804
|
+
$effect(() => {
|
|
805
|
+
if (sessionId && canvasElement && sessionInfo) {
|
|
806
|
+
// Skip during fast reconnect - fastReconnect() handles this case
|
|
807
|
+
if (isReconnecting) {
|
|
808
|
+
debug.log('webcodecs', 'Skipping streaming effect - fast reconnect in progress');
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// Check if we need to start or restart streaming
|
|
813
|
+
const needsStreaming = !isWebCodecsActive || activeStreamingSessionId !== sessionId;
|
|
814
|
+
|
|
815
|
+
if (needsStreaming) {
|
|
816
|
+
// Clear canvas immediately when session changes to prevent stale frames
|
|
817
|
+
if (activeStreamingSessionId !== sessionId) {
|
|
818
|
+
clearCanvas();
|
|
819
|
+
hasReceivedFirstFrame = false; // Reset to show loading overlay
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// Stop existing streaming first if session changed
|
|
823
|
+
// This ensures clean state before starting new stream
|
|
824
|
+
const doStartStreaming = async () => {
|
|
825
|
+
if (activeStreamingSessionId && activeStreamingSessionId !== sessionId) {
|
|
826
|
+
debug.log('webcodecs', `Session changed from ${activeStreamingSessionId} to ${sessionId}, stopping old stream first`);
|
|
827
|
+
await stopStreaming();
|
|
828
|
+
// Wait a bit for cleanup
|
|
829
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
830
|
+
}
|
|
831
|
+
await startStreaming();
|
|
832
|
+
};
|
|
833
|
+
|
|
834
|
+
// Longer delay to ensure backend session is fully ready
|
|
835
|
+
// This is especially important during viewport/device change
|
|
836
|
+
// when session is being recreated
|
|
837
|
+
const timeout = setTimeout(() => {
|
|
838
|
+
doStartStreaming();
|
|
839
|
+
}, 200);
|
|
840
|
+
|
|
841
|
+
return () => clearTimeout(timeout);
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
});
|
|
845
|
+
|
|
846
|
+
// Cleanup when sessionId is cleared
|
|
847
|
+
$effect(() => {
|
|
848
|
+
if (!sessionId && isWebCodecsActive) {
|
|
849
|
+
hasReceivedFirstFrame = false; // Reset loading state
|
|
850
|
+
stopStreaming();
|
|
851
|
+
}
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
// Setup event listeners when canvas is ready
|
|
855
|
+
$effect(() => {
|
|
856
|
+
if (canvasElement) {
|
|
857
|
+
const canvas = canvasElement;
|
|
858
|
+
|
|
859
|
+
canvas.addEventListener('dblclick', (e) => handleCanvasDoubleClick(e, canvas));
|
|
860
|
+
canvas.addEventListener('contextmenu', (e) => handleCanvasRightClick(e, canvas));
|
|
861
|
+
canvas.addEventListener('wheel', (e) => handleCanvasWheel(e, canvas), { passive: false });
|
|
862
|
+
canvas.addEventListener('keydown', handleCanvasKeydown);
|
|
863
|
+
canvas.addEventListener('mousedown', (e) => handleCanvasMouseDown(e, canvas));
|
|
864
|
+
canvas.addEventListener('mouseup', (e) => handleCanvasMouseUp(e, canvas));
|
|
865
|
+
|
|
866
|
+
let lastMoveTime = 0;
|
|
867
|
+
const handleMouseMove = (e: MouseEvent) => {
|
|
868
|
+
const now = Date.now();
|
|
869
|
+
// Low-end optimized throttle: reduced CPU usage
|
|
870
|
+
// 32ms hover = ~30fps, 16ms drag = ~60fps
|
|
871
|
+
const throttleMs = isDragging ? 16 : 32;
|
|
872
|
+
if (now - lastMoveTime >= throttleMs) {
|
|
873
|
+
lastMoveTime = now;
|
|
874
|
+
handleCanvasMouseMove(e, canvas);
|
|
875
|
+
}
|
|
876
|
+
};
|
|
877
|
+
canvas.addEventListener('mousemove', handleMouseMove);
|
|
878
|
+
|
|
879
|
+
canvas.addEventListener('mousedown', () => {
|
|
880
|
+
canvas.focus();
|
|
881
|
+
});
|
|
882
|
+
|
|
883
|
+
canvas.addEventListener('touchstart', (e) => handleTouchStart(e, canvas), { passive: false });
|
|
884
|
+
canvas.addEventListener('touchmove', (e) => handleTouchMove(e, canvas), { passive: false });
|
|
885
|
+
canvas.addEventListener('touchend', (e) => handleTouchEnd(e, canvas), { passive: false });
|
|
886
|
+
|
|
887
|
+
const handleMouseLeave = () => {
|
|
888
|
+
if (isMouseDown) {
|
|
889
|
+
// If drag was started, send mouseup before resetting
|
|
890
|
+
if (dragStarted) {
|
|
891
|
+
sendInteraction({
|
|
892
|
+
type: 'mouseup',
|
|
893
|
+
x: dragCurrentPos?.x || dragStartPos?.x || 0,
|
|
894
|
+
y: dragCurrentPos?.y || dragStartPos?.y || 0,
|
|
895
|
+
button: 'left'
|
|
896
|
+
});
|
|
897
|
+
}
|
|
898
|
+
isMouseDown = false;
|
|
899
|
+
isDragging = false;
|
|
900
|
+
dragStartPos = null;
|
|
901
|
+
dragCurrentPos = null;
|
|
902
|
+
dragStarted = false;
|
|
903
|
+
}
|
|
904
|
+
};
|
|
905
|
+
canvas.addEventListener('mouseleave', handleMouseLeave);
|
|
906
|
+
|
|
907
|
+
return () => {
|
|
908
|
+
canvas.removeEventListener('dblclick', (e) => handleCanvasDoubleClick(e, canvas));
|
|
909
|
+
canvas.removeEventListener('contextmenu', (e) => handleCanvasRightClick(e, canvas));
|
|
910
|
+
canvas.removeEventListener('wheel', (e) => handleCanvasWheel(e, canvas));
|
|
911
|
+
canvas.removeEventListener('keydown', handleCanvasKeydown);
|
|
912
|
+
canvas.removeEventListener('mousedown', (e) => handleCanvasMouseDown(e, canvas));
|
|
913
|
+
canvas.removeEventListener('mouseup', (e) => handleCanvasMouseUp(e, canvas));
|
|
914
|
+
canvas.removeEventListener('mousemove', handleMouseMove);
|
|
915
|
+
canvas.removeEventListener('touchstart', (e) => handleTouchStart(e, canvas));
|
|
916
|
+
canvas.removeEventListener('touchmove', (e) => handleTouchMove(e, canvas));
|
|
917
|
+
canvas.removeEventListener('touchend', (e) => handleTouchEnd(e, canvas));
|
|
918
|
+
};
|
|
919
|
+
}
|
|
920
|
+
});
|
|
921
|
+
|
|
922
|
+
// Touch event handlers
|
|
923
|
+
function handleTouchStart(event: TouchEvent, canvas: HTMLCanvasElement) {
|
|
924
|
+
if (!sessionId || event.touches.length === 0) return;
|
|
925
|
+
event.preventDefault();
|
|
926
|
+
|
|
927
|
+
const coords = getCanvasCoordinates(event, canvas);
|
|
928
|
+
isMouseDown = true;
|
|
929
|
+
mouseDownTime = Date.now();
|
|
930
|
+
dragStartPos = { x: coords.x, y: coords.y };
|
|
931
|
+
dragCurrentPos = { x: coords.x, y: coords.y };
|
|
932
|
+
dragStarted = false; // Reset drag started flag
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
function handleTouchMove(event: TouchEvent, canvas: HTMLCanvasElement) {
|
|
936
|
+
if (!sessionId || event.touches.length === 0 || !isMouseDown || !dragStartPos) return;
|
|
937
|
+
event.preventDefault();
|
|
938
|
+
|
|
939
|
+
const coords = getCanvasCoordinates(event, canvas);
|
|
940
|
+
dragCurrentPos = { x: coords.x, y: coords.y };
|
|
941
|
+
|
|
942
|
+
const dragDistance = Math.sqrt(
|
|
943
|
+
Math.pow(coords.x - dragStartPos.x, 2) + Math.pow(coords.y - dragStartPos.y, 2)
|
|
944
|
+
);
|
|
945
|
+
|
|
946
|
+
if (dragDistance > 15) {
|
|
947
|
+
// Send mousedown on first drag detection
|
|
948
|
+
if (!dragStarted) {
|
|
949
|
+
sendInteraction({
|
|
950
|
+
type: 'mousedown',
|
|
951
|
+
x: dragStartPos.x,
|
|
952
|
+
y: dragStartPos.y,
|
|
953
|
+
button: 'left'
|
|
954
|
+
});
|
|
955
|
+
dragStarted = true;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
isDragging = true;
|
|
959
|
+
// Send mousemove to continue dragging (mouse is already down)
|
|
960
|
+
sendInteraction({
|
|
961
|
+
type: 'mousemove',
|
|
962
|
+
x: coords.x,
|
|
963
|
+
y: coords.y
|
|
964
|
+
});
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
function handleTouchEnd(event: TouchEvent, canvas: HTMLCanvasElement) {
|
|
969
|
+
if (!sessionId || !isMouseDown) return;
|
|
970
|
+
event.preventDefault();
|
|
971
|
+
|
|
972
|
+
if (dragStartPos) {
|
|
973
|
+
const endPos = dragCurrentPos || dragStartPos;
|
|
974
|
+
|
|
975
|
+
// If drag was started (mousedown was sent), send mouseup
|
|
976
|
+
if (dragStarted) {
|
|
977
|
+
sendInteraction({
|
|
978
|
+
type: 'mouseup',
|
|
979
|
+
x: endPos.x,
|
|
980
|
+
y: endPos.y,
|
|
981
|
+
button: 'left'
|
|
982
|
+
});
|
|
983
|
+
} else {
|
|
984
|
+
// No drag occurred, this is a tap/click
|
|
985
|
+
sendInteraction({ type: 'click', x: dragStartPos.x, y: dragStartPos.y });
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
isMouseDown = false;
|
|
990
|
+
isDragging = false;
|
|
991
|
+
dragStartPos = null;
|
|
992
|
+
dragCurrentPos = null;
|
|
993
|
+
dragStarted = false;
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
function getCanvasElement() {
|
|
997
|
+
return canvasElement;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
// Notify canvas that navigation has completed
|
|
1001
|
+
// This triggers fast reconnection if connection was lost during navigation
|
|
1002
|
+
async function notifyNavigationComplete() {
|
|
1003
|
+
debug.log('webcodecs', 'Navigation complete notification received');
|
|
1004
|
+
navigationJustCompleted = true;
|
|
1005
|
+
|
|
1006
|
+
// If connection was lost during navigation, trigger fast reconnection
|
|
1007
|
+
// Backend has already restarted streaming, just need to reconnect frontend
|
|
1008
|
+
if (webCodecsService && !webCodecsService.getConnectionStatus() && sessionId) {
|
|
1009
|
+
debug.log('webcodecs', 'Connection lost during navigation - triggering fast reconnection');
|
|
1010
|
+
|
|
1011
|
+
// Reset navigation state to allow normal error handling after reconnect
|
|
1012
|
+
webCodecsService.setNavigating(false);
|
|
1013
|
+
|
|
1014
|
+
// Small delay to ensure backend has restarted streaming
|
|
1015
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
1016
|
+
|
|
1017
|
+
// Restart streaming (this will reconnect to the new peer)
|
|
1018
|
+
lastStartRequestId = null; // Clear to allow new start request
|
|
1019
|
+
await startStreaming();
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
// Expose API methods to parent component
|
|
1024
|
+
$effect(() => {
|
|
1025
|
+
canvasAPI = {
|
|
1026
|
+
updateCanvasCursor,
|
|
1027
|
+
setupCanvas,
|
|
1028
|
+
getCanvasElement,
|
|
1029
|
+
// Streaming control
|
|
1030
|
+
startStreaming,
|
|
1031
|
+
stopStreaming,
|
|
1032
|
+
isActive: () => isWebCodecsActive,
|
|
1033
|
+
getStats: () => webCodecsService?.getStats() ?? null,
|
|
1034
|
+
getLatency: () => latencyMs,
|
|
1035
|
+
// Navigation handling
|
|
1036
|
+
notifyNavigationComplete
|
|
1037
|
+
};
|
|
1038
|
+
});
|
|
1039
|
+
|
|
1040
|
+
onDestroy(() => {
|
|
1041
|
+
stopHealthCheck(); // Stop health monitoring
|
|
1042
|
+
if (webCodecsService) {
|
|
1043
|
+
webCodecsService.destroy();
|
|
1044
|
+
webCodecsService = null;
|
|
1045
|
+
}
|
|
1046
|
+
activeStreamingSessionId = null;
|
|
1047
|
+
isStartingStream = false;
|
|
1048
|
+
lastStartRequestId = null;
|
|
1049
|
+
});
|
|
1050
|
+
</script>
|
|
1051
|
+
|
|
1052
|
+
<!-- Canvas - loading overlay is handled by parent PreviewContainer -->
|
|
1053
|
+
<canvas
|
|
1054
|
+
bind:this={canvasElement}
|
|
1055
|
+
class="w-full h-full object-contain"
|
|
1056
|
+
tabindex="0"
|
|
1057
|
+
style="cursor: default;"
|
|
1058
|
+
></canvas>
|