painapple-code 1.0.0__py3-none-any.whl
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.
- painapple_code/__init__.py +30 -0
- painapple_code/__main__.py +18 -0
- painapple_code/_version.py +24 -0
- painapple_code/auth_middleware.py +930 -0
- painapple_code/cli/__init__.py +204 -0
- painapple_code/cli/deploy/__init__.py +15 -0
- painapple_code/cli/deploy/claude_seed.py +41 -0
- painapple_code/cli/deploy/config.py +260 -0
- painapple_code/cli/deploy/container.py +715 -0
- painapple_code/cli/deploy/launch.py +163 -0
- painapple_code/cli/deploy/runtime.py +223 -0
- painapple_code/cli/lifecycle_cmd.py +443 -0
- painapple_code/cli/list_cmd.py +572 -0
- painapple_code/cli/manage_cmd.py +670 -0
- painapple_code/cli/netinfo.py +125 -0
- painapple_code/cli/profiles.py +390 -0
- painapple_code/cli/serve_args.py +163 -0
- painapple_code/cli/serve_config.py +256 -0
- painapple_code/cli/setup_cmd.py +664 -0
- painapple_code/cli/ui.py +438 -0
- painapple_code/cost_analytics.py +879 -0
- painapple_code/data/models.defaults.yaml +18 -0
- painapple_code/data/models.yaml +18 -0
- painapple_code/data/presets.defaults.json +32 -0
- painapple_code/data/strings.yaml +2269 -0
- painapple_code/helpers.py +406 -0
- painapple_code/paths.py +1514 -0
- painapple_code/prompt_explorer.py +984 -0
- painapple_code/providers/__init__.py +224 -0
- painapple_code/providers/base.py +710 -0
- painapple_code/providers/claude/__init__.py +74 -0
- painapple_code/providers/claude/capabilities.py +218 -0
- painapple_code/providers/claude/errors.py +79 -0
- painapple_code/providers/claude/launch.py +50 -0
- painapple_code/providers/claude/summary.py +89 -0
- painapple_code/providers/claude/translate.py +74 -0
- painapple_code/providers/claude_sdk/__init__.py +139 -0
- painapple_code/providers/claude_sdk/driver.py +412 -0
- painapple_code/providers/codex/__init__.py +87 -0
- painapple_code/providers/codex/capabilities.py +321 -0
- painapple_code/providers/codex/errors.py +70 -0
- painapple_code/providers/codex/launch.py +142 -0
- painapple_code/providers/codex/summary.py +196 -0
- painapple_code/providers/codex/translate.py +204 -0
- painapple_code/providers/codex_app_server/__init__.py +100 -0
- painapple_code/providers/codex_app_server/capabilities.py +41 -0
- painapple_code/providers/codex_app_server/launch.py +202 -0
- painapple_code/providers/codex_app_server/summary.py +291 -0
- painapple_code/providers/codex_app_server/translate.py +263 -0
- painapple_code/providers/codex_app_server/transport.py +238 -0
- painapple_code/routes/__init__.py +0 -0
- painapple_code/routes/api_agents.py +441 -0
- painapple_code/routes/api_app.py +368 -0
- painapple_code/routes/api_app_commit_sections.py +127 -0
- painapple_code/routes/api_app_config.py +997 -0
- painapple_code/routes/api_app_session_prefs.py +217 -0
- painapple_code/routes/api_browser.py +690 -0
- painapple_code/routes/api_commands.py +374 -0
- painapple_code/routes/api_costs.py +128 -0
- painapple_code/routes/api_drafts.py +139 -0
- painapple_code/routes/api_exec.py +149 -0
- painapple_code/routes/api_files.py +542 -0
- painapple_code/routes/api_git.py +657 -0
- painapple_code/routes/api_logs.py +649 -0
- painapple_code/routes/api_plugins.py +275 -0
- painapple_code/routes/api_project_config.py +418 -0
- painapple_code/routes/api_prompts.py +231 -0
- painapple_code/routes/api_providers.py +53 -0
- painapple_code/routes/api_search.py +411 -0
- painapple_code/routes/api_session_stash.py +234 -0
- painapple_code/routes/api_session_welcome.py +151 -0
- painapple_code/routes/api_sessions.py +742 -0
- painapple_code/routes/api_shadow.py +323 -0
- painapple_code/routes/api_shadow_db.py +512 -0
- painapple_code/routes/api_shadow_files.py +332 -0
- painapple_code/routes/api_shadow_search.py +476 -0
- painapple_code/routes/api_skills.py +610 -0
- painapple_code/routes/api_tasks.py +147 -0
- painapple_code/routes/api_terminal.py +540 -0
- painapple_code/routes/api_upload.py +293 -0
- painapple_code/routes/api_viewer.py +229 -0
- painapple_code/routes/dependencies.py +109 -0
- painapple_code/routes/ws_chat.py +837 -0
- painapple_code/server.py +2208 -0
- painapple_code/server_logging.py +263 -0
- painapple_code/services/__init__.py +0 -0
- painapple_code/services/agent_session.py +2738 -0
- painapple_code/session_store.py +253 -0
- painapple_code/session_store_core.py +879 -0
- painapple_code/shadow_db.py +830 -0
- painapple_code/shadow_db_plans.py +369 -0
- painapple_code/shadow_db_queries.py +595 -0
- painapple_code/shadow_db_schema.py +676 -0
- painapple_code/shadow_git.py +1296 -0
- painapple_code/shadow_git_frontmatter.py +314 -0
- painapple_code/shadow_git_sections.py +520 -0
- painapple_code/shadow_git_summary.py +462 -0
- painapple_code/shadow_parser.py +560 -0
- painapple_code/static/css/00-variables.css +276 -0
- painapple_code/static/css/01-base.css +245 -0
- painapple_code/static/css/02-tooltips.css +36 -0
- painapple_code/static/css/09-left-rail.css +448 -0
- painapple_code/static/css/10-header.css +777 -0
- painapple_code/static/css/11-connection-bar.css +120 -0
- painapple_code/static/css/20-messages.css +750 -0
- painapple_code/static/css/21-tool-blocks.css +2340 -0
- painapple_code/static/css/22-edit-diff.css +619 -0
- painapple_code/static/css/23-thinking.css +1584 -0
- painapple_code/static/css/24-background-tasks.css +359 -0
- painapple_code/static/css/25-context-block.css +252 -0
- painapple_code/static/css/26-session-setup.css +144 -0
- painapple_code/static/css/30-markdown.css +854 -0
- painapple_code/static/css/40-input.css +2418 -0
- painapple_code/static/css/41-autocomplete.css +127 -0
- painapple_code/static/css/42-activity-strip.css +97 -0
- painapple_code/static/css/43-file-autocomplete.css +121 -0
- painapple_code/static/css/44-snippets-autocomplete.css +224 -0
- painapple_code/static/css/45-selection-bar.css +433 -0
- painapple_code/static/css/46-keyboard-bar.css +119 -0
- painapple_code/static/css/47-keyboard-longpress.css +116 -0
- painapple_code/static/css/47-token-profile.css +106 -0
- painapple_code/static/css/50-welcome.css +3095 -0
- painapple_code/static/css/51-modals.css +301 -0
- painapple_code/static/css/52-utilities.css +77 -0
- painapple_code/static/css/53-image-upload.css +456 -0
- painapple_code/static/css/54-todo-list.css +102 -0
- painapple_code/static/css/55-file-upload.css +113 -0
- painapple_code/static/css/56-chat-navigator.css +218 -0
- painapple_code/static/css/56-permission-card.css +332 -0
- painapple_code/static/css/56-plan-approval.css +204 -0
- painapple_code/static/css/56-question-form.css +602 -0
- painapple_code/static/css/57-swipe-indicator.css +95 -0
- painapple_code/static/css/58-session-families.css +627 -0
- painapple_code/static/css/59-about.css +196 -0
- painapple_code/static/css/61-log-explorer.css +723 -0
- painapple_code/static/css/62-json-tree.css +489 -0
- painapple_code/static/css/62-skills-widget.css +650 -0
- painapple_code/static/css/63-commands-widget.css +30 -0
- painapple_code/static/css/63-plugins-widget.css +174 -0
- painapple_code/static/css/63-terminal.css +516 -0
- painapple_code/static/css/64-search-files-widget.css +448 -0
- painapple_code/static/css/65-snippets-widget.css +426 -0
- painapple_code/static/css/66-active-sessions.css +979 -0
- painapple_code/static/css/66-agents-widget.css +265 -0
- painapple_code/static/css/66-file-explorer-widget.css +855 -0
- painapple_code/static/css/66-git-panel.css +791 -0
- painapple_code/static/css/66-history-explorer.css +1496 -0
- painapple_code/static/css/67-diff-viewer.css +602 -0
- painapple_code/static/css/67-editor-view.css +748 -0
- painapple_code/static/css/67-file-preview-widget.css +1873 -0
- painapple_code/static/css/68-browser-widget.css +337 -0
- painapple_code/static/css/68-compare-wizard.css +355 -0
- painapple_code/static/css/68-csv-preview.css +106 -0
- painapple_code/static/css/68-diff-preview.css +103 -0
- painapple_code/static/css/68-helpers-install-widget.css +491 -0
- painapple_code/static/css/68-image-annotate.css +346 -0
- painapple_code/static/css/68-terminal-view.css +98 -0
- painapple_code/static/css/68-uploads-widget.css +229 -0
- painapple_code/static/css/69-cost-analytics.css +537 -0
- painapple_code/static/css/70-config-panel.css +3736 -0
- painapple_code/static/css/70-excalidraw.css +136 -0
- painapple_code/static/css/70-prompt-explorer.css +890 -0
- painapple_code/static/css/71-chart.css +111 -0
- painapple_code/static/css/72-discussion-sidebar.css +678 -0
- painapple_code/static/css/74-debug-logs.css +656 -0
- painapple_code/static/css/80-open-dialog.css +146 -0
- painapple_code/static/css/80-quick-switcher.css +383 -0
- painapple_code/static/css/80-widget-system.css +1763 -0
- painapple_code/static/css/81-quick-actions.css +1020 -0
- painapple_code/static/css/82-context-popover.css +1233 -0
- painapple_code/static/css/83-context-menu.css +339 -0
- painapple_code/static/css/85-lazy-loading.css +102 -0
- painapple_code/static/css/86-zen-mode.css +708 -0
- painapple_code/static/css/87-grid-switcher.css +346 -0
- painapple_code/static/css/login.css +198 -0
- painapple_code/static/dist/app.bundle.js +7177 -0
- painapple_code/static/feature-triage.html +446 -0
- painapple_code/static/icons/apple-touch-icon.png +0 -0
- painapple_code/static/icons/favicon-32.png +0 -0
- painapple_code/static/icons/favicon.ico +0 -0
- painapple_code/static/icons/icon-128.png +0 -0
- painapple_code/static/icons/icon-144.png +0 -0
- painapple_code/static/icons/icon-152.png +0 -0
- painapple_code/static/icons/icon-180.png +0 -0
- painapple_code/static/icons/icon-192.png +0 -0
- painapple_code/static/icons/icon-384.png +0 -0
- painapple_code/static/icons/icon-512.png +0 -0
- painapple_code/static/icons/icon-72.png +0 -0
- painapple_code/static/icons/icon-96.png +0 -0
- painapple_code/static/js/action-delegate.js +187 -0
- painapple_code/static/js/activity-strip.js +193 -0
- painapple_code/static/js/app/context-menus.js +619 -0
- painapple_code/static/js/app/file-preview.js +163 -0
- painapple_code/static/js/app/init-ui.js +574 -0
- painapple_code/static/js/app/input-events.js +561 -0
- painapple_code/static/js/app/input-resize.js +313 -0
- painapple_code/static/js/app/message-actions.js +419 -0
- painapple_code/static/js/app/panels.js +210 -0
- painapple_code/static/js/app/rendering-delegators.js +246 -0
- painapple_code/static/js/app/server-loading.js +365 -0
- painapple_code/static/js/app/session-ops.js +287 -0
- painapple_code/static/js/app/session-switch.js +443 -0
- painapple_code/static/js/app/settings-helpers.js +280 -0
- painapple_code/static/js/app/startup-welcome.js +290 -0
- painapple_code/static/js/app-context.js +157 -0
- painapple_code/static/js/app.js +2134 -0
- painapple_code/static/js/auth-fetch.js +46 -0
- painapple_code/static/js/background-tasks.js +274 -0
- painapple_code/static/js/boot.js +73 -0
- painapple_code/static/js/caret-position.js +171 -0
- painapple_code/static/js/chat-navigator.js +710 -0
- painapple_code/static/js/chat-search.js +323 -0
- painapple_code/static/js/command-executor.js +176 -0
- painapple_code/static/js/command-store.js +497 -0
- painapple_code/static/js/components.js +1414 -0
- painapple_code/static/js/config.js +218 -0
- painapple_code/static/js/context-menu.js +740 -0
- painapple_code/static/js/controllers/chat-controller.js +4252 -0
- painapple_code/static/js/controllers/dialog-controller.js +217 -0
- painapple_code/static/js/controllers/tab-controller.js +1828 -0
- painapple_code/static/js/controllers/thinking-controller.js +915 -0
- painapple_code/static/js/diff-utils.js +486 -0
- painapple_code/static/js/drafts-pill.js +95 -0
- painapple_code/static/js/editor-view.js +815 -0
- painapple_code/static/js/effort-settings.js +430 -0
- painapple_code/static/js/feature-triage.js +811 -0
- painapple_code/static/js/file-autocomplete.js +516 -0
- painapple_code/static/js/file-tabs.js +123 -0
- painapple_code/static/js/gestures.js +448 -0
- painapple_code/static/js/grid-switcher.js +334 -0
- painapple_code/static/js/input-handler.js +1472 -0
- painapple_code/static/js/keyboard-bar.js +495 -0
- painapple_code/static/js/layout-switcher.js +109 -0
- painapple_code/static/js/linkify-utils.js +200 -0
- painapple_code/static/js/login.js +141 -0
- painapple_code/static/js/open-dialog.js +879 -0
- painapple_code/static/js/orphan-terminals.js +373 -0
- painapple_code/static/js/path-utils.js +179 -0
- painapple_code/static/js/perf-marks.js +102 -0
- painapple_code/static/js/permission-settings.js +475 -0
- painapple_code/static/js/pointer-input.js +59 -0
- painapple_code/static/js/preview/json-tree-menu.js +226 -0
- painapple_code/static/js/preview/json-tree-toolbar.js +291 -0
- painapple_code/static/js/preview/json-tree.js +457 -0
- painapple_code/static/js/preview/preview-breadcrumb.js +387 -0
- painapple_code/static/js/preview/preview-edit.js +367 -0
- painapple_code/static/js/preview/preview-events.js +361 -0
- painapple_code/static/js/preview/preview-history.js +567 -0
- painapple_code/static/js/preview/preview-inline-edit.js +869 -0
- painapple_code/static/js/preview/preview-poll.js +108 -0
- painapple_code/static/js/preview/preview-render.js +572 -0
- painapple_code/static/js/preview/preview-search.js +421 -0
- painapple_code/static/js/preview/preview-state.js +256 -0
- painapple_code/static/js/preview/preview-utils.js +251 -0
- painapple_code/static/js/preview-plugins/chart-plugin.js +22 -0
- painapple_code/static/js/preview-plugins/csv-plugin.js +188 -0
- painapple_code/static/js/preview-plugins/diff-plugin.js +258 -0
- painapple_code/static/js/preview-plugins/excalidraw-plugin.js +25 -0
- painapple_code/static/js/preview-plugins/html-plugin.js +104 -0
- painapple_code/static/js/preview-plugins/image-plugin.js +20 -0
- painapple_code/static/js/preview-plugins/index.js +34 -0
- painapple_code/static/js/preview-plugins/json-plugin.js +99 -0
- painapple_code/static/js/preview-plugins/jsonl-plugin.js +152 -0
- painapple_code/static/js/preview-plugins/markdown-plugin.js +266 -0
- painapple_code/static/js/preview-plugins/panzoom-plugin.js +63 -0
- painapple_code/static/js/preview-plugins/plugin-helpers.js +282 -0
- painapple_code/static/js/project-color-picker.js +110 -0
- painapple_code/static/js/project-colors.js +163 -0
- painapple_code/static/js/prompt-favorites.js +187 -0
- painapple_code/static/js/quick-actions-menu.js +1736 -0
- painapple_code/static/js/quick-actions-registry.js +1239 -0
- painapple_code/static/js/quick-switcher/controller.js +294 -0
- painapple_code/static/js/quick-switcher/fuzzy-scorer.js +123 -0
- painapple_code/static/js/quick-switcher/index.js +30 -0
- painapple_code/static/js/quick-switcher/providers/base-provider.js +51 -0
- painapple_code/static/js/quick-switcher/providers/command-provider.js +56 -0
- painapple_code/static/js/quick-switcher/providers/file-provider.js +291 -0
- painapple_code/static/js/quick-switcher/providers/panel-provider.js +132 -0
- painapple_code/static/js/quick-switcher/providers/project-provider.js +361 -0
- painapple_code/static/js/quick-switcher/providers/read-files-provider.js +104 -0
- painapple_code/static/js/quick-switcher/providers/skills-provider.js +129 -0
- painapple_code/static/js/quick-switcher/registry.js +69 -0
- painapple_code/static/js/quick-switcher/ui/item.js +54 -0
- painapple_code/static/js/quick-switcher/ui/picker.js +464 -0
- painapple_code/static/js/recent-opens.js +40 -0
- painapple_code/static/js/scroll-manager.js +530 -0
- painapple_code/static/js/scroll-state-machine.js +359 -0
- painapple_code/static/js/selection/action-bar.js +793 -0
- painapple_code/static/js/selection/index.js +15 -0
- painapple_code/static/js/selection/selection-handler.js +877 -0
- painapple_code/static/js/selection/state.js +181 -0
- painapple_code/static/js/session/agent-progress.js +202 -0
- painapple_code/static/js/session/handle-agent-message.js +481 -0
- painapple_code/static/js/session/handle-message.js +550 -0
- painapple_code/static/js/session/interactive.js +587 -0
- painapple_code/static/js/session/message-store.js +271 -0
- painapple_code/static/js/session/messages.js +253 -0
- painapple_code/static/js/session/persistence.js +179 -0
- painapple_code/static/js/session/restore.js +136 -0
- painapple_code/static/js/session/sync.js +333 -0
- painapple_code/static/js/session-container-pool.js +371 -0
- painapple_code/static/js/session-setup-panel.js +274 -0
- painapple_code/static/js/session.js +1047 -0
- painapple_code/static/js/shortcut-hints.js +272 -0
- painapple_code/static/js/shortcuts.js +1146 -0
- painapple_code/static/js/skills-autocomplete.js +262 -0
- painapple_code/static/js/snippets-autocomplete.js +738 -0
- painapple_code/static/js/stash-refs-view.js +83 -0
- painapple_code/static/js/stash-ui.js +768 -0
- painapple_code/static/js/stash.js +785 -0
- painapple_code/static/js/status-bar.js +1184 -0
- painapple_code/static/js/token-profile.js +274 -0
- painapple_code/static/js/tool-renderer-blocks.js +1626 -0
- painapple_code/static/js/tool-renderer-thinking.js +813 -0
- painapple_code/static/js/tool-renderer.js +1500 -0
- painapple_code/static/js/tooltips.js +197 -0
- painapple_code/static/js/upload-manager.js +1019 -0
- painapple_code/static/js/utils.js +521 -0
- painapple_code/static/js/welcome/api.js +363 -0
- painapple_code/static/js/welcome/cards.js +626 -0
- painapple_code/static/js/welcome/context-menu.js +585 -0
- painapple_code/static/js/welcome/families.js +801 -0
- painapple_code/static/js/welcome/preview.js +234 -0
- painapple_code/static/js/welcome/state.js +129 -0
- painapple_code/static/js/welcome.js +1874 -0
- painapple_code/static/js/widget-system/base-widget.js +717 -0
- painapple_code/static/js/widget-system/device-manager.js +178 -0
- painapple_code/static/js/widget-system/event-bus.js +152 -0
- painapple_code/static/js/widget-system/icons.js +120 -0
- painapple_code/static/js/widget-system/index.js +49 -0
- painapple_code/static/js/widget-system/init.js +54 -0
- painapple_code/static/js/widget-system/types/bottom-sheet.js +307 -0
- painapple_code/static/js/widget-system/types/floating.js +915 -0
- painapple_code/static/js/widget-system/types/index.js +40 -0
- painapple_code/static/js/widget-system/types/modal.js +147 -0
- painapple_code/static/js/widget-system/types/sidebar.js +158 -0
- painapple_code/static/js/widget-system/types/tab.js +146 -0
- painapple_code/static/js/widget-system/types/top-sheet.js +203 -0
- painapple_code/static/js/widget-system/widget-manager.js +1187 -0
- painapple_code/static/js/widgets/about-widget.js +260 -0
- painapple_code/static/js/widgets/active-sessions-widget.js +760 -0
- painapple_code/static/js/widgets/agents-widget.js +897 -0
- painapple_code/static/js/widgets/browser-widget.js +579 -0
- painapple_code/static/js/widgets/commands-widget.js +694 -0
- painapple_code/static/js/widgets/config/commit-sections.js +449 -0
- painapple_code/static/js/widgets/config/dir-autocomplete.js +224 -0
- painapple_code/static/js/widgets/config/gestures.js +161 -0
- painapple_code/static/js/widgets/config/models-tab.js +988 -0
- painapple_code/static/js/widgets/config/quick-actions-tab.js +882 -0
- painapple_code/static/js/widgets/config/shortcut-editor.js +587 -0
- painapple_code/static/js/widgets/config/state.js +517 -0
- painapple_code/static/js/widgets/config/system-controls.js +90 -0
- painapple_code/static/js/widgets/config-widget.js +1535 -0
- painapple_code/static/js/widgets/cost-analytics-widget.js +621 -0
- painapple_code/static/js/widgets/debug-widget.js +693 -0
- painapple_code/static/js/widgets/diff-viewer-widget.js +1420 -0
- painapple_code/static/js/widgets/discussion-widget.js +1107 -0
- painapple_code/static/js/widgets/file-explorer-widget.js +2166 -0
- painapple_code/static/js/widgets/file-preview-widget.js +861 -0
- painapple_code/static/js/widgets/git-widget.js +794 -0
- painapple_code/static/js/widgets/helpers-install-widget.js +720 -0
- painapple_code/static/js/widgets/history-explorer-widget.js +1804 -0
- painapple_code/static/js/widgets/image-annotate-widget.js +951 -0
- painapple_code/static/js/widgets/image-preview-widget.js +692 -0
- painapple_code/static/js/widgets/index.js +176 -0
- painapple_code/static/js/widgets/log-explorer-widget.js +1057 -0
- painapple_code/static/js/widgets/plugins-widget.js +305 -0
- painapple_code/static/js/widgets/prompt-explorer-widget.js +1119 -0
- painapple_code/static/js/widgets/search-files-widget.js +529 -0
- painapple_code/static/js/widgets/skills-widget.js +843 -0
- painapple_code/static/js/widgets/snippets-widget.js +479 -0
- painapple_code/static/js/widgets/sub-agents-widget.js +333 -0
- painapple_code/static/js/widgets/tasks-widget.js +379 -0
- painapple_code/static/js/widgets/terminal/connection.js +283 -0
- painapple_code/static/js/widgets/terminal/gestures.js +357 -0
- painapple_code/static/js/widgets/terminal/init.js +642 -0
- painapple_code/static/js/widgets/terminal/link-providers.js +532 -0
- painapple_code/static/js/widgets/terminal/osc52.js +213 -0
- painapple_code/static/js/widgets/terminal/render.js +159 -0
- painapple_code/static/js/widgets/terminal/size.js +54 -0
- painapple_code/static/js/widgets/terminal/state.js +211 -0
- painapple_code/static/js/widgets/terminal/wrap-utils.js +101 -0
- painapple_code/static/js/widgets/terminal-widget.js +793 -0
- painapple_code/static/js/widgets/uploads-widget.js +461 -0
- painapple_code/static/js/widgets/zen-widget.js +1001 -0
- painapple_code/static/login.html +51 -0
- painapple_code/static/manifest.json +73 -0
- painapple_code/static/sw.js +293 -0
- painapple_code/static/vendor/LICENSES.txt +769 -0
- painapple_code/static/vendor/README.md +103 -0
- painapple_code/static/vendor/codemirror.js +35 -0
- painapple_code/static/vendor/codemirror.js.LEGAL.txt +304 -0
- painapple_code/static/vendor/github-markdown-dark.min.css +1124 -0
- painapple_code/static/vendor/highlight-github-dark.min.css +10 -0
- painapple_code/static/vendor/highlight-lang-dockerfile.min.js +8 -0
- painapple_code/static/vendor/highlight-lang-nginx.min.js +21 -0
- painapple_code/static/vendor/highlight-lang-properties.min.js +10 -0
- painapple_code/static/vendor/highlight-lang-scala.min.js +28 -0
- painapple_code/static/vendor/highlight.min.js +1213 -0
- painapple_code/static/vendor/marked.min.js +6 -0
- painapple_code/static/vendor/purify.min.js +3 -0
- painapple_code/static/vendor/xterm-addon-fit.js +2 -0
- painapple_code/static/vendor/xterm.css +209 -0
- painapple_code/static/vendor/xterm.js +2 -0
- painapple_code/static/web-client.html +635 -0
- painapple_code/subprocess_registry.py +535 -0
- painapple_code/tls_cert.py +79 -0
- painapple_code/tools/agents/shadow-git-helper.md +400 -0
- painapple_code/tools/excalidraw-to-svg.js +143 -0
- painapple_code/tools/install-helpers.sh +148 -0
- painapple_code/tools/shadow-git +320 -0
- painapple_code/tools/shadow-query +120 -0
- painapple_code/tools/vegalite-to-svg.js +79 -0
- painapple_code/turn_query.py +766 -0
- painapple_code/turn_tracker.py +221 -0
- painapple_code/utils/__init__.py +0 -0
- painapple_code/utils/agent_cli.py +306 -0
- painapple_code/utils/chart.py +103 -0
- painapple_code/utils/excalidraw.py +107 -0
- painapple_code/utils/file_lock.py +162 -0
- painapple_code/utils/file_paths.py +648 -0
- painapple_code/utils/generate_icons.py +251 -0
- painapple_code/utils/proc.py +205 -0
- painapple_code/utils/pty_backend.py +59 -0
- painapple_code/utils/pty_posix.py +160 -0
- painapple_code/utils/pty_windows.py +245 -0
- painapple_code/utils/token_profiles.py +91 -0
- painapple_code/viewer_templates.py +657 -0
- painapple_code/welcome_search.py +879 -0
- painapple_code-1.0.0.dist-info/METADATA +292 -0
- painapple_code-1.0.0.dist-info/RECORD +436 -0
- painapple_code-1.0.0.dist-info/WHEEL +5 -0
- painapple_code-1.0.0.dist-info/entry_points.txt +2 -0
- painapple_code-1.0.0.dist-info/licenses/LICENSE +661 -0
- painapple_code-1.0.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +261 -0
- painapple_code-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1019 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Upload Manager Module
|
|
3
|
+
* Handles file and image uploads with preview, drag-drop, and paste support
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { CONFIG } from './config.js';
|
|
7
|
+
import { genId, escapeHtml, $ } from './utils.js';
|
|
8
|
+
import { ImagePreviewWidget, openImageAnnotator, isImageAnnotatorOpen, isAnnotateOnPasteEnabled } from './widgets/index.js';
|
|
9
|
+
import { ContextMenu, copyToClipboard, showToast } from './context-menu.js';
|
|
10
|
+
import { WidgetBus } from './widget-system/event-bus.js';
|
|
11
|
+
import S from './strings.js';
|
|
12
|
+
|
|
13
|
+
// Media types Claude accepts verbatim as a base64 image block. Anything else
|
|
14
|
+
// (svg, bmp, …) has to go back through /api/upload-image, which re-encodes it.
|
|
15
|
+
const CLAUDE_IMAGE_TYPES = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp']);
|
|
16
|
+
|
|
17
|
+
const EXT_MIME = {
|
|
18
|
+
jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif',
|
|
19
|
+
webp: 'image/webp', bmp: 'image/bmp', svg: 'image/svg+xml',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** Best-effort image mime from a filename — File.type drives handleImages(). */
|
|
23
|
+
function guessImageMime(name) {
|
|
24
|
+
const ext = (name || '').split('.').pop().toLowerCase();
|
|
25
|
+
return EXT_MIME[ext] || 'image/png';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* UploadManager - Handle file and image uploads
|
|
30
|
+
*/
|
|
31
|
+
export class UploadManager {
|
|
32
|
+
/**
|
|
33
|
+
* @param {Object} elements - DOM elements
|
|
34
|
+
* @param {HTMLElement} elements.inputContainer - Container for input area
|
|
35
|
+
* @param {HTMLTextAreaElement} elements.messageInput - The message input
|
|
36
|
+
* @param {HTMLElement} elements.messagesContainer - For drag-drop zone
|
|
37
|
+
* @param {HTMLButtonElement} elements.sendBtn - Send button (to update state)
|
|
38
|
+
* @param {Object} options - Configuration
|
|
39
|
+
* @param {string} options.apiBase - API base URL
|
|
40
|
+
* @param {Object} callbacks - Callbacks
|
|
41
|
+
* @param {Function} callbacks.getSessionId - Returns current session storeId
|
|
42
|
+
* @param {Function} callbacks.isConnected - Returns true if session connected
|
|
43
|
+
* @param {Function} callbacks.onError - Called on upload error
|
|
44
|
+
* @param {Function} callbacks.onStateChange - Called when uploads change
|
|
45
|
+
*/
|
|
46
|
+
constructor(elements, options = {}, callbacks = {}) {
|
|
47
|
+
this.els = elements;
|
|
48
|
+
this.apiBase = options.apiBase || CONFIG.API_BASE;
|
|
49
|
+
|
|
50
|
+
// Callbacks
|
|
51
|
+
this.getSessionId = callbacks.getSessionId || (() => null);
|
|
52
|
+
this.isConnected = callbacks.isConnected || (() => false);
|
|
53
|
+
this.onError = callbacks.onError || ((msg) => console.error(msg));
|
|
54
|
+
this.onStateChange = callbacks.onStateChange || (() => {});
|
|
55
|
+
|
|
56
|
+
// State
|
|
57
|
+
this.pendingImages = [];
|
|
58
|
+
this.uploadingImages = [];
|
|
59
|
+
this.pendingFiles = [];
|
|
60
|
+
this.uploadingFiles = [];
|
|
61
|
+
|
|
62
|
+
// Session ids of upload batches (a multi-file paste/drop) still being
|
|
63
|
+
// walked. handleImages()/_uploadFiles() upload sequentially, so between
|
|
64
|
+
// one file finishing and the next being pushed, uploading* is
|
|
65
|
+
// momentarily empty — without this, isUploading() would blink false
|
|
66
|
+
// mid-batch and a send parked on the batch would fire with only the
|
|
67
|
+
// first file.
|
|
68
|
+
//
|
|
69
|
+
// Keyed by session because this manager is a singleton shared by every
|
|
70
|
+
// tab: a batch started in session A keeps running after a switch to B,
|
|
71
|
+
// and counting it as "B is uploading" would park B's send behind an
|
|
72
|
+
// upload that will never belong to it.
|
|
73
|
+
this._activeBatchSessions = [];
|
|
74
|
+
|
|
75
|
+
// Context menu for file chips
|
|
76
|
+
this._contextMenu = new ContextMenu();
|
|
77
|
+
this._longPressTimer = null;
|
|
78
|
+
|
|
79
|
+
// Create hidden file inputs
|
|
80
|
+
this._createInputs();
|
|
81
|
+
|
|
82
|
+
// Bind event listeners
|
|
83
|
+
this._bindEvents();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Show image in preview modal (uses widget system)
|
|
88
|
+
*/
|
|
89
|
+
showImagePreview(src) {
|
|
90
|
+
ImagePreviewWidget.show(src);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Create hidden file inputs
|
|
95
|
+
*/
|
|
96
|
+
_createInputs() {
|
|
97
|
+
// Image input
|
|
98
|
+
this.imageInput = document.createElement('input');
|
|
99
|
+
this.imageInput.type = 'file';
|
|
100
|
+
this.imageInput.accept = 'image/png,image/jpeg,image/gif,image/webp';
|
|
101
|
+
this.imageInput.multiple = true;
|
|
102
|
+
this.imageInput.style.display = 'none';
|
|
103
|
+
document.body.appendChild(this.imageInput);
|
|
104
|
+
|
|
105
|
+
this.imageInput.addEventListener('change', (e) => {
|
|
106
|
+
this.handleImages(e.target.files);
|
|
107
|
+
this.imageInput.value = '';
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// General file input
|
|
111
|
+
this.fileInput = document.createElement('input');
|
|
112
|
+
this.fileInput.type = 'file';
|
|
113
|
+
this.fileInput.multiple = true;
|
|
114
|
+
this.fileInput.style.display = 'none';
|
|
115
|
+
document.body.appendChild(this.fileInput);
|
|
116
|
+
|
|
117
|
+
this.fileInput.addEventListener('change', (e) => {
|
|
118
|
+
this.handleFiles(e.target.files);
|
|
119
|
+
this.fileInput.value = '';
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Bind paste and drag-drop events
|
|
125
|
+
*/
|
|
126
|
+
_bindEvents() {
|
|
127
|
+
// Track Shift on the paste chord — ClipboardEvent carries no modifier
|
|
128
|
+
// state, so remember it from the keydown that triggers the paste.
|
|
129
|
+
// Shift inverts the annotate-on-paste setting: with auto-annotate on,
|
|
130
|
+
// Cmd/Ctrl+Shift+V uploads plain; with it off (default), Shift+V is
|
|
131
|
+
// the way to open the annotation editor from a paste.
|
|
132
|
+
this.els.messageInput.addEventListener('keydown', (e) => {
|
|
133
|
+
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'v') {
|
|
134
|
+
this._pasteShift = e.shiftKey;
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// Paste on message input
|
|
139
|
+
this.els.messageInput.addEventListener('paste', (e) => {
|
|
140
|
+
const items = e.clipboardData?.items;
|
|
141
|
+
if (!items) return;
|
|
142
|
+
|
|
143
|
+
const shift = this._pasteShift === true;
|
|
144
|
+
this._pasteShift = false;
|
|
145
|
+
|
|
146
|
+
for (const item of items) {
|
|
147
|
+
if (item.type.startsWith('image/')) {
|
|
148
|
+
e.preventDefault();
|
|
149
|
+
const file = item.getAsFile();
|
|
150
|
+
if (file) {
|
|
151
|
+
const annotate = isAnnotateOnPasteEnabled() !== shift;
|
|
152
|
+
// GIFs would be flattened by the canvas export; the
|
|
153
|
+
// editor is also skipped while one is already open.
|
|
154
|
+
if (annotate && file.type !== 'image/gif' && !isImageAnnotatorOpen()) {
|
|
155
|
+
this._openAnnotator(file);
|
|
156
|
+
} else {
|
|
157
|
+
this.handleImages([file]);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// Drag and drop on messages area
|
|
166
|
+
this.els.messagesContainer.addEventListener('dragover', (e) => {
|
|
167
|
+
e.preventDefault();
|
|
168
|
+
this.els.messagesContainer.classList.add('drag-over');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
this.els.messagesContainer.addEventListener('dragleave', (e) => {
|
|
172
|
+
e.preventDefault();
|
|
173
|
+
this.els.messagesContainer.classList.remove('drag-over');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
this.els.messagesContainer.addEventListener('drop', (e) => {
|
|
177
|
+
e.preventDefault();
|
|
178
|
+
this.els.messagesContainer.classList.remove('drag-over');
|
|
179
|
+
this.handleFiles(e.dataTransfer.files);
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Handle file uploads - routes images to image handler, others to file upload
|
|
185
|
+
*/
|
|
186
|
+
handleFiles(files) {
|
|
187
|
+
const fileArray = Array.from(files);
|
|
188
|
+
const imageFiles = fileArray.filter(f => f.type.startsWith('image/'));
|
|
189
|
+
const otherFiles = fileArray.filter(f => !f.type.startsWith('image/'));
|
|
190
|
+
|
|
191
|
+
if (imageFiles.length > 0) {
|
|
192
|
+
this.handleImages(imageFiles);
|
|
193
|
+
}
|
|
194
|
+
if (otherFiles.length > 0) {
|
|
195
|
+
this._uploadFiles(otherFiles);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Handle image uploads
|
|
201
|
+
*/
|
|
202
|
+
async handleImages(files) {
|
|
203
|
+
const imageFiles = Array.from(files).filter(f => f.type.startsWith('image/'));
|
|
204
|
+
if (!imageFiles.length) return;
|
|
205
|
+
|
|
206
|
+
const batch = this._beginBatch();
|
|
207
|
+
try {
|
|
208
|
+
await this._handleImagesBatch(imageFiles);
|
|
209
|
+
} finally {
|
|
210
|
+
this._endBatch(batch);
|
|
211
|
+
// Batch fully settled — let listeners (e.g. a send parked on this
|
|
212
|
+
// batch) act now that isUploading() is honestly false.
|
|
213
|
+
this._notifyChange();
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async _handleImagesBatch(imageFiles) {
|
|
218
|
+
for (const file of imageFiles) {
|
|
219
|
+
const preview = URL.createObjectURL(file);
|
|
220
|
+
const uploadId = genId();
|
|
221
|
+
|
|
222
|
+
// Show loading state
|
|
223
|
+
this.uploadingImages.push({ id: uploadId, preview });
|
|
224
|
+
this._renderImagePreviews();
|
|
225
|
+
|
|
226
|
+
try {
|
|
227
|
+
const formData = new FormData();
|
|
228
|
+
formData.append('file', file);
|
|
229
|
+
|
|
230
|
+
const sessionId = this.getSessionId();
|
|
231
|
+
let uploadUrl = `${this.apiBase}/api/upload-image`;
|
|
232
|
+
if (sessionId) uploadUrl += `?session=${encodeURIComponent(sessionId)}`;
|
|
233
|
+
|
|
234
|
+
const response = await fetch(uploadUrl, {
|
|
235
|
+
method: 'POST',
|
|
236
|
+
body: formData
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
this.uploadingImages = this.uploadingImages.filter(img => img.id !== uploadId);
|
|
240
|
+
|
|
241
|
+
if (!response.ok) {
|
|
242
|
+
const error = await response.json();
|
|
243
|
+
this.onError(`Upload failed: ${error.detail}`);
|
|
244
|
+
this._renderImagePreviews();
|
|
245
|
+
this._notifyChange();
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const data = await response.json();
|
|
250
|
+
|
|
251
|
+
// The user switched tabs while this was in flight. pendingImages
|
|
252
|
+
// now belongs to a different session (switchSession saved the
|
|
253
|
+
// outgoing arrays off before we resolved), so pushing here would
|
|
254
|
+
// silently attach this image to someone else's next message.
|
|
255
|
+
if (this._isForeignSession(sessionId)) {
|
|
256
|
+
URL.revokeObjectURL(preview);
|
|
257
|
+
this._renderImagePreviews();
|
|
258
|
+
this._notifyChange();
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
this.pendingImages.push({
|
|
263
|
+
file,
|
|
264
|
+
preview,
|
|
265
|
+
imageData: data.image,
|
|
266
|
+
storedName: data.stored_name || null,
|
|
267
|
+
mediaType: data.media_type || null,
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
this._renderImagePreviews();
|
|
271
|
+
this._notifyChange();
|
|
272
|
+
} catch (error) {
|
|
273
|
+
this.uploadingImages = this.uploadingImages.filter(img => img.id !== uploadId);
|
|
274
|
+
this.onError(`Upload error: ${error.message}`);
|
|
275
|
+
this._renderImagePreviews();
|
|
276
|
+
this._notifyChange();
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Open the annotation editor for a pasted image. Done → upload the
|
|
283
|
+
* annotated PNG; skip → upload the original; cancel → drop the paste.
|
|
284
|
+
*/
|
|
285
|
+
_openAnnotator(file) {
|
|
286
|
+
openImageAnnotator(file, {
|
|
287
|
+
onDone: (blob, markers) => {
|
|
288
|
+
const annotated = this._annotatedFile(blob, file.name);
|
|
289
|
+
this.handleImages([annotated]);
|
|
290
|
+
this._stashMarkers(annotated.name, markers);
|
|
291
|
+
},
|
|
292
|
+
onSkip: () => this.handleImages([file]),
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Re-open a pending image in the annotation editor; on done the
|
|
298
|
+
* annotated PNG replaces the original pending upload.
|
|
299
|
+
*/
|
|
300
|
+
async editImage(index) {
|
|
301
|
+
const img = this.pendingImages[index];
|
|
302
|
+
if (!img) return;
|
|
303
|
+
let blob = img.file;
|
|
304
|
+
if (!blob) {
|
|
305
|
+
// Restored from server — the preview blob URL is the only copy
|
|
306
|
+
try { blob = await fetch(img.preview).then(r => r.blob()); }
|
|
307
|
+
catch { return; }
|
|
308
|
+
}
|
|
309
|
+
openImageAnnotator(blob, {
|
|
310
|
+
onDone: (out, markers) => {
|
|
311
|
+
const name = img.file?.name || img.storedName;
|
|
312
|
+
this.removeImage(index);
|
|
313
|
+
const annotated = this._annotatedFile(out, name);
|
|
314
|
+
this.handleImages([annotated]);
|
|
315
|
+
this._stashMarkers(annotated.name, markers);
|
|
316
|
+
},
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Attach an annotated PNG produced outside the paste flow — currently the
|
|
322
|
+
* image gallery's "Open in Annotation Editor" action, which annotates a
|
|
323
|
+
* file that already exists on disk. The result lands in the same place as
|
|
324
|
+
* paste-to-annotate: a pending chat attachment, with marker notes stashed.
|
|
325
|
+
* @param {Blob} blob - PNG exported by the annotation editor
|
|
326
|
+
* @param {string} originalName - name of the source image
|
|
327
|
+
* @param {Array} markers - [{n, note}] from the editor
|
|
328
|
+
*/
|
|
329
|
+
attachAnnotated(blob, originalName, markers) {
|
|
330
|
+
const annotated = this._annotatedFile(blob, originalName);
|
|
331
|
+
this.handleImages([annotated]);
|
|
332
|
+
this._stashMarkers(annotated.name, markers);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Re-attach an upload that already exists on disk as a pending attachment
|
|
337
|
+
* on the current input — the Uploads widget's "Attach to Chat" action.
|
|
338
|
+
*
|
|
339
|
+
* Non-image files need no copy: the prompt only ever carries the absolute
|
|
340
|
+
* path, so an upload from any session in the project attaches as-is.
|
|
341
|
+
*
|
|
342
|
+
* Images DO travel as base64, so the bytes are fetched back. Same session →
|
|
343
|
+
* reuse the stored name (no duplicate on disk, and the pending image
|
|
344
|
+
* survives a page refresh). Different session → re-upload into THIS
|
|
345
|
+
* session, because restoreFromServer() resolves storedName against the
|
|
346
|
+
* current session's uploads dir and would 404 otherwise.
|
|
347
|
+
*
|
|
348
|
+
* @param {Object} entry - {name, session_id, path, size, is_image} as
|
|
349
|
+
* served by GET /api/sessions/{id}/uploads
|
|
350
|
+
* @returns {Promise<boolean>} true when something is now pending
|
|
351
|
+
*/
|
|
352
|
+
async attachStored(entry) {
|
|
353
|
+
if (!entry?.name) return false;
|
|
354
|
+
|
|
355
|
+
if (!entry.is_image) {
|
|
356
|
+
if (!entry.path) {
|
|
357
|
+
this.onError(S.uploads.attach_no_path);
|
|
358
|
+
return false;
|
|
359
|
+
}
|
|
360
|
+
const fileEntry = {
|
|
361
|
+
name: entry.name,
|
|
362
|
+
path: entry.path,
|
|
363
|
+
size: entry.size || 0,
|
|
364
|
+
originalName: entry.name,
|
|
365
|
+
};
|
|
366
|
+
const existingIdx = this.pendingFiles.findIndex(f => f.path === entry.path);
|
|
367
|
+
if (existingIdx >= 0) this.pendingFiles[existingIdx] = fileEntry;
|
|
368
|
+
else this.pendingFiles.push(fileEntry);
|
|
369
|
+
this._renderFilePreviews();
|
|
370
|
+
this._notifyChange();
|
|
371
|
+
return true;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const ownerId = entry.session_id;
|
|
375
|
+
const currentId = this.getSessionId();
|
|
376
|
+
if (!ownerId) return false;
|
|
377
|
+
|
|
378
|
+
// Already pending from this same session — don't stack a duplicate
|
|
379
|
+
if (ownerId === currentId && this.pendingImages.some(i => i.storedName === entry.name)) {
|
|
380
|
+
return true;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const uploadId = genId();
|
|
384
|
+
this.uploadingImages.push({
|
|
385
|
+
id: uploadId,
|
|
386
|
+
preview: `${this.apiBase}/api/sessions/${encodeURIComponent(ownerId)}/uploads/${encodeURIComponent(entry.name)}`,
|
|
387
|
+
});
|
|
388
|
+
this._renderImagePreviews();
|
|
389
|
+
|
|
390
|
+
try {
|
|
391
|
+
const resp = await fetch(
|
|
392
|
+
`${this.apiBase}/api/sessions/${encodeURIComponent(ownerId)}/uploads/${encodeURIComponent(entry.name)}?base64_encode=true`
|
|
393
|
+
);
|
|
394
|
+
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
395
|
+
const data = await resp.json();
|
|
396
|
+
const blob = this._base64Blob(data.data, data.media_type);
|
|
397
|
+
|
|
398
|
+
this.uploadingImages = this.uploadingImages.filter(i => i.id !== uploadId);
|
|
399
|
+
|
|
400
|
+
// Cross-session, or a format Claude can't take as-is (svg/bmp land
|
|
401
|
+
// on 'application/octet-stream') — round-trip through the upload
|
|
402
|
+
// endpoint so the server normalises AND files it under this session.
|
|
403
|
+
const claudeSafe = CLAUDE_IMAGE_TYPES.has(data.media_type);
|
|
404
|
+
if (ownerId !== currentId || !claudeSafe) {
|
|
405
|
+
const type = claudeSafe ? data.media_type : guessImageMime(entry.name);
|
|
406
|
+
await this.handleImages([new File([blob], entry.name, { type })]);
|
|
407
|
+
return true;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
this.pendingImages.push({
|
|
411
|
+
file: null,
|
|
412
|
+
preview: URL.createObjectURL(blob),
|
|
413
|
+
imageData: {
|
|
414
|
+
type: 'image',
|
|
415
|
+
source: { type: 'base64', media_type: data.media_type, data: data.data },
|
|
416
|
+
},
|
|
417
|
+
storedName: entry.name,
|
|
418
|
+
mediaType: data.media_type,
|
|
419
|
+
});
|
|
420
|
+
this._renderImagePreviews();
|
|
421
|
+
this._notifyChange();
|
|
422
|
+
return true;
|
|
423
|
+
} catch (err) {
|
|
424
|
+
this.uploadingImages = this.uploadingImages.filter(i => i.id !== uploadId);
|
|
425
|
+
this._renderImagePreviews();
|
|
426
|
+
this._notifyChange();
|
|
427
|
+
this.onError(S.uploads.attach_failed.replace('{name}', entry.name));
|
|
428
|
+
return false;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** Decode a base64 payload into a Blob (preview + re-upload source). */
|
|
433
|
+
_base64Blob(base64, mediaType) {
|
|
434
|
+
const binary = atob(base64);
|
|
435
|
+
const bytes = new Uint8Array(binary.length);
|
|
436
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
437
|
+
return new Blob([bytes], { type: mediaType });
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Turn numbered-marker comments from the annotation editor into Comments
|
|
442
|
+
* Stash items. The stash block and the annotated image travel in the same
|
|
443
|
+
* user message, so "marker N on <file>" is enough for Claude to connect
|
|
444
|
+
* the comment to the badge on the picture.
|
|
445
|
+
*/
|
|
446
|
+
async _stashMarkers(fileName, markers) {
|
|
447
|
+
if (!markers?.length) return;
|
|
448
|
+
try {
|
|
449
|
+
const { Stash } = await import('./stash.js');
|
|
450
|
+
// Stash.add unshifts — insert in reverse so the list reads 1, 2, 3
|
|
451
|
+
for (const m of [...markers].reverse()) {
|
|
452
|
+
await Stash.add({
|
|
453
|
+
type: 'image',
|
|
454
|
+
filePath: fileName,
|
|
455
|
+
markerIndex: m.n,
|
|
456
|
+
}, m.note);
|
|
457
|
+
}
|
|
458
|
+
showToast(S.toast.markers_stashed.replace('{count}', markers.length));
|
|
459
|
+
} catch (err) {
|
|
460
|
+
console.error('[Upload] Failed to stash marker comments:', err);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Wrap an exported annotation blob as a File named after the original.
|
|
466
|
+
*/
|
|
467
|
+
_annotatedFile(blob, originalName) {
|
|
468
|
+
const base = (originalName || 'pasted-image').replace(/\.[^.]+$/, '');
|
|
469
|
+
return new File([blob], `${base}-annotated.png`, { type: 'image/png' });
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Handle non-image file uploads
|
|
474
|
+
*/
|
|
475
|
+
async _uploadFiles(files) {
|
|
476
|
+
if (!files.length) return;
|
|
477
|
+
|
|
478
|
+
const batch = this._beginBatch();
|
|
479
|
+
try {
|
|
480
|
+
await this._uploadFilesBatch(files);
|
|
481
|
+
} finally {
|
|
482
|
+
this._endBatch(batch);
|
|
483
|
+
this._notifyChange();
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
async _uploadFilesBatch(files) {
|
|
488
|
+
const sessionId = this.getSessionId();
|
|
489
|
+
|
|
490
|
+
for (const file of files) {
|
|
491
|
+
const uploadId = genId();
|
|
492
|
+
|
|
493
|
+
this.uploadingFiles.push({ id: uploadId, name: file.name });
|
|
494
|
+
this._renderFilePreviews();
|
|
495
|
+
|
|
496
|
+
try {
|
|
497
|
+
const formData = new FormData();
|
|
498
|
+
formData.append('file', file);
|
|
499
|
+
|
|
500
|
+
let url = `${this.apiBase}/api/upload-file`;
|
|
501
|
+
if (sessionId) url += `?session=${encodeURIComponent(sessionId)}`;
|
|
502
|
+
|
|
503
|
+
const response = await fetch(url, { method: 'POST', body: formData });
|
|
504
|
+
|
|
505
|
+
this.uploadingFiles = this.uploadingFiles.filter(f => f.id !== uploadId);
|
|
506
|
+
|
|
507
|
+
if (!response.ok) {
|
|
508
|
+
const error = await response.json();
|
|
509
|
+
this.onError(`File upload failed: ${error.detail}`);
|
|
510
|
+
this._renderFilePreviews();
|
|
511
|
+
this._notifyChange();
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const data = await response.json();
|
|
516
|
+
const fileEntry = {
|
|
517
|
+
name: data.stored_name,
|
|
518
|
+
path: data.path,
|
|
519
|
+
size: data.size,
|
|
520
|
+
originalName: data.filename,
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
// Switched tabs mid-flight — pendingFiles is the new session's
|
|
524
|
+
// list now, so don't attach this file to it. The upload itself
|
|
525
|
+
// still succeeded under the original session (it's on disk
|
|
526
|
+
// there), which the WidgetBus event below reports.
|
|
527
|
+
if (this._isForeignSession(sessionId)) {
|
|
528
|
+
this._renderFilePreviews();
|
|
529
|
+
this._notifyChange();
|
|
530
|
+
WidgetBus.emit('uploads:changed', { sessionId });
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Replace existing entry with same name, or append
|
|
535
|
+
const existingIdx = this.pendingFiles.findIndex(f => f.name === data.stored_name);
|
|
536
|
+
if (existingIdx >= 0) {
|
|
537
|
+
this.pendingFiles[existingIdx] = fileEntry;
|
|
538
|
+
} else {
|
|
539
|
+
this.pendingFiles.push(fileEntry);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
this._renderFilePreviews();
|
|
543
|
+
this._notifyChange();
|
|
544
|
+
|
|
545
|
+
// Notify uploads widget that files changed (file is already on disk)
|
|
546
|
+
if (sessionId) {
|
|
547
|
+
WidgetBus.emit('uploads:changed', { sessionId });
|
|
548
|
+
}
|
|
549
|
+
} catch (error) {
|
|
550
|
+
this.uploadingFiles = this.uploadingFiles.filter(f => f.id !== uploadId);
|
|
551
|
+
this.onError(`File upload error: ${error.message}`);
|
|
552
|
+
this._renderFilePreviews();
|
|
553
|
+
this._notifyChange();
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Render file previews
|
|
560
|
+
*/
|
|
561
|
+
_renderFilePreviews() {
|
|
562
|
+
let container = $('#file-previews');
|
|
563
|
+
|
|
564
|
+
// Only create container if we have files to show
|
|
565
|
+
const hasFiles = this.uploadingFiles.length > 0 || this.pendingFiles.length > 0;
|
|
566
|
+
|
|
567
|
+
if (!container && hasFiles) {
|
|
568
|
+
container = document.createElement('div');
|
|
569
|
+
container.id = 'file-previews';
|
|
570
|
+
// Insert before image-previews if it exists, otherwise before textarea wrapper
|
|
571
|
+
const imagePreviews = $('#image-previews');
|
|
572
|
+
if (imagePreviews && imagePreviews.parentNode === this.els.inputContainer) {
|
|
573
|
+
this.els.inputContainer.insertBefore(container, imagePreviews);
|
|
574
|
+
} else {
|
|
575
|
+
const textareaWrapper = this.els.inputContainer.querySelector('.input-textarea-wrapper');
|
|
576
|
+
if (textareaWrapper) {
|
|
577
|
+
this.els.inputContainer.insertBefore(container, textareaWrapper);
|
|
578
|
+
} else {
|
|
579
|
+
this.els.inputContainer.appendChild(container);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// If no container and no files, nothing to do
|
|
585
|
+
if (!container) return;
|
|
586
|
+
|
|
587
|
+
const formatSize = (bytes) => {
|
|
588
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
589
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
590
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
591
|
+
};
|
|
592
|
+
|
|
593
|
+
const getFileIcon = (name) => {
|
|
594
|
+
const ext = name.split('.').pop()?.toLowerCase();
|
|
595
|
+
const codeExts = ['js', 'ts', 'jsx', 'tsx', 'py', 'rb', 'go', 'rs', 'java', 'c', 'cpp', 'h', 'css', 'html', 'vue', 'svelte'];
|
|
596
|
+
const dataExts = ['json', 'yaml', 'yml', 'xml', 'csv', 'sql'];
|
|
597
|
+
const docExts = ['md', 'txt', 'rst', 'doc', 'docx', 'pdf'];
|
|
598
|
+
if (codeExts.includes(ext)) return '📝';
|
|
599
|
+
if (dataExts.includes(ext)) return '📊';
|
|
600
|
+
if (docExts.includes(ext)) return '📄';
|
|
601
|
+
return '📎';
|
|
602
|
+
};
|
|
603
|
+
|
|
604
|
+
const uploadingHtml = this.uploadingFiles.map(f => `
|
|
605
|
+
<div class="file-preview uploading" data-upload-id="${f.id}">
|
|
606
|
+
<span class="file-icon">⏳</span>
|
|
607
|
+
<span class="file-name">${escapeHtml(f.name)}</span>
|
|
608
|
+
<span class="file-spinner"></span>
|
|
609
|
+
</div>
|
|
610
|
+
`).join('');
|
|
611
|
+
|
|
612
|
+
const pendingHtml = this.pendingFiles.map((f, idx) => `
|
|
613
|
+
<div class="file-preview" data-index="${idx}">
|
|
614
|
+
<span class="file-icon">${getFileIcon(f.name)}</span>
|
|
615
|
+
<span class="file-name">${escapeHtml(f.name)}</span>
|
|
616
|
+
<span class="file-size">${formatSize(f.size)}</span>
|
|
617
|
+
<button class="file-remove" data-index="${idx}" data-tooltip="${S.upload_manager.remove_file}">
|
|
618
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
619
|
+
<path d="M18 6L6 18M6 6l12 12"/>
|
|
620
|
+
</svg>
|
|
621
|
+
</button>
|
|
622
|
+
</div>
|
|
623
|
+
`).join('');
|
|
624
|
+
|
|
625
|
+
container.innerHTML = uploadingHtml + pendingHtml;
|
|
626
|
+
|
|
627
|
+
container.querySelectorAll('.file-remove').forEach(btn => {
|
|
628
|
+
btn.addEventListener('click', (e) => {
|
|
629
|
+
e.preventDefault();
|
|
630
|
+
e.stopPropagation();
|
|
631
|
+
this.removeFile(parseInt(btn.dataset.index));
|
|
632
|
+
});
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
// Click to preview, context menu for options
|
|
636
|
+
container.querySelectorAll('.file-preview:not(.uploading)').forEach(chip => {
|
|
637
|
+
const idx = parseInt(chip.dataset.index);
|
|
638
|
+
const file = this.pendingFiles[idx];
|
|
639
|
+
if (!file) return;
|
|
640
|
+
|
|
641
|
+
// Click → preview
|
|
642
|
+
chip.addEventListener('click', (e) => {
|
|
643
|
+
if (e.target.closest('.file-remove')) return;
|
|
644
|
+
e.preventDefault();
|
|
645
|
+
this._openFilePreview(file.path);
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
// Right-click → context menu
|
|
649
|
+
chip.addEventListener('contextmenu', (e) => {
|
|
650
|
+
e.preventDefault();
|
|
651
|
+
e.stopPropagation();
|
|
652
|
+
this._showFileContextMenu(e.clientX, e.clientY, file, idx);
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
// Long-press for touch → context menu
|
|
656
|
+
chip.addEventListener('touchstart', (e) => {
|
|
657
|
+
if (e.target.closest('.file-remove')) return;
|
|
658
|
+
this._longPressTimer = setTimeout(() => {
|
|
659
|
+
this._longPressTimer = null;
|
|
660
|
+
const touch = e.touches[0];
|
|
661
|
+
this._showFileContextMenu(touch.clientX, touch.clientY, file, idx);
|
|
662
|
+
}, 500);
|
|
663
|
+
}, { passive: true });
|
|
664
|
+
chip.addEventListener('touchend', () => {
|
|
665
|
+
if (this._longPressTimer) {
|
|
666
|
+
clearTimeout(this._longPressTimer);
|
|
667
|
+
this._longPressTimer = null;
|
|
668
|
+
}
|
|
669
|
+
});
|
|
670
|
+
chip.addEventListener('touchmove', () => {
|
|
671
|
+
if (this._longPressTimer) {
|
|
672
|
+
clearTimeout(this._longPressTimer);
|
|
673
|
+
this._longPressTimer = null;
|
|
674
|
+
}
|
|
675
|
+
});
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
container.classList.toggle('visible', hasFiles);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* Render image previews
|
|
683
|
+
*/
|
|
684
|
+
_renderImagePreviews() {
|
|
685
|
+
let container = $('#image-previews');
|
|
686
|
+
|
|
687
|
+
// Only create container if we have images to show
|
|
688
|
+
const hasImages = this.uploadingImages.length > 0 || this.pendingImages.length > 0;
|
|
689
|
+
|
|
690
|
+
if (!container && hasImages) {
|
|
691
|
+
container = document.createElement('div');
|
|
692
|
+
container.id = 'image-previews';
|
|
693
|
+
// Insert before the textarea wrapper (a direct child of inputContainer)
|
|
694
|
+
const textareaWrapper = this.els.inputContainer.querySelector('.input-textarea-wrapper');
|
|
695
|
+
if (textareaWrapper) {
|
|
696
|
+
this.els.inputContainer.insertBefore(container, textareaWrapper);
|
|
697
|
+
} else {
|
|
698
|
+
this.els.inputContainer.appendChild(container);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// If no container and no images, nothing to do
|
|
703
|
+
if (!container) return;
|
|
704
|
+
|
|
705
|
+
const uploadingHtml = this.uploadingImages.map(img => `
|
|
706
|
+
<div class="image-preview uploading" data-upload-id="${img.id}">
|
|
707
|
+
<img src="${img.preview}" alt="Uploading...">
|
|
708
|
+
<div class="upload-spinner">
|
|
709
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
710
|
+
<circle cx="12" cy="12" r="10" stroke-dasharray="32" stroke-dashoffset="8"/>
|
|
711
|
+
</svg>
|
|
712
|
+
</div>
|
|
713
|
+
</div>
|
|
714
|
+
`).join('');
|
|
715
|
+
|
|
716
|
+
const pendingHtml = this.pendingImages.map((img, idx) => `
|
|
717
|
+
<div class="image-preview" data-index="${idx}">
|
|
718
|
+
<img src="${img.preview}" alt="Upload preview">
|
|
719
|
+
<button class="image-annotate" data-index="${idx}" data-tooltip="${S.upload_manager.annotate_image}">
|
|
720
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
721
|
+
<path d="M17 3a2.85 2.85 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/>
|
|
722
|
+
</svg>
|
|
723
|
+
</button>
|
|
724
|
+
<button class="image-remove" data-index="${idx}" data-tooltip="${S.upload_manager.remove_image}">
|
|
725
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
726
|
+
<path d="M18 6L6 18M6 6l12 12"/>
|
|
727
|
+
</svg>
|
|
728
|
+
</button>
|
|
729
|
+
</div>
|
|
730
|
+
`).join('');
|
|
731
|
+
|
|
732
|
+
container.innerHTML = uploadingHtml + pendingHtml;
|
|
733
|
+
|
|
734
|
+
container.querySelectorAll('.image-remove').forEach(btn => {
|
|
735
|
+
btn.addEventListener('click', (e) => {
|
|
736
|
+
e.preventDefault();
|
|
737
|
+
e.stopPropagation();
|
|
738
|
+
this.removeImage(parseInt(btn.dataset.index));
|
|
739
|
+
});
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
container.querySelectorAll('.image-annotate').forEach(btn => {
|
|
743
|
+
btn.addEventListener('click', (e) => {
|
|
744
|
+
e.preventDefault();
|
|
745
|
+
e.stopPropagation();
|
|
746
|
+
this.editImage(parseInt(btn.dataset.index));
|
|
747
|
+
});
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
// Click a pending thumbnail → open the annotation editor directly
|
|
751
|
+
// (annotating is the primary intent; the pencil button is the same
|
|
752
|
+
// action). Uploading thumbnails have no pending index — skip them.
|
|
753
|
+
container.querySelectorAll('.image-preview:not(.uploading) img').forEach(img => {
|
|
754
|
+
img.addEventListener('click', (e) => {
|
|
755
|
+
e.preventDefault();
|
|
756
|
+
e.stopPropagation();
|
|
757
|
+
const wrap = img.closest('.image-preview[data-index]');
|
|
758
|
+
if (wrap) this.editImage(parseInt(wrap.dataset.index));
|
|
759
|
+
});
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
container.classList.toggle('visible', hasImages);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/**
|
|
766
|
+
* Notify that upload state changed
|
|
767
|
+
*/
|
|
768
|
+
_notifyChange() {
|
|
769
|
+
this.onStateChange();
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/** Register an in-flight batch against the session that started it. */
|
|
773
|
+
_beginBatch() {
|
|
774
|
+
const batch = { session: this.getSessionId() };
|
|
775
|
+
this._activeBatchSessions.push(batch);
|
|
776
|
+
return batch;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
_endBatch(batch) {
|
|
780
|
+
const i = this._activeBatchSessions.indexOf(batch);
|
|
781
|
+
if (i !== -1) this._activeBatchSessions.splice(i, 1);
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* Does `session` belong to a different session than the one on screen?
|
|
786
|
+
*
|
|
787
|
+
* Deliberately false when either id is missing: a fresh tab has no storeId
|
|
788
|
+
* until it connects, so an image pasted into it starts a batch with a null
|
|
789
|
+
* session and would otherwise be judged "foreign" the moment the id is
|
|
790
|
+
* assigned mid-upload — dropping a perfectly good attachment.
|
|
791
|
+
*/
|
|
792
|
+
_isForeignSession(session) {
|
|
793
|
+
const current = this.getSessionId();
|
|
794
|
+
return !!(session && current && session !== current);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// ─────────────────────────────────────────────────────────────────
|
|
798
|
+
// File preview & context menu
|
|
799
|
+
// ─────────────────────────────────────────────────────────────────
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* Open file in the inline preview widget
|
|
803
|
+
*/
|
|
804
|
+
_openFilePreview(filePath) {
|
|
805
|
+
window.app?.previewFile(filePath);
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/**
|
|
809
|
+
* Show context menu for a file chip
|
|
810
|
+
*/
|
|
811
|
+
_showFileContextMenu(x, y, file, index) {
|
|
812
|
+
this._contextMenu.show(x, y, [
|
|
813
|
+
{
|
|
814
|
+
label: S.context_menus.file.preview,
|
|
815
|
+
action: () => this._openFilePreview(file.path),
|
|
816
|
+
},
|
|
817
|
+
{
|
|
818
|
+
label: S.widgets.header_actions.copy_path,
|
|
819
|
+
action: async () => {
|
|
820
|
+
await copyToClipboard(file.path);
|
|
821
|
+
showToast(S.toast.path_copied);
|
|
822
|
+
},
|
|
823
|
+
},
|
|
824
|
+
{ separator: true },
|
|
825
|
+
{
|
|
826
|
+
label: S.common.delete,
|
|
827
|
+
action: () => this.removeFile(index),
|
|
828
|
+
},
|
|
829
|
+
]);
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// ─────────────────────────────────────────────────────────────────
|
|
833
|
+
// Public API
|
|
834
|
+
// ─────────────────────────────────────────────────────────────────
|
|
835
|
+
|
|
836
|
+
/**
|
|
837
|
+
* Open image file picker
|
|
838
|
+
*/
|
|
839
|
+
openImagePicker() {
|
|
840
|
+
this.imageInput.click();
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/**
|
|
844
|
+
* Open general file picker
|
|
845
|
+
*/
|
|
846
|
+
openFilePicker() {
|
|
847
|
+
this.fileInput.click();
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* Remove a pending file
|
|
852
|
+
*/
|
|
853
|
+
removeFile(index) {
|
|
854
|
+
if (index >= 0 && index < this.pendingFiles.length) {
|
|
855
|
+
this.pendingFiles.splice(index, 1);
|
|
856
|
+
this._renderFilePreviews();
|
|
857
|
+
this._notifyChange();
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
/**
|
|
862
|
+
* Remove a pending image
|
|
863
|
+
*/
|
|
864
|
+
removeImage(index) {
|
|
865
|
+
if (index >= 0 && index < this.pendingImages.length) {
|
|
866
|
+
URL.revokeObjectURL(this.pendingImages[index].preview);
|
|
867
|
+
this.pendingImages.splice(index, 1);
|
|
868
|
+
this._renderImagePreviews();
|
|
869
|
+
this._notifyChange();
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
/**
|
|
874
|
+
* Clear all pending files
|
|
875
|
+
*/
|
|
876
|
+
clearFiles() {
|
|
877
|
+
this.pendingFiles = [];
|
|
878
|
+
this._renderFilePreviews();
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* Clear all pending images
|
|
883
|
+
*/
|
|
884
|
+
clearImages() {
|
|
885
|
+
this.pendingImages.forEach(img => URL.revokeObjectURL(img.preview));
|
|
886
|
+
this.pendingImages = [];
|
|
887
|
+
this._renderImagePreviews();
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/**
|
|
891
|
+
* Clear all pending uploads
|
|
892
|
+
*/
|
|
893
|
+
clear() {
|
|
894
|
+
this.clearFiles();
|
|
895
|
+
this.clearImages();
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/**
|
|
899
|
+
* Save current upload state (for session switching).
|
|
900
|
+
* Returns arrays that can be stored on a Session object.
|
|
901
|
+
* Does NOT revoke blob URLs — the session still owns them.
|
|
902
|
+
*/
|
|
903
|
+
saveState() {
|
|
904
|
+
return {
|
|
905
|
+
pendingImages: this.pendingImages.slice(),
|
|
906
|
+
pendingFiles: this.pendingFiles.slice()
|
|
907
|
+
};
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/**
|
|
911
|
+
* Restore upload state (for session switching).
|
|
912
|
+
* Replaces current state and re-renders previews.
|
|
913
|
+
*/
|
|
914
|
+
restoreState(state) {
|
|
915
|
+
// Clear current without revoking URLs (they belong to the outgoing session)
|
|
916
|
+
this.pendingImages = state?.pendingImages || [];
|
|
917
|
+
this.pendingFiles = state?.pendingFiles || [];
|
|
918
|
+
this.uploadingImages = [];
|
|
919
|
+
this.uploadingFiles = [];
|
|
920
|
+
this._renderImagePreviews();
|
|
921
|
+
this._renderFilePreviews();
|
|
922
|
+
this._notifyChange();
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
/**
|
|
926
|
+
* Restore uploads from server after page refresh.
|
|
927
|
+
* Fetches images from server to reconstruct imageData and blob URLs.
|
|
928
|
+
* Files are metadata-only (already on disk server-side).
|
|
929
|
+
*
|
|
930
|
+
* @param {Object} metadata - {images: [{storedName, mediaType}], files: [{name, path, size, originalName}]}
|
|
931
|
+
* @param {string} sessionId - The server-side session storeId
|
|
932
|
+
*/
|
|
933
|
+
async restoreFromServer(metadata, sessionId) {
|
|
934
|
+
if (!metadata || !sessionId) return;
|
|
935
|
+
const { images = [], files = [] } = metadata;
|
|
936
|
+
|
|
937
|
+
// Restore files (metadata-only, no server fetch needed)
|
|
938
|
+
if (files.length > 0) {
|
|
939
|
+
this.pendingFiles = files.slice();
|
|
940
|
+
this._renderFilePreviews();
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
// Restore images (fetch from server for base64 + preview)
|
|
944
|
+
if (images.length > 0) {
|
|
945
|
+
for (const imgMeta of images) {
|
|
946
|
+
try {
|
|
947
|
+
const resp = await fetch(
|
|
948
|
+
`${this.apiBase}/api/sessions/${encodeURIComponent(sessionId)}/uploads/${encodeURIComponent(imgMeta.storedName)}?base64_encode=true`
|
|
949
|
+
);
|
|
950
|
+
if (!resp.ok) {
|
|
951
|
+
console.warn(`[UploadManager] Failed to restore image ${imgMeta.storedName}: ${resp.status}`);
|
|
952
|
+
continue;
|
|
953
|
+
}
|
|
954
|
+
const data = await resp.json();
|
|
955
|
+
|
|
956
|
+
// Reconstruct Claude API imageData
|
|
957
|
+
const imageData = {
|
|
958
|
+
type: 'image',
|
|
959
|
+
source: {
|
|
960
|
+
type: 'base64',
|
|
961
|
+
media_type: data.media_type,
|
|
962
|
+
data: data.data
|
|
963
|
+
}
|
|
964
|
+
};
|
|
965
|
+
|
|
966
|
+
// Create blob URL for preview
|
|
967
|
+
const preview = URL.createObjectURL(this._base64Blob(data.data, data.media_type));
|
|
968
|
+
|
|
969
|
+
this.pendingImages.push({
|
|
970
|
+
file: null,
|
|
971
|
+
preview,
|
|
972
|
+
imageData,
|
|
973
|
+
storedName: imgMeta.storedName,
|
|
974
|
+
mediaType: data.media_type,
|
|
975
|
+
});
|
|
976
|
+
} catch (err) {
|
|
977
|
+
console.warn(`[UploadManager] Error restoring image ${imgMeta.storedName}:`, err);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
this._renderImagePreviews();
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
if (images.length > 0 || files.length > 0) {
|
|
984
|
+
this._notifyChange();
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
/**
|
|
989
|
+
* Get pending images for sending
|
|
990
|
+
*/
|
|
991
|
+
getPendingImages() {
|
|
992
|
+
return this.pendingImages.map(img => img.imageData);
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
/**
|
|
996
|
+
* Get pending files for message text
|
|
997
|
+
*/
|
|
998
|
+
getPendingFiles() {
|
|
999
|
+
return this.pendingFiles.slice();
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
/**
|
|
1003
|
+
* Check if there are pending uploads
|
|
1004
|
+
*/
|
|
1005
|
+
get hasPending() {
|
|
1006
|
+
return this.pendingImages.length > 0 || this.pendingFiles.length > 0;
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
/**
|
|
1010
|
+
* Check if there are active uploads
|
|
1011
|
+
*/
|
|
1012
|
+
get isUploading() {
|
|
1013
|
+
// Only batches belonging to the session on screen count — see
|
|
1014
|
+
// _activeBatchSessions in the constructor.
|
|
1015
|
+
return this._activeBatchSessions.some(b => !this._isForeignSession(b.session)) ||
|
|
1016
|
+
this.uploadingImages.length > 0 ||
|
|
1017
|
+
this.uploadingFiles.length > 0;
|
|
1018
|
+
}
|
|
1019
|
+
}
|