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,1474 @@
|
|
|
1
|
+
import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core'
|
|
2
|
+
import {
|
|
3
|
+
ActionBarPrimitive,
|
|
4
|
+
BranchPickerPrimitive,
|
|
5
|
+
ComposerPrimitive,
|
|
6
|
+
ErrorPrimitive,
|
|
7
|
+
MessagePrimitive,
|
|
8
|
+
type ToolCallMessagePartProps,
|
|
9
|
+
useAui,
|
|
10
|
+
useAuiState
|
|
11
|
+
} from '@assistant-ui/react'
|
|
12
|
+
import { useStore } from '@nanostores/react'
|
|
13
|
+
import { IconPlayerStopFilled } from '@tabler/icons-react'
|
|
14
|
+
import {
|
|
15
|
+
type ClipboardEvent,
|
|
16
|
+
type ComponentProps,
|
|
17
|
+
type FC,
|
|
18
|
+
type FocusEvent,
|
|
19
|
+
type FormEvent,
|
|
20
|
+
type KeyboardEvent,
|
|
21
|
+
type DragEvent as ReactDragEvent,
|
|
22
|
+
type ReactNode,
|
|
23
|
+
useCallback,
|
|
24
|
+
useEffect,
|
|
25
|
+
useMemo,
|
|
26
|
+
useRef,
|
|
27
|
+
useState
|
|
28
|
+
} from 'react'
|
|
29
|
+
|
|
30
|
+
import { COMPOSER_DROP_ACTIVE_CLASS, COMPOSER_DROP_FADE_CLASS } from '@/app/chat/composer/drop-affordance'
|
|
31
|
+
import {
|
|
32
|
+
type ComposerInsertMode,
|
|
33
|
+
focusComposerInput,
|
|
34
|
+
markActiveComposer,
|
|
35
|
+
onComposerFocusRequest,
|
|
36
|
+
onComposerInsertRequest
|
|
37
|
+
} from '@/app/chat/composer/focus'
|
|
38
|
+
import { useAtCompletions } from '@/app/chat/composer/hooks/use-at-completions'
|
|
39
|
+
import { useSlashCompletions } from '@/app/chat/composer/hooks/use-slash-completions'
|
|
40
|
+
import { dragHasAttachments, droppedFileInlineRef, insertInlineRefsIntoEditor } from '@/app/chat/composer/inline-refs'
|
|
41
|
+
import {
|
|
42
|
+
composerPlainText,
|
|
43
|
+
placeCaretEnd,
|
|
44
|
+
refChipElement,
|
|
45
|
+
renderComposerContents,
|
|
46
|
+
RICH_INPUT_SLOT
|
|
47
|
+
} from '@/app/chat/composer/rich-editor'
|
|
48
|
+
import { detectTrigger, textBeforeCaret, type TriggerState } from '@/app/chat/composer/text-utils'
|
|
49
|
+
import { ComposerTriggerPopover } from '@/app/chat/composer/trigger-popover'
|
|
50
|
+
import { extractDroppedFiles, NASTECH_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions'
|
|
51
|
+
import { ClarifyTool } from '@/components/assistant-ui/clarify-tool'
|
|
52
|
+
import { DirectiveContent, NASTECHDirectiveFormatter } from '@/components/assistant-ui/directive-text'
|
|
53
|
+
import { MarkdownText, MarkdownTextContent } from '@/components/assistant-ui/markdown-text'
|
|
54
|
+
import { VirtualizedThread } from '@/components/assistant-ui/thread-virtualizer'
|
|
55
|
+
import { HoistedTodoPanel, todosFromMessageContent } from '@/components/assistant-ui/todo-tool'
|
|
56
|
+
import { ToolFallback, ToolGroupSlot } from '@/components/assistant-ui/tool-fallback'
|
|
57
|
+
import { TooltipIconButton } from '@/components/assistant-ui/tooltip-icon-button'
|
|
58
|
+
import { UserMessageText } from '@/components/assistant-ui/user-message-text'
|
|
59
|
+
import { useElapsedSeconds } from '@/components/chat/activity-timer'
|
|
60
|
+
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
|
|
61
|
+
import { DisclosureRow } from '@/components/chat/disclosure-row'
|
|
62
|
+
import { GeneratedImageProvider, useGeneratedImageContext } from '@/components/chat/generated-image-context'
|
|
63
|
+
import { ImageGenerationPlaceholder } from '@/components/chat/image-generation-placeholder'
|
|
64
|
+
import { Intro, type IntroProps } from '@/components/chat/intro'
|
|
65
|
+
import { PreviewAttachment } from '@/components/chat/preview-attachment'
|
|
66
|
+
import { Codicon } from '@/components/ui/codicon'
|
|
67
|
+
import { CopyButton } from '@/components/ui/copy-button'
|
|
68
|
+
import {
|
|
69
|
+
DropdownMenu,
|
|
70
|
+
DropdownMenuContent,
|
|
71
|
+
DropdownMenuItem,
|
|
72
|
+
DropdownMenuLabel,
|
|
73
|
+
DropdownMenuTrigger
|
|
74
|
+
} from '@/components/ui/dropdown-menu'
|
|
75
|
+
import { Loader } from '@/components/ui/loader'
|
|
76
|
+
import type { NasTechGateway } from '@/nastech'
|
|
77
|
+
import { useResizeObserver } from '@/hooks/use-resize-observer'
|
|
78
|
+
import { useI18n } from '@/i18n'
|
|
79
|
+
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
|
|
80
|
+
import { triggerHaptic } from '@/lib/haptics'
|
|
81
|
+
import { GitBranchIcon, Loader2Icon, Volume2Icon, VolumeXIcon } from '@/lib/icons'
|
|
82
|
+
import { extractPreviewTargets } from '@/lib/preview-targets'
|
|
83
|
+
import { useEnterAnimation } from '@/lib/use-enter-animation'
|
|
84
|
+
import { cn } from '@/lib/utils'
|
|
85
|
+
import { playSpeechText, stopVoicePlayback } from '@/lib/voice-playback'
|
|
86
|
+
import { notifyError } from '@/store/notifications'
|
|
87
|
+
import { $voicePlayback } from '@/store/voice-playback'
|
|
88
|
+
|
|
89
|
+
type ThreadLoadingState = 'response' | 'session'
|
|
90
|
+
|
|
91
|
+
interface MessageActionProps {
|
|
92
|
+
messageId: string
|
|
93
|
+
messageText: string
|
|
94
|
+
onBranchInNewChat?: (messageId: string) => void
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let readAloudAudio: HTMLAudioElement | null = null
|
|
98
|
+
|
|
99
|
+
function partText(part: unknown): string {
|
|
100
|
+
if (typeof part === 'string') {
|
|
101
|
+
return part
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (!part || typeof part !== 'object') {
|
|
105
|
+
return ''
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const row = part as { text?: unknown; type?: unknown }
|
|
109
|
+
|
|
110
|
+
return (!row.type || row.type === 'text') && typeof row.text === 'string' ? row.text : ''
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function messageContentText(content: unknown): string {
|
|
114
|
+
if (typeof content === 'string') {
|
|
115
|
+
return content.trim()
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return Array.isArray(content) ? content.map(partText).join('').trim() : ''
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export const Thread: FC<{
|
|
122
|
+
clampToComposer?: boolean
|
|
123
|
+
cwd?: string | null
|
|
124
|
+
gateway?: NasTechGateway | null
|
|
125
|
+
intro?: IntroProps
|
|
126
|
+
loading?: ThreadLoadingState
|
|
127
|
+
onBranchInNewChat?: (messageId: string) => void
|
|
128
|
+
onCancel?: () => Promise<void> | void
|
|
129
|
+
sessionId?: string | null
|
|
130
|
+
sessionKey?: string | null
|
|
131
|
+
}> = ({
|
|
132
|
+
clampToComposer = false,
|
|
133
|
+
cwd = null,
|
|
134
|
+
gateway = null,
|
|
135
|
+
intro,
|
|
136
|
+
loading,
|
|
137
|
+
onBranchInNewChat,
|
|
138
|
+
onCancel,
|
|
139
|
+
sessionId = null,
|
|
140
|
+
sessionKey
|
|
141
|
+
}) => {
|
|
142
|
+
const messageComponents = useMemo(
|
|
143
|
+
() => ({
|
|
144
|
+
AssistantMessage: () => <AssistantMessage onBranchInNewChat={onBranchInNewChat} />,
|
|
145
|
+
SystemMessage,
|
|
146
|
+
UserEditComposer: () => <UserEditComposer cwd={cwd} gateway={gateway} sessionId={sessionId} />,
|
|
147
|
+
UserMessage: () => <UserMessage onCancel={onCancel} />
|
|
148
|
+
}),
|
|
149
|
+
[cwd, gateway, onBranchInNewChat, onCancel, sessionId]
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
const emptyPlaceholder = intro ? (
|
|
153
|
+
<div
|
|
154
|
+
className="flex min-h-0 w-full flex-col items-center justify-center"
|
|
155
|
+
style={{ paddingBottom: 'var(--composer-measured-height)' }}
|
|
156
|
+
>
|
|
157
|
+
<Intro {...intro} />
|
|
158
|
+
</div>
|
|
159
|
+
) : undefined
|
|
160
|
+
|
|
161
|
+
return (
|
|
162
|
+
<GeneratedImageProvider>
|
|
163
|
+
<div className="relative grid h-full min-h-0 max-w-full grid-rows-[minmax(0,1fr)] overflow-hidden bg-transparent contain-[layout_paint]">
|
|
164
|
+
<VirtualizedThread
|
|
165
|
+
clampToComposer={clampToComposer}
|
|
166
|
+
components={messageComponents}
|
|
167
|
+
emptyPlaceholder={emptyPlaceholder}
|
|
168
|
+
loadingIndicator={loading === 'response' ? <ResponseLoadingIndicator /> : null}
|
|
169
|
+
sessionKey={sessionKey}
|
|
170
|
+
/>
|
|
171
|
+
{loading === 'session' && <CenteredThreadSpinner />}
|
|
172
|
+
</div>
|
|
173
|
+
</GeneratedImageProvider>
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function pickPrimaryPreviewTarget(targets: string[]): string[] {
|
|
178
|
+
if (targets.length <= 1) {
|
|
179
|
+
return targets
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const localUrl = targets.find(value => /^https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])/i.test(value))
|
|
183
|
+
|
|
184
|
+
return [localUrl || targets[targets.length - 1]]
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const CenteredThreadSpinner: FC = () => {
|
|
188
|
+
const { t } = useI18n()
|
|
189
|
+
|
|
190
|
+
return (
|
|
191
|
+
<div
|
|
192
|
+
aria-label={t.assistant.thread.loadingSession}
|
|
193
|
+
className="pointer-events-none absolute inset-0 z-1 grid place-items-center"
|
|
194
|
+
role="status"
|
|
195
|
+
>
|
|
196
|
+
<Loader
|
|
197
|
+
aria-hidden="true"
|
|
198
|
+
className="size-12 text-midground/70"
|
|
199
|
+
pathSteps={220}
|
|
200
|
+
role="presentation"
|
|
201
|
+
strokeScale={0.72}
|
|
202
|
+
type="rose-curve"
|
|
203
|
+
/>
|
|
204
|
+
</div>
|
|
205
|
+
)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }> = ({ onBranchInNewChat }) => {
|
|
209
|
+
const messageId = useAuiState(s => s.message.id)
|
|
210
|
+
const content = useAuiState(s => s.message.content)
|
|
211
|
+
const messageText = messageContentText(content)
|
|
212
|
+
const hoistedTodos = useMemo(() => todosFromMessageContent(content), [content])
|
|
213
|
+
|
|
214
|
+
const previewTargets = useMemo(() => {
|
|
215
|
+
if (!messageText || !/(https?:\/\/|file:\/\/)/i.test(messageText)) {
|
|
216
|
+
return []
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return pickPrimaryPreviewTarget(extractPreviewTargets(messageText))
|
|
220
|
+
}, [messageText])
|
|
221
|
+
|
|
222
|
+
const messageStatus = useAuiState(s => s.message.status?.type)
|
|
223
|
+
const isPlaceholder = messageStatus === 'running' && content.length === 0
|
|
224
|
+
const enterRef = useEnterAnimation(messageStatus === 'running', `assistant-message:${messageId}`)
|
|
225
|
+
|
|
226
|
+
if (isPlaceholder) {
|
|
227
|
+
return null
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return (
|
|
231
|
+
<MessagePrimitive.Root
|
|
232
|
+
className="group flex w-full min-w-0 max-w-full flex-col gap-0 self-start overflow-hidden"
|
|
233
|
+
data-role="assistant"
|
|
234
|
+
data-slot="aui_assistant-message-root"
|
|
235
|
+
data-streaming={messageStatus === 'running' ? 'true' : undefined}
|
|
236
|
+
ref={enterRef}
|
|
237
|
+
>
|
|
238
|
+
<div
|
|
239
|
+
className="wrap-anywhere min-w-0 max-w-full overflow-hidden text-pretty text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground"
|
|
240
|
+
data-slot="aui_assistant-message-content"
|
|
241
|
+
>
|
|
242
|
+
{hoistedTodos.length > 0 && <HoistedTodoPanel todos={hoistedTodos} />}
|
|
243
|
+
<MessagePrimitive.Parts components={MESSAGE_PARTS_COMPONENTS} />
|
|
244
|
+
{messageStatus === 'running' && <StreamStallIndicator activity={`${content.length}:${messageText.length}`} />}
|
|
245
|
+
{previewTargets.length > 0 && (
|
|
246
|
+
<div className="mt-3 flex flex-wrap gap-2">
|
|
247
|
+
{previewTargets.map(target => (
|
|
248
|
+
<PreviewAttachment key={target} source="explicit-link" target={target} />
|
|
249
|
+
))}
|
|
250
|
+
</div>
|
|
251
|
+
)}
|
|
252
|
+
<MessagePrimitive.Error>
|
|
253
|
+
<ErrorPrimitive.Root
|
|
254
|
+
className="mt-1.5 text-[0.78rem] leading-5 text-[color-mix(in_srgb,var(--dt-destructive)_78%,var(--ui-text-secondary))]"
|
|
255
|
+
role="alert"
|
|
256
|
+
>
|
|
257
|
+
<ErrorPrimitive.Message />
|
|
258
|
+
</ErrorPrimitive.Root>
|
|
259
|
+
</MessagePrimitive.Error>
|
|
260
|
+
</div>
|
|
261
|
+
{messageText.trim().length > 0 && (
|
|
262
|
+
<AssistantFooter messageId={messageId} messageText={messageText} onBranchInNewChat={onBranchInNewChat} />
|
|
263
|
+
)}
|
|
264
|
+
</MessagePrimitive.Root>
|
|
265
|
+
)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const StatusRow: FC<{ children: ReactNode; label: string } & React.ComponentPropsWithoutRef<'div'>> = ({
|
|
269
|
+
children,
|
|
270
|
+
label,
|
|
271
|
+
className,
|
|
272
|
+
...rest
|
|
273
|
+
}) => (
|
|
274
|
+
<div
|
|
275
|
+
aria-label={label}
|
|
276
|
+
aria-live="polite"
|
|
277
|
+
className={cn('flex max-w-full items-center gap-2 self-start text-sm text-muted-foreground/70', className)}
|
|
278
|
+
role="status"
|
|
279
|
+
{...rest}
|
|
280
|
+
>
|
|
281
|
+
{children}
|
|
282
|
+
</div>
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
const ResponseLoadingIndicator: FC = () => {
|
|
286
|
+
const { t } = useI18n()
|
|
287
|
+
const elapsed = useElapsedSeconds()
|
|
288
|
+
|
|
289
|
+
return (
|
|
290
|
+
<StatusRow data-slot="aui_response-loading" label={t.assistant.thread.loadingResponse}>
|
|
291
|
+
<span aria-hidden="true" className="dither inline-block size-3 rounded-[2px] text-midground/80 animate-pulse" />
|
|
292
|
+
<ActivityTimerText seconds={elapsed} />
|
|
293
|
+
</StatusRow>
|
|
294
|
+
)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Seconds of no visible output (text or part count) before a still-running turn
|
|
298
|
+
// is treated as stalled and the thinking indicator returns at the tail.
|
|
299
|
+
const STREAM_STALL_S = 2
|
|
300
|
+
|
|
301
|
+
// Tail "still thinking" indicator: the pre-first-token spinner goes away once
|
|
302
|
+
// text flows, but if the stream then goes quiet mid-turn (tool think-time,
|
|
303
|
+
// provider stall) nothing signals that work continues. Watch a per-render
|
|
304
|
+
// activity signal; when it hasn't changed for STREAM_STALL_S, re-show the
|
|
305
|
+
// dither + a timer counting from the last activity.
|
|
306
|
+
const StreamStallIndicator: FC<{ activity: string }> = ({ activity }) => {
|
|
307
|
+
const [stalled, setStalled] = useState(false)
|
|
308
|
+
|
|
309
|
+
useEffect(() => {
|
|
310
|
+
setStalled(false)
|
|
311
|
+
const id = window.setTimeout(() => setStalled(true), STREAM_STALL_S * 1000)
|
|
312
|
+
|
|
313
|
+
return () => window.clearTimeout(id)
|
|
314
|
+
}, [activity])
|
|
315
|
+
|
|
316
|
+
const elapsed = useElapsedSeconds(stalled)
|
|
317
|
+
|
|
318
|
+
if (!stalled) {
|
|
319
|
+
return null
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return (
|
|
323
|
+
<StatusRow className="mt-1.5" data-slot="aui_stream-stall" label="NasTech is thinking">
|
|
324
|
+
<span aria-hidden="true" className="dither inline-block size-3 rounded-[2px] text-midground/80 animate-pulse" />
|
|
325
|
+
<ActivityTimerText seconds={elapsed} />
|
|
326
|
+
</StatusRow>
|
|
327
|
+
)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const ImageGenerateTool: FC<ToolCallMessagePartProps> = ({ result }) => {
|
|
331
|
+
const generatedImage = useGeneratedImageContext()
|
|
332
|
+
const running = result === undefined
|
|
333
|
+
|
|
334
|
+
useEffect(() => {
|
|
335
|
+
generatedImage?.setPending(running)
|
|
336
|
+
}, [generatedImage, running])
|
|
337
|
+
|
|
338
|
+
if (!running) {
|
|
339
|
+
return null
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return (
|
|
343
|
+
<div className="mt-1.5">
|
|
344
|
+
<ImageGenerationPlaceholder />
|
|
345
|
+
</div>
|
|
346
|
+
)
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const ChainToolFallback: FC<ToolCallMessagePartProps> = props => {
|
|
350
|
+
// todo parts are hoisted to a dedicated panel above the message content.
|
|
351
|
+
if (props.toolName === 'todo') {
|
|
352
|
+
return null
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (props.toolName === 'image_generate') {
|
|
356
|
+
return <ImageGenerateTool {...props} />
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (props.toolName === 'clarify') {
|
|
360
|
+
return <ClarifyTool {...props} />
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return <ToolFallback {...props} />
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const ThinkingDisclosure: FC<{
|
|
367
|
+
children: ReactNode
|
|
368
|
+
messageRunning?: boolean
|
|
369
|
+
pending?: boolean
|
|
370
|
+
timerKey?: string
|
|
371
|
+
}> = ({ children, messageRunning = false, pending = false, timerKey }) => {
|
|
372
|
+
const { t } = useI18n()
|
|
373
|
+
// `null` = no explicit user toggle yet, defer to the streaming default.
|
|
374
|
+
// The default is "auto-open while streaming, auto-collapse when done" so
|
|
375
|
+
// reasoning surfaces a live preview without manual interaction. The first
|
|
376
|
+
// explicit toggle wins from then on.
|
|
377
|
+
const [userOpen, setUserOpen] = useState<boolean | null>(null)
|
|
378
|
+
const elapsed = useElapsedSeconds(pending, timerKey)
|
|
379
|
+
const scrollRef = useRef<HTMLDivElement | null>(null)
|
|
380
|
+
const contentRef = useRef<HTMLDivElement | null>(null)
|
|
381
|
+
const enterRef = useEnterAnimation(messageRunning, timerKey)
|
|
382
|
+
|
|
383
|
+
const open = userOpen ?? pending
|
|
384
|
+
const isPreview = pending && userOpen === null
|
|
385
|
+
|
|
386
|
+
// While the preview is live, pin the scroll container to the bottom on
|
|
387
|
+
// every content growth so the latest tokens are always visible. Combined
|
|
388
|
+
// with the top mask in styles.css, this reads as text settling in from
|
|
389
|
+
// below while older lines fade out at the top.
|
|
390
|
+
useEffect(() => {
|
|
391
|
+
if (!isPreview) {
|
|
392
|
+
return
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const el = scrollRef.current
|
|
396
|
+
const content = contentRef.current
|
|
397
|
+
|
|
398
|
+
if (!el || !content) {
|
|
399
|
+
return
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const pin = () => {
|
|
403
|
+
el.scrollTop = el.scrollHeight
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
pin()
|
|
407
|
+
const observer = new ResizeObserver(pin)
|
|
408
|
+
observer.observe(content)
|
|
409
|
+
|
|
410
|
+
return () => observer.disconnect()
|
|
411
|
+
// Re-run when the disclosure toggles so the observer attaches to the new
|
|
412
|
+
// DOM after expand/collapse (refs are conditionally rendered on `open`).
|
|
413
|
+
}, [isPreview, open])
|
|
414
|
+
|
|
415
|
+
return (
|
|
416
|
+
<div
|
|
417
|
+
className="text-[length:var(--conversation-tool-font-size)] text-(--ui-text-tertiary)"
|
|
418
|
+
data-slot="aui_thinking-disclosure"
|
|
419
|
+
ref={enterRef}
|
|
420
|
+
>
|
|
421
|
+
<DisclosureRow onToggle={() => setUserOpen(!open)} open={open}>
|
|
422
|
+
<span className="flex min-w-0 items-baseline gap-1.5">
|
|
423
|
+
<span
|
|
424
|
+
className={cn(
|
|
425
|
+
'text-[length:var(--conversation-tool-font-size)] font-medium leading-(--conversation-line-height) text-(--ui-text-secondary)',
|
|
426
|
+
pending && 'shimmer text-foreground/55'
|
|
427
|
+
)}
|
|
428
|
+
>
|
|
429
|
+
{t.assistant.thread.thinking}
|
|
430
|
+
</span>
|
|
431
|
+
{pending && (
|
|
432
|
+
<ActivityTimerText
|
|
433
|
+
className="text-[length:var(--conversation-caption-font-size)] tabular-nums text-(--ui-text-tertiary)"
|
|
434
|
+
seconds={elapsed}
|
|
435
|
+
/>
|
|
436
|
+
)}
|
|
437
|
+
</span>
|
|
438
|
+
</DisclosureRow>
|
|
439
|
+
{open && (
|
|
440
|
+
<div
|
|
441
|
+
className={cn(
|
|
442
|
+
// Body sits flush with the "Thinking" header — no left indent —
|
|
443
|
+
// and inherits the disclosure-level opacity fade defined in
|
|
444
|
+
// styles.css (~0.67 at rest, 1 on hover/focus).
|
|
445
|
+
'mt-0.5 w-full min-w-0 max-w-full overflow-hidden wrap-anywhere pb-1',
|
|
446
|
+
isPreview && 'thinking-preview max-h-40'
|
|
447
|
+
)}
|
|
448
|
+
ref={scrollRef}
|
|
449
|
+
>
|
|
450
|
+
<div ref={contentRef}>{children}</div>
|
|
451
|
+
</div>
|
|
452
|
+
)}
|
|
453
|
+
</div>
|
|
454
|
+
)
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Self-gate "Thinking…" on this message's own reasoning parts. Reading
|
|
458
|
+
// `thread.isRunning` directly would flicker shimmer/timer on every old
|
|
459
|
+
// assistant whenever the external-store runtime clears+reimports its
|
|
460
|
+
// repository (one ref-identity bump per streaming delta).
|
|
461
|
+
const ReasoningAccordionGroup: FC<{ children?: ReactNode; endIndex: number; startIndex: number }> = ({
|
|
462
|
+
children,
|
|
463
|
+
endIndex,
|
|
464
|
+
startIndex
|
|
465
|
+
}) => {
|
|
466
|
+
const messageId = useAuiState(s => s.message.id)
|
|
467
|
+
const messageRunning = useAuiState(s => s.message.status?.type === 'running')
|
|
468
|
+
|
|
469
|
+
const pending = useAuiState(
|
|
470
|
+
s =>
|
|
471
|
+
s.thread.isRunning &&
|
|
472
|
+
s.message.status?.type === 'running' &&
|
|
473
|
+
s.message.parts
|
|
474
|
+
.slice(Math.max(0, startIndex))
|
|
475
|
+
.some(p => p?.type === 'reasoning' && p.status?.type !== 'complete')
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
// A reasoning group with no actual text is pure noise — drop the whole
|
|
479
|
+
// "Thinking" disclosure rather than leave an empty header eating a row. This
|
|
480
|
+
// applies live too: encrypted/spinner-coerced reasoning (Opus reasoning max)
|
|
481
|
+
// never carries visible text, and the bottom-of-thread loader already signals
|
|
482
|
+
// "thinking", so an empty header is never wanted. Real reasoning surfaces the
|
|
483
|
+
// instant its first token lands.
|
|
484
|
+
const hasContent = useAuiState(s =>
|
|
485
|
+
s.message.parts
|
|
486
|
+
.slice(Math.max(0, startIndex), endIndex + 1)
|
|
487
|
+
.some(p => p?.type === 'reasoning' && typeof p.text === 'string' && p.text.trim().length > 0)
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
if (!hasContent) {
|
|
491
|
+
return null
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
return (
|
|
495
|
+
<ThinkingDisclosure messageRunning={messageRunning} pending={pending} timerKey={`reasoning:${messageId}`}>
|
|
496
|
+
{children}
|
|
497
|
+
</ThinkingDisclosure>
|
|
498
|
+
)
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const ReasoningTextPart: FC<{ text: string; status?: { type: string } }> = ({ text, status }) => {
|
|
502
|
+
const displayText = text.trimStart()
|
|
503
|
+
const messageRunning = useAuiState(s => s.message.status?.type === 'running')
|
|
504
|
+
const isRunning = status?.type === 'running' || messageRunning
|
|
505
|
+
|
|
506
|
+
return (
|
|
507
|
+
<MarkdownTextContent
|
|
508
|
+
containerClassName={cn(
|
|
509
|
+
'text-xs leading-snug text-muted-foreground/85',
|
|
510
|
+
isRunning && 'shimmer text-muted-foreground/55'
|
|
511
|
+
)}
|
|
512
|
+
containerProps={{ 'data-slot': 'aui_reasoning-text' } as ComponentProps<'div'>}
|
|
513
|
+
isRunning={isRunning}
|
|
514
|
+
text={displayText}
|
|
515
|
+
/>
|
|
516
|
+
)
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// Module-level constant so the `components` prop on `MessagePrimitive.Parts`
|
|
520
|
+
// has a stable identity across renders. Without this every AssistantMessage
|
|
521
|
+
// render would create a fresh `components` object, invalidating the memo on
|
|
522
|
+
// `MessagePrimitivePartByIndex` and forcing every tool/reasoning child to
|
|
523
|
+
// re-render on every streaming delta. Memo invalidation alone doesn't
|
|
524
|
+
// remount, but combined with the previous ToolFallback group-swap it was a
|
|
525
|
+
// big chunk of the per-delta work.
|
|
526
|
+
const MESSAGE_PARTS_COMPONENTS = {
|
|
527
|
+
Reasoning: ReasoningTextPart,
|
|
528
|
+
ReasoningGroup: ReasoningAccordionGroup,
|
|
529
|
+
Text: MarkdownText,
|
|
530
|
+
ToolGroup: ToolGroupSlot,
|
|
531
|
+
tools: { Fallback: ChainToolFallback }
|
|
532
|
+
} as const
|
|
533
|
+
|
|
534
|
+
const TIME_FMT = new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: '2-digit' })
|
|
535
|
+
|
|
536
|
+
const SHORT_FMT = new Intl.DateTimeFormat(undefined, {
|
|
537
|
+
day: 'numeric',
|
|
538
|
+
hour: 'numeric',
|
|
539
|
+
minute: '2-digit',
|
|
540
|
+
month: 'short'
|
|
541
|
+
})
|
|
542
|
+
|
|
543
|
+
function startOfDay(d: Date): number {
|
|
544
|
+
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function formatMessageTimestamp(
|
|
548
|
+
value: Date | string | number | undefined,
|
|
549
|
+
labels: { today: (time: string) => string; yesterday: (time: string) => string }
|
|
550
|
+
): string {
|
|
551
|
+
if (!value) {
|
|
552
|
+
return ''
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const date = value instanceof Date ? value : new Date(value)
|
|
556
|
+
|
|
557
|
+
if (Number.isNaN(date.getTime())) {
|
|
558
|
+
return ''
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
const dayDelta = Math.round((startOfDay(new Date()) - startOfDay(date)) / 86_400_000)
|
|
562
|
+
|
|
563
|
+
if (dayDelta === 0) {
|
|
564
|
+
return labels.today(TIME_FMT.format(date))
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
if (dayDelta === 1) {
|
|
568
|
+
return labels.yesterday(TIME_FMT.format(date))
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
return SHORT_FMT.format(date)
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const AssistantActionBar: FC<MessageActionProps> = ({ messageId, messageText, onBranchInNewChat }) => {
|
|
575
|
+
const { t } = useI18n()
|
|
576
|
+
const copy = t.assistant.thread
|
|
577
|
+
const [menuOpen, setMenuOpen] = useState(false)
|
|
578
|
+
|
|
579
|
+
return (
|
|
580
|
+
<div className="relative flex w-full shrink-0 justify-end">
|
|
581
|
+
<ActionBarPrimitive.Root
|
|
582
|
+
className={cn(
|
|
583
|
+
// NOTE: intentionally NOT `hideWhenRunning`. That prop unmounts the
|
|
584
|
+
// bar while the thread streams, which collapses every completed
|
|
585
|
+
// assistant message's footer by this bar's height and shifts the
|
|
586
|
+
// whole conversation when the turn resolves. The bar is already
|
|
587
|
+
// invisible by default (opacity-0 + pointer-events-none, reveals on
|
|
588
|
+
// hover), so keeping it mounted reserves stable layout height with
|
|
589
|
+
// no visual change during streaming.
|
|
590
|
+
'relative flex flex-row items-center justify-end gap-2 py-1.5 opacity-0 pointer-events-none group-hover:pointer-events-auto group-hover:opacity-100 focus-within:pointer-events-auto focus-within:opacity-100',
|
|
591
|
+
menuOpen && 'pointer-events-auto opacity-100 [&_button]:opacity-100'
|
|
592
|
+
)}
|
|
593
|
+
data-slot="aui_msg-actions"
|
|
594
|
+
>
|
|
595
|
+
<CopyButton appearance="icon" buttonSize="icon" disabled={!messageText} label={copy.copy} text={messageText} />
|
|
596
|
+
<ActionBarPrimitive.Reload asChild>
|
|
597
|
+
<TooltipIconButton onClick={() => triggerHaptic('submit')} tooltip={copy.refresh}>
|
|
598
|
+
<Codicon name="refresh" />
|
|
599
|
+
</TooltipIconButton>
|
|
600
|
+
</ActionBarPrimitive.Reload>
|
|
601
|
+
<DropdownMenu onOpenChange={setMenuOpen} open={menuOpen}>
|
|
602
|
+
<DropdownMenuTrigger asChild>
|
|
603
|
+
<TooltipIconButton tooltip={copy.moreActions}>
|
|
604
|
+
<Codicon name="ellipsis" />
|
|
605
|
+
</TooltipIconButton>
|
|
606
|
+
</DropdownMenuTrigger>
|
|
607
|
+
<DropdownMenuContent align="start" onCloseAutoFocus={e => e.preventDefault()} sideOffset={6}>
|
|
608
|
+
<MessageTimestamp />
|
|
609
|
+
<DropdownMenuItem onSelect={() => onBranchInNewChat?.(messageId)}>
|
|
610
|
+
<GitBranchIcon />
|
|
611
|
+
{copy.branchNewChat}
|
|
612
|
+
</DropdownMenuItem>
|
|
613
|
+
<ReadAloudItem messageId={messageId} text={messageText} />
|
|
614
|
+
</DropdownMenuContent>
|
|
615
|
+
</DropdownMenu>
|
|
616
|
+
</ActionBarPrimitive.Root>
|
|
617
|
+
</div>
|
|
618
|
+
)
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
const ReadAloudItem: FC<{ messageId: string; text: string }> = ({ messageId, text }) => {
|
|
622
|
+
const { t } = useI18n()
|
|
623
|
+
const copy = t.assistant.thread
|
|
624
|
+
const voicePlayback = useStore($voicePlayback)
|
|
625
|
+
|
|
626
|
+
const readAloudStatus =
|
|
627
|
+
voicePlayback.source === 'read-aloud' && voicePlayback.messageId === messageId ? voicePlayback.status : 'idle'
|
|
628
|
+
|
|
629
|
+
const isPreparing = readAloudStatus === 'preparing'
|
|
630
|
+
const isSpeaking = readAloudStatus === 'speaking'
|
|
631
|
+
const anyPlaybackActive = voicePlayback.status !== 'idle'
|
|
632
|
+
const Icon = isPreparing ? Loader2Icon : isSpeaking ? VolumeXIcon : Volume2Icon
|
|
633
|
+
|
|
634
|
+
const read = useCallback(async () => {
|
|
635
|
+
if (!text || $voicePlayback.get().status !== 'idle') {
|
|
636
|
+
return
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
try {
|
|
640
|
+
await playSpeechText(text, { messageId, source: 'read-aloud' })
|
|
641
|
+
} catch (error) {
|
|
642
|
+
notifyError(error, copy.readAloudFailed)
|
|
643
|
+
}
|
|
644
|
+
}, [copy.readAloudFailed, messageId, text])
|
|
645
|
+
|
|
646
|
+
return (
|
|
647
|
+
<DropdownMenuItem
|
|
648
|
+
disabled={isPreparing || (!isSpeaking && (anyPlaybackActive || !text))}
|
|
649
|
+
onSelect={e => {
|
|
650
|
+
e.preventDefault()
|
|
651
|
+
void (isSpeaking ? stopVoicePlayback() : read())
|
|
652
|
+
}}
|
|
653
|
+
>
|
|
654
|
+
<Icon className={isPreparing ? 'animate-spin' : undefined} />
|
|
655
|
+
{isPreparing ? copy.preparingAudio : isSpeaking ? copy.stopReading : copy.readAloud}
|
|
656
|
+
</DropdownMenuItem>
|
|
657
|
+
)
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
const MessageTimestamp: FC = () => {
|
|
661
|
+
const { t } = useI18n()
|
|
662
|
+
const createdAt = useAuiState(s => s.message.createdAt)
|
|
663
|
+
const label = formatMessageTimestamp(createdAt, t.assistant.thread)
|
|
664
|
+
|
|
665
|
+
if (!label) {
|
|
666
|
+
return null
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
return <DropdownMenuLabel className="text-xs font-normal text-muted-foreground">{label}</DropdownMenuLabel>
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
const AssistantFooter: FC<MessageActionProps> = props => (
|
|
673
|
+
<div className="flex min-h-6 flex-col items-end gap-1 pr-(--message-text-indent) pl-(--message-text-indent)">
|
|
674
|
+
<BranchPickerPrimitive.Root
|
|
675
|
+
className="inline-flex h-6 items-center gap-1 text-xs text-muted-foreground"
|
|
676
|
+
hideWhenSingleBranch
|
|
677
|
+
>
|
|
678
|
+
<BranchPickerPrimitive.Previous className="grid size-6 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:cursor-default disabled:opacity-35">
|
|
679
|
+
<Codicon name="chevron-left" size="0.875rem" />
|
|
680
|
+
</BranchPickerPrimitive.Previous>
|
|
681
|
+
<span className="tabular-nums">
|
|
682
|
+
<BranchPickerPrimitive.Number /> / <BranchPickerPrimitive.Count />
|
|
683
|
+
</span>
|
|
684
|
+
<BranchPickerPrimitive.Next className="grid size-6 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:cursor-default disabled:opacity-35">
|
|
685
|
+
<Codicon name="chevron-right" size="0.875rem" />
|
|
686
|
+
</BranchPickerPrimitive.Next>
|
|
687
|
+
</BranchPickerPrimitive.Root>
|
|
688
|
+
<AssistantActionBar {...props} />
|
|
689
|
+
</div>
|
|
690
|
+
)
|
|
691
|
+
|
|
692
|
+
const EMPTY_ATTACHMENT_REFS: string[] = []
|
|
693
|
+
|
|
694
|
+
function messageAttachmentRefs(value: unknown): string[] {
|
|
695
|
+
if (!Array.isArray(value)) {
|
|
696
|
+
return EMPTY_ATTACHMENT_REFS
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
return value.every(ref => typeof ref === 'string') ? value : EMPTY_ATTACHMENT_REFS
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function StickyHumanMessageContainer({ children }: { children: ReactNode }) {
|
|
703
|
+
return (
|
|
704
|
+
<div
|
|
705
|
+
className="group/user-message sticky z-40 -mx-4 flex w-[calc(100%+2rem)] min-w-0 max-w-none flex-col items-stretch gap-0 self-end overflow-visible bg-(--ui-chat-surface-background) px-4 pb-(--conversation-turn-gap) pt-2"
|
|
706
|
+
data-role="user"
|
|
707
|
+
data-slot="aui_user-message-root"
|
|
708
|
+
>
|
|
709
|
+
{children}
|
|
710
|
+
</div>
|
|
711
|
+
)
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// Shared "user bubble" base. Both the read-only message and the inline
|
|
715
|
+
// edit composer render the same bubble surface (rounded glass card);
|
|
716
|
+
// they only differ in border weight, cursor, and padding-right (the
|
|
717
|
+
// read-only view reserves room for the restore icon).
|
|
718
|
+
const USER_BUBBLE_BASE_CLASS =
|
|
719
|
+
'composer-human-message standalone-glass relative flex w-full min-w-0 max-w-full flex-col gap-1.5 overflow-hidden rounded-xl border bg-(--dt-user-bubble) px-3 py-2 text-left'
|
|
720
|
+
|
|
721
|
+
const USER_ACTION_ICON_BUTTON_CLASS =
|
|
722
|
+
'grid place-items-center rounded-md bg-transparent text-(--ui-text-secondary) transition-colors hover:bg-(--ui-control-active-background) hover:text-foreground disabled:cursor-default disabled:text-(--ui-text-quaternary) disabled:opacity-70'
|
|
723
|
+
|
|
724
|
+
const USER_ACTION_ICON_SIZE = '0.6875rem'
|
|
725
|
+
const StopGlyph = <IconPlayerStopFilled aria-hidden className="size-3.5 -translate-y-px" />
|
|
726
|
+
|
|
727
|
+
const UserMessage: FC<{
|
|
728
|
+
onCancel?: () => Promise<void> | void
|
|
729
|
+
}> = ({ onCancel }) => {
|
|
730
|
+
const { t } = useI18n()
|
|
731
|
+
const copy = t.assistant.thread
|
|
732
|
+
const messageId = useAuiState(s => s.message.id)
|
|
733
|
+
const content = useAuiState(s => s.message.content)
|
|
734
|
+
const messageText = messageContentText(content)
|
|
735
|
+
const threadRunning = useAuiState(s => s.thread.isRunning)
|
|
736
|
+
|
|
737
|
+
const latestUserId = useAuiState(s => {
|
|
738
|
+
for (let i = s.thread.messages.length - 1; i >= 0; i--) {
|
|
739
|
+
const message = s.thread.messages[i] as { id?: string; role?: string }
|
|
740
|
+
|
|
741
|
+
if (message.role === 'user') {
|
|
742
|
+
return message.id ?? null
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
return null
|
|
747
|
+
})
|
|
748
|
+
|
|
749
|
+
const attachmentRefs = useAuiState(s => {
|
|
750
|
+
const custom = (s.message.metadata?.custom ?? {}) as { attachmentRefs?: unknown }
|
|
751
|
+
|
|
752
|
+
return messageAttachmentRefs(custom.attachmentRefs)
|
|
753
|
+
})
|
|
754
|
+
|
|
755
|
+
// Sticky human bubbles clamp to ~2 lines with a soft fade so a long prompt
|
|
756
|
+
// doesn't dominate the viewport while the response streams underneath; the
|
|
757
|
+
// clamp lifts on hover / focus (see styles.css). We measure the *unclamped*
|
|
758
|
+
// inner wrapper so the ResizeObserver only fires on real content / width
|
|
759
|
+
// changes, not on every frame while the outer max-height animates open.
|
|
760
|
+
const clampInnerRef = useRef<HTMLDivElement | null>(null)
|
|
761
|
+
const [bodyClamped, setBodyClamped] = useState(false)
|
|
762
|
+
|
|
763
|
+
const measureClamp = useCallback(() => {
|
|
764
|
+
const inner = clampInnerRef.current
|
|
765
|
+
const outer = inner?.parentElement
|
|
766
|
+
|
|
767
|
+
if (!inner || !outer) {
|
|
768
|
+
return
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
const styles = getComputedStyle(inner)
|
|
772
|
+
const lineHeight = parseFloat(styles.lineHeight) || 1.5 * parseFloat(styles.fontSize) || 20
|
|
773
|
+
const fullHeight = inner.scrollHeight
|
|
774
|
+
|
|
775
|
+
outer.style.setProperty('--human-msg-full', `${fullHeight}px`)
|
|
776
|
+
setBodyClamped(fullHeight > lineHeight * 2 + 1)
|
|
777
|
+
}, [])
|
|
778
|
+
|
|
779
|
+
useResizeObserver(measureClamp, clampInnerRef)
|
|
780
|
+
|
|
781
|
+
const hasBody = messageText.trim().length > 0
|
|
782
|
+
const isLatestUser = messageId === latestUserId
|
|
783
|
+
const showStop = isLatestUser && threadRunning && Boolean(onCancel)
|
|
784
|
+
const showRestore = !isLatestUser && !threadRunning
|
|
785
|
+
|
|
786
|
+
const bubbleClassName = cn(
|
|
787
|
+
USER_BUBBLE_BASE_CLASS,
|
|
788
|
+
'border-(--ui-stroke-tertiary) pr-9 text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground/95 transition-colors',
|
|
789
|
+
!threadRunning && 'cursor-pointer hover:border-(--ui-stroke-secondary)'
|
|
790
|
+
)
|
|
791
|
+
|
|
792
|
+
const bubbleContent = (
|
|
793
|
+
<>
|
|
794
|
+
{attachmentRefs.length > 0 && (
|
|
795
|
+
<span className="-mx-1 flex flex-wrap gap-1 border-b border-border/45 pb-1.5">
|
|
796
|
+
<DirectiveContent text={attachmentRefs.join(' ')} />
|
|
797
|
+
</span>
|
|
798
|
+
)}
|
|
799
|
+
{hasBody && (
|
|
800
|
+
// Render the user's text through a minimal markdown pipeline:
|
|
801
|
+
// backtick `code` and ``` fenced ``` blocks, with directive chips
|
|
802
|
+
// (`@file:` etc.) still resolved inside the plain-text spans.
|
|
803
|
+
<div className="sticky-human-clamp" data-clamped={bodyClamped ? 'true' : undefined}>
|
|
804
|
+
<div ref={clampInnerRef}>
|
|
805
|
+
<UserMessageText className="wrap-anywhere" text={messageText} />
|
|
806
|
+
</div>
|
|
807
|
+
</div>
|
|
808
|
+
)}
|
|
809
|
+
</>
|
|
810
|
+
)
|
|
811
|
+
|
|
812
|
+
return (
|
|
813
|
+
<MessagePrimitive.Root asChild>
|
|
814
|
+
<StickyHumanMessageContainer>
|
|
815
|
+
<ActionBarPrimitive.Root className="relative w-full max-w-full" data-slot="aui_user-bubble-actions">
|
|
816
|
+
<div className="human-message-with-todos-wrapper flex w-full flex-col gap-0">
|
|
817
|
+
<div className="relative w-full">
|
|
818
|
+
{threadRunning ? (
|
|
819
|
+
<div className={bubbleClassName}>{bubbleContent}</div>
|
|
820
|
+
) : (
|
|
821
|
+
<ActionBarPrimitive.Edit asChild>
|
|
822
|
+
<button
|
|
823
|
+
aria-label={copy.editMessage}
|
|
824
|
+
className={bubbleClassName}
|
|
825
|
+
onClick={() => triggerHaptic('selection')}
|
|
826
|
+
title={copy.editMessage}
|
|
827
|
+
type="button"
|
|
828
|
+
>
|
|
829
|
+
{bubbleContent}
|
|
830
|
+
</button>
|
|
831
|
+
</ActionBarPrimitive.Edit>
|
|
832
|
+
)}
|
|
833
|
+
{(showStop || showRestore) && (
|
|
834
|
+
<div className="pointer-events-none absolute right-2 bottom-2 z-10 flex items-center justify-center opacity-0 transition-opacity group-hover/user-message:opacity-100 group-focus-within/user-message:opacity-100">
|
|
835
|
+
{showStop ? (
|
|
836
|
+
<button
|
|
837
|
+
aria-label={copy.stop}
|
|
838
|
+
className={cn('pointer-events-auto size-5', USER_ACTION_ICON_BUTTON_CLASS)}
|
|
839
|
+
onClick={event => {
|
|
840
|
+
event.preventDefault()
|
|
841
|
+
event.stopPropagation()
|
|
842
|
+
void onCancel?.()
|
|
843
|
+
}}
|
|
844
|
+
title={copy.stop}
|
|
845
|
+
type="button"
|
|
846
|
+
>
|
|
847
|
+
{StopGlyph}
|
|
848
|
+
</button>
|
|
849
|
+
) : (
|
|
850
|
+
<span
|
|
851
|
+
aria-hidden="true"
|
|
852
|
+
className="flex size-6 items-center justify-center rounded-md text-(--ui-text-tertiary)"
|
|
853
|
+
title={copy.editableCheckpoint}
|
|
854
|
+
>
|
|
855
|
+
<Codicon name="discard" size="0.875rem" />
|
|
856
|
+
</span>
|
|
857
|
+
)}
|
|
858
|
+
</div>
|
|
859
|
+
)}
|
|
860
|
+
</div>
|
|
861
|
+
<BranchPickerPrimitive.Root
|
|
862
|
+
className="checkpoint-container flex items-center gap-1 pb-0 pt-1 pl-1.5 text-[0.75rem] leading-none text-(--ui-text-tertiary)"
|
|
863
|
+
hideWhenSingleBranch
|
|
864
|
+
>
|
|
865
|
+
<span aria-hidden className="checkpoint-icon size-1.5 rounded-full border border-current" />
|
|
866
|
+
<BranchPickerPrimitive.Previous
|
|
867
|
+
className="checkpoint-restore-text rounded-sm bg-transparent px-1 opacity-65 hover:opacity-100 disabled:hidden disabled:cursor-default"
|
|
868
|
+
title={copy.restorePrevious}
|
|
869
|
+
>
|
|
870
|
+
{copy.restoreCheckpoint}
|
|
871
|
+
</BranchPickerPrimitive.Previous>
|
|
872
|
+
<span className="checkpoint-divider opacity-55">
|
|
873
|
+
<BranchPickerPrimitive.Number />/<BranchPickerPrimitive.Count />
|
|
874
|
+
</span>
|
|
875
|
+
<BranchPickerPrimitive.Next
|
|
876
|
+
className="checkpoint-restore-text rounded-sm bg-transparent px-1 opacity-65 hover:opacity-100 disabled:hidden disabled:cursor-default"
|
|
877
|
+
title={copy.restoreNext}
|
|
878
|
+
>
|
|
879
|
+
{copy.goForward}
|
|
880
|
+
</BranchPickerPrimitive.Next>
|
|
881
|
+
</BranchPickerPrimitive.Root>
|
|
882
|
+
</div>
|
|
883
|
+
</ActionBarPrimitive.Root>
|
|
884
|
+
</StickyHumanMessageContainer>
|
|
885
|
+
</MessagePrimitive.Root>
|
|
886
|
+
)
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
const SLASH_STATUS_RE = /^slash:(?<command>\/[^\n]+)\n(?<output>[\s\S]*)$/
|
|
890
|
+
const STEER_NOTE_RE = /^steer:(?<text>[\s\S]+)$/
|
|
891
|
+
|
|
892
|
+
const SystemMessage: FC = () => {
|
|
893
|
+
const text = useAuiState(s => messageContentText(s.message.content))
|
|
894
|
+
|
|
895
|
+
if (!text) {
|
|
896
|
+
return null
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
const steerNote = text.match(STEER_NOTE_RE)
|
|
900
|
+
|
|
901
|
+
if (steerNote?.groups) {
|
|
902
|
+
return (
|
|
903
|
+
<MessagePrimitive.Root
|
|
904
|
+
className="flex max-w-[min(86%,44rem)] items-center gap-1.5 self-center px-2 py-0.5 text-[0.6875rem] leading-5 text-muted-foreground/60"
|
|
905
|
+
data-role="system"
|
|
906
|
+
data-slot="aui_system-message-root"
|
|
907
|
+
>
|
|
908
|
+
<Codicon className="text-muted-foreground/55" name="compass" size="0.75rem" />
|
|
909
|
+
<span className="text-muted-foreground/55">steered</span>
|
|
910
|
+
<span className="text-muted-foreground/35">·</span>
|
|
911
|
+
<span className="whitespace-pre-wrap">{steerNote.groups.text.trim()}</span>
|
|
912
|
+
</MessagePrimitive.Root>
|
|
913
|
+
)
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
const slashStatus = text.match(SLASH_STATUS_RE)
|
|
917
|
+
|
|
918
|
+
if (slashStatus?.groups) {
|
|
919
|
+
return (
|
|
920
|
+
<MessagePrimitive.Root
|
|
921
|
+
className="max-w-[min(86%,44rem)] self-center px-2 py-0.5 text-center text-[0.6875rem] leading-5 text-muted-foreground/60"
|
|
922
|
+
data-role="system"
|
|
923
|
+
data-slot="aui_system-message-root"
|
|
924
|
+
>
|
|
925
|
+
<span className="font-mono text-muted-foreground/55">{slashStatus.groups.command}</span>
|
|
926
|
+
<span className="mx-1.5 text-muted-foreground/35">·</span>
|
|
927
|
+
<span className="whitespace-pre-wrap">{slashStatus.groups.output.trim()}</span>
|
|
928
|
+
</MessagePrimitive.Root>
|
|
929
|
+
)
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
return (
|
|
933
|
+
<MessagePrimitive.Root
|
|
934
|
+
className="max-w-[min(86%,44rem)] self-center px-2 py-0.5 text-center text-[0.6875rem] leading-5 text-muted-foreground/55"
|
|
935
|
+
data-role="system"
|
|
936
|
+
data-slot="aui_system-message-root"
|
|
937
|
+
>
|
|
938
|
+
<span className="whitespace-pre-wrap">{text}</span>
|
|
939
|
+
</MessagePrimitive.Root>
|
|
940
|
+
)
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
interface UserEditComposerProps {
|
|
944
|
+
cwd: string | null
|
|
945
|
+
gateway: NasTechGateway | null
|
|
946
|
+
sessionId: string | null
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }) => {
|
|
950
|
+
const { t } = useI18n()
|
|
951
|
+
const copy = t.assistant.thread
|
|
952
|
+
const aui = useAui()
|
|
953
|
+
const draft = useAuiState(s => s.composer.text)
|
|
954
|
+
const rootRef = useRef<HTMLDivElement | null>(null)
|
|
955
|
+
const editorRef = useRef<HTMLDivElement | null>(null)
|
|
956
|
+
const draftRef = useRef(draft)
|
|
957
|
+
const dragDepthRef = useRef(0)
|
|
958
|
+
const [dragActive, setDragActive] = useState(false)
|
|
959
|
+
const [trigger, setTrigger] = useState<TriggerState | null>(null)
|
|
960
|
+
const [triggerActive, setTriggerActive] = useState(0)
|
|
961
|
+
const [triggerItems, setTriggerItems] = useState<readonly Unstable_TriggerItem[]>([])
|
|
962
|
+
// See index.tsx: set in keydown when the open popover consumes a nav/control
|
|
963
|
+
// key so the matching keyup skips refreshTrigger (timing-immune vs reading
|
|
964
|
+
// `trigger`, which keyup sees as already-null after Escape).
|
|
965
|
+
const triggerKeyConsumedRef = useRef(false)
|
|
966
|
+
const [triggerPlacement, setTriggerPlacement] = useState<'bottom' | 'top'>('top')
|
|
967
|
+
const [focusRequestId, setFocusRequestId] = useState(0)
|
|
968
|
+
const [submitting, setSubmitting] = useState(false)
|
|
969
|
+
const expanded = draft.includes('\n')
|
|
970
|
+
const canSubmit = draft.trim().length > 0
|
|
971
|
+
const at = useAtCompletions({ cwd, gateway, sessionId })
|
|
972
|
+
const slash = useSlashCompletions({ gateway })
|
|
973
|
+
|
|
974
|
+
const focusEditor = useCallback(() => {
|
|
975
|
+
const editor = editorRef.current
|
|
976
|
+
|
|
977
|
+
focusComposerInput(editor)
|
|
978
|
+
|
|
979
|
+
if (editor) {
|
|
980
|
+
placeCaretEnd(editor)
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
markActiveComposer('edit')
|
|
984
|
+
}, [])
|
|
985
|
+
|
|
986
|
+
const requestEditFocus = useCallback(() => {
|
|
987
|
+
setFocusRequestId(id => id + 1)
|
|
988
|
+
}, [])
|
|
989
|
+
|
|
990
|
+
const appendExternalText = useCallback(
|
|
991
|
+
(text: string, mode: ComposerInsertMode) => {
|
|
992
|
+
const value = text.trim()
|
|
993
|
+
|
|
994
|
+
if (!value) {
|
|
995
|
+
return
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
const base = mode === 'inline' ? draftRef.current.trimEnd() : draftRef.current
|
|
999
|
+
const sep = mode === 'inline' ? (base ? ' ' : '') : base && !base.endsWith('\n') ? '\n\n' : ''
|
|
1000
|
+
const next = `${base}${sep}${value}`
|
|
1001
|
+
|
|
1002
|
+
draftRef.current = next
|
|
1003
|
+
aui.composer().setText(next)
|
|
1004
|
+
|
|
1005
|
+
const editor = editorRef.current
|
|
1006
|
+
|
|
1007
|
+
if (editor) {
|
|
1008
|
+
renderComposerContents(editor, next)
|
|
1009
|
+
placeCaretEnd(editor)
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
setFocusRequestId(id => id + 1)
|
|
1013
|
+
},
|
|
1014
|
+
[aui]
|
|
1015
|
+
)
|
|
1016
|
+
|
|
1017
|
+
useEffect(() => {
|
|
1018
|
+
draftRef.current = draft
|
|
1019
|
+
|
|
1020
|
+
const editor = editorRef.current
|
|
1021
|
+
|
|
1022
|
+
if (
|
|
1023
|
+
editor &&
|
|
1024
|
+
(editor.childNodes.length === 0 || (document.activeElement !== editor && composerPlainText(editor) !== draft))
|
|
1025
|
+
) {
|
|
1026
|
+
renderComposerContents(editor, draft)
|
|
1027
|
+
|
|
1028
|
+
if (document.activeElement === editor) {
|
|
1029
|
+
placeCaretEnd(editor)
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
}, [draft])
|
|
1033
|
+
|
|
1034
|
+
useEffect(() => {
|
|
1035
|
+
focusEditor()
|
|
1036
|
+
}, [focusEditor, focusRequestId])
|
|
1037
|
+
|
|
1038
|
+
useEffect(() => {
|
|
1039
|
+
const offFocus = onComposerFocusRequest(target => {
|
|
1040
|
+
if (target === 'edit') {
|
|
1041
|
+
setFocusRequestId(id => id + 1)
|
|
1042
|
+
}
|
|
1043
|
+
})
|
|
1044
|
+
|
|
1045
|
+
const offInsert = onComposerInsertRequest(({ mode, target, text }) => {
|
|
1046
|
+
if (target === 'edit') {
|
|
1047
|
+
appendExternalText(text, mode)
|
|
1048
|
+
}
|
|
1049
|
+
})
|
|
1050
|
+
|
|
1051
|
+
return () => {
|
|
1052
|
+
offFocus()
|
|
1053
|
+
offInsert()
|
|
1054
|
+
}
|
|
1055
|
+
}, [appendExternalText])
|
|
1056
|
+
|
|
1057
|
+
const syncDraftFromEditor = useCallback(
|
|
1058
|
+
(editor: HTMLDivElement) => {
|
|
1059
|
+
const nextDraft = composerPlainText(editor)
|
|
1060
|
+
|
|
1061
|
+
if (nextDraft !== draftRef.current) {
|
|
1062
|
+
draftRef.current = nextDraft
|
|
1063
|
+
aui.composer().setText(nextDraft)
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
return nextDraft
|
|
1067
|
+
},
|
|
1068
|
+
[aui]
|
|
1069
|
+
)
|
|
1070
|
+
|
|
1071
|
+
const refreshTrigger = useCallback(() => {
|
|
1072
|
+
const editor = editorRef.current
|
|
1073
|
+
|
|
1074
|
+
if (!editor) {
|
|
1075
|
+
return
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
const before = textBeforeCaret(editor)
|
|
1079
|
+
const detected = detectTrigger(before ?? composerPlainText(editor))
|
|
1080
|
+
|
|
1081
|
+
if (detected) {
|
|
1082
|
+
const rect = editor.getBoundingClientRect()
|
|
1083
|
+
const spaceAbove = rect.top
|
|
1084
|
+
const spaceBelow = window.innerHeight - rect.bottom
|
|
1085
|
+
|
|
1086
|
+
setTriggerPlacement(spaceAbove < 220 && spaceBelow > spaceAbove ? 'bottom' : 'top')
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
setTrigger(detected)
|
|
1090
|
+
|
|
1091
|
+
// Only reset the highlight when the trigger actually changed (opened, or
|
|
1092
|
+
// the query/kind differs). Re-detecting the *same* trigger — e.g. on a
|
|
1093
|
+
// caret move (mouseup) or a stray refresh — must preserve the user's
|
|
1094
|
+
// current selection instead of snapping back to the first item.
|
|
1095
|
+
if (detected?.kind !== trigger?.kind || detected?.query !== trigger?.query) {
|
|
1096
|
+
setTriggerActive(0)
|
|
1097
|
+
}
|
|
1098
|
+
}, [trigger])
|
|
1099
|
+
|
|
1100
|
+
const closeTrigger = useCallback(() => {
|
|
1101
|
+
setTrigger(null)
|
|
1102
|
+
setTriggerItems([])
|
|
1103
|
+
setTriggerActive(0)
|
|
1104
|
+
}, [])
|
|
1105
|
+
|
|
1106
|
+
const triggerAdapter: Unstable_TriggerAdapter | null =
|
|
1107
|
+
trigger?.kind === '@' ? at.adapter : trigger?.kind === '/' ? slash.adapter : null
|
|
1108
|
+
|
|
1109
|
+
useEffect(() => {
|
|
1110
|
+
if (!trigger || !triggerAdapter?.search) {
|
|
1111
|
+
setTriggerItems([])
|
|
1112
|
+
|
|
1113
|
+
return
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
setTriggerItems(triggerAdapter.search(trigger.query))
|
|
1117
|
+
}, [trigger, triggerAdapter])
|
|
1118
|
+
|
|
1119
|
+
useEffect(() => {
|
|
1120
|
+
setTriggerActive(idx => Math.min(idx, Math.max(0, triggerItems.length - 1)))
|
|
1121
|
+
}, [triggerItems.length])
|
|
1122
|
+
|
|
1123
|
+
const triggerLoading = trigger?.kind === '@' ? at.loading : trigger?.kind === '/' ? slash.loading : false
|
|
1124
|
+
|
|
1125
|
+
const replaceTriggerWithChip = useCallback(
|
|
1126
|
+
(item: Unstable_TriggerItem) => {
|
|
1127
|
+
const editor = editorRef.current
|
|
1128
|
+
|
|
1129
|
+
if (!editor || !trigger) {
|
|
1130
|
+
return
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
const serialized = NASTECHDirectiveFormatter.serialize(item)
|
|
1134
|
+
const starter = serialized.endsWith(':')
|
|
1135
|
+
const text = starter || serialized.endsWith(' ') ? serialized : `${serialized} `
|
|
1136
|
+
const directive = !starter && serialized.match(/^@([^:]+):(.+)$/)
|
|
1137
|
+
|
|
1138
|
+
const finish = () => {
|
|
1139
|
+
draftRef.current = composerPlainText(editor)
|
|
1140
|
+
aui.composer().setText(draftRef.current)
|
|
1141
|
+
requestEditFocus()
|
|
1142
|
+
starter ? window.setTimeout(refreshTrigger, 0) : closeTrigger()
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
const sel = window.getSelection()
|
|
1146
|
+
const range = sel?.rangeCount ? sel.getRangeAt(0) : null
|
|
1147
|
+
const node = range?.startContainer
|
|
1148
|
+
const offset = range?.startOffset ?? 0
|
|
1149
|
+
|
|
1150
|
+
if (!sel || !range || node?.nodeType !== Node.TEXT_NODE || offset < trigger.tokenLength) {
|
|
1151
|
+
const current = composerPlainText(editor)
|
|
1152
|
+
renderComposerContents(editor, `${current.slice(0, Math.max(0, current.length - trigger.tokenLength))}${text}`)
|
|
1153
|
+
placeCaretEnd(editor)
|
|
1154
|
+
|
|
1155
|
+
return finish()
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
const replaceRange = document.createRange()
|
|
1159
|
+
replaceRange.setStart(node, offset - trigger.tokenLength)
|
|
1160
|
+
replaceRange.setEnd(node, offset)
|
|
1161
|
+
replaceRange.deleteContents()
|
|
1162
|
+
|
|
1163
|
+
if (directive) {
|
|
1164
|
+
const chip = refChipElement(directive[1], directive[2])
|
|
1165
|
+
const space = document.createTextNode(' ')
|
|
1166
|
+
const fragment = document.createDocumentFragment()
|
|
1167
|
+
fragment.append(chip, space)
|
|
1168
|
+
replaceRange.insertNode(fragment)
|
|
1169
|
+
|
|
1170
|
+
const caret = document.createRange()
|
|
1171
|
+
caret.setStart(space, 1)
|
|
1172
|
+
caret.collapse(true)
|
|
1173
|
+
sel.removeAllRanges()
|
|
1174
|
+
sel.addRange(caret)
|
|
1175
|
+
|
|
1176
|
+
return finish()
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
document.execCommand('insertText', false, text)
|
|
1180
|
+
finish()
|
|
1181
|
+
},
|
|
1182
|
+
[aui, closeTrigger, refreshTrigger, requestEditFocus, trigger]
|
|
1183
|
+
)
|
|
1184
|
+
|
|
1185
|
+
const insertDroppedRefs = useCallback(
|
|
1186
|
+
(candidates: ReturnType<typeof extractDroppedFiles>) => {
|
|
1187
|
+
const editor = editorRef.current
|
|
1188
|
+
|
|
1189
|
+
if (!editor) {
|
|
1190
|
+
return false
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
const refs = candidates
|
|
1194
|
+
.map(candidate => droppedFileInlineRef(candidate, cwd))
|
|
1195
|
+
.filter((ref): ref is string => Boolean(ref))
|
|
1196
|
+
|
|
1197
|
+
const nextDraft = insertInlineRefsIntoEditor(editor, refs)
|
|
1198
|
+
|
|
1199
|
+
if (nextDraft === null) {
|
|
1200
|
+
return false
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
draftRef.current = nextDraft
|
|
1204
|
+
aui.composer().setText(nextDraft)
|
|
1205
|
+
requestEditFocus()
|
|
1206
|
+
|
|
1207
|
+
return true
|
|
1208
|
+
},
|
|
1209
|
+
[aui, cwd, requestEditFocus]
|
|
1210
|
+
)
|
|
1211
|
+
|
|
1212
|
+
const resetDragState = useCallback(() => {
|
|
1213
|
+
dragDepthRef.current = 0
|
|
1214
|
+
setDragActive(false)
|
|
1215
|
+
}, [])
|
|
1216
|
+
|
|
1217
|
+
const handleDragEnter = (event: ReactDragEvent<HTMLElement>) => {
|
|
1218
|
+
if (!dragHasAttachments(event.dataTransfer, NASTECH_PATHS_MIME)) {
|
|
1219
|
+
return
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
event.preventDefault()
|
|
1223
|
+
dragDepthRef.current += 1
|
|
1224
|
+
|
|
1225
|
+
if (!dragActive) {
|
|
1226
|
+
setDragActive(true)
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
const handleDragOver = (event: ReactDragEvent<HTMLElement>) => {
|
|
1231
|
+
if (!dragHasAttachments(event.dataTransfer, NASTECH_PATHS_MIME)) {
|
|
1232
|
+
return
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
event.preventDefault()
|
|
1236
|
+
event.dataTransfer.dropEffect = 'copy'
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
const handleDragLeave = (event: ReactDragEvent<HTMLElement>) => {
|
|
1240
|
+
event.preventDefault()
|
|
1241
|
+
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1)
|
|
1242
|
+
|
|
1243
|
+
if (dragDepthRef.current === 0) {
|
|
1244
|
+
setDragActive(false)
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
const handleDrop = (event: ReactDragEvent<HTMLElement>) => {
|
|
1249
|
+
if (!dragHasAttachments(event.dataTransfer, NASTECH_PATHS_MIME)) {
|
|
1250
|
+
return
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
const candidates = extractDroppedFiles(event.dataTransfer)
|
|
1254
|
+
|
|
1255
|
+
if (!candidates.length) {
|
|
1256
|
+
return
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
event.preventDefault()
|
|
1260
|
+
event.stopPropagation()
|
|
1261
|
+
resetDragState()
|
|
1262
|
+
|
|
1263
|
+
if (insertDroppedRefs(candidates)) {
|
|
1264
|
+
triggerHaptic('selection')
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
const handleInput = (event: FormEvent<HTMLDivElement>) => {
|
|
1269
|
+
const editor = event.currentTarget
|
|
1270
|
+
|
|
1271
|
+
if (editor.childNodes.length === 1 && editor.firstChild?.nodeName === 'BR') {
|
|
1272
|
+
editor.replaceChildren()
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
syncDraftFromEditor(editor)
|
|
1276
|
+
window.setTimeout(refreshTrigger, 0)
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => {
|
|
1280
|
+
const pastedText = event.clipboardData.getData('text')
|
|
1281
|
+
|
|
1282
|
+
if (!pastedText || DATA_IMAGE_URL_RE.test(pastedText.trim())) {
|
|
1283
|
+
event.preventDefault()
|
|
1284
|
+
|
|
1285
|
+
return
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
event.preventDefault()
|
|
1289
|
+
document.execCommand('insertText', false, pastedText)
|
|
1290
|
+
syncDraftFromEditor(event.currentTarget)
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
const submitEdit = (editor: HTMLDivElement) => {
|
|
1294
|
+
const nextDraft = syncDraftFromEditor(editor)
|
|
1295
|
+
|
|
1296
|
+
if (submitting || !nextDraft.trim()) {
|
|
1297
|
+
return
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
setSubmitting(true)
|
|
1301
|
+
aui.composer().send()
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
const handleEditBlur = useCallback(
|
|
1305
|
+
(event: FocusEvent<HTMLDivElement>) => {
|
|
1306
|
+
const nextTarget = event.relatedTarget
|
|
1307
|
+
|
|
1308
|
+
if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) {
|
|
1309
|
+
return
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
window.setTimeout(() => {
|
|
1313
|
+
const root = rootRef.current
|
|
1314
|
+
const active = document.activeElement
|
|
1315
|
+
|
|
1316
|
+
if (submitting || (root && active && root.contains(active))) {
|
|
1317
|
+
return
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
closeTrigger()
|
|
1321
|
+
aui.composer().cancel()
|
|
1322
|
+
}, 80)
|
|
1323
|
+
},
|
|
1324
|
+
[aui, closeTrigger, submitting]
|
|
1325
|
+
)
|
|
1326
|
+
|
|
1327
|
+
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
|
1328
|
+
if (trigger && triggerItems.length > 0) {
|
|
1329
|
+
if (event.key === 'ArrowDown') {
|
|
1330
|
+
event.preventDefault()
|
|
1331
|
+
triggerKeyConsumedRef.current = true
|
|
1332
|
+
setTriggerActive(idx => (idx + 1) % triggerItems.length)
|
|
1333
|
+
|
|
1334
|
+
return
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
if (event.key === 'ArrowUp') {
|
|
1338
|
+
event.preventDefault()
|
|
1339
|
+
triggerKeyConsumedRef.current = true
|
|
1340
|
+
setTriggerActive(idx => (idx - 1 + triggerItems.length) % triggerItems.length)
|
|
1341
|
+
|
|
1342
|
+
return
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
if (event.key === 'Enter' || event.key === 'Tab') {
|
|
1346
|
+
event.preventDefault()
|
|
1347
|
+
triggerKeyConsumedRef.current = true
|
|
1348
|
+
const item = triggerItems[triggerActive]
|
|
1349
|
+
|
|
1350
|
+
if (item) {
|
|
1351
|
+
replaceTriggerWithChip(item)
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
return
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
if (event.key === 'Escape') {
|
|
1358
|
+
event.preventDefault()
|
|
1359
|
+
triggerKeyConsumedRef.current = true
|
|
1360
|
+
closeTrigger()
|
|
1361
|
+
|
|
1362
|
+
return
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
if (event.key === 'Escape') {
|
|
1367
|
+
event.preventDefault()
|
|
1368
|
+
aui.composer().cancel()
|
|
1369
|
+
|
|
1370
|
+
return
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
if (event.key === 'Enter' && !event.shiftKey) {
|
|
1374
|
+
event.preventDefault()
|
|
1375
|
+
submitEdit(event.currentTarget)
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
const handleKeyUp = () => {
|
|
1380
|
+
// If this keyup belongs to a key the open trigger popover already consumed
|
|
1381
|
+
// in keydown (Arrow/Enter/Tab/Escape), skip the refresh. Those keys never
|
|
1382
|
+
// edit text, and for Escape the keydown already closed the menu — a refresh
|
|
1383
|
+
// here would re-detect the still-present `/` and instantly reopen it. We
|
|
1384
|
+
// read a ref set during keydown rather than `trigger`, because by keyup
|
|
1385
|
+
// time React has re-rendered and `trigger` may already be null.
|
|
1386
|
+
if (triggerKeyConsumedRef.current) {
|
|
1387
|
+
triggerKeyConsumedRef.current = false
|
|
1388
|
+
|
|
1389
|
+
return
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
window.setTimeout(refreshTrigger, 0)
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
return (
|
|
1396
|
+
<ComposerPrimitive.Root className="contents" data-slot="aui_edit-composer-root">
|
|
1397
|
+
<StickyHumanMessageContainer>
|
|
1398
|
+
<div
|
|
1399
|
+
className="composer-human-message-container human-execution-message-top relative flex w-full items-start rounded-md bg-(--ui-chat-surface-background)"
|
|
1400
|
+
onBlur={handleEditBlur}
|
|
1401
|
+
onDragEnter={handleDragEnter}
|
|
1402
|
+
onDragLeave={handleDragLeave}
|
|
1403
|
+
onDragOver={handleDragOver}
|
|
1404
|
+
onDrop={handleDrop}
|
|
1405
|
+
ref={rootRef}
|
|
1406
|
+
>
|
|
1407
|
+
{trigger && (
|
|
1408
|
+
<ComposerTriggerPopover
|
|
1409
|
+
activeIndex={triggerActive}
|
|
1410
|
+
items={triggerItems}
|
|
1411
|
+
kind={trigger.kind}
|
|
1412
|
+
loading={triggerLoading}
|
|
1413
|
+
onHover={setTriggerActive}
|
|
1414
|
+
onPick={replaceTriggerWithChip}
|
|
1415
|
+
placement={triggerPlacement}
|
|
1416
|
+
/>
|
|
1417
|
+
)}
|
|
1418
|
+
<div
|
|
1419
|
+
className={cn(
|
|
1420
|
+
USER_BUBBLE_BASE_CLASS,
|
|
1421
|
+
'ui-prompt-input__container relative border-(--ui-stroke-secondary) data-[expanded=true]:min-h-20',
|
|
1422
|
+
COMPOSER_DROP_FADE_CLASS,
|
|
1423
|
+
dragActive && COMPOSER_DROP_ACTIVE_CLASS
|
|
1424
|
+
)}
|
|
1425
|
+
data-expanded={expanded ? 'true' : undefined}
|
|
1426
|
+
>
|
|
1427
|
+
<div
|
|
1428
|
+
aria-label={copy.editMessage}
|
|
1429
|
+
autoFocus
|
|
1430
|
+
className={cn(
|
|
1431
|
+
'ui-prompt-input-editor__input max-h-48 w-full resize-none bg-transparent p-0 pr-7 text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground/95 outline-none',
|
|
1432
|
+
'empty:before:content-[attr(data-placeholder)] empty:before:text-muted-foreground/60',
|
|
1433
|
+
'**:data-ref-text:cursor-default',
|
|
1434
|
+
expanded ? 'min-h-16' : 'min-h-[1.25rem]'
|
|
1435
|
+
)}
|
|
1436
|
+
contentEditable
|
|
1437
|
+
data-placeholder={copy.editMessage}
|
|
1438
|
+
data-slot={RICH_INPUT_SLOT}
|
|
1439
|
+
onBlur={() => window.setTimeout(closeTrigger, 80)}
|
|
1440
|
+
onDragOver={handleDragOver}
|
|
1441
|
+
onDrop={handleDrop}
|
|
1442
|
+
onFocus={() => markActiveComposer('edit')}
|
|
1443
|
+
onInput={handleInput}
|
|
1444
|
+
onKeyDown={handleKeyDown}
|
|
1445
|
+
onKeyUp={handleKeyUp}
|
|
1446
|
+
onMouseUp={refreshTrigger}
|
|
1447
|
+
onPaste={handlePaste}
|
|
1448
|
+
ref={editorRef}
|
|
1449
|
+
role="textbox"
|
|
1450
|
+
suppressContentEditableWarning
|
|
1451
|
+
/>
|
|
1452
|
+
<ComposerPrimitive.Input className="sr-only" tabIndex={-1} unstable_focusOnScrollToBottom={false} />
|
|
1453
|
+
<button
|
|
1454
|
+
aria-label={copy.sendEdited}
|
|
1455
|
+
className={cn('absolute right-2 bottom-2 size-5', USER_ACTION_ICON_BUTTON_CLASS)}
|
|
1456
|
+
disabled={!canSubmit || submitting}
|
|
1457
|
+
onClick={() => {
|
|
1458
|
+
const editor = editorRef.current
|
|
1459
|
+
|
|
1460
|
+
if (editor) {
|
|
1461
|
+
submitEdit(editor)
|
|
1462
|
+
}
|
|
1463
|
+
}}
|
|
1464
|
+
title={copy.sendEdited}
|
|
1465
|
+
type="button"
|
|
1466
|
+
>
|
|
1467
|
+
{submitting ? StopGlyph : <Codicon name="arrow-up" size={USER_ACTION_ICON_SIZE} />}
|
|
1468
|
+
</button>
|
|
1469
|
+
</div>
|
|
1470
|
+
</div>
|
|
1471
|
+
</StickyHumanMessageContainer>
|
|
1472
|
+
</ComposerPrimitive.Root>
|
|
1473
|
+
)
|
|
1474
|
+
}
|