mirror of
https://github.com/rait-09/obsidian-agent-client.git
synced 2026-07-22 06:43:37 +00:00
Merge branch 'dev' into Gnonymous/feature/chat-font-size
This commit is contained in:
commit
eb70f315bb
31 changed files with 3815 additions and 983 deletions
|
|
@ -24,6 +24,7 @@ https://github.com/user-attachments/assets/1c538349-b3fb-44dd-a163-7331cbca7824
|
|||
- **スラッシュコマンド**: エージェントが提供する`/`コマンドを使用
|
||||
- **マルチエージェント**: Claude Code、Codex、Gemini CLI、カスタムエージェントを切り替え
|
||||
- **マルチセッション**: 複数のエージェントを別々のビューで同時実行
|
||||
- **フローティングチャット**: 素早くアクセスできる折りたたみ可能なチャットウィンドウ
|
||||
- **モード・モデル切り替え**: チャット画面からAIモデルやエージェントモードを変更
|
||||
- **セッション履歴**: 過去の会話を再開またはフォーク
|
||||
- **チャットエクスポート**: 会話をMarkdownノートとして保存
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ https://github.com/user-attachments/assets/1c538349-b3fb-44dd-a163-7331cbca7824
|
|||
- **Slash Commands**: Use `/` commands provided by your agent
|
||||
- **Multi-Agent Support**: Switch between Claude Code, Codex, Gemini CLI, and custom agents
|
||||
- **Multi-Session**: Run multiple agents simultaneously in separate views
|
||||
- **Floating Chat**: A persistent, collapsible chat window for quick access
|
||||
- **Mode & Model Switching**: Change AI models and agent modes from the chat
|
||||
- **Session History**: Resume or fork previous conversations
|
||||
- **Chat Export**: Save conversations as Markdown notes
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ export default defineConfig({
|
|||
{ text: "Model Selection", link: "/usage/model-selection" },
|
||||
{ text: "Session History", link: "/usage/session-history" },
|
||||
{ text: "Multi-Session Chat", link: "/usage/multi-session" },
|
||||
{ text: "Floating Chat", link: "/usage/floating-chat" },
|
||||
{ text: "Editing", link: "/usage/editing" },
|
||||
{ text: "Chat Export", link: "/usage/chat-export" },
|
||||
{ text: "Commands & Hotkeys", link: "/usage/commands" },
|
||||
|
|
|
|||
BIN
docs/public/images/floating-chat-button.webp
Normal file
BIN
docs/public/images/floating-chat-button.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
BIN
docs/public/images/floating-chat-instance-menu.webp
Normal file
BIN
docs/public/images/floating-chat-instance-menu.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8 KiB |
BIN
docs/public/images/floating-chat-view.webp
Normal file
BIN
docs/public/images/floating-chat-view.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 183 KiB |
|
|
@ -27,6 +27,14 @@ Control multiple chat views at once. See [Multi-Session Chat](/usage/multi-sessi
|
|||
| **Broadcast send** | Send messages in all views simultaneously |
|
||||
| **Broadcast cancel** | Cancel operations in all views |
|
||||
|
||||
## Floating Chat Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| **Open floating chat window** | Open an existing floating window, or create one if none exist |
|
||||
| **Open new floating chat window** | Always create a new floating window |
|
||||
| **Close floating chat window** | Hide the focused floating window (session is preserved) |
|
||||
|
||||
## Agent Commands
|
||||
|
||||
| Command | Description |
|
||||
|
|
|
|||
83
docs/usage/floating-chat.md
Normal file
83
docs/usage/floating-chat.md
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
# Floating Chat
|
||||
|
||||
A persistent, draggable chat window that floats over your workspace. Perfect for quick conversations without leaving your current view.
|
||||
|
||||
<p align="center">
|
||||
<img src="/images/floating-chat-view.webp" alt="Floating chat window open over the editor" />
|
||||
</p>
|
||||
|
||||
## Overview
|
||||
|
||||
The Floating Chat provides a lightweight alternative to the sidebar chat view:
|
||||
|
||||
- **Draggable window** — move it anywhere on screen
|
||||
- **Resizable** — drag the edges to adjust size
|
||||
- **Collapsible** — hide the window without losing your session
|
||||
- **Multi-window** — open multiple floating windows simultaneously
|
||||
- **Independent sessions** — each window runs its own agent session
|
||||
|
||||
::: tip
|
||||
Enable the floating chat in **Settings → Agent Client → Floating chat → Show floating button**.
|
||||
:::
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. Enable **Show floating button** in settings
|
||||
2. A floating button appears in the bottom-right corner
|
||||
3. Click the button to open a chat window
|
||||
4. Start chatting — the window works just like the sidebar chat
|
||||
|
||||
<p align="center">
|
||||
<img src="/images/floating-chat-button.webp" alt="Floating button in the bottom-right corner" width="200" />
|
||||
</p>
|
||||
|
||||
## Moving and Resizing
|
||||
|
||||
- **Drag** the header bar to move the window
|
||||
- **Resize** by dragging the bottom-right corner of the window
|
||||
- Position and size are saved automatically
|
||||
|
||||
## Multiple Windows
|
||||
|
||||
Open more than one floating chat window to run parallel conversations.
|
||||
|
||||
### Opening Additional Windows
|
||||
|
||||
- Click the **copy-plus** icon in the floating window header
|
||||
- Or use the command **"Open new floating chat window"** from the command palette
|
||||
|
||||
### Switching Between Windows
|
||||
|
||||
When multiple windows exist, clicking the floating button shows an instance menu:
|
||||
|
||||
<p align="center">
|
||||
<img src="/images/floating-chat-instance-menu.webp" alt="Instance menu with multiple sessions listed" width="300" />
|
||||
</p>
|
||||
|
||||
- Click a session name to expand that window
|
||||
- Click **×** to close a session
|
||||
|
||||
::: tip
|
||||
The focused floating window is always displayed in front of other floating windows.
|
||||
:::
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| **Open floating chat window** | Open an existing floating window, or create one if none exist |
|
||||
| **Open new floating chat window** | Always create a new floating window |
|
||||
| **Close floating chat window** | Hide the focused floating window (session is preserved) |
|
||||
|
||||
::: tip
|
||||
Assign keyboard shortcuts to these commands in **Settings → Hotkeys** for quick access.
|
||||
:::
|
||||
|
||||
## Configuration
|
||||
|
||||
Customize the floating chat in **Settings → Agent Client → Floating chat**:
|
||||
|
||||
| Setting | Default | Description |
|
||||
|---------|---------|-------------|
|
||||
| **Show floating button** | Off | Display the floating button and enable floating chat |
|
||||
| **Floating button image** | Default icon | URL or vault path to a custom button image |
|
||||
|
|
@ -72,6 +72,12 @@ See [Multi-Session Chat](/usage/multi-session) for details on:
|
|||
- Broadcast commands
|
||||
- Focus navigation
|
||||
|
||||
## Floating Chat
|
||||
|
||||
A draggable, resizable chat window that floats over your workspace. Enable it in **Settings → Agent Client → Floating chat**.
|
||||
|
||||
See [Floating Chat](/usage/floating-chat) for details.
|
||||
|
||||
## Changing Models and Modes
|
||||
|
||||
Below the input field, you'll find dropdowns to:
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "agent-client",
|
||||
"name": "Agent Client",
|
||||
"version": "0.7.5",
|
||||
"version": "0.8.0-preview.1",
|
||||
"minAppVersion": "0.15.0",
|
||||
"description": "Chat with AI agents via the Agent Client Protocol directly from your vault.",
|
||||
"author": "RAIT-09",
|
||||
|
|
|
|||
274
package-lock.json
generated
274
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "obsidian-agent-client",
|
||||
"version": "0.7.5",
|
||||
"version": "0.8.0-preview.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "obsidian-agent-client",
|
||||
"version": "0.7.5",
|
||||
"version": "0.8.0-preview.1",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.13.1",
|
||||
|
|
@ -45,16 +45,16 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@algolia/abtesting": {
|
||||
"version": "1.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.13.0.tgz",
|
||||
"integrity": "sha512-Zrqam12iorp3FjiKMXSTpedGYznZ3hTEOAr2oCxI8tbF8bS1kQHClyDYNq/eV0ewMNLyFkgZVWjaS+8spsOYiQ==",
|
||||
"version": "1.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.14.0.tgz",
|
||||
"integrity": "sha512-cZfj+1Z1dgrk3YPtNQNt0H9Rr67P8b4M79JjUKGS0d7/EbFbGxGgSu6zby5f22KXo3LT0LZa4O2c6VVbupJuDg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@algolia/client-common": "5.47.0",
|
||||
"@algolia/requester-browser-xhr": "5.47.0",
|
||||
"@algolia/requester-fetch": "5.47.0",
|
||||
"@algolia/requester-node-http": "5.47.0"
|
||||
"@algolia/client-common": "5.48.0",
|
||||
"@algolia/requester-browser-xhr": "5.48.0",
|
||||
"@algolia/requester-fetch": "5.48.0",
|
||||
"@algolia/requester-node-http": "5.48.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.0.0"
|
||||
|
|
@ -110,41 +110,41 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@algolia/client-abtesting": {
|
||||
"version": "5.47.0",
|
||||
"resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.47.0.tgz",
|
||||
"integrity": "sha512-aOpsdlgS9xTEvz47+nXmw8m0NtUiQbvGWNuSEb7fA46iPL5FxOmOUZkh8PREBJpZ0/H8fclSc7BMJCVr+Dn72w==",
|
||||
"version": "5.48.0",
|
||||
"resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.48.0.tgz",
|
||||
"integrity": "sha512-n17WSJ7vazmM6yDkWBAjY12J8ERkW9toOqNgQ1GEZu/Kc4dJDJod1iy+QP5T/UlR3WICgZDi/7a/VX5TY5LAPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@algolia/client-common": "5.47.0",
|
||||
"@algolia/requester-browser-xhr": "5.47.0",
|
||||
"@algolia/requester-fetch": "5.47.0",
|
||||
"@algolia/requester-node-http": "5.47.0"
|
||||
"@algolia/client-common": "5.48.0",
|
||||
"@algolia/requester-browser-xhr": "5.48.0",
|
||||
"@algolia/requester-fetch": "5.48.0",
|
||||
"@algolia/requester-node-http": "5.48.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@algolia/client-analytics": {
|
||||
"version": "5.47.0",
|
||||
"resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.47.0.tgz",
|
||||
"integrity": "sha512-EcF4w7IvIk1sowrO7Pdy4Ako7x/S8+nuCgdk6En+u5jsaNQM4rTT09zjBPA+WQphXkA2mLrsMwge96rf6i7Mow==",
|
||||
"version": "5.48.0",
|
||||
"resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.48.0.tgz",
|
||||
"integrity": "sha512-v5bMZMEqW9U2l40/tTAaRyn4AKrYLio7KcRuHmLaJtxuJAhvZiE7Y62XIsF070juz4MN3eyvfQmI+y5+OVbZuA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@algolia/client-common": "5.47.0",
|
||||
"@algolia/requester-browser-xhr": "5.47.0",
|
||||
"@algolia/requester-fetch": "5.47.0",
|
||||
"@algolia/requester-node-http": "5.47.0"
|
||||
"@algolia/client-common": "5.48.0",
|
||||
"@algolia/requester-browser-xhr": "5.48.0",
|
||||
"@algolia/requester-fetch": "5.48.0",
|
||||
"@algolia/requester-node-http": "5.48.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@algolia/client-common": {
|
||||
"version": "5.47.0",
|
||||
"resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.47.0.tgz",
|
||||
"integrity": "sha512-Wzg5Me2FqgRDj0lFuPWFK05UOWccSMsIBL2YqmTmaOzxVlLZ+oUqvKbsUSOE5ud8Fo1JU7JyiLmEXBtgDKzTwg==",
|
||||
"version": "5.48.0",
|
||||
"resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.48.0.tgz",
|
||||
"integrity": "sha512-7H3DgRyi7UByScc0wz7EMrhgNl7fKPDjKX9OcWixLwCj7yrRXDSIzwunykuYUUO7V7HD4s319e15FlJ9CQIIFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
|
@ -152,151 +152,151 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@algolia/client-insights": {
|
||||
"version": "5.47.0",
|
||||
"resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.47.0.tgz",
|
||||
"integrity": "sha512-Ci+cn/FDIsDxSKMRBEiyKrqybblbk8xugo6ujDN1GSTv9RIZxwxqZYuHfdLnLEwLlX7GB8pqVyqrUSlRnR+sJA==",
|
||||
"version": "5.48.0",
|
||||
"resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.48.0.tgz",
|
||||
"integrity": "sha512-tXmkB6qrIGAXrtRYHQNpfW0ekru/qymV02bjT0w5QGaGw0W91yT+53WB6dTtRRsIrgS30Al6efBvyaEosjZ5uw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@algolia/client-common": "5.47.0",
|
||||
"@algolia/requester-browser-xhr": "5.47.0",
|
||||
"@algolia/requester-fetch": "5.47.0",
|
||||
"@algolia/requester-node-http": "5.47.0"
|
||||
"@algolia/client-common": "5.48.0",
|
||||
"@algolia/requester-browser-xhr": "5.48.0",
|
||||
"@algolia/requester-fetch": "5.48.0",
|
||||
"@algolia/requester-node-http": "5.48.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@algolia/client-personalization": {
|
||||
"version": "5.47.0",
|
||||
"resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.47.0.tgz",
|
||||
"integrity": "sha512-gsLnHPZmWcX0T3IigkDL2imCNtsQ7dR5xfnwiFsb+uTHCuYQt+IwSNjsd8tok6HLGLzZrliSaXtB5mfGBtYZvQ==",
|
||||
"version": "5.48.0",
|
||||
"resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.48.0.tgz",
|
||||
"integrity": "sha512-4tXEsrdtcBZbDF73u14Kb3otN+xUdTVGop1tBjict+Rc/FhsJQVIwJIcTrOJqmvhtBfc56Bu65FiVOnpAZCxcw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@algolia/client-common": "5.47.0",
|
||||
"@algolia/requester-browser-xhr": "5.47.0",
|
||||
"@algolia/requester-fetch": "5.47.0",
|
||||
"@algolia/requester-node-http": "5.47.0"
|
||||
"@algolia/client-common": "5.48.0",
|
||||
"@algolia/requester-browser-xhr": "5.48.0",
|
||||
"@algolia/requester-fetch": "5.48.0",
|
||||
"@algolia/requester-node-http": "5.48.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@algolia/client-query-suggestions": {
|
||||
"version": "5.47.0",
|
||||
"resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.47.0.tgz",
|
||||
"integrity": "sha512-PDOw0s8WSlR2fWFjPQldEpmm/gAoUgLigvC3k/jCSi/DzigdGX6RdC0Gh1RR1P8Cbk5KOWYDuL3TNzdYwkfDyA==",
|
||||
"version": "5.48.0",
|
||||
"resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.48.0.tgz",
|
||||
"integrity": "sha512-unzSUwWFpsDrO8935RhMAlyK0Ttua/5XveVIwzfjs5w+GVBsHgIkbOe8VbBJccMU/z1LCwvu1AY3kffuSLAR5Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@algolia/client-common": "5.47.0",
|
||||
"@algolia/requester-browser-xhr": "5.47.0",
|
||||
"@algolia/requester-fetch": "5.47.0",
|
||||
"@algolia/requester-node-http": "5.47.0"
|
||||
"@algolia/client-common": "5.48.0",
|
||||
"@algolia/requester-browser-xhr": "5.48.0",
|
||||
"@algolia/requester-fetch": "5.48.0",
|
||||
"@algolia/requester-node-http": "5.48.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@algolia/client-search": {
|
||||
"version": "5.47.0",
|
||||
"resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.47.0.tgz",
|
||||
"integrity": "sha512-b5hlU69CuhnS2Rqgsz7uSW0t4VqrLMLTPbUpEl0QVz56rsSwr1Sugyogrjb493sWDA+XU1FU5m9eB8uH7MoI0g==",
|
||||
"version": "5.48.0",
|
||||
"resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.48.0.tgz",
|
||||
"integrity": "sha512-RB9bKgYTVUiOcEb5bOcZ169jiiVW811dCsJoLT19DcbbFmU4QaK0ghSTssij35QBQ3SCOitXOUrHcGgNVwS7sQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@algolia/client-common": "5.47.0",
|
||||
"@algolia/requester-browser-xhr": "5.47.0",
|
||||
"@algolia/requester-fetch": "5.47.0",
|
||||
"@algolia/requester-node-http": "5.47.0"
|
||||
"@algolia/client-common": "5.48.0",
|
||||
"@algolia/requester-browser-xhr": "5.48.0",
|
||||
"@algolia/requester-fetch": "5.48.0",
|
||||
"@algolia/requester-node-http": "5.48.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@algolia/ingestion": {
|
||||
"version": "1.47.0",
|
||||
"resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.47.0.tgz",
|
||||
"integrity": "sha512-WvwwXp5+LqIGISK3zHRApLT1xkuEk320/EGeD7uYy+K8WwDd5OjXnhjuXRhYr1685KnkvWkq1rQ/ihCJjOfHpQ==",
|
||||
"version": "1.48.0",
|
||||
"resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.48.0.tgz",
|
||||
"integrity": "sha512-rhoSoPu+TDzDpvpk3cY/pYgbeWXr23DxnAIH/AkN0dUC+GCnVIeNSQkLaJ+CL4NZ51cjLIjksrzb4KC5Xu+ktw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@algolia/client-common": "5.47.0",
|
||||
"@algolia/requester-browser-xhr": "5.47.0",
|
||||
"@algolia/requester-fetch": "5.47.0",
|
||||
"@algolia/requester-node-http": "5.47.0"
|
||||
"@algolia/client-common": "5.48.0",
|
||||
"@algolia/requester-browser-xhr": "5.48.0",
|
||||
"@algolia/requester-fetch": "5.48.0",
|
||||
"@algolia/requester-node-http": "5.48.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@algolia/monitoring": {
|
||||
"version": "1.47.0",
|
||||
"resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.47.0.tgz",
|
||||
"integrity": "sha512-j2EUFKAlzM0TE4GRfkDE3IDfkVeJdcbBANWzK16Tb3RHz87WuDfQ9oeEW6XiRE1/bEkq2xf4MvZesvSeQrZRDA==",
|
||||
"version": "1.48.0",
|
||||
"resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.48.0.tgz",
|
||||
"integrity": "sha512-aSe6jKvWt+8VdjOaq2ERtsXp9+qMXNJ3mTyTc1VMhNfgPl7ArOhRMRSQ8QBnY8ZL4yV5Xpezb7lAg8pdGrrulg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@algolia/client-common": "5.47.0",
|
||||
"@algolia/requester-browser-xhr": "5.47.0",
|
||||
"@algolia/requester-fetch": "5.47.0",
|
||||
"@algolia/requester-node-http": "5.47.0"
|
||||
"@algolia/client-common": "5.48.0",
|
||||
"@algolia/requester-browser-xhr": "5.48.0",
|
||||
"@algolia/requester-fetch": "5.48.0",
|
||||
"@algolia/requester-node-http": "5.48.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@algolia/recommend": {
|
||||
"version": "5.47.0",
|
||||
"resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.47.0.tgz",
|
||||
"integrity": "sha512-+kTSE4aQ1ARj2feXyN+DMq0CIDHJwZw1kpxIunedkmpWUg8k3TzFwWsMCzJVkF2nu1UcFbl7xsIURz3Q3XwOXA==",
|
||||
"version": "5.48.0",
|
||||
"resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.48.0.tgz",
|
||||
"integrity": "sha512-p9tfI1bimAaZrdiVExL/dDyGUZ8gyiSHsktP1ZWGzt5hXpM3nhv4tSjyHtXjEKtA0UvsaHKwSfFE8aAAm1eIQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@algolia/client-common": "5.47.0",
|
||||
"@algolia/requester-browser-xhr": "5.47.0",
|
||||
"@algolia/requester-fetch": "5.47.0",
|
||||
"@algolia/requester-node-http": "5.47.0"
|
||||
"@algolia/client-common": "5.48.0",
|
||||
"@algolia/requester-browser-xhr": "5.48.0",
|
||||
"@algolia/requester-fetch": "5.48.0",
|
||||
"@algolia/requester-node-http": "5.48.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@algolia/requester-browser-xhr": {
|
||||
"version": "5.47.0",
|
||||
"resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.47.0.tgz",
|
||||
"integrity": "sha512-Ja+zPoeSA2SDowPwCNRbm5Q2mzDvVV8oqxCQ4m6SNmbKmPlCfe30zPfrt9ho3kBHnsg37pGucwOedRIOIklCHw==",
|
||||
"version": "5.48.0",
|
||||
"resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.48.0.tgz",
|
||||
"integrity": "sha512-XshyfpsQB7BLnHseMinp3fVHOGlTv6uEHOzNK/3XrEF9mjxoZAcdVfY1OCXObfwRWX5qXZOq8FnrndFd44iVsQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@algolia/client-common": "5.47.0"
|
||||
"@algolia/client-common": "5.48.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@algolia/requester-fetch": {
|
||||
"version": "5.47.0",
|
||||
"resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.47.0.tgz",
|
||||
"integrity": "sha512-N6nOvLbaR4Ge+oVm7T4W/ea1PqcSbsHR4O58FJ31XtZjFPtOyxmnhgCmGCzP9hsJI6+x0yxJjkW5BMK/XI8OvA==",
|
||||
"version": "5.48.0",
|
||||
"resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.48.0.tgz",
|
||||
"integrity": "sha512-Q4XNSVQU89bKNAPuvzSYqTH9AcbOOiIo6AeYMQTxgSJ2+uvT78CLPMG89RIIloYuAtSfE07s40OLV50++l1Bbw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@algolia/client-common": "5.47.0"
|
||||
"@algolia/client-common": "5.48.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@algolia/requester-node-http": {
|
||||
"version": "5.47.0",
|
||||
"resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.47.0.tgz",
|
||||
"integrity": "sha512-z1oyLq5/UVkohVXNDEY70mJbT/sv/t6HYtCvCwNrOri6pxBJDomP9R83KOlwcat+xqBQEdJHjbrPh36f1avmZA==",
|
||||
"version": "5.48.0",
|
||||
"resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.48.0.tgz",
|
||||
"integrity": "sha512-ZgxV2+5qt3NLeUYBTsi6PLyHcENQWC0iFppFZekHSEDA2wcLdTUjnaJzimTEULHIvJuLRCkUs4JABdhuJktEag==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@algolia/client-common": "5.47.0"
|
||||
"@algolia/client-common": "5.48.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.0.0"
|
||||
|
|
@ -323,13 +323,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@babel/parser": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz",
|
||||
"integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==",
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz",
|
||||
"integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.28.6"
|
||||
"@babel/types": "^7.29.0"
|
||||
},
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
|
|
@ -339,9 +339,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@babel/types": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz",
|
||||
"integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==",
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
|
||||
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -425,10 +425,17 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@docsearch/js/node_modules/@types/react": {
|
||||
<<<<<<< Gnonymous/feature/chat-font-size
|
||||
"version": "18.3.27",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
|
||||
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
|
||||
"dev": true,
|
||||
=======
|
||||
"version": "18.3.28",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
|
||||
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
|
||||
"extraneous": true,
|
||||
>>>>>>> dev
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
|
|
@ -1131,9 +1138,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@iconify-json/simple-icons": {
|
||||
"version": "1.2.68",
|
||||
"resolved": "https://registry.npmjs.org/@iconify-json/simple-icons/-/simple-icons-1.2.68.tgz",
|
||||
"integrity": "sha512-bQPl1zuZlX6AnofreA1v7J+hoPncrFMppqGboe/SH54jZO37meiBUGBqNOxEpc0HKfZGxJaVVJwZd4gdMYu3hw==",
|
||||
"version": "1.2.69",
|
||||
"resolved": "https://registry.npmjs.org/@iconify-json/simple-icons/-/simple-icons-1.2.69.tgz",
|
||||
"integrity": "sha512-T/rhy5n7pzE0ZOxQVlF68SNPCYYjRBpddjgjrJO5WWVRG8es5BQmvxIE9kKF+t2hhPGvuGQFpXmUyqbOtnxirQ==",
|
||||
"dev": true,
|
||||
"license": "CC0-1.0",
|
||||
"dependencies": {
|
||||
|
|
@ -1759,9 +1766,9 @@
|
|||
"peer": true
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.10.tgz",
|
||||
"integrity": "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==",
|
||||
"version": "19.2.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.13.tgz",
|
||||
"integrity": "sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -2351,26 +2358,26 @@
|
|||
}
|
||||
},
|
||||
"node_modules/algoliasearch": {
|
||||
"version": "5.47.0",
|
||||
"resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.47.0.tgz",
|
||||
"integrity": "sha512-AGtz2U7zOV4DlsuYV84tLp2tBbA7RPtLA44jbVH4TTpDcc1dIWmULjHSsunlhscbzDydnjuFlNhflR3nV4VJaQ==",
|
||||
"version": "5.48.0",
|
||||
"resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.48.0.tgz",
|
||||
"integrity": "sha512-aD8EQC6KEman6/S79FtPdQmB7D4af/etcRL/KwiKFKgAE62iU8c5PeEQvpvIcBPurC3O/4Lj78nOl7ZcoazqSw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@algolia/abtesting": "1.13.0",
|
||||
"@algolia/client-abtesting": "5.47.0",
|
||||
"@algolia/client-analytics": "5.47.0",
|
||||
"@algolia/client-common": "5.47.0",
|
||||
"@algolia/client-insights": "5.47.0",
|
||||
"@algolia/client-personalization": "5.47.0",
|
||||
"@algolia/client-query-suggestions": "5.47.0",
|
||||
"@algolia/client-search": "5.47.0",
|
||||
"@algolia/ingestion": "1.47.0",
|
||||
"@algolia/monitoring": "1.47.0",
|
||||
"@algolia/recommend": "5.47.0",
|
||||
"@algolia/requester-browser-xhr": "5.47.0",
|
||||
"@algolia/requester-fetch": "5.47.0",
|
||||
"@algolia/requester-node-http": "5.47.0"
|
||||
"@algolia/abtesting": "1.14.0",
|
||||
"@algolia/client-abtesting": "5.48.0",
|
||||
"@algolia/client-analytics": "5.48.0",
|
||||
"@algolia/client-common": "5.48.0",
|
||||
"@algolia/client-insights": "5.48.0",
|
||||
"@algolia/client-personalization": "5.48.0",
|
||||
"@algolia/client-query-suggestions": "5.48.0",
|
||||
"@algolia/client-search": "5.48.0",
|
||||
"@algolia/ingestion": "1.48.0",
|
||||
"@algolia/monitoring": "1.48.0",
|
||||
"@algolia/recommend": "5.48.0",
|
||||
"@algolia/requester-browser-xhr": "5.48.0",
|
||||
"@algolia/requester-fetch": "5.48.0",
|
||||
"@algolia/requester-node-http": "5.48.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.0.0"
|
||||
|
|
@ -2998,14 +3005,14 @@
|
|||
}
|
||||
},
|
||||
"node_modules/enhanced-resolve": {
|
||||
"version": "5.18.4",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz",
|
||||
"integrity": "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==",
|
||||
"version": "5.19.0",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz",
|
||||
"integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.4",
|
||||
"tapable": "^2.2.0"
|
||||
"tapable": "^2.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
|
|
@ -4173,9 +4180,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/get-tsconfig": {
|
||||
"version": "4.13.1",
|
||||
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.1.tgz",
|
||||
"integrity": "sha512-EoY1N2xCn44xU6750Sx7OjOIT59FkmstNc3X6y5xpz7D5cBtZRe/3pSlTkDJgqsOk3WwZPkWfonhhUJfttQo3w==",
|
||||
"version": "4.13.6",
|
||||
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz",
|
||||
"integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -5636,9 +5643,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/preact": {
|
||||
"version": "10.28.2",
|
||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.28.2.tgz",
|
||||
"integrity": "sha512-lbteaWGzGHdlIuiJ0l2Jq454m6kcpI1zNje6d8MlGAFlYvP2GO4ibnat7P74Esfz4sPTdM6UxtTwh/d3pwM9JA==",
|
||||
"version": "10.28.3",
|
||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.28.3.tgz",
|
||||
"integrity": "sha512-tCmoRkPQLpBeWzpmbhryairGnhW9tKV6c6gr/w+RhoRoKEJwsjzipwp//1oCpGPOchvSLaAPlpcJi9MwMmoPyA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
|
|
@ -6028,9 +6035,9 @@
|
|||
"peer": true
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.3",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
||||
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
|
|
@ -7250,10 +7257,17 @@
|
|||
}
|
||||
},
|
||||
"node_modules/vitepress/node_modules/@types/node": {
|
||||
<<<<<<< Gnonymous/feature/chat-font-size
|
||||
"version": "25.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.1.0.tgz",
|
||||
"integrity": "sha512-t7frlewr6+cbx+9Ohpl0NOTKXZNV9xHRmNOvql47BFJKcEG1CxtxlPEEe+gR9uhVWM4DwhnvTF110mIL4yP9RA==",
|
||||
"dev": true,
|
||||
=======
|
||||
"version": "25.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.1.tgz",
|
||||
"integrity": "sha512-CPrnr8voK8vC6eEtyRzvMpgp3VyVRhgclonE7qYi6P9sXwYb59ucfrnmFBTaP0yUi8Gk4yZg/LlTJULGxvTNsg==",
|
||||
"extraneous": true,
|
||||
>>>>>>> dev
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "obsidian-agent-client",
|
||||
"version": "0.7.5",
|
||||
"version": "0.8.0-preview.1",
|
||||
"description": "Use AI coding agents via the Agent Client Protocol directly inside Obsidian",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ const { useRef, useState, useEffect, useCallback } = React;
|
|||
import { setIcon, DropdownComponent, Notice } from "obsidian";
|
||||
|
||||
import type AgentClientPlugin from "../../plugin";
|
||||
import type { ChatView } from "./ChatView";
|
||||
import type { IChatViewHost } from "./types";
|
||||
import type { NoteMetadata } from "../../domain/ports/vault-access.port";
|
||||
import type {
|
||||
SlashCommand,
|
||||
|
|
@ -14,9 +14,11 @@ import type { ImagePromptContent } from "../../domain/models/prompt-content";
|
|||
import type { UseMentionsReturn } from "../../hooks/useMentions";
|
||||
import type { UseSlashCommandsReturn } from "../../hooks/useSlashCommands";
|
||||
import type { UseAutoMentionReturn } from "../../hooks/useAutoMention";
|
||||
import type { ChatMessage } from "../../domain/models/chat-message";
|
||||
import { SuggestionDropdown } from "./SuggestionDropdown";
|
||||
import { ErrorOverlay } from "./ErrorOverlay";
|
||||
import { ImagePreviewStrip, type AttachedImage } from "./ImagePreviewStrip";
|
||||
import { useInputHistory } from "../../hooks/useInputHistory";
|
||||
import { getLogger } from "../../shared/logger";
|
||||
import type { ErrorInfo } from "../../domain/models/agent-error";
|
||||
import { useSettings } from "../../hooks/useSettings";
|
||||
|
|
@ -71,7 +73,7 @@ export interface ChatInputProps {
|
|||
/** Plugin instance */
|
||||
plugin: AgentClientPlugin;
|
||||
/** View instance for event registration */
|
||||
view: ChatView;
|
||||
view: IChatViewHost;
|
||||
/** Callback to send a message with optional images */
|
||||
onSendMessage: (
|
||||
content: string,
|
||||
|
|
@ -106,6 +108,8 @@ export interface ChatInputProps {
|
|||
errorInfo: ErrorInfo | null;
|
||||
/** Callback to clear the error */
|
||||
onClearError: () => void;
|
||||
/** Messages array for input history navigation */
|
||||
messages: ChatMessage[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -150,6 +154,8 @@ export function ChatInput({
|
|||
// Error overlay props
|
||||
errorInfo,
|
||||
onClearError,
|
||||
// Input history
|
||||
messages,
|
||||
}: ChatInputProps) {
|
||||
const logger = getLogger();
|
||||
const settings = useSettings(plugin);
|
||||
|
|
@ -167,6 +173,12 @@ export function ChatInput({
|
|||
const [commandText, setCommandText] = useState<string>("");
|
||||
const [isDraggingOver, setIsDraggingOver] = useState(false);
|
||||
|
||||
// Input history navigation (ArrowUp/ArrowDown)
|
||||
const { handleHistoryKeyDown, resetHistory } = useInputHistory(
|
||||
messages,
|
||||
onInputChange,
|
||||
);
|
||||
|
||||
// Refs
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const dragCounterRef = useRef(0);
|
||||
|
|
@ -534,6 +546,7 @@ export function ChatInput({
|
|||
onAttachedImagesChange([]);
|
||||
setHintText(null);
|
||||
setCommandText("");
|
||||
resetHistory();
|
||||
|
||||
await onSendMessage(
|
||||
messageToSend,
|
||||
|
|
@ -547,6 +560,7 @@ export function ChatInput({
|
|||
onStopGeneration,
|
||||
onInputChange,
|
||||
onAttachedImagesChange,
|
||||
resetHistory,
|
||||
]);
|
||||
|
||||
/**
|
||||
|
|
@ -638,6 +652,11 @@ export function ChatInput({
|
|||
return;
|
||||
}
|
||||
|
||||
// Handle input history navigation (ArrowUp/ArrowDown)
|
||||
if (handleHistoryKeyDown(e, textareaRef.current)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal input handling - check if should send based on shortcut setting
|
||||
if (e.key === "Enter" && !e.nativeEvent.isComposing) {
|
||||
const shouldSend =
|
||||
|
|
@ -656,6 +675,7 @@ export function ChatInput({
|
|||
},
|
||||
[
|
||||
handleDropdownKeyPress,
|
||||
handleHistoryKeyDown,
|
||||
isSending,
|
||||
isButtonDisabled,
|
||||
handleSendOrStop,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ const { useRef, useState, useEffect, useCallback } = React;
|
|||
import type { ChatMessage } from "../../domain/models/chat-message";
|
||||
import type { IAcpClient } from "../../adapters/acp/acp.adapter";
|
||||
import type AgentClientPlugin from "../../plugin";
|
||||
import type { ChatView } from "./ChatView";
|
||||
import type { IChatViewHost } from "./types";
|
||||
import { MessageRenderer } from "./MessageRenderer";
|
||||
|
||||
/**
|
||||
|
|
@ -24,7 +24,7 @@ export interface ChatMessagesProps {
|
|||
/** Plugin instance */
|
||||
plugin: AgentClientPlugin;
|
||||
/** View instance for event registration */
|
||||
view: ChatView;
|
||||
view: IChatViewHost;
|
||||
/** ACP client for terminal operations */
|
||||
acpClient?: IAcpClient;
|
||||
/** Callback to approve a permission request */
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -2,7 +2,7 @@ import * as React from "react";
|
|||
const { useRef, useEffect } = React;
|
||||
import { setIcon } from "obsidian";
|
||||
import type { ErrorInfo } from "../../domain/models/agent-error";
|
||||
import type { ChatView } from "./ChatView";
|
||||
import type { IChatViewHost } from "./types";
|
||||
|
||||
export interface ErrorOverlayProps {
|
||||
/** Error information to display */
|
||||
|
|
@ -12,7 +12,7 @@ export interface ErrorOverlayProps {
|
|||
/** Whether to show emojis */
|
||||
showEmojis: boolean;
|
||||
/** View instance for event registration */
|
||||
view: ChatView;
|
||||
view: IChatViewHost;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
198
src/components/chat/FloatingButton.tsx
Normal file
198
src/components/chat/FloatingButton.tsx
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
import * as React from "react";
|
||||
const { useState, useRef, useEffect, useCallback, useMemo } = React;
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
|
||||
import { setIcon } from "obsidian";
|
||||
import type AgentClientPlugin from "../../plugin";
|
||||
import { useSettings } from "../../hooks/useSettings";
|
||||
|
||||
interface VaultAdapterWithResourcePath {
|
||||
getResourcePath?: (path: string) => string;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// FloatingButtonContainer Class
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Container that manages the floating button React component lifecycle.
|
||||
* Independent from any floating chat view instance.
|
||||
*/
|
||||
export class FloatingButtonContainer {
|
||||
private root: Root | null = null;
|
||||
private containerEl: HTMLElement;
|
||||
|
||||
constructor(private plugin: AgentClientPlugin) {
|
||||
this.containerEl = document.body.createDiv({
|
||||
cls: "agent-client-floating-button-root",
|
||||
});
|
||||
}
|
||||
|
||||
mount(): void {
|
||||
this.root = createRoot(this.containerEl);
|
||||
this.root.render(<FloatingButtonComponent plugin={this.plugin} />);
|
||||
}
|
||||
|
||||
unmount(): void {
|
||||
if (this.root) {
|
||||
this.root.unmount();
|
||||
this.root = null;
|
||||
}
|
||||
this.containerEl.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// FloatingButtonComponent
|
||||
// ============================================================
|
||||
|
||||
interface FloatingButtonProps {
|
||||
plugin: AgentClientPlugin;
|
||||
}
|
||||
|
||||
function FloatingButtonComponent({ plugin }: FloatingButtonProps) {
|
||||
const settings = useSettings(plugin);
|
||||
|
||||
const [showInstanceMenu, setShowInstanceMenu] = useState(false);
|
||||
const instanceMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Floating button image source
|
||||
const floatingButtonImageSrc = useMemo(() => {
|
||||
const img = settings.floatingButtonImage;
|
||||
if (!img) return null;
|
||||
if (
|
||||
img.startsWith("http://") ||
|
||||
img.startsWith("https://") ||
|
||||
img.startsWith("data:")
|
||||
) {
|
||||
return img;
|
||||
}
|
||||
return (
|
||||
plugin.app.vault.adapter as VaultAdapterWithResourcePath
|
||||
).getResourcePath?.(img);
|
||||
}, [settings.floatingButtonImage, plugin.app.vault.adapter]);
|
||||
|
||||
// Build display labels with duplicate numbering
|
||||
const allInstances = plugin.getFloatingChatInstances();
|
||||
|
||||
const instanceLabels = useMemo(() => {
|
||||
const views = plugin.viewRegistry.getByType("floating");
|
||||
const entries = views.map((v) => ({
|
||||
viewId: v.viewId,
|
||||
label: v.getDisplayName(),
|
||||
}));
|
||||
const countMap = new Map<string, number>();
|
||||
for (const e of entries) {
|
||||
countMap.set(e.label, (countMap.get(e.label) ?? 0) + 1);
|
||||
}
|
||||
const indexMap = new Map<string, number>();
|
||||
return entries.map((e) => {
|
||||
if ((countMap.get(e.label) ?? 0) > 1) {
|
||||
const idx = (indexMap.get(e.label) ?? 0) + 1;
|
||||
indexMap.set(e.label, idx);
|
||||
return {
|
||||
viewId: e.viewId,
|
||||
label: idx === 1 ? e.label : `${e.label} ${idx}`,
|
||||
};
|
||||
}
|
||||
return e;
|
||||
});
|
||||
}, [plugin.viewRegistry, allInstances]);
|
||||
|
||||
// Button click handler
|
||||
const handleButtonClick = useCallback(() => {
|
||||
const instances = plugin.getFloatingChatInstances();
|
||||
if (instances.length === 0) {
|
||||
// No instances, create one and expand
|
||||
plugin.openNewFloatingChat(true);
|
||||
} else if (instances.length === 1) {
|
||||
// Single instance, just expand
|
||||
plugin.expandFloatingChat(instances[0]);
|
||||
} else {
|
||||
// Multiple instances, show menu
|
||||
setShowInstanceMenu(true);
|
||||
}
|
||||
}, [plugin]);
|
||||
|
||||
// Close instance menu on outside click
|
||||
useEffect(() => {
|
||||
if (!showInstanceMenu) return;
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
instanceMenuRef.current &&
|
||||
!instanceMenuRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setShowInstanceMenu(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, [showInstanceMenu]);
|
||||
|
||||
if (!settings.showFloatingButton) return null;
|
||||
|
||||
const buttonClassName = floatingButtonImageSrc
|
||||
? "agent-client-floating-button has-custom-image"
|
||||
: "agent-client-floating-button";
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={buttonClassName} onClick={handleButtonClick}>
|
||||
{floatingButtonImageSrc ? (
|
||||
<img src={floatingButtonImageSrc} alt="Open chat" />
|
||||
) : (
|
||||
<div
|
||||
className="agent-client-floating-button-fallback"
|
||||
ref={(el) => {
|
||||
if (el) setIcon(el, "bot-message-square");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{showInstanceMenu && (
|
||||
<div
|
||||
ref={instanceMenuRef}
|
||||
className="agent-client-floating-instance-menu"
|
||||
>
|
||||
<div className="agent-client-floating-instance-menu-header">
|
||||
Select session to open
|
||||
</div>
|
||||
{instanceLabels.map(({ viewId: id, label }) => (
|
||||
<div
|
||||
key={id}
|
||||
className="agent-client-floating-instance-menu-item"
|
||||
onClick={() => {
|
||||
plugin.expandFloatingChat(id);
|
||||
plugin.viewRegistry.setFocused(id);
|
||||
setShowInstanceMenu(false);
|
||||
}}
|
||||
>
|
||||
<span className="agent-client-floating-instance-menu-label">
|
||||
{label}
|
||||
</span>
|
||||
{instanceLabels.length > 1 && (
|
||||
<button
|
||||
className="agent-client-floating-instance-menu-close"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
plugin.closeFloatingChat(id);
|
||||
if (instanceLabels.length <= 2) {
|
||||
setShowInstanceMenu(false);
|
||||
}
|
||||
}}
|
||||
title="Close session"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
805
src/components/chat/FloatingChatView.tsx
Normal file
805
src/components/chat/FloatingChatView.tsx
Normal file
|
|
@ -0,0 +1,805 @@
|
|||
import * as React from "react";
|
||||
const { useState, useRef, useEffect, useCallback, useMemo } = React;
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { Notice } from "obsidian";
|
||||
|
||||
import type AgentClientPlugin from "../../plugin";
|
||||
import type {
|
||||
IChatViewContainer,
|
||||
ChatViewType,
|
||||
} from "../../domain/ports/chat-view-container.port";
|
||||
import type { ChatInputState } from "../../domain/models/chat-input-state";
|
||||
import type { IChatViewHost } from "./types";
|
||||
import type { ImagePromptContent } from "../../domain/models/prompt-content";
|
||||
|
||||
// Component imports
|
||||
import { ChatMessages } from "./ChatMessages";
|
||||
import { ChatInput } from "./ChatInput";
|
||||
import { InlineHeader } from "./InlineHeader";
|
||||
|
||||
// Hooks imports
|
||||
import { useChatController } from "../../hooks/useChatController";
|
||||
|
||||
import { clampPosition } from "../../shared/floating-utils";
|
||||
|
||||
// ============================================================
|
||||
// Type Definitions
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Callbacks for FloatingViewContainer to access React component state.
|
||||
* These enable the container to implement IChatViewContainer by delegating
|
||||
* to the React component's state and handlers.
|
||||
*/
|
||||
interface FloatingViewCallbacks {
|
||||
getDisplayName: () => string;
|
||||
getInputState: () => ChatInputState | null;
|
||||
setInputState: (state: ChatInputState) => void;
|
||||
canSend: () => boolean;
|
||||
sendMessage: () => Promise<boolean>;
|
||||
cancelOperation: () => Promise<void>;
|
||||
focus: () => void;
|
||||
hasFocus: () => boolean;
|
||||
expand: () => void;
|
||||
collapse: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracked listener for cleanup on unmount.
|
||||
* Uses union type to match IChatViewHost's overloaded registerDomEvent signatures.
|
||||
*
|
||||
* Note: options is not tracked because current usage sites do not use options.
|
||||
* If future code uses options (e.g., { capture: true }), the cleanup will not
|
||||
* work correctly. This is acceptable as current usage does not require options.
|
||||
*/
|
||||
interface RegisteredListener {
|
||||
target: Window | Document | HTMLElement;
|
||||
type: string;
|
||||
callback: EventListenerOrEventListenerObject;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// FloatingViewContainer Class
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Wrapper class that implements IChatViewContainer for floating chat views.
|
||||
* Manages the React component lifecycle and provides the interface for
|
||||
* unified view management via ChatViewRegistry.
|
||||
*/
|
||||
export class FloatingViewContainer implements IChatViewContainer {
|
||||
readonly viewType: ChatViewType = "floating";
|
||||
readonly viewId: string;
|
||||
|
||||
private plugin: AgentClientPlugin;
|
||||
private root: Root | null = null;
|
||||
private containerEl: HTMLElement;
|
||||
private callbacks: FloatingViewCallbacks | null = null;
|
||||
|
||||
constructor(plugin: AgentClientPlugin, instanceId: string) {
|
||||
this.plugin = plugin;
|
||||
// viewId format: "floating-chat-{instanceId}" to match useChatController's adapter key
|
||||
this.viewId = `floating-chat-${instanceId}`;
|
||||
this.containerEl = document.body.createDiv({
|
||||
cls: "agent-client-floating-view-root",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount the React component and register with the plugin.
|
||||
*/
|
||||
mount(
|
||||
initialExpanded: boolean,
|
||||
initialPosition?: { x: number; y: number },
|
||||
): void {
|
||||
this.root = createRoot(this.containerEl);
|
||||
this.root.render(
|
||||
<FloatingChatComponent
|
||||
plugin={this.plugin}
|
||||
viewId={this.viewId}
|
||||
initialExpanded={initialExpanded}
|
||||
initialPosition={initialPosition}
|
||||
onRegisterCallbacks={(cbs) => {
|
||||
this.callbacks = cbs;
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Register with plugin's view registry
|
||||
this.plugin.viewRegistry.register(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unmount the React component and unregister from the plugin.
|
||||
*/
|
||||
unmount(): void {
|
||||
this.plugin.viewRegistry.unregister(this.viewId);
|
||||
|
||||
if (this.root) {
|
||||
this.root.unmount();
|
||||
this.root = null;
|
||||
}
|
||||
this.containerEl.remove();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// IChatViewContainer Implementation
|
||||
// ============================================================
|
||||
|
||||
getDisplayName(): string {
|
||||
return this.callbacks?.getDisplayName() ?? "Chat";
|
||||
}
|
||||
|
||||
onActivate(): void {
|
||||
this.containerEl.classList.add("is-focused");
|
||||
}
|
||||
|
||||
onDeactivate(): void {
|
||||
this.containerEl.classList.remove("is-focused");
|
||||
}
|
||||
|
||||
focus(): void {
|
||||
// Expand if collapsed, then focus
|
||||
this.callbacks?.focus();
|
||||
}
|
||||
|
||||
hasFocus(): boolean {
|
||||
return this.callbacks?.hasFocus() ?? false;
|
||||
}
|
||||
|
||||
expand(): void {
|
||||
this.callbacks?.expand();
|
||||
}
|
||||
|
||||
collapse(): void {
|
||||
this.callbacks?.collapse();
|
||||
}
|
||||
|
||||
getInputState(): ChatInputState | null {
|
||||
return this.callbacks?.getInputState() ?? null;
|
||||
}
|
||||
|
||||
setInputState(state: ChatInputState): void {
|
||||
this.callbacks?.setInputState(state);
|
||||
}
|
||||
|
||||
canSend(): boolean {
|
||||
return this.callbacks?.canSend() ?? false;
|
||||
}
|
||||
|
||||
async sendMessage(): Promise<boolean> {
|
||||
return (await this.callbacks?.sendMessage()) ?? false;
|
||||
}
|
||||
|
||||
async cancelOperation(): Promise<void> {
|
||||
await this.callbacks?.cancelOperation();
|
||||
}
|
||||
|
||||
getContainerEl(): HTMLElement {
|
||||
return this.containerEl;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// FloatingChatComponent
|
||||
// ============================================================
|
||||
|
||||
interface FloatingChatComponentProps {
|
||||
plugin: AgentClientPlugin;
|
||||
viewId: string; // Full viewId passed from FloatingViewContainer
|
||||
initialExpanded?: boolean;
|
||||
initialPosition?: { x: number; y: number };
|
||||
onRegisterCallbacks?: (callbacks: FloatingViewCallbacks) => void;
|
||||
}
|
||||
|
||||
function FloatingChatComponent({
|
||||
plugin,
|
||||
viewId,
|
||||
initialExpanded = false,
|
||||
initialPosition,
|
||||
onRegisterCallbacks,
|
||||
}: FloatingChatComponentProps) {
|
||||
// ============================================================
|
||||
// Chat Controller Hook (Centralized Logic)
|
||||
// ============================================================
|
||||
const controller = useChatController({
|
||||
plugin,
|
||||
viewId, // Use viewId prop directly (already in "floating-chat-{id}" format)
|
||||
workingDirectory: undefined, // Let hook determine from vault
|
||||
});
|
||||
|
||||
const {
|
||||
acpAdapter,
|
||||
settings,
|
||||
session,
|
||||
isSessionReady,
|
||||
messages,
|
||||
isSending,
|
||||
isUpdateAvailable,
|
||||
permission,
|
||||
mentions,
|
||||
autoMention,
|
||||
slashCommands,
|
||||
sessionHistory,
|
||||
activeAgentLabel,
|
||||
availableAgents,
|
||||
errorInfo,
|
||||
handleSendMessage,
|
||||
handleStopGeneration,
|
||||
handleNewChat,
|
||||
handleExportChat,
|
||||
handleSwitchAgent,
|
||||
handleRestartAgent,
|
||||
handleClearError,
|
||||
handleOpenHistory,
|
||||
handleSetMode,
|
||||
handleSetModel,
|
||||
inputValue,
|
||||
setInputValue,
|
||||
attachedImages,
|
||||
setAttachedImages,
|
||||
restoredMessage,
|
||||
handleRestoredMessageConsumed,
|
||||
} = controller;
|
||||
|
||||
// ============================================================
|
||||
// UI State (View-Specific)
|
||||
// ============================================================
|
||||
const [isExpanded, setIsExpanded] = useState(initialExpanded);
|
||||
const [size, setSize] = useState(settings.floatingWindowSize);
|
||||
const [position, setPosition] = useState(() => {
|
||||
if (initialPosition) return initialPosition;
|
||||
if (settings.floatingWindowPosition)
|
||||
return settings.floatingWindowPosition;
|
||||
return {
|
||||
x: window.innerWidth - settings.floatingWindowSize.width - 50,
|
||||
y: window.innerHeight - settings.floatingWindowSize.height - 50,
|
||||
};
|
||||
});
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const dragOffset = useRef({ x: 0, y: 0 });
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const acpClientRef = useRef(acpAdapter);
|
||||
|
||||
// Track registered listeners for cleanup
|
||||
const registeredListenersRef = useRef<RegisteredListener[]>([]);
|
||||
|
||||
// IChatViewHost implementation with listener tracking
|
||||
// Type assertion is used because the implementation handles all overload cases uniformly
|
||||
const viewHost: IChatViewHost = useMemo(
|
||||
() => ({
|
||||
app: plugin.app,
|
||||
registerDomEvent: ((
|
||||
target: Window | Document | HTMLElement,
|
||||
type: string,
|
||||
callback: EventListenerOrEventListenerObject,
|
||||
) => {
|
||||
target.addEventListener(type, callback);
|
||||
// Track for cleanup on unmount
|
||||
// Note: options is not tracked (see RegisteredListener comment)
|
||||
registeredListenersRef.current.push({ target, type, callback });
|
||||
}) as IChatViewHost["registerDomEvent"],
|
||||
}),
|
||||
[plugin.app],
|
||||
);
|
||||
|
||||
// Cleanup registered listeners on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
for (const {
|
||||
target,
|
||||
type,
|
||||
callback,
|
||||
} of registeredListenersRef.current) {
|
||||
target.removeEventListener(type, callback);
|
||||
}
|
||||
registeredListenersRef.current = [];
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Handlers for window management
|
||||
const handleOpenNewFloatingChat = useCallback(() => {
|
||||
// Open new window with 30px offset from current position, clamped to viewport
|
||||
plugin.openNewFloatingChat(
|
||||
true,
|
||||
clampPosition(
|
||||
position.x - 30,
|
||||
position.y - 30,
|
||||
size.width,
|
||||
size.height,
|
||||
),
|
||||
);
|
||||
}, [plugin, position, size.width, size.height]);
|
||||
|
||||
const handleCloseWindow = useCallback(() => {
|
||||
setIsExpanded(false);
|
||||
}, []);
|
||||
|
||||
// Listen for expand requests
|
||||
useEffect(() => {
|
||||
const handleExpandRequest = (
|
||||
event: CustomEvent<{ viewId: string }>,
|
||||
) => {
|
||||
if (event.detail.viewId === viewId) {
|
||||
setIsExpanded(true);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener(
|
||||
"agent-client:expand-floating-chat" as never,
|
||||
handleExpandRequest as EventListener,
|
||||
);
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
"agent-client:expand-floating-chat" as never,
|
||||
handleExpandRequest as EventListener,
|
||||
);
|
||||
};
|
||||
}, [viewId]);
|
||||
|
||||
// Sync manual resizing with state
|
||||
useEffect(() => {
|
||||
if (!isExpanded || !containerRef.current) return;
|
||||
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const { width, height } = entry.contentRect;
|
||||
// Only update if significantly different to avoid loops
|
||||
if (
|
||||
Math.abs(width - size.width) > 5 ||
|
||||
Math.abs(height - size.height) > 5
|
||||
) {
|
||||
setSize({ width, height });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(containerRef.current);
|
||||
return () => observer.disconnect();
|
||||
}, [isExpanded, size.width, size.height]);
|
||||
|
||||
// Save size to settings
|
||||
useEffect(() => {
|
||||
const saveSize = async () => {
|
||||
if (
|
||||
size.width !== settings.floatingWindowSize.width ||
|
||||
size.height !== settings.floatingWindowSize.height
|
||||
) {
|
||||
await plugin.saveSettingsAndNotify({
|
||||
...plugin.settings,
|
||||
floatingWindowSize: size,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
void saveSize();
|
||||
}, 500); // Debounce save
|
||||
return () => clearTimeout(timer);
|
||||
}, [size, plugin, settings.floatingWindowSize]);
|
||||
|
||||
// Save position to settings
|
||||
useEffect(() => {
|
||||
const savePosition = async () => {
|
||||
if (
|
||||
!settings.floatingWindowPosition ||
|
||||
position.x !== settings.floatingWindowPosition.x ||
|
||||
position.y !== settings.floatingWindowPosition.y
|
||||
) {
|
||||
await plugin.saveSettingsAndNotify({
|
||||
...plugin.settings,
|
||||
floatingWindowPosition: position,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
void savePosition();
|
||||
}, 500); // Debounce save
|
||||
return () => clearTimeout(timer);
|
||||
}, [position, plugin, settings.floatingWindowPosition]);
|
||||
|
||||
// ============================================================
|
||||
// Dragging Logic (View-Specific)
|
||||
// ============================================================
|
||||
const onMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (!containerRef.current) return;
|
||||
setIsDragging(true);
|
||||
dragOffset.current = {
|
||||
x: e.clientX - position.x,
|
||||
y: e.clientY - position.y,
|
||||
};
|
||||
},
|
||||
[position],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
if (!isDragging) return;
|
||||
setPosition(
|
||||
clampPosition(
|
||||
e.clientX - dragOffset.current.x,
|
||||
e.clientY - dragOffset.current.y,
|
||||
size.width,
|
||||
size.height,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
if (isDragging) {
|
||||
window.addEventListener("mousemove", onMouseMove);
|
||||
window.addEventListener("mouseup", onMouseUp);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", onMouseMove);
|
||||
window.removeEventListener("mouseup", onMouseUp);
|
||||
};
|
||||
}, [isDragging, size.width, size.height]);
|
||||
|
||||
// ============================================================
|
||||
// Callback Registration for IChatViewContainer
|
||||
// ============================================================
|
||||
// Register callbacks for FloatingViewContainer to access React component state
|
||||
// These must match ChatView's sendMessageForBroadcast/canSendForBroadcast functionality
|
||||
useEffect(() => {
|
||||
if (onRegisterCallbacks) {
|
||||
onRegisterCallbacks({
|
||||
getDisplayName: () => activeAgentLabel,
|
||||
getInputState: () => ({
|
||||
text: inputValue,
|
||||
images: attachedImages,
|
||||
}),
|
||||
setInputState: (state) => {
|
||||
setInputValue(state.text);
|
||||
setAttachedImages(state.images);
|
||||
},
|
||||
// Match ChatView.canSendForBroadcast exactly
|
||||
canSend: () => {
|
||||
const hasContent =
|
||||
inputValue.trim() !== "" || attachedImages.length > 0;
|
||||
return (
|
||||
hasContent &&
|
||||
isSessionReady &&
|
||||
!sessionHistory.loading &&
|
||||
!isSending
|
||||
);
|
||||
},
|
||||
// Match ChatView.sendMessageForBroadcast exactly
|
||||
sendMessage: async () => {
|
||||
// Allow sending if there's text OR images
|
||||
if (!inputValue.trim() && attachedImages.length === 0) {
|
||||
return false;
|
||||
}
|
||||
if (!isSessionReady || sessionHistory.loading) {
|
||||
return false;
|
||||
}
|
||||
if (isSending) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Convert attached images to ImagePromptContent format
|
||||
const imagesToSend: ImagePromptContent[] =
|
||||
attachedImages.map((img) => ({
|
||||
type: "image",
|
||||
data: img.data,
|
||||
mimeType: img.mimeType,
|
||||
}));
|
||||
|
||||
// Clear input before sending
|
||||
const messageToSend = inputValue.trim();
|
||||
setInputValue("");
|
||||
setAttachedImages([]);
|
||||
|
||||
await handleSendMessage(
|
||||
messageToSend,
|
||||
imagesToSend.length > 0 ? imagesToSend : undefined,
|
||||
);
|
||||
return true;
|
||||
},
|
||||
cancelOperation: handleStopGeneration,
|
||||
// Focus with auto-expand
|
||||
focus: () => {
|
||||
// Expand if collapsed
|
||||
if (!isExpanded) {
|
||||
setIsExpanded(true);
|
||||
}
|
||||
// Focus after next render (expansion may need a frame)
|
||||
requestAnimationFrame(() => {
|
||||
const textarea = containerRef.current?.querySelector(
|
||||
"textarea.agent-client-chat-input-textarea",
|
||||
);
|
||||
if (textarea instanceof HTMLTextAreaElement) {
|
||||
textarea.focus();
|
||||
}
|
||||
});
|
||||
},
|
||||
hasFocus: () =>
|
||||
isExpanded &&
|
||||
(containerRef.current?.contains(document.activeElement) ??
|
||||
false),
|
||||
expand: () => {
|
||||
setIsExpanded(true);
|
||||
},
|
||||
collapse: () => {
|
||||
setIsExpanded(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [
|
||||
onRegisterCallbacks,
|
||||
activeAgentLabel,
|
||||
inputValue,
|
||||
attachedImages,
|
||||
isSessionReady,
|
||||
isSending,
|
||||
sessionHistory.loading,
|
||||
isExpanded,
|
||||
handleSendMessage,
|
||||
handleStopGeneration,
|
||||
]);
|
||||
|
||||
// ============================================================
|
||||
// Workspace Events (Hotkeys) - same as ChatView.ChatComponent
|
||||
// ============================================================
|
||||
|
||||
// 1. Toggle auto-mention
|
||||
useEffect(() => {
|
||||
const workspace = plugin.app.workspace;
|
||||
type CustomEventCallback = (targetViewId?: string) => void;
|
||||
|
||||
const eventRef = (
|
||||
workspace as unknown as {
|
||||
on: (
|
||||
name: string,
|
||||
callback: CustomEventCallback,
|
||||
) => ReturnType<typeof workspace.on>;
|
||||
}
|
||||
).on("agent-client:toggle-auto-mention", (targetViewId?: string) => {
|
||||
if (targetViewId && targetViewId !== viewId) return;
|
||||
autoMention.toggle();
|
||||
});
|
||||
|
||||
return () => {
|
||||
workspace.offref(eventRef);
|
||||
};
|
||||
}, [plugin.app.workspace, autoMention.toggle, viewId]);
|
||||
|
||||
// 2. New chat requested (from "New chat with [Agent]" command)
|
||||
useEffect(() => {
|
||||
const workspace = plugin.app.workspace;
|
||||
|
||||
const eventRef = (
|
||||
workspace as unknown as {
|
||||
on: (
|
||||
name: string,
|
||||
callback: (agentId?: string) => void,
|
||||
) => ReturnType<typeof workspace.on>;
|
||||
}
|
||||
).on("agent-client:new-chat-requested", (agentId?: string) => {
|
||||
// Only respond if we are the last active view
|
||||
if (
|
||||
plugin.lastActiveChatViewId &&
|
||||
plugin.lastActiveChatViewId !== viewId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
void handleNewChat(agentId);
|
||||
});
|
||||
|
||||
return () => {
|
||||
workspace.offref(eventRef);
|
||||
};
|
||||
}, [
|
||||
plugin.app.workspace,
|
||||
plugin.lastActiveChatViewId,
|
||||
handleNewChat,
|
||||
viewId,
|
||||
]);
|
||||
|
||||
// 3. Permission commands
|
||||
useEffect(() => {
|
||||
const workspace = plugin.app.workspace;
|
||||
type CustomEventCallback = (targetViewId?: string) => void;
|
||||
|
||||
const approveRef = (
|
||||
workspace as unknown as {
|
||||
on: (
|
||||
name: string,
|
||||
callback: CustomEventCallback,
|
||||
) => ReturnType<typeof workspace.on>;
|
||||
}
|
||||
).on(
|
||||
"agent-client:approve-active-permission",
|
||||
(targetViewId?: string) => {
|
||||
if (targetViewId && targetViewId !== viewId) return;
|
||||
void (async () => {
|
||||
const success = await permission.approveActivePermission();
|
||||
if (!success) {
|
||||
new Notice(
|
||||
"[Agent Client] No active permission request",
|
||||
);
|
||||
}
|
||||
})();
|
||||
},
|
||||
);
|
||||
|
||||
const rejectRef = (
|
||||
workspace as unknown as {
|
||||
on: (
|
||||
name: string,
|
||||
callback: CustomEventCallback,
|
||||
) => ReturnType<typeof workspace.on>;
|
||||
}
|
||||
).on(
|
||||
"agent-client:reject-active-permission",
|
||||
(targetViewId?: string) => {
|
||||
if (targetViewId && targetViewId !== viewId) return;
|
||||
void (async () => {
|
||||
const success = await permission.rejectActivePermission();
|
||||
if (!success) {
|
||||
new Notice(
|
||||
"[Agent Client] No active permission request",
|
||||
);
|
||||
}
|
||||
})();
|
||||
},
|
||||
);
|
||||
|
||||
const cancelRef = (
|
||||
workspace as unknown as {
|
||||
on: (
|
||||
name: string,
|
||||
callback: CustomEventCallback,
|
||||
) => ReturnType<typeof workspace.on>;
|
||||
}
|
||||
).on("agent-client:cancel-message", (targetViewId?: string) => {
|
||||
if (targetViewId && targetViewId !== viewId) return;
|
||||
void handleStopGeneration();
|
||||
});
|
||||
|
||||
return () => {
|
||||
workspace.offref(approveRef);
|
||||
workspace.offref(rejectRef);
|
||||
workspace.offref(cancelRef);
|
||||
};
|
||||
}, [
|
||||
plugin.app.workspace,
|
||||
permission.approveActivePermission,
|
||||
permission.rejectActivePermission,
|
||||
handleStopGeneration,
|
||||
viewId,
|
||||
]);
|
||||
|
||||
// ============================================================
|
||||
// Focus Tracking (same as ChatView)
|
||||
// ============================================================
|
||||
useEffect(() => {
|
||||
const handleFocus = () => {
|
||||
plugin.setLastActiveChatViewId(viewId);
|
||||
};
|
||||
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
container.addEventListener("focus", handleFocus, true);
|
||||
container.addEventListener("click", handleFocus);
|
||||
|
||||
// Set as active on mount
|
||||
plugin.setLastActiveChatViewId(viewId);
|
||||
|
||||
return () => {
|
||||
container.removeEventListener("focus", handleFocus, true);
|
||||
container.removeEventListener("click", handleFocus);
|
||||
};
|
||||
}, [plugin, viewId, isExpanded]);
|
||||
|
||||
// ============================================================
|
||||
// Render
|
||||
// ============================================================
|
||||
if (!isExpanded) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="agent-client-floating-window"
|
||||
style={{
|
||||
left: position.x,
|
||||
top: position.y,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="agent-client-floating-header"
|
||||
onMouseDown={onMouseDown}
|
||||
>
|
||||
<InlineHeader
|
||||
variant="floating"
|
||||
agentLabel={activeAgentLabel}
|
||||
availableAgents={availableAgents}
|
||||
currentAgentId={session.agentId}
|
||||
isUpdateAvailable={isUpdateAvailable}
|
||||
hasMessages={messages.length > 0}
|
||||
onAgentChange={(agentId) => void handleSwitchAgent(agentId)}
|
||||
onNewSession={() => void handleNewChat()}
|
||||
onOpenHistory={() => void handleOpenHistory()}
|
||||
onExportChat={() => void handleExportChat()}
|
||||
onRestartAgent={() => void handleRestartAgent()}
|
||||
onOpenNewWindow={handleOpenNewFloatingChat}
|
||||
onClose={handleCloseWindow}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="agent-client-floating-content">
|
||||
<div className="agent-client-floating-messages-container">
|
||||
<ChatMessages
|
||||
messages={messages}
|
||||
isSending={isSending}
|
||||
isSessionReady={isSessionReady}
|
||||
isRestoringSession={sessionHistory.loading}
|
||||
agentLabel={activeAgentLabel}
|
||||
plugin={plugin}
|
||||
view={viewHost}
|
||||
acpClient={acpClientRef.current}
|
||||
onApprovePermission={permission.approvePermission}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ChatInput
|
||||
isSending={isSending}
|
||||
isSessionReady={isSessionReady}
|
||||
isRestoringSession={sessionHistory.loading}
|
||||
agentLabel={activeAgentLabel}
|
||||
availableCommands={session.availableCommands || []}
|
||||
autoMentionEnabled={settings.autoMentionActiveNote}
|
||||
restoredMessage={restoredMessage}
|
||||
mentions={mentions}
|
||||
slashCommands={slashCommands}
|
||||
autoMention={autoMention}
|
||||
plugin={plugin}
|
||||
view={viewHost}
|
||||
onSendMessage={handleSendMessage}
|
||||
onStopGeneration={handleStopGeneration}
|
||||
onRestoredMessageConsumed={handleRestoredMessageConsumed}
|
||||
modes={session.modes}
|
||||
onModeChange={(modeId) => void handleSetMode(modeId)}
|
||||
models={session.models}
|
||||
onModelChange={(modelId) => void handleSetModel(modelId)}
|
||||
supportsImages={session.promptCapabilities?.image ?? false}
|
||||
agentId={session.agentId}
|
||||
inputValue={inputValue}
|
||||
onInputChange={setInputValue}
|
||||
attachedImages={attachedImages}
|
||||
onAttachedImagesChange={setAttachedImages}
|
||||
errorInfo={errorInfo}
|
||||
onClearError={handleClearError}
|
||||
messages={messages}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
/**
|
||||
* Create a new floating chat view.
|
||||
* @param plugin - The plugin instance
|
||||
* @param instanceId - The instance ID (e.g., "0", "1", "2")
|
||||
* @param initialExpanded - Whether to start expanded
|
||||
* @returns The FloatingViewContainer instance
|
||||
*/
|
||||
export function createFloatingChat(
|
||||
plugin: AgentClientPlugin,
|
||||
instanceId: string,
|
||||
initialExpanded = false,
|
||||
initialPosition?: { x: number; y: number },
|
||||
): FloatingViewContainer {
|
||||
const container = new FloatingViewContainer(plugin, instanceId);
|
||||
container.mount(initialExpanded, initialPosition);
|
||||
return container;
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ import * as ReactDOM from "react-dom";
|
|||
const { useRef, useEffect } = React;
|
||||
import { setIcon } from "obsidian";
|
||||
import type AgentClientPlugin from "../../plugin";
|
||||
import type { ChatView } from "./ChatView";
|
||||
import type { IChatViewHost } from "./types";
|
||||
|
||||
interface AgentInfo {
|
||||
id: string;
|
||||
|
|
@ -30,7 +30,7 @@ export interface HeaderMenuProps {
|
|||
/** Plugin instance */
|
||||
plugin: AgentClientPlugin;
|
||||
/** View instance for event registration */
|
||||
view: ChatView;
|
||||
view: IChatViewHost;
|
||||
}
|
||||
|
||||
export function HeaderMenu({
|
||||
|
|
|
|||
191
src/components/chat/InlineHeader.tsx
Normal file
191
src/components/chat/InlineHeader.tsx
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
import * as React from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { DropdownComponent, setIcon } from "obsidian";
|
||||
import { HeaderButton } from "./HeaderButton";
|
||||
|
||||
// Agent info for display
|
||||
interface AgentInfo {
|
||||
id: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for InlineHeader component
|
||||
*/
|
||||
export interface InlineHeaderProps {
|
||||
/** Display name of the active agent */
|
||||
agentLabel: string;
|
||||
/** Available agents for switching */
|
||||
availableAgents: AgentInfo[];
|
||||
/** Current agent ID */
|
||||
currentAgentId: string;
|
||||
/** Whether a plugin update is available */
|
||||
isUpdateAvailable: boolean;
|
||||
/** Whether there are messages to export */
|
||||
hasMessages: boolean;
|
||||
/** Callback to switch agent */
|
||||
onAgentChange: (agentId: string) => void;
|
||||
/** Callback to create a new chat session */
|
||||
onNewSession: () => void;
|
||||
/** Callback to open session history */
|
||||
onOpenHistory: () => void;
|
||||
/** Callback to export the chat */
|
||||
onExportChat: () => void;
|
||||
/** Callback to restart agent */
|
||||
onRestartAgent: () => void;
|
||||
/** View variant (TODO(code-block): "codeblock" for future code block chat view) */
|
||||
variant: "floating" | "codeblock";
|
||||
/** Callback to open new window (floating only) */
|
||||
onOpenNewWindow?: () => void;
|
||||
/** Callback to close window (floating only) */
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline header component for Floating and CodeBlock chat views.
|
||||
*
|
||||
* Features:
|
||||
* - Agent selector
|
||||
* - Update notification (if available)
|
||||
* - Action buttons with Lucide icons (new chat, history, export, restart)
|
||||
* - Close button (floating variant only)
|
||||
*/
|
||||
export function InlineHeader({
|
||||
agentLabel,
|
||||
availableAgents,
|
||||
currentAgentId,
|
||||
isUpdateAvailable,
|
||||
hasMessages,
|
||||
onAgentChange,
|
||||
onNewSession,
|
||||
onOpenHistory,
|
||||
onExportChat,
|
||||
onRestartAgent,
|
||||
variant,
|
||||
onOpenNewWindow,
|
||||
onClose,
|
||||
}: InlineHeaderProps) {
|
||||
// Refs for agent dropdown
|
||||
const agentDropdownRef = useRef<HTMLDivElement>(null);
|
||||
const agentDropdownInstance = useRef<DropdownComponent | null>(null);
|
||||
|
||||
// Stable ref for onAgentChange callback
|
||||
const onAgentChangeRef = useRef(onAgentChange);
|
||||
onAgentChangeRef.current = onAgentChange;
|
||||
|
||||
// Initialize agent dropdown
|
||||
useEffect(() => {
|
||||
const containerEl = agentDropdownRef.current;
|
||||
if (!containerEl) return;
|
||||
|
||||
// Only show dropdown if there are multiple agents
|
||||
if (availableAgents.length <= 1) {
|
||||
if (agentDropdownInstance.current) {
|
||||
containerEl.empty();
|
||||
agentDropdownInstance.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Create dropdown if not exists
|
||||
if (!agentDropdownInstance.current) {
|
||||
const dropdown = new DropdownComponent(containerEl);
|
||||
agentDropdownInstance.current = dropdown;
|
||||
|
||||
// Add options
|
||||
for (const agent of availableAgents) {
|
||||
dropdown.addOption(agent.id, agent.displayName);
|
||||
}
|
||||
|
||||
// Set initial value
|
||||
if (currentAgentId) {
|
||||
dropdown.setValue(currentAgentId);
|
||||
}
|
||||
|
||||
// Handle change
|
||||
dropdown.onChange((value) => {
|
||||
onAgentChangeRef.current?.(value);
|
||||
});
|
||||
}
|
||||
|
||||
// Cleanup on unmount or when availableAgents change
|
||||
return () => {
|
||||
if (agentDropdownInstance.current) {
|
||||
containerEl.empty();
|
||||
agentDropdownInstance.current = null;
|
||||
}
|
||||
};
|
||||
}, [availableAgents]);
|
||||
|
||||
// Update dropdown value when currentAgentId changes
|
||||
useEffect(() => {
|
||||
if (agentDropdownInstance.current && currentAgentId) {
|
||||
agentDropdownInstance.current.setValue(currentAgentId);
|
||||
}
|
||||
}, [currentAgentId]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`agent-client-inline-header agent-client-inline-header-${variant}`}
|
||||
>
|
||||
<div className="agent-client-inline-header-main">
|
||||
{availableAgents.length > 1 ? (
|
||||
<div className="agent-client-agent-selector">
|
||||
<div ref={agentDropdownRef} />
|
||||
<span
|
||||
className="agent-client-agent-selector-icon"
|
||||
ref={(el) => {
|
||||
if (el) setIcon(el, "chevron-down");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<span className="agent-client-agent-label">
|
||||
{agentLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{isUpdateAvailable && (
|
||||
<p className="agent-client-chat-view-header-update">
|
||||
Plugin update available!
|
||||
</p>
|
||||
)}
|
||||
<div className="agent-client-inline-header-actions">
|
||||
<HeaderButton
|
||||
iconName="plus"
|
||||
tooltip="New session"
|
||||
onClick={onNewSession}
|
||||
/>
|
||||
<HeaderButton
|
||||
iconName="history"
|
||||
tooltip="Session history"
|
||||
onClick={onOpenHistory}
|
||||
/>
|
||||
<HeaderButton
|
||||
iconName="save"
|
||||
tooltip="Export chat to Markdown"
|
||||
onClick={onExportChat}
|
||||
/>
|
||||
{/* <HeaderButton
|
||||
iconName="rotate-cw"
|
||||
tooltip="Restart agent"
|
||||
onClick={onRestartAgent}
|
||||
/> */}
|
||||
{variant === "floating" && onOpenNewWindow && (
|
||||
<HeaderButton
|
||||
iconName="copy-plus"
|
||||
tooltip="Open new floating chat"
|
||||
onClick={onOpenNewWindow}
|
||||
/>
|
||||
)}
|
||||
{variant === "floating" && onClose && (
|
||||
<HeaderButton
|
||||
iconName="x"
|
||||
tooltip="Close"
|
||||
onClick={onClose}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@ import * as React from "react";
|
|||
const { useRef, useEffect } = React;
|
||||
import { getLogger } from "../../shared/logger";
|
||||
import type AgentClientPlugin from "../../plugin";
|
||||
import type { ChatView } from "./ChatView";
|
||||
import type { IChatViewHost } from "./types";
|
||||
import type { NoteMetadata } from "../../domain/ports/vault-access.port";
|
||||
import type { SlashCommand } from "../../domain/models/chat-session";
|
||||
|
||||
|
|
@ -37,7 +37,7 @@ interface SuggestionDropdownProps {
|
|||
plugin: AgentClientPlugin;
|
||||
|
||||
/** View instance for event registration */
|
||||
view: ChatView;
|
||||
view: IChatViewHost;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
56
src/components/chat/types.ts
Normal file
56
src/components/chat/types.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
/**
|
||||
* Minimal view interface for chat components.
|
||||
*
|
||||
* This interface extracts the minimal set of methods that ChatMessages,
|
||||
* ChatInput, and other components need from a view. By depending on this
|
||||
* interface instead of ChatView directly, these components can work with
|
||||
* both sidebar ChatView and FloatingChatView.
|
||||
*/
|
||||
|
||||
import type { App } from "obsidian";
|
||||
|
||||
/**
|
||||
* Minimal interface for components that need view-level DOM event registration.
|
||||
*
|
||||
* ChatMessages, ChatInput, SuggestionDropdown, and ErrorOverlay use this
|
||||
* for registering scroll and click-outside handlers.
|
||||
*
|
||||
* Note on `this: HTMLElement` in callback signatures:
|
||||
* - This matches Obsidian's Component.registerDomEvent signature for compatibility
|
||||
* - In practice, callbacks use arrow functions and don't reference `this`
|
||||
* - We maintain this signature to allow ChatView to implement IChatViewHost
|
||||
* without type casting (ChatView extends Component which has this signature)
|
||||
*/
|
||||
export interface IChatViewHost {
|
||||
/** Obsidian App instance for API access */
|
||||
app: App;
|
||||
|
||||
/**
|
||||
* Register a DOM event listener that will be cleaned up when the view closes.
|
||||
*
|
||||
* In sidebar ChatView, this delegates to Obsidian's Component.registerDomEvent.
|
||||
* In floating views, this adds the listener and tracks it for cleanup on unmount.
|
||||
*
|
||||
* Note: Only Window, Document, and HTMLElement are supported as targets.
|
||||
* This matches the actual usage in components (document for click-outside,
|
||||
* HTMLElement for scroll handlers).
|
||||
*/
|
||||
registerDomEvent<K extends keyof WindowEventMap>(
|
||||
el: Window,
|
||||
type: K,
|
||||
callback: (this: HTMLElement, ev: WindowEventMap[K]) => unknown,
|
||||
options?: boolean | AddEventListenerOptions,
|
||||
): void;
|
||||
registerDomEvent<K extends keyof DocumentEventMap>(
|
||||
el: Document,
|
||||
type: K,
|
||||
callback: (this: HTMLElement, ev: DocumentEventMap[K]) => unknown,
|
||||
options?: boolean | AddEventListenerOptions,
|
||||
): void;
|
||||
registerDomEvent<K extends keyof HTMLElementEventMap>(
|
||||
el: HTMLElement,
|
||||
type: K,
|
||||
callback: (this: HTMLElement, ev: HTMLElementEventMap[K]) => unknown,
|
||||
options?: boolean | AddEventListenerOptions,
|
||||
): void;
|
||||
}
|
||||
|
|
@ -343,6 +343,56 @@ export class AgentClientSettingTab extends PluginSettingTab {
|
|||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Floating chat
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
new Setting(containerEl).setName("Floating chat").setHeading();
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Show floating button")
|
||||
.setDesc(
|
||||
"Display a floating chat button that opens a draggable chat window.",
|
||||
)
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(this.plugin.settings.showFloatingButton)
|
||||
.onChange(async (value) => {
|
||||
const wasEnabled =
|
||||
this.plugin.settings.showFloatingButton;
|
||||
this.plugin.settings.showFloatingButton = value;
|
||||
await this.plugin.saveSettings();
|
||||
|
||||
// Handle dynamic toggle of floating chat
|
||||
if (value && !wasEnabled) {
|
||||
// Turning ON: create floating chat instance
|
||||
this.plugin.openNewFloatingChat();
|
||||
} else if (!value && wasEnabled) {
|
||||
// Turning OFF: close all floating chat instances
|
||||
const instances =
|
||||
this.plugin.getFloatingChatInstances();
|
||||
for (const instanceId of instances) {
|
||||
this.plugin.closeFloatingChat(instanceId);
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Floating button image")
|
||||
.setDesc(
|
||||
"URL or path to an image for the floating button. Leave empty for default icon.",
|
||||
)
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder("https://example.com/avatar.png")
|
||||
.setValue(this.plugin.settings.floatingButtonImage)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.floatingButtonImage = value.trim();
|
||||
await this.plugin.saveSettings();
|
||||
}),
|
||||
);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Permissions
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
140
src/domain/ports/chat-view-container.port.ts
Normal file
140
src/domain/ports/chat-view-container.port.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
/**
|
||||
* Port for chat view containers
|
||||
*
|
||||
* This interface defines the contract for all chat view implementations
|
||||
* (sidebar, floating, future code-block). It enables unified view management
|
||||
* for features like focus tracking, broadcast commands, and multi-view operations.
|
||||
*
|
||||
* Design notes:
|
||||
* - viewId is unique across all view types
|
||||
* - viewType enables type-specific filtering
|
||||
* - Lifecycle methods (onActivate/onDeactivate) are called by ChatViewRegistry
|
||||
* - Broadcast methods mirror ChatView's existing registerInputCallbacks interface
|
||||
*/
|
||||
|
||||
import type { ChatInputState } from "../models/chat-input-state";
|
||||
|
||||
/**
|
||||
* Type of chat view container.
|
||||
* Used for filtering and type-specific behavior.
|
||||
*/
|
||||
export type ChatViewType = "sidebar" | "floating";
|
||||
|
||||
/**
|
||||
* Interface that all chat view containers must implement.
|
||||
* Enables the plugin to manage views uniformly regardless of their implementation.
|
||||
*/
|
||||
export interface IChatViewContainer {
|
||||
// ============================================================
|
||||
// Identification
|
||||
// ============================================================
|
||||
|
||||
/** Unique identifier for this view instance */
|
||||
readonly viewId: string;
|
||||
|
||||
/** Type of this view (sidebar, floating, etc.) */
|
||||
readonly viewType: ChatViewType;
|
||||
|
||||
/** Human-readable display name for this view (e.g. active agent label). */
|
||||
getDisplayName(): string;
|
||||
|
||||
// ============================================================
|
||||
// Lifecycle
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Called when this view becomes the active/focused view.
|
||||
* Triggered by ChatViewRegistry.setFocused().
|
||||
*/
|
||||
onActivate(): void;
|
||||
|
||||
/**
|
||||
* Called when this view loses active/focused status.
|
||||
* Triggered by ChatViewRegistry.setFocused() or unregister().
|
||||
*/
|
||||
onDeactivate(): void;
|
||||
|
||||
// ============================================================
|
||||
// Focus Management
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Programmatically focus this view's input.
|
||||
* Should focus the chat input textarea.
|
||||
* For floating views, this also expands the window if collapsed.
|
||||
*/
|
||||
focus(): void;
|
||||
|
||||
/**
|
||||
* Check if this view currently has focus.
|
||||
* Returns true if any element within this view's container is focused.
|
||||
*/
|
||||
hasFocus(): boolean;
|
||||
|
||||
/**
|
||||
* Expand the view if it's in a collapsed state.
|
||||
* For sidebar views, this is a no-op.
|
||||
* For floating views, this expands the window.
|
||||
*
|
||||
* Note: This method is provided for explicit expand operations (e.g., from UI).
|
||||
* When focus() is called, it internally handles expansion before focusing.
|
||||
* ChatViewRegistry uses focus() which implicitly expands, so expand() is not
|
||||
* directly called by the registry.
|
||||
*/
|
||||
expand(): void;
|
||||
|
||||
/**
|
||||
* Collapse the view if it's in an expanded state.
|
||||
* For sidebar views, this is a no-op.
|
||||
* For floating views, this hides the window without destroying the instance.
|
||||
*/
|
||||
collapse(): void;
|
||||
|
||||
// ============================================================
|
||||
// Broadcast Commands
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Get current input state (text + images) for broadcast.
|
||||
* Returns null if input state is not available.
|
||||
*/
|
||||
getInputState(): ChatInputState | null;
|
||||
|
||||
/**
|
||||
* Set input state (text + images) from broadcast.
|
||||
* Used to copy prompt from one view to another.
|
||||
*/
|
||||
setInputState(state: ChatInputState): void;
|
||||
|
||||
/**
|
||||
* Check if this view is ready to send a message.
|
||||
* Returns true if:
|
||||
* - Session is ready
|
||||
* - Not currently sending
|
||||
* - Not loading session history
|
||||
* - Has content (text or images)
|
||||
*/
|
||||
canSend(): boolean;
|
||||
|
||||
/**
|
||||
* Trigger send message with full support for images.
|
||||
* @returns Promise<boolean> - true if message was sent, false otherwise
|
||||
*/
|
||||
sendMessage(): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Cancel current operation.
|
||||
* Stops ongoing message generation.
|
||||
*/
|
||||
cancelOperation(): Promise<void>;
|
||||
|
||||
// ============================================================
|
||||
// Container Access
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Get the DOM container element for this view.
|
||||
* Used for focus detection and DOM queries.
|
||||
*/
|
||||
getContainerEl(): HTMLElement;
|
||||
}
|
||||
|
|
@ -467,6 +467,37 @@ export function useAgentSession(
|
|||
agentInfo: needsInitialize ? agentInfo : prev.agentInfo,
|
||||
lastActivityAt: new Date(),
|
||||
}));
|
||||
|
||||
// Restore last used model if available
|
||||
if (sessionResult.models && sessionResult.sessionId) {
|
||||
const savedModelId = settings.lastUsedModels[agentId];
|
||||
if (
|
||||
savedModelId &&
|
||||
savedModelId !== sessionResult.models.currentModelId &&
|
||||
sessionResult.models.availableModels.some(
|
||||
(m) => m.modelId === savedModelId,
|
||||
)
|
||||
) {
|
||||
try {
|
||||
await agentClient.setSessionModel(
|
||||
sessionResult.sessionId,
|
||||
savedModelId,
|
||||
);
|
||||
setSession((prev) => {
|
||||
if (!prev.models) return prev;
|
||||
return {
|
||||
...prev,
|
||||
models: {
|
||||
...prev.models,
|
||||
currentModelId: savedModelId,
|
||||
},
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
// Agent default model is fine as fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Error - update to error state
|
||||
setSession((prev) => ({ ...prev, state: "error" }));
|
||||
|
|
@ -830,6 +861,17 @@ export function useAgentSession(
|
|||
await agentClient.setSessionModel(session.sessionId, modelId);
|
||||
// Note: Unlike modes, there is no dedicated notification for model changes.
|
||||
// UI is already updated optimistically above.
|
||||
|
||||
// Persist last used model for this agent
|
||||
if (session.agentId) {
|
||||
const currentSettings = settingsAccess.getSnapshot();
|
||||
void settingsAccess.updateSettings({
|
||||
lastUsedModels: {
|
||||
...currentSettings.lastUsedModels,
|
||||
[session.agentId]: modelId,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to set model:", error);
|
||||
// Rollback to previous model on error
|
||||
|
|
@ -847,7 +889,13 @@ export function useAgentSession(
|
|||
}
|
||||
}
|
||||
},
|
||||
[agentClient, session.sessionId, session.models?.currentModelId],
|
||||
[
|
||||
agentClient,
|
||||
session.sessionId,
|
||||
session.models?.currentModelId,
|
||||
settingsAccess,
|
||||
session.agentId,
|
||||
],
|
||||
);
|
||||
|
||||
// Register error callback for process-level errors
|
||||
|
|
|
|||
858
src/hooks/useChatController.ts
Normal file
858
src/hooks/useChatController.ts
Normal file
|
|
@ -0,0 +1,858 @@
|
|||
import { useState, useRef, useEffect, useMemo, useCallback } from "react";
|
||||
import { Notice, FileSystemAdapter } from "obsidian";
|
||||
|
||||
import type AgentClientPlugin from "../plugin";
|
||||
import type { AttachedImage } from "../components/chat/ImagePreviewStrip";
|
||||
import { SessionHistoryModal } from "../components/chat/SessionHistoryModal";
|
||||
import { ConfirmDeleteModal } from "../components/chat/ConfirmDeleteModal";
|
||||
|
||||
// Service imports
|
||||
import { NoteMentionService } from "../adapters/obsidian/mention-service";
|
||||
import { getLogger, Logger } from "../shared/logger";
|
||||
import { ChatExporter } from "../shared/chat-exporter";
|
||||
|
||||
// Adapter imports
|
||||
import { ObsidianVaultAdapter } from "../adapters/obsidian/vault.adapter";
|
||||
import type { IAcpClient } from "../adapters/acp/acp.adapter";
|
||||
|
||||
// Hooks imports
|
||||
import { useSettings } from "./useSettings";
|
||||
import { useMentions } from "./useMentions";
|
||||
import { useSlashCommands } from "./useSlashCommands";
|
||||
import { useAutoMention } from "./useAutoMention";
|
||||
import { useAgentSession } from "./useAgentSession";
|
||||
import { useChat } from "./useChat";
|
||||
import { usePermission } from "./usePermission";
|
||||
import { useAutoExport } from "./useAutoExport";
|
||||
import { useSessionHistory } from "./useSessionHistory";
|
||||
|
||||
// Domain model imports
|
||||
import type {
|
||||
SessionModeState,
|
||||
SessionModelState,
|
||||
} from "../domain/models/chat-session";
|
||||
import type { ImagePromptContent } from "../domain/models/prompt-content";
|
||||
|
||||
// Agent info for display (from plugin.getAvailableAgents())
|
||||
interface AgentInfo {
|
||||
id: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export interface UseChatControllerOptions {
|
||||
plugin: AgentClientPlugin;
|
||||
viewId: string;
|
||||
workingDirectory?: string;
|
||||
initialAgentId?: string;
|
||||
// TODO(code-block): Configuration for future code block chat view
|
||||
config?: {
|
||||
agent?: string;
|
||||
model?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface UseChatControllerReturn {
|
||||
// Memoized services/adapters
|
||||
logger: Logger;
|
||||
vaultPath: string;
|
||||
acpAdapter: IAcpClient;
|
||||
vaultAccessAdapter: ObsidianVaultAdapter;
|
||||
noteMentionService: NoteMentionService;
|
||||
|
||||
// Settings & State
|
||||
settings: ReturnType<typeof useSettings>;
|
||||
session: ReturnType<typeof useAgentSession>["session"];
|
||||
isSessionReady: boolean;
|
||||
messages: ReturnType<typeof useChat>["messages"];
|
||||
isSending: boolean;
|
||||
isUpdateAvailable: boolean;
|
||||
isLoadingSessionHistory: boolean;
|
||||
|
||||
// Hook returns
|
||||
permission: ReturnType<typeof usePermission>;
|
||||
mentions: ReturnType<typeof useMentions>;
|
||||
autoMention: ReturnType<typeof useAutoMention>;
|
||||
slashCommands: ReturnType<typeof useSlashCommands>;
|
||||
sessionHistory: ReturnType<typeof useSessionHistory>;
|
||||
autoExport: ReturnType<typeof useAutoExport>;
|
||||
|
||||
// Computed values
|
||||
activeAgentLabel: string;
|
||||
availableAgents: AgentInfo[];
|
||||
errorInfo:
|
||||
| ReturnType<typeof useChat>["errorInfo"]
|
||||
| ReturnType<typeof useAgentSession>["errorInfo"];
|
||||
|
||||
// Core callbacks
|
||||
handleSendMessage: (
|
||||
content: string,
|
||||
images?: ImagePromptContent[],
|
||||
) => Promise<void>;
|
||||
handleStopGeneration: () => Promise<void>;
|
||||
handleNewChat: (requestedAgentId?: string) => Promise<void>;
|
||||
handleExportChat: () => Promise<void>;
|
||||
handleSwitchAgent: (agentId: string) => Promise<void>;
|
||||
handleRestartAgent: () => Promise<void>;
|
||||
handleClearError: () => void;
|
||||
handleRestoreSession: (sessionId: string, cwd: string) => Promise<void>;
|
||||
handleForkSession: (sessionId: string, cwd: string) => Promise<void>;
|
||||
handleDeleteSession: (sessionId: string) => void;
|
||||
handleOpenHistory: () => void;
|
||||
handleSetMode: (modeId: string) => Promise<void>;
|
||||
handleSetModel: (modelId: string) => Promise<void>;
|
||||
|
||||
// Input state (for broadcast commands - sidebar only)
|
||||
inputValue: string;
|
||||
setInputValue: (value: string) => void;
|
||||
attachedImages: AttachedImage[];
|
||||
setAttachedImages: (images: AttachedImage[]) => void;
|
||||
restoredMessage: string | null;
|
||||
handleRestoredMessageConsumed: () => void;
|
||||
|
||||
// History modal management
|
||||
historyModalRef: React.RefObject<SessionHistoryModal | null>;
|
||||
}
|
||||
|
||||
export function useChatController(
|
||||
options: UseChatControllerOptions,
|
||||
): UseChatControllerReturn {
|
||||
const { plugin, viewId, initialAgentId, config } = options;
|
||||
|
||||
// ============================================================
|
||||
// Memoized Services & Adapters
|
||||
// ============================================================
|
||||
const logger = getLogger();
|
||||
|
||||
const vaultPath = useMemo(() => {
|
||||
if (options.workingDirectory) {
|
||||
return options.workingDirectory;
|
||||
}
|
||||
const adapter = plugin.app.vault.adapter;
|
||||
if (adapter instanceof FileSystemAdapter) {
|
||||
return adapter.getBasePath();
|
||||
}
|
||||
// Fallback for non-FileSystemAdapter (e.g., mobile)
|
||||
return process.cwd();
|
||||
}, [plugin, options.workingDirectory]);
|
||||
|
||||
const noteMentionService = useMemo(
|
||||
() => new NoteMentionService(plugin),
|
||||
[plugin],
|
||||
);
|
||||
|
||||
// Cleanup NoteMentionService when component unmounts
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
noteMentionService.destroy();
|
||||
};
|
||||
}, [noteMentionService]);
|
||||
|
||||
const acpAdapter = useMemo(
|
||||
() => plugin.getOrCreateAdapter(viewId),
|
||||
[plugin, viewId],
|
||||
);
|
||||
|
||||
const vaultAccessAdapter = useMemo(() => {
|
||||
return new ObsidianVaultAdapter(plugin, noteMentionService);
|
||||
}, [plugin, noteMentionService]);
|
||||
|
||||
// ============================================================
|
||||
// Custom Hooks
|
||||
// ============================================================
|
||||
const settings = useSettings(plugin);
|
||||
|
||||
const agentSession = useAgentSession(
|
||||
acpAdapter,
|
||||
plugin.settingsStore,
|
||||
vaultPath,
|
||||
initialAgentId,
|
||||
);
|
||||
|
||||
const {
|
||||
session,
|
||||
errorInfo: sessionErrorInfo,
|
||||
isReady: isSessionReady,
|
||||
} = agentSession;
|
||||
|
||||
const chat = useChat(
|
||||
acpAdapter,
|
||||
vaultAccessAdapter,
|
||||
noteMentionService,
|
||||
{
|
||||
sessionId: session.sessionId,
|
||||
authMethods: session.authMethods,
|
||||
promptCapabilities: session.promptCapabilities,
|
||||
},
|
||||
{
|
||||
windowsWslMode: settings.windowsWslMode,
|
||||
maxNoteLength: settings.displaySettings.maxNoteLength,
|
||||
maxSelectionLength: settings.displaySettings.maxSelectionLength,
|
||||
},
|
||||
);
|
||||
|
||||
const { messages, isSending } = chat;
|
||||
|
||||
const permission = usePermission(acpAdapter, messages);
|
||||
|
||||
const mentions = useMentions(vaultAccessAdapter, plugin);
|
||||
const autoMention = useAutoMention(vaultAccessAdapter);
|
||||
const slashCommands = useSlashCommands(
|
||||
session.availableCommands || [],
|
||||
autoMention.toggle,
|
||||
);
|
||||
|
||||
const autoExport = useAutoExport(plugin);
|
||||
|
||||
// Session history hook with callback for session load
|
||||
const handleSessionLoad = useCallback(
|
||||
(
|
||||
sessionId: string,
|
||||
modes?: SessionModeState,
|
||||
models?: SessionModelState,
|
||||
) => {
|
||||
logger.log(
|
||||
`[useChatController] Session loaded/resumed/forked: ${sessionId}`,
|
||||
{
|
||||
modes,
|
||||
models,
|
||||
},
|
||||
);
|
||||
agentSession.updateSessionFromLoad(sessionId, modes, models);
|
||||
},
|
||||
[logger, agentSession],
|
||||
);
|
||||
|
||||
const [isLoadingSessionHistory, setIsLoadingSessionHistory] =
|
||||
useState(false);
|
||||
|
||||
const handleLoadStart = useCallback(() => {
|
||||
logger.log(
|
||||
"[useChatController] session/load started, ignoring history replay",
|
||||
);
|
||||
setIsLoadingSessionHistory(true);
|
||||
chat.clearMessages();
|
||||
}, [logger, chat]);
|
||||
|
||||
const handleLoadEnd = useCallback(() => {
|
||||
logger.log(
|
||||
"[useChatController] session/load ended, resuming normal processing",
|
||||
);
|
||||
setIsLoadingSessionHistory(false);
|
||||
}, [logger]);
|
||||
|
||||
const sessionHistory = useSessionHistory({
|
||||
agentClient: acpAdapter,
|
||||
session,
|
||||
settingsAccess: plugin.settingsStore,
|
||||
cwd: vaultPath,
|
||||
onSessionLoad: handleSessionLoad,
|
||||
onMessagesRestore: chat.setMessagesFromLocal,
|
||||
onLoadStart: handleLoadStart,
|
||||
onLoadEnd: handleLoadEnd,
|
||||
});
|
||||
|
||||
// Combined error info (session errors take precedence)
|
||||
const errorInfo =
|
||||
sessionErrorInfo || chat.errorInfo || permission.errorInfo;
|
||||
|
||||
// ============================================================
|
||||
// Local State
|
||||
// ============================================================
|
||||
const [isUpdateAvailable, setIsUpdateAvailable] = useState(false);
|
||||
const [restoredMessage, setRestoredMessage] = useState<string | null>(null);
|
||||
|
||||
// Input state (for broadcast commands - sidebar only)
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [attachedImages, setAttachedImages] = useState<AttachedImage[]>([]);
|
||||
|
||||
// ============================================================
|
||||
// Refs
|
||||
// ============================================================
|
||||
const historyModalRef = useRef<SessionHistoryModal | null>(null);
|
||||
|
||||
// ============================================================
|
||||
// Computed Values
|
||||
// ============================================================
|
||||
const activeAgentLabel = useMemo(() => {
|
||||
const activeId = session.agentId;
|
||||
if (activeId === plugin.settings.claude.id) {
|
||||
return (
|
||||
plugin.settings.claude.displayName || plugin.settings.claude.id
|
||||
);
|
||||
}
|
||||
if (activeId === plugin.settings.codex.id) {
|
||||
return (
|
||||
plugin.settings.codex.displayName || plugin.settings.codex.id
|
||||
);
|
||||
}
|
||||
if (activeId === plugin.settings.gemini.id) {
|
||||
return (
|
||||
plugin.settings.gemini.displayName || plugin.settings.gemini.id
|
||||
);
|
||||
}
|
||||
const custom = plugin.settings.customAgents.find(
|
||||
(agent) => agent.id === activeId,
|
||||
);
|
||||
return custom?.displayName || custom?.id || activeId;
|
||||
}, [session.agentId, plugin.settings]);
|
||||
|
||||
const availableAgents = useMemo(() => {
|
||||
return plugin.getAvailableAgents();
|
||||
}, [plugin]);
|
||||
|
||||
// ============================================================
|
||||
// Callbacks
|
||||
// ============================================================
|
||||
const handleSendMessage = useCallback(
|
||||
async (content: string, images?: ImagePromptContent[]) => {
|
||||
const isFirstMessage = messages.length === 0;
|
||||
|
||||
await chat.sendMessage(content, {
|
||||
activeNote: settings.autoMentionActiveNote
|
||||
? autoMention.activeNote
|
||||
: null,
|
||||
vaultBasePath: vaultPath,
|
||||
isAutoMentionDisabled: autoMention.isDisabled,
|
||||
images,
|
||||
});
|
||||
|
||||
// Save session metadata locally on first message
|
||||
if (isFirstMessage && session.sessionId) {
|
||||
await sessionHistory.saveSessionLocally(
|
||||
session.sessionId,
|
||||
content,
|
||||
);
|
||||
logger.log(
|
||||
`[useChatController] Session saved locally: ${session.sessionId}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
[
|
||||
chat,
|
||||
autoMention,
|
||||
plugin,
|
||||
messages.length,
|
||||
session.sessionId,
|
||||
sessionHistory,
|
||||
logger,
|
||||
settings.autoMentionActiveNote,
|
||||
],
|
||||
);
|
||||
|
||||
const handleStopGeneration = useCallback(async () => {
|
||||
logger.log("Cancelling current operation...");
|
||||
const lastMessage = chat.lastUserMessage;
|
||||
await agentSession.cancelOperation();
|
||||
if (lastMessage) {
|
||||
setRestoredMessage(lastMessage);
|
||||
}
|
||||
}, [logger, agentSession, chat.lastUserMessage]);
|
||||
|
||||
const handleNewChat = useCallback(
|
||||
async (requestedAgentId?: string) => {
|
||||
const isAgentSwitch =
|
||||
requestedAgentId && requestedAgentId !== session.agentId;
|
||||
|
||||
// Skip if already empty AND not switching agents
|
||||
if (messages.length === 0 && !isAgentSwitch) {
|
||||
new Notice("[Agent Client] Already a new session");
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancel ongoing generation before starting new chat
|
||||
if (chat.isSending) {
|
||||
await agentSession.cancelOperation();
|
||||
}
|
||||
|
||||
logger.log(
|
||||
`[Debug] Creating new session${isAgentSwitch ? ` with agent: ${requestedAgentId}` : ""}...`,
|
||||
);
|
||||
|
||||
// Auto-export current chat before starting new one (if has messages)
|
||||
if (messages.length > 0) {
|
||||
await autoExport.autoExportIfEnabled(
|
||||
"newChat",
|
||||
messages,
|
||||
session,
|
||||
);
|
||||
}
|
||||
|
||||
autoMention.toggle(false);
|
||||
chat.clearMessages();
|
||||
|
||||
const newAgentId = isAgentSwitch
|
||||
? requestedAgentId
|
||||
: session.agentId;
|
||||
await agentSession.restartSession(newAgentId);
|
||||
|
||||
// Invalidate session history cache when creating new session
|
||||
sessionHistory.invalidateCache();
|
||||
},
|
||||
[
|
||||
messages,
|
||||
session,
|
||||
logger,
|
||||
autoExport,
|
||||
autoMention,
|
||||
chat,
|
||||
agentSession,
|
||||
sessionHistory,
|
||||
],
|
||||
);
|
||||
|
||||
const handleExportChat = useCallback(async () => {
|
||||
if (messages.length === 0) {
|
||||
new Notice("[Agent Client] No messages to export");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const exporter = new ChatExporter(plugin);
|
||||
const openFile = plugin.settings.exportSettings.openFileAfterExport;
|
||||
const filePath = await exporter.exportToMarkdown(
|
||||
messages,
|
||||
session.agentDisplayName,
|
||||
session.agentId,
|
||||
session.sessionId || "unknown",
|
||||
session.createdAt,
|
||||
openFile,
|
||||
);
|
||||
new Notice(`[Agent Client] Chat exported to ${filePath}`);
|
||||
} catch (error) {
|
||||
new Notice("[Agent Client] Failed to export chat");
|
||||
logger.error("Export error:", error);
|
||||
}
|
||||
}, [messages, session, plugin, logger]);
|
||||
|
||||
const handleSwitchAgent = useCallback(
|
||||
async (agentId: string) => {
|
||||
if (agentId !== session.agentId) {
|
||||
await handleNewChat(agentId);
|
||||
}
|
||||
},
|
||||
[session.agentId, handleNewChat],
|
||||
);
|
||||
|
||||
const handleRestartAgent = useCallback(async () => {
|
||||
logger.log("[useChatController] Restarting agent process...");
|
||||
|
||||
// Auto-export current chat before restart (if has messages)
|
||||
if (messages.length > 0) {
|
||||
await autoExport.autoExportIfEnabled("newChat", messages, session);
|
||||
}
|
||||
|
||||
// Clear messages for fresh start
|
||||
chat.clearMessages();
|
||||
|
||||
try {
|
||||
await agentSession.forceRestartAgent();
|
||||
new Notice("[Agent Client] Agent restarted");
|
||||
} catch (error) {
|
||||
new Notice("[Agent Client] Failed to restart agent");
|
||||
logger.error("Restart error:", error);
|
||||
}
|
||||
}, [logger, messages, session, autoExport, chat, agentSession]);
|
||||
|
||||
const handleClearError = useCallback(() => {
|
||||
chat.clearError();
|
||||
}, [chat]);
|
||||
|
||||
const handleRestoredMessageConsumed = useCallback(() => {
|
||||
setRestoredMessage(null);
|
||||
}, []);
|
||||
|
||||
// ============================================================
|
||||
// Session History Modal Callbacks
|
||||
// ============================================================
|
||||
const handleRestoreSession = useCallback(
|
||||
async (sessionId: string, cwd: string) => {
|
||||
try {
|
||||
logger.log(
|
||||
`[useChatController] Restoring session: ${sessionId}`,
|
||||
);
|
||||
chat.clearMessages();
|
||||
await sessionHistory.restoreSession(sessionId, cwd);
|
||||
new Notice("[Agent Client] Session restored");
|
||||
} catch (error) {
|
||||
new Notice("[Agent Client] Failed to restore session");
|
||||
logger.error("Session restore error:", error);
|
||||
}
|
||||
},
|
||||
[logger, chat, sessionHistory],
|
||||
);
|
||||
|
||||
const handleForkSession = useCallback(
|
||||
async (sessionId: string, cwd: string) => {
|
||||
try {
|
||||
logger.log(`[useChatController] Forking session: ${sessionId}`);
|
||||
chat.clearMessages();
|
||||
await sessionHistory.forkSession(sessionId, cwd);
|
||||
new Notice("[Agent Client] Session forked");
|
||||
} catch (error) {
|
||||
new Notice("[Agent Client] Failed to fork session");
|
||||
logger.error("Session fork error:", error);
|
||||
}
|
||||
},
|
||||
[logger, chat, sessionHistory],
|
||||
);
|
||||
|
||||
const handleDeleteSession = useCallback(
|
||||
(sessionId: string) => {
|
||||
const targetSession = sessionHistory.sessions.find(
|
||||
(s) => s.sessionId === sessionId,
|
||||
);
|
||||
const sessionTitle = targetSession?.title ?? "Untitled Session";
|
||||
|
||||
const confirmModal = new ConfirmDeleteModal(
|
||||
plugin.app,
|
||||
sessionTitle,
|
||||
async () => {
|
||||
try {
|
||||
logger.log(
|
||||
`[useChatController] Deleting session: ${sessionId}`,
|
||||
);
|
||||
await sessionHistory.deleteSession(sessionId);
|
||||
new Notice("[Agent Client] Session deleted");
|
||||
} catch (error) {
|
||||
new Notice("[Agent Client] Failed to delete session");
|
||||
logger.error("Session delete error:", error);
|
||||
}
|
||||
},
|
||||
);
|
||||
confirmModal.open();
|
||||
},
|
||||
[plugin.app, sessionHistory, logger],
|
||||
);
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
void sessionHistory.loadMoreSessions();
|
||||
}, [sessionHistory]);
|
||||
|
||||
const handleFetchSessions = useCallback(
|
||||
(cwd?: string) => {
|
||||
void sessionHistory.fetchSessions(cwd);
|
||||
},
|
||||
[sessionHistory],
|
||||
);
|
||||
|
||||
const handleOpenHistory = useCallback(() => {
|
||||
// Create modal if it doesn't exist
|
||||
if (!historyModalRef.current) {
|
||||
historyModalRef.current = new SessionHistoryModal(plugin.app, {
|
||||
sessions: sessionHistory.sessions,
|
||||
loading: sessionHistory.loading,
|
||||
error: sessionHistory.error,
|
||||
hasMore: sessionHistory.hasMore,
|
||||
currentCwd: vaultPath,
|
||||
canList: sessionHistory.canList,
|
||||
canRestore: sessionHistory.canRestore,
|
||||
canFork: sessionHistory.canFork,
|
||||
isUsingLocalSessions: sessionHistory.isUsingLocalSessions,
|
||||
localSessionIds: sessionHistory.localSessionIds,
|
||||
isAgentReady: isSessionReady,
|
||||
debugMode: settings.debugMode,
|
||||
onRestoreSession: handleRestoreSession,
|
||||
onForkSession: handleForkSession,
|
||||
onDeleteSession: handleDeleteSession,
|
||||
onLoadMore: handleLoadMore,
|
||||
onFetchSessions: handleFetchSessions,
|
||||
});
|
||||
}
|
||||
historyModalRef.current.open();
|
||||
void sessionHistory.fetchSessions(vaultPath);
|
||||
}, [
|
||||
plugin.app,
|
||||
sessionHistory,
|
||||
vaultPath,
|
||||
isSessionReady,
|
||||
settings.debugMode,
|
||||
handleRestoreSession,
|
||||
handleForkSession,
|
||||
handleDeleteSession,
|
||||
handleLoadMore,
|
||||
handleFetchSessions,
|
||||
]);
|
||||
|
||||
const handleSetMode = useCallback(
|
||||
async (modeId: string) => {
|
||||
await agentSession.setMode(modeId);
|
||||
},
|
||||
[agentSession],
|
||||
);
|
||||
|
||||
const handleSetModel = useCallback(
|
||||
async (modelId: string) => {
|
||||
await agentSession.setModel(modelId);
|
||||
},
|
||||
[agentSession],
|
||||
);
|
||||
|
||||
// Update modal props when session history state changes
|
||||
useEffect(() => {
|
||||
if (historyModalRef.current) {
|
||||
historyModalRef.current.updateProps({
|
||||
sessions: sessionHistory.sessions,
|
||||
loading: sessionHistory.loading,
|
||||
error: sessionHistory.error,
|
||||
hasMore: sessionHistory.hasMore,
|
||||
currentCwd: vaultPath,
|
||||
canList: sessionHistory.canList,
|
||||
canRestore: sessionHistory.canRestore,
|
||||
canFork: sessionHistory.canFork,
|
||||
isUsingLocalSessions: sessionHistory.isUsingLocalSessions,
|
||||
localSessionIds: sessionHistory.localSessionIds,
|
||||
isAgentReady: isSessionReady,
|
||||
debugMode: settings.debugMode,
|
||||
onRestoreSession: handleRestoreSession,
|
||||
onForkSession: handleForkSession,
|
||||
onDeleteSession: handleDeleteSession,
|
||||
onLoadMore: handleLoadMore,
|
||||
onFetchSessions: handleFetchSessions,
|
||||
});
|
||||
}
|
||||
}, [
|
||||
sessionHistory.sessions,
|
||||
sessionHistory.loading,
|
||||
sessionHistory.error,
|
||||
sessionHistory.hasMore,
|
||||
sessionHistory.canList,
|
||||
sessionHistory.canRestore,
|
||||
sessionHistory.canFork,
|
||||
sessionHistory.isUsingLocalSessions,
|
||||
vaultPath,
|
||||
isSessionReady,
|
||||
settings.debugMode,
|
||||
handleRestoreSession,
|
||||
handleForkSession,
|
||||
handleDeleteSession,
|
||||
handleLoadMore,
|
||||
handleFetchSessions,
|
||||
]);
|
||||
|
||||
// ============================================================
|
||||
// Effects - Session Lifecycle
|
||||
// ============================================================
|
||||
// Initialize session on mount
|
||||
useEffect(() => {
|
||||
logger.log("[Debug] Starting connection setup via useAgentSession...");
|
||||
void agentSession.createSession(config?.agent || initialAgentId);
|
||||
}, [agentSession.createSession, config?.agent, initialAgentId]);
|
||||
|
||||
// TODO(code-block): Apply configured model when session is ready
|
||||
useEffect(() => {
|
||||
if (config?.model && isSessionReady && session.models) {
|
||||
const modelExists = session.models.availableModels.some(
|
||||
(m) => m.modelId === config.model,
|
||||
);
|
||||
if (modelExists && session.models.currentModelId !== config.model) {
|
||||
logger.log(
|
||||
"[useChatController] Applying configured model:",
|
||||
config.model,
|
||||
);
|
||||
void agentSession.setModel(config.model);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
config?.model,
|
||||
isSessionReady,
|
||||
session.models,
|
||||
agentSession.setModel,
|
||||
logger,
|
||||
]);
|
||||
|
||||
// Refs for cleanup (to access latest values in cleanup function)
|
||||
const messagesRef = useRef(messages);
|
||||
const sessionRef = useRef(session);
|
||||
const autoExportRef = useRef(autoExport);
|
||||
const closeSessionRef = useRef(agentSession.closeSession);
|
||||
messagesRef.current = messages;
|
||||
sessionRef.current = session;
|
||||
autoExportRef.current = autoExport;
|
||||
closeSessionRef.current = agentSession.closeSession;
|
||||
|
||||
// Cleanup on unmount only - auto-export and close session
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
logger.log(
|
||||
"[useChatController] Cleanup: auto-export and close session",
|
||||
);
|
||||
void (async () => {
|
||||
await autoExportRef.current.autoExportIfEnabled(
|
||||
"closeChat",
|
||||
messagesRef.current,
|
||||
sessionRef.current,
|
||||
);
|
||||
await closeSessionRef.current();
|
||||
})();
|
||||
};
|
||||
}, [logger]);
|
||||
|
||||
// ============================================================
|
||||
// Effects - ACP Adapter Callbacks
|
||||
// ============================================================
|
||||
// Register unified session update callback
|
||||
useEffect(() => {
|
||||
acpAdapter.onSessionUpdate((update) => {
|
||||
// Filter by sessionId - ignore updates from old sessions
|
||||
if (session.sessionId && update.sessionId !== session.sessionId) {
|
||||
logger.log(
|
||||
`[useChatController] Ignoring update for old session: ${update.sessionId} (current: ${session.sessionId})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// During session/load, ignore history replay messages but process session-level updates
|
||||
if (isLoadingSessionHistory) {
|
||||
// Only process session-level updates during load
|
||||
if (update.type === "available_commands_update") {
|
||||
agentSession.updateAvailableCommands(update.commands);
|
||||
} else if (update.type === "current_mode_update") {
|
||||
agentSession.updateCurrentMode(update.currentModeId);
|
||||
}
|
||||
// Ignore all message-related updates (history replay)
|
||||
return;
|
||||
}
|
||||
|
||||
// Route message-related updates to useChat
|
||||
chat.handleSessionUpdate(update);
|
||||
|
||||
// Route session-level updates to useAgentSession
|
||||
if (update.type === "available_commands_update") {
|
||||
agentSession.updateAvailableCommands(update.commands);
|
||||
} else if (update.type === "current_mode_update") {
|
||||
agentSession.updateCurrentMode(update.currentModeId);
|
||||
}
|
||||
});
|
||||
}, [
|
||||
acpAdapter,
|
||||
session.sessionId,
|
||||
logger,
|
||||
isLoadingSessionHistory,
|
||||
chat.handleSessionUpdate,
|
||||
agentSession.updateAvailableCommands,
|
||||
agentSession.updateCurrentMode,
|
||||
]);
|
||||
|
||||
// Register updateMessage callback for permission UI updates
|
||||
useEffect(() => {
|
||||
acpAdapter.setUpdateMessageCallback(chat.updateMessage);
|
||||
}, [acpAdapter, chat.updateMessage]);
|
||||
|
||||
// ============================================================
|
||||
// Effects - Update Check
|
||||
// ============================================================
|
||||
useEffect(() => {
|
||||
plugin
|
||||
.checkForUpdates()
|
||||
.then(setIsUpdateAvailable)
|
||||
.catch((error) => {
|
||||
logger.error("Failed to check for updates:", error);
|
||||
});
|
||||
}, [plugin, logger]);
|
||||
|
||||
// ============================================================
|
||||
// Effects - Save Session Messages on Turn End
|
||||
// ============================================================
|
||||
const prevIsSendingRef = useRef<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
const wasSending = prevIsSendingRef.current;
|
||||
prevIsSendingRef.current = isSending;
|
||||
|
||||
// Save when turn ends (isSending: true → false) and has messages
|
||||
if (
|
||||
wasSending &&
|
||||
!isSending &&
|
||||
session.sessionId &&
|
||||
messages.length > 0
|
||||
) {
|
||||
sessionHistory.saveSessionMessages(session.sessionId, messages);
|
||||
logger.log(
|
||||
`[useChatController] Session messages saved: ${session.sessionId}`,
|
||||
);
|
||||
}
|
||||
}, [isSending, session.sessionId, messages, sessionHistory, logger]);
|
||||
|
||||
// ============================================================
|
||||
// Effects - Auto-mention Active Note Tracking
|
||||
// ============================================================
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const refreshActiveNote = async () => {
|
||||
if (!isMounted) return;
|
||||
await autoMention.updateActiveNote();
|
||||
};
|
||||
|
||||
const unsubscribe = vaultAccessAdapter.subscribeSelectionChanges(() => {
|
||||
void refreshActiveNote();
|
||||
});
|
||||
|
||||
void refreshActiveNote();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
unsubscribe();
|
||||
};
|
||||
}, [autoMention.updateActiveNote, vaultAccessAdapter]);
|
||||
|
||||
// ============================================================
|
||||
// Return
|
||||
// ============================================================
|
||||
return {
|
||||
// Services & Adapters
|
||||
logger,
|
||||
vaultPath,
|
||||
acpAdapter,
|
||||
vaultAccessAdapter,
|
||||
noteMentionService,
|
||||
|
||||
// Settings & State
|
||||
settings,
|
||||
session,
|
||||
isSessionReady,
|
||||
messages,
|
||||
isSending,
|
||||
isUpdateAvailable,
|
||||
isLoadingSessionHistory,
|
||||
|
||||
// Hook returns
|
||||
permission,
|
||||
mentions,
|
||||
autoMention,
|
||||
slashCommands,
|
||||
sessionHistory,
|
||||
autoExport,
|
||||
|
||||
// Computed values
|
||||
activeAgentLabel,
|
||||
availableAgents,
|
||||
errorInfo,
|
||||
|
||||
// Core callbacks
|
||||
handleSendMessage,
|
||||
handleStopGeneration,
|
||||
handleNewChat,
|
||||
handleExportChat,
|
||||
handleSwitchAgent,
|
||||
handleRestartAgent,
|
||||
handleClearError,
|
||||
handleRestoreSession,
|
||||
handleForkSession,
|
||||
handleDeleteSession,
|
||||
handleOpenHistory,
|
||||
handleSetMode,
|
||||
handleSetModel,
|
||||
|
||||
// Input state
|
||||
inputValue,
|
||||
setInputValue,
|
||||
attachedImages,
|
||||
setAttachedImages,
|
||||
restoredMessage,
|
||||
handleRestoredMessageConsumed,
|
||||
|
||||
// History modal management
|
||||
historyModalRef,
|
||||
};
|
||||
}
|
||||
143
src/hooks/useInputHistory.ts
Normal file
143
src/hooks/useInputHistory.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
import { useCallback, useRef, useMemo } from "react";
|
||||
import type { ChatMessage } from "../domain/models/chat-message";
|
||||
|
||||
export interface UseInputHistoryReturn {
|
||||
/**
|
||||
* Key handler for ArrowUp/ArrowDown history navigation.
|
||||
* Returns true if the event was handled (caller should return early).
|
||||
*/
|
||||
handleHistoryKeyDown: (
|
||||
e: React.KeyboardEvent,
|
||||
textareaEl: HTMLTextAreaElement | null,
|
||||
) => boolean;
|
||||
|
||||
/**
|
||||
* Reset history navigation state. Call after sending a message.
|
||||
*/
|
||||
resetHistory: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for navigating input history with ArrowUp/ArrowDown keys.
|
||||
*
|
||||
* - ArrowUp when input is empty recalls the previous user message.
|
||||
* - Continued ArrowUp/ArrowDown navigates through history.
|
||||
* - If the user edits a restored message, history navigation resets.
|
||||
* - ArrowDown from the most recent entry returns to empty input.
|
||||
*/
|
||||
export function useInputHistory(
|
||||
messages: ChatMessage[],
|
||||
onInputChange: (value: string) => void,
|
||||
): UseInputHistoryReturn {
|
||||
// -1 = no history selected, 0 = most recent user message, 1 = second most recent, ...
|
||||
const historyIndexRef = useRef(-1);
|
||||
// The exact text placed into the input via history navigation.
|
||||
// Used to detect if the user has edited it (which exits history mode).
|
||||
const restoredTextRef = useRef<string | null>(null);
|
||||
|
||||
// Extract user message texts in chronological order
|
||||
const userMessages = useMemo(() => {
|
||||
return messages
|
||||
.filter((m) => m.role === "user")
|
||||
.map((m) => {
|
||||
const textContent = m.content.find(
|
||||
(c) => c.type === "text" || c.type === "text_with_context",
|
||||
);
|
||||
return textContent && "text" in textContent
|
||||
? textContent.text
|
||||
: "";
|
||||
})
|
||||
.filter((text) => text.trim() !== "");
|
||||
}, [messages]);
|
||||
|
||||
const handleHistoryKeyDown = useCallback(
|
||||
(
|
||||
e: React.KeyboardEvent,
|
||||
textareaEl: HTMLTextAreaElement | null,
|
||||
): boolean => {
|
||||
if (!textareaEl) return false;
|
||||
if (e.nativeEvent.isComposing) return false;
|
||||
if (userMessages.length === 0) return false;
|
||||
|
||||
// Exit history mode if user edited text or moved cursor
|
||||
if (historyIndexRef.current !== -1) {
|
||||
if (
|
||||
e.key === "ArrowLeft" ||
|
||||
e.key === "ArrowRight" ||
|
||||
(restoredTextRef.current !== null &&
|
||||
textareaEl.value !== restoredTextRef.current)
|
||||
) {
|
||||
historyIndexRef.current = -1;
|
||||
restoredTextRef.current = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === "ArrowUp") {
|
||||
// Allow when input is empty OR already navigating history
|
||||
if (
|
||||
textareaEl.value.trim() !== "" &&
|
||||
historyIndexRef.current === -1
|
||||
)
|
||||
return false;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
const nextIndex = historyIndexRef.current + 1;
|
||||
if (nextIndex >= userMessages.length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
historyIndexRef.current = nextIndex;
|
||||
const messageText =
|
||||
userMessages[userMessages.length - 1 - nextIndex];
|
||||
restoredTextRef.current = messageText;
|
||||
onInputChange(messageText);
|
||||
|
||||
window.setTimeout(() => {
|
||||
textareaEl.selectionStart = messageText.length;
|
||||
textareaEl.selectionEnd = messageText.length;
|
||||
}, 0);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
const currentIndex = historyIndexRef.current;
|
||||
if (currentIndex === -1) return false;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
const nextIndex = currentIndex - 1;
|
||||
historyIndexRef.current = nextIndex;
|
||||
|
||||
if (nextIndex === -1) {
|
||||
restoredTextRef.current = null;
|
||||
onInputChange("");
|
||||
} else {
|
||||
const messageText =
|
||||
userMessages[userMessages.length - 1 - nextIndex];
|
||||
restoredTextRef.current = messageText;
|
||||
onInputChange(messageText);
|
||||
|
||||
window.setTimeout(() => {
|
||||
textareaEl.selectionStart = messageText.length;
|
||||
textareaEl.selectionEnd = messageText.length;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
[userMessages, onInputChange],
|
||||
);
|
||||
|
||||
const resetHistory = useCallback(() => {
|
||||
historyIndexRef.current = -1;
|
||||
restoredTextRef.current = null;
|
||||
}, []);
|
||||
|
||||
return { handleHistoryKeyDown, resetHistory };
|
||||
}
|
||||
363
src/plugin.ts
363
src/plugin.ts
|
|
@ -1,6 +1,13 @@
|
|||
import { Plugin, WorkspaceLeaf, Notice, requestUrl } from "obsidian";
|
||||
import type { Root } from "react-dom/client";
|
||||
import * as semver from "semver";
|
||||
import { ChatView, VIEW_TYPE_CHAT } from "./components/chat/ChatView";
|
||||
import {
|
||||
createFloatingChat,
|
||||
FloatingViewContainer,
|
||||
} from "./components/chat/FloatingChatView";
|
||||
import { FloatingButtonContainer } from "./components/chat/FloatingButton";
|
||||
import { ChatViewRegistry } from "./shared/chat-view-registry";
|
||||
import {
|
||||
createSettingsStore,
|
||||
type SettingsStore,
|
||||
|
|
@ -84,6 +91,13 @@ export interface AgentClientPluginSettings {
|
|||
};
|
||||
// Locally saved session metadata (for agents without session/list support)
|
||||
savedSessions: SavedSessionInfo[];
|
||||
// Last used model per agent (agentId → modelId)
|
||||
lastUsedModels: Record<string, string>;
|
||||
// Floating chat button settings
|
||||
showFloatingButton: boolean;
|
||||
floatingButtonImage: string;
|
||||
floatingWindowSize: { width: number; height: number };
|
||||
floatingWindowPosition: { x: number; y: number } | null;
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: AgentClientPluginSettings = {
|
||||
|
|
@ -141,16 +155,31 @@ const DEFAULT_SETTINGS: AgentClientPluginSettings = {
|
|||
fontSize: null,
|
||||
},
|
||||
savedSessions: [],
|
||||
lastUsedModels: {},
|
||||
showFloatingButton: false,
|
||||
floatingButtonImage: "",
|
||||
floatingWindowSize: { width: 400, height: 500 },
|
||||
floatingWindowPosition: null,
|
||||
};
|
||||
|
||||
export default class AgentClientPlugin extends Plugin {
|
||||
settings: AgentClientPluginSettings;
|
||||
settingsStore!: SettingsStore;
|
||||
|
||||
/** Registry for all chat view containers (sidebar + floating) */
|
||||
viewRegistry = new ChatViewRegistry();
|
||||
|
||||
/** Map of viewId to AcpAdapter for multi-session support */
|
||||
private _adapters: Map<string, AcpAdapter> = new Map();
|
||||
/** Track the last active ChatView for keybind targeting */
|
||||
private _lastActiveChatViewId: string | null = null;
|
||||
/** Floating button container (independent from chat view instances) */
|
||||
private floatingButton: FloatingButtonContainer | null = null;
|
||||
/** Map of viewId to floating chat roots and containers (legacy, being migrated to viewRegistry) */
|
||||
private floatingChatInstances: Map<
|
||||
string,
|
||||
{ root: Root; container: HTMLElement }
|
||||
> = new Map();
|
||||
/** Counter for generating unique floating chat instance IDs */
|
||||
private floatingChatCounter = 0;
|
||||
|
||||
async onload() {
|
||||
await this.loadSettings();
|
||||
|
|
@ -210,8 +239,61 @@ export default class AgentClientPlugin extends Plugin {
|
|||
this.registerPermissionCommands();
|
||||
this.registerBroadcastCommands();
|
||||
|
||||
// Floating chat window commands
|
||||
this.addCommand({
|
||||
id: "open-floating-chat",
|
||||
name: "Open floating chat window",
|
||||
callback: () => {
|
||||
if (!this.settings.showFloatingButton) return;
|
||||
const instances = this.getFloatingChatInstances();
|
||||
if (instances.length === 0) {
|
||||
this.openNewFloatingChat(true);
|
||||
} else if (instances.length === 1) {
|
||||
this.expandFloatingChat(instances[0]);
|
||||
} else {
|
||||
const focused = this.viewRegistry.getFocused();
|
||||
if (focused && focused.viewType === "floating") {
|
||||
focused.expand();
|
||||
} else {
|
||||
this.expandFloatingChat(
|
||||
instances[instances.length - 1],
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: "open-new-floating-chat",
|
||||
name: "Open new floating chat window",
|
||||
callback: () => {
|
||||
if (!this.settings.showFloatingButton) return;
|
||||
this.openNewFloatingChat(true);
|
||||
},
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: "close-floating-chat",
|
||||
name: "Close floating chat window",
|
||||
callback: () => {
|
||||
const focused = this.viewRegistry.getFocused();
|
||||
if (focused && focused.viewType === "floating") {
|
||||
focused.collapse();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
this.addSettingTab(new AgentClientSettingTab(this.app, this));
|
||||
|
||||
// Mount floating button (always present; visibility controlled by settings inside component)
|
||||
this.floatingButton = new FloatingButtonContainer(this);
|
||||
this.floatingButton.mount();
|
||||
|
||||
// Mount initial floating chat instance only if enabled
|
||||
if (this.settings.showFloatingButton) {
|
||||
this.openNewFloatingChat();
|
||||
}
|
||||
|
||||
// Clean up all ACP sessions when Obsidian quits
|
||||
// Note: We don't wait for disconnect to complete to avoid blocking quit
|
||||
this.registerEvent(
|
||||
|
|
@ -230,7 +312,24 @@ export default class AgentClientPlugin extends Plugin {
|
|||
);
|
||||
}
|
||||
|
||||
onunload() {}
|
||||
onunload() {
|
||||
// Unmount floating button
|
||||
this.floatingButton?.unmount();
|
||||
this.floatingButton = null;
|
||||
|
||||
// Unmount all floating chat instances via registry
|
||||
for (const container of this.viewRegistry.getByType("floating")) {
|
||||
if (container instanceof FloatingViewContainer) {
|
||||
container.unmount();
|
||||
}
|
||||
}
|
||||
|
||||
// Clear registry (sidebar views are managed by Obsidian workspace)
|
||||
this.viewRegistry.clear();
|
||||
|
||||
// Clear legacy storage
|
||||
this.floatingChatInstances.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create an AcpAdapter for a specific view.
|
||||
|
|
@ -262,17 +361,15 @@ export default class AgentClientPlugin extends Plugin {
|
|||
}
|
||||
this._adapters.delete(viewId);
|
||||
}
|
||||
// Clear lastActiveChatViewId if it was this view
|
||||
if (this._lastActiveChatViewId === viewId) {
|
||||
this._lastActiveChatViewId = null;
|
||||
}
|
||||
// Note: lastActiveChatViewId is now managed by viewRegistry
|
||||
// Clearing happens automatically when view is unregistered
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last active ChatView ID for keybind targeting.
|
||||
*/
|
||||
get lastActiveChatViewId(): string | null {
|
||||
return this._lastActiveChatViewId;
|
||||
return this.viewRegistry.getFocusedId();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -280,7 +377,9 @@ export default class AgentClientPlugin extends Plugin {
|
|||
* Called when a ChatView receives focus or interaction.
|
||||
*/
|
||||
setLastActiveChatViewId(viewId: string | null): void {
|
||||
this._lastActiveChatViewId = viewId;
|
||||
if (viewId) {
|
||||
this.viewRegistry.setFocused(viewId);
|
||||
}
|
||||
}
|
||||
|
||||
async activateView() {
|
||||
|
|
@ -291,12 +390,11 @@ export default class AgentClientPlugin extends Plugin {
|
|||
|
||||
if (leaves.length > 0) {
|
||||
// Find the leaf matching lastActiveChatViewId, or fall back to first leaf
|
||||
if (this._lastActiveChatViewId) {
|
||||
const focusedId = this.lastActiveChatViewId;
|
||||
if (focusedId) {
|
||||
leaf =
|
||||
leaves.find(
|
||||
(l) =>
|
||||
(l.view as ChatView)?.viewId ===
|
||||
this._lastActiveChatViewId,
|
||||
(l) => (l.view as ChatView)?.viewId === focusedId,
|
||||
) || leaves[0];
|
||||
} else {
|
||||
leaf = leaves[0];
|
||||
|
|
@ -336,43 +434,14 @@ export default class AgentClientPlugin extends Plugin {
|
|||
|
||||
/**
|
||||
* Focus the next or previous ChatView in the list.
|
||||
* Cycles through all ChatView leaves.
|
||||
* Uses ChatViewRegistry which includes both sidebar and floating views.
|
||||
*/
|
||||
private focusChatView(direction: "next" | "previous"): void {
|
||||
const { workspace } = this.app;
|
||||
const leaves = workspace.getLeavesOfType(VIEW_TYPE_CHAT);
|
||||
|
||||
if (leaves.length === 0) {
|
||||
return;
|
||||
if (direction === "next") {
|
||||
this.viewRegistry.focusNext();
|
||||
} else {
|
||||
this.viewRegistry.focusPrevious();
|
||||
}
|
||||
|
||||
if (leaves.length === 1) {
|
||||
void workspace.revealLeaf(leaves[0]);
|
||||
this.focusTextarea(leaves[0]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Find current index
|
||||
let currentIndex = 0;
|
||||
if (this._lastActiveChatViewId) {
|
||||
const foundIndex = leaves.findIndex(
|
||||
(l) =>
|
||||
(l.view as ChatView)?.viewId === this._lastActiveChatViewId,
|
||||
);
|
||||
if (foundIndex !== -1) {
|
||||
currentIndex = foundIndex;
|
||||
}
|
||||
}
|
||||
|
||||
// Get target index (cycle)
|
||||
const targetIndex =
|
||||
direction === "next"
|
||||
? (currentIndex + 1) % leaves.length
|
||||
: (currentIndex - 1 + leaves.length) % leaves.length;
|
||||
const targetLeaf = leaves[targetIndex];
|
||||
|
||||
void workspace.revealLeaf(targetLeaf);
|
||||
this.focusTextarea(targetLeaf);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -428,6 +497,63 @@ export default class AgentClientPlugin extends Plugin {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a new floating chat window.
|
||||
* Each window is independent with its own session.
|
||||
*/
|
||||
openNewFloatingChat(
|
||||
initialExpanded = false,
|
||||
initialPosition?: { x: number; y: number },
|
||||
): void {
|
||||
// instanceId is just the counter (e.g., "0", "1", "2")
|
||||
// FloatingViewContainer will create viewId as "floating-chat-{instanceId}"
|
||||
const instanceId = String(this.floatingChatCounter++);
|
||||
const container = createFloatingChat(
|
||||
this,
|
||||
instanceId,
|
||||
initialExpanded,
|
||||
initialPosition,
|
||||
);
|
||||
// Store by viewId for consistent lookup
|
||||
this.floatingChatInstances.set(container.viewId, {
|
||||
root: null as unknown as Root,
|
||||
container: container.getContainerEl(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Close a specific floating chat window.
|
||||
* @param viewId - The viewId in "floating-chat-{id}" format (from getFloatingChatInstances())
|
||||
*/
|
||||
closeFloatingChat(viewId: string): void {
|
||||
const container = this.viewRegistry.get(viewId);
|
||||
if (container && container instanceof FloatingViewContainer) {
|
||||
container.unmount();
|
||||
}
|
||||
// Also remove from legacy floatingChatInstances if present
|
||||
this.floatingChatInstances.delete(viewId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all floating chat instance viewIds.
|
||||
* @returns Array of viewIds in "floating-chat-{id}" format
|
||||
*/
|
||||
getFloatingChatInstances(): string[] {
|
||||
return this.viewRegistry.getByType("floating").map((v) => v.viewId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand a specific floating chat window by triggering a custom event.
|
||||
* @param viewId - The viewId in "floating-chat-{id}" format (from getFloatingChatInstances())
|
||||
*/
|
||||
expandFloatingChat(viewId: string): void {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("agent-client:expand-floating-chat", {
|
||||
detail: { viewId },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available agents (claude, codex, gemini, custom)
|
||||
*/
|
||||
|
|
@ -491,10 +617,17 @@ export default class AgentClientPlugin extends Plugin {
|
|||
id: "approve-active-permission",
|
||||
name: "Approve active permission",
|
||||
callback: async () => {
|
||||
await this.activateView();
|
||||
// Only activate sidebar view if the focused view is a sidebar
|
||||
// (avoid stealing focus from floating views)
|
||||
const focusedId = this.lastActiveChatViewId;
|
||||
const isFloatingFocused =
|
||||
focusedId?.startsWith("floating-chat-");
|
||||
if (!isFloatingFocused) {
|
||||
await this.activateView();
|
||||
}
|
||||
this.app.workspace.trigger(
|
||||
"agent-client:approve-active-permission" as "quit",
|
||||
this._lastActiveChatViewId,
|
||||
this.lastActiveChatViewId,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
|
@ -503,10 +636,17 @@ export default class AgentClientPlugin extends Plugin {
|
|||
id: "reject-active-permission",
|
||||
name: "Reject active permission",
|
||||
callback: async () => {
|
||||
await this.activateView();
|
||||
// Only activate sidebar view if the focused view is a sidebar
|
||||
// (avoid stealing focus from floating views)
|
||||
const focusedId = this.lastActiveChatViewId;
|
||||
const isFloatingFocused =
|
||||
focusedId?.startsWith("floating-chat-");
|
||||
if (!isFloatingFocused) {
|
||||
await this.activateView();
|
||||
}
|
||||
this.app.workspace.trigger(
|
||||
"agent-client:reject-active-permission" as "quit",
|
||||
this._lastActiveChatViewId,
|
||||
this.lastActiveChatViewId,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
|
@ -515,10 +655,17 @@ export default class AgentClientPlugin extends Plugin {
|
|||
id: "toggle-auto-mention",
|
||||
name: "Toggle auto-mention",
|
||||
callback: async () => {
|
||||
await this.activateView();
|
||||
// Only activate sidebar view if the focused view is a sidebar
|
||||
// (avoid stealing focus from floating views)
|
||||
const focusedId = this.lastActiveChatViewId;
|
||||
const isFloatingFocused =
|
||||
focusedId?.startsWith("floating-chat-");
|
||||
if (!isFloatingFocused) {
|
||||
await this.activateView();
|
||||
}
|
||||
this.app.workspace.trigger(
|
||||
"agent-client:toggle-auto-mention" as "quit",
|
||||
this._lastActiveChatViewId,
|
||||
this.lastActiveChatViewId,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
|
@ -529,7 +676,7 @@ export default class AgentClientPlugin extends Plugin {
|
|||
callback: () => {
|
||||
this.app.workspace.trigger(
|
||||
"agent-client:cancel-message" as "quit",
|
||||
this._lastActiveChatViewId,
|
||||
this.lastActiveChatViewId,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
|
@ -571,23 +718,15 @@ export default class AgentClientPlugin extends Plugin {
|
|||
* Copy prompt from active view to all other views
|
||||
*/
|
||||
private broadcastPrompt(): void {
|
||||
const allChatViews = this.getAllChatViews();
|
||||
if (allChatViews.length === 0) {
|
||||
const allViews = this.viewRegistry.getAll();
|
||||
if (allViews.length === 0) {
|
||||
new Notice("[Agent Client] No chat views open");
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the active (source) view
|
||||
const activeViewId = this._lastActiveChatViewId;
|
||||
const sourceView = allChatViews.find((v) => v.viewId === activeViewId);
|
||||
|
||||
if (!sourceView) {
|
||||
new Notice("[Agent Client] No active chat view found");
|
||||
return;
|
||||
}
|
||||
|
||||
// Get input state from source view
|
||||
const inputState = sourceView.getInputState();
|
||||
const inputState = this.viewRegistry.toFocused((v) =>
|
||||
v.getInputState(),
|
||||
);
|
||||
if (
|
||||
!inputState ||
|
||||
(inputState.text.trim() === "" && inputState.images.length === 0)
|
||||
|
|
@ -596,10 +735,8 @@ export default class AgentClientPlugin extends Plugin {
|
|||
return;
|
||||
}
|
||||
|
||||
// Broadcast to all other views
|
||||
const targetViews = allChatViews.filter(
|
||||
(v) => v.viewId !== activeViewId,
|
||||
);
|
||||
const focusedId = this.viewRegistry.getFocusedId();
|
||||
const targetViews = allViews.filter((v) => v.viewId !== focusedId);
|
||||
if (targetViews.length === 0) {
|
||||
new Notice("[Agent Client] No other chat views to broadcast to");
|
||||
return;
|
||||
|
|
@ -614,20 +751,18 @@ export default class AgentClientPlugin extends Plugin {
|
|||
* Send message in all views that can send
|
||||
*/
|
||||
private async broadcastSend(): Promise<void> {
|
||||
const allChatViews = this.getAllChatViews();
|
||||
if (allChatViews.length === 0) {
|
||||
const allViews = this.viewRegistry.getAll();
|
||||
if (allViews.length === 0) {
|
||||
new Notice("[Agent Client] No chat views open");
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter to views that can send
|
||||
const sendableViews = allChatViews.filter((v) => v.canSend());
|
||||
const sendableViews = allViews.filter((v) => v.canSend());
|
||||
if (sendableViews.length === 0) {
|
||||
new Notice("[Agent Client] No views ready to send");
|
||||
return;
|
||||
}
|
||||
|
||||
// Send in all views concurrently
|
||||
await Promise.allSettled(sendableViews.map((v) => v.sendMessage()));
|
||||
}
|
||||
|
||||
|
|
@ -635,28 +770,16 @@ export default class AgentClientPlugin extends Plugin {
|
|||
* Cancel operation in all views
|
||||
*/
|
||||
private async broadcastCancel(): Promise<void> {
|
||||
const allChatViews = this.getAllChatViews();
|
||||
if (allChatViews.length === 0) {
|
||||
const allViews = this.viewRegistry.getAll();
|
||||
if (allViews.length === 0) {
|
||||
new Notice("[Agent Client] No chat views open");
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancel in all views concurrently
|
||||
await Promise.allSettled(allChatViews.map((v) => v.cancelOperation()));
|
||||
|
||||
await Promise.allSettled(allViews.map((v) => v.cancelOperation()));
|
||||
new Notice("[Agent Client] Cancel broadcast to all views");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all open ChatView instances
|
||||
*/
|
||||
private getAllChatViews(): ChatView[] {
|
||||
const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE_CHAT);
|
||||
return leaves
|
||||
.map((leaf) => leaf.view)
|
||||
.filter((view): view is ChatView => view instanceof ChatView);
|
||||
}
|
||||
|
||||
async loadSettings() {
|
||||
const rawSettings = ((await this.loadData()) ?? {}) as Record<
|
||||
string,
|
||||
|
|
@ -920,6 +1043,64 @@ export default class AgentClientPlugin extends Plugin {
|
|||
savedSessions: Array.isArray(rawSettings.savedSessions)
|
||||
? (rawSettings.savedSessions as SavedSessionInfo[])
|
||||
: DEFAULT_SETTINGS.savedSessions,
|
||||
lastUsedModels: (() => {
|
||||
const raw = rawSettings.lastUsedModels;
|
||||
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
||||
const result: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(
|
||||
raw as Record<string, unknown>,
|
||||
)) {
|
||||
if (
|
||||
typeof key === "string" &&
|
||||
key.length > 0 &&
|
||||
typeof value === "string" &&
|
||||
value.length > 0
|
||||
) {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return DEFAULT_SETTINGS.lastUsedModels;
|
||||
})(),
|
||||
showFloatingButton:
|
||||
typeof rawSettings.showFloatingButton === "boolean"
|
||||
? rawSettings.showFloatingButton
|
||||
: DEFAULT_SETTINGS.showFloatingButton,
|
||||
floatingButtonImage:
|
||||
typeof rawSettings.floatingButtonImage === "string"
|
||||
? rawSettings.floatingButtonImage
|
||||
: DEFAULT_SETTINGS.floatingButtonImage,
|
||||
floatingWindowSize: (() => {
|
||||
const raw = rawSettings.floatingWindowSize as
|
||||
| { width?: number; height?: number }
|
||||
| null
|
||||
| undefined;
|
||||
if (
|
||||
raw &&
|
||||
typeof raw === "object" &&
|
||||
typeof raw.width === "number" &&
|
||||
typeof raw.height === "number"
|
||||
) {
|
||||
return { width: raw.width, height: raw.height };
|
||||
}
|
||||
return DEFAULT_SETTINGS.floatingWindowSize;
|
||||
})(),
|
||||
floatingWindowPosition: (() => {
|
||||
const raw = rawSettings.floatingWindowPosition as
|
||||
| { x?: number; y?: number }
|
||||
| null
|
||||
| undefined;
|
||||
if (
|
||||
raw &&
|
||||
typeof raw === "object" &&
|
||||
typeof raw.x === "number" &&
|
||||
typeof raw.y === "number"
|
||||
) {
|
||||
return { x: raw.x, y: raw.y };
|
||||
}
|
||||
return null;
|
||||
})(),
|
||||
};
|
||||
|
||||
this.ensureDefaultAgentId();
|
||||
|
|
|
|||
219
src/shared/chat-view-registry.ts
Normal file
219
src/shared/chat-view-registry.ts
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
/**
|
||||
* Registry for managing all chat view containers.
|
||||
*
|
||||
* Provides unified access to views for:
|
||||
* - Focus tracking (replacing _lastActiveChatViewId + floating tracking)
|
||||
* - Broadcast commands (extending to all view types)
|
||||
* - Multi-view operations (focusNext, toAll, etc.)
|
||||
*
|
||||
* Design notes:
|
||||
* - Views register themselves on mount, unregister on close
|
||||
* - Focus is tracked via focusedViewId
|
||||
* - Registry does not own view lifecycle, only tracks references
|
||||
* - clear() is called during plugin unload for cleanup
|
||||
* - focusNext/Previous order is based on registration order, not workspace leaf order
|
||||
* (this is acceptable as users don't have strong expectations about the order)
|
||||
*/
|
||||
|
||||
import type {
|
||||
IChatViewContainer,
|
||||
ChatViewType,
|
||||
} from "../domain/ports/chat-view-container.port";
|
||||
import { getLogger } from "./logger";
|
||||
|
||||
export class ChatViewRegistry {
|
||||
private views = new Map<string, IChatViewContainer>();
|
||||
private focusedViewId: string | null = null;
|
||||
private logger = getLogger();
|
||||
|
||||
// ============================================================
|
||||
// Registration
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Register a view container.
|
||||
* The first registered view automatically becomes focused.
|
||||
*/
|
||||
register(view: IChatViewContainer): void {
|
||||
this.logger.log(
|
||||
`[ChatViewRegistry] Registering view: ${view.viewId} (${view.viewType})`,
|
||||
);
|
||||
this.views.set(view.viewId, view);
|
||||
|
||||
// First view becomes focused by default
|
||||
if (this.views.size === 1) {
|
||||
this.setFocused(view.viewId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a view container.
|
||||
* If the focused view is unregistered, focus moves to another view.
|
||||
*/
|
||||
unregister(viewId: string): void {
|
||||
this.logger.log(`[ChatViewRegistry] Unregistering view: ${viewId}`);
|
||||
const view = this.views.get(viewId);
|
||||
if (view) {
|
||||
view.onDeactivate();
|
||||
}
|
||||
this.views.delete(viewId);
|
||||
|
||||
// Move focus if this was the focused view
|
||||
if (this.focusedViewId === viewId) {
|
||||
const remaining = Array.from(this.views.keys());
|
||||
this.focusedViewId = remaining.length > 0 ? remaining[0] : null;
|
||||
if (this.focusedViewId) {
|
||||
this.views.get(this.focusedViewId)?.onActivate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all views from the registry.
|
||||
* Called during plugin unload to clean up resources.
|
||||
* Note: This does NOT call unmount() on views - that should be done separately.
|
||||
*/
|
||||
clear(): void {
|
||||
this.logger.log("[ChatViewRegistry] Clearing all views");
|
||||
for (const view of this.views.values()) {
|
||||
view.onDeactivate();
|
||||
}
|
||||
this.views.clear();
|
||||
this.focusedViewId = null;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Focus Management
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Get the currently focused view.
|
||||
*/
|
||||
getFocused(): IChatViewContainer | null {
|
||||
return this.focusedViewId
|
||||
? (this.views.get(this.focusedViewId) ?? null)
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the focused view ID.
|
||||
*/
|
||||
getFocusedId(): string | null {
|
||||
return this.focusedViewId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a view as focused.
|
||||
*/
|
||||
setFocused(viewId: string): void {
|
||||
if (this.focusedViewId === viewId) return;
|
||||
if (!this.views.has(viewId)) return;
|
||||
|
||||
// Deactivate previous
|
||||
if (this.focusedViewId) {
|
||||
this.views.get(this.focusedViewId)?.onDeactivate();
|
||||
}
|
||||
|
||||
// Activate new
|
||||
this.focusedViewId = viewId;
|
||||
this.views.get(viewId)?.onActivate();
|
||||
this.logger.log(`[ChatViewRegistry] Focus changed to: ${viewId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus the next view in the list (cyclic).
|
||||
* Order is based on registration order (Map insertion order).
|
||||
*/
|
||||
focusNext(): void {
|
||||
const ids = Array.from(this.views.keys());
|
||||
if (ids.length === 0) return;
|
||||
|
||||
const currentIndex = this.focusedViewId
|
||||
? ids.indexOf(this.focusedViewId)
|
||||
: -1;
|
||||
const nextIndex = (currentIndex + 1) % ids.length;
|
||||
this.setFocused(ids[nextIndex]);
|
||||
this.views.get(ids[nextIndex])?.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus the previous view in the list (cyclic).
|
||||
* Order is based on registration order (Map insertion order).
|
||||
*/
|
||||
focusPrevious(): void {
|
||||
const ids = Array.from(this.views.keys());
|
||||
if (ids.length === 0) return;
|
||||
|
||||
const currentIndex = this.focusedViewId
|
||||
? ids.indexOf(this.focusedViewId)
|
||||
: 0;
|
||||
const prevIndex = (currentIndex - 1 + ids.length) % ids.length;
|
||||
this.setFocused(ids[prevIndex]);
|
||||
this.views.get(ids[prevIndex])?.focus();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Broadcast Operations
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Execute action on the focused view only.
|
||||
*/
|
||||
toFocused<T>(action: (view: IChatViewContainer) => T): T | null {
|
||||
const focused = this.getFocused();
|
||||
return focused ? action(focused) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute action on all views.
|
||||
*/
|
||||
toAll(action: (view: IChatViewContainer) => void): void {
|
||||
this.views.forEach(action);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute action on views of a specific type.
|
||||
*/
|
||||
toType(
|
||||
type: ChatViewType,
|
||||
action: (view: IChatViewContainer) => void,
|
||||
): void {
|
||||
this.views.forEach((view) => {
|
||||
if (view.viewType === type) action(view);
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Query
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Get all registered views.
|
||||
*/
|
||||
getAll(): IChatViewContainer[] {
|
||||
return Array.from(this.views.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get views of a specific type.
|
||||
*/
|
||||
getByType(type: ChatViewType): IChatViewContainer[] {
|
||||
return Array.from(this.views.values()).filter(
|
||||
(v) => v.viewType === type,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a view by ID.
|
||||
*/
|
||||
get(viewId: string): IChatViewContainer | null {
|
||||
return this.views.get(viewId) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of registered views.
|
||||
*/
|
||||
get size(): number {
|
||||
return this.views.size;
|
||||
}
|
||||
}
|
||||
14
src/shared/floating-utils.ts
Normal file
14
src/shared/floating-utils.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
/**
|
||||
* Clamp position so the floating window stays within the viewport.
|
||||
*/
|
||||
export function clampPosition(
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
): { x: number; y: number } {
|
||||
return {
|
||||
x: Math.max(0, Math.min(x, window.innerWidth - width)),
|
||||
y: Math.max(0, Math.min(y, window.innerHeight - height)),
|
||||
};
|
||||
}
|
||||
335
styles.css
335
styles.css
|
|
@ -264,7 +264,6 @@ If your plugin does not need CSS, delete this file.
|
|||
.agent-client-message-permission-request {
|
||||
padding: 6px 0px;
|
||||
margin-top: 4px;
|
||||
background-color: var(--background-secondary);
|
||||
font-size: 14px;
|
||||
user-select: text;
|
||||
overflow-wrap: break-word;
|
||||
|
|
@ -432,6 +431,32 @@ If your plugin does not need CSS, delete this file.
|
|||
gap: 8px;
|
||||
}
|
||||
|
||||
/* ===== Inline Header (Floating/CodeBlock) ===== */
|
||||
.agent-client-inline-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--background-modifier-border);
|
||||
background-color: var(--background-primary);
|
||||
flex-shrink: 0;
|
||||
flex-wrap: nowrap;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.agent-client-inline-header-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.agent-client-inline-header-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
justify-content: flex-end;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Messages container */
|
||||
.agent-client-chat-view-messages {
|
||||
flex: 1;
|
||||
|
|
@ -442,6 +467,7 @@ If your plugin does not need CSS, delete this file.
|
|||
flex-direction: column;
|
||||
gap: 2px;
|
||||
font-size: var(--ac-chat-font-size);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Allow horizontal scroll for wide tables within messages */
|
||||
|
|
@ -1151,6 +1177,12 @@ If your plugin does not need CSS, delete this file.
|
|||
overflow-wrap: break-word;
|
||||
word-wrap: break-word;
|
||||
word-break: break-word;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.agent-client-markdown-text-renderer .markdown-rendered pre {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* Tool Call Diff Renderer */
|
||||
|
|
@ -1791,3 +1823,304 @@ If your plugin does not need CSS, delete this file.
|
|||
.agent-client-header-menu-back:hover {
|
||||
background-color: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
/* ===== Floating Chat Button ===== */
|
||||
/* Floating button & menu root — higher z-index, always above windows */
|
||||
.agent-client-floating-button-root {
|
||||
position: fixed;
|
||||
z-index: 30;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.agent-client-floating-button-root > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Floating chat window root — lower z-index */
|
||||
.agent-client-floating-view-root {
|
||||
position: fixed;
|
||||
z-index: 20;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.agent-client-floating-view-root.is-focused {
|
||||
z-index: 21;
|
||||
}
|
||||
|
||||
.agent-client-floating-view-root > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.agent-client-floating-button {
|
||||
position: fixed;
|
||||
bottom: 30px;
|
||||
right: 40px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
min-width: 48px;
|
||||
min-height: 48px;
|
||||
border-radius: 50%;
|
||||
background: var(--interactive-accent);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
transition:
|
||||
transform 0.2s,
|
||||
box-shadow 0.2s;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.agent-client-floating-button.has-custom-image {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.agent-client-floating-button:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.agent-client-floating-button img {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
min-width: 48px;
|
||||
min-height: 48px;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
border-radius: 50%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.agent-client-floating-button-fallback {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-on-accent);
|
||||
}
|
||||
|
||||
.agent-client-floating-button-fallback svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
/* ===== Floating Chat Window ===== */
|
||||
.agent-client-floating-window {
|
||||
position: fixed;
|
||||
background: var(--background-primary);
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
resize: both;
|
||||
min-width: 300px;
|
||||
min-height: 460px;
|
||||
}
|
||||
|
||||
.agent-client-floating-header {
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
border-radius: 11px 11px 0 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.agent-client-floating-header:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.agent-client-floating-header .agent-client-inline-header {
|
||||
border-radius: 11px 11px 0 0;
|
||||
}
|
||||
|
||||
.agent-client-floating-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 4px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.agent-client-floating-messages-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden; /* Let inner .agent-client-chat-view-messages handle scrolling */
|
||||
margin-bottom: 8px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Floating chat instance selector menu */
|
||||
.agent-client-floating-instance-menu {
|
||||
position: fixed;
|
||||
bottom: 100px;
|
||||
right: 20px;
|
||||
min-width: 220px;
|
||||
background: var(--background-primary);
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.agent-client-floating-instance-menu-header {
|
||||
padding: 12px 16px;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
background: var(--background-secondary);
|
||||
border-bottom: 1px solid var(--background-modifier-border);
|
||||
}
|
||||
|
||||
.agent-client-floating-instance-menu-item {
|
||||
padding: 10px 16px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: var(--text-normal);
|
||||
transition: background-color 0.1s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.agent-client-floating-instance-menu-label {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.agent-client-floating-instance-menu-item:hover {
|
||||
background: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.agent-client-floating-instance-menu-close {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
border: none !important;
|
||||
border-radius: 0 !important;
|
||||
background-color: transparent !important;
|
||||
box-shadow: none !important;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
outline: none !important;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.agent-client-floating-instance-menu-close:hover {
|
||||
background-color: var(--background-modifier-hover) !important;
|
||||
color: var(--interactive-accent);
|
||||
}
|
||||
|
||||
/* ===== Code Block Chat View ===== */
|
||||
/* TODO(code-block): Styles for future code block chat view */
|
||||
.agent-client-code-block-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: 8px;
|
||||
background: var(--background-primary);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.agent-client-code-block-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.agent-client-code-block-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 8px;
|
||||
background: var(--background-secondary);
|
||||
border-radius: 4px;
|
||||
margin-bottom: 4px;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.agent-client-code-block-status-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.agent-client-code-block-messages {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* ===== Agent Selector (InlineHeader) ===== */
|
||||
.agent-client-agent-selector {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 4px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.1s ease;
|
||||
height: 20px;
|
||||
max-width: 160px;
|
||||
}
|
||||
|
||||
.agent-client-agent-selector:hover {
|
||||
background-color: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.agent-client-agent-selector-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
pointer-events: none;
|
||||
color: var(--text-faint);
|
||||
margin-left: 2px;
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.agent-client-agent-selector-icon svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.agent-client-agent-selector select.dropdown {
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
border: none !important;
|
||||
border-radius: 0 !important;
|
||||
background: none !important;
|
||||
background-color: transparent !important;
|
||||
background-image: none !important;
|
||||
box-shadow: none !important;
|
||||
color: var(--text-normal) !important;
|
||||
font-size: 12px !important;
|
||||
cursor: pointer !important;
|
||||
outline: none !important;
|
||||
appearance: none !important;
|
||||
-webkit-appearance: none !important;
|
||||
-moz-appearance: none !important;
|
||||
max-width: 140px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.agent-client-agent-selector select.dropdown:hover,
|
||||
.agent-client-agent-selector select.dropdown:focus {
|
||||
color: var(--text-normal) !important;
|
||||
background-color: transparent !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* Agent label (single agent) */
|
||||
.agent-client-agent-label {
|
||||
color: var(--text-normal);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue