nastechai-desktop 18.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.prettierrc +11 -0
- package/DESIGN.md +167 -0
- package/README.md +141 -0
- package/assets/icon.icns +0 -0
- package/assets/icon.ico +0 -0
- package/assets/icon.png +0 -0
- package/components.json +21 -0
- package/electron/backend-env.cjs +112 -0
- package/electron/backend-env.test.cjs +111 -0
- package/electron/backend-probes.cjs +106 -0
- package/electron/backend-probes.test.cjs +82 -0
- package/electron/backend-ready.cjs +66 -0
- package/electron/bootstrap-platform.cjs +91 -0
- package/electron/bootstrap-platform.test.cjs +111 -0
- package/electron/bootstrap-runner.cjs +720 -0
- package/electron/bootstrap-runner.test.cjs +138 -0
- package/electron/connection-config.cjs +254 -0
- package/electron/connection-config.test.cjs +329 -0
- package/electron/dashboard-token.cjs +99 -0
- package/electron/dashboard-token.test.cjs +142 -0
- package/electron/desktop-uninstall.cjs +232 -0
- package/electron/desktop-uninstall.test.cjs +246 -0
- package/electron/entitlements.mac.inherit.plist +14 -0
- package/electron/entitlements.mac.plist +14 -0
- package/electron/fs-read-dir.cjs +109 -0
- package/electron/fs-read-dir.test.cjs +364 -0
- package/electron/gateway-ws-probe.cjs +188 -0
- package/electron/gateway-ws-probe.test.cjs +122 -0
- package/electron/git-root.cjs +54 -0
- package/electron/git-root.test.cjs +40 -0
- package/electron/git-worktrees.cjs +174 -0
- package/electron/hardening.cjs +184 -0
- package/electron/hardening.test.cjs +116 -0
- package/electron/main.cjs +5762 -0
- package/electron/oauth-net-request.cjs +20 -0
- package/electron/oauth-net-request.test.cjs +34 -0
- package/electron/preload.cjs +135 -0
- package/electron/session-windows.cjs +99 -0
- package/electron/session-windows.test.cjs +177 -0
- package/electron/update-remote.cjs +56 -0
- package/electron/update-remote.test.cjs +78 -0
- package/electron/vscode-marketplace.cjs +331 -0
- package/electron/vscode-marketplace.test.cjs +113 -0
- package/electron/windows-child-process.test.cjs +57 -0
- package/electron/windows-user-env.cjs +76 -0
- package/electron/windows-user-env.test.cjs +90 -0
- package/electron/workspace-cwd.cjs +38 -0
- package/electron/workspace-cwd.test.cjs +45 -0
- package/eslint.config.mjs +122 -0
- package/index.html +17 -0
- package/package.json +254 -0
- package/pr-assets/session-source-folders.png +0 -0
- package/preview-demo.html +65 -0
- package/public/apple-touch-icon.png +0 -0
- package/public/ds-assets/filler-bg0.jpg +0 -0
- package/public/nastech-frames/nastech-frame-0.png +0 -0
- package/public/nastech-frames/nastech-frame-1.png +0 -0
- package/public/nastech-frames/nastech-frame-2.png +0 -0
- package/public/nastech-frames/nastech-frame-3.png +0 -0
- package/public/nastech-frames/nastech-frame-4.png +0 -0
- package/public/nastech-frames/nastech-frame-5.png +0 -0
- package/public/nastech-frames/nastech-frame-6.png +0 -0
- package/public/nastech-frames/nastech-frame-7.png +0 -0
- package/public/nastech-girl.jpg +0 -0
- package/public/nastech-sprite.png +0 -0
- package/public/nastech.png +0 -0
- package/scripts/after-pack.cjs +41 -0
- package/scripts/assert-dist-built.cjs +70 -0
- package/scripts/assert-dist-built.test.cjs +84 -0
- package/scripts/assert-root-install.cjs +13 -0
- package/scripts/before-build.cjs +11 -0
- package/scripts/before-pack.cjs +78 -0
- package/scripts/before-pack.test.cjs +53 -0
- package/scripts/click-session.mjs +51 -0
- package/scripts/dev-no-hmr.mjs +22 -0
- package/scripts/diag-jump.mjs +115 -0
- package/scripts/diag-scroll-reset.mjs +229 -0
- package/scripts/eval.mjs +21 -0
- package/scripts/leak-typing.mjs +222 -0
- package/scripts/measure-jump.mjs +108 -0
- package/scripts/measure-latency.mjs +184 -0
- package/scripts/measure-real-stream.mjs +252 -0
- package/scripts/measure-submit.mjs +179 -0
- package/scripts/measure-synthetic-stream.mjs +322 -0
- package/scripts/notarize-artifact.cjs +77 -0
- package/scripts/notarize.cjs +100 -0
- package/scripts/patch-electron-builder-mac-binary.cjs +59 -0
- package/scripts/probe-renderer.mjs +38 -0
- package/scripts/probe-thread.mjs +40 -0
- package/scripts/profile-long-stream.mjs +191 -0
- package/scripts/profile-real-stream.mjs +137 -0
- package/scripts/profile-synth-stream.mjs +103 -0
- package/scripts/profile-typing-lag.md +381 -0
- package/scripts/profile-typing.mjs +260 -0
- package/scripts/reload-renderer.mjs +25 -0
- package/scripts/reload.mjs +36 -0
- package/scripts/set-exe-identity.cjs +94 -0
- package/scripts/stage-native-deps.cjs +159 -0
- package/scripts/test-desktop.mjs +425 -0
- package/scripts/write-build-stamp.cjs +126 -0
- package/src/app/agents/index.tsx +398 -0
- package/src/app/artifacts/index.test.ts +62 -0
- package/src/app/artifacts/index.tsx +906 -0
- package/src/app/chat/chat-drop-overlay.tsx +48 -0
- package/src/app/chat/chat-swap-overlay.tsx +47 -0
- package/src/app/chat/composer/attachments.tsx +114 -0
- package/src/app/chat/composer/completion-drawer.tsx +63 -0
- package/src/app/chat/composer/context-menu.tsx +172 -0
- package/src/app/chat/composer/controls.tsx +289 -0
- package/src/app/chat/composer/drop-affordance.ts +2 -0
- package/src/app/chat/composer/enter-submit-dom-race.test.tsx +218 -0
- package/src/app/chat/composer/focus.ts +134 -0
- package/src/app/chat/composer/help-hint.tsx +59 -0
- package/src/app/chat/composer/hooks/use-at-completions.ts +141 -0
- package/src/app/chat/composer/hooks/use-live-completion-adapter.ts +119 -0
- package/src/app/chat/composer/hooks/use-mic-recorder.ts +291 -0
- package/src/app/chat/composer/hooks/use-slash-completions.ts +114 -0
- package/src/app/chat/composer/hooks/use-voice-conversation.ts +390 -0
- package/src/app/chat/composer/hooks/use-voice-recorder.ts +116 -0
- package/src/app/chat/composer/ime-composition-dom-repro.test.tsx +108 -0
- package/src/app/chat/composer/index.tsx +1611 -0
- package/src/app/chat/composer/inline-refs.ts +138 -0
- package/src/app/chat/composer/model-pill.tsx +86 -0
- package/src/app/chat/composer/queue-panel.tsx +130 -0
- package/src/app/chat/composer/rich-editor.test.ts +18 -0
- package/src/app/chat/composer/rich-editor.ts +165 -0
- package/src/app/chat/composer/skin-slash-popover.tsx +61 -0
- package/src/app/chat/composer/slash-nav-dom-repro.test.tsx +186 -0
- package/src/app/chat/composer/status-stack/index.tsx +202 -0
- package/src/app/chat/composer/status-stack/status-row.tsx +155 -0
- package/src/app/chat/composer/text-utils.test.ts +77 -0
- package/src/app/chat/composer/text-utils.ts +107 -0
- package/src/app/chat/composer/trigger-popover.test.tsx +42 -0
- package/src/app/chat/composer/trigger-popover.tsx +116 -0
- package/src/app/chat/composer/types.ts +64 -0
- package/src/app/chat/composer/url-dialog.tsx +82 -0
- package/src/app/chat/composer/voice-activity.tsx +252 -0
- package/src/app/chat/hooks/use-composer-actions.test.ts +57 -0
- package/src/app/chat/hooks/use-composer-actions.ts +525 -0
- package/src/app/chat/hooks/use-file-drop-zone.ts +118 -0
- package/src/app/chat/index.tsx +390 -0
- package/src/app/chat/perf-probe.tsx +269 -0
- package/src/app/chat/right-rail/index.ts +1 -0
- package/src/app/chat/right-rail/preview-console-state.ts +82 -0
- package/src/app/chat/right-rail/preview-console.tsx +290 -0
- package/src/app/chat/right-rail/preview-file.tsx +559 -0
- package/src/app/chat/right-rail/preview-pane.test.tsx +43 -0
- package/src/app/chat/right-rail/preview-pane.tsx +657 -0
- package/src/app/chat/right-rail/preview.tsx +171 -0
- package/src/app/chat/scroll-to-bottom-button.test.tsx +67 -0
- package/src/app/chat/scroll-to-bottom-button.tsx +74 -0
- package/src/app/chat/sidebar/cron-jobs-section.tsx +325 -0
- package/src/app/chat/sidebar/index.tsx +1219 -0
- package/src/app/chat/sidebar/load-more-row.tsx +30 -0
- package/src/app/chat/sidebar/order.test.ts +21 -0
- package/src/app/chat/sidebar/order.ts +17 -0
- package/src/app/chat/sidebar/profile-switcher.tsx +516 -0
- package/src/app/chat/sidebar/session-actions-menu.tsx +264 -0
- package/src/app/chat/sidebar/session-row.tsx +257 -0
- package/src/app/chat/sidebar/virtual-session-list.tsx +154 -0
- package/src/app/chat/sidebar/workspace-groups.test.ts +149 -0
- package/src/app/chat/sidebar/workspace-groups.ts +326 -0
- package/src/app/chat/thread-loading.test.ts +34 -0
- package/src/app/chat/thread-loading.ts +26 -0
- package/src/app/command-center/index.tsx +654 -0
- package/src/app/command-palette/index.tsx +513 -0
- package/src/app/command-palette/marketplace-theme-page.tsx +157 -0
- package/src/app/cron/index.tsx +942 -0
- package/src/app/cron/job-state.ts +29 -0
- package/src/app/desktop-controller.tsx +938 -0
- package/src/app/floating-hud.ts +22 -0
- package/src/app/gateway/hooks/use-gateway-boot.test.tsx +265 -0
- package/src/app/gateway/hooks/use-gateway-boot.ts +387 -0
- package/src/app/gateway/hooks/use-gateway-request.ts +138 -0
- package/src/app/hooks/use-keybinds.ts +186 -0
- package/src/app/hooks/use-refresh-hotkey.ts +45 -0
- package/src/app/hooks/use-route-enum-param.ts +38 -0
- package/src/app/index.tsx +1 -0
- package/src/app/layout-constants.ts +13 -0
- package/src/app/messaging/index.tsx +648 -0
- package/src/app/messaging/platform-icon.tsx +93 -0
- package/src/app/model-picker-overlay.tsx +42 -0
- package/src/app/model-visibility-overlay.tsx +31 -0
- package/src/app/overlays/overlay-chrome.tsx +66 -0
- package/src/app/overlays/overlay-search-input.tsx +33 -0
- package/src/app/overlays/overlay-split-layout.tsx +130 -0
- package/src/app/overlays/overlay-view.tsx +91 -0
- package/src/app/page-search-shell.tsx +75 -0
- package/src/app/profiles/create-profile-dialog.tsx +154 -0
- package/src/app/profiles/delete-profile-dialog.tsx +65 -0
- package/src/app/profiles/index.tsx +671 -0
- package/src/app/profiles/rename-profile-dialog.tsx +125 -0
- package/src/app/right-sidebar/files/dnd-manager.ts +27 -0
- package/src/app/right-sidebar/files/ipc.test.ts +100 -0
- package/src/app/right-sidebar/files/ipc.ts +161 -0
- package/src/app/right-sidebar/files/remote-picker.tsx +177 -0
- package/src/app/right-sidebar/files/tree.tsx +224 -0
- package/src/app/right-sidebar/files/use-project-tree.test.ts +190 -0
- package/src/app/right-sidebar/files/use-project-tree.ts +268 -0
- package/src/app/right-sidebar/index.test.tsx +75 -0
- package/src/app/right-sidebar/index.tsx +395 -0
- package/src/app/right-sidebar/store.ts +15 -0
- package/src/app/right-sidebar/terminal/buffer.ts +65 -0
- package/src/app/right-sidebar/terminal/index.tsx +98 -0
- package/src/app/right-sidebar/terminal/persistent.tsx +122 -0
- package/src/app/right-sidebar/terminal/selection.ts +75 -0
- package/src/app/right-sidebar/terminal/use-terminal-session.ts +504 -0
- package/src/app/routes.ts +88 -0
- package/src/app/session/hooks/use-context-suggestions.ts +58 -0
- package/src/app/session/hooks/use-cwd-actions.ts +109 -0
- package/src/app/session/hooks/use-message-stream.ts +957 -0
- package/src/app/session/hooks/use-model-controls.test.tsx +198 -0
- package/src/app/session/hooks/use-model-controls.ts +106 -0
- package/src/app/session/hooks/use-nastech-config.ts +74 -0
- package/src/app/session/hooks/use-preview-routing.test.tsx +168 -0
- package/src/app/session/hooks/use-preview-routing.ts +223 -0
- package/src/app/session/hooks/use-prompt-actions.test.tsx +316 -0
- package/src/app/session/hooks/use-prompt-actions.ts +1030 -0
- package/src/app/session/hooks/use-route-resume.test.tsx +136 -0
- package/src/app/session/hooks/use-route-resume.ts +115 -0
- package/src/app/session/hooks/use-session-actions.test.tsx +119 -0
- package/src/app/session/hooks/use-session-actions.ts +885 -0
- package/src/app/session/hooks/use-session-state-cache.test.tsx +118 -0
- package/src/app/session/hooks/use-session-state-cache.ts +191 -0
- package/src/app/session-picker-overlay.tsx +32 -0
- package/src/app/session-switcher.tsx +107 -0
- package/src/app/settings/about-settings.tsx +173 -0
- package/src/app/settings/appearance-settings.tsx +162 -0
- package/src/app/settings/config-settings.tsx +384 -0
- package/src/app/settings/constants.ts +545 -0
- package/src/app/settings/credential-key-ui.tsx +373 -0
- package/src/app/settings/env-credentials.tsx +198 -0
- package/src/app/settings/env-var-actions-menu.tsx +136 -0
- package/src/app/settings/field-copy.ts +56 -0
- package/src/app/settings/gateway-settings.tsx +620 -0
- package/src/app/settings/helpers.test.ts +138 -0
- package/src/app/settings/helpers.ts +151 -0
- package/src/app/settings/index.tsx +237 -0
- package/src/app/settings/keys-settings.tsx +96 -0
- package/src/app/settings/mcp-settings.tsx +271 -0
- package/src/app/settings/model-settings.test.tsx +157 -0
- package/src/app/settings/model-settings.tsx +559 -0
- package/src/app/settings/notifications-settings.tsx +150 -0
- package/src/app/settings/primitives.tsx +115 -0
- package/src/app/settings/providers-settings.test.tsx +100 -0
- package/src/app/settings/providers-settings.tsx +258 -0
- package/src/app/settings/sessions-settings.tsx +276 -0
- package/src/app/settings/toolset-config-panel.test.tsx +289 -0
- package/src/app/settings/toolset-config-panel.tsx +449 -0
- package/src/app/settings/types.ts +42 -0
- package/src/app/settings/uninstall-section.tsx +185 -0
- package/src/app/settings/use-deep-link-highlight.ts +60 -0
- package/src/app/shell/app-shell.tsx +167 -0
- package/src/app/shell/gateway-menu-panel.tsx +150 -0
- package/src/app/shell/hooks/use-overlay-routing.ts +71 -0
- package/src/app/shell/hooks/use-status-snapshot.ts +57 -0
- package/src/app/shell/hooks/use-statusbar-items.tsx +403 -0
- package/src/app/shell/keybind-panel.tsx +220 -0
- package/src/app/shell/model-edit-submenu.test.tsx +84 -0
- package/src/app/shell/model-edit-submenu.tsx +245 -0
- package/src/app/shell/model-menu-panel.tsx +295 -0
- package/src/app/shell/sidebar-label.tsx +22 -0
- package/src/app/shell/statusbar-controls.tsx +185 -0
- package/src/app/shell/titlebar-controls.tsx +244 -0
- package/src/app/shell/titlebar.test.ts +26 -0
- package/src/app/shell/titlebar.ts +45 -0
- package/src/app/shell/use-group-registry.ts +39 -0
- package/src/app/skills/index.test.tsx +103 -0
- package/src/app/skills/index.tsx +371 -0
- package/src/app/types.ts +99 -0
- package/src/app/updates-overlay.tsx +369 -0
- package/src/components/Backdrop.tsx +114 -0
- package/src/components/assistant-ui/ansi-text.tsx +34 -0
- package/src/components/assistant-ui/clarify-tool.tsx +281 -0
- package/src/components/assistant-ui/directive-text.test.ts +39 -0
- package/src/components/assistant-ui/directive-text.tsx +389 -0
- package/src/components/assistant-ui/markdown-text.test.ts +204 -0
- package/src/components/assistant-ui/markdown-text.tsx +497 -0
- package/src/components/assistant-ui/message-render-boundary.test.tsx +80 -0
- package/src/components/assistant-ui/message-render-boundary.tsx +48 -0
- package/src/components/assistant-ui/streaming.test.tsx +739 -0
- package/src/components/assistant-ui/thread-list.tsx +307 -0
- package/src/components/assistant-ui/thread-virtualizer.tsx +512 -0
- package/src/components/assistant-ui/thread.tsx +1474 -0
- package/src/components/assistant-ui/todo-tool.tsx +109 -0
- package/src/components/assistant-ui/tool-approval-group.test.tsx +158 -0
- package/src/components/assistant-ui/tool-approval.test.tsx +81 -0
- package/src/components/assistant-ui/tool-approval.tsx +209 -0
- package/src/components/assistant-ui/tool-fallback-model.test.ts +66 -0
- package/src/components/assistant-ui/tool-fallback-model.ts +1368 -0
- package/src/components/assistant-ui/tool-fallback.tsx +466 -0
- package/src/components/assistant-ui/tooltip-icon-button.tsx +33 -0
- package/src/components/assistant-ui/user-message-edit.test.tsx +141 -0
- package/src/components/assistant-ui/user-message-text.tsx +150 -0
- package/src/components/boot-failure-overlay.tsx +246 -0
- package/src/components/boot-failure-reauth.test.ts +100 -0
- package/src/components/boot-failure-reauth.ts +81 -0
- package/src/components/brand-mark.tsx +19 -0
- package/src/components/chat/activity-timer-text.tsx +24 -0
- package/src/components/chat/activity-timer.test.tsx +43 -0
- package/src/components/chat/activity-timer.ts +64 -0
- package/src/components/chat/code-card.tsx +78 -0
- package/src/components/chat/compact-markdown.tsx +113 -0
- package/src/components/chat/composer-dock.ts +31 -0
- package/src/components/chat/diff-lines.tsx +54 -0
- package/src/components/chat/disclosure-row.tsx +63 -0
- package/src/components/chat/generated-image-context.tsx +19 -0
- package/src/components/chat/generated-image-result.tsx +174 -0
- package/src/components/chat/image-generation-placeholder.tsx +279 -0
- package/src/components/chat/intro-copy.jsonl +75 -0
- package/src/components/chat/intro.tsx +182 -0
- package/src/components/chat/preview-attachment.tsx +125 -0
- package/src/components/chat/shiki-highlighter.tsx +107 -0
- package/src/components/chat/status-row.tsx +70 -0
- package/src/components/chat/status-section.tsx +42 -0
- package/src/components/chat/terminal-output.tsx +50 -0
- package/src/components/chat/zoomable-image.tsx +177 -0
- package/src/components/desktop-install-overlay.tsx +595 -0
- package/src/components/desktop-onboarding-overlay.test.tsx +100 -0
- package/src/components/desktop-onboarding-overlay.tsx +1286 -0
- package/src/components/error-boundary.tsx +77 -0
- package/src/components/gateway-connecting-overlay.test.tsx +143 -0
- package/src/components/gateway-connecting-overlay.tsx +183 -0
- package/src/components/haptics-provider.tsx +19 -0
- package/src/components/language-switcher.test.tsx +53 -0
- package/src/components/language-switcher.tsx +175 -0
- package/src/components/model-picker.tsx +340 -0
- package/src/components/model-visibility-dialog.tsx +155 -0
- package/src/components/notifications.tsx +196 -0
- package/src/components/page-loader.tsx +34 -0
- package/src/components/pane-shell/context.ts +14 -0
- package/src/components/pane-shell/index.ts +4 -0
- package/src/components/pane-shell/pane-shell.test.tsx +333 -0
- package/src/components/pane-shell/pane-shell.tsx +330 -0
- package/src/components/prompt-overlays.tsx +234 -0
- package/src/components/session-picker.tsx +108 -0
- package/src/components/status-dot.tsx +26 -0
- package/src/components/ui/action-status.tsx +25 -0
- package/src/components/ui/alert.tsx +53 -0
- package/src/components/ui/badge.tsx +35 -0
- package/src/components/ui/braille-spinner.tsx +61 -0
- package/src/components/ui/button.tsx +81 -0
- package/src/components/ui/checkbox.tsx +27 -0
- package/src/components/ui/codicon.tsx +20 -0
- package/src/components/ui/command.tsx +111 -0
- package/src/components/ui/confirm-dialog.tsx +109 -0
- package/src/components/ui/context-menu.tsx +141 -0
- package/src/components/ui/control.ts +25 -0
- package/src/components/ui/copy-button.test.tsx +36 -0
- package/src/components/ui/copy-button.tsx +229 -0
- package/src/components/ui/dialog.tsx +152 -0
- package/src/components/ui/disclosure-caret.tsx +20 -0
- package/src/components/ui/dropdown-menu.tsx +291 -0
- package/src/components/ui/error-state.tsx +50 -0
- package/src/components/ui/fade-text.tsx +110 -0
- package/src/components/ui/glyph-spinner.tsx +63 -0
- package/src/components/ui/input.tsx +22 -0
- package/src/components/ui/kbd.tsx +37 -0
- package/src/components/ui/loader.tsx +558 -0
- package/src/components/ui/log-view.tsx +17 -0
- package/src/components/ui/pagination.tsx +114 -0
- package/src/components/ui/popover.tsx +44 -0
- package/src/components/ui/scroll-area.tsx +43 -0
- package/src/components/ui/search-field.tsx +80 -0
- package/src/components/ui/segmented-control.tsx +51 -0
- package/src/components/ui/select.tsx +92 -0
- package/src/components/ui/separator.tsx +26 -0
- package/src/components/ui/sheet.tsx +116 -0
- package/src/components/ui/sidebar.tsx +674 -0
- package/src/components/ui/skeleton.tsx +7 -0
- package/src/components/ui/switch.tsx +49 -0
- package/src/components/ui/tabs.tsx +36 -0
- package/src/components/ui/text-tab.tsx +43 -0
- package/src/components/ui/textarea.tsx +11 -0
- package/src/components/ui/tool-icon.tsx +65 -0
- package/src/components/ui/tooltip.tsx +69 -0
- package/src/fonts/JetBrainsMono-Bold.woff2 +0 -0
- package/src/fonts/JetBrainsMono-Italic.woff2 +0 -0
- package/src/fonts/JetBrainsMono-Regular.woff2 +0 -0
- package/src/global.d.ts +457 -0
- package/src/hooks/use-image-download.ts +85 -0
- package/src/hooks/use-media-query.ts +24 -0
- package/src/hooks/use-mobile.ts +3 -0
- package/src/hooks/use-resize-observer.ts +38 -0
- package/src/hooks/use-worktree-info.ts +68 -0
- package/src/i18n/catalog.ts +12 -0
- package/src/i18n/context.test.tsx +232 -0
- package/src/i18n/context.tsx +183 -0
- package/src/i18n/define-locale.ts +41 -0
- package/src/i18n/en.ts +1779 -0
- package/src/i18n/index.ts +20 -0
- package/src/i18n/ja.ts +1890 -0
- package/src/i18n/languages.test.ts +43 -0
- package/src/i18n/languages.ts +86 -0
- package/src/i18n/runtime.test.ts +75 -0
- package/src/i18n/runtime.ts +53 -0
- package/src/i18n/types.ts +1452 -0
- package/src/i18n/zh-hant.ts +1849 -0
- package/src/i18n/zh.ts +1923 -0
- package/src/lib/ansi.test.ts +123 -0
- package/src/lib/ansi.ts +175 -0
- package/src/lib/chat-messages.test.ts +708 -0
- package/src/lib/chat-messages.ts +885 -0
- package/src/lib/chat-runtime.test.ts +18 -0
- package/src/lib/chat-runtime.ts +335 -0
- package/src/lib/clipboard.ts +28 -0
- package/src/lib/commit-changelog.test.ts +114 -0
- package/src/lib/commit-changelog.ts +177 -0
- package/src/lib/completion-sound.ts +519 -0
- package/src/lib/desktop-fs.test.ts +116 -0
- package/src/lib/desktop-fs.ts +113 -0
- package/src/lib/desktop-slash-commands.test.ts +126 -0
- package/src/lib/desktop-slash-commands.ts +286 -0
- package/src/lib/embedded-images.test.ts +35 -0
- package/src/lib/embedded-images.ts +60 -0
- package/src/lib/external-link.test.tsx +168 -0
- package/src/lib/external-link.tsx +303 -0
- package/src/lib/gateway-events.test.ts +27 -0
- package/src/lib/gateway-events.ts +49 -0
- package/src/lib/gateway-ws-url.test.ts +78 -0
- package/src/lib/gateway-ws-url.ts +91 -0
- package/src/lib/generated-images.test.ts +97 -0
- package/src/lib/generated-images.ts +116 -0
- package/src/lib/haptics.ts +129 -0
- package/src/lib/icons.ts +203 -0
- package/src/lib/incremental-external-store-runtime.ts +188 -0
- package/src/lib/katex-memo.ts +260 -0
- package/src/lib/keybinds/actions.ts +125 -0
- package/src/lib/keybinds/combo.test.ts +86 -0
- package/src/lib/keybinds/combo.ts +169 -0
- package/src/lib/local-preview.ts +126 -0
- package/src/lib/markdown-code.test.ts +23 -0
- package/src/lib/markdown-code.ts +195 -0
- package/src/lib/markdown-preprocess.ts +386 -0
- package/src/lib/media.remote.test.ts +58 -0
- package/src/lib/media.ts +111 -0
- package/src/lib/model-status-label.test.ts +31 -0
- package/src/lib/model-status-label.ts +103 -0
- package/src/lib/mutable-ref.ts +6 -0
- package/src/lib/preview-targets.test.ts +27 -0
- package/src/lib/preview-targets.ts +63 -0
- package/src/lib/profile-color.ts +58 -0
- package/src/lib/provider-setup-errors.test.ts +26 -0
- package/src/lib/provider-setup-errors.ts +12 -0
- package/src/lib/query-client.ts +13 -0
- package/src/lib/remend-tail.test.ts +105 -0
- package/src/lib/remend-tail.ts +108 -0
- package/src/lib/runtime-readiness.test.ts +65 -0
- package/src/lib/runtime-readiness.ts +147 -0
- package/src/lib/session-export.ts +57 -0
- package/src/lib/session-search.test.ts +58 -0
- package/src/lib/session-search.ts +19 -0
- package/src/lib/session-source.ts +62 -0
- package/src/lib/speech-text.ts +35 -0
- package/src/lib/statusbar.ts +91 -0
- package/src/lib/storage.test.ts +25 -0
- package/src/lib/storage.ts +107 -0
- package/src/lib/todos.test.ts +35 -0
- package/src/lib/todos.ts +51 -0
- package/src/lib/tool-result-summary.test.ts +106 -0
- package/src/lib/tool-result-summary.ts +467 -0
- package/src/lib/update-copy.test.ts +38 -0
- package/src/lib/update-copy.ts +44 -0
- package/src/lib/use-enter-animation.ts +100 -0
- package/src/lib/utils.ts +6 -0
- package/src/lib/voice-playback.ts +128 -0
- package/src/lib/yolo-session.ts +26 -0
- package/src/main.tsx +43 -0
- package/src/nastech.test.ts +49 -0
- package/src/nastech.ts +718 -0
- package/src/store/activity.ts +100 -0
- package/src/store/boot.ts +91 -0
- package/src/store/clarify.test.ts +81 -0
- package/src/store/clarify.ts +69 -0
- package/src/store/command-palette.ts +20 -0
- package/src/store/compaction.test.ts +53 -0
- package/src/store/compaction.ts +38 -0
- package/src/store/completion-sound.ts +32 -0
- package/src/store/composer-input-history.test.ts +147 -0
- package/src/store/composer-input-history.ts +158 -0
- package/src/store/composer-queue.test.ts +148 -0
- package/src/store/composer-queue.ts +239 -0
- package/src/store/composer-status.test.ts +99 -0
- package/src/store/composer-status.ts +277 -0
- package/src/store/composer.test.ts +106 -0
- package/src/store/composer.ts +184 -0
- package/src/store/cron.ts +19 -0
- package/src/store/gateway.ts +290 -0
- package/src/store/haptics.ts +17 -0
- package/src/store/keybinds.ts +139 -0
- package/src/store/layout.ts +176 -0
- package/src/store/model-presets.test.ts +51 -0
- package/src/store/model-presets.ts +86 -0
- package/src/store/model-visibility.test.ts +37 -0
- package/src/store/model-visibility.ts +108 -0
- package/src/store/native-notifications.test.ts +192 -0
- package/src/store/native-notifications.ts +203 -0
- package/src/store/notifications.ts +165 -0
- package/src/store/onboarding.test.ts +372 -0
- package/src/store/onboarding.ts +866 -0
- package/src/store/panes.test.ts +146 -0
- package/src/store/panes.ts +145 -0
- package/src/store/preview.test.ts +135 -0
- package/src/store/preview.ts +466 -0
- package/src/store/profile.test.ts +89 -0
- package/src/store/profile.ts +365 -0
- package/src/store/prompts.test.ts +121 -0
- package/src/store/prompts.ts +115 -0
- package/src/store/session-switcher.test.ts +115 -0
- package/src/store/session-switcher.ts +128 -0
- package/src/store/session-sync.ts +25 -0
- package/src/store/session.test.ts +131 -0
- package/src/store/session.ts +255 -0
- package/src/store/subagents.test.ts +111 -0
- package/src/store/subagents.ts +260 -0
- package/src/store/thread-scroll.ts +46 -0
- package/src/store/todos.test.ts +47 -0
- package/src/store/todos.ts +64 -0
- package/src/store/tool-diffs.ts +23 -0
- package/src/store/tool-dismiss.ts +45 -0
- package/src/store/tool-view.ts +91 -0
- package/src/store/translucency.ts +38 -0
- package/src/store/updates.test.ts +77 -0
- package/src/store/updates.ts +315 -0
- package/src/store/voice-playback.ts +24 -0
- package/src/store/windows.test.ts +143 -0
- package/src/store/windows.ts +77 -0
- package/src/styles.css +1235 -0
- package/src/themes/color.ts +142 -0
- package/src/themes/context.tsx +339 -0
- package/src/themes/index.ts +3 -0
- package/src/themes/install.test.ts +119 -0
- package/src/themes/install.ts +95 -0
- package/src/themes/presets.test.ts +33 -0
- package/src/themes/presets.ts +293 -0
- package/src/themes/profile-theme.test.ts +41 -0
- package/src/themes/types.ts +66 -0
- package/src/themes/use-skin-command.ts +60 -0
- package/src/themes/user-themes.test.ts +63 -0
- package/src/themes/user-themes.ts +122 -0
- package/src/themes/vscode.test.ts +171 -0
- package/src/themes/vscode.ts +343 -0
- package/src/types/nastech.ts +646 -0
- package/src/vite-env.d.ts +1 -0
- package/tsconfig.json +25 -0
- package/vite.config.ts +56 -0
|
@@ -0,0 +1,1611 @@
|
|
|
1
|
+
import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core'
|
|
2
|
+
import { ComposerPrimitive, useAui, useAuiState } from '@assistant-ui/react'
|
|
3
|
+
import { useStore } from '@nanostores/react'
|
|
4
|
+
import {
|
|
5
|
+
type ClipboardEvent,
|
|
6
|
+
type FormEvent,
|
|
7
|
+
type KeyboardEvent,
|
|
8
|
+
type DragEvent as ReactDragEvent,
|
|
9
|
+
useCallback,
|
|
10
|
+
useEffect,
|
|
11
|
+
useMemo,
|
|
12
|
+
useRef,
|
|
13
|
+
useState
|
|
14
|
+
} from 'react'
|
|
15
|
+
|
|
16
|
+
import { NASTECHDirectiveFormatter } from '@/components/assistant-ui/directive-text'
|
|
17
|
+
import { Button } from '@/components/ui/button'
|
|
18
|
+
import { useMediaQuery } from '@/hooks/use-media-query'
|
|
19
|
+
import { useResizeObserver } from '@/hooks/use-resize-observer'
|
|
20
|
+
import { useI18n } from '@/i18n'
|
|
21
|
+
import { chatMessageText } from '@/lib/chat-messages'
|
|
22
|
+
import { SLASH_COMMAND_RE } from '@/lib/chat-runtime'
|
|
23
|
+
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
|
|
24
|
+
import { triggerHaptic } from '@/lib/haptics'
|
|
25
|
+
import { cn } from '@/lib/utils'
|
|
26
|
+
import { $composerAttachments, clearComposerAttachments, type ComposerAttachment } from '@/store/composer'
|
|
27
|
+
import {
|
|
28
|
+
browseBackward,
|
|
29
|
+
browseForward,
|
|
30
|
+
deriveUserHistory,
|
|
31
|
+
isBrowsingHistory,
|
|
32
|
+
resetBrowseState
|
|
33
|
+
} from '@/store/composer-input-history'
|
|
34
|
+
import {
|
|
35
|
+
$queuedPromptsBySession,
|
|
36
|
+
enqueueQueuedPrompt,
|
|
37
|
+
promoteQueuedPrompt,
|
|
38
|
+
type QueuedPromptEntry,
|
|
39
|
+
removeQueuedPrompt,
|
|
40
|
+
shouldAutoDrainOnSettle,
|
|
41
|
+
updateQueuedPrompt
|
|
42
|
+
} from '@/store/composer-queue'
|
|
43
|
+
import { $gatewayState, $messages } from '@/store/session'
|
|
44
|
+
import { $threadScrolledUp } from '@/store/thread-scroll'
|
|
45
|
+
|
|
46
|
+
import { extractDroppedFiles, NASTECH_PATHS_MIME } from '../hooks/use-composer-actions'
|
|
47
|
+
|
|
48
|
+
import { AttachmentList } from './attachments'
|
|
49
|
+
import { ContextMenu } from './context-menu'
|
|
50
|
+
import { ComposerControls } from './controls'
|
|
51
|
+
import { COMPOSER_DROP_ACTIVE_CLASS, COMPOSER_DROP_FADE_CLASS } from './drop-affordance'
|
|
52
|
+
import {
|
|
53
|
+
type ComposerInsertMode,
|
|
54
|
+
focusComposerInput,
|
|
55
|
+
markActiveComposer,
|
|
56
|
+
onComposerFocusRequest,
|
|
57
|
+
onComposerInsertRefsRequest,
|
|
58
|
+
onComposerInsertRequest
|
|
59
|
+
} from './focus'
|
|
60
|
+
import { HelpHint } from './help-hint'
|
|
61
|
+
import { useAtCompletions } from './hooks/use-at-completions'
|
|
62
|
+
import { useSlashCompletions } from './hooks/use-slash-completions'
|
|
63
|
+
import { useVoiceConversation } from './hooks/use-voice-conversation'
|
|
64
|
+
import { useVoiceRecorder } from './hooks/use-voice-recorder'
|
|
65
|
+
import {
|
|
66
|
+
dragHasAttachments,
|
|
67
|
+
droppedFileInlineRef,
|
|
68
|
+
type InlineRefInput,
|
|
69
|
+
insertInlineRefsIntoEditor
|
|
70
|
+
} from './inline-refs'
|
|
71
|
+
import { QueuePanel } from './queue-panel'
|
|
72
|
+
import {
|
|
73
|
+
composerPlainText,
|
|
74
|
+
placeCaretEnd,
|
|
75
|
+
refChipElement,
|
|
76
|
+
renderComposerContents,
|
|
77
|
+
RICH_INPUT_SLOT
|
|
78
|
+
} from './rich-editor'
|
|
79
|
+
import { SkinSlashPopover } from './skin-slash-popover'
|
|
80
|
+
import { detectTrigger, extractClipboardImageBlobs, textBeforeCaret, type TriggerState } from './text-utils'
|
|
81
|
+
import { ComposerTriggerPopover } from './trigger-popover'
|
|
82
|
+
import type { ChatBarProps } from './types'
|
|
83
|
+
import { UrlDialog } from './url-dialog'
|
|
84
|
+
import { VoiceActivity, VoicePlaybackActivity } from './voice-activity'
|
|
85
|
+
|
|
86
|
+
const COMPOSER_STACK_BREAKPOINT_PX = 320
|
|
87
|
+
|
|
88
|
+
// A single editor line is ~28px (--composer-input-min-height 1.625rem + 0.5rem
|
|
89
|
+
// vertical padding). Anything taller means the text wrapped to a second line,
|
|
90
|
+
// which is when the composer should expand to the stacked layout.
|
|
91
|
+
const COMPOSER_SINGLE_LINE_MAX_PX = 36
|
|
92
|
+
|
|
93
|
+
const COMPOSER_FADE_BACKGROUND =
|
|
94
|
+
'linear-gradient(to bottom, transparent, color-mix(in srgb, var(--dt-background) 10%, transparent))'
|
|
95
|
+
|
|
96
|
+
const pickPlaceholder = (pool: readonly string[]) => pool[Math.floor(Math.random() * pool.length)]
|
|
97
|
+
|
|
98
|
+
interface QueueEditState {
|
|
99
|
+
attachments: ComposerAttachment[]
|
|
100
|
+
draft: string
|
|
101
|
+
entryId: string
|
|
102
|
+
sessionKey: string
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const cloneAttachments = (attachments: ComposerAttachment[]) => attachments.map(a => ({ ...a }))
|
|
106
|
+
|
|
107
|
+
export function ChatBar({
|
|
108
|
+
busy,
|
|
109
|
+
cwd,
|
|
110
|
+
disabled,
|
|
111
|
+
focusKey,
|
|
112
|
+
gateway,
|
|
113
|
+
maxRecordingSeconds = 120,
|
|
114
|
+
queueSessionKey,
|
|
115
|
+
sessionId,
|
|
116
|
+
state,
|
|
117
|
+
onCancel,
|
|
118
|
+
onAddUrl,
|
|
119
|
+
onAttachDroppedItems,
|
|
120
|
+
onAttachImageBlob,
|
|
121
|
+
onPasteClipboardImage,
|
|
122
|
+
onPickFiles,
|
|
123
|
+
onPickFolders,
|
|
124
|
+
onPickImages,
|
|
125
|
+
onRemoveAttachment,
|
|
126
|
+
onSteer,
|
|
127
|
+
onSubmit,
|
|
128
|
+
onTranscribeAudio
|
|
129
|
+
}: ChatBarProps) {
|
|
130
|
+
const aui = useAui()
|
|
131
|
+
const draft = useAuiState(s => s.composer.text)
|
|
132
|
+
const attachments = useStore($composerAttachments)
|
|
133
|
+
const queuedPromptsBySession = useStore($queuedPromptsBySession)
|
|
134
|
+
const scrolledUp = useStore($threadScrolledUp)
|
|
135
|
+
const sessionMessages = useStore($messages)
|
|
136
|
+
const activeQueueSessionKey = queueSessionKey || sessionId || null
|
|
137
|
+
|
|
138
|
+
const queuedPrompts = useMemo(
|
|
139
|
+
() => (activeQueueSessionKey ? (queuedPromptsBySession[activeQueueSessionKey] ?? []) : []),
|
|
140
|
+
[activeQueueSessionKey, queuedPromptsBySession]
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
const composerRef = useRef<HTMLFormElement | null>(null)
|
|
144
|
+
const composerSurfaceRef = useRef<HTMLDivElement | null>(null)
|
|
145
|
+
const editorRef = useRef<HTMLDivElement | null>(null)
|
|
146
|
+
const draftRef = useRef(draft)
|
|
147
|
+
const previousBusyRef = useRef(busy)
|
|
148
|
+
const drainingQueueRef = useRef(false)
|
|
149
|
+
const urlInputRef = useRef<HTMLInputElement | null>(null)
|
|
150
|
+
|
|
151
|
+
const [urlOpen, setUrlOpen] = useState(false)
|
|
152
|
+
const [urlValue, setUrlValue] = useState('')
|
|
153
|
+
const [expanded, setExpanded] = useState(false)
|
|
154
|
+
const [voiceConversationActive, setVoiceConversationActive] = useState(false)
|
|
155
|
+
const [tight, setTight] = useState(false)
|
|
156
|
+
const [dragActive, setDragActive] = useState(false)
|
|
157
|
+
const [queueEdit, setQueueEdit] = useState<QueueEditState | null>(null)
|
|
158
|
+
const [focusRequestId, setFocusRequestId] = useState(0)
|
|
159
|
+
const dragDepthRef = useRef(0)
|
|
160
|
+
const composingRef = useRef(false) // true during IME composition (CJK input)
|
|
161
|
+
const lastSpokenIdRef = useRef<string | null>(null)
|
|
162
|
+
|
|
163
|
+
const narrow = useMediaQuery('(max-width: 30rem)')
|
|
164
|
+
|
|
165
|
+
const at = useAtCompletions({ gateway: gateway ?? null, sessionId: sessionId ?? null, cwd: cwd ?? null })
|
|
166
|
+
const slash = useSlashCompletions({ gateway: gateway ?? null })
|
|
167
|
+
|
|
168
|
+
const stacked = expanded || narrow || tight
|
|
169
|
+
const trimmedDraft = draft.trim()
|
|
170
|
+
const hasComposerPayload = trimmedDraft.length > 0 || attachments.length > 0
|
|
171
|
+
const canSubmit = busy || hasComposerPayload
|
|
172
|
+
const editingQueuedPrompt = queueEdit ? (queuedPrompts.find(entry => entry.id === queueEdit.entryId) ?? null) : null
|
|
173
|
+
const busyAction = busy && hasComposerPayload ? 'queue' : 'stop'
|
|
174
|
+
// Steer only makes sense mid-turn, text-only (the gateway can't carry images
|
|
175
|
+
// into a tool result) and never for a slash command (those execute inline).
|
|
176
|
+
const canSteer =
|
|
177
|
+
busy && !!onSteer && attachments.length === 0 && trimmedDraft.length > 0 && !SLASH_COMMAND_RE.test(trimmedDraft)
|
|
178
|
+
const showHelpHint = draft === '?'
|
|
179
|
+
|
|
180
|
+
const { t } = useI18n()
|
|
181
|
+
const gatewayState = useStore($gatewayState)
|
|
182
|
+
const newSessionPlaceholders = t.composer.newSessionPlaceholders
|
|
183
|
+
const followUpPlaceholders = t.composer.followUpPlaceholders
|
|
184
|
+
|
|
185
|
+
// Resting placeholder: a starter for brand-new sessions, a continuation for
|
|
186
|
+
// existing ones. Picked once and only re-rolled when we genuinely move to a
|
|
187
|
+
// *different* conversation. Critically, the first id assignment of a freshly
|
|
188
|
+
// started session (null → id, on the first send) is treated as the same
|
|
189
|
+
// conversation so the placeholder doesn't visibly flip mid-stream.
|
|
190
|
+
const [restingPlaceholder, setRestingPlaceholder] = useState(() =>
|
|
191
|
+
pickPlaceholder(sessionId ? followUpPlaceholders : newSessionPlaceholders)
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
const prevSessionIdRef = useRef(sessionId)
|
|
195
|
+
|
|
196
|
+
useEffect(() => {
|
|
197
|
+
const prev = prevSessionIdRef.current
|
|
198
|
+
prevSessionIdRef.current = sessionId
|
|
199
|
+
|
|
200
|
+
if (prev === sessionId) {
|
|
201
|
+
return
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// null → id: the new session we're already in just got persisted. Keep the
|
|
205
|
+
// starter we showed instead of swapping to a follow-up under the user.
|
|
206
|
+
if (prev == null && sessionId) {
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
resetBrowseState(prev)
|
|
211
|
+
setRestingPlaceholder(pickPlaceholder(sessionId ? followUpPlaceholders : newSessionPlaceholders))
|
|
212
|
+
}, [followUpPlaceholders, newSessionPlaceholders, sessionId])
|
|
213
|
+
|
|
214
|
+
// When the bar is disabled it's because the gateway isn't open. Distinguish a
|
|
215
|
+
// cold start ("Starting NasTech...") from a dropped connection we're trying to
|
|
216
|
+
// restore (e.g. after the Mac slept) so the stuck state reads as recoverable.
|
|
217
|
+
const placeholder = disabled
|
|
218
|
+
? gatewayState === 'closed' || gatewayState === 'error'
|
|
219
|
+
? t.composer.placeholderReconnecting
|
|
220
|
+
: t.composer.placeholderStarting
|
|
221
|
+
: restingPlaceholder
|
|
222
|
+
|
|
223
|
+
const focusInput = useCallback(() => {
|
|
224
|
+
focusComposerInput(editorRef.current)
|
|
225
|
+
markActiveComposer('main')
|
|
226
|
+
}, [])
|
|
227
|
+
|
|
228
|
+
const requestMainFocus = useCallback(() => {
|
|
229
|
+
setFocusRequestId(id => id + 1)
|
|
230
|
+
}, [])
|
|
231
|
+
|
|
232
|
+
const appendExternalText = useCallback(
|
|
233
|
+
(text: string, mode: ComposerInsertMode) => {
|
|
234
|
+
const value = text.trim()
|
|
235
|
+
|
|
236
|
+
if (!value) {
|
|
237
|
+
return
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const base = mode === 'inline' ? draftRef.current.trimEnd() : draftRef.current
|
|
241
|
+
const sep = mode === 'inline' ? (base ? ' ' : '') : base && !base.endsWith('\n') ? '\n\n' : ''
|
|
242
|
+
const next = `${base}${sep}${value}`
|
|
243
|
+
|
|
244
|
+
draftRef.current = next
|
|
245
|
+
aui.composer().setText(next)
|
|
246
|
+
|
|
247
|
+
const editor = editorRef.current
|
|
248
|
+
|
|
249
|
+
if (editor) {
|
|
250
|
+
renderComposerContents(editor, next)
|
|
251
|
+
placeCaretEnd(editor)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
setFocusRequestId(id => id + 1)
|
|
255
|
+
},
|
|
256
|
+
[aui]
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
useEffect(() => {
|
|
260
|
+
if (!disabled) {
|
|
261
|
+
focusInput()
|
|
262
|
+
}
|
|
263
|
+
}, [disabled, focusInput, focusKey, focusRequestId])
|
|
264
|
+
|
|
265
|
+
useEffect(() => {
|
|
266
|
+
if (disabled) {
|
|
267
|
+
return undefined
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const offFocus = onComposerFocusRequest(target => {
|
|
271
|
+
if (target === 'main') {
|
|
272
|
+
setFocusRequestId(id => id + 1)
|
|
273
|
+
}
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
const offInsert = onComposerInsertRequest(({ mode, target, text }) => {
|
|
277
|
+
if (target === 'main') {
|
|
278
|
+
appendExternalText(text, mode)
|
|
279
|
+
}
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
return () => {
|
|
283
|
+
offFocus()
|
|
284
|
+
offInsert()
|
|
285
|
+
}
|
|
286
|
+
}, [appendExternalText, disabled])
|
|
287
|
+
|
|
288
|
+
// Keep draftRef in sync with the assistant-ui composer state for callers
|
|
289
|
+
// that read the latest text outside the React render cycle. We don't push
|
|
290
|
+
// to `$composerDraft` per keystroke any more — nobody outside the composer
|
|
291
|
+
// subscribes to it (verified by grep), and the round-trip
|
|
292
|
+
// `setText` ⇄ `subscribe` ⇄ `setText` was adding two useEffects to the per-
|
|
293
|
+
// keystroke critical path. `reconcileComposerTerminalSelections` only
|
|
294
|
+
// matters when the draft is submitted; we now call it from the submit
|
|
295
|
+
// path instead.
|
|
296
|
+
useEffect(() => {
|
|
297
|
+
draftRef.current = draft
|
|
298
|
+
|
|
299
|
+
const editor = editorRef.current
|
|
300
|
+
|
|
301
|
+
if (editor && document.activeElement !== editor && composerPlainText(editor) !== draft) {
|
|
302
|
+
renderComposerContents(editor, draft)
|
|
303
|
+
}
|
|
304
|
+
}, [draft])
|
|
305
|
+
|
|
306
|
+
useEffect(() => {
|
|
307
|
+
if (urlOpen) {
|
|
308
|
+
window.requestAnimationFrame(() => urlInputRef.current?.focus({ preventScroll: true }))
|
|
309
|
+
}
|
|
310
|
+
}, [urlOpen])
|
|
311
|
+
|
|
312
|
+
// Expansion (input on its own full-width row, controls below) is driven by
|
|
313
|
+
// the editor's *actual* rendered height via the ResizeObserver in
|
|
314
|
+
// syncComposerMetrics — it only fires when the text genuinely wraps to a
|
|
315
|
+
// second line, so the layout flips exactly at the wrap point rather than at
|
|
316
|
+
// a guessed character count. We only handle the two cases the observer
|
|
317
|
+
// can't: an explicit newline (expand before layout settles) and an emptied
|
|
318
|
+
// draft (collapse back). We never read scrollHeight per keystroke.
|
|
319
|
+
useEffect(() => {
|
|
320
|
+
if (!draft) {
|
|
321
|
+
setExpanded(false)
|
|
322
|
+
|
|
323
|
+
return
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (expanded) {
|
|
327
|
+
return
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (draft.includes('\n')) {
|
|
331
|
+
setExpanded(true)
|
|
332
|
+
}
|
|
333
|
+
}, [draft, expanded])
|
|
334
|
+
|
|
335
|
+
// Bucket measured heights so we only invalidate the global CSS var when
|
|
336
|
+
// the size crosses a meaningful threshold. Without bucketing, the editor
|
|
337
|
+
// grows ~1px per character → setProperty fires every keystroke → entire
|
|
338
|
+
// tree's computed style is invalidated → next paint forces a full
|
|
339
|
+
// recalculate-style pass. With an 8px bucket, the invalidation rate drops
|
|
340
|
+
// ~8× and small char-by-char typing produces no style invalidation at all
|
|
341
|
+
// until a wrap or row change actually happens.
|
|
342
|
+
const lastBucketedHeightRef = useRef(0)
|
|
343
|
+
const lastBucketedSurfaceHeightRef = useRef(0)
|
|
344
|
+
const lastTightRef = useRef<boolean | null>(null)
|
|
345
|
+
|
|
346
|
+
const syncComposerMetrics = useCallback(() => {
|
|
347
|
+
const composer = composerRef.current
|
|
348
|
+
|
|
349
|
+
if (!composer) {
|
|
350
|
+
return
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const { height, width } = composer.getBoundingClientRect()
|
|
354
|
+
const surfaceHeight = composerSurfaceRef.current?.getBoundingClientRect().height
|
|
355
|
+
const root = document.documentElement
|
|
356
|
+
|
|
357
|
+
if (width > 0) {
|
|
358
|
+
const nextTight = width < COMPOSER_STACK_BREAKPOINT_PX
|
|
359
|
+
|
|
360
|
+
if (nextTight !== lastTightRef.current) {
|
|
361
|
+
lastTightRef.current = nextTight
|
|
362
|
+
setTight(nextTight)
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Expand once the input has actually wrapped past a single line. The
|
|
367
|
+
// observer only fires on real size changes, so this reads scrollHeight at
|
|
368
|
+
// most once per wrap (not per keystroke). One line ≈ 28px (1.625rem
|
|
369
|
+
// min-height + padding); a second line clears ~36px. We only ever expand
|
|
370
|
+
// here — collapse is handled by the emptied-draft effect to avoid
|
|
371
|
+
// oscillating across the wrap boundary as the input switches widths.
|
|
372
|
+
const editor = editorRef.current
|
|
373
|
+
|
|
374
|
+
if (editor && editor.scrollHeight > COMPOSER_SINGLE_LINE_MAX_PX) {
|
|
375
|
+
setExpanded(true)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (height > 0) {
|
|
379
|
+
const bucket = Math.round(height / 8) * 8
|
|
380
|
+
|
|
381
|
+
if (bucket !== lastBucketedHeightRef.current) {
|
|
382
|
+
lastBucketedHeightRef.current = bucket
|
|
383
|
+
root.style.setProperty('--composer-measured-height', `${bucket}px`)
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (surfaceHeight && surfaceHeight > 0) {
|
|
388
|
+
const bucket = Math.round(surfaceHeight / 8) * 8
|
|
389
|
+
|
|
390
|
+
if (bucket !== lastBucketedSurfaceHeightRef.current) {
|
|
391
|
+
lastBucketedSurfaceHeightRef.current = bucket
|
|
392
|
+
root.style.setProperty('--composer-surface-measured-height', `${bucket}px`)
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}, [])
|
|
396
|
+
|
|
397
|
+
useResizeObserver(syncComposerMetrics, composerRef, composerSurfaceRef, editorRef)
|
|
398
|
+
|
|
399
|
+
useEffect(() => {
|
|
400
|
+
return () => {
|
|
401
|
+
const root = document.documentElement
|
|
402
|
+
root.style.removeProperty('--composer-measured-height')
|
|
403
|
+
root.style.removeProperty('--composer-surface-measured-height')
|
|
404
|
+
}
|
|
405
|
+
}, [])
|
|
406
|
+
|
|
407
|
+
const insertText = (text: string) => {
|
|
408
|
+
const currentDraft = draftRef.current
|
|
409
|
+
const sep = currentDraft && !currentDraft.endsWith('\n') ? '\n' : ''
|
|
410
|
+
const nextDraft = `${currentDraft}${sep}${text}`
|
|
411
|
+
|
|
412
|
+
draftRef.current = nextDraft
|
|
413
|
+
aui.composer().setText(nextDraft)
|
|
414
|
+
|
|
415
|
+
// Push the new text into the contentEditable editor directly. Setting the
|
|
416
|
+
// assistant-ui composer state alone is not enough: the draft→editor sync
|
|
417
|
+
// effect only re-renders the editor when it is NOT focused
|
|
418
|
+
// (document.activeElement !== editor), and the dictation/insert paths
|
|
419
|
+
// typically run while the editor has (or immediately regains) focus — so
|
|
420
|
+
// the store would hold the text but the visible editor would stay empty
|
|
421
|
+
// and there'd be nothing to send. Mirror appendExternalText here.
|
|
422
|
+
const editor = editorRef.current
|
|
423
|
+
|
|
424
|
+
if (editor) {
|
|
425
|
+
renderComposerContents(editor, nextDraft)
|
|
426
|
+
placeCaretEnd(editor)
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
requestMainFocus()
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const insertInlineRefs = (refs: InlineRefInput[]) => {
|
|
433
|
+
const editor = editorRef.current
|
|
434
|
+
|
|
435
|
+
if (!editor) {
|
|
436
|
+
return false
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const nextDraft = insertInlineRefsIntoEditor(editor, refs)
|
|
440
|
+
|
|
441
|
+
if (nextDraft === null) {
|
|
442
|
+
return false
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
draftRef.current = nextDraft
|
|
446
|
+
aui.composer().setText(nextDraft)
|
|
447
|
+
requestMainFocus()
|
|
448
|
+
|
|
449
|
+
return true
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Latest-closure ref so the (once-only) subscription always calls the current
|
|
453
|
+
// insertInlineRefs without re-subscribing every render.
|
|
454
|
+
const insertInlineRefsRef = useRef(insertInlineRefs)
|
|
455
|
+
insertInlineRefsRef.current = insertInlineRefs
|
|
456
|
+
|
|
457
|
+
useEffect(() => {
|
|
458
|
+
return onComposerInsertRefsRequest(({ refs, target }) => {
|
|
459
|
+
if (target === 'main') {
|
|
460
|
+
insertInlineRefsRef.current(refs)
|
|
461
|
+
}
|
|
462
|
+
})
|
|
463
|
+
}, [])
|
|
464
|
+
|
|
465
|
+
const selectSkinSlashCommand = (command: string) => {
|
|
466
|
+
draftRef.current = command
|
|
467
|
+
aui.composer().setText(command)
|
|
468
|
+
requestMainFocus()
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => {
|
|
472
|
+
const imageBlobs = extractClipboardImageBlobs(event.clipboardData)
|
|
473
|
+
|
|
474
|
+
if (imageBlobs.length > 0) {
|
|
475
|
+
event.preventDefault()
|
|
476
|
+
|
|
477
|
+
if (onAttachImageBlob) {
|
|
478
|
+
triggerHaptic('selection')
|
|
479
|
+
|
|
480
|
+
for (const blob of imageBlobs) {
|
|
481
|
+
void onAttachImageBlob(blob)
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
return
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Trim surrounding whitespace so a copy that dragged along leading/trailing
|
|
489
|
+
// blank lines (common when selecting from terminals, code blocks, web pages)
|
|
490
|
+
// doesn't dump multiline padding into the composer. Internal newlines are
|
|
491
|
+
// preserved — only the edges are cleaned up.
|
|
492
|
+
const pastedText = event.clipboardData.getData('text').trim()
|
|
493
|
+
|
|
494
|
+
if (!pastedText) {
|
|
495
|
+
event.preventDefault()
|
|
496
|
+
|
|
497
|
+
return
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if (DATA_IMAGE_URL_RE.test(pastedText)) {
|
|
501
|
+
event.preventDefault()
|
|
502
|
+
|
|
503
|
+
return
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
event.preventDefault()
|
|
507
|
+
document.execCommand('insertText', false, pastedText)
|
|
508
|
+
const nextDraft = composerPlainText(event.currentTarget)
|
|
509
|
+
draftRef.current = nextDraft
|
|
510
|
+
aui.composer().setText(nextDraft)
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const [trigger, setTrigger] = useState<TriggerState | null>(null)
|
|
514
|
+
const [triggerActive, setTriggerActive] = useState(0)
|
|
515
|
+
const [triggerItems, setTriggerItems] = useState<readonly Unstable_TriggerItem[]>([])
|
|
516
|
+
// Set synchronously in keydown when the open trigger popover consumes a
|
|
517
|
+
// navigation/control key (Arrow/Enter/Tab/Escape). The subsequent keyup must
|
|
518
|
+
// NOT run refreshTrigger for that keypress: it never edits text, and for
|
|
519
|
+
// Escape the keydown has already set trigger=null, so a keyup refresh would
|
|
520
|
+
// re-detect the still-present `/` and instantly reopen the menu. A ref is
|
|
521
|
+
// used instead of reading `trigger` in keyup because by keyup time React has
|
|
522
|
+
// re-rendered and the handler closure sees the post-keydown state.
|
|
523
|
+
const triggerKeyConsumedRef = useRef(false)
|
|
524
|
+
|
|
525
|
+
const refreshTrigger = useCallback(() => {
|
|
526
|
+
const editor = editorRef.current
|
|
527
|
+
|
|
528
|
+
if (!editor) {
|
|
529
|
+
return
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// Fast-bail: if neither `@` nor `/` appears in the current draft, there's
|
|
533
|
+
// nothing for `detectTrigger` to match. Use `textContent` (cheap browser-
|
|
534
|
+
// native walk) for the precondition check rather than `composerPlainText`
|
|
535
|
+
// (recursive child walk with chip-aware logic). Only when a trigger char
|
|
536
|
+
// is present do we pay the cost of the full walk + DOM range work.
|
|
537
|
+
const rawText = editor.textContent ?? ''
|
|
538
|
+
|
|
539
|
+
if (!rawText.includes('@') && !rawText.includes('/')) {
|
|
540
|
+
if (trigger) {
|
|
541
|
+
setTrigger(null)
|
|
542
|
+
setTriggerActive(0)
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
return
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const before = textBeforeCaret(editor)
|
|
549
|
+
const detected = detectTrigger(before ?? composerPlainText(editor))
|
|
550
|
+
|
|
551
|
+
setTrigger(detected)
|
|
552
|
+
|
|
553
|
+
// Only reset the highlight when the trigger actually changed (opened, or
|
|
554
|
+
// the query/kind differs). Re-detecting the *same* trigger — e.g. on a
|
|
555
|
+
// caret move (mouseup) or a stray refresh — must preserve the user's
|
|
556
|
+
// current selection instead of snapping back to the first item.
|
|
557
|
+
if (detected?.kind !== trigger?.kind || detected?.query !== trigger?.query) {
|
|
558
|
+
setTriggerActive(0)
|
|
559
|
+
}
|
|
560
|
+
}, [trigger])
|
|
561
|
+
|
|
562
|
+
// Pull the live contentEditable text into draftRef + the AUI composer state
|
|
563
|
+
// (which drives `hasComposerPayload` → the send button). Shared by the input
|
|
564
|
+
// and compositionend paths so committed IME text reaches state through either.
|
|
565
|
+
const flushEditorToDraft = (editor: HTMLDivElement) => {
|
|
566
|
+
if (editor.childNodes.length === 1 && editor.firstChild?.nodeName === 'BR') {
|
|
567
|
+
editor.replaceChildren()
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const nextDraft = composerPlainText(editor)
|
|
571
|
+
|
|
572
|
+
if (nextDraft !== draftRef.current) {
|
|
573
|
+
draftRef.current = nextDraft
|
|
574
|
+
aui.composer().setText(nextDraft)
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
window.setTimeout(refreshTrigger, 0)
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const handleEditorInput = (event: FormEvent<HTMLDivElement>) => {
|
|
581
|
+
// During IME composition the DOM contains uncommitted preedit text
|
|
582
|
+
// mixed with real content. Skip state writes — compositionend flushes
|
|
583
|
+
// the finalized text (see onCompositionEnd).
|
|
584
|
+
if (composingRef.current) {
|
|
585
|
+
return
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
flushEditorToDraft(event.currentTarget)
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const triggerAdapter: Unstable_TriggerAdapter | null =
|
|
592
|
+
trigger?.kind === '@' ? at.adapter : trigger?.kind === '/' ? slash.adapter : null
|
|
593
|
+
|
|
594
|
+
useEffect(() => {
|
|
595
|
+
if (!trigger || !triggerAdapter?.search) {
|
|
596
|
+
setTriggerItems([])
|
|
597
|
+
|
|
598
|
+
return
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
setTriggerItems(triggerAdapter.search(trigger.query))
|
|
602
|
+
}, [trigger, triggerAdapter])
|
|
603
|
+
|
|
604
|
+
const triggerLoading = trigger?.kind === '@' ? at.loading : trigger?.kind === '/' ? slash.loading : false
|
|
605
|
+
|
|
606
|
+
const closeTrigger = () => {
|
|
607
|
+
setTrigger(null)
|
|
608
|
+
setTriggerItems([])
|
|
609
|
+
setTriggerActive(0)
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
useEffect(() => {
|
|
613
|
+
setTriggerActive(idx => Math.min(idx, Math.max(0, triggerItems.length - 1)))
|
|
614
|
+
}, [triggerItems.length])
|
|
615
|
+
|
|
616
|
+
const replaceTriggerWithChip = (item: Unstable_TriggerItem) => {
|
|
617
|
+
const editor = editorRef.current
|
|
618
|
+
|
|
619
|
+
if (!editor || !trigger) {
|
|
620
|
+
return
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
const serialized = NASTECHDirectiveFormatter.serialize(item)
|
|
624
|
+
const starter = serialized.endsWith(':')
|
|
625
|
+
const text = starter || serialized.endsWith(' ') ? serialized : `${serialized} `
|
|
626
|
+
const directive = !starter && serialized.match(/^@([^:]+):(.+)$/)
|
|
627
|
+
|
|
628
|
+
const finish = () => {
|
|
629
|
+
draftRef.current = composerPlainText(editor)
|
|
630
|
+
aui.composer().setText(draftRef.current)
|
|
631
|
+
requestMainFocus()
|
|
632
|
+
starter ? window.setTimeout(refreshTrigger, 0) : closeTrigger()
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
const sel = window.getSelection()
|
|
636
|
+
const range = sel?.rangeCount ? sel.getRangeAt(0) : null
|
|
637
|
+
const node = range?.startContainer
|
|
638
|
+
const offset = range?.startOffset ?? 0
|
|
639
|
+
|
|
640
|
+
if (!sel || !range || node?.nodeType !== Node.TEXT_NODE || offset < trigger.tokenLength) {
|
|
641
|
+
const current = composerPlainText(editor)
|
|
642
|
+
renderComposerContents(editor, `${current.slice(0, Math.max(0, current.length - trigger.tokenLength))}${text}`)
|
|
643
|
+
placeCaretEnd(editor)
|
|
644
|
+
|
|
645
|
+
return finish()
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
const replaceRange = document.createRange()
|
|
649
|
+
replaceRange.setStart(node, offset - trigger.tokenLength)
|
|
650
|
+
replaceRange.setEnd(node, offset)
|
|
651
|
+
replaceRange.deleteContents()
|
|
652
|
+
|
|
653
|
+
if (directive) {
|
|
654
|
+
const chip = refChipElement(directive[1], directive[2])
|
|
655
|
+
const space = document.createTextNode(' ')
|
|
656
|
+
const fragment = document.createDocumentFragment()
|
|
657
|
+
fragment.append(chip, space)
|
|
658
|
+
replaceRange.insertNode(fragment)
|
|
659
|
+
|
|
660
|
+
const caret = document.createRange()
|
|
661
|
+
caret.setStart(space, 1)
|
|
662
|
+
caret.collapse(true)
|
|
663
|
+
sel.removeAllRanges()
|
|
664
|
+
sel.addRange(caret)
|
|
665
|
+
|
|
666
|
+
return finish()
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
document.execCommand('insertText', false, text)
|
|
670
|
+
finish()
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
const handleEditorKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
|
674
|
+
// IME composition: Enter confirms composed text, not a message submission.
|
|
675
|
+
// We check both composingRef (set by compositionstart/compositionend, robust
|
|
676
|
+
// across browsers) and nativeEvent.isComposing (Chromium fallback). Without
|
|
677
|
+
// this guard, pressing Enter to finalise a Korean/Japanese/Chinese IME
|
|
678
|
+
// preedit fires submitDraft() and splits the message mid-word.
|
|
679
|
+
if (composingRef.current || event.nativeEvent.isComposing) {
|
|
680
|
+
return
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// Cmd/Ctrl+Shift+K drains the next queued message. Plain Cmd/Ctrl+K is
|
|
684
|
+
// reserved for the global command palette.
|
|
685
|
+
if ((event.metaKey || event.ctrlKey) && !event.altKey && event.shiftKey && event.key.toLowerCase() === 'k') {
|
|
686
|
+
event.preventDefault()
|
|
687
|
+
|
|
688
|
+
if (!busy) {
|
|
689
|
+
void drainNextQueued()
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
return
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
if (trigger && triggerItems.length > 0) {
|
|
696
|
+
if (event.key === 'ArrowDown') {
|
|
697
|
+
event.preventDefault()
|
|
698
|
+
triggerKeyConsumedRef.current = true
|
|
699
|
+
setTriggerActive(idx => (idx + 1) % triggerItems.length)
|
|
700
|
+
|
|
701
|
+
return
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
if (event.key === 'ArrowUp') {
|
|
705
|
+
event.preventDefault()
|
|
706
|
+
triggerKeyConsumedRef.current = true
|
|
707
|
+
setTriggerActive(idx => (idx - 1 + triggerItems.length) % triggerItems.length)
|
|
708
|
+
|
|
709
|
+
return
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
if (event.key === 'Enter' || event.key === 'Tab') {
|
|
713
|
+
event.preventDefault()
|
|
714
|
+
triggerKeyConsumedRef.current = true
|
|
715
|
+
const item = triggerItems[triggerActive]
|
|
716
|
+
|
|
717
|
+
if (item) {
|
|
718
|
+
replaceTriggerWithChip(item)
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
return
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
if (event.key === 'Escape') {
|
|
725
|
+
event.preventDefault()
|
|
726
|
+
triggerKeyConsumedRef.current = true
|
|
727
|
+
closeTrigger()
|
|
728
|
+
|
|
729
|
+
return
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// ArrowUp/ArrowDown navigate, in priority order: the queue (edit entries in
|
|
734
|
+
// place) then sent-message history. The history ring is derived from live
|
|
735
|
+
// session messages each press — single source of truth, no mirror.
|
|
736
|
+
if (event.key === 'ArrowUp') {
|
|
737
|
+
const currentDraft = draftRef.current
|
|
738
|
+
|
|
739
|
+
// Editing a queued turn → walk to the older entry.
|
|
740
|
+
if (queueEdit && stepQueuedEdit(-1)) {
|
|
741
|
+
event.preventDefault()
|
|
742
|
+
triggerKeyConsumedRef.current = true
|
|
743
|
+
|
|
744
|
+
return
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// Empty composer + a queued turn → open the newest queued entry for edit
|
|
748
|
+
// (the row's pencil), not a text recall. Enter saves it back to the queue.
|
|
749
|
+
if (!currentDraft.trim() && !queueEdit && queuedPrompts.length > 0) {
|
|
750
|
+
event.preventDefault()
|
|
751
|
+
triggerKeyConsumedRef.current = true
|
|
752
|
+
beginQueuedEdit(queuedPrompts[queuedPrompts.length - 1]!)
|
|
753
|
+
|
|
754
|
+
return
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// Don't hijack a typed draft unless already browsing — they'd lose it.
|
|
758
|
+
if (currentDraft.trim() && !isBrowsingHistory(sessionId)) {
|
|
759
|
+
return
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
event.preventDefault()
|
|
763
|
+
triggerKeyConsumedRef.current = true
|
|
764
|
+
|
|
765
|
+
const history = deriveUserHistory(sessionMessages, chatMessageText)
|
|
766
|
+
const entry = browseBackward(sessionId, currentDraft, history)
|
|
767
|
+
|
|
768
|
+
if (entry !== null) {
|
|
769
|
+
loadIntoComposer(entry, $composerAttachments.get())
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
return
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
if (event.key === 'ArrowDown') {
|
|
776
|
+
// Editing a queued turn → walk to the newer entry (past the newest exits).
|
|
777
|
+
if (queueEdit) {
|
|
778
|
+
event.preventDefault()
|
|
779
|
+
triggerKeyConsumedRef.current = true
|
|
780
|
+
stepQueuedEdit(1)
|
|
781
|
+
|
|
782
|
+
return
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// Browsing sent history → step toward the present, restoring the draft.
|
|
786
|
+
if (isBrowsingHistory(sessionId)) {
|
|
787
|
+
event.preventDefault()
|
|
788
|
+
triggerKeyConsumedRef.current = true
|
|
789
|
+
|
|
790
|
+
const history = deriveUserHistory(sessionMessages, chatMessageText)
|
|
791
|
+
const result = browseForward(sessionId, history)
|
|
792
|
+
|
|
793
|
+
if (result !== null) {
|
|
794
|
+
loadIntoComposer(result.text, $composerAttachments.get())
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
return
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// Cmd/Ctrl+Enter is reserved for steering the live run — never a send.
|
|
802
|
+
// Steer when there's a steerable draft, otherwise swallow it so it can't
|
|
803
|
+
// surprise-send. (Plain Enter still queues while busy / sends when idle.)
|
|
804
|
+
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey) && !event.shiftKey) {
|
|
805
|
+
event.preventDefault()
|
|
806
|
+
|
|
807
|
+
if (canSteer) {
|
|
808
|
+
steerDraft()
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
return
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
if (event.key === 'Enter' && !event.shiftKey) {
|
|
815
|
+
event.preventDefault()
|
|
816
|
+
|
|
817
|
+
if (!busy && !hasComposerPayload && queuedPrompts.length > 0) {
|
|
818
|
+
void drainNextQueued()
|
|
819
|
+
|
|
820
|
+
return
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// Empty Enter while busy is a no-op — interrupting is explicit (Stop/Esc),
|
|
824
|
+
// never a stray Enter after sending. With a payload, submitDraft queues it.
|
|
825
|
+
if (busy && !hasComposerPayload) {
|
|
826
|
+
return
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
submitDraft()
|
|
830
|
+
|
|
831
|
+
return
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
if (event.key === 'Escape') {
|
|
835
|
+
// Editing a queued turn → Esc cancels the edit, restoring the prior draft.
|
|
836
|
+
if (queueEdit) {
|
|
837
|
+
event.preventDefault()
|
|
838
|
+
exitQueuedEdit('cancel')
|
|
839
|
+
|
|
840
|
+
return
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// Otherwise Esc interrupts the running turn (Stop-button parity).
|
|
844
|
+
if (busy) {
|
|
845
|
+
event.preventDefault()
|
|
846
|
+
triggerHaptic('cancel')
|
|
847
|
+
void Promise.resolve(onCancel())
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
const handleEditorKeyUp = () => {
|
|
853
|
+
// If this keyup belongs to a key the open trigger popover already consumed
|
|
854
|
+
// in keydown (Arrow/Enter/Tab/Escape), skip the refresh. Those keys never
|
|
855
|
+
// edit text, and for Escape the keydown already closed the menu — a refresh
|
|
856
|
+
// here would re-detect the still-present `/` and instantly reopen it. We
|
|
857
|
+
// read a ref set during keydown rather than `trigger`, because by keyup
|
|
858
|
+
// time React has re-rendered and `trigger` may already be null.
|
|
859
|
+
if (triggerKeyConsumedRef.current) {
|
|
860
|
+
triggerKeyConsumedRef.current = false
|
|
861
|
+
|
|
862
|
+
return
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
window.setTimeout(refreshTrigger, 0)
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
const resetDragState = () => {
|
|
869
|
+
dragDepthRef.current = 0
|
|
870
|
+
setDragActive(false)
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
const handleDragEnter = (event: ReactDragEvent<HTMLFormElement>) => {
|
|
874
|
+
if (!onAttachDroppedItems || !dragHasAttachments(event.dataTransfer, NASTECH_PATHS_MIME)) {
|
|
875
|
+
return
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
event.preventDefault()
|
|
879
|
+
dragDepthRef.current += 1
|
|
880
|
+
|
|
881
|
+
if (!dragActive) {
|
|
882
|
+
setDragActive(true)
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
const handleDragOver = (event: ReactDragEvent<HTMLFormElement>) => {
|
|
887
|
+
if (!onAttachDroppedItems || !dragHasAttachments(event.dataTransfer, NASTECH_PATHS_MIME)) {
|
|
888
|
+
return
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
event.preventDefault()
|
|
892
|
+
event.dataTransfer.dropEffect = 'copy'
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
const handleDragLeave = (event: ReactDragEvent<HTMLFormElement>) => {
|
|
896
|
+
if (!onAttachDroppedItems) {
|
|
897
|
+
return
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
event.preventDefault()
|
|
901
|
+
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1)
|
|
902
|
+
|
|
903
|
+
if (dragDepthRef.current === 0) {
|
|
904
|
+
setDragActive(false)
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
const handleDrop = (event: ReactDragEvent<HTMLFormElement>) => {
|
|
909
|
+
if (!onAttachDroppedItems) {
|
|
910
|
+
return
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
event.preventDefault()
|
|
914
|
+
resetDragState()
|
|
915
|
+
|
|
916
|
+
const candidates = extractDroppedFiles(event.dataTransfer)
|
|
917
|
+
|
|
918
|
+
if (candidates.length === 0) {
|
|
919
|
+
return
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
if (Array.from(event.dataTransfer.types || []).includes(NASTECH_PATHS_MIME)) {
|
|
923
|
+
const refs = candidates
|
|
924
|
+
.map(candidate => droppedFileInlineRef(candidate, cwd))
|
|
925
|
+
.filter((ref): ref is string => Boolean(ref))
|
|
926
|
+
|
|
927
|
+
if (insertInlineRefs(refs)) {
|
|
928
|
+
triggerHaptic('selection')
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
return
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
void Promise.resolve(onAttachDroppedItems(candidates)).then(attached => {
|
|
935
|
+
if (attached) {
|
|
936
|
+
triggerHaptic('selection')
|
|
937
|
+
requestMainFocus()
|
|
938
|
+
}
|
|
939
|
+
})
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
const handleInputDragOver = (event: ReactDragEvent<HTMLDivElement>) => {
|
|
943
|
+
if (!dragHasAttachments(event.dataTransfer, NASTECH_PATHS_MIME)) {
|
|
944
|
+
return
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
event.preventDefault()
|
|
948
|
+
event.stopPropagation()
|
|
949
|
+
event.dataTransfer.dropEffect = 'copy'
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
const handleInputDrop = (event: ReactDragEvent<HTMLDivElement>) => {
|
|
953
|
+
if (!dragHasAttachments(event.dataTransfer, NASTECH_PATHS_MIME)) {
|
|
954
|
+
return
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
const candidates = extractDroppedFiles(event.dataTransfer)
|
|
958
|
+
|
|
959
|
+
const refs = candidates
|
|
960
|
+
.map(candidate => droppedFileInlineRef(candidate, cwd))
|
|
961
|
+
.filter((ref): ref is string => Boolean(ref))
|
|
962
|
+
|
|
963
|
+
if (!refs.length) {
|
|
964
|
+
return
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
event.preventDefault()
|
|
968
|
+
event.stopPropagation()
|
|
969
|
+
resetDragState()
|
|
970
|
+
|
|
971
|
+
if (insertInlineRefs(refs)) {
|
|
972
|
+
triggerHaptic('selection')
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
const clearDraft = useCallback(() => {
|
|
977
|
+
aui.composer().setText('')
|
|
978
|
+
draftRef.current = ''
|
|
979
|
+
|
|
980
|
+
if (editorRef.current) {
|
|
981
|
+
editorRef.current.replaceChildren()
|
|
982
|
+
}
|
|
983
|
+
}, [aui])
|
|
984
|
+
|
|
985
|
+
const loadIntoComposer = (text: string, attachments: ComposerAttachment[]) => {
|
|
986
|
+
draftRef.current = text
|
|
987
|
+
aui.composer().setText(text)
|
|
988
|
+
$composerAttachments.set(cloneAttachments(attachments))
|
|
989
|
+
|
|
990
|
+
const editor = editorRef.current
|
|
991
|
+
|
|
992
|
+
if (editor) {
|
|
993
|
+
renderComposerContents(editor, text)
|
|
994
|
+
placeCaretEnd(editor)
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
const beginQueuedEdit = (entry: QueuedPromptEntry) => {
|
|
999
|
+
if (!activeQueueSessionKey || queueEdit) {
|
|
1000
|
+
return
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
setQueueEdit({
|
|
1004
|
+
attachments: cloneAttachments($composerAttachments.get()),
|
|
1005
|
+
draft: draftRef.current,
|
|
1006
|
+
entryId: entry.id,
|
|
1007
|
+
sessionKey: activeQueueSessionKey
|
|
1008
|
+
})
|
|
1009
|
+
loadIntoComposer(entry.text, entry.attachments)
|
|
1010
|
+
triggerHaptic('selection')
|
|
1011
|
+
focusInput()
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
// Walk queued entries while editing (ArrowUp = older, ArrowDown = newer),
|
|
1015
|
+
// saving the in-progress edit on each step. Stepping newer past the last
|
|
1016
|
+
// entry exits edit mode and restores the pre-edit draft.
|
|
1017
|
+
const stepQueuedEdit = (direction: -1 | 1) => {
|
|
1018
|
+
if (!queueEdit) {
|
|
1019
|
+
return false
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
const index = queuedPrompts.findIndex(e => e.id === queueEdit.entryId)
|
|
1023
|
+
const target = index + direction
|
|
1024
|
+
|
|
1025
|
+
if (index < 0 || target < 0) {
|
|
1026
|
+
return index >= 0 // at the oldest: swallow; missing entry: let it fall through
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
const saved = updateQueuedPrompt(queueEdit.sessionKey, queueEdit.entryId, {
|
|
1030
|
+
attachments: cloneAttachments($composerAttachments.get()),
|
|
1031
|
+
text: draftRef.current
|
|
1032
|
+
})
|
|
1033
|
+
|
|
1034
|
+
const next = queuedPrompts[target]
|
|
1035
|
+
|
|
1036
|
+
if (next) {
|
|
1037
|
+
setQueueEdit({ ...queueEdit, entryId: next.id })
|
|
1038
|
+
loadIntoComposer(next.text, next.attachments)
|
|
1039
|
+
} else {
|
|
1040
|
+
setQueueEdit(null)
|
|
1041
|
+
loadIntoComposer(queueEdit.draft, queueEdit.attachments)
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
triggerHaptic(saved ? 'success' : 'selection')
|
|
1045
|
+
focusInput()
|
|
1046
|
+
|
|
1047
|
+
return true
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
const exitQueuedEdit = (action: 'cancel' | 'save'): boolean => {
|
|
1051
|
+
if (!queueEdit) {
|
|
1052
|
+
return false
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
if (action === 'save') {
|
|
1056
|
+
const text = draftRef.current
|
|
1057
|
+
const next = cloneAttachments($composerAttachments.get())
|
|
1058
|
+
|
|
1059
|
+
if (!text.trim() && next.length === 0) {
|
|
1060
|
+
return false
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
const saved = updateQueuedPrompt(queueEdit.sessionKey, queueEdit.entryId, { attachments: next, text })
|
|
1064
|
+
triggerHaptic(saved ? 'success' : 'selection')
|
|
1065
|
+
} else {
|
|
1066
|
+
triggerHaptic('cancel')
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
loadIntoComposer(queueEdit.draft, queueEdit.attachments)
|
|
1070
|
+
setQueueEdit(null)
|
|
1071
|
+
focusInput()
|
|
1072
|
+
|
|
1073
|
+
return true
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
const queueCurrentDraft = useCallback(() => {
|
|
1077
|
+
if (!activeQueueSessionKey || (!draft.trim() && attachments.length === 0)) {
|
|
1078
|
+
return false
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
if (!enqueueQueuedPrompt(activeQueueSessionKey, { text: draft, attachments })) {
|
|
1082
|
+
return false
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
clearDraft()
|
|
1086
|
+
clearComposerAttachments()
|
|
1087
|
+
triggerHaptic('selection')
|
|
1088
|
+
|
|
1089
|
+
return true
|
|
1090
|
+
}, [activeQueueSessionKey, attachments, clearDraft, draft])
|
|
1091
|
+
|
|
1092
|
+
// Steer the live turn (nudge without interrupting). Clears the draft up front
|
|
1093
|
+
// for snappy feedback; if the gateway rejects (no live tool window) the words
|
|
1094
|
+
// are re-queued so nothing is lost — same safety net as a plain queue.
|
|
1095
|
+
const steerDraft = useCallback(() => {
|
|
1096
|
+
if (!onSteer || !canSteer) {
|
|
1097
|
+
return
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
const text = draftRef.current.trim()
|
|
1101
|
+
|
|
1102
|
+
triggerHaptic('submit')
|
|
1103
|
+
clearDraft()
|
|
1104
|
+
|
|
1105
|
+
void Promise.resolve(onSteer(text)).then(accepted => {
|
|
1106
|
+
if (!accepted && activeQueueSessionKey) {
|
|
1107
|
+
enqueueQueuedPrompt(activeQueueSessionKey, { text, attachments: [] })
|
|
1108
|
+
}
|
|
1109
|
+
})
|
|
1110
|
+
}, [activeQueueSessionKey, canSteer, clearDraft, onSteer])
|
|
1111
|
+
|
|
1112
|
+
// All queue drain paths share one lock + send-then-remove sequence.
|
|
1113
|
+
// `pickEntry` lets each caller choose head, by-id, or skip-edited.
|
|
1114
|
+
const runDrain = useCallback(
|
|
1115
|
+
async (pickEntry: (entries: QueuedPromptEntry[]) => QueuedPromptEntry | undefined): Promise<boolean> => {
|
|
1116
|
+
if (drainingQueueRef.current || !activeQueueSessionKey) {
|
|
1117
|
+
return false
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
const entry = pickEntry(queuedPrompts)
|
|
1121
|
+
|
|
1122
|
+
if (!entry) {
|
|
1123
|
+
return false
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
drainingQueueRef.current = true
|
|
1127
|
+
|
|
1128
|
+
try {
|
|
1129
|
+
const accepted = await Promise.resolve(
|
|
1130
|
+
onSubmit(entry.text, { attachments: entry.attachments, fromQueue: true })
|
|
1131
|
+
)
|
|
1132
|
+
|
|
1133
|
+
if (accepted === false) {
|
|
1134
|
+
return false
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
removeQueuedPrompt(activeQueueSessionKey, entry.id)
|
|
1138
|
+
resetBrowseState(sessionId)
|
|
1139
|
+
|
|
1140
|
+
return true
|
|
1141
|
+
} finally {
|
|
1142
|
+
drainingQueueRef.current = false
|
|
1143
|
+
}
|
|
1144
|
+
},
|
|
1145
|
+
[activeQueueSessionKey, onSubmit, queuedPrompts, sessionId]
|
|
1146
|
+
)
|
|
1147
|
+
|
|
1148
|
+
const drainNextQueued = useCallback(
|
|
1149
|
+
() =>
|
|
1150
|
+
runDrain(entries => {
|
|
1151
|
+
const skip = queueEdit?.entryId
|
|
1152
|
+
|
|
1153
|
+
return skip ? entries.find(e => e.id !== skip) : entries[0]
|
|
1154
|
+
}),
|
|
1155
|
+
[queueEdit, runDrain]
|
|
1156
|
+
)
|
|
1157
|
+
|
|
1158
|
+
const sendQueuedNow = useCallback(
|
|
1159
|
+
(id: string) => {
|
|
1160
|
+
if (!activeQueueSessionKey || id === queueEdit?.entryId) {
|
|
1161
|
+
return false
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
if (busy) {
|
|
1165
|
+
// Promote to the head, then interrupt. The gateway always emits a
|
|
1166
|
+
// settle (message.complete + session.info running:false) when the
|
|
1167
|
+
// turn unwinds, and the busy→false auto-drain below sends this entry.
|
|
1168
|
+
promoteQueuedPrompt(activeQueueSessionKey, id)
|
|
1169
|
+
triggerHaptic('selection')
|
|
1170
|
+
void Promise.resolve(onCancel())
|
|
1171
|
+
|
|
1172
|
+
return true
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
return runDrain(entries => entries.find(e => e.id === id))
|
|
1176
|
+
},
|
|
1177
|
+
[activeQueueSessionKey, busy, onCancel, queueEdit, runDrain]
|
|
1178
|
+
)
|
|
1179
|
+
|
|
1180
|
+
// Auto-drain on busy → false (turn settled). Queued turns always flow once
|
|
1181
|
+
// the session is idle again — whether the turn finished naturally or the
|
|
1182
|
+
// user interrupted it. Interrupting to reach a queued message is the whole
|
|
1183
|
+
// point of the queue, so we never suppress the drain. To cancel queued
|
|
1184
|
+
// turns, the user deletes them from the panel.
|
|
1185
|
+
useEffect(() => {
|
|
1186
|
+
const wasBusy = previousBusyRef.current
|
|
1187
|
+
previousBusyRef.current = busy
|
|
1188
|
+
|
|
1189
|
+
if (
|
|
1190
|
+
shouldAutoDrainOnSettle({
|
|
1191
|
+
isBusy: busy,
|
|
1192
|
+
queueLength: queuedPrompts.length,
|
|
1193
|
+
wasBusy
|
|
1194
|
+
})
|
|
1195
|
+
) {
|
|
1196
|
+
void drainNextQueued()
|
|
1197
|
+
}
|
|
1198
|
+
}, [busy, drainNextQueued, queuedPrompts.length])
|
|
1199
|
+
|
|
1200
|
+
// Clean up queue edit when its target disappears (session swap or external delete).
|
|
1201
|
+
useEffect(() => {
|
|
1202
|
+
if (!queueEdit) {
|
|
1203
|
+
return
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
if (queueEdit.sessionKey === activeQueueSessionKey && editingQueuedPrompt) {
|
|
1207
|
+
return
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
loadIntoComposer(queueEdit.draft, queueEdit.attachments)
|
|
1211
|
+
setQueueEdit(null)
|
|
1212
|
+
}, [activeQueueSessionKey, editingQueuedPrompt, queueEdit]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
1213
|
+
|
|
1214
|
+
const submitDraft = () => {
|
|
1215
|
+
if (queueEdit) {
|
|
1216
|
+
exitQueuedEdit('save')
|
|
1217
|
+
} else if (busy) {
|
|
1218
|
+
// Slash commands should execute immediately even while the agent is
|
|
1219
|
+
// busy — they're client-side operations (/yolo, /skin, /new, /help,
|
|
1220
|
+
// etc.) or self-contained gateway RPCs (/status, /compress). onSubmit
|
|
1221
|
+
// routes them to executeSlashCommand, which has its own per-command
|
|
1222
|
+
// busy guard for commands that genuinely need an idle session (skill
|
|
1223
|
+
// /send directives). Queuing them would make every slash command wait
|
|
1224
|
+
// for the current turn to finish, which is how the TUI never behaves.
|
|
1225
|
+
if (!attachments.length && SLASH_COMMAND_RE.test(draft.trim())) {
|
|
1226
|
+
const submitted = draft
|
|
1227
|
+
triggerHaptic('submit')
|
|
1228
|
+
clearDraft()
|
|
1229
|
+
void onSubmit(submitted)
|
|
1230
|
+
} else if (hasComposerPayload) {
|
|
1231
|
+
queueCurrentDraft()
|
|
1232
|
+
} else {
|
|
1233
|
+
// Stop button (the only way to reach here while busy with an empty
|
|
1234
|
+
// composer — empty Enter is short-circuited in the keydown handler).
|
|
1235
|
+
triggerHaptic('cancel')
|
|
1236
|
+
void Promise.resolve(onCancel())
|
|
1237
|
+
}
|
|
1238
|
+
} else if (!hasComposerPayload && queuedPrompts.length > 0) {
|
|
1239
|
+
void drainNextQueued()
|
|
1240
|
+
} else if (draft.trim() || attachments.length > 0) {
|
|
1241
|
+
const submitted = draft
|
|
1242
|
+
triggerHaptic('submit')
|
|
1243
|
+
resetBrowseState(sessionId)
|
|
1244
|
+
clearDraft()
|
|
1245
|
+
clearComposerAttachments()
|
|
1246
|
+
void onSubmit(submitted, { attachments })
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
focusInput()
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
const submitUrl = () => {
|
|
1253
|
+
const url = urlValue.trim()
|
|
1254
|
+
|
|
1255
|
+
if (!url) {
|
|
1256
|
+
return
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
if (onAddUrl) {
|
|
1260
|
+
onAddUrl(url)
|
|
1261
|
+
} else {
|
|
1262
|
+
insertText(`@url:${url}`)
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
triggerHaptic('success')
|
|
1266
|
+
setUrlValue('')
|
|
1267
|
+
setUrlOpen(false)
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
const { dictate, voiceActivityState, voiceStatus } = useVoiceRecorder({
|
|
1271
|
+
focusInput,
|
|
1272
|
+
maxRecordingSeconds,
|
|
1273
|
+
onTranscript: insertText,
|
|
1274
|
+
onTranscribeAudio
|
|
1275
|
+
})
|
|
1276
|
+
|
|
1277
|
+
const pendingResponse = () => {
|
|
1278
|
+
const messages = $messages.get()
|
|
1279
|
+
const last = messages.findLast(m => m.role === 'assistant' && !m.hidden)
|
|
1280
|
+
|
|
1281
|
+
if (!last || last.id === lastSpokenIdRef.current) {
|
|
1282
|
+
return null
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
const text = chatMessageText(last).trim()
|
|
1286
|
+
|
|
1287
|
+
if (!text) {
|
|
1288
|
+
return null
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
return {
|
|
1292
|
+
id: last.id,
|
|
1293
|
+
pending: Boolean(last.pending),
|
|
1294
|
+
text
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
const consumePendingResponse = () => {
|
|
1299
|
+
const messages = $messages.get()
|
|
1300
|
+
const last = messages.findLast(m => m.role === 'assistant' && !m.hidden)
|
|
1301
|
+
|
|
1302
|
+
if (last) {
|
|
1303
|
+
lastSpokenIdRef.current = last.id
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
const submitVoiceTurn = async (text: string) => {
|
|
1308
|
+
if (busy) {
|
|
1309
|
+
return
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
triggerHaptic('submit')
|
|
1313
|
+
resetBrowseState(sessionId)
|
|
1314
|
+
clearDraft()
|
|
1315
|
+
await onSubmit(text)
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
const conversation = useVoiceConversation({
|
|
1319
|
+
busy,
|
|
1320
|
+
consumePendingResponse,
|
|
1321
|
+
enabled: voiceConversationActive,
|
|
1322
|
+
onFatalError: () => setVoiceConversationActive(false),
|
|
1323
|
+
onSubmit: submitVoiceTurn,
|
|
1324
|
+
onTranscribeAudio,
|
|
1325
|
+
pendingResponse
|
|
1326
|
+
})
|
|
1327
|
+
|
|
1328
|
+
const contextMenu = (
|
|
1329
|
+
<ContextMenu
|
|
1330
|
+
onInsertText={insertText}
|
|
1331
|
+
onOpenUrlDialog={() => {
|
|
1332
|
+
triggerHaptic('open')
|
|
1333
|
+
setUrlOpen(true)
|
|
1334
|
+
}}
|
|
1335
|
+
onPasteClipboardImage={onPasteClipboardImage}
|
|
1336
|
+
onPickFiles={onPickFiles}
|
|
1337
|
+
onPickFolders={onPickFolders}
|
|
1338
|
+
onPickImages={onPickImages}
|
|
1339
|
+
state={state}
|
|
1340
|
+
/>
|
|
1341
|
+
)
|
|
1342
|
+
|
|
1343
|
+
const controls = (
|
|
1344
|
+
<ComposerControls
|
|
1345
|
+
busy={busy}
|
|
1346
|
+
busyAction={busyAction}
|
|
1347
|
+
canSteer={canSteer}
|
|
1348
|
+
canSubmit={canSubmit}
|
|
1349
|
+
conversation={{
|
|
1350
|
+
active: voiceConversationActive,
|
|
1351
|
+
level: conversation.level,
|
|
1352
|
+
muted: conversation.muted,
|
|
1353
|
+
onEnd: () => {
|
|
1354
|
+
setVoiceConversationActive(false)
|
|
1355
|
+
void conversation.end()
|
|
1356
|
+
},
|
|
1357
|
+
onStart: () => setVoiceConversationActive(true),
|
|
1358
|
+
onStopTurn: conversation.stopTurn,
|
|
1359
|
+
onToggleMute: conversation.toggleMute,
|
|
1360
|
+
status: conversation.status
|
|
1361
|
+
}}
|
|
1362
|
+
disabled={disabled}
|
|
1363
|
+
hasComposerPayload={hasComposerPayload}
|
|
1364
|
+
onDictate={dictate}
|
|
1365
|
+
onSteer={steerDraft}
|
|
1366
|
+
state={state}
|
|
1367
|
+
voiceStatus={voiceStatus}
|
|
1368
|
+
/>
|
|
1369
|
+
)
|
|
1370
|
+
|
|
1371
|
+
const input = (
|
|
1372
|
+
<div className={cn('relative', stacked ? 'w-full' : 'min-w-(--composer-input-inline-min-width) flex-1')}>
|
|
1373
|
+
<div
|
|
1374
|
+
aria-label={t.composer.message}
|
|
1375
|
+
autoCapitalize="off"
|
|
1376
|
+
autoCorrect="off"
|
|
1377
|
+
className={cn(
|
|
1378
|
+
'min-h-(--composer-input-min-height) max-h-(--composer-input-max-height) overflow-y-auto whitespace-pre-wrap break-words [overflow-wrap:anywhere] bg-transparent pb-1 pr-1 pt-1 leading-normal text-foreground outline-none disabled:cursor-not-allowed',
|
|
1379
|
+
'empty:before:content-[attr(data-placeholder)] empty:before:text-muted-foreground/60',
|
|
1380
|
+
'**:data-ref-text:cursor-default',
|
|
1381
|
+
stacked && 'pl-3',
|
|
1382
|
+
stacked ? 'w-full' : 'min-w-(--composer-input-inline-min-width) flex-1'
|
|
1383
|
+
)}
|
|
1384
|
+
contentEditable={!disabled}
|
|
1385
|
+
data-placeholder={placeholder}
|
|
1386
|
+
data-slot={RICH_INPUT_SLOT}
|
|
1387
|
+
onBlur={() => window.setTimeout(closeTrigger, 80)}
|
|
1388
|
+
onCompositionEnd={event => {
|
|
1389
|
+
composingRef.current = false
|
|
1390
|
+
|
|
1391
|
+
// The input events fired *during* composition were skipped (they
|
|
1392
|
+
// carried uncommitted preedit text), and Chromium does NOT reliably
|
|
1393
|
+
// emit a trailing input event after compositionend on Windows IMEs.
|
|
1394
|
+
// Without flushing here, committed multi-character IME input (e.g.
|
|
1395
|
+
// Chinese "你好", Japanese, Korean) never reaches composer state, so
|
|
1396
|
+
// `hasComposerPayload` stays false and the send button stays hidden
|
|
1397
|
+
// until an unrelated edit forces a sync (#39614).
|
|
1398
|
+
flushEditorToDraft(event.currentTarget)
|
|
1399
|
+
}}
|
|
1400
|
+
onCompositionStart={() => {
|
|
1401
|
+
composingRef.current = true
|
|
1402
|
+
}}
|
|
1403
|
+
onDragOver={handleInputDragOver}
|
|
1404
|
+
onDrop={handleInputDrop}
|
|
1405
|
+
onFocus={() => markActiveComposer('main')}
|
|
1406
|
+
onInput={handleEditorInput}
|
|
1407
|
+
onKeyDown={handleEditorKeyDown}
|
|
1408
|
+
onKeyUp={handleEditorKeyUp}
|
|
1409
|
+
onMouseUp={refreshTrigger}
|
|
1410
|
+
onPaste={handlePaste}
|
|
1411
|
+
ref={editorRef}
|
|
1412
|
+
role="textbox"
|
|
1413
|
+
spellCheck="true"
|
|
1414
|
+
suppressContentEditableWarning
|
|
1415
|
+
/>
|
|
1416
|
+
{/* assistant-ui requires ComposerPrimitive.Input somewhere in the tree
|
|
1417
|
+
so the composer-state binding (text + IME + paste + form-submit hookup)
|
|
1418
|
+
wires up. We render the real input UI ourselves above via the
|
|
1419
|
+
contentEditable, so the primitive is invisible (sr-only).
|
|
1420
|
+
|
|
1421
|
+
IMPORTANT: don't let it render its default <TextareaAutosize>. That
|
|
1422
|
+
component runs `useLayoutEffect(resizeTextarea)` on every value change
|
|
1423
|
+
and reads `node.scrollHeight` against a hidden measurement textarea,
|
|
1424
|
+
forcing two synchronous layouts per keystroke for an element the
|
|
1425
|
+
user can't see. Profiling 400-char synthetic typing showed >900ms
|
|
1426
|
+
cumulative cost in getHeight2/calculateNodeHeight alone (~2.3ms/key)
|
|
1427
|
+
on top of the per-keystroke React commit.
|
|
1428
|
+
|
|
1429
|
+
`asChild` swaps TextareaAutosize for a Radix Slot wrapping our
|
|
1430
|
+
plain <textarea>, which carries the binding but skips autosize. */}
|
|
1431
|
+
<ComposerPrimitive.Input asChild submitMode="ctrlEnter" tabIndex={-1} unstable_focusOnScrollToBottom={false}>
|
|
1432
|
+
<textarea aria-hidden className="sr-only" tabIndex={-1} />
|
|
1433
|
+
</ComposerPrimitive.Input>
|
|
1434
|
+
</div>
|
|
1435
|
+
)
|
|
1436
|
+
|
|
1437
|
+
return (
|
|
1438
|
+
<>
|
|
1439
|
+
<ComposerPrimitive.Unstable_TriggerPopoverRoot>
|
|
1440
|
+
<ComposerPrimitive.Root
|
|
1441
|
+
className="group/composer absolute bottom-0 left-1/2 z-30 w-[min(var(--composer-width),calc(100%-2rem))] max-w-full -translate-x-1/2 rounded-2xl pt-2 pb-[var(--composer-shell-pad-block-end)]"
|
|
1442
|
+
data-drag-active={dragActive ? '' : undefined}
|
|
1443
|
+
data-slot="composer-root"
|
|
1444
|
+
data-thread-scrolled-up={scrolledUp ? '' : undefined}
|
|
1445
|
+
onDragEnter={handleDragEnter}
|
|
1446
|
+
onDragLeave={handleDragLeave}
|
|
1447
|
+
onDragOver={handleDragOver}
|
|
1448
|
+
onDrop={handleDrop}
|
|
1449
|
+
onSubmit={e => {
|
|
1450
|
+
e.preventDefault()
|
|
1451
|
+
|
|
1452
|
+
if (composingRef.current) {
|
|
1453
|
+
return
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
submitDraft()
|
|
1457
|
+
}}
|
|
1458
|
+
ref={composerRef}
|
|
1459
|
+
>
|
|
1460
|
+
{showHelpHint && <HelpHint />}
|
|
1461
|
+
{trigger && (
|
|
1462
|
+
<ComposerTriggerPopover
|
|
1463
|
+
activeIndex={triggerActive}
|
|
1464
|
+
items={triggerItems}
|
|
1465
|
+
kind={trigger.kind}
|
|
1466
|
+
loading={triggerLoading}
|
|
1467
|
+
onHover={setTriggerActive}
|
|
1468
|
+
onPick={replaceTriggerWithChip}
|
|
1469
|
+
/>
|
|
1470
|
+
)}
|
|
1471
|
+
<SkinSlashPopover draft={draft} onSelect={selectSkinSlashCommand} />
|
|
1472
|
+
{activeQueueSessionKey && queuedPrompts.length > 0 && (
|
|
1473
|
+
// Out of flow so the queue never inflates the composer's measured
|
|
1474
|
+
// height (that drives thread bottom padding → chat resizes on
|
|
1475
|
+
// queue). Overlaps -mb-2 onto the surface's top border for a shared
|
|
1476
|
+
// edge; capped + scrollable. Overlays the chat instead of pushing it.
|
|
1477
|
+
<div className="absolute inset-x-0 bottom-full z-6 -mb-2 max-h-[40vh] overflow-y-auto">
|
|
1478
|
+
<QueuePanel
|
|
1479
|
+
busy={busy}
|
|
1480
|
+
editingId={queueEdit?.entryId ?? null}
|
|
1481
|
+
entries={queuedPrompts}
|
|
1482
|
+
onDelete={id => {
|
|
1483
|
+
if (removeQueuedPrompt(activeQueueSessionKey, id) && queueEdit?.entryId === id) {
|
|
1484
|
+
exitQueuedEdit('cancel')
|
|
1485
|
+
}
|
|
1486
|
+
}}
|
|
1487
|
+
onEdit={beginQueuedEdit}
|
|
1488
|
+
onSendNow={id => void sendQueuedNow(id)}
|
|
1489
|
+
/>
|
|
1490
|
+
</div>
|
|
1491
|
+
)}
|
|
1492
|
+
<div
|
|
1493
|
+
className="pointer-events-none absolute inset-0 rounded-[inherit]"
|
|
1494
|
+
style={{ background: COMPOSER_FADE_BACKGROUND }}
|
|
1495
|
+
/>
|
|
1496
|
+
<div className="relative w-full rounded-[inherit]">
|
|
1497
|
+
<div
|
|
1498
|
+
className={cn(
|
|
1499
|
+
'relative z-4 isolate rounded-[inherit] border border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(18%*var(--composer-ring-strength)),var(--dt-input))] transition-[border-color] duration-200 ease-out',
|
|
1500
|
+
COMPOSER_DROP_FADE_CLASS,
|
|
1501
|
+
'group-focus-within/composer:border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(45%*var(--composer-ring-strength)),transparent)]',
|
|
1502
|
+
'group-has-data-[state=open]/composer:border-t-transparent',
|
|
1503
|
+
dragActive && COMPOSER_DROP_ACTIVE_CLASS
|
|
1504
|
+
)}
|
|
1505
|
+
data-slot="composer-surface"
|
|
1506
|
+
ref={composerSurfaceRef}
|
|
1507
|
+
>
|
|
1508
|
+
<div
|
|
1509
|
+
aria-hidden
|
|
1510
|
+
className={cn(
|
|
1511
|
+
'pointer-events-none absolute inset-0 -z-10 rounded-[inherit]',
|
|
1512
|
+
'bg-[color-mix(in_srgb,var(--dt-card)_72%,transparent)]',
|
|
1513
|
+
'backdrop-blur-[0.75rem] backdrop-saturate-[1.12]',
|
|
1514
|
+
'[-webkit-backdrop-filter:blur(0.75rem)_saturate(1.12)]',
|
|
1515
|
+
'transition-[background-color] duration-150 ease-out',
|
|
1516
|
+
'group-data-[thread-scrolled-up]/composer:bg-[color-mix(in_srgb,var(--dt-card)_48%,transparent)]',
|
|
1517
|
+
'group-focus-within/composer:bg-[color-mix(in_srgb,var(--dt-card)_85%,transparent)]'
|
|
1518
|
+
)}
|
|
1519
|
+
/>
|
|
1520
|
+
<div
|
|
1521
|
+
className={cn(
|
|
1522
|
+
'relative z-1 flex min-h-0 w-full flex-col gap-(--composer-row-gap) overflow-hidden rounded-[inherit] px-(--composer-surface-pad-x) py-(--composer-surface-pad-y) transition-opacity duration-200 ease-out',
|
|
1523
|
+
scrolledUp
|
|
1524
|
+
? 'opacity-30 group-hover/composer:opacity-100 group-focus-within/composer:opacity-100'
|
|
1525
|
+
: 'opacity-100'
|
|
1526
|
+
)}
|
|
1527
|
+
data-slot="composer-fade"
|
|
1528
|
+
>
|
|
1529
|
+
<VoiceActivity state={voiceActivityState} />
|
|
1530
|
+
<VoicePlaybackActivity />
|
|
1531
|
+
{queueEdit && editingQueuedPrompt && (
|
|
1532
|
+
<div className="flex items-center justify-between gap-2 rounded-lg border border-[color-mix(in_srgb,var(--dt-composer-ring)_32%,transparent)] bg-accent/18 px-2 py-1">
|
|
1533
|
+
<div className="min-w-0 text-[0.7rem] text-muted-foreground/88">
|
|
1534
|
+
{t.composer.editingQueuedInComposer}
|
|
1535
|
+
</div>
|
|
1536
|
+
<div className="flex shrink-0 items-center gap-1">
|
|
1537
|
+
<Button
|
|
1538
|
+
className="h-6 rounded-md px-2 text-[0.68rem]"
|
|
1539
|
+
onClick={() => exitQueuedEdit('cancel')}
|
|
1540
|
+
type="button"
|
|
1541
|
+
variant="ghost"
|
|
1542
|
+
>
|
|
1543
|
+
{t.common.cancel}
|
|
1544
|
+
</Button>
|
|
1545
|
+
<Button
|
|
1546
|
+
className="h-6 rounded-md px-2 text-[0.68rem]"
|
|
1547
|
+
onClick={() => exitQueuedEdit('save')}
|
|
1548
|
+
type="button"
|
|
1549
|
+
>
|
|
1550
|
+
{t.common.save}
|
|
1551
|
+
</Button>
|
|
1552
|
+
</div>
|
|
1553
|
+
</div>
|
|
1554
|
+
)}
|
|
1555
|
+
{attachments.length > 0 && <AttachmentList attachments={attachments} onRemove={onRemoveAttachment} />}
|
|
1556
|
+
<div
|
|
1557
|
+
className={cn(
|
|
1558
|
+
'grid w-full',
|
|
1559
|
+
stacked
|
|
1560
|
+
? 'grid-cols-[auto_1fr] gap-(--composer-row-gap) [grid-template-areas:"input_input"_"menu_controls"]'
|
|
1561
|
+
: 'grid-cols-[auto_1fr_auto] items-center gap-(--composer-control-gap) [grid-template-areas:"menu_input_controls"]'
|
|
1562
|
+
)}
|
|
1563
|
+
>
|
|
1564
|
+
<div className="flex items-center [grid-area:menu]">{contextMenu}</div>
|
|
1565
|
+
<div className="min-w-0 [grid-area:input]">{input}</div>
|
|
1566
|
+
<div className="flex items-center justify-end [grid-area:controls]">{controls}</div>
|
|
1567
|
+
</div>
|
|
1568
|
+
</div>
|
|
1569
|
+
</div>
|
|
1570
|
+
</div>
|
|
1571
|
+
</ComposerPrimitive.Root>
|
|
1572
|
+
</ComposerPrimitive.Unstable_TriggerPopoverRoot>
|
|
1573
|
+
|
|
1574
|
+
<UrlDialog
|
|
1575
|
+
inputRef={urlInputRef}
|
|
1576
|
+
onChange={setUrlValue}
|
|
1577
|
+
onOpenChange={setUrlOpen}
|
|
1578
|
+
onSubmit={submitUrl}
|
|
1579
|
+
open={urlOpen}
|
|
1580
|
+
value={urlValue}
|
|
1581
|
+
/>
|
|
1582
|
+
</>
|
|
1583
|
+
)
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
export function ChatBarFallback() {
|
|
1587
|
+
return (
|
|
1588
|
+
<div
|
|
1589
|
+
className={cn(
|
|
1590
|
+
'group/composer absolute bottom-0 left-1/2 z-30 w-[min(var(--composer-width),calc(100%-2rem))] max-w-full -translate-x-1/2 rounded-2xl pt-2 pb-[var(--composer-shell-pad-block-end)]',
|
|
1591
|
+
'bg-linear-to-b from-transparent to-background/55'
|
|
1592
|
+
)}
|
|
1593
|
+
data-slot="composer-root"
|
|
1594
|
+
>
|
|
1595
|
+
<div className="composer-fallback-surface relative isolate h-(--composer-fallback-height) w-full rounded-[inherit] border border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(18%*var(--composer-ring-strength)),var(--dt-input))]">
|
|
1596
|
+
<div
|
|
1597
|
+
aria-hidden
|
|
1598
|
+
className={cn(
|
|
1599
|
+
'pointer-events-none absolute inset-0 -z-10 rounded-[inherit]',
|
|
1600
|
+
'bg-[color-mix(in_srgb,var(--dt-card)_72%,transparent)]',
|
|
1601
|
+
'backdrop-blur-[0.75rem] backdrop-saturate-[1.12]',
|
|
1602
|
+
'[-webkit-backdrop-filter:blur(0.75rem)_saturate(1.12)]',
|
|
1603
|
+
'transition-[background-color] duration-150 ease-out',
|
|
1604
|
+
'group-data-[thread-scrolled-up]/composer:bg-[color-mix(in_srgb,var(--dt-card)_48%,transparent)]',
|
|
1605
|
+
'group-focus-within/composer:bg-[color-mix(in_srgb,var(--dt-card)_85%,transparent)]'
|
|
1606
|
+
)}
|
|
1607
|
+
/>
|
|
1608
|
+
</div>
|
|
1609
|
+
</div>
|
|
1610
|
+
)
|
|
1611
|
+
}
|