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
This commit is contained in:
Emt-lin 2026-02-04 09:37:33 +08:00 committed by GitHub
parent 800547534a
commit 76520d0259
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
56 changed files with 7200 additions and 682 deletions

602
package-lock.json generated
View file

@ -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",

View file

@ -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",

File diff suppressed because it is too large Load diff

View file

@ -1,30 +1,58 @@
import { getCommandId, sortCommandsByOrder } from "@/commands/customCommandUtils";
import { getCachedCustomCommands } from "@/commands/state";
import { COMMAND_IDS } from "@/constants";
import { Menu } from "obsidian";
import { CustomCommand } from "./type";
import type { App, Menu } from "obsidian";
import type { CustomCommand } from "./type";
interface CommandManager {
executeCommandById: (commandId: string) => boolean;
}
interface AppWithCommands extends App {
commands: CommandManager;
}
/**
* Type guard for the command manager surface used by Copilot.
*/
function hasCommandManager(app: App): app is AppWithCommands {
return typeof (app as Partial<AppWithCommands>).commands?.executeCommandById === "function";
}
/**
* Registers the Copilot submenu entries in Obsidian's editor context menu.
*/
export function registerContextMenu(menu: Menu, obsidianApp: App): void {
if (!hasCommandManager(obsidianApp)) return;
const execute = (commandId: string): void => {
obsidianApp.commands.executeCommandById(commandId);
};
export function registerContextMenu(menu: Menu) {
// Create the main "Copilot" submenu
menu.addItem((item) => {
item.setTitle("Copilot");
(item as any).setSubmenu();
item.setSubmenu();
const submenu = (item as any).submenu;
const submenu = item.submenu;
if (!submenu) return;
// Add the main selection command
submenu.addItem((subItem: any) => {
submenu.addItem((subItem) => {
subItem.setTitle("Add selection to chat context").onClick(() => {
(app as any).commands.executeCommandById(
`copilot:${COMMAND_IDS.ADD_SELECTION_TO_CHAT_CONTEXT}`
);
execute(`copilot:${COMMAND_IDS.ADD_SELECTION_TO_CHAT_CONTEXT}`);
});
});
submenu.addItem((subItem: any) => {
submenu.addItem((subItem) => {
subItem.setTitle("Quick Ask").onClick(() => {
execute(`copilot:${COMMAND_IDS.TRIGGER_QUICK_ASK}`);
});
});
submenu.addItem((subItem) => {
subItem.setTitle("Trigger quick command").onClick(() => {
(app as any).commands.executeCommandById(`copilot:${COMMAND_IDS.TRIGGER_QUICK_COMMAND}`);
execute(`copilot:${COMMAND_IDS.TRIGGER_QUICK_COMMAND}`);
});
});
@ -41,9 +69,9 @@ export function registerContextMenu(menu: Menu) {
// Add custom commands to submenu
sortCommandsByOrder(visibleCustomCommands).forEach((command: CustomCommand) => {
submenu.addItem((subItem: any) => {
submenu.addItem((subItem) => {
subItem.setTitle(command.title).onClick(() => {
(app as any).commands.executeCommandById(`copilot:${getCommandId(command.title)}`);
execute(`copilot:${getCommandId(command.title)}`);
});
});
});

View file

@ -0,0 +1,63 @@
/**
* Custom Command Chat Engine - shared streaming chat logic.
* Extracted from CustomCommandChatModal.tsx for reuse across Quick Ask and Custom Commands.
*/
import { RunnableSequence } from "@langchain/core/runnables";
import { BaseChatMemory, BufferMemory } from "@langchain/classic/memory";
import {
ChatPromptTemplate,
HumanMessagePromptTemplate,
MessagesPlaceholder,
SystemMessagePromptTemplate,
} from "@langchain/core/prompts";
import ChatModelManager from "@/LLMProviders/chatModelManager";
import { CustomModel } from "@/aiParams";
/**
* Creates a new BufferMemory instance for chat history.
*/
export function createChatMemory(): BufferMemory {
return new BufferMemory({
returnMessages: true,
memoryKey: "history",
});
}
/**
* Creates a chat chain with the specified model and system prompt.
*
* @param selectedModel - The model configuration to use
* @param systemPrompt - The system prompt for the conversation
* @param memory - The memory instance for conversation history
* @returns A configured RunnableSequence for chat
*/
export async function createChatChain(
selectedModel: CustomModel,
systemPrompt: string,
memory: BaseChatMemory
): Promise<RunnableSequence> {
const chatModel = await ChatModelManager.getInstance().createModelInstance(selectedModel);
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}"),
]);
return RunnableSequence.from([
{
input: (initialInput) => initialInput.input,
memory: () => memory.loadMemoryVariables({}),
},
{
input: (previousOutput) => previousOutput.input,
history: (previousOutput) => previousOutput.memory.history,
},
chatPrompt,
chatModel,
]);
}

View file

@ -213,18 +213,25 @@ export async function processCommandPrompt(
// Also, selected text is required for custom commands. If neither `{}` nor
// `{copilot-selection}` is found, append the selected text to the prompt.
const index = processedPrompt.indexOf(LEGACY_SELECTED_TEXT_PLACEHOLDER);
if (index === -1 && selectedText.trim()) {
return (
processedPrompt +
"\n\n<" +
SELECTED_TEXT_TAG +
">" +
selectedText +
"</" +
SELECTED_TEXT_TAG +
">"
);
if (index === -1) {
// No legacy placeholder found
if (selectedText.trim()) {
// Append selected text if present
return (
processedPrompt +
"\n\n<" +
SELECTED_TEXT_TAG +
">" +
selectedText +
"</" +
SELECTED_TEXT_TAG +
">"
);
}
// No placeholder and no selected text - return as is
return processedPrompt;
}
// Replace legacy placeholder with selected text
return (
processedPrompt.slice(0, index) +
selectedText +

View file

@ -8,10 +8,15 @@ import {
} from "@/LLMProviders/chainRunner/utils/promptPayloadRecorder";
import { CustomCommandSettingsModal } from "@/commands/CustomCommandSettingsModal";
import { EMPTY_COMMAND, QUICK_COMMAND_CODE_BLOCK } from "@/commands/constants";
import { EMPTY_COMMAND } from "@/commands/constants";
import { CustomCommandManager } from "@/commands/customCommandManager";
import { removeQuickCommandBlocks } from "@/commands/customCommandUtils";
import { getCachedCustomCommands } from "@/commands/state";
import { CustomCommand } from "@/commands/type";
import {
QUICK_COMMAND_SYSTEM_PROMPT,
appendIncludeNoteContextPlaceholders,
} from "@/commands/quickCommandPrompts";
import { CustomCommandChatModal } from "@/commands/CustomCommandChatModal";
import { ApplyCustomCommandModal } from "@/components/modals/ApplyCustomCommandModal";
import { YoutubeTranscriptModal } from "@/components/modals/YoutubeTranscriptModal";
import { checkIsPlusUser } from "@/plusUtils";
@ -107,6 +112,8 @@ export function registerCommands(
plugin.newChat();
});
// Quick Command - opens a modal dialog for quick interactions
// Note: For inline floating panel experience, use Quick Ask instead
addCheckCommand(plugin, COMMAND_IDS.TRIGGER_QUICK_COMMAND, (checking: boolean) => {
const activeView = plugin.app.workspace.getActiveViewOfType(MarkdownView);
@ -135,15 +142,33 @@ export function registerCommands(
return false;
}
removeQuickCommandBlocks(editor);
// Directly open the Modal
const quickCommand: CustomCommand = {
title: "Quick Command",
content: "", // Empty content, wait for user input
showInContextMenu: false,
showInSlashMenu: false,
order: 0,
modelKey: "", // Empty = inherit from quickCommandModelKey
lastUsedMs: Date.now(),
};
// Get the current cursor/selection position (after potential content update)
const cursor = editor.getCursor("from");
const line = cursor.line;
// Insert the quick command code block above the selected text
const codeBlock = `\`\`\`${QUICK_COMMAND_CODE_BLOCK}\n\`\`\`\n`;
editor.replaceRange(codeBlock, { line, ch: 0 });
const modal = new CustomCommandChatModal(plugin.app, {
selectedText,
command: quickCommand,
systemPrompt: QUICK_COMMAND_SYSTEM_PROMPT,
behaviorConfig: {
autoExecuteOnOpen: false,
hideContentAreaOnIdle: true,
commandLabel: "Quick Command",
commandIcon: null, // No icon for Quick Command
showIncludeNoteContext: true, // Show the Note checkbox
modelSelectionScope: "quick-command", // Persist model changes to quickCommandModelKey
firstSubmitTransform: (input, includeNoteContext) =>
appendIncludeNoteContextPlaceholders(input, includeNoteContext),
},
});
modal.open();
return true;
});
@ -547,4 +572,37 @@ export function registerCommands(
const modal = new YoutubeTranscriptModal(plugin.app);
modal.open();
});
// Add Quick Ask command (recommended shortcut: cmd/ctrl+K)
// Quick Ask is the floating panel that appears near the selection in the editor
addCheckCommand(plugin, COMMAND_IDS.TRIGGER_QUICK_ASK, (checking: boolean) => {
const activeView = plugin.app.workspace.getActiveViewOfType(MarkdownView);
if (checking) {
// Return true only if we're not in source mode and have an active editor
return !!(!isSourceModeOn() && activeView && activeView.editor);
}
// Need to check this again because it can still be triggered via shortcut
if (isSourceModeOn()) {
new Notice("Quick Ask is not available in source mode.");
return false;
}
if (!activeView || !activeView.editor) {
new Notice("No active editor found.");
return false;
}
// Get the CM6 EditorView from the Obsidian editor
const view = activeView.editor.cm;
if (!view) {
new Notice("Could not access CodeMirror editor.");
return false;
}
// Show the Quick Ask panel (pass activeView for leaf binding)
plugin.quickAskController.show(activeView, view);
return true;
});
}

View file

@ -0,0 +1,63 @@
/**
* Quick Command system prompt and utility functions.
* Shared across Quick Ask and Quick Command features.
*/
/**
* System prompt for Quick Command / Quick Ask interactions.
* Designed for precise, action-oriented responses.
*/
export const QUICK_COMMAND_SYSTEM_PROMPT = `
You are an AI assistant designed to execute user instructions with precision. Your responses should be:
- Direct and focused: Address only what is explicitly requested
- Concise: Avoid unnecessary elaboration unless the user asks for details
- Context-aware: When text is selected or highlighted, treat it as the primary target for any requested action
- Action-oriented: Prioritize completing the task over explaining the process
Key principles:
- Follow instructions literally and completely
- Assume selected/highlighted text is the focus unless told otherwise
- Use all provided context: Consider any additional information, examples, or constraints the user provides to better complete the task
- Add explanations only when explicitly requested or when clarification is essential
- Maintain the user's preferred format and style
Response format: Match the format implied by the user's request (e.g., if they ask for a list, provide a list; if they ask for a rewrite, provide only the rewritten text).
`;
/**
* Appends Include note context placeholders to user content if enabled.
* Only adds placeholders that don't already exist in the content.
*
* @param userContent - The original user input content
* @param includeActiveNote - Whether to include note context
* @returns The content with placeholders appended if needed
*/
export function appendIncludeNoteContextPlaceholders(
userContent: string,
includeActiveNote: boolean
): string {
if (!includeActiveNote) {
return userContent;
}
// Check if placeholders already exist to avoid duplication
const hasSelectedTextPlaceholder = userContent.includes("{}");
const hasActiveNotePlaceholder = /\{activenote\}/i.test(userContent);
// Only append placeholders that don't already exist
const placeholdersToAdd: string[] = [];
if (!hasSelectedTextPlaceholder) {
placeholdersToAdd.push("{}");
}
if (!hasActiveNotePlaceholder) {
placeholdersToAdd.push("{activeNote}");
}
if (placeholdersToAdd.length > 0) {
return userContent + `\n\n${placeholdersToAdd.join("\n\n")}`;
}
return userContent;
}

View file

@ -1,6 +1,7 @@
import {
clearSelectedTextContexts,
getCurrentProject,
getSelectedTextContexts,
ProjectConfig,
removeSelectedTextContext,
setCurrentProject,
@ -129,6 +130,13 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
// Ref for the chat container (used for drag-and-drop)
const chatContainerRef = useRef<HTMLDivElement>(null);
/**
* Persist editor selection highlight when clicking into Chat
*/
const handleChatPointerDownCapture = useCallback((): void => {
plugin.chatSelectionHighlightController.persistFromPointerDown();
}, [plugin]);
// Safe setter utilities - automatically wrap state setters to prevent updates after unmount
const safeSet = useMemo<{
setCurrentAiMessage: (value: string) => void;
@ -572,16 +580,29 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
const handleRemoveSelectedText = useCallback(
(id: string) => {
const removed = selectedTextContexts.find((ctx) => ctx.id === id);
// Get fresh state to avoid stale closure issues (fixes race condition on rapid removals)
const currentContexts = getSelectedTextContexts();
const removed = currentContexts.find((ctx) => ctx.id === id);
removeSelectedTextContext(id);
// Suppress web selection to prevent it from being auto-captured again
if (removed?.sourceType === "web") {
plugin.suppressCurrentWebSelection(removed.url);
}
// Note: highlight cleanup is now handled by the useEffect below that watches selectedTextContexts
},
[plugin, selectedTextContexts]
[plugin]
);
/**
* State-driven highlight cleanup: automatically clear editor highlight
* when no note contexts remain. This ensures highlight stays in sync
* with context state regardless of how contexts are modified.
*/
useEffect(() => {
plugin.chatSelectionHighlightController.clearIfNoNoteContexts(selectedTextContexts);
}, [selectedTextContexts, plugin]);
useEffect(() => {
const handleChatVisibility = () => {
chatInput.focusInput();
@ -649,6 +670,8 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
// Capture web selection URL before clearing for suppression
const webSelectionUrl = selectedTextContexts.find((ctx) => ctx.sourceType === "web")?.url;
clearSelectedTextContexts();
// Clear chat selection highlight
plugin.chatSelectionHighlightController.clearForNewChat();
// Suppress web selection to prevent it from reappearing in new chat
plugin.suppressCurrentWebSelection(webSelectionUrl);
// Respect the autoAddActiveContentToContext setting for all non-project chains
@ -859,7 +882,11 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
);
return (
<div ref={chatContainerRef} className="tw-flex tw-size-full tw-flex-col tw-overflow-hidden">
<div
ref={chatContainerRef}
onPointerDownCapture={handleChatPointerDownCapture}
className="tw-flex tw-size-full tw-flex-col tw-overflow-hidden"
>
<div className="tw-h-full">
<div className="tw-relative tw-flex tw-h-full tw-flex-col">
{isDragActive && (

View file

@ -1,216 +0,0 @@
import React, { useState, useEffect, useRef } from "react";
import { Notice, MarkdownView } from "obsidian";
import { createRoot } from "react-dom/client";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import { ModelSelector } from "@/components/ui/ModelSelector";
import { CustomCommandChatModal } from "@/commands/CustomCommandChatModal";
import { useModelKey } from "@/aiParams";
import { CustomCommand } from "@/commands/type";
import { removeQuickCommandBlocks } from "@/commands/customCommandUtils";
import CopilotPlugin from "@/main";
import { updateSetting, useSettingsValue } from "@/settings/model";
interface QuickCommandProps {
plugin: CopilotPlugin;
onRemove: () => void;
}
export function QuickCommand({ plugin, onRemove }: QuickCommandProps) {
const [prompt, setPrompt] = useState("");
const settings = useSettingsValue();
const [selectedText, setSelectedText] = useState("");
const [globalModelKey] = useModelKey();
const textareaRef = useRef<HTMLTextAreaElement>(null);
const selectedModelKey = settings.quickCommandModelKey ?? globalModelKey;
const includeActiveNote = settings.quickCommandIncludeNoteContext;
// Get the currently selected text from the editor
useEffect(() => {
const activeView = plugin.app.workspace.getActiveViewOfType(MarkdownView);
if (activeView && activeView.editor) {
const currentSelection = activeView.editor.getSelection();
setSelectedText(currentSelection);
}
}, [plugin.app]);
// Auto-focus textarea on mount
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.focus();
}
}, []);
const handleSubmit = async () => {
if (!prompt.trim()) {
new Notice("Please enter a prompt");
return;
}
const systemPrompt = `
You are an AI assistant designed to execute user instructions with precision. Your responses should be:
- Direct and focused: Address only what is explicitly requested
- Concise: Avoid unnecessary elaboration unless the user asks for details
- Context-aware: When text is selected or highlighted, treat it as the primary target for any requested action
- Action-oriented: Prioritize completing the task over explaining the process
Key principles:
- Follow instructions literally and completely
- Assume selected/highlighted text is the focus unless told otherwise
- Use all provided context: Consider any additional information, examples, or constraints the user provides to better complete the task
- Add explanations only when explicitly requested or when clarification is essential
- Maintain the user's preferred format and style
Response format: Match the format implied by the user's request (e.g., if they ask for a list, provide a list; if they ask for a rewrite, provide only the rewritten text).
`;
let userContent = prompt;
if (includeActiveNote) {
// Check if placeholders already exist to avoid duplication
const hasSelectedTextPlaceholder = userContent.includes("{}");
const hasActiveNotePlaceholder = /\{activenote\}/i.test(userContent);
// Only append placeholders that don't already exist
const placeholdersToAdd = [];
if (!hasSelectedTextPlaceholder) {
placeholdersToAdd.push("{}");
}
if (!hasActiveNotePlaceholder) {
placeholdersToAdd.push("{activeNote}");
}
if (placeholdersToAdd.length > 0) {
userContent += `\n\n${placeholdersToAdd.join("\n\n")}`;
}
}
const quickCommand: CustomCommand = {
title: "Quick Command",
content: userContent,
showInContextMenu: false,
showInSlashMenu: false,
order: 0,
modelKey: selectedModelKey,
lastUsedMs: Date.now(),
};
const modal = new CustomCommandChatModal(plugin.app, {
selectedText,
command: quickCommand,
systemPrompt,
});
modal.open();
onRemove();
};
const handleCancel = () => {
onRemove();
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
handleCancel();
} else if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
};
/**
* Handle model selection change and persist to settings
*/
const handleModelChange = (modelKey: string) => {
updateSetting("quickCommandModelKey", modelKey);
};
/**
* Handle include note context checkbox change and persist to settings
*/
const handleIncludeNoteContextChange = (checked: boolean) => {
updateSetting("quickCommandIncludeNoteContext", checked);
};
return (
<div
className="tw-rounded-lg tw-border tw-border-solid tw-border-border tw-bg-primary tw-p-4"
onKeyDown={handleKeyDown}
>
<div className="tw-space-y-4">
<Textarea
ref={textareaRef}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="Ask me anything..."
className="tw-min-h-24 tw-resize-none"
rows={3}
/>
<div className="tw-flex tw-items-center tw-justify-between tw-gap-4">
<div className="tw-flex tw-items-center tw-gap-4">
<ModelSelector
size="sm"
variant="ghost"
value={selectedModelKey}
onChange={handleModelChange}
/>
<div className="tw-flex tw-items-center tw-gap-2">
<Checkbox
id="includeActiveNote"
checked={includeActiveNote}
onCheckedChange={(checked) => handleIncludeNoteContextChange(!!checked)}
/>
<label
htmlFor="includeActiveNote"
className="tw-cursor-pointer tw-text-sm tw-text-muted"
>
Include note context
</label>
</div>
</div>
<div className="tw-flex tw-items-center tw-gap-2">
<Button variant="secondary" onClick={handleCancel} size="sm">
Cancel
</Button>
<Button onClick={handleSubmit} size="sm">
Submit
</Button>
</div>
</div>
</div>
</div>
);
}
export interface QuickCommandContainerProps {
plugin: CopilotPlugin;
element: HTMLElement;
}
export function createQuickCommandContainer({ plugin, element }: QuickCommandContainerProps) {
const container = document.createElement("div");
element.appendChild(container);
const root = createRoot(container);
const handleRemove = () => {
const activeView = plugin.app.workspace.getActiveViewOfType(MarkdownView);
if (activeView && activeView.editor) {
removeQuickCommandBlocks(activeView.editor);
}
root.unmount();
container.remove();
};
root.render(<QuickCommand plugin={plugin} onRemove={handleRemove} />);
return { root, container };
}

View file

@ -790,8 +790,7 @@ const ChatSingleMessage: React.FC<ChatSingleMessageProps> = ({
const editor = leaf.view.editor;
const hasSelection = editor.getSelection().length > 0;
const cleanedContent = cleanMessageForCopy(message.message);
insertIntoEditor(cleanedContent, hasSelection);
insertIntoEditor(message.message, hasSelection);
};
const renderMessageContent = () => {

View file

@ -4,7 +4,13 @@ import { FileText, Wrench, Folder, Globe } from "lucide-react";
import { TypeaheadOption } from "../TypeaheadMenuContent";
import type { WebTabContext } from "@/types/message";
export type AtMentionCategory = "notes" | "tools" | "folders" | "activeNote" | "webTabs" | "activeWebTab";
export type AtMentionCategory =
| "notes"
| "tools"
| "folders"
| "activeNote"
| "webTabs"
| "activeWebTab";
export interface AtMentionOption extends TypeaheadOption {
category: AtMentionCategory;

View file

@ -149,9 +149,7 @@ export function useAtMentionSearch(
});
}
return activeOptions.length > 0
? [...activeOptions, ...categoryOptions]
: categoryOptions;
return activeOptions.length > 0 ? [...activeOptions, ...categoryOptions] : categoryOptions;
}
// Search across all categories when query exists

View file

@ -1,4 +1,4 @@
import React from "react";
import { useRef, useEffect } from "react";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { COMMAND_PRIORITY_LOW, KEY_ENTER_COMMAND } from "lexical";
import { SEND_SHORTCUT } from "@/constants";
@ -19,16 +19,93 @@ interface KeyboardPluginProps {
*/
export function KeyboardPlugin({ onSubmit, sendShortcut }: KeyboardPluginProps) {
const [editor] = useLexicalComposerContext();
// Track IME composition state with a ref to avoid stale closure issues
const isComposingRef = useRef(false);
// Track the timeout to clear it on new composition
const compositionTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
React.useEffect(() => {
// Handle composition events to track IME state
// Use registerRootListener to handle cases where rootElement is initially null
useEffect(() => {
let currentRootElement: HTMLElement | null = null;
const handleCompositionStart = () => {
// Clear any pending timeout from previous composition
if (compositionTimeoutRef.current) {
clearTimeout(compositionTimeoutRef.current);
compositionTimeoutRef.current = null;
}
isComposingRef.current = true;
};
const handleCompositionEnd = () => {
// Clear any pending timeout
if (compositionTimeoutRef.current) {
clearTimeout(compositionTimeoutRef.current);
}
// Delay resetting the flag to ensure the Enter key from IME confirmation is ignored
compositionTimeoutRef.current = setTimeout(() => {
isComposingRef.current = false;
compositionTimeoutRef.current = null;
}, 100);
};
const attachListeners = (el: HTMLElement) => {
el.addEventListener("compositionstart", handleCompositionStart);
el.addEventListener("compositionend", handleCompositionEnd);
};
const detachListeners = (el: HTMLElement) => {
el.removeEventListener("compositionstart", handleCompositionStart);
el.removeEventListener("compositionend", handleCompositionEnd);
};
// Use registerRootListener to handle root element changes
const unregisterRootListener = editor.registerRootListener(
(rootElement: HTMLElement | null, prevRootElement: HTMLElement | null) => {
if (prevRootElement) {
detachListeners(prevRootElement);
}
if (rootElement) {
attachListeners(rootElement);
}
currentRootElement = rootElement;
}
);
return () => {
unregisterRootListener();
if (currentRootElement) {
detachListeners(currentRootElement);
currentRootElement = null;
}
// Clean up timeout on unmount
if (compositionTimeoutRef.current) {
clearTimeout(compositionTimeoutRef.current);
compositionTimeoutRef.current = null;
}
};
}, [editor]);
useEffect(() => {
return editor.registerCommand(
KEY_ENTER_COMMAND,
(event: KeyboardEvent) => {
// Ignore Enter key during IME composition (e.g., Chinese, Japanese, Korean input)
if (event.isComposing) {
(event: KeyboardEvent | null) => {
// Handle null event (Lexical internal use)
if (!event) {
return false;
}
// Ignore Enter key during IME composition (e.g., Chinese, Japanese, Korean input)
// Check our ref, event.isComposing, and keyCode 229 for comprehensive IME detection
// IMPORTANT: Return true and preventDefault to "swallow" the event, preventing default newline insertion
if (isComposingRef.current || event.isComposing || event.keyCode === 229) {
event.preventDefault();
return true;
}
const shouldSubmit = checkShortcutMatch(event, sendShortcut);
if (shouldSubmit) {

View file

@ -25,9 +25,12 @@ export function ValueSyncPlugin({ value }: ValueSyncPluginProps) {
if (currentText !== value) {
root.clear();
// Always add a paragraph node to ensure editor is in a valid state
const paragraph = $createParagraphNode();
if (value) {
root.append($createParagraphNode().append($createTextNode(value)));
paragraph.append($createTextNode(value));
}
root.append(paragraph);
}
});
}, [editor, value]);

View file

@ -0,0 +1,68 @@
import * as React from "react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
type ActionState = "idle" | "loading" | "result";
interface ActionButtonsProps {
state: ActionState;
onStop?: () => void;
onInsert?: () => void;
onReplace?: () => void;
onSubmit?: () => void;
onCancel?: () => void;
showInsertReplace?: boolean;
showSubmitCancel?: boolean;
className?: string;
}
/**
* Action buttons for the modal footer.
* Shows different buttons based on state: Stop during loading, Insert/Replace on result.
*/
export function ActionButtons({
state,
onStop,
onInsert,
onReplace,
onSubmit,
onCancel,
showInsertReplace = true,
showSubmitCancel = false,
className,
}: ActionButtonsProps) {
return (
<div className={cn("tw-flex tw-items-center tw-gap-2", className)}>
{/* Stop button during loading */}
{state === "loading" && (
<Button variant="secondary" size="sm" onClick={onStop}>
Stop
</Button>
)}
{/* Insert/Replace buttons on result */}
{state === "result" && showInsertReplace && (
<>
<Button size="sm" variant="secondary" onClick={onInsert}>
Insert
</Button>
<Button size="sm" onClick={onReplace}>
Replace
</Button>
</>
)}
{/* Submit/Cancel buttons (alternative mode) */}
{showSubmitCancel && state !== "loading" && (
<>
<Button variant="secondary" size="sm" onClick={onCancel}>
Cancel
</Button>
<Button size="sm" onClick={onSubmit}>
Submit
</Button>
</>
)}
</div>
);
}

View file

@ -0,0 +1,75 @@
import * as React from "react";
import { cn } from "@/lib/utils";
import { Copy, ClipboardCopy } from "lucide-react";
import { Button } from "@/components/ui/button";
interface Message {
id: string;
role: "user" | "assistant";
content: string;
}
interface ChatMessageProps {
message: Message;
isStreaming?: boolean;
onCopy?: (message: Message) => void;
onInsert?: (message: Message) => void;
}
/**
* Individual chat message bubble for Quick Ask modal.
* User messages align right, assistant messages align left with action buttons.
*/
export function ChatMessage({ message, isStreaming, onCopy, onInsert }: ChatMessageProps) {
const isUser = message.role === "user";
return (
<div
className={cn(
"tw-group tw-flex tw-flex-col tw-gap-1",
isUser ? "tw-items-end" : "tw-items-start"
)}
>
{/* Message bubble */}
<div
className={cn(
"tw-max-w-[80%] tw-rounded-lg tw-px-3 tw-py-2 tw-text-sm",
"tw-whitespace-pre-wrap tw-leading-relaxed",
isUser ? "tw-bg-interactive-accent tw-text-on-accent" : "tw-bg-secondary tw-text-normal"
)}
>
{message.content}
{/* Streaming cursor for assistant messages */}
{!isUser && isStreaming && (
<span className="tw-ml-0.5 tw-inline-block tw-h-4 tw-w-1.5 tw-animate-pulse tw-rounded-sm tw-bg-interactive-accent tw-align-middle" />
)}
</div>
{/* Action buttons for assistant messages */}
{!isUser && !isStreaming && (
<div className="tw-flex tw-items-center tw-gap-1 tw-opacity-0 tw-transition-opacity group-hover:tw-opacity-100">
<Button
variant="ghost2"
size="icon"
className="tw-size-5 hover:tw-bg-modifier-hover"
onClick={() => onCopy?.(message)}
aria-label="Copy message"
>
<Copy className="tw-size-3" />
</Button>
<Button
variant="ghost2"
size="icon"
className="tw-size-5 hover:tw-bg-modifier-hover"
onClick={() => onInsert?.(message)}
aria-label="Insert at cursor"
>
<ClipboardCopy className="tw-size-3" />
</Button>
</div>
)}
</div>
);
}
export type { Message };

View file

@ -0,0 +1,27 @@
import * as React from "react";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
interface CloseButtonProps {
onClose: () => void;
className?: string;
}
/**
* Close button positioned in the top-right corner of the modal.
* Styled to match QuickAskPanel's close button using Button ghost2 variant.
*/
export function CloseButton({ onClose, className }: CloseButtonProps) {
return (
<Button
className={cn("tw-absolute tw-right-2 tw-top-1 tw-rounded tw-p-1 tw-text-normal", className)}
variant="ghost2"
onClick={onClose}
title="Close"
aria-label="Close"
>
<X className="tw-size-4" />
</Button>
);
}

View file

@ -0,0 +1,37 @@
import * as React from "react";
import { Pencil } from "lucide-react";
import { cn } from "@/lib/utils";
interface CommandLabelProps {
/** Icon to display. Pass null to hide icon, undefined for default icon. */
icon?: React.ReactNode | null;
label: string;
className?: string;
}
/**
* Displays the command name/label with an optional icon.
* Used as a header in MenuCommandModal.
*/
export function CommandLabel({ icon, label, className }: CommandLabelProps) {
// P0 Fix: null means no icon, undefined means default icon
const iconElement =
icon === null ? null : icon !== undefined ? (
icon
) : (
<Pencil className="tw-size-4 tw-text-muted" />
);
return (
<div
className={cn(
"tw-flex tw-flex-none tw-items-center tw-justify-center tw-gap-2",
"tw-px-4 tw-py-2",
className
)}
>
{iconElement}
<span className="tw-truncate tw-text-sm tw-font-medium tw-text-normal">{label}</span>
</div>
);
}

View file

@ -0,0 +1,107 @@
import * as React from "react";
import { cn } from "@/lib/utils";
import { Textarea } from "@/components/ui/textarea";
type ContentState =
| { type: "idle" }
| { type: "loading" }
| { type: "result"; text: string; isStreaming?: boolean };
interface ContentAreaProps {
state: ContentState;
editable?: boolean;
value?: string;
onChange?: (value: string) => void;
placeholder?: string;
className?: string;
minHeight?: string;
/**
* When true, uses a native <textarea> (no auto-grow) so the surrounding panel can control height.
* This avoids changing the shared Textarea component behavior.
*/
disableAutoGrow?: boolean;
}
/**
* Content area for AI responses - always uses Textarea for consistent UI.
* States:
* - idle: Empty disabled Textarea with placeholder
* - loading: Disabled Textarea showing "loading..."
* - streaming: Disabled Textarea showing streaming content
* - result: Enabled Textarea (editable) showing final content
*/
export function ContentArea({
state,
editable = false,
value,
onChange,
placeholder = "Ready to generate...",
className,
minHeight = "180px",
disableAutoGrow = false,
}: ContentAreaProps) {
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
// Auto-scroll to bottom when streaming
React.useEffect(() => {
if (state.type === "result" && state.isStreaming && textareaRef.current) {
textareaRef.current.scrollTop = textareaRef.current.scrollHeight;
}
}, [state]);
// Determine display value and disabled state based on content state
let displayValue = "";
let isDisabled = true;
if (state.type === "idle") {
displayValue = "";
isDisabled = true;
} else if (state.type === "loading") {
displayValue = "loading...";
isDisabled = true;
} else if (state.type === "result") {
// Use editable value if provided and not streaming, otherwise use state.text
displayValue = editable && value !== undefined ? value : state.text;
// Disabled during streaming, enabled when done (if editable)
isDisabled = state.isStreaming || !editable;
}
// When disableAutoGrow is true, use native textarea with flex-1 to fill available space
if (disableAutoGrow) {
return (
<div className={cn("tw-flex tw-min-h-0 tw-flex-1 tw-flex-col tw-px-4 tw-py-2", className)}>
<textarea
ref={textareaRef}
value={displayValue}
onChange={(e) => onChange?.(e.target.value)}
placeholder={placeholder}
disabled={isDisabled}
className={cn(
"tw-min-w-fit tw-overflow-auto tw-border-solid",
"tw-min-h-0 tw-w-full tw-flex-1 tw-resize-none tw-rounded-md tw-border tw-bg-transparent tw-px-3 tw-py-2 tw-text-base tw-shadow-sm placeholder:tw-text-muted focus-visible:tw-outline-none focus-visible:tw-ring-1 focus-visible:tw-ring-ring disabled:tw-cursor-not-allowed disabled:tw-opacity-50 md:tw-text-sm",
isDisabled && "tw-cursor-default tw-opacity-70"
)}
/>
</div>
);
}
return (
<div className={cn("tw-flex-1 tw-px-4 tw-py-2", className)}>
<Textarea
ref={textareaRef}
value={displayValue}
onChange={(e) => onChange?.(e.target.value)}
placeholder={placeholder}
disabled={isDisabled}
className={cn(
"tw-min-h-[120px] tw-resize-y",
isDisabled && "tw-cursor-default tw-opacity-70"
)}
style={{ minHeight }}
/>
</div>
);
}
export type { ContentState };

View file

@ -0,0 +1,28 @@
import * as React from "react";
import { cn } from "@/lib/utils";
interface DragHandleProps {
onMouseDown?: (e: React.MouseEvent) => void;
className?: string;
}
/**
* A visual drag handle indicator for the modal.
* Shows a centered bar that users can drag to reposition the modal.
* Styled to match QuickAskPanel's drag handle.
*/
export function DragHandle({ onMouseDown, className }: DragHandleProps) {
return (
<div
className={cn(
"tw-flex tw-h-4 tw-items-center tw-justify-center",
"tw-cursor-grab active:tw-cursor-grabbing",
"hover:tw-bg-[color-mix(in_srgb,var(--background-modifier-hover)_20%,transparent)]",
className
)}
onMouseDown={onMouseDown}
>
<div className="tw-h-[5px] tw-w-16 tw-rounded-sm tw-bg-[color-mix(in_srgb,var(--text-muted)_40%,transparent)] hover:tw-bg-[color-mix(in_srgb,var(--text-muted)_65%,transparent)]" />
</div>
);
}

View file

@ -0,0 +1,228 @@
import React, { useCallback, useEffect, useLayoutEffect, useState } from "react";
import { cn } from "@/lib/utils";
import { useDraggable } from "@/hooks/use-draggable";
import { useRafResizable } from "@/hooks/use-resizable";
import { DragHandle } from "./drag-handle";
import { CloseButton } from "./close-button";
const DRAGGABLE_MODAL_DATA_ATTRIBUTE = "data-copilot-draggable-modal";
const DRAGGABLE_MODAL_SELECTOR = `[${DRAGGABLE_MODAL_DATA_ATTRIBUTE}="true"]`;
interface DraggableModalProps {
open: boolean;
onClose: () => void;
children: React.ReactNode;
className?: string;
initialPosition?: { x: number; y: number };
width?: string;
/**
* Enables QuickAsk-style resize handles (bottom edge + corners).
* Affects both width and height (and may shift the modal horizontally when resizing from the left corner).
*/
resizable?: boolean;
/**
* Minimum modal height in pixels when `resizable` is enabled.
*/
minHeight?: number;
/**
* When true, pressing Escape can close the topmost DraggableModal even if focus is outside any DraggableModal.
*
* This keeps the "inner layer consumes Escape first" behavior via `event.defaultPrevented`,
* while avoiding the "one Escape closes all open modals" issue when multiple DraggableModals are open.
*/
closeOnEscapeFromOutside?: boolean;
}
/**
* A draggable modal container with Flexbox layout.
* Uses flex-none for header and flex-1 for content to ensure stable layout.
*/
export function DraggableModal({
open,
onClose,
children,
className,
initialPosition,
width = "min(500px, 90vw)",
resizable = false,
minHeight = 260,
closeOnEscapeFromOutside = false,
}: DraggableModalProps) {
const { position, setPosition, dragRef, handleMouseDown, isDragging } = useDraggable({
initialPosition: initialPosition || {
x: typeof window !== "undefined" ? (window.innerWidth - 500) / 2 : 100,
y: typeof window !== "undefined" ? (window.innerHeight - 400) / 2 : 100,
},
// Allow dragging outside window bounds (like Quick Ask)
bounds: null,
});
// Resize state (height and width)
const [heightPx, setHeightPx] = useState<number | null>(null);
const [widthPx, setWidthPx] = useState<number | null>(null);
// When resizable, lock an initial height so streaming/content won't "push" the modal taller.
useLayoutEffect(() => {
if (!open || !resizable) return;
if (heightPx !== null && widthPx !== null) return;
const el = dragRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const maxHeight =
typeof window !== "undefined" ? Math.floor(window.innerHeight * 0.85) : rect.height;
if (heightPx === null) {
setHeightPx(Math.min(maxHeight, Math.max(minHeight, rect.height)));
}
if (widthPx === null) {
setWidthPx(rect.width);
}
}, [open, resizable, heightPx, widthPx, minHeight, dragRef]);
// Auto-expand height when minHeight increases (e.g., ContentArea becomes visible)
useEffect(() => {
if (!resizable || heightPx === null) return;
if (heightPx < minHeight) {
setHeightPx(minHeight);
}
}, [resizable, minHeight, heightPx]);
const getResizeRect = useCallback(() => {
return dragRef.current?.getBoundingClientRect() ?? null;
}, [dragRef]);
const getResizeConstraints = useCallback(() => {
const maxHeight =
typeof window !== "undefined"
? Math.floor(window.innerHeight * 0.85)
: Number.POSITIVE_INFINITY;
return { minWidth: 300, minHeight, maxHeight };
}, [minHeight]);
const applyResize = useCallback(
(next: { width: number; height: number; x?: number }) => {
setHeightPx((prev) => (prev === next.height ? prev : next.height));
setWidthPx((prev) => (prev === next.width ? prev : next.width));
if (typeof next.x === "number") {
setPosition((prev) => (prev.x === next.x ? prev : { ...prev, x: next.x as number }));
}
},
[setPosition]
);
const { isResizing, handleResizeStart } = useRafResizable({
enabled: resizable,
getRect: getResizeRect,
getConstraints: getResizeConstraints,
onResize: applyResize,
});
// Handle Escape key
useEffect(() => {
if (!open) return;
const ownerDocument = dragRef.current?.ownerDocument ?? document;
const handleKeyDown = (e: KeyboardEvent) => {
// Respect defaultPrevented to let internal components (e.g., Lexical typeahead, Radix menus) consume Escape first
if (e.key !== "Escape") return;
if (e.defaultPrevented) return;
const modalEl = dragRef.current;
if (!modalEl) return;
const activeEl = ownerDocument.activeElement;
const activeModalEl =
activeEl instanceof Element ? activeEl.closest(DRAGGABLE_MODAL_SELECTOR) : null;
// If focus is inside some DraggableModal, only that modal should close.
if (activeModalEl) {
if (activeModalEl !== modalEl) return;
e.preventDefault();
e.stopPropagation();
onClose();
return;
}
// Focus is not inside any DraggableModal. Optionally close the topmost DraggableModal.
if (!closeOnEscapeFromOutside) return;
const modals = Array.from(
ownerDocument.querySelectorAll<HTMLElement>(DRAGGABLE_MODAL_SELECTOR)
);
const topmostModal = modals[modals.length - 1];
if (topmostModal !== modalEl) return;
e.preventDefault();
e.stopPropagation();
onClose();
};
ownerDocument.addEventListener("keydown", handleKeyDown);
return () => {
ownerDocument.removeEventListener("keydown", handleKeyDown);
};
}, [open, onClose, dragRef, closeOnEscapeFromOutside]);
if (!open) return null;
return (
<div
ref={dragRef}
{...{ [DRAGGABLE_MODAL_DATA_ATTRIBUTE]: "true" }}
className={cn(
// Positioning and z-index
"tw-fixed tw-z-popover",
// Flexbox layout for stable structure
"tw-flex tw-flex-col",
// Visual styling
"tw-border tw-border-solid tw-border-border tw-bg-primary",
resizable ? "tw-rounded-t-lg tw-shadow-2xl" : "tw-rounded-lg tw-shadow-2xl",
// Responsive constraints
"tw-max-h-[85vh]",
// Hover group for QuickAsk-style corner indicators
resizable && "tw-group",
// Interaction states
isDragging && "tw-cursor-grabbing tw-select-none",
isResizing && "tw-select-none",
className
)}
style={{
left: position.x,
top: position.y,
width: resizable && widthPx !== null ? widthPx : width,
...(resizable && heightPx !== null ? { height: heightPx } : {}),
}}
>
{/* Header: drag handle + close button (flex-none) */}
<div className="tw-relative tw-flex-none">
<DragHandle onMouseDown={handleMouseDown} />
<CloseButton onClose={onClose} />
</div>
{/* Main content container (flex-1 with overflow handling) */}
{/* min-h-0 is critical: allows flex children to shrink below content size */}
<div className="tw-flex tw-min-h-0 tw-flex-1 tw-flex-col tw-overflow-hidden">{children}</div>
{/* Resize handles - QuickAsk style */}
{resizable && (
<>
<div
className="tw-absolute tw-bottom-0 tw-left-0 tw-h-1 tw-w-full tw-cursor-ns-resize"
onMouseDown={handleResizeStart("bottom")}
/>
<div
className="quick-ask-resize-indicator-left tw-absolute tw-bottom-0 tw-left-0 tw-size-3 tw-cursor-nesw-resize"
onMouseDown={handleResizeStart("bottom-left")}
/>
<div
className="quick-ask-resize-indicator-right tw-absolute tw-bottom-0 tw-right-0 tw-z-[10] tw-size-3 tw-cursor-nwse-resize"
onMouseDown={handleResizeStart("bottom-right")}
/>
</>
)}
</div>
);
}

View file

@ -0,0 +1,89 @@
import * as React from "react";
import { cn } from "@/lib/utils";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { X } from "lucide-react";
interface FollowUpInputProps {
value: string;
onChange: (value: string) => void;
onSubmit?: () => void;
onClear?: () => void;
placeholder?: string;
className?: string;
showClear?: boolean;
disabled?: boolean;
/** Hint text shown on the right side of the input (e.g., "Generating...") */
hint?: string;
/** Auto-focus the input on mount */
autoFocus?: boolean;
}
/**
* Text input for follow-up instructions or questions.
* Supports Enter to submit and shows a clear button when content exists.
*/
export function FollowUpInput({
value,
onChange,
onSubmit,
onClear,
placeholder = "Enter follow-up instructions...",
className,
showClear = true,
disabled = false,
hint,
autoFocus = false,
}: FollowUpInputProps) {
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// Avoid submitting when Enter is used to confirm IME composition (e.g., Chinese/Japanese/Korean).
// keyCode 229 is a legacy indicator for IME processing.
const nativeEvent = e.nativeEvent as KeyboardEvent & {
isComposing?: boolean;
keyCode?: number;
};
if (nativeEvent.isComposing || nativeEvent.keyCode === 229) {
return;
}
if (e.key === "Enter" && !e.shiftKey && !e.metaKey && !e.ctrlKey && !e.altKey) {
// Only prevent default and submit if onSubmit is provided
if (!onSubmit) return;
e.preventDefault();
onSubmit();
}
};
return (
<div className={cn("tw-relative tw-flex-none tw-px-4 tw-py-2", className)}>
<Textarea
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={disabled}
autoFocus={autoFocus}
className="tw-min-h-[36px] tw-resize-none tw-py-2 tw-pr-8"
/>
{/* Hint text (e.g., "Generating...") - pointer-events-none to not block textarea clicks */}
{hint && (
<span className="tw-pointer-events-none tw-absolute tw-bottom-4 tw-right-6 tw-text-xs tw-text-muted">
{hint}
</span>
)}
{showClear && value && onClear && !hint && (
<Button
type="button"
variant="ghost2"
size="fit"
onClick={onClear}
disabled={disabled}
className="tw-absolute tw-right-6 tw-top-4 tw-text-muted"
aria-label="Clear input"
>
<X className="tw-size-4" />
</Button>
)}
</div>
);
}

View file

@ -0,0 +1,12 @@
export { DragHandle } from "./drag-handle";
export { CloseButton } from "./close-button";
export { CommandLabel } from "./command-label";
export { SelectedContent } from "./selected-content";
export { ContentArea, type ContentState } from "./content-area";
export { FollowUpInput } from "./follow-up-input";
export { ActionButtons } from "./action-buttons";
export { DraggableModal } from "./draggable-modal";
export { ChatMessage, type Message } from "./chat-message";
// Composed modals
export { MenuCommandModal } from "./menu-command-modal";

View file

@ -0,0 +1,207 @@
import * as React from "react";
import { Send } from "lucide-react";
import { DraggableModal } from "./draggable-modal";
import { CommandLabel } from "./command-label";
import { SelectedContent } from "./selected-content";
import { ContentArea, type ContentState } from "./content-area";
import { FollowUpInput } from "./follow-up-input";
import { ModelSelector } from "@/components/ui/ModelSelector";
import { Checkbox } from "@/components/ui/checkbox";
import { HelpTooltip } from "@/components/ui/help-tooltip";
import { ActionButtons } from "./action-buttons";
import { Button } from "@/components/ui/button";
interface MenuCommandModalProps {
open: boolean;
onClose: () => void;
/** Icon to display. Pass null to hide icon, undefined for default icon. */
commandIcon?: React.ReactNode | null;
commandLabel: string;
selectedText: string;
contentState: ContentState;
/** Editable content value (used when content is editable) */
editableContent?: string;
/** Callback when editable content changes */
onEditableContentChange?: (value: string) => void;
followUpValue: string;
onFollowUpChange: (value: string) => void;
onFollowUpSubmit: () => void;
/** Selected model key */
selectedModel: string;
/** Callback when model changes */
onSelectModel: (modelKey: string) => void;
onStop?: () => void;
onInsert?: () => void;
onReplace?: () => void;
/** Initial position for the modal (defaults to center of screen) */
initialPosition?: { x: number; y: number };
/** Enable QuickAsk-style resize (height only). */
resizable?: boolean;
/** Hide ContentArea when state is idle (for Quick Command mode) */
hideContentAreaOnIdle?: boolean;
/** Include note context checkbox state (only shown if provided) */
includeNoteContext?: boolean;
/** Callback when include note context changes */
onIncludeNoteContextChange?: (checked: boolean) => void;
}
/**
* Modal for executing menu commands (e.g., Summarize, Translate).
* Layout:
* - Command label (flex-none)
* - Selected content with hover expand (flex-none)
* - Content area for AI response (flex-1, min-h: 160px)
* - Follow-up input (flex-none)
* - Bottom toolbar with model selector and action buttons (flex-none)
*/
export function MenuCommandModal({
open,
onClose,
commandIcon,
commandLabel,
selectedText,
contentState,
editableContent,
onEditableContentChange,
followUpValue,
onFollowUpChange,
onFollowUpSubmit,
selectedModel,
onSelectModel,
onStop,
onInsert,
onReplace,
initialPosition,
resizable = false,
hideContentAreaOnIdle = false,
includeNoteContext,
onIncludeNoteContextChange,
}: MenuCommandModalProps) {
// P0 Fix: Treat streaming as "loading" state to show Stop button
const actionState =
contentState.type === "loading"
? "loading"
: contentState.type === "result" && contentState.isStreaming
? "loading"
: contentState.type === "result"
? "result"
: "idle";
// Busy == generating response (loading/streaming). While busy we still allow typing,
// but we disable submitting to avoid concurrent requests.
const isBusy =
contentState.type === "loading" ||
(contentState.type === "result" && !!contentState.isStreaming);
// Content is editable when we have a result and not streaming
const isEditable =
contentState.type === "result" && !contentState.isStreaming && !!onEditableContentChange;
// Conditionally show ContentArea based on hideContentAreaOnIdle prop
const showContentArea = hideContentAreaOnIdle ? contentState.type !== "idle" : true;
// Dynamic minHeight: compact when ContentArea is hidden, normal when shown
const dynamicMinHeight = showContentArea ? 400 : 180;
return (
<DraggableModal
open={open}
onClose={onClose}
initialPosition={initialPosition}
resizable={resizable}
minHeight={resizable ? dynamicMinHeight : undefined}
closeOnEscapeFromOutside
>
{/* Command Label - flex-none */}
<CommandLabel
icon={commandIcon}
label={commandLabel}
className="tw-border-b tw-border-border"
/>
{/* Selected Content - flex-none, hover to expand */}
<SelectedContent content={selectedText} />
{/* Content Area - flex-1, editable when result is ready */}
{showContentArea && (
<ContentArea
state={contentState}
editable={isEditable}
value={editableContent}
onChange={onEditableContentChange}
disableAutoGrow={resizable}
minHeight={resizable ? "0px" : undefined}
/>
)}
{/* Follow-up Input - flex-none, allow typing during streaming but disable submit */}
<FollowUpInput
value={followUpValue}
onChange={onFollowUpChange}
onSubmit={() => {
if (!isBusy) onFollowUpSubmit();
}}
onClear={() => onFollowUpChange("")}
placeholder="Enter follow-up instructions..."
className={!showContentArea ? "tw-mt-auto" : undefined}
hint={isBusy ? "Generating..." : undefined}
autoFocus
/>
{/* Bottom Toolbar - flex-none */}
<div className="tw-flex tw-flex-none tw-items-center tw-justify-between tw-border-t tw-border-border tw-px-4 tw-py-3">
<div className="tw-flex tw-items-center tw-gap-3">
<ModelSelector
size="sm"
variant="ghost"
value={selectedModel}
onChange={onSelectModel}
disabled={isBusy}
/>
{onIncludeNoteContextChange && (
<div className="tw-flex tw-items-center tw-gap-1.5">
<Checkbox
id="menuCommandIncludeContext"
checked={includeNoteContext}
onCheckedChange={(checked) => onIncludeNoteContextChange(!!checked)}
className="tw-size-3.5"
disabled={isBusy}
/>
<label
htmlFor="menuCommandIncludeContext"
className="tw-cursor-pointer tw-text-xs tw-text-muted"
>
Note
</label>
<HelpTooltip
content="Include the active note's content as context"
side="top"
/>
</div>
)}
</div>
<div className="tw-flex tw-items-center tw-gap-2">
{/* Send button: shown when content area is hidden (idle Quick Command mode) */}
{hideContentAreaOnIdle && actionState === "idle" && (
<Button
variant="default"
size="sm"
onClick={onFollowUpSubmit}
disabled={!followUpValue.trim() || isBusy}
title="Send message"
>
<Send className="tw-mr-1 tw-size-4" />
Send
</Button>
)}
<ActionButtons
state={actionState}
onStop={onStop}
onInsert={onInsert}
onReplace={onReplace}
/>
</div>
</div>
</DraggableModal>
);
}

View file

@ -0,0 +1,49 @@
import * as React from "react";
import { cn } from "@/lib/utils";
import { Eye } from "lucide-react";
import { Button } from "@/components/ui/button";
import { HelpTooltip } from "@/components/ui/help-tooltip";
interface SelectedContentProps {
content: string;
className?: string;
}
/**
* Displays selected context with multi-line truncation (up to 3 lines).
* Hover or focus the Eye icon on the right to view full content in a tooltip.
* On mobile, click the Eye icon to toggle the tooltip.
*/
export function SelectedContent({ content, className }: SelectedContentProps) {
if (!content) return null;
return (
<div
className={cn(
// Use tw-items-start so Eye icon aligns with first line when content spans multiple lines
"tw-group tw-flex tw-w-full tw-items-start tw-gap-2",
"tw-border-b tw-border-border tw-bg-secondary/30",
"tw-px-4 tw-py-2",
className
)}
>
{/* Truncated content (up to 3 lines) */}
<span className="tw-line-clamp-2 tw-min-w-0 tw-flex-1 tw-whitespace-pre-wrap tw-break-words tw-text-sm tw-text-muted">
{content}
</span>
{/* Eye icon with tooltip - visible on hover/focus, always visible on touch devices */}
<div className="tw-group-hover:tw-opacity-100 tw-group-focus-within:tw-opacity-100 tw-flex-none tw-opacity-30 tw-transition-opacity">
<HelpTooltip
content={content}
side="left"
contentClassName="tw-max-h-64 tw-max-w-xs tw-overflow-y-auto tw-whitespace-pre-wrap"
>
<Button type="button" variant="ghost2" size="fit" aria-label="View full content">
<Eye className="tw-size-4 tw-text-muted" />
</Button>
</HelpTooltip>
</div>
</div>
);
}

View file

@ -0,0 +1,75 @@
/**
* ModeSelector - Dropdown for selecting Quick Ask mode.
* Uses DropdownMenu for consistent styling with ModelSelector.
*/
import React from "react";
import { MessageCircle, Pencil, Zap, ChevronDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import type { QuickAskMode, QuickAskModeConfig } from "./types";
interface ModeSelectorProps {
modes: QuickAskModeConfig[];
value: QuickAskMode;
onChange: (mode: QuickAskMode) => void;
disabled?: boolean;
}
const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
"message-circle": MessageCircle,
pencil: Pencil,
zap: Zap,
};
/**
* ModeSelector component for Quick Ask panel.
*/
export function ModeSelector({ modes, value, onChange, disabled }: ModeSelectorProps) {
const currentMode = modes.find((m) => m.id === value);
const IconComponent = iconMap[currentMode?.icon ?? ""] ?? MessageCircle;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
disabled={disabled}
className="tw-min-w-0 tw-gap-1 tw-px-1.5 tw-text-muted"
>
<IconComponent className="tw-size-3.5" />
<span className="tw-text-xs">{currentMode?.label ?? "Ask"}</span>
{!disabled && <ChevronDown className="tw-size-3.5" />}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{modes.map((mode) => {
const ModeIcon = iconMap[mode.icon] ?? MessageCircle;
return (
<DropdownMenuItem
key={mode.id}
onSelect={() => onChange(mode.id)}
disabled={!mode.implemented}
className="tw-gap-2"
>
<ModeIcon className="tw-size-4" />
<div className="tw-flex tw-flex-col">
<span>{mode.label}</span>
{!mode.implemented && (
<span className="tw-text-xs tw-text-muted">Coming soon</span>
)}
</div>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}

View file

@ -0,0 +1,175 @@
/**
* QuickAskInput - Lexical-based input component for Quick Ask.
* Simplified version of LexicalEditor with @ mention support.
*/
import React, { useCallback, useEffect, useState } from "react";
import { $getRoot, EditorState, LexicalEditor as LexicalEditorType } from "lexical";
import { LexicalComposer } from "@lexical/react/LexicalComposer";
import { PlainTextPlugin } from "@lexical/react/LexicalPlainTextPlugin";
import { ContentEditable } from "@lexical/react/LexicalContentEditable";
import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin";
import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin";
import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary";
import { TFile } from "obsidian";
import { NotePillNode } from "@/components/chat-components/pills/NotePillNode";
import { ActiveNotePillNode } from "@/components/chat-components/pills/ActiveNotePillNode";
import { FolderPillNode } from "@/components/chat-components/pills/FolderPillNode";
import { WebTabPillNode } from "@/components/chat-components/pills/WebTabPillNode";
import { ActiveWebTabPillNode } from "@/components/chat-components/pills/ActiveWebTabPillNode";
import { KeyboardPlugin } from "@/components/chat-components/plugins/KeyboardPlugin";
import { ValueSyncPlugin } from "@/components/chat-components/plugins/ValueSyncPlugin";
import { FocusPlugin } from "@/components/chat-components/plugins/FocusPlugin";
import { NotePillSyncPlugin } from "@/components/chat-components/plugins/NotePillSyncPlugin";
import { FolderPillSyncPlugin } from "@/components/chat-components/plugins/FolderPillSyncPlugin";
import { ActiveNotePillSyncPlugin } from "@/components/chat-components/plugins/ActiveNotePillSyncPlugin";
import { PillDeletionPlugin } from "@/components/chat-components/plugins/PillDeletionPlugin";
import { AtMentionCommandPlugin } from "@/components/chat-components/plugins/AtMentionCommandPlugin";
import { ActiveFileProvider } from "@/components/chat-components/context/ActiveFileContext";
import { cn } from "@/lib/utils";
import { logError } from "@/logger";
import { SEND_SHORTCUT } from "@/constants";
interface QuickAskInputProps {
value: string;
onChange: (value: string) => void;
onSubmit: () => void;
/** Send shortcut configuration */
sendShortcut?: SEND_SHORTCUT;
placeholder?: string;
disabled?: boolean;
className?: string;
/** Callback when notes are added via @ mention */
onNotesChange?: (notes: { path: string; basename: string }[]) => void;
/** Callback when notes are removed */
onNotesRemoved?: (removedNotes: { path: string; basename: string }[]) => void;
/** Callback when folders are added via @ mention */
onFoldersChange?: (folders: string[]) => void;
/** Callback when folders are removed */
onFoldersRemoved?: (removedFolders: string[]) => void;
/** Callback when active note is added */
onActiveNoteAdded?: () => void;
/** Callback when active note is removed */
onActiveNoteRemoved?: () => void;
/** Callback when editor is ready */
onEditorReady?: (editor: LexicalEditorType) => void;
/** Current active file for @ mention context */
currentActiveFile?: TFile | null;
}
/**
* QuickAskInput - Lexical editor for Quick Ask panel.
*/
export const QuickAskInput = React.memo(function QuickAskInput({
value,
onChange,
onSubmit,
sendShortcut = SEND_SHORTCUT.ENTER,
placeholder = "Ask a question...",
disabled = false,
className = "",
onNotesChange,
onNotesRemoved,
onFoldersChange,
onFoldersRemoved,
onActiveNoteAdded,
onActiveNoteRemoved,
onEditorReady,
currentActiveFile = null,
}: QuickAskInputProps) {
const [focusFn, setFocusFn] = useState<(() => void) | null>(null);
// Wrapper to properly set function state
const handleFocusRegistration = useCallback((fn: () => void) => {
setFocusFn(() => fn);
}, []);
// Auto-focus on mount
useEffect(() => {
if (focusFn) {
const timer = setTimeout(() => {
focusFn();
}, 50);
return () => clearTimeout(timer);
}
}, [focusFn]);
const initialConfig = React.useMemo(
() => ({
namespace: "QuickAskEditor",
theme: {
root: "tw-outline-none",
paragraph: "tw-m-0",
},
nodes: [
NotePillNode,
ActiveNotePillNode,
FolderPillNode,
WebTabPillNode,
ActiveWebTabPillNode,
],
onError: (error: Error) => {
logError("QuickAskInput Lexical error:", error);
},
editable: !disabled,
}),
[disabled]
);
const handleEditorChange = useCallback(
(editorState: EditorState) => {
editorState.read(() => {
const root = $getRoot();
const textContent = root.getTextContent();
onChange(textContent);
});
},
[onChange]
);
const handleEditorReady = useCallback(
(editor: LexicalEditorType) => {
onEditorReady?.(editor);
},
[onEditorReady]
);
return (
<LexicalComposer initialConfig={initialConfig}>
<ActiveFileProvider currentActiveFile={currentActiveFile}>
<div className={cn("tw-relative", className)}>
<PlainTextPlugin
contentEditable={
<ContentEditable
className="tw-max-h-48 tw-min-h-12 tw-w-full tw-resize-none tw-overflow-y-auto tw-border-none tw-bg-transparent tw-px-1 tw-py-2 tw-pr-8 tw-text-sm tw-text-normal tw-outline-none"
aria-label="Quick Ask input"
/>
}
placeholder={
<div className="tw-pointer-events-none tw-absolute tw-left-1 tw-top-2 tw-select-none tw-text-sm tw-text-muted/60">
{placeholder}
</div>
}
ErrorBoundary={LexicalErrorBoundary}
/>
<OnChangePlugin onChange={handleEditorChange} />
<HistoryPlugin />
<KeyboardPlugin onSubmit={onSubmit} sendShortcut={sendShortcut} />
<ValueSyncPlugin value={value} />
<FocusPlugin onFocus={handleFocusRegistration} onEditorReady={handleEditorReady} />
<NotePillSyncPlugin onNotesChange={onNotesChange} onNotesRemoved={onNotesRemoved} />
<FolderPillSyncPlugin
onFoldersChange={onFoldersChange}
onFoldersRemoved={onFoldersRemoved}
/>
<ActiveNotePillSyncPlugin
onActiveNoteAdded={onActiveNoteAdded}
onActiveNoteRemoved={onActiveNoteRemoved}
/>
<PillDeletionPlugin />
<AtMentionCommandPlugin isCopilotPlus={false} currentActiveFile={currentActiveFile} />
</div>
</ActiveFileProvider>
</LexicalComposer>
);
});

View file

@ -0,0 +1,171 @@
/**
* QuickAskMessage - Renders individual messages in Quick Ask panel.
* Handles markdown rendering for assistant messages.
*/
import React, { useEffect, useRef } from "react";
import { MarkdownRenderer } from "obsidian";
import { Copy, ClipboardPaste, Replace } from "lucide-react";
import { Button } from "@/components/ui/button";
import { getErrorMessage, type ReplaceInvalidReason } from "@/editor/replaceGuard";
import { logError } from "@/logger";
import type CopilotPlugin from "@/main";
import type { QuickAskMessage } from "./types";
interface QuickAskMessageProps {
message: QuickAskMessage;
isStreaming: boolean;
isLastAssistantMessage: boolean;
onCopy: (messageId: string) => void;
onInsert: (messageId: string) => void;
onReplace: (messageId: string) => void;
hasSelection: boolean;
isReplaceValid: boolean;
replaceInvalidReason: ReplaceInvalidReason | null;
/** Whether Replace is disabled because streaming is in progress */
isDisabledDueToStreaming?: boolean;
/** File path captured when panel opened; used for stable Markdown link resolution */
filePathSnapshot: string | null;
plugin: CopilotPlugin;
}
/**
* Component for rendering a single Quick Ask message.
* Renders markdown for completed assistant messages.
*/
export const QuickAskMessageComponent = React.memo(function QuickAskMessageComponent({
message,
isStreaming,
isLastAssistantMessage,
onCopy,
onInsert,
onReplace,
hasSelection,
isReplaceValid,
replaceInvalidReason,
isDisabledDueToStreaming,
filePathSnapshot,
plugin,
}: QuickAskMessageProps) {
const contentRef = useRef<HTMLDivElement>(null);
// Render markdown for completed assistant messages
useEffect(() => {
if (message.role !== "assistant" || isStreaming) return;
let cancelled = false;
/**
* Renders markdown directly into the target element if still mounted.
*/
const renderMarkdown = async (): Promise<void> => {
const targetEl = contentRef.current;
if (!targetEl) return;
targetEl.empty();
const sourcePath = filePathSnapshot ?? "";
try {
await MarkdownRenderer.renderMarkdown(message.content, targetEl, sourcePath, plugin);
} catch (error) {
logError("Failed to render markdown:", error);
if (cancelled) return;
// Fallback to plain text if markdown rendering fails
targetEl.empty();
targetEl.textContent = message.content;
}
if (cancelled) return;
targetEl.classList.add("markdown-rendered");
};
void renderMarkdown();
return () => {
cancelled = true;
};
}, [message.content, message.role, isStreaming, filePathSnapshot, plugin]);
// User message - right aligned with accent background (like YOLO)
if (message.role === "user") {
return (
<div className="tw-max-w-[85%] tw-self-end tw-rounded-lg tw-rounded-br-sm tw-bg-interactive-accent tw-px-3 tw-py-2 tw-text-on-accent">
<div className="tw-whitespace-pre-wrap tw-break-words tw-text-sm">{message.content}</div>
</div>
);
}
// Assistant message - streaming (left aligned)
if (isStreaming) {
return (
<div className="tw-max-w-[95%] tw-self-start tw-rounded-lg tw-rounded-bl-sm tw-bg-secondary tw-px-3 tw-py-2">
<div className="tw-whitespace-pre-wrap tw-break-words tw-text-sm tw-text-normal">
{message.content}
<span className="tw-animate-pulse tw-text-accent"></span>
</div>
</div>
);
}
// Assistant message - completed with markdown + action buttons (left aligned)
// Last assistant message shows action buttons by default, others show on hover
const actionBarVisibility = isLastAssistantMessage
? "tw-opacity-100"
: "tw-opacity-0 group-hover/message:tw-opacity-100";
return (
<div className="tw-group/message tw-max-w-[95%] tw-self-start">
<div className="tw-rounded-lg tw-rounded-bl-sm tw-bg-secondary tw-px-3 tw-py-2">
<div
ref={contentRef}
className="tw-text-sm [&.markdown-rendered]:tw-text-sm [&_code]:tw-text-xs [&_p]:tw-my-1 [&_pre]:tw-my-2"
/>
</div>
{message.content && (
<div
className={`tw-mt-1 tw-flex tw-items-center tw-gap-0.5 tw-transition-opacity ${actionBarVisibility}`}
>
<Button
variant="ghost2"
size="icon"
className="tw-size-5 hover:tw-bg-modifier-hover"
onClick={() => onCopy(message.id)}
title="Copy to clipboard"
>
<Copy className="tw-size-3" />
</Button>
<Button
variant="ghost2"
size="icon"
className="tw-size-5 hover:tw-bg-modifier-hover"
onClick={() => onInsert(message.id)}
title="Insert at cursor"
>
<ClipboardPaste className="tw-size-3" />
</Button>
{hasSelection && (
<Button
variant="ghost2"
size="icon"
className="tw-size-5 hover:tw-bg-modifier-hover"
onClick={() => onReplace(message.id)}
disabled={!isReplaceValid}
title={
isDisabledDueToStreaming
? "Stop generating to replace"
: !isReplaceValid
? getErrorMessage(replaceInvalidReason)
: "Replace selection"
}
>
<Replace className="tw-size-3" />
</Button>
)}
</div>
)}
</div>
);
});

View file

@ -0,0 +1,744 @@
/**
* QuickAskOverlay - Manages the DOM overlay for Quick Ask panel.
* Handles positioning, scroll tracking, drag/resize state, and React root lifecycle.
*
* This is the single source of truth for panel position and size.
* QuickAskPanel fills its container and delegates resize events here.
*/
import { EditorView } from "@codemirror/view";
import type { Editor } from "obsidian";
import React from "react";
import { createRoot, Root } from "react-dom/client";
import { updateDynamicStyleClass, clearDynamicStyleClass } from "@/utils/dom/dynamicStyleManager";
import { QuickAskPanel } from "./QuickAskPanel";
import type CopilotPlugin from "@/main";
import type { ReplaceGuard } from "@/editor/replaceGuard";
import type { ResizeDirection } from "@/hooks/use-resizable";
// Layout constants for Quick Ask panel positioning
const PANEL_MARGIN = 12;
const PANEL_OFFSET_Y = 6;
const PANEL_DEFAULT_WIDTH_RATIO = 0.83; // 83% of viewport
const PANEL_MAX_WIDTH_RATIO = 0.9; // 90% of viewport
const PANEL_DEFAULT_WIDTH_MAX = 420;
const PANEL_MAX_WIDTH_MAX = 560;
const PANEL_MIN_WIDTH = 300;
const PANEL_MIN_HEIGHT = 200;
interface AnchorRect {
left: number;
right: number;
top: number;
bottom: number;
}
interface QuickAskOverlayOptions {
plugin: CopilotPlugin;
editor: Editor;
view: EditorView;
selectedText: string;
selectionFrom: number;
selectionTo: number;
replaceGuard: ReplaceGuard;
onClose: () => void;
}
/**
* Overlay class that manages the Quick Ask panel DOM and positioning.
* Single source of truth for position and size.
*/
export class QuickAskOverlay {
private static overlayRoot: HTMLElement | null = null;
private static currentInstance: QuickAskOverlay | null = null;
private root: Root | null = null;
private overlayContainer: HTMLDivElement | null = null;
private cleanupCallbacks: (() => void)[] = [];
private overlayHost: HTMLElement | null = null;
private ownerDocument: Document | null = null;
private ownerWindow: Window | null = null;
private rafId: number | null = null;
private panelRerenderRafId: number | null = null;
private resizeObserver: ResizeObserver | null = null;
private isClosing = false;
private closeAnimationTimeout: number | null = null;
// Drag state
private dragPosition: { x: number; y: number } | null = null;
// Resize state (single source of truth; height is optional — only set when user resizes vertically)
private resizeSize: { width: number; height?: number } | null = null;
// Reason: Track whether the user has intentionally resized the height
// (vs width-only resize which shouldn't remove the chat area max-height).
private hasUserResizedHeight = false;
// Anchor position
private pos: number | null = null;
private fallbackPos: number | null = null;
// Resize interaction state
private isResizing = false;
private resizeDirection: ResizeDirection | null = null;
private resizeStartRect: DOMRect | null = null;
private resizeStartMouse: { x: number; y: number } | null = null;
private resizeRafId: number | null = null;
// Save original body styles to restore after resize
private savedBodyUserSelect: string = "";
private savedBodyCursor: string = "";
constructor(private readonly options: QuickAskOverlayOptions) {}
/**
* Mounts the overlay at the specified anchor positions.
* @param pos - Primary anchor position (typically selection head)
* @param fallbackPos - Fallback anchor position (typically selection anchor)
*/
mount(pos: number, fallbackPos?: number | null): void {
this.pos = pos;
this.fallbackPos = typeof fallbackPos === "number" ? fallbackPos : null;
QuickAskOverlay.currentInstance = this;
this.mountOverlay();
this.setupGlobalListeners();
this.schedulePositionUpdate();
}
/**
* Destroys the overlay and cleans up resources.
*/
destroy(): void {
// Clear current instance reference
if (QuickAskOverlay.currentInstance === this) {
QuickAskOverlay.currentInstance = null;
}
const win = this.ownerWindow ?? window;
if (this.closeAnimationTimeout !== null) {
win.clearTimeout(this.closeAnimationTimeout);
this.closeAnimationTimeout = null;
}
// Clean up resize state
this.cleanupResize();
// Run cleanup callbacks
for (const cleanup of this.cleanupCallbacks) {
try {
cleanup();
} catch {
// Ignore cleanup errors
}
}
this.cleanupCallbacks = [];
if (this.rafId !== null) {
win.cancelAnimationFrame(this.rafId);
this.rafId = null;
}
if (this.panelRerenderRafId !== null) {
win.cancelAnimationFrame(this.panelRerenderRafId);
this.panelRerenderRafId = null;
}
this.resizeObserver?.disconnect();
this.resizeObserver = null;
this.root?.unmount();
this.root = null;
if (this.overlayContainer?.parentNode) {
this.overlayContainer.parentNode.removeChild(this.overlayContainer);
}
if (this.overlayContainer) {
clearDynamicStyleClass(this.overlayContainer);
}
this.overlayContainer = null;
const overlayRoot = QuickAskOverlay.overlayRoot;
if (overlayRoot && overlayRoot.childElementCount === 0) {
const host = overlayRoot.parentElement;
overlayRoot.remove();
QuickAskOverlay.overlayRoot = null;
host?.classList.remove("copilot-quick-ask-overlay-host");
}
this.pos = null;
this.fallbackPos = null;
this.ownerDocument = null;
this.ownerWindow = null;
}
/**
* Updates the anchor position.
*/
updatePosition(pos?: number, fallbackPos?: number | null): void {
if (typeof pos === "number") {
this.pos = pos;
}
if (typeof fallbackPos === "number") {
this.fallbackPos = fallbackPos;
} else if (fallbackPos === null) {
this.fallbackPos = null;
}
this.schedulePositionUpdate();
}
/**
* Gets the ReplaceGuard instance (for quickAskExtension to call onDocChanged).
*/
getReplaceGuard() {
return this.options.replaceGuard;
}
/**
* Schedules a React re-render of the QuickAskPanel (coalesced per animation frame).
* Called when document changes to update Replace button disabled state.
*/
schedulePanelRerender(): void {
if (this.panelRerenderRafId !== null) {
return;
}
const win = this.ownerWindow ?? window;
this.panelRerenderRafId = win.requestAnimationFrame(() => {
this.panelRerenderRafId = null;
this.renderPanel();
});
}
/**
* Re-renders the QuickAskPanel with current props.
* React preserves component state across renders.
*/
private renderPanel(): void {
if (!this.root) {
return;
}
this.root.render(
<QuickAskPanel
plugin={this.options.plugin}
editor={this.options.editor}
view={this.options.view}
selectedText={this.options.selectedText}
replaceGuard={this.options.replaceGuard}
onClose={this.closeWithAnimation}
onDragOffset={this.handleDragOffset}
onResizeStart={this.handleResizeStart}
hasCustomHeight={this.hasUserResizedHeight}
/>
);
}
/**
* Triggers close animation from outside.
*/
static closeCurrentWithAnimation(): boolean {
if (QuickAskOverlay.currentInstance) {
QuickAskOverlay.currentInstance.closeWithAnimation();
return true;
}
return false;
}
private closeWithAnimation = () => {
if (this.isClosing) return;
this.isClosing = true;
// Add closing animation class
if (this.overlayContainer) {
this.overlayContainer.classList.add("closing");
// Listen for animation end instead of hardcoded timeout
// Filter by target and animation name to avoid child element animations triggering close
const handleAnimationEnd = (event: AnimationEvent) => {
if (
event.target !== this.overlayContainer ||
event.animationName !== "copilot-quick-ask-fade-out"
) {
return;
}
this.overlayContainer?.removeEventListener("animationend", handleAnimationEnd);
if (this.closeAnimationTimeout !== null) {
window.clearTimeout(this.closeAnimationTimeout);
this.closeAnimationTimeout = null;
}
this.options.onClose();
};
this.overlayContainer.addEventListener("animationend", handleAnimationEnd);
// Fallback timeout in case animationend doesn't fire (e.g., reduced motion)
const win = this.ownerWindow ?? window;
this.closeAnimationTimeout = win.setTimeout(() => {
this.overlayContainer?.removeEventListener("animationend", handleAnimationEnd);
this.closeAnimationTimeout = null;
this.options.onClose();
}, 300); // Slightly longer than animation as fallback
} else {
this.options.onClose();
}
};
private static getOverlayRoot(host: HTMLElement): HTMLElement {
if (QuickAskOverlay.overlayRoot && QuickAskOverlay.overlayRoot.parentElement !== host) {
QuickAskOverlay.overlayRoot.parentElement?.classList.remove("copilot-quick-ask-overlay-host");
QuickAskOverlay.overlayRoot.remove();
QuickAskOverlay.overlayRoot = null;
}
if (QuickAskOverlay.overlayRoot) return QuickAskOverlay.overlayRoot;
const doc = host.ownerDocument ?? document;
const root = doc.createElement("div");
root.className = "copilot-quick-ask-overlay-root";
host.appendChild(root);
host.classList.add("copilot-quick-ask-overlay-host");
QuickAskOverlay.overlayRoot = root;
return root;
}
private mountOverlay(): void {
// Mount overlay inside editor DOM for proper layering
const overlayHost = this.options.view.dom ?? document.body;
this.overlayHost = overlayHost;
// Capture owner document/window for popout window compatibility
const doc = overlayHost.ownerDocument ?? document;
const win = doc.defaultView ?? window;
this.ownerDocument = doc;
this.ownerWindow = win;
const overlayRoot = QuickAskOverlay.getOverlayRoot(overlayHost);
const overlayContainer = doc.createElement("div");
overlayContainer.className = "copilot-quick-ask-overlay";
overlayRoot.appendChild(overlayContainer);
this.overlayContainer = overlayContainer;
this.root = createRoot(overlayContainer);
this.renderPanel();
// Setup scroll listeners
const handleScroll = () => this.schedulePositionUpdate();
win.addEventListener("scroll", handleScroll, true);
this.cleanupCallbacks.push(() => win.removeEventListener("scroll", handleScroll, true));
const handleResize = () => this.schedulePositionUpdate();
win.addEventListener("resize", handleResize);
this.cleanupCallbacks.push(() => win.removeEventListener("resize", handleResize));
const scrollDom = this.options.view?.scrollDOM;
if (scrollDom) {
scrollDom.addEventListener("scroll", handleScroll);
this.cleanupCallbacks.push(() => scrollDom.removeEventListener("scroll", handleScroll));
}
// Setup resize observer with availability check
// Reason: Only observe scrollDom (editor viewport changes). Do NOT observe
// overlayContainer — its height changes during AI streaming would trigger
// position recalculation and cause the panel to jump up/down.
if (typeof ResizeObserver !== "undefined") {
this.resizeObserver = new ResizeObserver(() => this.schedulePositionUpdate());
if (scrollDom) this.resizeObserver.observe(scrollDom);
}
}
private setupGlobalListeners(): void {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
if (event.defaultPrevented) return;
const doc = this.ownerDocument ?? document;
const activeEl = doc.activeElement;
const isFocusInsidePanel = !!(activeEl && this.overlayContainer?.contains(activeEl));
// If focus is inside the panel, let QuickAskPanel / Lexical consume Escape first.
if (isFocusInsidePanel) return;
event.preventDefault();
event.stopPropagation();
this.closeWithAnimation();
};
// Bubble-phase so inner handlers (Lexical menus, etc.) can run first.
const win = this.ownerWindow ?? window;
win.addEventListener("keydown", handleKeyDown);
this.cleanupCallbacks.push(() => win.removeEventListener("keydown", handleKeyDown));
}
private schedulePositionUpdate(): void {
if (this.rafId !== null) return;
const win = this.ownerWindow ?? window;
this.rafId = win.requestAnimationFrame(() => {
this.rafId = null;
this.updateOverlayPosition();
});
}
/**
* Returns true if the anchor rect intersects the visible editor viewport.
* Mirrors CustomCommandChatModal's visibility check.
*/
private isAnchorRectVisible(coords: AnchorRect, visibleRect: DOMRect): boolean {
return (
coords.bottom >= visibleRect.top &&
coords.top <= visibleRect.bottom &&
coords.right >= visibleRect.left &&
coords.left <= visibleRect.right
);
}
/**
* Try selection head first, then anchor - use whichever is visible.
* If neither is visible, return null so the caller can fall back to centering.
*/
private resolveVisibleAnchorRect(
hostRect: DOMRect,
scrollRect: DOMRect | undefined
): AnchorRect | null {
const visibleRect = scrollRect ?? hostRect;
const positionsToTry: Array<number | null> = [this.pos, this.fallbackPos];
for (const pos of positionsToTry) {
if (typeof pos !== "number") continue;
const coords = this.options.view.coordsAtPos(pos) as AnchorRect | null;
if (!coords) continue;
if (this.isAnchorRectVisible(coords, visibleRect)) return coords;
}
return null;
}
private updateOverlayPosition(): void {
if (!this.overlayContainer || this.pos === null) return;
// If panel has been dragged, use drag position
if (this.dragPosition) {
this.updateDragPosition();
return;
}
const doc = this.ownerDocument ?? document;
const hostRect =
this.overlayHost?.getBoundingClientRect() ?? doc.body.getBoundingClientRect();
const viewportWidth = hostRect.width;
const scrollDom = this.options.view.scrollDOM;
const scrollRect = scrollDom?.getBoundingClientRect();
const sizer = scrollDom?.querySelector(".cm-sizer");
const sizerRect = sizer?.getBoundingClientRect();
// Try to find a visible anchor position (head first, then anchor)
const anchorRect = this.resolveVisibleAnchorRect(hostRect, scrollRect);
// Calculate panel dimensions using constants
const defaultWidth = Math.min(
PANEL_DEFAULT_WIDTH_MAX,
viewportWidth * PANEL_DEFAULT_WIDTH_RATIO
);
const maxWidth = Math.min(PANEL_MAX_WIDTH_MAX, viewportWidth * PANEL_MAX_WIDTH_RATIO);
// Minimum width adapts to viewport to prevent overflow in narrow panes
const minWidth = Math.min(PANEL_MIN_WIDTH, viewportWidth - PANEL_MARGIN * 2);
// Respect resizeSize even when not dragged
const panelWidth =
this.resizeSize?.width ?? Math.max(minWidth, Math.min(defaultWidth, maxWidth));
const panelHeight = this.resizeSize?.height;
const contentLeft =
(sizerRect?.left ?? scrollRect?.left ?? hostRect.left + PANEL_MARGIN) - hostRect.left;
const editorContentWidth =
sizerRect?.width ?? scrollRect?.width ?? viewportWidth - PANEL_MARGIN * 2;
const contentRight = contentLeft + editorContentWidth;
// Calculate left position: anchor to selection if visible, otherwise center
let left = anchorRect
? anchorRect.left - hostRect.left
: contentLeft + (editorContentWidth - panelWidth) / 2;
left = Math.min(left, contentRight - panelWidth);
left = Math.max(left, contentLeft);
left = Math.min(left, viewportWidth - PANEL_MARGIN - panelWidth);
left = Math.max(left, PANEL_MARGIN);
// Calculate visible area bounds for top positioning
const visibleTop = (scrollRect?.top ?? hostRect.top) - hostRect.top;
const visibleBottom = (scrollRect?.bottom ?? hostRect.bottom) - hostRect.top;
const visibleHeight = visibleBottom - visibleTop;
const minTop = visibleTop + PANEL_MARGIN;
// First pass: apply width/left so we can measure actual height for centering
updateDynamicStyleClass(this.overlayContainer, "copilot-quick-ask-overlay-pos", {
width: panelWidth,
...(typeof panelHeight === "number" ? { height: panelHeight } : {}),
left: Math.round(left),
top: Math.round(minTop), // Temporary top for measurement
});
// Measure height for centering and clamping
const heightForClamp =
typeof panelHeight === "number"
? panelHeight
: this.overlayContainer.getBoundingClientRect().height || PANEL_MIN_HEIGHT;
// Calculate top position: anchor below selection if visible, otherwise center in viewport
let top = anchorRect
? anchorRect.bottom - hostRect.top + PANEL_OFFSET_Y
: visibleTop + (visibleHeight - heightForClamp) / 2; // True center
top = Math.max(top, minTop);
// Clamp top to keep panel within viewport
const maxTop = visibleBottom - PANEL_MARGIN - heightForClamp;
const effectiveMaxTop = Math.max(minTop, maxTop);
const clampedTop = Math.max(minTop, Math.min(top, effectiveMaxTop));
// Final pass: apply the correct top position
updateDynamicStyleClass(this.overlayContainer, "copilot-quick-ask-overlay-pos", {
width: panelWidth,
...(typeof panelHeight === "number" ? { height: panelHeight } : {}),
left: Math.round(left),
top: Math.round(clampedTop),
});
}
private handleDragOffset = (offset: { x: number; y: number }): void => {
this.dragPosition = offset;
this.updateDragPosition();
};
/**
* Called by Panel when user starts resizing.
* Overlay takes over and handles the entire resize interaction.
*/
private handleResizeStart = (
direction: ResizeDirection,
start: { x: number; y: number }
): void => {
if (this.isResizing) return;
const rect = this.overlayContainer?.getBoundingClientRect();
if (!rect) return;
this.isResizing = true;
this.resizeDirection = direction;
this.resizeStartRect = rect;
this.resizeStartMouse = start;
const doc = this.ownerDocument ?? document;
const body = doc.body;
// Prevent text selection and set resize cursor on body during drag
// Save original values to restore later
this.savedBodyUserSelect = body.style.userSelect;
this.savedBodyCursor = body.style.cursor;
body.classList.add("copilot-quick-ask-resizing");
body.style.userSelect = "none";
// Set cursor based on direction to ensure consistent feedback
const cursorMap: Record<ResizeDirection, string> = {
right: "ew-resize",
bottom: "ns-resize",
"bottom-left": "nesw-resize",
"bottom-right": "nwse-resize",
};
body.style.cursor = cursorMap[direction] ?? "default";
// Bind document-level listeners (use capture for consistency with useRafResizable/useDraggable)
doc.addEventListener("mousemove", this.handleResizeMove, true);
doc.addEventListener("mouseup", this.handleResizeEnd, true);
};
private handleResizeMove = (e: MouseEvent): void => {
if (!this.isResizing || !this.resizeStartRect || !this.resizeStartMouse) return;
const win = this.ownerWindow ?? window;
// Cancel any pending RAF
if (this.resizeRafId !== null) {
win.cancelAnimationFrame(this.resizeRafId);
}
this.resizeRafId = win.requestAnimationFrame(() => {
this.resizeRafId = null;
this.applyResize(e.clientX, e.clientY);
});
};
private handleResizeEnd = (): void => {
this.cleanupResize();
// Re-render panel to update hasCustomHeight
this.renderPanel();
};
private cleanupResize(): void {
const win = this.ownerWindow ?? window;
if (this.resizeRafId !== null) {
win.cancelAnimationFrame(this.resizeRafId);
this.resizeRafId = null;
}
// Only restore body styles if we actually started resizing
// This prevents polluting body styles when destroy() is called without resize
if (this.isResizing) {
const doc = this.ownerDocument ?? document;
const body = doc.body;
doc.removeEventListener("mousemove", this.handleResizeMove, true);
doc.removeEventListener("mouseup", this.handleResizeEnd, true);
body.classList.remove("copilot-quick-ask-resizing");
// Restore original body styles
body.style.userSelect = this.savedBodyUserSelect;
body.style.cursor = this.savedBodyCursor;
}
this.isResizing = false;
this.resizeDirection = null;
this.resizeStartRect = null;
this.resizeStartMouse = null;
}
private applyResize(clientX: number, clientY: number): void {
if (!this.resizeStartRect || !this.resizeStartMouse || !this.resizeDirection) return;
const doc = this.ownerDocument ?? document;
const hostRect =
this.overlayHost?.getBoundingClientRect() ?? doc.body.getBoundingClientRect();
const deltaX = clientX - this.resizeStartMouse.x;
const deltaY = clientY - this.resizeStartMouse.y;
const startRect = this.resizeStartRect;
const direction = this.resizeDirection;
// Calculate constraints
const viewportWidth = hostRect.width;
const minWidth = Math.min(PANEL_MIN_WIDTH, viewportWidth - PANEL_MARGIN * 2);
const minHeight = PANEL_MIN_HEIGHT;
// Calculate max bounds based on direction
const boundLeft = hostRect.left + PANEL_MARGIN;
const boundRight = hostRect.right - PANEL_MARGIN;
const boundBottom = hostRect.bottom - PANEL_MARGIN;
let nextWidth = startRect.width;
let nextHeight = startRect.height;
let nextX: number | undefined;
let nextY: number | undefined;
// Handle different resize directions
// For width-only resize (right), we don't touch height at all
const involvesHeight = direction !== "right";
switch (direction) {
case "right":
nextWidth = startRect.width + deltaX;
break;
case "bottom":
nextHeight = startRect.height + deltaY;
break;
case "bottom-right":
nextWidth = startRect.width + deltaX;
nextHeight = startRect.height + deltaY;
break;
case "bottom-left":
// Width grows to the left, so we need to adjust position
nextWidth = startRect.width - deltaX;
nextHeight = startRect.height + deltaY;
// Calculate new left position
nextX = startRect.left + deltaX;
nextY = startRect.top;
break;
}
// Apply constraints
const maxWidthRight = boundRight - startRect.left;
const maxWidthLeft = startRect.right - boundLeft;
const maxHeight = boundBottom - startRect.top;
if (direction === "bottom-left") {
// For bottom-left, constrain width based on how far left we can go
nextWidth = Math.max(minWidth, Math.min(nextWidth, maxWidthLeft));
// Recalculate X based on constrained width
nextX = startRect.right - nextWidth;
// Ensure X doesn't go past left bound
if (nextX < boundLeft) {
nextX = boundLeft;
nextWidth = startRect.right - boundLeft;
}
} else {
nextWidth = Math.max(minWidth, Math.min(nextWidth, maxWidthRight));
}
// Only apply height constraints when the direction involves vertical resizing
if (involvesHeight) {
nextHeight = Math.max(minHeight, Math.min(nextHeight, maxHeight));
}
// Reason: Only set explicit height when the resize direction involves vertical movement.
// Width-only resize preserves any previous user-set height (or keeps it undefined
// so the panel auto-sizes based on content).
const prevHeight = this.resizeSize?.height;
const nextSize: { width: number; height?: number } = { width: nextWidth };
if (involvesHeight) {
nextSize.height = nextHeight;
} else if (this.hasUserResizedHeight && typeof prevHeight === "number") {
nextSize.height = prevHeight;
}
this.resizeSize = nextSize;
// Only mark as "user resized height" when the direction involves vertical resizing
// and re-render panel immediately so hasCustomHeight takes effect during drag
const prevHasUserResizedHeight = this.hasUserResizedHeight;
if (involvesHeight) {
this.hasUserResizedHeight = true;
}
// If hasUserResizedHeight changed, re-render panel to update max-height immediately
if (this.hasUserResizedHeight !== prevHasUserResizedHeight) {
this.renderPanel();
}
// If bottom-left, also update drag position to switch to drag mode
if (direction === "bottom-left" && nextX !== undefined) {
this.dragPosition = { x: nextX, y: nextY ?? startRect.top };
this.updateDragPosition();
} else if (this.dragPosition) {
// Already in drag mode, just update position
this.updateDragPosition();
} else {
// Not in drag mode, update anchor-based position
this.schedulePositionUpdate();
}
}
private updateDragPosition(): void {
if (!this.overlayContainer || !this.dragPosition) return;
const doc = this.ownerDocument ?? document;
const hostRect =
this.overlayHost?.getBoundingClientRect() ?? doc.body.getBoundingClientRect();
const viewportWidth = hostRect.width;
// Calculate panel dimensions using constants
const defaultWidth = Math.min(
PANEL_DEFAULT_WIDTH_MAX,
viewportWidth * PANEL_DEFAULT_WIDTH_RATIO
);
const maxWidth = Math.min(PANEL_MAX_WIDTH_MAX, viewportWidth * PANEL_MAX_WIDTH_RATIO);
// Minimum width adapts to viewport to prevent overflow in narrow panes
const minWidth = Math.min(PANEL_MIN_WIDTH, viewportWidth - PANEL_MARGIN * 2);
// Use resized width if available, otherwise use default
const panelWidth =
this.resizeSize?.width ?? Math.max(minWidth, Math.min(defaultWidth, maxWidth));
const panelHeight = this.resizeSize?.height;
updateDynamicStyleClass(this.overlayContainer, "copilot-quick-ask-overlay-pos", {
width: panelWidth,
...(panelHeight ? { height: panelHeight } : {}),
left: Math.round(this.dragPosition.x - hostRect.left),
top: Math.round(this.dragPosition.y - hostRect.top),
});
}
}

View file

@ -0,0 +1,442 @@
/**
* QuickAskPanel - Main UI component for Quick Ask feature.
* Provides multi-turn chat interface with Copy/Insert/Replace actions.
*
* Note: This component fills its container (w-full h-full).
* All positioning and sizing is managed by QuickAskOverlay.
*/
import React, { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { Notice } from "obsidian";
import { Send, Square, X, MessageSquareX } from "lucide-react";
import { useModelKey } from "@/aiParams";
import { useDraggable } from "@/hooks/use-draggable";
import type { ResizeDirection } from "@/hooks/use-resizable";
import { useSettingsValue, updateSetting } from "@/settings/model";
import { cleanMessageForCopy } from "@/utils";
import { ModelSelector } from "@/components/ui/ModelSelector";
import { Checkbox } from "@/components/ui/checkbox";
import { HelpTooltip } from "@/components/ui/help-tooltip";
import { useQuickAskSession } from "./useQuickAskSession";
import { QuickAskMessageComponent } from "./QuickAskMessage";
import { QuickAskInput } from "./QuickAskInput";
import { SelectedContent } from "@/components/command-ui";
// TODO: Uncomment when Edit/Edit-Direct modes are implemented
// import { ModeSelector } from "./ModeSelector";
// import { modeRegistry } from "./modeRegistry";
import type { QuickAskPanelProps } from "./types";
import type { ReplaceInvalidReason } from "@/editor/replaceGuard";
import { Button } from "@/components/ui/button";
/**
* QuickAskPanel - Floating panel for Quick Ask interactions.
* Fills its container; sizing is controlled by QuickAskOverlay.
*/
export function QuickAskPanel({
plugin,
view,
selectedText,
replaceGuard,
onClose,
onDragOffset,
onResizeStart,
hasCustomHeight,
}: QuickAskPanelProps) {
// UI state
const [inputText, setInputText] = useState("");
// TODO: Uncomment when Edit/Edit-Direct modes are implemented
// const [mode, setMode] = useState<QuickAskMode>("ask");
const chatAreaRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const isChatPinnedToBottomRef = useRef(true);
// Get current active file for @ mention context
const currentActiveFile = plugin.app.workspace.getActiveFile();
// Reason: Snapshot file path at mount time for stable Markdown link resolution.
// Using dynamic getActiveFile() would cause links to resolve against
// whichever note the user switches to after opening Quick Ask.
const filePathSnapshotRef = useRef<string | null>(currentActiveFile?.path ?? null);
const filePathSnapshot = filePathSnapshotRef.current;
// Settings
const settings = useSettingsValue();
const [globalModelKey] = useModelKey();
const selectedModelKey = settings.quickCommandModelKey ?? globalModelKey;
// Use local state for includeNoteContext to ensure immediate UI updates
const [includeNoteContext, setIncludeNoteContext] = useState(
() => settings.quickCommandIncludeNoteContext
);
// Session hook
const { messages, isStreaming, sendMessage, stop, clear } = useQuickAskSession({
selectedText,
selectedModelKey,
includeNoteContext,
settings,
});
// Derived state
const hasMessages = messages.length > 0;
// Reason: Use replaceGuard.getRange() as single source of truth for selection state.
// Previously used selectionFrom/selectionTo props that were stale and never updated.
const selectionRange = replaceGuard.getRange();
const hasSelection = !!selectionRange && selectionRange.from !== selectionRange.to;
// TODO: Uncomment when Edit/Edit-Direct modes are implemented
// const availableModes = modeRegistry.getAvailable(hasSelection);
// Keep mode valid when selection state changes
// TODO: Uncomment when Edit/Edit-Direct modes are implemented
// useEffect(() => {
// const modes = modeRegistry.getAvailable(hasSelection);
// if (!modes.some((m) => m.id === mode)) {
// setMode(modes[0]?.id ?? "ask");
// }
// }, [hasSelection, mode]);
const lastMessageId = messages[messages.length - 1]?.id;
const lastAssistantIdx = useMemo(() => {
return messages.reduce((lastIdx, m, i) => (m.role === "assistant" ? i : lastIdx), -1);
}, [messages]);
// Drag handling using shared hook
const getCurrentPanelPosition = useCallback(() => {
const rect = containerRef.current?.getBoundingClientRect();
return rect ? { x: rect.left, y: rect.top } : { x: 0, y: 0 };
}, []);
const handleDragPositionChange = useCallback(
(pos: { x: number; y: number }) => {
onDragOffset?.(pos);
},
[onDragOffset]
);
const { handleMouseDown: handleDragMouseDown } = useDraggable({
dragRef: containerRef,
bounds: null,
writeToDom: false,
getPosition: getCurrentPanelPosition,
onPositionChange: handleDragPositionChange,
});
// Resize handle - just forward to Overlay
const handleResizeMouseDown = useCallback(
(direction: ResizeDirection) =>
(e: React.MouseEvent): void => {
e.preventDefault();
e.stopPropagation();
onResizeStart?.(direction, { x: e.clientX, y: e.clientY });
},
[onResizeStart]
);
// Submit handler
const handleSubmit = useCallback(async () => {
if (!inputText.trim() || isStreaming) return;
const text = inputText;
setInputText(""); // Clear input immediately before sending
await sendMessage(text);
}, [inputText, isStreaming, sendMessage]);
// Keyboard handler - only handle Escape (Enter is handled by QuickAskInput)
// Allow Lexical typeahead (e.g. @ menu) to consume Escape first
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
// P0 Fix: Always stop propagation to prevent CM6 from handling
e.stopPropagation();
if (e.key === "Escape" && !e.defaultPrevented) {
e.preventDefault();
onClose();
}
},
[onClose]
);
// P0 Fix: Generic handler to stop propagation for other keyboard events
const handleStopPropagation = useCallback((e: React.SyntheticEvent) => {
e.stopPropagation();
}, []);
// Track whether the user is at (or near) the bottom; only auto-scroll when pinned.
useEffect(() => {
const el = chatAreaRef.current;
if (!el) return;
const thresholdPx = 24;
/** Returns true if the user is close enough to the bottom to auto-follow new messages. */
const isAtBottom = (): boolean => {
return el.scrollHeight - el.scrollTop - el.clientHeight <= thresholdPx;
};
const handleScroll = () => {
isChatPinnedToBottomRef.current = isAtBottom();
};
// Initialize.
isChatPinnedToBottomRef.current = isAtBottom();
el.addEventListener("scroll", handleScroll);
return () => {
el.removeEventListener("scroll", handleScroll);
};
}, [hasMessages]);
// Auto-scroll to bottom only when pinned
useEffect(() => {
const el = chatAreaRef.current;
if (!el) return;
if (!isChatPinnedToBottomRef.current) return;
el.scrollTop = el.scrollHeight;
}, [messages]);
// Action handlers
const handleCopy = useCallback(
async (messageId: string) => {
const message = messages.find((m) => m.id === messageId);
if (message && message.role === "assistant") {
const cleaned = cleanMessageForCopy(message.content);
try {
await navigator.clipboard.writeText(cleaned);
new Notice("Copied to clipboard");
} catch {
new Notice("Failed to copy to clipboard");
}
}
},
[messages]
);
const handleInsert = useCallback(
(messageId: string) => {
const message = messages.find((m) => m.id === messageId);
if (!message || message.role !== "assistant") return;
try {
const cleaned = cleanMessageForCopy(message.content);
const insertPos = view.state.selection.main.to;
// Reason: CM6 normalizes \r\n → \n internally, so string.length would overcount.
// Using state.toText() ensures the length matches CM6's internal representation.
const insertText = view.state.toText(cleaned);
view.dispatch({
changes: { from: insertPos, to: insertPos, insert: insertText },
// Select the inserted content to highlight it
selection: { anchor: insertPos, head: insertPos + insertText.length },
});
// Ensure editor gets focus so selection is visible
view.focus();
new Notice("Inserted");
onClose();
} catch {
// View might be destroyed or in invalid state
new Notice("Failed to insert. Editor may have changed.");
}
},
[messages, view, onClose]
);
const handleReplace = useCallback(
(messageId: string) => {
const message = messages.find((m) => m.id === messageId);
if (!message || message.role !== "assistant") return;
const cleaned = cleanMessageForCopy(message.content);
const result = replaceGuard.replace(cleaned);
if (!result.ok) {
new Notice(result.message ?? "Cannot replace.");
return;
}
new Notice("Replaced");
onClose();
},
[messages, replaceGuard, onClose]
);
const handleModelChange = useCallback((modelKey: string) => {
updateSetting("quickCommandModelKey", modelKey);
}, []);
const handleIncludeNoteContextChange = useCallback((checked: boolean) => {
setIncludeNoteContext(checked);
updateSetting("quickCommandIncludeNoteContext", checked);
}, []);
// Only compute selection validity when not streaming (performance optimization)
// During streaming, the button is not clickable anyway
const replaceValidation = hasSelection && !isStreaming ? replaceGuard.validate() : null;
const selectionValid = !!replaceValidation?.ok;
const replaceInvalidReason: ReplaceInvalidReason | null = replaceValidation?.reason ?? null;
// Track if button is disabled due to streaming (for accurate tooltip)
const isDisabledDueToStreaming = hasSelection && isStreaming;
return (
<div
ref={containerRef}
className={`tw-group tw-relative tw-flex tw-size-full tw-flex-col tw-rounded-lg tw-rounded-b-none tw-border tw-border-solid tw-border-border tw-bg-primary tw-shadow-lg${
hasMessages && !hasCustomHeight ? " tw-max-h-[min(500px,70vh)]" : ""
}`}
onKeyDown={handleKeyDown}
onKeyUp={handleStopPropagation}
onBeforeInput={handleStopPropagation}
onInput={handleStopPropagation}
onMouseDown={handleStopPropagation}
onMouseUp={handleStopPropagation}
onClick={handleStopPropagation}
>
{/* Header: drag handle + close button */}
<div className="tw-relative tw-flex-none">
<div
className="tw-flex tw-h-4 tw-cursor-grab tw-items-center tw-justify-center hover:tw-bg-[color-mix(in_srgb,var(--background-modifier-hover)_20%,transparent)] active:tw-cursor-grabbing"
onMouseDown={handleDragMouseDown}
>
<div className="tw-h-[5px] tw-w-16 tw-rounded-sm tw-bg-[color-mix(in_srgb,var(--text-muted)_40%,transparent)] hover:tw-bg-[color-mix(in_srgb,var(--text-muted)_65%,transparent)]" />
</div>
<Button
className="tw-absolute tw-right-2 tw-top-1 tw-rounded tw-p-1 tw-text-normal"
variant="ghost2"
onClick={onClose}
title="Close"
>
<X className="tw-size-4" />
</Button>
</div>
{/* Selected text preview - shown below header when text is selected */}
<SelectedContent content={selectedText.trim()} className="tw-mt-3 tw-px-3" />
{/* Chat area - shown above input when there are messages (like YOLO) */}
{hasMessages && (
<div
ref={chatAreaRef}
data-quick-ask-scroll="true"
className="tw-flex tw-min-h-0 tw-flex-1 tw-flex-col tw-gap-2 tw-overflow-y-auto tw-px-3 tw-py-2"
>
{messages.map((msg, idx) => (
<QuickAskMessageComponent
key={msg.id}
message={msg}
isStreaming={isStreaming && msg.id === lastMessageId && msg.role === "assistant"}
isLastAssistantMessage={msg.role === "assistant" && idx === lastAssistantIdx}
onCopy={handleCopy}
onInsert={handleInsert}
onReplace={handleReplace}
hasSelection={hasSelection}
isReplaceValid={selectionValid}
replaceInvalidReason={replaceInvalidReason}
isDisabledDueToStreaming={isDisabledDueToStreaming}
filePathSnapshot={filePathSnapshot}
plugin={plugin}
/>
))}
</div>
)}
{/* Spacer to push toolbar to bottom when panel is resized but no messages */}
{!hasMessages && hasCustomHeight && <div className="tw-flex-1" />}
{/* Input area - below chat area when there are messages */}
<div className="tw-px-3 tw-pb-1 tw-pt-2">
<QuickAskInput
value={inputText}
onChange={setInputText}
onSubmit={handleSubmit}
sendShortcut={settings.defaultSendShortcut}
placeholder={isStreaming ? "Generating..." : "Ask a question... "}
currentActiveFile={currentActiveFile}
/>
</div>
{/* Toolbar - always at bottom */}
<div className="tw-mt-auto tw-flex tw-items-center tw-justify-between tw-gap-2 tw-border-t tw-border-solid tw-border-border tw-px-3 tw-py-1.5">
<div className="tw-flex tw-items-center tw-gap-1">
{/* TODO: Uncomment when Edit/Edit-Direct modes are implemented
<ModeSelector
modes={availableModes}
value={mode}
onChange={setMode}
disabled={isStreaming}
/>
*/}
<ModelSelector
size="sm"
variant="ghost"
value={selectedModelKey}
onChange={handleModelChange}
disabled={isStreaming}
/>
<div className="tw-flex tw-items-center tw-gap-1.5">
<Checkbox
id="quickAskIncludeContext"
checked={includeNoteContext}
onCheckedChange={(checked) => handleIncludeNoteContextChange(!!checked)}
className="tw-size-3.5"
disabled={isStreaming}
/>
<label
htmlFor="quickAskIncludeContext"
className="tw-cursor-pointer tw-text-xs tw-text-muted"
>
Note
</label>
<HelpTooltip content="Include the active note's content as context" side="top" />
</div>
</div>
<div className="tw-flex tw-items-center tw-gap-1">
{hasMessages && (
<Button
variant="ghost2"
size="icon"
className="hover:tw-bg-modifier-hover"
onClick={clear}
title="Clear conversation"
>
<MessageSquareX className="tw-size-4" />
</Button>
)}
{isStreaming ? (
<Button variant="destructive" size="icon" onClick={stop} title="Stop generating">
<Square className="tw-size-4" />
</Button>
) : (
<Button
variant="default"
size="icon"
onClick={handleSubmit}
disabled={!inputText.trim()}
title="Send message"
>
<Send className="tw-size-4" />
</Button>
)}
</div>
</div>
{/* Resize handles */}
<div
className="tw-absolute tw-right-0 tw-top-4 tw-h-[calc(100%-16px)] tw-w-1 tw-cursor-ew-resize"
onMouseDown={handleResizeMouseDown("right")}
/>
<div
className="tw-absolute tw-bottom-0 tw-left-0 tw-h-1 tw-w-full tw-cursor-ns-resize"
onMouseDown={handleResizeMouseDown("bottom")}
/>
<div
className="quick-ask-resize-indicator-left tw-absolute tw-bottom-0 tw-left-0 tw-size-3 tw-cursor-nesw-resize"
onMouseDown={handleResizeMouseDown("bottom-left")}
/>
<div
className="quick-ask-resize-indicator-right tw-absolute tw-bottom-0 tw-right-0 tw-z-[10] tw-size-3 tw-cursor-nwse-resize"
onMouseDown={handleResizeMouseDown("bottom-right")}
/>
</div>
);
}

View file

@ -0,0 +1,12 @@
/**
* Quick Ask components exports.
*/
export { QuickAskOverlay } from "./QuickAskOverlay";
export { QuickAskPanel } from "./QuickAskPanel";
export { QuickAskMessageComponent } from "./QuickAskMessage";
export { QuickAskInput } from "./QuickAskInput";
export { useQuickAskSession } from "./useQuickAskSession";
// Reason: ModeSelector and modeRegistry are not exported because edit/edit-direct modes
// are not yet implemented. Export them when those modes are ready.
export type { QuickAskMode, QuickAskMessage, QuickAskPanelProps, QuickAskWidgetPayload } from "./types";

View file

@ -0,0 +1,61 @@
/**
* Mode registry for Quick Ask feature.
* Manages available modes and their configurations.
*/
import type { QuickAskMode, QuickAskModeConfig } from "./types";
import { QUICK_COMMAND_SYSTEM_PROMPT } from "@/commands/quickCommandPrompts";
import { askModeConfig, editModeConfig, editDirectModeConfig } from "./modes";
/**
* Registry class for managing Quick Ask modes.
*/
class QuickAskModeRegistry {
private modes = new Map<QuickAskMode, QuickAskModeConfig>();
constructor() {
// Register built-in modes
this.register(askModeConfig);
this.register(editModeConfig);
this.register(editDirectModeConfig);
}
/**
* Registers a mode configuration.
*/
register(config: QuickAskModeConfig): void {
this.modes.set(config.id, config);
}
/**
* Gets a mode configuration by ID.
*/
get(id: QuickAskMode): QuickAskModeConfig | undefined {
return this.modes.get(id);
}
/**
* Gets all registered modes.
*/
getAll(): QuickAskModeConfig[] {
return Array.from(this.modes.values());
}
/**
* Gets modes available based on selection state.
*/
getAvailable(hasSelection: boolean): QuickAskModeConfig[] {
return this.getAll().filter((mode) => !mode.requiresSelection || hasSelection);
}
/**
* Gets the system prompt for a mode.
*/
getSystemPrompt(id: QuickAskMode): string {
const mode = this.get(id);
return mode?.systemPrompt ?? QUICK_COMMAND_SYSTEM_PROMPT;
}
}
// Create singleton instance
export const modeRegistry = new QuickAskModeRegistry();

View file

@ -0,0 +1,16 @@
/**
* Ask mode configuration for Quick Ask.
* Multi-turn conversation mode - the default and primary mode.
*/
import type { QuickAskModeConfig } from "../types";
export const askModeConfig: QuickAskModeConfig = {
id: "ask",
label: "Ask",
icon: "message-circle",
description: "Multi-turn conversation",
requiresSelection: false,
// systemPrompt is undefined - modeRegistry.getSystemPrompt() will use default
implemented: true,
};

View file

@ -0,0 +1,15 @@
/**
* Edit-Direct mode configuration for Quick Ask.
* Direct apply edits without preview - Future implementation.
*/
import type { QuickAskModeConfig } from "../types";
export const editDirectModeConfig: QuickAskModeConfig = {
id: "edit-direct",
label: "Edit Direct",
icon: "zap",
description: "Apply edits directly",
requiresSelection: true,
implemented: false,
};

View file

@ -0,0 +1,15 @@
/**
* Edit mode configuration for Quick Ask.
* Generate edits with preview - Future implementation.
*/
import type { QuickAskModeConfig } from "../types";
export const editModeConfig: QuickAskModeConfig = {
id: "edit",
label: "Edit",
icon: "pencil",
description: "Generate edits with preview",
requiresSelection: true,
implemented: false,
};

View file

@ -0,0 +1,7 @@
/**
* Mode configurations exports.
*/
export { askModeConfig } from "./askMode";
export { editModeConfig } from "./editMode";
export { editDirectModeConfig } from "./editDirectMode";

View file

@ -0,0 +1,82 @@
/**
* Type definitions for Quick Ask feature.
*/
import type { EditorView } from "@codemirror/view";
import type { Editor } from "obsidian";
import type CopilotPlugin from "@/main";
import type { ReplaceGuard } from "@/editor/replaceGuard";
import type { ResizeDirection } from "@/hooks/use-resizable";
/**
* Quick Ask mode types.
* - ask: Multi-turn conversation mode (Phase 1)
* - edit: Generate edits with preview (Future)
* - edit-direct: Direct apply edits (Future)
*/
export type QuickAskMode = "ask" | "edit" | "edit-direct";
/**
* A single message in the Quick Ask conversation.
*/
export interface QuickAskMessage {
id: string;
role: "user" | "assistant";
content: string;
timestamp: number;
}
/**
* Props for the QuickAskPanel component.
*/
export interface QuickAskPanelProps {
plugin: CopilotPlugin;
editor: Editor;
view: EditorView;
/** Selected text captured when panel opened (content snapshot) */
selectedText: string;
/** ReplaceGuard for safe Replace operations */
replaceGuard: ReplaceGuard;
/** Callback to close the panel */
onClose: () => void;
/** Callback when panel is dragged */
onDragOffset?: (offset: { x: number; y: number }) => void;
/** Callback when resize starts - Overlay handles the actual resize logic */
onResizeStart?: (direction: ResizeDirection, start: { x: number; y: number }) => void;
/** Whether Overlay has set a custom height (user has resized) */
hasCustomHeight?: boolean;
}
/**
* Payload for the Quick Ask widget StateEffect.
*/
export interface QuickAskWidgetPayload {
pos: number;
/** Optional fallback anchor position (typically selection anchor) */
fallbackPos?: number | null;
options: {
plugin: CopilotPlugin;
editor: Editor;
view: EditorView;
selectedText: string;
selectionFrom: number;
selectionTo: number;
replaceGuard: ReplaceGuard;
onClose: () => void;
};
}
/**
* Mode configuration for Quick Ask.
*/
export interface QuickAskModeConfig {
id: QuickAskMode;
label: string;
icon: string;
description: string;
requiresSelection: boolean;
/** System prompt for this mode */
systemPrompt?: string;
/** Whether this mode is implemented */
implemented: boolean;
}

View file

@ -0,0 +1,195 @@
/**
* useQuickAskSession - Custom hook for managing Quick Ask chat session.
* Handles conversation state and delegates streaming to shared hook.
*/
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { Notice } from "obsidian";
import { v4 as uuidv4 } from "uuid";
import {
useStreamingChatSession,
type StreamingChatTurnContext,
} from "@/hooks/use-streaming-chat-session";
import {
QUICK_COMMAND_SYSTEM_PROMPT,
appendIncludeNoteContextPlaceholders,
} from "@/commands/quickCommandPrompts";
import { processCommandPrompt } from "@/commands/customCommandUtils";
import { findCustomModel } from "@/utils";
import { logError, logWarn } from "@/logger";
import type { QuickAskMessage } from "./types";
import type { CopilotSettings } from "@/settings/model";
interface UseQuickAskSessionParams {
selectedText: string;
selectedModelKey: string;
includeNoteContext: boolean;
settings: CopilotSettings;
}
interface QuickAskSessionApi {
messages: QuickAskMessage[];
isStreaming: boolean;
sendMessage: (inputText: string) => Promise<void>;
stop: () => void;
clear: () => void;
}
/**
* Hook for managing Quick Ask session state and streaming.
*/
export function useQuickAskSession(params: UseQuickAskSessionParams): QuickAskSessionApi {
const { selectedText, selectedModelKey, includeNoteContext, settings } = params;
// Message history (completed messages only)
const [messages, setMessages] = useState<QuickAskMessage[]>([]);
// Reason: Prevents setState calls after the component unmounts.
// Without this guard, async operations (runTurn) that resolve after panel close
// would trigger React warnings and potential state corruption.
const isMountedRef = useRef(true);
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
// Safely resolve the selected model with fallback to first enabled model
const resolvedModel = useMemo(() => {
try {
const model = findCustomModel(selectedModelKey, settings.activeModels);
if (!model.enabled) {
logWarn("Selected model is disabled; falling back to first enabled model.", {
selectedModelKey,
});
return settings.activeModels.find((m) => m.enabled) ?? null;
}
return model;
} catch {
logWarn("Selected model not found; falling back to first enabled model.");
return settings.activeModels.find((m) => m.enabled) ?? null;
}
}, [selectedModelKey, settings.activeModels]);
// Use shared streaming hook
const {
isStreaming,
streamingText,
runTurn,
stop: stopStreaming,
reset,
} = useStreamingChatSession({
model: resolvedModel,
systemPrompt: QUICK_COMMAND_SYSTEM_PROMPT,
excludeThinking: true,
onNoModel: () => {
logError("No active model is configured. Please configure a model in Copilot settings.");
new Notice("No active model configured. Please configure a model in Copilot settings.");
},
onNonAbortError: (error) => {
logError("Error generating response:", error);
new Notice("Error generating response. Please try again.");
},
});
const sendMessage = useCallback(
async (input: string) => {
if (!input.trim()) return;
// Add user message immediately for responsive UI
// Reason: selectedText is now shown separately via SelectedContent component,
// so displayContent no longer embeds <selected_text> XML tags.
const userMessage: QuickAskMessage = {
id: uuidv4(),
role: "user",
content: input,
timestamp: Date.now(),
};
setMessages((prev) => [...prev, userMessage]);
// Run the streaming turn
const result = await runTurn(async (ctx: StreamingChatTurnContext) => {
// Apply first-turn transforms
let processedInput = input;
if (ctx.isFirstTurn) {
processedInput = appendIncludeNoteContextPlaceholders(input, includeNoteContext);
}
// Check abort before async operation
if (ctx.signal.aborted) return "";
// Process prompt (follow-up messages skip appending selected text)
const prompt = await processCommandPrompt(processedInput, selectedText, !ctx.isFirstTurn);
return prompt;
});
// Reason: If the panel was closed/unmounted during the async runTurn,
// skip all state updates to avoid orphan setState calls.
if (!isMountedRef.current) {
return;
}
if (result) {
// Add assistant message on success
const assistantMessage: QuickAskMessage = {
id: uuidv4(),
role: "assistant",
content: result,
timestamp: Date.now(),
};
setMessages((prev) => [...prev, assistantMessage]);
} else {
// Reason: If runTurn returns null, rollback the optimistically added
// user message to avoid orphan messages. This covers:
// - busy / re-entrancy (another turn in progress)
// - no model configured
// - empty prompt
// - abort before streaming started
// - reset() called (stale turn)
setMessages((prev) => {
const last = prev[prev.length - 1];
if (last?.id === userMessage.id) {
return prev.slice(0, -1);
}
return prev;
});
}
},
[includeNoteContext, runTurn, selectedText]
);
const stop = useCallback(() => {
stopStreaming();
}, [stopStreaming]);
const clear = useCallback(() => {
setMessages([]);
reset();
}, [reset]);
// Compute display messages (include streaming content)
const displayMessages = useMemo(() => {
if (!isStreaming || !streamingText) return messages;
return [
...messages,
{
id: "streaming",
role: "assistant" as const,
content: streamingText,
timestamp: Date.now(),
},
];
}, [messages, isStreaming, streamingText]);
return {
messages: displayMessages,
isStreaming,
sendMessage,
stop,
clear,
};
}

View file

@ -748,6 +748,7 @@ export const COMMAND_IDS = {
OPEN_LOG_FILE: "open-log-file",
CLEAR_LOG_FILE: "clear-log-file",
DOWNLOAD_YOUTUBE_SCRIPT: "download-youtube-script",
TRIGGER_QUICK_ASK: "trigger-quick-ask",
} as const;
export const COMMAND_NAMES: Record<CommandId, string> = {
@ -776,6 +777,7 @@ export const COMMAND_NAMES: Record<CommandId, string> = {
[COMMAND_IDS.OPEN_LOG_FILE]: "Create log file",
[COMMAND_IDS.CLEAR_LOG_FILE]: "Clear log file",
[COMMAND_IDS.DOWNLOAD_YOUTUBE_SCRIPT]: "Download YouTube Script (plus)",
[COMMAND_IDS.TRIGGER_QUICK_ASK]: "Quick Ask",
};
export type CommandId = (typeof COMMAND_IDS)[keyof typeof COMMAND_IDS];

View file

@ -0,0 +1,327 @@
/**
* ChatSelectionHighlightController
*
* Manages persistent selection highlight for the Chat panel.
* Uses an independent CM6 StateField (via `createPersistentHighlight`) to avoid
* conflicts with SelectionHighlight used by QuickAsk and CustomCommandModal.
*/
import { EditorView } from "@codemirror/view";
import { MarkdownView, type WorkspaceLeaf } from "obsidian";
import type CopilotPlugin from "@/main";
import { CHAT_VIEWTYPE } from "@/constants";
import type { SelectedTextContext } from "@/types/message";
import { logError, logWarn } from "@/logger";
import {
createPersistentHighlight,
type PersistentHighlightRange,
} from "@/editor/persistentHighlight";
// ============================================================================
// Independent CM6 Highlight Instance (isolated from SelectionHighlight)
// ============================================================================
/**
* Chat-owned persistent highlight primitives.
* Completely independent from the QuickAsk/CustomCommandModal highlight.
*/
const chatHighlight = createPersistentHighlight("copilot-chat-selection-highlight");
/**
* Hide chat selection highlight on a specific EditorView.
* Used for global cleanup during plugin unload.
* @param view - The EditorView to clear highlight from
*/
export function hideChatSelectionHighlight(view: EditorView): void {
try {
const effects = chatHighlight.buildEffects(view, null);
if (effects.length > 0) {
view.dispatch({ effects });
}
} catch {
// Ignore errors during cleanup (view may be destroyed)
}
}
// ============================================================================
// Controller
// ============================================================================
/**
* Snapshot of the current highlight state.
* Used for idempotency checks and safe cleanup.
*/
interface Snapshot {
view: EditorView;
from: number;
to: number;
}
/**
* Options for ChatSelectionHighlightController.
*/
export interface ChatSelectionHighlightControllerOptions {
/** Close QuickAsk when entering Chat. Default: false */
closeQuickAskOnChatFocus?: boolean;
}
/**
* Controller that manages Chat-owned persistent selection highlight lifecycle.
*
* This controller uses an independent CM6 StateField to avoid conflicts with
* SelectionHighlight used by QuickAsk and CustomCommandModal.
*
* Key features:
* - Persists editor selection when focus moves to Chat panel
* - Automatically clears highlight when leaving Chat
* - Supports both mouse click and keyboard navigation to Chat
* - Idempotent: avoids redundant dispatches
*/
export class ChatSelectionHighlightController {
private readonly plugin: CopilotPlugin;
private readonly closeQuickAskOnChatFocus: boolean;
/** Last active Markdown leaf, used for fallback when active view is already Chat */
private lastActiveMarkdownLeaf: WorkspaceLeaf | null = null;
/** Whether the last active leaf was a MarkdownView */
private lastActiveLeafWasMarkdown = false;
/** Snapshot of current highlight for idempotency and cleanup */
private snapshot: Snapshot | null = null;
/**
* Creates a new ChatSelectionHighlightController.
* @param plugin - The CopilotPlugin instance
* @param options - Optional configuration
*/
constructor(plugin: CopilotPlugin, options?: ChatSelectionHighlightControllerOptions) {
this.plugin = plugin;
this.closeQuickAskOnChatFocus = options?.closeQuickAskOnChatFocus ?? false;
}
// --------------------------------------------------------------------------
// Lifecycle
// --------------------------------------------------------------------------
/**
* Initializes the controller state from the current active leaf.
* Call once during plugin onload.
*/
initialize(): void {
const leaf = this.plugin.app.workspace.activeLeaf ?? null;
this.lastActiveLeafWasMarkdown = !!(leaf?.view instanceof MarkdownView);
if (this.lastActiveLeafWasMarkdown && leaf) {
this.lastActiveMarkdownLeaf = leaf;
}
}
/**
* Cleans up the controller state.
* Call during plugin onunload.
*/
cleanup(): void {
this.clear();
this.lastActiveMarkdownLeaf = null;
this.lastActiveLeafWasMarkdown = false;
}
// --------------------------------------------------------------------------
// Event Handlers
// --------------------------------------------------------------------------
/**
* Handles active-leaf-change event.
* Manages highlight lifecycle based on leaf transitions:
* - Entering Chat from Markdown: persists selection highlight
* - Leaving Chat: clears highlight
* @param leaf - The newly active leaf, or null
*/
handleActiveLeafChange(leaf: WorkspaceLeaf | null): void {
const prevWasMarkdown = this.lastActiveLeafWasMarkdown;
const nextType = leaf?.getViewState().type ?? null;
const nextIsMarkdown = !!(leaf?.view instanceof MarkdownView);
// Update tracking
this.lastActiveLeafWasMarkdown = nextIsMarkdown;
if (nextIsMarkdown && leaf) {
this.lastActiveMarkdownLeaf = leaf;
}
// Leaving Chat: clear highlight
if (this.snapshot && nextType !== CHAT_VIEWTYPE) {
this.clear();
}
// Entering Chat from Markdown: persist with fallback allowed
if (nextType === CHAT_VIEWTYPE && prevWasMarkdown) {
if (this.closeQuickAskOnChatFocus) {
this.plugin.quickAskController?.close(false);
}
// Use fallback because active leaf is already Chat at this point
this.persist({ useFallback: true });
}
}
/**
* Persists highlight when user clicks into Chat panel.
* Uses capture phase to run before focus changes.
* Does not use fallback - only works if MarkdownView is currently active.
*/
persistFromPointerDown(): void {
// Early exit if already in Chat - no MarkdownView will be active
const activeLeafType = this.plugin.app.workspace.activeLeaf?.getViewState().type;
if (activeLeafType === CHAT_VIEWTYPE) {
return;
}
this.persist({ useFallback: false });
}
/**
* Clears highlight if no note contexts remain after removal.
* @param nextContexts - The remaining contexts after removal
*/
clearIfNoNoteContexts(nextContexts: ReadonlyArray<SelectedTextContext>): void {
if (!nextContexts.some((ctx) => ctx.sourceType === "note")) {
this.clear();
}
}
/**
* Clears highlight when starting a new chat session.
*/
clearForNewChat(): void {
this.clear();
}
// --------------------------------------------------------------------------
// Internal: CM6 Operations
// --------------------------------------------------------------------------
/**
* Persists the current editor selection as a CM6 decoration highlight.
* @param options - Configuration for this persist operation
* @param options.useFallback - Whether to use lastActiveMarkdownLeaf if active view is not Markdown
*/
private persist(options: { useFallback: boolean }): void {
const cm = this.getEditorView(options.useFallback);
if (!cm) return;
const sel = cm.state.selection.main;
if (sel.from === sel.to) return;
const from = sel.from;
const to = sel.to;
// Idempotency: skip if same highlight already exists
const current = this.getHighlightRange(cm);
if (
this.snapshot?.view === cm &&
this.snapshot.from === from &&
this.snapshot.to === to &&
current?.from === from &&
current?.to === to
) {
return;
}
// Clear previous highlight on different view and reset snapshot
// This ensures snapshot is always in sync: either null or pointing to current highlight
if (this.snapshot && this.snapshot.view !== cm) {
this.hideHighlight(this.snapshot.view);
this.snapshot = null; // Clear immediately to avoid stale reference
}
// Only update snapshot if dispatch succeeds (avoids state desync on view destroyed)
const success = this.showHighlight(cm, from, to);
if (success) {
this.snapshot = { view: cm, from, to };
}
}
/**
* Clears the current highlight unconditionally.
* Always clears to avoid "stuck" highlights when document changes
* cause mapPos to shift the range.
*/
private clear(): void {
if (!this.snapshot) return;
this.hideHighlight(this.snapshot.view);
this.snapshot = null;
}
/**
* Gets the EditorView to use for highlight operations.
* @param allowFallback - Whether to fall back to lastActiveMarkdownLeaf
* @returns The EditorView, or null if not available
*/
private getEditorView(allowFallback: boolean): EditorView | null {
const active = this.plugin.app.workspace.getActiveViewOfType(MarkdownView);
if (active?.editor?.cm) {
return active.editor.cm;
}
if (allowFallback && this.lastActiveMarkdownLeaf?.view instanceof MarkdownView) {
return this.lastActiveMarkdownLeaf.view.editor?.cm ?? null;
}
return null;
}
// --------------------------------------------------------------------------
// Internal: CM6 Highlight Primitives (delegating to factory instance)
// --------------------------------------------------------------------------
/**
* Shows a highlight on the given EditorView.
* Automatically installs the extension if not already present.
* @param view - The EditorView to highlight
* @param from - Start offset
* @param to - End offset
* @returns true if effects were dispatched successfully, false otherwise
*/
private showHighlight(view: EditorView, from: number, to: number): boolean {
try {
const effects = chatHighlight.buildEffects(view, { from, to });
if (effects.length === 0) {
// No effects to dispatch (e.g., invalid range) — not a success
return false;
}
view.dispatch({ effects });
return true;
} catch (error) {
logError("ChatSelectionHighlight show failed:", error);
return false;
}
}
/**
* Hides the highlight on the given EditorView.
* Uses logWarn instead of logError since failures during cleanup
* are often due to view being destroyed (normal race condition).
* @param view - The EditorView to clear highlight from
*/
private hideHighlight(view: EditorView): void {
try {
const effects = chatHighlight.buildEffects(view, null);
if (effects.length > 0) {
view.dispatch({ effects });
}
} catch (error) {
// Use warn instead of error - failures here are often due to
// view being destroyed, which is a normal race condition
logWarn("ChatSelectionHighlight hide failed (view may be destroyed):", error);
}
}
/**
* Gets the current highlight range from the EditorView.
* @param view - The EditorView to query
* @returns The current highlight range, or null if none
*/
private getHighlightRange(view: EditorView): PersistentHighlightRange | null {
return chatHighlight.getRange(view);
}
}

32
src/editor/index.ts Normal file
View file

@ -0,0 +1,32 @@
/**
* Editor utilities - CM6 extensions and helpers.
*/
// Persistent highlight factory
export { createPersistentHighlight } from "./persistentHighlight";
export type { PersistentHighlightRange, PersistentHighlightInstance } from "./persistentHighlight";
// Selection highlight (QuickAsk / CustomCommandModal)
export {
SelectionHighlight,
showSelectionHighlight,
updateSelectionHighlight,
hideSelectionHighlight,
selectionHighlightExtension,
} from "./selectionHighlight";
export type { SelectionHighlightRange } from "./selectionHighlight";
// Quick Ask
export { QuickAskController } from "./quickAskController";
export {
quickAskWidgetEffect,
quickAskOverlayPlugin,
createQuickAskExtension,
} from "./quickAskExtension";
// Chat selection highlight
export {
ChatSelectionHighlightController,
hideChatSelectionHighlight,
} from "./chatSelectionHighlightController";
export type { ChatSelectionHighlightControllerOptions } from "./chatSelectionHighlightController";

View file

@ -0,0 +1,220 @@
/**
* persistentHighlight.ts
*
* Generic CM6 primitive factory for a single persistent highlight range.
* Each call to `createPersistentHighlight` returns fully isolated
* StateField / StateEffect instances, so multiple highlight systems
* (e.g. QuickAsk and Chat) can coexist without state conflicts.
*/
import { StateEffect, StateEffectType, StateField, type Extension } from "@codemirror/state";
import { Decoration, EditorView } from "@codemirror/view";
// ============================================================================
// Types
// ============================================================================
/**
* A persistent highlight range in CM6 document offsets.
*/
export interface PersistentHighlightRange {
from: number;
to: number;
}
/**
* The public API surface returned by `createPersistentHighlight`.
*/
export interface PersistentHighlightInstance {
/** The CM6 StateField storing the current highlight range. */
readonly field: StateField<PersistentHighlightRange | null>;
/** The CM6 StateEffectType used to set/clear the highlight. */
readonly effect: StateEffectType<PersistentHighlightRange | null>;
/** The CM6 extension bundle (field + baseTheme) to install into a view. */
readonly extension: Extension;
/**
* Show or update the persistent highlight.
* Automatically installs the extension on first use.
* @throws If the EditorView is destroyed (callers should catch as needed).
*/
show(view: EditorView, from: number, to: number): void;
/**
* Hide (clear) the persistent highlight.
* No-op if the extension is not installed.
* @throws If the EditorView is destroyed (callers should catch as needed).
*/
hide(view: EditorView): void;
/**
* Build effects without dispatching.
* Useful for batching multiple effects into a single `view.dispatch()`.
*/
buildEffects(
view: EditorView,
range: { from: number; to: number } | null
): StateEffect<unknown>[];
/**
* Read the current highlight range.
* Returns null when the extension is not installed or the highlight is hidden.
*/
getRange(view: EditorView): PersistentHighlightRange | null;
}
// ============================================================================
// Factory
// ============================================================================
/**
* Create an isolated persistent-highlight instance bound to a CSS class.
*
* Each call produces independent StateField/StateEffect objects so that
* multiple highlight systems can coexist in the same EditorView without
* interfering with each other.
*
* @param className - CSS class applied to the mark decoration and baseTheme rule
* @returns Isolated highlight primitives and helpers
*/
export function createPersistentHighlight(className: string): PersistentHighlightInstance {
// -- Effect -----------------------------------------------------------------
const setEffect = StateEffect.define<PersistentHighlightRange | null>();
// -- Decoration -------------------------------------------------------------
const mark = Decoration.mark({ class: className });
// -- Helpers ----------------------------------------------------------------
/**
* Normalize and clamp a range to the current document.
* Returns null when the range is empty or fully out of bounds.
*/
function normalizeRange(
docLength: number,
from: number,
to: number
): PersistentHighlightRange | null {
const clampedFrom = Math.max(0, Math.min(from, docLength));
const clampedTo = Math.max(0, Math.min(to, docLength));
if (clampedFrom === clampedTo) return null;
return {
from: Math.min(clampedFrom, clampedTo),
to: Math.max(clampedFrom, clampedTo),
};
}
// -- StateField -------------------------------------------------------------
const field = StateField.define<PersistentHighlightRange | null>({
create: () => null,
update(value, tr) {
let next = value;
// Remap existing range through document changes
if (next && !tr.changes.empty) {
const mappedFrom = tr.changes.mapPos(next.from, 1);
const mappedTo = tr.changes.mapPos(next.to, -1);
next = normalizeRange(tr.state.doc.length, mappedFrom, mappedTo);
}
// Apply set/clear effects
for (const effect of tr.effects) {
if (!effect.is(setEffect)) continue;
next = effect.value
? normalizeRange(tr.state.doc.length, effect.value.from, effect.value.to)
: null;
}
return next;
},
provide: (f) =>
EditorView.decorations.from(f, (range) =>
range ? Decoration.set([mark.range(range.from, range.to)]) : Decoration.none
),
});
// -- Theme ------------------------------------------------------------------
const theme = EditorView.baseTheme({
[`.${className}`]: {
backgroundColor: "var(--text-selection)",
borderRadius: "2px",
},
});
// -- Extension bundle -------------------------------------------------------
const extension: Extension = [field, theme];
// -- Utilities --------------------------------------------------------------
/** Whether the field is installed in a given view. */
function isInstalled(view: EditorView): boolean {
return view.state.field(field, false) !== undefined;
}
/**
* Build effects for showing/hiding highlight without dispatching.
* Returns an empty array when there is nothing to do.
*/
function buildEffects(
view: EditorView,
range: { from: number; to: number } | null
): StateEffect<unknown>[] {
const effects: StateEffect<unknown>[] = [];
if (!range) {
if (isInstalled(view)) {
effects.push(setEffect.of(null));
}
return effects;
}
const normalized = normalizeRange(view.state.doc.length, range.from, range.to);
if (!normalized) {
// Invalid range — treat as "hide"
if (isInstalled(view)) {
effects.push(setEffect.of(null));
}
return effects;
}
if (!isInstalled(view)) {
effects.push(StateEffect.appendConfig.of(extension));
}
effects.push(setEffect.of(normalized));
return effects;
}
/**
* Dispatch a show. Throws on destroyed views callers decide how to log.
*/
function show(view: EditorView, from: number, to: number): void {
const effects = buildEffects(view, { from, to });
if (effects.length > 0) {
view.dispatch({ effects });
}
}
/**
* Dispatch a hide. Throws on destroyed views callers decide how to log.
*/
function hide(view: EditorView): void {
const effects = buildEffects(view, null);
if (effects.length > 0) {
view.dispatch({ effects });
}
}
/** Read the current highlight range (null = not installed / hidden). */
function getRange(view: EditorView): PersistentHighlightRange | null {
return view.state.field(field, false) ?? null;
}
return { field, effect: setEffect, extension, show, hide, buildEffects, getRange };
}

View file

@ -0,0 +1,192 @@
/**
* Controller for the Quick Ask feature.
* Manages the lifecycle of Quick Ask panels and integrates with CM6.
*/
import type { Extension } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { MarkdownView } from "obsidian";
import { quickAskWidgetEffect, quickAskOverlayPlugin } from "./quickAskExtension";
import { QuickAskOverlay } from "@/components/quick-ask/QuickAskOverlay";
import { createMapPosReplaceGuard } from "./replaceGuard";
import { SelectionHighlight } from "./selectionHighlight";
import type CopilotPlugin from "@/main";
import { logWarn } from "@/logger";
interface QuickAskWidgetState {
view: EditorView;
pos: number;
close: (restoreFocus?: boolean) => void;
}
/**
* Controller that manages Quick Ask panel instances.
*/
export class QuickAskController {
private plugin: CopilotPlugin;
private quickAskWidgetState: QuickAskWidgetState | null = null;
constructor(plugin: CopilotPlugin) {
this.plugin = plugin;
}
/**
* Closes the current Quick Ask panel.
* @param restoreFocus - Whether to restore focus to the editor
*/
close(restoreFocus = true): void {
const state = this.quickAskWidgetState;
if (!state) {
return;
}
if (!restoreFocus) {
this.quickAskWidgetState = null;
try {
// Combine widget close and highlight hide in one dispatch
const effects = [
quickAskWidgetEffect.of(null),
...SelectionHighlight.buildEffects(state.view, null),
];
state.view.dispatch({ effects });
} catch (error) {
// View may have been destroyed - clear state and continue
logWarn("Failed to dispatch close effect:", error);
}
return;
}
// Clear state to prevent duplicate close
this.quickAskWidgetState = null;
// Try to trigger close animation
const hasAnimation = QuickAskOverlay.closeCurrentWithAnimation();
if (!hasAnimation) {
// If no animation instance, dispatch close effect directly
try {
// Combine widget close and highlight hide in one dispatch
const effects = [
quickAskWidgetEffect.of(null),
...SelectionHighlight.buildEffects(state.view, null),
];
state.view.dispatch({ effects });
state.view.focus();
} catch (error) {
// View may have been destroyed - already cleared state above
logWarn("Failed to dispatch close effect or focus:", error);
}
}
}
/**
* Shows the Quick Ask panel for the given editor.
* @param markdownView - The MarkdownView instance (for leaf binding)
* @param view - The CodeMirror EditorView
*/
show(markdownView: MarkdownView, view: EditorView): void {
const selection = view.state.selection.main;
const editor = markdownView.editor;
const leaf = markdownView.leaf;
const filePath = markdownView.file?.path ?? null;
// Get selected text from doc.sliceString (not editor.getSelection() to avoid CRLF issues)
const selectedTextSnapshot = view.state.doc.sliceString(selection.from, selection.to);
const selectionFrom = selection.from;
const selectionTo = selection.to;
// Close any existing Quick Ask panel
this.close(false);
// Create ReplaceGuard
const replaceGuard = createMapPosReplaceGuard({
editorView: view,
leafSnapshot: leaf,
filePathSnapshot: filePath,
selectedTextSnapshot,
initialRange: { from: selectionFrom, to: selectionTo },
getLeafState: () => {
// Get current leaf state
const currentView = leaf.view;
if (!(currentView instanceof MarkdownView)) {
return { leaf: null, editorView: null, filePath: null };
}
return {
leaf,
editorView: currentView.editor?.cm ?? null,
filePath: currentView.file?.path ?? null,
};
},
});
const close = (restoreFocus = true) => {
const isCurrentView = !this.quickAskWidgetState || this.quickAskWidgetState.view === view;
if (isCurrentView) {
this.quickAskWidgetState = null;
}
try {
// Combine widget close and highlight hide in one dispatch
const effects = [
quickAskWidgetEffect.of(null),
...SelectionHighlight.buildEffects(view, null),
];
view.dispatch({ effects });
if (isCurrentView && restoreFocus) {
view.focus();
}
} catch (error) {
// View may have been destroyed
logWarn("Failed to dispatch close effect or focus:", error);
}
};
try {
view.dispatch({
effects: [
// First clear any existing widget and highlight
quickAskWidgetEffect.of(null),
...SelectionHighlight.buildEffects(view, null),
// Then create the new widget with highlight
quickAskWidgetEffect.of({
pos: selection.head,
fallbackPos: selection.anchor,
options: {
plugin: this.plugin,
editor,
view,
selectedText: selectedTextSnapshot,
selectionFrom,
selectionTo,
replaceGuard,
onClose: () => close(true),
},
}),
...SelectionHighlight.buildEffects(view, { from: selectionFrom, to: selectionTo }),
],
});
this.quickAskWidgetState = { view, pos: selection.head, close };
} catch (error) {
// View may have been destroyed
logWarn("Failed to show Quick Ask panel:", error);
this.quickAskWidgetState = null;
}
}
/**
* Checks if Quick Ask is currently open.
*/
isOpen(): boolean {
return this.quickAskWidgetState !== null;
}
/**
* Creates the CM6 extension for Quick Ask.
* This should be registered with the editor.
*/
createExtension(): Extension {
return [quickAskOverlayPlugin];
}
}

View file

@ -0,0 +1,101 @@
/**
* CodeMirror 6 Extension for Quick Ask feature.
* Provides StateEffect and ViewPlugin for managing the Quick Ask overlay.
*
* NOTE: SelectionHighlight is managed by quickAskController.ts, not here.
* This avoids "Calls to EditorView.update are not allowed while an update is in progress" errors
* that occur when dispatching from within ViewPlugin.update().
*/
import { StateEffect } from "@codemirror/state";
import { EditorView, ViewPlugin, ViewUpdate } from "@codemirror/view";
import { QuickAskOverlay } from "@/components/quick-ask/QuickAskOverlay";
import type { QuickAskWidgetPayload } from "@/components/quick-ask/types";
/**
* StateEffect for showing/hiding the Quick Ask widget.
* Pass null to close, or a payload to open.
*/
export const quickAskWidgetEffect = StateEffect.define<QuickAskWidgetPayload | null>();
/**
* ViewPlugin that manages the QuickAskOverlay lifecycle.
* Responds to StateEffects and updates position on document changes.
*/
export const quickAskOverlayPlugin = ViewPlugin.fromClass(
class {
private overlay: QuickAskOverlay | null = null;
private pos: number | null = null;
private fallbackPos: number | null = null;
constructor(private readonly view: EditorView) {}
update(update: ViewUpdate) {
// Handle StateEffects
for (const tr of update.transactions) {
for (const effect of tr.effects) {
if (!effect.is(quickAskWidgetEffect)) continue;
const payload = effect.value;
if (!payload) {
// Close the overlay (highlight is handled by controller)
this.overlay?.destroy();
this.overlay = null;
this.pos = null;
this.fallbackPos = null;
continue;
}
// Close existing overlay before opening new one
this.overlay?.destroy();
this.pos = payload.pos;
this.fallbackPos = typeof payload.fallbackPos === "number" ? payload.fallbackPos : null;
// NOTE: SelectionHighlight is now managed by quickAskController.ts
// to avoid dispatch-during-update errors
this.overlay = new QuickAskOverlay(payload.options);
this.overlay.mount(payload.pos, this.fallbackPos);
}
}
// Update position and selection range on document changes
if (this.overlay && update.docChanged) {
// Update ReplaceGuard's range mapping (single source of truth for Replace)
const guard = this.overlay.getReplaceGuard();
if (guard?.onDocChanged) {
guard.onDocChanged(update.changes);
}
// Update anchor position for panel positioning
if (this.pos !== null) {
this.pos = update.changes.mapPos(this.pos);
}
if (this.fallbackPos !== null) {
this.fallbackPos = update.changes.mapPos(this.fallbackPos);
}
if (this.pos !== null) {
this.overlay.updatePosition(this.pos, this.fallbackPos);
}
// Trigger panel re-render to update Replace button disabled state
this.overlay.schedulePanelRerender();
}
}
destroy() {
// NOTE: SelectionHighlight cleanup is handled by quickAskController.ts
this.overlay?.destroy();
this.overlay = null;
this.pos = null;
this.fallbackPos = null;
}
}
);
/**
* Creates the Quick Ask CM6 extension array.
*/
export function createQuickAskExtension() {
return [quickAskOverlayPlugin];
}

367
src/editor/replaceGuard.ts Normal file
View file

@ -0,0 +1,367 @@
// src/editor/replaceGuard.ts
import type { ChangeDesc } from "@codemirror/state";
import type { EditorView } from "@codemirror/view";
import type { WorkspaceLeaf } from "obsidian";
import { SelectionHighlight } from "./selectionHighlight";
import { logError } from "@/logger";
/**
* Replace
*/
export type ReplaceInvalidReason =
| "no_range" // 没有有效范围
| "range_out_of_bounds" // 范围超出文档边界
| "content_changed" // 选区内容已变化
| "file_changed" // 文件已切换
| "editor_changed" // EditorView 已变化
| "leaf_changed" // Leaf 已变化
| "target_unavailable"; // 目标不可用
/**
* Replace /
*/
export interface ReplaceStatus {
ok: boolean;
reason: ReplaceInvalidReason | null;
range: { from: number; to: number } | null;
/** 用户提示消息 */
message?: string;
}
/**
* ReplaceGuard
* "捕获 → 映射 → 校验 → 替换"
*
*/
export interface ReplaceGuard {
/** 获取当前映射后的范围 */
getRange(): { from: number; to: number } | null;
/** 校验 Replace 是否可执行 */
validate(): ReplaceStatus;
/**
* ()
* - MapPos 策略: 需要实现
* - SelectionHighlight 策略: 不需要实现
*/
onDocChanged?(changes: ChangeDesc): void;
/** 执行替换 (内部会先校验) */
replace(replacement: string): ReplaceStatus;
}
/**
*
*/
export function getErrorMessage(reason: ReplaceInvalidReason | null): string {
switch (reason) {
case "no_range":
return "No selection range available.";
case "range_out_of_bounds":
return "Selection range is out of bounds.";
case "content_changed":
return "Selection content has changed. Please reselect and try again.";
case "file_changed":
return "File has changed. Please reselect in the original file.";
case "editor_changed":
return "Editor has changed. Please reselect and try again.";
case "leaf_changed":
return "Editor pane has changed. Please reselect and try again.";
case "target_unavailable":
return "Editor is no longer available.";
default:
return "Cannot replace. Please reselect and try again.";
}
}
/**
* Dispatch a replacement into CodeMirror and focus the editor.
*
* This centralizes the shared "replace + select inserted content" behavior used by
* different ReplaceGuard implementations.
*
* @throws If the EditorView cannot dispatch (e.g. disposed / unavailable).
*/
function dispatchReplace(
editorView: EditorView,
range: { from: number; to: number },
replacement: string
): void {
// Reason: CM6 normalizes \r\n → \n internally, so string.length would overcount.
// Using state.toText() ensures the length matches CM6's internal representation.
const insertText = editorView.state.toText(replacement);
editorView.dispatch({
changes: {
from: range.from,
to: range.to,
insert: insertText,
},
// Select the replaced content to highlight it
selection: {
anchor: range.from,
head: range.from + insertText.length,
},
});
editorView.focus();
}
// ============================================================================
// MapPosReplaceGuard (Quick Ask)
// ============================================================================
export interface MapPosReplaceGuardParams {
editorView: EditorView;
/** 打开时的 leaf 引用 */
leafSnapshot: WorkspaceLeaf;
/** 打开时的文件路径 */
filePathSnapshot: string | null;
/** 打开时的选中文本 (从 doc.sliceString 获取) */
selectedTextSnapshot: string;
/** 打开时的选区范围 */
initialRange: { from: number; to: number };
/**
* leaf
* Quick Ask
*/
getLeafState: () => {
leaf: WorkspaceLeaf | null;
editorView: EditorView | null;
filePath: string | null;
};
}
/**
* Quick Ask 使 MapPos
*/
export function createMapPosReplaceGuard(params: MapPosReplaceGuardParams): ReplaceGuard {
const {
editorView,
leafSnapshot,
filePathSnapshot,
selectedTextSnapshot,
initialRange,
getLeafState,
} = params;
// 内部维护的映射范围
let range = { ...initialRange };
// validate 缓存:仅在 docChanged 或 leafState 变化时重新计算
type LeafStateSnapshot = ReturnType<MapPosReplaceGuardParams["getLeafState"]>;
let isValidationDirty = true;
let lastLeafStateSnapshot: LeafStateSnapshot | null = null;
let lastValidationResult: ReplaceStatus | null = null;
const onDocChanged = (changes: ChangeDesc): void => {
const mappedFrom = changes.mapPos(range.from, 1);
const mappedTo = changes.mapPos(range.to, -1);
range = {
from: Math.min(mappedFrom, mappedTo),
to: Math.max(mappedFrom, mappedTo),
};
isValidationDirty = true;
};
const getRange = (): { from: number; to: number } | null => {
return { ...range };
};
const validate = (): ReplaceStatus => {
const state = getLeafState();
const leafStateChanged =
!lastLeafStateSnapshot ||
state.leaf !== lastLeafStateSnapshot.leaf ||
state.editorView !== lastLeafStateSnapshot.editorView ||
state.filePath !== lastLeafStateSnapshot.filePath ||
!editorView.dom.isConnected;
if (!isValidationDirty && !leafStateChanged && lastValidationResult) {
return lastValidationResult;
}
isValidationDirty = false;
lastLeafStateSnapshot = state;
const invalid = (
reason: ReplaceInvalidReason,
nextRange: { from: number; to: number } | null
): ReplaceStatus => ({
ok: false,
reason,
range: nextRange,
message: getErrorMessage(reason),
});
// 1. 检查 leaf 是否还是同一个
if (!state.leaf || state.leaf !== leafSnapshot) {
lastValidationResult = invalid("leaf_changed", null);
return lastValidationResult;
}
// 2. 检查 EditorView 是否还是同一个
if (!state.editorView || state.editorView !== editorView) {
lastValidationResult = invalid("editor_changed", null);
return lastValidationResult;
}
// 3. 检查文件路径是否一致
if (state.filePath !== filePathSnapshot) {
lastValidationResult = invalid("file_changed", null);
return lastValidationResult;
}
// 4. 检查 EditorView 是否可用
if (!editorView.dom.isConnected) {
lastValidationResult = invalid("target_unavailable", null);
return lastValidationResult;
}
const doc = editorView.state.doc;
// 5. 检查范围边界
if (range.from < 0 || range.to > doc.length || range.from >= range.to) {
lastValidationResult = invalid("range_out_of_bounds", null);
return lastValidationResult;
}
// 6. 检查内容是否一致
const currentText = doc.sliceString(range.from, range.to);
if (currentText !== selectedTextSnapshot) {
lastValidationResult = invalid("content_changed", { ...range });
return lastValidationResult;
}
lastValidationResult = { ok: true, reason: null, range: { ...range } };
return lastValidationResult;
};
const replace = (replacement: string): ReplaceStatus => {
const status = validate();
if (!status.ok || !status.range) {
return status;
}
try {
dispatchReplace(editorView, status.range, replacement);
return { ok: true, reason: null, range: status.range };
} catch (error) {
logError("MapPosReplaceGuard replace failed:", error);
return {
ok: false,
reason: "target_unavailable",
range: null,
message: getErrorMessage("target_unavailable"),
};
}
};
return { getRange, validate, onDocChanged, replace };
}
// ============================================================================
// HighlightReplaceGuard (Modal)
// ============================================================================
export interface HighlightReplaceGuardParams {
editorView: EditorView;
/** 打开时的文件路径 */
filePathSnapshot: string | null;
/** 打开时的选中文本 (从 doc.sliceString 获取) */
selectedTextSnapshot: string;
/**
* active view
*/
getCurrentContext: () => {
editorView: EditorView | null;
filePath: string | null;
};
}
/**
* Modal 使 SelectionHighlight
*/
export function createHighlightReplaceGuard(params: HighlightReplaceGuardParams): ReplaceGuard {
const { editorView, filePathSnapshot, selectedTextSnapshot, getCurrentContext } = params;
const getRange = (): { from: number; to: number } | null => {
const range = SelectionHighlight.getRange(editorView);
return range ? { from: range.from, to: range.to } : null;
};
const validate = (): ReplaceStatus => {
const context = getCurrentContext();
const invalid = (
reason: ReplaceInvalidReason,
nextRange: { from: number; to: number } | null
): ReplaceStatus => ({
ok: false,
reason,
range: nextRange,
message: getErrorMessage(reason),
});
// 1. 检查是否有 active EditorView
if (!context.editorView) {
return invalid("target_unavailable", null);
}
// 2. 检查 EditorView 实例是否一致
if (context.editorView !== editorView) {
return invalid("editor_changed", null);
}
// 3. 检查文件路径是否一致
if (context.filePath !== filePathSnapshot) {
return invalid("file_changed", null);
}
// 4. 获取映射后的范围
const range = getRange();
if (!range) {
return invalid("no_range", null);
}
const doc = editorView.state.doc;
// 5. 检查范围边界
if (range.from < 0 || range.to > doc.length || range.from >= range.to) {
return invalid("range_out_of_bounds", null);
}
// 6. 检查内容是否一致
const currentText = doc.sliceString(range.from, range.to);
if (currentText !== selectedTextSnapshot) {
return invalid("content_changed", range);
}
return { ok: true, reason: null, range };
};
const replace = (replacement: string): ReplaceStatus => {
const status = validate();
if (!status.ok || !status.range) {
return status;
}
try {
dispatchReplace(editorView, status.range, replacement);
return { ok: true, reason: null, range: status.range };
} catch (error) {
logError("HighlightReplaceGuard replace failed:", error);
return {
ok: false,
reason: "target_unavailable",
range: null,
message: getErrorMessage("target_unavailable"),
};
}
};
// SelectionHighlight 自己 mapPos不需要 onDocChanged
return { getRange, validate, replace };
}

View file

@ -0,0 +1,121 @@
/**
* SelectionHighlight - Persistent selection highlight using CM6 Decoration.
*
* Provides a way to keep selection visible even when editor loses focus.
* Automatically tracks document changes using mapPos.
*
* This module is a thin wrapper around the generic `createPersistentHighlight`
* factory, preserving the original public API for backwards compatibility.
*/
import { StateEffect, type Extension } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { logError } from "@/logger";
import {
createPersistentHighlight,
type PersistentHighlightRange,
} from "@/editor/persistentHighlight";
// ============================================================================
// Instance (isolated from Chat highlight)
// ============================================================================
const selectionHighlight = createPersistentHighlight("copilot-selection-highlight");
// ============================================================================
// Public Types
// ============================================================================
/**
* A persistent highlight range in CM6 document offsets.
* Re-exported for backwards compatibility.
*/
export type SelectionHighlightRange = PersistentHighlightRange;
// ============================================================================
// Public API (backwards-compatible)
// ============================================================================
/**
* Extension that enables persistent selection highlight support.
*/
export const selectionHighlightExtension: Extension = selectionHighlight.extension;
/**
* Dispatch helper with error isolation (e.g. view destroyed).
*/
function safeDispatch(view: EditorView, spec: Parameters<EditorView["dispatch"]>[0]): void {
try {
view.dispatch(spec);
} catch (error) {
logError("SelectionHighlight dispatch failed:", error);
}
}
/**
* Build effects for showing/hiding selection highlight without dispatching.
* Use this when you need to combine highlight effects with other effects in a single dispatch.
* @param view - The EditorView
* @param range - The range to highlight, or null to hide
* @returns Array of StateEffects to include in a dispatch call
*/
export function buildSelectionHighlightEffects(
view: EditorView,
range: { from: number; to: number } | null
): StateEffect<unknown>[] {
return selectionHighlight.buildEffects(view, range);
}
/**
* Show a persistent highlight in `view` for [from, to].
* Automatically installs the extension in this view if missing.
*/
export function showSelectionHighlight(view: EditorView, from: number, to: number): void {
const effects = selectionHighlight.buildEffects(view, { from, to });
if (effects.length > 0) {
safeDispatch(view, { effects });
}
}
/**
* Update the persistent highlight in `view` to [from, to].
* Alias of `showSelectionHighlight` (semantic clarity).
*/
export function updateSelectionHighlight(view: EditorView, from: number, to: number): void {
showSelectionHighlight(view, from, to);
}
/**
* Hide (clear) the persistent highlight in `view`.
* Does NOT install the extension if missing.
*/
export function hideSelectionHighlight(view: EditorView): void {
const effects = selectionHighlight.buildEffects(view, null);
if (effects.length > 0) {
safeDispatch(view, { effects });
}
}
/**
* Get the current persistent highlight range in `view` after mapping through document changes.
* Returns null when the extension isn't installed or the highlight is hidden.
*/
export function getSelectionHighlightRange(view: EditorView): SelectionHighlightRange | null {
return selectionHighlight.getRange(view);
}
/**
* Convenience object API:
* - `SelectionHighlight.show(view, from, to)`
* - `SelectionHighlight.update(view, from, to)`
* - `SelectionHighlight.hide(view)`
* - `SelectionHighlight.getRange(view)`
* - `SelectionHighlight.buildEffects(view, range)` - Build effects without dispatching
*/
export const SelectionHighlight = {
show: showSelectionHighlight,
update: updateSelectionHighlight,
hide: hideSelectionHighlight,
getRange: getSelectionHighlightRange,
buildEffects: buildSelectionHighlightEffects,
} as const;

288
src/hooks/use-draggable.ts Normal file
View file

@ -0,0 +1,288 @@
import type React from "react";
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
interface Position {
x: number;
y: number;
}
interface UseDraggableOptions {
initialPosition?: Position;
bounds?: "window" | "parent" | null;
/**
* Optional external ref for the draggable element.
* Useful when the element ref is shared with other hooks (e.g., resize).
*/
dragRef?: React.RefObject<HTMLDivElement>;
/**
* Optional getter for the current element position at drag start.
* Use when the element is positioned outside of React state.
*/
getPosition?: () => Position;
/**
* Called whenever a new position is applied (RAF-throttled).
* Useful when the caller controls positioning outside the hook.
*/
onPositionChange?: (position: Position) => void;
/**
* Whether to write `left/top` to the DOM.
* Disable when the element is positioned by an external owner.
*/
writeToDom?: boolean;
}
/**
* Clamps a number between the given min/max.
*/
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
/**
* useDraggable
*
* A draggable hook optimized for performance:
* - Optionally writes `left/top` directly to the DOM (no React re-render on mousemove)
* - Uses `requestAnimationFrame` to throttle updates
* - Applies `preventDefault()` + `document.body.style.userSelect = "none"` during drag
*
* API is kept compatible:
* `position, setPosition, isDragging, dragRef, handleMouseDown`
*/
export function useDraggable(options: UseDraggableOptions = {}) {
const {
initialPosition = { x: 0, y: 0 },
bounds = "window",
dragRef: providedDragRef,
getPosition,
onPositionChange,
writeToDom = true,
} = options;
const [position, setPositionState] = useState<Position>(initialPosition);
const [isDragging, setIsDragging] = useState(false);
const internalDragRef = useRef<HTMLDivElement>(null);
const dragRef = providedDragRef ?? internalDragRef;
const positionRef = useRef<Position>(initialPosition);
const dragOffsetRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
const pendingPositionRef = useRef<Position | null>(null);
const rafIdRef = useRef<number | null>(null);
const cleanupDragRef = useRef<((commit: boolean) => void) | null>(null);
const isMountedRef = useRef(true);
const previousBodyStyleRef = useRef<{ userSelect: string; cursor: string } | null>(null);
/**
* Writes position to the drag element via inline styles.
*/
const writePositionToDom = useCallback(
(next: Position): void => {
if (!writeToDom) return;
const el = dragRef.current;
if (!el) return;
const left = `${next.x}px`;
const top = `${next.y}px`;
if (el.style.left !== left) el.style.left = left;
if (el.style.top !== top) el.style.top = top;
},
[dragRef, writeToDom]
);
/**
* Applies bounds (if enabled), updates refs, and optionally writes to DOM.
*/
const applyPosition = useCallback(
(raw: Position): Position => {
let nextX = raw.x;
let nextY = raw.y;
const el = dragRef.current;
if (bounds === "window" && el) {
const ownerWindow = el.ownerDocument?.defaultView ?? window;
const rect = el.getBoundingClientRect();
const maxX = ownerWindow.innerWidth - rect.width;
const maxY = ownerWindow.innerHeight - rect.height;
nextX = clamp(nextX, 0, Math.max(0, maxX));
nextY = clamp(nextY, 0, Math.max(0, maxY));
} else if (bounds === "parent" && el?.parentElement) {
const rect = el.getBoundingClientRect();
const parentRect = el.parentElement.getBoundingClientRect();
const minX = parentRect.left;
const minY = parentRect.top;
const maxX = parentRect.right - rect.width;
const maxY = parentRect.bottom - rect.height;
nextX = clamp(nextX, minX, Math.max(minX, maxX));
nextY = clamp(nextY, minY, Math.max(minY, maxY));
}
const next = { x: nextX, y: nextY };
positionRef.current = next;
writePositionToDom(next);
onPositionChange?.(next);
return next;
},
[bounds, dragRef, writePositionToDom, onPositionChange]
);
/**
* Schedules a DOM update on the next animation frame.
*/
const scheduleApply = useCallback((): void => {
if (rafIdRef.current != null) return;
const ownerWindow = dragRef.current?.ownerDocument?.defaultView ?? window;
rafIdRef.current = ownerWindow.requestAnimationFrame(() => {
rafIdRef.current = null;
const pending = pendingPositionRef.current;
if (!pending) return;
pendingPositionRef.current = null;
applyPosition(pending);
});
}, [applyPosition, dragRef]);
/**
* Compatible setter: updates state + ref, and also writes to DOM immediately.
* (No throttling here; `setPosition` is expected to be called infrequently.)
*/
const setPosition = useCallback<React.Dispatch<React.SetStateAction<Position>>>(
(value) => {
const base = positionRef.current;
const next = typeof value === "function" ? value(base) : value;
positionRef.current = next;
setPositionState(next);
writePositionToDom(next);
onPositionChange?.(next);
},
[writePositionToDom, onPositionChange]
);
/**
* Starts dragging: captures pointer offset and installs global listeners.
*/
const handleMouseDown = useCallback(
(e: React.MouseEvent): void => {
// Only respond to left mouse button
if (e.button !== 0) return;
e.preventDefault();
if (cleanupDragRef.current) return;
setIsDragging(true);
const current = getPosition ? getPosition() : positionRef.current;
positionRef.current = current;
writePositionToDom(current);
dragOffsetRef.current = {
x: e.clientX - current.x,
y: e.clientY - current.y,
};
const ownerDocument = dragRef.current?.ownerDocument ?? document;
const ownerWindow = ownerDocument.defaultView ?? window;
const body = ownerDocument.body;
previousBodyStyleRef.current = {
userSelect: body.style.userSelect,
cursor: body.style.cursor,
};
body.style.userSelect = "none";
body.style.cursor = "grabbing";
/**
* Mouse move handler: updates pending position and schedules RAF apply.
*/
const handleMouseMove = (event: MouseEvent): void => {
pendingPositionRef.current = {
x: event.clientX - dragOffsetRef.current.x,
y: event.clientY - dragOffsetRef.current.y,
};
scheduleApply();
};
/**
* Drag cleanup (commit=true on mouseup; commit=false on unmount safety cleanup).
*/
const cleanup = (commit: boolean): void => {
if (cleanupDragRef.current !== cleanup) return;
ownerDocument.removeEventListener("mousemove", handleMouseMove, true);
ownerDocument.removeEventListener("mouseup", handleMouseUp, true);
if (rafIdRef.current != null) {
ownerWindow.cancelAnimationFrame(rafIdRef.current);
rafIdRef.current = null;
}
const pending = pendingPositionRef.current;
pendingPositionRef.current = null;
const finalPosition = pending ? applyPosition(pending) : positionRef.current;
const previous = previousBodyStyleRef.current;
if (previous) {
body.style.userSelect = previous.userSelect;
body.style.cursor = previous.cursor;
previousBodyStyleRef.current = null;
} else {
body.style.userSelect = "";
body.style.cursor = "";
}
cleanupDragRef.current = null;
if (commit && isMountedRef.current) {
setPositionState(finalPosition);
setIsDragging(false);
}
};
/**
* Mouse up handler: ends dragging and commits position to React state once.
*/
const handleMouseUp = (): void => {
cleanup(true);
};
cleanupDragRef.current = cleanup;
ownerDocument.addEventListener("mousemove", handleMouseMove, true);
ownerDocument.addEventListener("mouseup", handleMouseUp, true);
},
[applyPosition, dragRef, getPosition, scheduleApply, writePositionToDom]
);
useEffect(() => {
return () => {
isMountedRef.current = false;
cleanupDragRef.current?.(false);
};
}, []);
useLayoutEffect(() => {
// Keep DOM aligned with the latest ref position even if parent re-renders.
writePositionToDom(positionRef.current);
});
return {
position,
setPosition,
isDragging,
dragRef,
handleMouseDown,
};
}

View file

@ -0,0 +1,50 @@
/**
* useRafThrottledCallback - Shared RAF-throttled callback hook.
* Returns a callback that is throttled to at most once per animation frame.
* The latest arguments are always used, suitable for streaming UI updates.
*/
import { useRef, useCallback, useEffect } from "react";
/**
* Returns a callback that is throttled to at most once per animation frame.
* The latest arguments are always used, suitable for streaming UI updates.
* Includes cleanup on unmount to prevent late state updates.
*/
export function useRafThrottledCallback<T extends (...args: unknown[]) => void>(callback: T): T {
const callbackRef = useRef(callback);
const frameRef = useRef<number | null>(null);
const lastArgsRef = useRef<Parameters<T> | null>(null);
// Keep callback ref up to date
useEffect(() => {
callbackRef.current = callback;
}, [callback]);
// Cancel pending RAF on unmount
useEffect(() => {
return () => {
if (frameRef.current !== null) {
cancelAnimationFrame(frameRef.current);
frameRef.current = null;
}
};
}, []);
return useCallback(
((...args: Parameters<T>) => {
lastArgsRef.current = args;
if (frameRef.current !== null) return;
frameRef.current = requestAnimationFrame(() => {
frameRef.current = null;
const latestArgs = lastArgsRef.current;
if (latestArgs) {
callbackRef.current(...latestArgs);
}
});
}) as T,
[]
);
}

244
src/hooks/use-resizable.ts Normal file
View file

@ -0,0 +1,244 @@
/**
* useRafResizable - Shared RAF-throttled resize handle logic.
* Designed for floating panels/modals that need document-level mouse listeners.
*/
import type React from "react";
import { useCallback, useEffect, useRef, useState } from "react";
export type ResizeDirection = "right" | "bottom" | "bottom-left" | "bottom-right";
export interface ResizeConstraints {
minWidth: number;
minHeight: number;
maxWidth?: number;
maxHeight?: number;
}
export interface ResizeUpdate {
width: number;
height: number;
/**
* Optional new top-left (client coordinates) for left-edge resizing.
*/
x?: number;
y?: number;
}
interface ResizeStartState {
direction: ResizeDirection;
startClientX: number;
startClientY: number;
width: number;
height: number;
left: number;
top: number;
right: number;
}
export interface UseRafResizableOptions {
enabled?: boolean;
/**
* Returns the element's current DOMRect. Used to capture the resize baseline on mouse down.
*/
getRect: () => DOMRect | null;
/**
* Returns min/max constraints for the resize interaction.
* Called on every mousemove so it can depend on the current viewport/host.
*/
getConstraints?: () => ResizeConstraints;
/**
* Called at most once per animation frame with the latest computed dimensions.
*/
onResize: (next: ResizeUpdate) => void;
}
export interface UseRafResizableResult {
isResizing: boolean;
handleResizeStart: (direction: ResizeDirection) => (e: React.MouseEvent) => void;
}
/**
* Shared resize-handle logic for floating panels/modals.
* - Uses document-level mouse listeners
* - Throttles updates with requestAnimationFrame
* - Temporarily disables text selection and sets an appropriate cursor
*/
export function useRafResizable(options: UseRafResizableOptions): UseRafResizableResult {
const { enabled = true, getRect, getConstraints, onResize } = options;
const [isResizing, setIsResizing] = useState(false);
const startRef = useRef<ResizeStartState | null>(null);
const ownerDocumentRef = useRef<Document | null>(null);
const ownerWindowRef = useRef<Window | null>(null);
const rafIdRef = useRef<number | null>(null);
const pendingRef = useRef<ResizeUpdate | null>(null);
const onResizeRef = useRef(onResize);
useEffect(() => {
onResizeRef.current = onResize;
}, [onResize]);
const getConstraintsRef = useRef(getConstraints);
useEffect(() => {
getConstraintsRef.current = getConstraints;
}, [getConstraints]);
const handleResizeStart = useCallback(
(direction: ResizeDirection) =>
(e: React.MouseEvent): void => {
if (!enabled) return;
const rect = getRect();
if (!rect) return;
// Capture owner document/window from the event target
const targetElement = e.currentTarget as HTMLElement | null;
const ownerDoc = targetElement?.ownerDocument ?? document;
ownerDocumentRef.current = ownerDoc;
ownerWindowRef.current = ownerDoc.defaultView ?? window;
startRef.current = {
direction,
startClientX: e.clientX,
startClientY: e.clientY,
width: rect.width,
height: rect.height,
left: rect.left,
top: rect.top,
right: rect.right,
};
setIsResizing(true);
e.preventDefault();
e.stopPropagation();
},
[enabled, getRect]
);
useEffect(() => {
if (!isResizing) return;
const start = startRef.current;
if (!start) return;
const direction = start.direction;
const cursor =
direction === "right"
? "ew-resize"
: direction === "bottom"
? "ns-resize"
: direction === "bottom-left"
? "nesw-resize"
: "nwse-resize";
const ownerDocument = ownerDocumentRef.current ?? document;
const ownerWindow = ownerWindowRef.current ?? window;
const body = ownerDocument.body;
const previousCursor = body.style.cursor;
const previousUserSelect = body.style.userSelect;
body.style.cursor = cursor;
body.style.userSelect = "none";
const cancelRaf = (): void => {
if (rafIdRef.current === null) return;
ownerWindow.cancelAnimationFrame(rafIdRef.current);
rafIdRef.current = null;
};
const flushPending = (): void => {
const pending = pendingRef.current;
if (!pending) return;
pendingRef.current = null;
onResizeRef.current(pending);
};
const scheduleFlush = (): void => {
if (rafIdRef.current !== null) return;
rafIdRef.current = ownerWindow.requestAnimationFrame(() => {
rafIdRef.current = null;
flushPending();
});
};
const handleMouseMove = (event: MouseEvent): void => {
const currentStart = startRef.current;
if (!currentStart) return;
const deltaX = event.clientX - currentStart.startClientX;
const deltaY = event.clientY - currentStart.startClientY;
const constraints = getConstraintsRef.current?.() ?? {
minWidth: 0,
minHeight: 0,
};
const minWidth = constraints.minWidth;
const minHeight = constraints.minHeight;
const maxWidth = constraints.maxWidth ?? Number.POSITIVE_INFINITY;
const maxHeight = constraints.maxHeight ?? Number.POSITIVE_INFINITY;
let nextWidth = currentStart.width;
let nextHeight = currentStart.height;
let nextX: number | undefined;
let nextY: number | undefined;
if (direction === "right" || direction === "bottom-right") {
nextWidth = Math.min(maxWidth, Math.max(minWidth, currentStart.width + deltaX));
}
if (direction === "bottom-left") {
nextWidth = Math.min(maxWidth, Math.max(minWidth, currentStart.width - deltaX));
nextX = currentStart.right - nextWidth;
nextY = currentStart.top;
}
if (direction === "bottom" || direction === "bottom-right" || direction === "bottom-left") {
nextHeight = Math.min(maxHeight, Math.max(minHeight, currentStart.height + deltaY));
}
pendingRef.current =
nextX === undefined
? { width: nextWidth, height: nextHeight }
: { width: nextWidth, height: nextHeight, x: nextX, y: nextY };
scheduleFlush();
};
const finish = (): void => {
cancelRaf();
flushPending();
ownerDocument.removeEventListener("mousemove", handleMouseMove, true);
ownerDocument.removeEventListener("mouseup", handleMouseUp, true);
body.style.cursor = previousCursor;
body.style.userSelect = previousUserSelect;
startRef.current = null;
setIsResizing(false);
};
const handleMouseUp = (): void => {
finish();
};
ownerDocument.addEventListener("mousemove", handleMouseMove, true);
ownerDocument.addEventListener("mouseup", handleMouseUp, true);
return () => {
cancelRaf();
pendingRef.current = null;
startRef.current = null;
ownerDocument.removeEventListener("mousemove", handleMouseMove, true);
ownerDocument.removeEventListener("mouseup", handleMouseUp, true);
body.style.cursor = previousCursor;
body.style.userSelect = previousUserSelect;
};
}, [isResizing]);
return { isResizing, handleResizeStart };
}

View file

@ -0,0 +1,381 @@
/**
* useStreamingChatSession - Shared streaming chat session hook.
*
* Responsibilities:
* - AbortController lifecycle (stop/reset/unmount)
* - Re-entrancy lock (isStreamingRef)
* - RAF-throttled streaming text updates
* - Lazy chain + memory creation and recreation (model/systemPrompt changes)
* - First-turn tracking based on successful memory persistence
* - ThinkBlockStreamer integration for provider-agnostic streaming chunks
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { RunnableSequence } from "@langchain/core/runnables";
import type { BaseChatMemory } from "@langchain/classic/memory";
import type { CustomModel } from "@/aiParams";
import { createChatChain, createChatMemory } from "@/commands/customCommandChatEngine";
import { ThinkBlockStreamer } from "@/LLMProviders/chainRunner/utils/ThinkBlockStreamer";
import { ABORT_REASON } from "@/constants";
import { logError } from "@/logger";
import { useRafThrottledCallback } from "@/hooks/use-raf-throttled-callback";
export interface StreamingChatTurnContext {
/** Abort signal for the current turn (covers prompt-building + streaming). */
signal: AbortSignal;
/** True if no previous successful turn has been saved into memory. */
isFirstTurn: boolean;
}
export interface UseStreamingChatSessionParams {
/** Resolved model to use (already validated/enabled). */
model: CustomModel | null;
/** System prompt for the chain (empty string allowed). */
systemPrompt: string;
/** Exclude thinking blocks from streamed output (default: true). */
excludeThinking?: boolean;
/** Called when a turn is requested but no model is available. */
onNoModel?: () => void;
/** Called when a non-abort error occurs during streaming. */
onNonAbortError?: (error: unknown) => void;
}
export interface StreamingChatSessionApi {
isStreaming: boolean;
streamingText: string;
/** Returns true if this is the first successful turn (based on memory persistence). */
getIsFirstTurn: () => boolean;
/**
* Runs a streaming turn.
* `getPrompt` is executed after the AbortController is created, so stop/reset can abort prompt-building too.
*
* Returns the committed (trimmed) output, or null if:
* - aborted due to NEW_CHAT / UNMOUNT
* - missing model
* - non-abort error
* - empty output
*/
runTurn: (
getPrompt: (ctx: StreamingChatTurnContext) => Promise<string>
) => Promise<string | null>;
/** Aborts the current turn (default reason: USER_STOPPED). */
stop: (reason?: ABORT_REASON) => void;
/** Aborts with NEW_CHAT and resets chain/memory/first-turn + streaming state. */
reset: () => void;
/** Get the current memory instance (for saving partial context on stop). */
getMemory: () => BaseChatMemory | null;
/** Get the latest streaming text from ref (bypasses RAF throttle). */
getLatestStreamingText: () => string;
}
interface ChainAndMemory {
chain: RunnableSequence;
memory: BaseChatMemory;
}
/** Returns a stable key for caching chain per model. */
function getModelKey(model: CustomModel): string {
return `${model.name}|${model.provider}`;
}
/** Returns true if an aborted signal should skip persistence/commit. */
function shouldSkipPersistOnAbort(signal: AbortSignal): boolean {
if (!signal.aborted) return false;
const reason = signal.reason;
// If abort() was called without a string reason, treat it as a "non-commit" cancel.
if (typeof reason !== "string") return true;
return reason === ABORT_REASON.NEW_CHAT || reason === ABORT_REASON.UNMOUNT;
}
/**
* Shared streaming chat session hook for Quick Ask + CustomCommandChatModal.
*/
export function useStreamingChatSession(
params: UseStreamingChatSessionParams
): StreamingChatSessionApi {
const { model, systemPrompt, excludeThinking = true, onNoModel, onNonAbortError } = params;
const [isStreaming, setIsStreaming] = useState(false);
const [streamingText, setStreamingText] = useState("");
const isMountedRef = useRef(true);
const isStreamingRef = useRef(false);
const abortControllerRef = useRef<AbortController | null>(null);
const streamingTextRef = useRef("");
const hasSavedContextOnceRef = useRef(false);
// Reason: Monotonically increasing turn ID prevents stale finally blocks
// from overwriting state after reset() starts a new generation.
const turnIdRef = useRef(0);
// Reason: Store callbacks in refs to avoid runTurn identity changing
// when caller passes inline functions. This prevents autoExecute effects
// from re-running due to runTurn dependency changes.
const onNoModelRef = useRef(onNoModel);
const onNonAbortErrorRef = useRef(onNonAbortError);
useEffect(() => {
onNoModelRef.current = onNoModel;
onNonAbortErrorRef.current = onNonAbortError;
}, [onNoModel, onNonAbortError]);
const memoryRef = useRef<BaseChatMemory | null>(null);
const chainRef = useRef<RunnableSequence | null>(null);
const currentModelKeyRef = useRef<string | null>(null);
const currentSystemPromptRef = useRef<string | null>(null);
const modelKey = useMemo(() => {
if (!model) return null;
return getModelKey(model);
}, [model]);
const setStreamingTextThrottled = useRafThrottledCallback((text: string) => {
if (!isMountedRef.current) return;
setStreamingText(text);
});
/** Updates streaming refs + schedules a throttled state update. */
const handleDelta = useCallback(
(text: string): void => {
if (!isMountedRef.current) return;
streamingTextRef.current = text;
setStreamingTextThrottled(text);
},
[setStreamingTextThrottled]
);
/** Ensures chain/memory exist and are recreated when model/systemPrompt change. */
const getOrCreateChain = useCallback(
async (signal: AbortSignal): Promise<ChainAndMemory | null> => {
if (!model || !modelKey) {
onNoModelRef.current?.();
return null;
}
const needsRecreate =
!chainRef.current ||
currentModelKeyRef.current !== modelKey ||
currentSystemPromptRef.current !== systemPrompt;
if (needsRecreate) {
if (!memoryRef.current) {
memoryRef.current = createChatMemory();
}
const nextChain = await createChatChain(model, systemPrompt, memoryRef.current);
// Avoid stale write-back if the turn was cancelled while creating the chain.
if (signal.aborted) return null;
chainRef.current = nextChain;
currentModelKeyRef.current = modelKey;
currentSystemPromptRef.current = systemPrompt;
}
if (!chainRef.current || !memoryRef.current) return null;
return { chain: chainRef.current, memory: memoryRef.current };
},
[model, modelKey, systemPrompt]
);
/** Cleanup on unmount: abort any active turn and prevent state updates. */
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
abortControllerRef.current?.abort(ABORT_REASON.UNMOUNT);
abortControllerRef.current = null;
};
}, []);
/**
* If model/systemPrompt changes while streaming, abort to avoid stale writes
* and force chain recreation on next turn. Memory is preserved.
*/
useEffect(() => {
if (isStreamingRef.current) {
abortControllerRef.current?.abort(ABORT_REASON.NEW_CHAT);
}
chainRef.current = null;
currentModelKeyRef.current = null;
currentSystemPromptRef.current = null;
}, [modelKey, systemPrompt]);
const getIsFirstTurn = useCallback((): boolean => {
return !hasSavedContextOnceRef.current;
}, []);
const getMemory = useCallback((): BaseChatMemory | null => {
return memoryRef.current;
}, []);
/** Returns the latest streaming text directly from the ref, bypassing RAF throttle. */
const getLatestStreamingText = useCallback((): string => {
return streamingTextRef.current;
}, []);
const stop = useCallback((reason: ABORT_REASON = ABORT_REASON.USER_STOPPED): void => {
abortControllerRef.current?.abort(reason);
}, []);
const reset = useCallback((): void => {
abortControllerRef.current?.abort(ABORT_REASON.NEW_CHAT);
abortControllerRef.current = null;
chainRef.current = null;
currentModelKeyRef.current = null;
currentSystemPromptRef.current = null;
memoryRef.current = createChatMemory();
hasSavedContextOnceRef.current = false;
// Increment turn ID so any pending finally block from the old turn
// will skip state updates.
turnIdRef.current += 1;
streamingTextRef.current = "";
isStreamingRef.current = false;
if (isMountedRef.current) {
setStreamingText("");
setStreamingTextThrottled("");
setIsStreaming(false);
}
}, [setStreamingTextThrottled]);
const runTurn = useCallback(
async (
getPrompt: (ctx: StreamingChatTurnContext) => Promise<string>
): Promise<string | null> => {
if (isStreamingRef.current) return null;
isStreamingRef.current = true;
if (isMountedRef.current) setIsStreaming(true);
const currentTurnId = ++turnIdRef.current;
const abortController = new AbortController();
abortControllerRef.current = abortController;
// Reset streaming buffers (also update throttler so pending RAF won't resurrect stale text).
streamingTextRef.current = "";
if (isMountedRef.current) {
setStreamingText("");
setStreamingTextThrottled("");
}
// Reason: Wrap handleDelta with a turnId guard so that after reset(),
// any pending ThinkBlockStreamer callbacks from the old turn are no-ops.
const turnScopedDelta = (text: string): void => {
if (turnIdRef.current !== currentTurnId) return;
handleDelta(text);
};
const thinkStreamer = new ThinkBlockStreamer(turnScopedDelta, excludeThinking);
let didNonAbortError = false;
let memory: BaseChatMemory | null = null;
let prompt = "";
let committed: string | null = null;
try {
const isFirstTurn = !hasSavedContextOnceRef.current;
if (!model) {
onNoModelRef.current?.();
return null;
}
prompt = await getPrompt({ signal: abortController.signal, isFirstTurn });
if (abortController.signal.aborted) return null;
if (!prompt.trim()) return null;
const chainAndMemory = await getOrCreateChain(abortController.signal);
if (!chainAndMemory) return null;
memory = chainAndMemory.memory;
const chainWithSignal = chainAndMemory.chain.withConfig({
signal: abortController.signal,
});
const stream = await chainWithSignal.stream({ input: prompt });
for await (const chunk of stream) {
thinkStreamer.processChunk(chunk);
if (abortController.signal.aborted) break;
}
} catch (error) {
const isAbort =
(error instanceof Error && error.name === "AbortError") || abortController.signal.aborted;
if (!isAbort) {
didNonAbortError = true;
onNonAbortErrorRef.current?.(error);
}
} finally {
const result = thinkStreamer.close().content.trim();
const shouldSkip = shouldSkipPersistOnAbort(abortController.signal);
const isStale = turnIdRef.current !== currentTurnId;
// Reason: Decouple result delivery from persistence success.
// Even if saveContext fails, the user should still see the generated result.
// But skip everything if the turn is stale (reset was called).
if (!didNonAbortError && result && !shouldSkip && !isStale) {
committed = result;
// Best-effort persistence - failure doesn't affect UI
if (memory) {
try {
await memory.saveContext({ input: prompt }, { output: result });
hasSavedContextOnceRef.current = true;
} catch (error) {
logError("Error saving chat context:", error);
}
}
}
// Final cleanup - only if this turn is still the active one.
// Reason: If reset() was called while this turn was in-flight,
// turnIdRef will have been incremented, and we must not overwrite
// the clean state that reset() established.
if (!isStale) {
streamingTextRef.current = "";
isStreamingRef.current = false;
}
if (abortControllerRef.current === abortController) {
abortControllerRef.current = null;
}
if (isMountedRef.current && !isStale) {
setStreamingText("");
setStreamingTextThrottled("");
setIsStreaming(false);
}
}
return committed;
},
[excludeThinking, getOrCreateChain, handleDelta, model, setStreamingTextThrottled]
);
return {
isStreaming,
streamingText,
getIsFirstTurn,
runTurn,
stop,
reset,
getMemory,
getLatestStreamingText,
};
}

View file

@ -10,13 +10,11 @@ import CopilotView from "@/components/CopilotView";
import { APPLY_VIEW_TYPE, ApplyView } from "@/components/composer/ApplyView";
import { LoadChatHistoryModal } from "@/components/modals/LoadChatHistoryModal";
import { QUICK_COMMAND_CODE_BLOCK } from "@/commands/constants";
import { registerContextMenu } from "@/commands/contextMenu";
import { CustomCommandRegister } from "@/commands/customCommandRegister";
import { migrateCommands, suggestDefaultCommands } from "@/commands/migrator";
import { migrateSystemPromptsFromSettings } from "@/system-prompts/migration";
import { SystemPromptRegister } from "@/system-prompts/systemPromptRegister";
import { createQuickCommandContainer } from "@/components/QuickCommand";
import { ABORT_REASON, CHAT_VIEWTYPE, DEFAULT_OPEN_AREA, EVENT_NAMES } from "@/constants";
import { ChatManager } from "@/core/ChatManager";
import { MessageRepository } from "@/core/MessageRepository";
@ -44,6 +42,7 @@ import { ChatUIState } from "@/state/ChatUIState";
import { VaultDataManager } from "@/state/vaultDataAtoms";
import { FileParserManager } from "@/tools/FileParserManager";
import { initializeBuiltinTools } from "@/tools/builtinTools";
import { ChatSelectionHighlightController, hideChatSelectionHighlight, QuickAskController, SelectionHighlight } from "@/editor";
import {
Editor,
MarkdownView,
@ -78,8 +77,11 @@ export default class CopilotPlugin extends Plugin {
settingsUnsubscriber?: () => void;
chatUIState: ChatUIState;
userMemoryManager: UserMemoryManager;
quickAskController: QuickAskController;
chatSelectionHighlightController: ChatSelectionHighlightController;
private selectionDebounceTimer?: number;
private selectionChangeHandler?: () => void;
private lastSelectionSignature?: string;
private webSelectionTracker?: WebSelectionTracker;
private readonly chatHistoryLastAccessedAtManager = new RecentUsageManager<string>();
@ -128,6 +130,16 @@ export default class CopilotPlugin extends Plugin {
// Initialize UserMemoryManager
this.userMemoryManager = new UserMemoryManager(this.app);
// Initialize QuickAskController and register CM6 extension
this.quickAskController = new QuickAskController(this);
this.registerEditorExtension(this.quickAskController.createExtension());
// Initialize Chat selection highlight controller
this.chatSelectionHighlightController = new ChatSelectionHighlightController(this, {
closeQuickAskOnChatFocus: false,
});
this.chatSelectionHighlightController.initialize();
// Single source of truth for Active Web Tab ({activeWebTab}) state
// Preserves activeWebTab when switching to Chat view
// Only run on desktop - Web Viewer is not available on mobile
@ -150,28 +162,19 @@ export default class CopilotPlugin extends Plugin {
registerCommands(this, undefined, getSettings());
this.registerMarkdownCodeBlockProcessor(QUICK_COMMAND_CODE_BLOCK, (_, el) => {
createQuickCommandContainer({
plugin: this,
element: el,
});
// Remove parent element class names to clear default code block styling
if (el.parentElement) {
el.parentElement.className = "";
}
});
// Tool initialization is now handled automatically in CopilotPlusChainRunner and AutonomousAgentChainRunner
this.registerEvent(
this.app.workspace.on("editor-menu", (menu: Menu) => {
return registerContextMenu(menu);
registerContextMenu(menu, this.app);
})
);
this.registerEvent(
this.app.workspace.on("active-leaf-change", (leaf) => {
// Delegate to chat selection highlight controller
this.chatSelectionHighlightController.handleActiveLeafChange(leaf ?? null);
if (leaf && leaf.view instanceof MarkdownView) {
const file = leaf.view.file;
if (file) {
@ -211,6 +214,13 @@ export default class CopilotPlugin extends Plugin {
}
async onunload() {
// Clear all persistent selection highlights before unload
// This prevents "stuck" highlights after hot reload (dev environment)
this.clearAllPersistentSelectionHighlights();
// Cleanup chat selection highlight controller
this.chatSelectionHighlightController?.cleanup();
if (this.projectManager) {
this.projectManager.onunload();
}
@ -241,6 +251,27 @@ export default class CopilotPlugin extends Plugin {
logInfo("Copilot plugin unloaded");
}
/**
* Clear all persistent selection highlights across all Markdown editors.
* Called during plugin unload to prevent "stuck" highlights after hot reload.
*/
private clearAllPersistentSelectionHighlights(): void {
try {
const leaves = this.app.workspace.getLeavesOfType("markdown");
for (const leaf of leaves) {
const view = leaf.view;
if (!(view instanceof MarkdownView)) continue;
const cm = view.editor?.cm;
if (cm) {
SelectionHighlight.hide(cm);
hideChatSelectionHighlight(cm);
}
}
} catch (error) {
logWarn("Failed to clear persistent selection highlights:", error);
}
}
updateUserMessageHistory(newMessage: string) {
this.userMessageHistory = [...this.userMessageHistory, newMessage];
}
@ -386,24 +417,41 @@ export default class CopilotPlugin extends Plugin {
}
const editor = activeView.editor;
const selectedText = editor.getSelection();
// Only process non-empty selections
if (!selectedText || !selectedText.trim()) {
return;
}
const activeFile = this.app.workspace.getActiveFile();
if (!activeFile) {
return;
}
// Get selection range to determine line numbers
// Get selection range first to validate it exists
const selectionRange = editor.listSelections()[0];
if (!selectionRange) {
return;
}
// Compute selection signature to avoid redundant updates
const signature = activeFile
? `${activeFile.path}:${selectionRange.anchor.line}:${selectionRange.anchor.ch}:${selectionRange.head.line}:${selectionRange.head.ch}`
: "";
// Skip if selection hasn't changed
if (signature === this.lastSelectionSignature) {
return;
}
this.lastSelectionSignature = signature;
const selectedText = editor.getSelection();
// If selection is empty, clear note-type contexts
if (!selectedText || !selectedText.trim()) {
const currentContexts = getSelectedTextContexts();
const nonNoteContexts = currentContexts.filter((ctx) => ctx.sourceType !== "note");
if (currentContexts.length !== nonNoteContexts.length) {
setSelectedTextContexts(nonNoteContexts);
}
return;
}
if (!activeFile) {
return;
}
const anchorLine = selectionRange.anchor.line + 1;
const headLine = selectionRange.head.line + 1;
const startLine = Math.min(anchorLine, headLine);

View file

@ -1129,3 +1129,92 @@ body.is-mobile div[data-radix-popper-content-wrapper] {
opacity: 1;
}
}
/* Quick Ask Overlay Styles */
.copilot-quick-ask-overlay-host {
position: relative;
}
/* Override Obsidian's default contenteditable border */
.copilot-quick-ask-overlay [contenteditable] {
border: none !important;
box-shadow: none !important;
outline: none !important;
}
.copilot-quick-ask-overlay-root {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: var(--layer-popover);
}
.copilot-quick-ask-overlay {
position: absolute;
pointer-events: auto;
z-index: var(--layer-popover);
animation: copilot-quick-ask-fade-in 0.2s ease-out;
}
.copilot-quick-ask-overlay.closing {
animation: copilot-quick-ask-fade-out 0.2s ease-in forwards;
}
@keyframes copilot-quick-ask-fade-in {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes copilot-quick-ask-fade-out {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(-8px);
}
}
/* Quick Ask Resize Indicators */
.quick-ask-resize-indicator-right::after {
content: '';
position: absolute;
bottom: -1px;
right: -1px;
width: 8px;
height: 8px;
border-right: 2px solid color-mix(in srgb, var(--text-muted) 35%, transparent);
border-bottom: 2px solid color-mix(in srgb, var(--text-muted) 35%, transparent);
opacity: 0;
transition: opacity 0.15s ease;
pointer-events: none;
}
.quick-ask-resize-indicator-left::after {
content: '';
position: absolute;
bottom: -1px;
left: -1px;
width: 8px;
height: 8px;
border-left: 2px solid color-mix(in srgb, var(--text-muted) 35%, transparent);
border-bottom: 2px solid color-mix(in srgb, var(--text-muted) 35%, transparent);
opacity: 0;
transition: opacity 0.15s ease;
pointer-events: none;
}
.tw-group:hover .quick-ask-resize-indicator-right::after,
.tw-group:hover .quick-ask-resize-indicator-left::after {
opacity: 1;
}

View file

@ -1,3 +1,4 @@
import type { EditorView } from "@codemirror/view";
import { type TFile } from "obsidian";
declare module "obsidian" {
@ -8,6 +9,25 @@ declare module "obsidian" {
data: Map<string, any>;
} | null;
}
interface Editor {
/**
* The underlying CodeMirror 6 editor view, when available.
*/
cm?: EditorView;
}
interface MenuItem {
/**
* Creates a submenu for this item.
*/
setSubmenu(): this;
/**
* Submenu instance created by `setSubmenu()`, when available.
*/
submenu?: Menu;
}
}
export enum PromptSortStrategy {

View file

@ -9,7 +9,7 @@ import {
SettingKeyProviders,
USER_SENDER,
} from "@/constants";
import { logInfo } from "@/logger";
import { logInfo, logWarn } from "@/logger";
import { CopilotSettings } from "@/settings/model";
import { ChatMessage } from "@/types/message";
import { BaseChatModel } from "@langchain/core/language_models/chat_models";
@ -952,6 +952,11 @@ export function cleanMessageForCopy(message: string): string {
return cleanedMessage;
}
/**
* Inserts a message into the active markdown editor, optionally replacing the current selection.
* Uses a single CM6 transaction to avoid undo stack splitting.
* Ensures the inserted/replaced range is selected after the operation.
*/
export async function insertIntoEditor(message: string, replace: boolean = false) {
let leaf = app.workspace.getMostRecentLeaf();
if (!leaf) {
@ -975,13 +980,65 @@ export async function insertIntoEditor(message: string, replace: boolean = false
// Clean the message before inserting (removes think tags, writeToFile blocks, tool calls)
const cleanedMessage = cleanMessageForCopy(message);
const cleanedLines = cleanedMessage.split("\n");
if (replace) {
editor.replaceRange(cleanedMessage, cursorFrom, cursorTo);
} else {
editor.replaceRange(cleanedMessage, cursorTo);
/** Computes the end editor position after inserting text at start position */
const getEndPosition = (
start: { line: number; ch: number },
textLines: string[]
): { line: number; ch: number } => {
const lineDelta = textLines.length - 1;
if (lineDelta === 0) {
return { line: start.line, ch: start.ch + (textLines[0]?.length ?? 0) };
}
return { line: start.line + lineDelta, ch: textLines[textLines.length - 1]?.length ?? 0 };
};
/** Inserts via Obsidian Editor API (fallback when CM6 unavailable or dispatch fails) */
const insertWithEditorAPI = (): void => {
const changeFrom = replace ? cursorFrom : cursorTo;
editor.replaceRange(cleanedMessage, changeFrom, cursorTo);
editor.setSelection(changeFrom, getEndPosition(changeFrom, cleanedLines));
};
/** Focuses the editor and shows success notice */
const finalizeInsertion = (): void => {
editor.focus();
new Notice("Message inserted into the active note.");
};
// Check if CM6 EditorView is available and valid
const view = editor.cm;
const isCM6View = view?.state?.doc && typeof view.dispatch === "function";
if (!isCM6View) {
insertWithEditorAPI();
finalizeInsertion();
return;
}
new Notice("Message inserted into the active note.");
// Use CM6 selection offsets directly (simpler than manual line/ch conversion)
const { from, to } = view.state.selection.main;
const changeFrom = replace ? from : to;
// Use CM6's toText to handle CRLF normalization correctly
// (CM6 treats \r\n as single newline, so string.length would be wrong)
const insertText = view.state.toText(cleanedMessage);
const endOffset = changeFrom + insertText.length;
try {
// Single transaction: text change + selection change
view.dispatch({
changes: { from: changeFrom, to, insert: insertText },
selection: { anchor: changeFrom, head: endOffset },
});
} catch (e) {
// Fallback to Obsidian API if CM6 dispatch fails
logWarn("CM6 dispatch failed, falling back to Obsidian API", e);
insertWithEditorAPI();
}
finalizeInsertion();
}
export function debounce<T extends (...args: any[]) => void>(

View file

@ -0,0 +1,150 @@
/**
* Dynamic style manager for overlay positioning.
* Manages inline styles on elements with automatic cleanup.
*/
type StyleRecord = Record<string, string | number | undefined>;
interface ElementState {
properties: Set<string>;
prefix?: string;
}
const elementState = new WeakMap<HTMLElement, ElementState>();
/**
* Converts camelCase property names to kebab-case CSS properties.
*/
function toKebabCase(property: string): string {
if (property.startsWith("--")) return property;
return property
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
.replace(/_/g, "-")
.toLowerCase();
}
/**
* CSS properties that should not have 'px' appended when given as numbers.
* These are unitless properties in CSS.
*/
const UNITLESS_CSS_PROPERTIES = new Set([
"z-index",
"zIndex",
"opacity",
"flex",
"flex-grow",
"flexGrow",
"flex-shrink",
"flexShrink",
"font-weight",
"fontWeight",
"line-height",
"lineHeight",
"order",
"orphans",
"widows",
"tab-size",
"tabSize",
"column-count",
"columnCount",
]);
/**
* Normalizes style values, converting numbers to pixel values for length properties.
* Unitless CSS properties (z-index, opacity, flex, etc.) are not given 'px' suffix.
*/
function normalizeStyles(styles: StyleRecord): Map<string, string> {
const normalized = new Map<string, string>();
for (const [property, value] of Object.entries(styles)) {
if (value === undefined || value === null) continue;
const cssProperty = toKebabCase(property);
let stringValue: string;
if (typeof value === "number" && !property.startsWith("--")) {
// Check both camelCase and kebab-case versions for unitless properties
const isUnitless = UNITLESS_CSS_PROPERTIES.has(property) || UNITLESS_CSS_PROPERTIES.has(cssProperty);
stringValue = isUnitless ? String(value) : `${value}px`;
} else {
stringValue = String(value);
}
normalized.set(cssProperty, stringValue);
}
return normalized;
}
/**
* Removes classes with a specific prefix from an element.
*/
function removePrefixedClasses(element: HTMLElement, prefix: string): void {
if (!prefix) return;
const prefixPattern = `${prefix}-`;
// P2 Fix: Convert to array first to avoid modifying classList while iterating
const classesToRemove = Array.from(element.classList).filter((className) =>
className.startsWith(prefixPattern)
);
classesToRemove.forEach((className) => {
element.classList.remove(className);
});
}
/**
* Updates dynamic styles on an element, tracking which properties were set
* for proper cleanup later.
*
* @param element - The element to style
* @param prefix - A prefix for class-based cleanup
* @param styles - Style properties to apply
*/
export function updateDynamicStyleClass(
element: HTMLElement,
prefix: string,
styles: StyleRecord
): void {
if (!element) return;
const normalized = normalizeStyles(styles);
const previousState = elementState.get(element);
// P2 Fix: When prefix changes, clean up the OLD prefix classes, not the new one
if (previousState && previousState.prefix && previousState.prefix !== prefix) {
removePrefixedClasses(element, previousState.prefix);
}
const previousProperties = previousState?.properties ?? new Set<string>();
const nextProperties = new Set<string>();
// Remove properties that are no longer present
previousProperties.forEach((property) => {
if (!normalized.has(property)) {
element.style.removeProperty(property);
}
});
// Apply current styles
normalized.forEach((value, property) => {
element.style.setProperty(property, value);
nextProperties.add(property);
});
if (nextProperties.size === 0) {
elementState.delete(element);
return;
}
elementState.set(element, { properties: nextProperties, prefix });
}
/**
* Clears all dynamic styles from an element.
*/
export function clearDynamicStyleClass(element: HTMLElement): void {
const state = elementState.get(element);
if (!state) return;
state.properties.forEach((property) => {
element.style.removeProperty(property);
});
if (state.prefix) {
removePrefixedClasses(element, state.prefix);
}
elementState.delete(element);
}