Merge branch 'dev' into Gnonymous/feature/chat-font-size

This commit is contained in:
RAIT-09 2026-02-18 01:43:59 +09:00 committed by GitHub
commit eb70f315bb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 3815 additions and 983 deletions

View file

@ -24,6 +24,7 @@ https://github.com/user-attachments/assets/1c538349-b3fb-44dd-a163-7331cbca7824
- **スラッシュコマンド**: エージェントが提供する`/`コマンドを使用
- **マルチエージェント**: Claude Code、Codex、Gemini CLI、カスタムエージェントを切り替え
- **マルチセッション**: 複数のエージェントを別々のビューで同時実行
- **フローティングチャット**: 素早くアクセスできる折りたたみ可能なチャットウィンドウ
- **モード・モデル切り替え**: チャット画面からAIモデルやエージェントモードを変更
- **セッション履歴**: 過去の会話を再開またはフォーク
- **チャットエクスポート**: 会話をMarkdownートとして保存

View file

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

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

View file

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

View 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 |

View file

@ -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:

View file

@ -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
View file

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

View file

@ -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": {

View file

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

View file

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

View file

@ -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;
}
/**

View 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>
)}
</>
);
}

View 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;
}

View file

@ -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({

View 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>
);
}

View file

@ -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;
}
/**

View 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;
}

View file

@ -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
// ─────────────────────────────────────────────────────────────────────

View 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;
}

View file

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

View 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,
};
}

View 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 };
}

View file

@ -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();

View 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;
}
}

View 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)),
};
}

View file

@ -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;
}