mirror of
https://github.com/logancyang/obsidian-copilot.git
synced 2026-07-22 07:50:24 +00:00
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:
parent
800547534a
commit
76520d0259
56 changed files with 7200 additions and 682 deletions
602
package-lock.json
generated
602
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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)}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
63
src/commands/customCommandChatEngine.ts
Normal file
63
src/commands/customCommandChatEngine.ts
Normal 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,
|
||||
]);
|
||||
}
|
||||
|
|
@ -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 +
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
63
src/commands/quickCommandPrompts.ts
Normal file
63
src/commands/quickCommandPrompts.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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 = () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
68
src/components/command-ui/action-buttons.tsx
Normal file
68
src/components/command-ui/action-buttons.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
75
src/components/command-ui/chat-message.tsx
Normal file
75
src/components/command-ui/chat-message.tsx
Normal 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 };
|
||||
27
src/components/command-ui/close-button.tsx
Normal file
27
src/components/command-ui/close-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
src/components/command-ui/command-label.tsx
Normal file
37
src/components/command-ui/command-label.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
107
src/components/command-ui/content-area.tsx
Normal file
107
src/components/command-ui/content-area.tsx
Normal 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 };
|
||||
28
src/components/command-ui/drag-handle.tsx
Normal file
28
src/components/command-ui/drag-handle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
228
src/components/command-ui/draggable-modal.tsx
Normal file
228
src/components/command-ui/draggable-modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
89
src/components/command-ui/follow-up-input.tsx
Normal file
89
src/components/command-ui/follow-up-input.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
12
src/components/command-ui/index.ts
Normal file
12
src/components/command-ui/index.ts
Normal 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";
|
||||
207
src/components/command-ui/menu-command-modal.tsx
Normal file
207
src/components/command-ui/menu-command-modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
49
src/components/command-ui/selected-content.tsx
Normal file
49
src/components/command-ui/selected-content.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
75
src/components/quick-ask/ModeSelector.tsx
Normal file
75
src/components/quick-ask/ModeSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
175
src/components/quick-ask/QuickAskInput.tsx
Normal file
175
src/components/quick-ask/QuickAskInput.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
171
src/components/quick-ask/QuickAskMessage.tsx
Normal file
171
src/components/quick-ask/QuickAskMessage.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
744
src/components/quick-ask/QuickAskOverlay.tsx
Normal file
744
src/components/quick-ask/QuickAskOverlay.tsx
Normal 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),
|
||||
});
|
||||
}
|
||||
}
|
||||
442
src/components/quick-ask/QuickAskPanel.tsx
Normal file
442
src/components/quick-ask/QuickAskPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
12
src/components/quick-ask/index.ts
Normal file
12
src/components/quick-ask/index.ts
Normal 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";
|
||||
61
src/components/quick-ask/modeRegistry.ts
Normal file
61
src/components/quick-ask/modeRegistry.ts
Normal 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();
|
||||
16
src/components/quick-ask/modes/askMode.ts
Normal file
16
src/components/quick-ask/modes/askMode.ts
Normal 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,
|
||||
};
|
||||
15
src/components/quick-ask/modes/editDirectMode.ts
Normal file
15
src/components/quick-ask/modes/editDirectMode.ts
Normal 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,
|
||||
};
|
||||
15
src/components/quick-ask/modes/editMode.ts
Normal file
15
src/components/quick-ask/modes/editMode.ts
Normal 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,
|
||||
};
|
||||
7
src/components/quick-ask/modes/index.ts
Normal file
7
src/components/quick-ask/modes/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
/**
|
||||
* Mode configurations exports.
|
||||
*/
|
||||
|
||||
export { askModeConfig } from "./askMode";
|
||||
export { editModeConfig } from "./editMode";
|
||||
export { editDirectModeConfig } from "./editDirectMode";
|
||||
82
src/components/quick-ask/types.ts
Normal file
82
src/components/quick-ask/types.ts
Normal 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;
|
||||
}
|
||||
195
src/components/quick-ask/useQuickAskSession.ts
Normal file
195
src/components/quick-ask/useQuickAskSession.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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];
|
||||
|
|
|
|||
327
src/editor/chatSelectionHighlightController.ts
Normal file
327
src/editor/chatSelectionHighlightController.ts
Normal 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
32
src/editor/index.ts
Normal 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";
|
||||
220
src/editor/persistentHighlight.ts
Normal file
220
src/editor/persistentHighlight.ts
Normal 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 };
|
||||
}
|
||||
192
src/editor/quickAskController.ts
Normal file
192
src/editor/quickAskController.ts
Normal 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];
|
||||
}
|
||||
}
|
||||
101
src/editor/quickAskExtension.ts
Normal file
101
src/editor/quickAskExtension.ts
Normal 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
367
src/editor/replaceGuard.ts
Normal 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 };
|
||||
}
|
||||
121
src/editor/selectionHighlight.ts
Normal file
121
src/editor/selectionHighlight.ts
Normal 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
288
src/hooks/use-draggable.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
50
src/hooks/use-raf-throttled-callback.ts
Normal file
50
src/hooks/use-raf-throttled-callback.ts
Normal 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
244
src/hooks/use-resizable.ts
Normal 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 };
|
||||
}
|
||||
381
src/hooks/use-streaming-chat-session.ts
Normal file
381
src/hooks/use-streaming-chat-session.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
100
src/main.ts
100
src/main.ts
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
20
src/types.ts
20
src/types.ts
|
|
@ -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 {
|
||||
|
|
|
|||
69
src/utils.ts
69
src/utils.ts
|
|
@ -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>(
|
||||
|
|
|
|||
150
src/utils/dom/dynamicStyleManager.ts
Normal file
150
src/utils/dom/dynamicStyleManager.ts
Normal 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);
|
||||
}
|
||||
Loading…
Reference in a new issue