From 76520d02592d35a39cdd9226955c6a369fa679d6 Mon Sep 17 00:00:00 2001 From: Emt-lin <41323133+Emt-lin@users.noreply.github.com> Date: Wed, 4 Feb 2026 09:37:33 +0800 Subject: [PATCH] Add Editor "Quick Ask" Floating Panel + Persistent Selection Highlights (#2139) * feat: implement floating quick command with AI-powered inline editing Add a new floating quick command feature that allows users to interact with AI directly from the editor. Key features include: - Quick Ask panel with draggable modal interface - Multiple interaction modes: ask, edit, and edit-direct - Selection highlight and replace guard for safe text operations - Streaming chat session support for real-time AI responses - Context menu integration for quick access - Resizable and draggable UI components # Conflicts: # src/commands/CustomCommandChatModal.tsx # src/styles/tailwind.css * feat: add persistent selection highlight for Chat panel * refactor: extract persistent highlight factory and fix state consistency * fix: improve popup positioning with flip logic and line-start trap fix - Use selection.head instead of selection.to for anchor point - Fix line-start trap: only apply when head is selection end - Add flip logic: show above when not enough space below - Add horizontal visibility check - Single-line selection: center popup horizontally - Multi-line selection: follow head position - Cursor mode: align near cursor instead of centering --- package-lock.json | 602 ++++++++++- package.json | 4 +- src/commands/CustomCommandChatModal.tsx | 943 +++++++++++------- src/commands/contextMenu.ts | 54 +- src/commands/customCommandChatEngine.ts | 63 ++ src/commands/customCommandUtils.ts | 29 +- src/commands/index.ts | 78 +- src/commands/quickCommandPrompts.ts | 63 ++ src/components/Chat.tsx | 33 +- src/components/QuickCommand.tsx | 216 ---- .../chat-components/ChatSingleMessage.tsx | 3 +- .../hooks/useAtMentionCategories.tsx | 8 +- .../hooks/useAtMentionSearch.ts | 4 +- .../plugins/KeyboardPlugin.tsx | 87 +- .../plugins/ValueSyncPlugin.tsx | 5 +- src/components/command-ui/action-buttons.tsx | 68 ++ src/components/command-ui/chat-message.tsx | 75 ++ src/components/command-ui/close-button.tsx | 27 + src/components/command-ui/command-label.tsx | 37 + src/components/command-ui/content-area.tsx | 107 ++ src/components/command-ui/drag-handle.tsx | 28 + src/components/command-ui/draggable-modal.tsx | 228 +++++ src/components/command-ui/follow-up-input.tsx | 89 ++ src/components/command-ui/index.ts | 12 + .../command-ui/menu-command-modal.tsx | 207 ++++ .../command-ui/selected-content.tsx | 49 + src/components/quick-ask/ModeSelector.tsx | 75 ++ src/components/quick-ask/QuickAskInput.tsx | 175 ++++ src/components/quick-ask/QuickAskMessage.tsx | 171 ++++ src/components/quick-ask/QuickAskOverlay.tsx | 744 ++++++++++++++ src/components/quick-ask/QuickAskPanel.tsx | 442 ++++++++ src/components/quick-ask/index.ts | 12 + src/components/quick-ask/modeRegistry.ts | 61 ++ src/components/quick-ask/modes/askMode.ts | 16 + .../quick-ask/modes/editDirectMode.ts | 15 + src/components/quick-ask/modes/editMode.ts | 15 + src/components/quick-ask/modes/index.ts | 7 + src/components/quick-ask/types.ts | 82 ++ .../quick-ask/useQuickAskSession.ts | 195 ++++ src/constants.ts | 2 + .../chatSelectionHighlightController.ts | 327 ++++++ src/editor/index.ts | 32 + src/editor/persistentHighlight.ts | 220 ++++ src/editor/quickAskController.ts | 192 ++++ src/editor/quickAskExtension.ts | 101 ++ src/editor/replaceGuard.ts | 367 +++++++ src/editor/selectionHighlight.ts | 121 +++ src/hooks/use-draggable.ts | 288 ++++++ src/hooks/use-raf-throttled-callback.ts | 50 + src/hooks/use-resizable.ts | 244 +++++ src/hooks/use-streaming-chat-session.ts | 381 +++++++ src/main.ts | 100 +- src/styles/tailwind.css | 89 ++ src/types.ts | 20 + src/utils.ts | 69 +- src/utils/dom/dynamicStyleManager.ts | 150 +++ 56 files changed, 7200 insertions(+), 682 deletions(-) create mode 100644 src/commands/customCommandChatEngine.ts create mode 100644 src/commands/quickCommandPrompts.ts delete mode 100644 src/components/QuickCommand.tsx create mode 100644 src/components/command-ui/action-buttons.tsx create mode 100644 src/components/command-ui/chat-message.tsx create mode 100644 src/components/command-ui/close-button.tsx create mode 100644 src/components/command-ui/command-label.tsx create mode 100644 src/components/command-ui/content-area.tsx create mode 100644 src/components/command-ui/drag-handle.tsx create mode 100644 src/components/command-ui/draggable-modal.tsx create mode 100644 src/components/command-ui/follow-up-input.tsx create mode 100644 src/components/command-ui/index.ts create mode 100644 src/components/command-ui/menu-command-modal.tsx create mode 100644 src/components/command-ui/selected-content.tsx create mode 100644 src/components/quick-ask/ModeSelector.tsx create mode 100644 src/components/quick-ask/QuickAskInput.tsx create mode 100644 src/components/quick-ask/QuickAskMessage.tsx create mode 100644 src/components/quick-ask/QuickAskOverlay.tsx create mode 100644 src/components/quick-ask/QuickAskPanel.tsx create mode 100644 src/components/quick-ask/index.ts create mode 100644 src/components/quick-ask/modeRegistry.ts create mode 100644 src/components/quick-ask/modes/askMode.ts create mode 100644 src/components/quick-ask/modes/editDirectMode.ts create mode 100644 src/components/quick-ask/modes/editMode.ts create mode 100644 src/components/quick-ask/modes/index.ts create mode 100644 src/components/quick-ask/types.ts create mode 100644 src/components/quick-ask/useQuickAskSession.ts create mode 100644 src/editor/chatSelectionHighlightController.ts create mode 100644 src/editor/index.ts create mode 100644 src/editor/persistentHighlight.ts create mode 100644 src/editor/quickAskController.ts create mode 100644 src/editor/quickAskExtension.ts create mode 100644 src/editor/replaceGuard.ts create mode 100644 src/editor/selectionHighlight.ts create mode 100644 src/hooks/use-draggable.ts create mode 100644 src/hooks/use-raf-throttled-callback.ts create mode 100644 src/hooks/use-resizable.ts create mode 100644 src/hooks/use-streaming-chat-session.ts create mode 100644 src/utils/dom/dynamicStyleManager.ts diff --git a/package-lock.json b/package-lock.json index 493f88ae..8a5ed6c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,7 +31,7 @@ "@lexical/selection": "^0.34.0", "@lexical/utils": "^0.34.0", "@orama/orama": "^3.0.0-rc-2", - "@radix-ui/react-checkbox": "^1.1.3", + "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-collapsible": "^1.1.2", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.4", @@ -42,7 +42,7 @@ "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slider": "^1.3.5", - "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-switch": "^1.1.1", "@radix-ui/react-tabs": "^1.1.3", "@radix-ui/react-tooltip": "^1.2.8", @@ -5303,6 +5303,21 @@ } } }, + "node_modules/@radix-ui/react-arrow/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-arrow/node_modules/@radix-ui/react-primitive": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", @@ -5326,20 +5341,38 @@ } } }, - "node_modules/@radix-ui/react-checkbox": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.1.3.tgz", - "integrity": "sha512-HD7/ocp8f1B3e6OHygH0n7ZKjONkhciy1Nh0yuBgObqThc3oyx+vuMfFHKAknXRHHWVE9XvXStxJFyjUmB8PIw==", + "node_modules/@radix-ui/react-arrow/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.1", - "@radix-ui/react-compose-refs": "1.1.1", - "@radix-ui/react-context": "1.1.1", - "@radix-ui/react-presence": "1.1.2", - "@radix-ui/react-primitive": "2.0.1", - "@radix-ui/react-use-controllable-state": "1.1.0", - "@radix-ui/react-use-previous": "1.1.0", - "@radix-ui/react-use-size": "1.1.0" + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz", + "integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", @@ -5357,15 +5390,15 @@ } }, "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/primitive": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz", - "integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", "license": "MIT" }, "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-compose-refs": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz", - "integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -5377,13 +5410,52 @@ } } }, - "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-primitive": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.1.tgz", - "integrity": "sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg==", + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-slot": "1.1.1" + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", @@ -5401,12 +5473,79 @@ } }, "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-slot": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz", - "integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.1" + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-use-previous": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", + "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", @@ -5589,6 +5728,24 @@ } } }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-compose-refs": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz", @@ -5754,6 +5911,24 @@ } } }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-controllable-state": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", @@ -5873,6 +6048,24 @@ } } }, + "node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-use-callback-ref": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", @@ -5994,6 +6187,24 @@ } } }, + "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-use-controllable-state": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", @@ -6106,6 +6317,24 @@ } } }, + "node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-use-callback-ref": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", @@ -6161,6 +6390,21 @@ } } }, + "node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-primitive": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", @@ -6184,6 +6428,24 @@ } } }, + "node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-menu": { "version": "2.1.16", "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", @@ -6340,6 +6602,24 @@ } } }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-use-callback-ref": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", @@ -6508,6 +6788,24 @@ } } }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-use-controllable-state": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", @@ -6627,6 +6925,24 @@ } } }, + "node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-use-callback-ref": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", @@ -6699,6 +7015,21 @@ } } }, + "node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-primitive": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", @@ -6722,6 +7053,24 @@ } } }, + "node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-use-layout-effect": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", @@ -6840,6 +7189,21 @@ } } }, + "node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-context": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", @@ -6878,6 +7242,24 @@ } } }, + "node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-roving-focus": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", @@ -7001,6 +7383,24 @@ } } }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-use-callback-ref": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", @@ -7179,6 +7579,24 @@ } } }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-use-callback-ref": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", @@ -7344,6 +7762,24 @@ } } }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-use-callback-ref": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", @@ -7431,6 +7867,21 @@ } } }, + "node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", @@ -7454,6 +7905,24 @@ } } }, + "node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-slider": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.5.tgz", @@ -7561,6 +8030,24 @@ } } }, + "node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-use-controllable-state": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", @@ -7629,9 +8116,9 @@ } }, "node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", + "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" @@ -7974,6 +8461,24 @@ } } }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-use-controllable-state": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", @@ -8192,6 +8697,21 @@ } } }, + "node_modules/@radix-ui/react-visually-hidden/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-visually-hidden/node_modules/@radix-ui/react-primitive": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", @@ -8215,6 +8735,24 @@ } } }, + "node_modules/@radix-ui/react-visually-hidden/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/rect": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", diff --git a/package.json b/package.json index fd67fb3b..26d3fb65 100644 --- a/package.json +++ b/package.json @@ -100,7 +100,7 @@ "@lexical/selection": "^0.34.0", "@lexical/utils": "^0.34.0", "@orama/orama": "^3.0.0-rc-2", - "@radix-ui/react-checkbox": "^1.1.3", + "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-collapsible": "^1.1.2", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.4", @@ -111,7 +111,7 @@ "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slider": "^1.3.5", - "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-switch": "^1.1.1", "@radix-ui/react-tabs": "^1.1.3", "@radix-ui/react-tooltip": "^1.2.8", diff --git a/src/commands/CustomCommandChatModal.tsx b/src/commands/CustomCommandChatModal.tsx index 6cb64899..e53ab263 100644 --- a/src/commands/CustomCommandChatModal.tsx +++ b/src/commands/CustomCommandChatModal.tsx @@ -1,422 +1,633 @@ import { CustomModel, useModelKey } from "@/aiParams"; import { processCommandPrompt } from "@/commands/customCommandUtils"; -import { Button } from "@/components/ui/button"; -import { getModelDisplayText } from "@/components/ui/model-display"; -import ChatModelManager from "@/LLMProviders/chatModelManager"; -import { ThinkBlockStreamer } from "@/LLMProviders/chainRunner/utils/ThinkBlockStreamer"; -import { logError } from "@/logger"; -import { findCustomModel, insertIntoEditor } from "@/utils"; +import { MenuCommandModal, type ContentState } from "@/components/command-ui"; +import { SelectionHighlight } from "@/editor/selectionHighlight"; import { - ChatPromptTemplate, - HumanMessagePromptTemplate, - MessagesPlaceholder, - SystemMessagePromptTemplate, -} from "@langchain/core/prompts"; -import { RunnableSequence } from "@langchain/core/runnables"; -import { BaseChatMemory, BufferMemory } from "@langchain/classic/memory"; -import { ArrowBigUp, Bot, Command, Copy, CornerDownLeft, PenLine } from "lucide-react"; -import { App, Modal, Notice, Platform } from "obsidian"; + createHighlightReplaceGuard, + type ReplaceGuard, +} from "@/editor/replaceGuard"; +import { logError, logWarn } from "@/logger"; +import { cleanMessageForCopy, findCustomModel, insertIntoEditor } from "@/utils"; +import type { EditorView } from "@codemirror/view"; +import { PenLine } from "lucide-react"; +import { App, Notice, MarkdownView } from "obsidian"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createRoot, Root } from "react-dom/client"; import { CustomCommand } from "@/commands/type"; -import { useSettingsValue } from "@/settings/model"; +import { useSettingsValue, updateSetting } from "@/settings/model"; +import { + useStreamingChatSession, + type StreamingChatTurnContext, +} from "@/hooks/use-streaming-chat-session"; +import { ABORT_REASON } from "@/constants"; -// Custom hook for managing chat chain -function useChatChain(selectedModel: CustomModel, systemPrompt?: string) { - const [chatMemory] = useState( - new BufferMemory({ returnMessages: true, memoryKey: "history" }) - ); - const [chatChain, setChatChain] = useState(null); +// ============================================================================ +// Behavior Config - Replaces mode-based branching +// ============================================================================ - // Initialize chat chain - useEffect(() => { - async function initChatChain() { - const chatModel = await ChatModelManager.getInstance().createModelInstance(selectedModel); +/** + * Model selection scope determines how model changes are persisted. + * - 'quick-command': Changes persist to quickCommandModelKey (shared with Quick Ask) + * - 'custom-command': Changes only affect current session (respects command-level config) + */ +export type ModelSelectionScope = "quick-command" | "custom-command"; - const defaultSystemPrompt = - "You are a helpful assistant. You'll help the user with their content editing needs."; - const chatPrompt = ChatPromptTemplate.fromMessages([ - SystemMessagePromptTemplate.fromTemplate(systemPrompt || defaultSystemPrompt), - new MessagesPlaceholder("history"), - HumanMessagePromptTemplate.fromTemplate("{input}"), - ]); - - const newChatChain = RunnableSequence.from([ - { - input: (initialInput) => initialInput.input, - memory: () => chatMemory.loadMemoryVariables({}), - }, - { - input: (previousOutput) => previousOutput.input, - history: (previousOutput) => previousOutput.memory.history, - }, - chatPrompt, - chatModel, - ]); - - setChatChain(newChatChain); - } - - initChatChain(); - }, [selectedModel, chatMemory, systemPrompt]); - - return { chatChain, chatMemory }; +/** + * Configuration for modal behavior. + * This replaces the mode-based branching with explicit configuration. + */ +export interface ModalBehaviorConfig { + /** Whether to auto-execute the command on open (menu mode: true, quick mode: false) */ + autoExecuteOnOpen: boolean; + /** Whether to hide ContentArea when state is idle */ + hideContentAreaOnIdle: boolean; + /** Transform function for first submit (e.g., append note context placeholders) */ + firstSubmitTransform?: (input: string, includeNoteContext: boolean) => string; + /** Label to display in the modal header */ + commandLabel: string; + /** Icon to display in the modal header (null for no icon, undefined for default) */ + commandIcon?: React.ReactNode | null; + /** Whether to show the "Include note context" checkbox */ + showIncludeNoteContext?: boolean; + /** + * Model selection scope - determines persistence behavior. + * - 'quick-command': Persists to quickCommandModelKey (default for Quick Command) + * - 'custom-command': Only affects current session (default for Custom Commands) + */ + modelSelectionScope?: ModelSelectionScope; } +/** + * Resolves modal behavior defaults and caller overrides. + */ +function resolveBehaviorConfig( + command: CustomCommand, + overrides?: Partial +): ModalBehaviorConfig { + const defaults: ModalBehaviorConfig = { + autoExecuteOnOpen: true, + hideContentAreaOnIdle: false, + commandLabel: command.title, + commandIcon: , + }; + + return { ...defaults, ...overrides }; +} + +// ============================================================================ +// Content Component +// ============================================================================ + interface CustomCommandChatModalContentProps { originalText: string; command: CustomCommand; onInsert: (message: string) => void; onReplace: (message: string) => void; + onClose: () => void; systemPrompt?: string; + initialPosition?: { x: number; y: number }; + behaviorConfig?: Partial; } +/** + * Content component for CustomCommandChatModal using the new MenuCommandModal. + */ function CustomCommandChatModalContent({ originalText, command, onInsert, onReplace, + onClose, systemPrompt, + initialPosition, + behaviorConfig, }: CustomCommandChatModalContentProps) { - const [aiCurrentMessage, setAiCurrentMessage] = useState(null); - const [processedMessage, setProcessedMessage] = useState(null); - const [followupInstruction, setFollowupInstruction] = useState(""); - const textareaRef = useRef(null); - const followupRef = useRef(null); - const [generating, setGenerating] = useState(true); - const [modelKey] = useModelKey(); - const settings = useSettingsValue(); - const selectedModel = useMemo( - () => findCustomModel(command.modelKey || modelKey, settings.activeModels), - [command.modelKey, modelKey, settings.activeModels] + // Resolve behavior configuration + const behavior = useMemo( + () => resolveBehaviorConfig(command, behaviorConfig), + [command, behaviorConfig] ); - const { chatChain, chatMemory } = useChatChain(selectedModel, systemPrompt); + // Prevent concurrent submissions (double-Enter / re-entrancy). + const followUpSubmitLockRef = useRef(false); + // Track mount state to avoid setState on unmounted component + const isMountedRef = useRef(true); - const commandTitle = command.title; - - /** - * Compute the display value for the textarea - */ - const displayValue = useMemo(() => { - // If we have a processed message, show that - if (processedMessage) { - return processedMessage; - } - - // If not generating, show loading - if (!generating) { - return "loading..."; - } - - // If generating but no content yet, show loading - if (!aiCurrentMessage || aiCurrentMessage.trim() === "") { - return "loading..."; - } - - // Otherwise show the streaming content - return aiCurrentMessage; - }, [processedMessage, generating, aiCurrentMessage]); - - // Reusable function to handle streaming responses wrapped in useCallback - const streamResponse = useCallback( - async (input: string, abortController: AbortController) => { - if (!chatChain) { - console.error("Chat chain not initialized"); - new Notice("Chat engine not ready. Please try again."); - setGenerating(false); - return null; - } - - try { - setAiCurrentMessage(null); - setProcessedMessage(null); - setGenerating(true); - - const chainWithSignal = chatChain.withConfig({ signal: abortController.signal }); - - const stream = await chainWithSignal.stream({ input }); - - // Initialize ThinkBlockStreamer to handle reasoning content from Claude and Deepseek - // excludeThinking=true means thinking tokens are not included in the response - const thinkStreamer = new ThinkBlockStreamer( - (message: string) => { - setAiCurrentMessage(message); - }, - true // excludeThinking - ); - - for await (const chunk of stream) { - if (abortController.signal.aborted) break; - thinkStreamer.processChunk(chunk); - } - - // Close the streamer to finalize the response and close any open think blocks - const result = thinkStreamer.close(); - - if (!abortController.signal.aborted) { - const trimmedResponse = result.content.trim(); - setProcessedMessage(trimmedResponse); - setGenerating(false); - - await chatMemory.saveContext({ input }, { output: trimmedResponse }); - - return trimmedResponse; - } - - return null; - } catch (error) { - logError("Error generating response:", error); - setGenerating(false); - return null; - } - }, - [chatChain, chatMemory] - ); - - // Generate initial response + // Cleanup on unmount useEffect(() => { - const abortController = new AbortController(); - abortControllerRef.current = abortController; + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + }; + }, []); + + // State + const [finalText, setFinalText] = useState(""); + const [editedText, setEditedText] = useState(""); + const [isLoading, setIsLoading] = useState(behavior.autoExecuteOnOpen); + const [followUpValue, setFollowUpValue] = useState(""); + + // Model selection + const [globalModelKey] = useModelKey(); + const settings = useSettingsValue(); + const modelSelectionScope = behavior.modelSelectionScope ?? "custom-command"; + + // Determine initial model key based on scope: + // - quick-command: Use quickCommandModelKey (shared with Quick Ask) + // - custom-command: Use command's modelKey if set, otherwise global model + const initialModelKey = useMemo(() => { + if (modelSelectionScope === "quick-command") { + // Use ?? to match QuickAskPanel behavior (empty string is valid, only null/undefined falls back) + return settings.quickCommandModelKey ?? globalModelKey; + } + // For custom-command scope, respect command-level config + // Use || here because empty string means "inherit from global" + return command.modelKey || globalModelKey; + }, [modelSelectionScope, settings.quickCommandModelKey, command.modelKey, globalModelKey]); + + const [selectedModelKey, setSelectedModelKey] = useState(initialModelKey); + + // Handle model change with scope-aware persistence + const handleModelChange = useCallback( + (newModelKey: string) => { + setSelectedModelKey(newModelKey); + // Only persist for quick-command scope (shared with Quick Ask) + if (modelSelectionScope === "quick-command") { + updateSetting("quickCommandModelKey", newModelKey); + } + // For custom-command scope, changes only affect current session + }, + [modelSelectionScope] + ); + + // Include note context state (for Quick Command mode) + // Use local state for immediate UI updates, sync to settings on change + const [includeNoteContext, setIncludeNoteContext] = useState( + () => settings.quickCommandIncludeNoteContext + ); + + const handleIncludeNoteContextChange = useCallback((checked: boolean) => { + setIncludeNoteContext(checked); + updateSetting("quickCommandIncludeNoteContext", checked); + }, []); + + // Track if we've already shown the fallback notice to avoid repeated notices + const didShowFallbackNoticeRef = useRef(false); + + // Safely resolve the selected model with fallback to first enabled model + const resolvedModel = useMemo((): CustomModel | null => { + try { + const model = findCustomModel(selectedModelKey, settings.activeModels); + // Treat disabled models as invalid selections (ModelSelector won't present them) + if (!model.enabled) { + throw new Error(`Selected model is disabled: ${selectedModelKey}`); + } + return model; + } catch { + // Stale model key can happen when a model is removed/renamed/disabled; don't crash the modal. + // Avoid side effects during render; notify/log in the effect below. + return settings.activeModels.find((m) => m.enabled) ?? null; + } + }, [selectedModelKey, settings.activeModels]); + + // Compute the key for the resolved model + const resolvedModelKey = useMemo(() => { + if (!resolvedModel) return null; + return `${resolvedModel.name}|${resolvedModel.provider}`; + }, [resolvedModel]); + + // Update selectedModelKey if we had to fall back to a different model + useEffect(() => { + if (!resolvedModelKey) return; + if (resolvedModelKey === selectedModelKey) return; + + // Always keep UI selection consistent with the resolved model + setSelectedModelKey(resolvedModelKey); + + // Notify only once per modal lifecycle + if (didShowFallbackNoticeRef.current) return; + didShowFallbackNoticeRef.current = true; + logWarn("Selected model is no longer available. Falling back to a default model.", { + selectedModelKey, + resolvedModelKey, + }); + new Notice("Selected model is no longer available. Falling back to a default model."); + }, [resolvedModelKey, selectedModelKey]); + + // Use shared streaming hook + const { + isStreaming, + streamingText, + runTurn, + stop: stopStreaming, + getLatestStreamingText, + } = useStreamingChatSession({ + model: resolvedModel, + systemPrompt: systemPrompt || "", + excludeThinking: true, + onNoModel: () => { + new Notice("No active model is configured. Please configure a model in Copilot settings."); + setIsLoading(false); + }, + onNonAbortError: (error) => { + logError("Error generating response:", error); + new Notice("Error generating response. Please try again."); + setIsLoading(false); + }, + }); + + // Track the last input prompt for saving context on stop + const lastInputPromptRef = useRef(""); + + // Sync editedText with finalText when finalText changes + useEffect(() => { + if (finalText) { + setEditedText(finalText); + } + }, [finalText]); + + // Compute content state for MenuCommandModal + const contentState: ContentState = useMemo(() => { + if (isLoading && !isStreaming && !streamingText && !finalText) { + return { type: "loading" }; + } + if (isStreaming || streamingText) { + return { type: "result", text: streamingText || finalText, isStreaming }; + } + if (finalText) { + return { type: "result", text: finalText, isStreaming: false }; + } + return { type: "idle" }; + }, [isLoading, isStreaming, streamingText, finalText]); + + // Track if auto-execute has already run (prevent re-execution on model change) + const didAutoExecuteRef = useRef(false); + + // Generate initial response (only for autoExecuteOnOpen mode) + useEffect(() => { + if (!behavior.autoExecuteOnOpen) return; + // Only execute once - prevent re-execution when model changes + if (didAutoExecuteRef.current) return; + didAutoExecuteRef.current = true; + + let cancelled = false; async function generateInitialResponse() { - if (!chatChain) { - // We'll wait for the chain to be ready - return; - } - try { - const prompt = await processCommandPrompt(command.content, originalText); - await streamResponse(prompt, abortController); + const result = await runTurn(async (ctx: StreamingChatTurnContext) => { + if (ctx.signal.aborted) return ""; + const prompt = await processCommandPrompt(command.content, originalText); + lastInputPromptRef.current = prompt; + return prompt; + }); + + if (!cancelled && result) { + setFinalText(result); + lastInputPromptRef.current = ""; + } } catch (error) { logError("Error in initial response:", error); - setGenerating(false); + } finally { + if (!cancelled) { + setIsLoading(false); + } } } generateInitialResponse(); + return () => { - abortController.abort(); + cancelled = true; + stopStreaming(ABORT_REASON.UNMOUNT); }; - }, [command.content, originalText, chatChain, streamResponse]); + }, [behavior.autoExecuteOnOpen, command.content, originalText, runTurn, stopStreaming]); - const abortControllerRef = useRef(null); + const handleFollowUpSubmit = async () => { + if (!followUpValue.trim()) return; - const handleFollowupSubmit = async () => { - if (!followupInstruction.trim() || !chatChain) { - if (!chatChain) { - new Notice("Chat engine not ready. Please try again."); - } - return; - } + // Prevent concurrent submissions + if (followUpSubmitLockRef.current) return; + if (isLoading || isStreaming) return; - const abortController = new AbortController(); - abortControllerRef.current = abortController; + followUpSubmitLockRef.current = true; + + // Clear input and previous result immediately for responsive UI + const inputValue = followUpValue; + setFollowUpValue(""); + setFinalText(""); + setEditedText(""); - // Skip appending the selected text to the prompt because it's already - // included in the original prompt. - const prompt = await processCommandPrompt(followupInstruction, originalText, true); try { - const result = await streamResponse(prompt, abortController); + setIsLoading(true); + + const result = await runTurn(async (ctx: StreamingChatTurnContext) => { + if (ctx.signal.aborted) return ""; + + // Use ctx.isFirstTurn to determine if this is the first turn + const isFirstTurn = ctx.isFirstTurn; + + // Apply first submit transform if provided (e.g., append note context placeholders) + let rawInput = inputValue; + if (isFirstTurn && behavior.firstSubmitTransform) { + rawInput = behavior.firstSubmitTransform(rawInput, includeNoteContext); + } + + // Process prompt (expand placeholders) + const prompt = await processCommandPrompt(rawInput, originalText, !isFirstTurn); + lastInputPromptRef.current = prompt; + return prompt; + }); + + // Guard against unmount during async operation + if (!isMountedRef.current) return; if (result) { - // Reset follow-up instruction on success - setFollowupInstruction(""); + setFinalText(result); + lastInputPromptRef.current = ""; + } + } catch (error) { + // Handle errors in follow-up submit + if (error instanceof Error && error.name === "AbortError") { + // Silently ignore abort errors + } else { + logError("Error in follow-up submit:", error); + if (isMountedRef.current) { + new Notice("Failed to send message. Please try again."); + } } } finally { - if (abortController.signal.aborted) { - setGenerating(false); - setProcessedMessage(aiCurrentMessage ?? ""); + if (isMountedRef.current) { + setIsLoading(false); } - abortControllerRef.current = null; + followUpSubmitLockRef.current = false; } }; - // Handle stopping generation - const handleStopGeneration = () => { - if (abortControllerRef.current) { - abortControllerRef.current.abort(); - abortControllerRef.current = null; - setGenerating(false); + /** + * Handle user stop action. + * The shared hook handles memory persistence on USER_STOPPED automatically. + * We only need to capture the latest text for UI display. + */ + const handleStop = useCallback(() => { + // Reason: Use getLatestStreamingText() to bypass RAF throttle, + // ensuring we capture the most recent streamed content. + const latestStreamedText = getLatestStreamingText().trim(); + + stopStreaming(ABORT_REASON.USER_STOPPED); + + if (latestStreamedText) { + setFinalText(latestStreamedText); } + lastInputPromptRef.current = ""; + }, [stopStreaming, getLatestStreamingText]); + + const handleInsert = () => { + const text = editedText || finalText || streamingText; + if (text) onInsert(text); }; - // Handle keyboard shortcuts - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.nativeEvent.isComposing) return; - - // For insert/replace buttons - if (!generating && processedMessage && !showFollowupSubmit) { - // Command+Enter (Mac) or Ctrl+Enter (Windows) for Replace - if (e.key === "Enter" && (Platform.isMacOS ? e.metaKey : e.ctrlKey) && !e.shiftKey) { - e.preventDefault(); - onReplace(processedMessage); - } - - // Command+Shift+Enter (Mac) or Ctrl+Shift+Enter (Windows) for Insert - if (e.key === "Enter" && (Platform.isMacOS ? e.metaKey : e.ctrlKey) && e.shiftKey) { - e.preventDefault(); - onInsert(processedMessage); - } - } - - // For submit button (follow-up instruction) - if (showFollowupSubmit && e.key === "Enter" && !e.shiftKey && !e.metaKey && !e.ctrlKey) { - e.preventDefault(); - handleFollowupSubmit(); - } + const handleReplace = () => { + const text = editedText || finalText || streamingText; + if (text) onReplace(text); }; - // Scroll textarea to bottom when generating content - useEffect(() => { - if (textareaRef.current && aiCurrentMessage && generating) { - const textarea = textareaRef.current; - textarea.scrollTop = textarea.scrollHeight; - } - }, [aiCurrentMessage, generating]); - - // Determine if follow-up submit button should be shown - const showFollowupSubmit = !generating && followupInstruction.trim().length > 0; - return ( -
-
- {originalText} -
-
- {commandTitle && ( -
- - {commandTitle} -
- )} -
-
-