diff --git a/README.ja.md b/README.ja.md
index 133a340..13efcee 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -24,6 +24,7 @@ https://github.com/user-attachments/assets/1c538349-b3fb-44dd-a163-7331cbca7824
- **スラッシュコマンド**: エージェントが提供する`/`コマンドを使用
- **マルチエージェント**: Claude Code、Codex、Gemini CLI、カスタムエージェントを切り替え
- **マルチセッション**: 複数のエージェントを別々のビューで同時実行
+- **フローティングチャット**: 素早くアクセスできる折りたたみ可能なチャットウィンドウ
- **モード・モデル切り替え**: チャット画面からAIモデルやエージェントモードを変更
- **セッション履歴**: 過去の会話を再開またはフォーク
- **チャットエクスポート**: 会話をMarkdownノートとして保存
diff --git a/README.md b/README.md
index 9eebd72..1ddc421 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts
index 138e66a..80a89dc 100644
--- a/docs/.vitepress/config.mts
+++ b/docs/.vitepress/config.mts
@@ -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" },
diff --git a/docs/public/images/floating-chat-button.webp b/docs/public/images/floating-chat-button.webp
new file mode 100644
index 0000000..125dfb6
Binary files /dev/null and b/docs/public/images/floating-chat-button.webp differ
diff --git a/docs/public/images/floating-chat-instance-menu.webp b/docs/public/images/floating-chat-instance-menu.webp
new file mode 100644
index 0000000..65a952b
Binary files /dev/null and b/docs/public/images/floating-chat-instance-menu.webp differ
diff --git a/docs/public/images/floating-chat-view.webp b/docs/public/images/floating-chat-view.webp
new file mode 100644
index 0000000..0fad7a8
Binary files /dev/null and b/docs/public/images/floating-chat-view.webp differ
diff --git a/docs/usage/commands.md b/docs/usage/commands.md
index 5a80766..b880f00 100644
--- a/docs/usage/commands.md
+++ b/docs/usage/commands.md
@@ -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 |
diff --git a/docs/usage/floating-chat.md b/docs/usage/floating-chat.md
new file mode 100644
index 0000000..7321276
--- /dev/null
+++ b/docs/usage/floating-chat.md
@@ -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.
+
+
+
+
+
+## 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
+
+
+
+
+
+## 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:
+
+
+
+
+
+- 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 |
diff --git a/docs/usage/index.md b/docs/usage/index.md
index b2f0fbc..0d778ee 100644
--- a/docs/usage/index.md
+++ b/docs/usage/index.md
@@ -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:
diff --git a/manifest.json b/manifest.json
index c7a17b4..181dc9c 100644
--- a/manifest.json
+++ b/manifest.json
@@ -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",
diff --git a/package-lock.json b/package-lock.json
index 1a9dac9..628d21e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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,
diff --git a/package.json b/package.json
index c74672c..883c517 100644
--- a/package.json
+++ b/package.json
@@ -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": {
diff --git a/src/components/chat/ChatInput.tsx b/src/components/chat/ChatInput.tsx
index 586f9f7..3fa9c1b 100644
--- a/src/components/chat/ChatInput.tsx
+++ b/src/components/chat/ChatInput.tsx
@@ -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("");
const [isDraggingOver, setIsDraggingOver] = useState(false);
+ // Input history navigation (ArrowUp/ArrowDown)
+ const { handleHistoryKeyDown, resetHistory } = useInputHistory(
+ messages,
+ onInputChange,
+ );
+
// Refs
const textareaRef = useRef(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,
diff --git a/src/components/chat/ChatMessages.tsx b/src/components/chat/ChatMessages.tsx
index dc257ad..508cfa9 100644
--- a/src/components/chat/ChatMessages.tsx
+++ b/src/components/chat/ChatMessages.tsx
@@ -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 */
diff --git a/src/components/chat/ChatView.tsx b/src/components/chat/ChatView.tsx
index b6ab494..17cfa13 100644
--- a/src/components/chat/ChatView.tsx
+++ b/src/components/chat/ChatView.tsx
@@ -1,12 +1,10 @@
-import {
- ItemView,
- WorkspaceLeaf,
- Platform,
- Notice,
- FileSystemAdapter,
-} from "obsidian";
+import { ItemView, WorkspaceLeaf, Platform, Notice } from "obsidian";
+import type {
+ IChatViewContainer,
+ ChatViewType,
+} from "../../domain/ports/chat-view-container.port";
import * as React from "react";
-const { useState, useRef, useEffect, useMemo, useCallback } = React;
+const { useState, useRef, useEffect, useCallback } = React;
import { createRoot, Root } from "react-dom/client";
import type AgentClientPlugin from "../../plugin";
@@ -16,38 +14,18 @@ import type { ChatInputState } from "../../domain/models/chat-input-state";
import { ChatHeader } from "./ChatHeader";
import { ChatMessages } from "./ChatMessages";
import { ChatInput } from "./ChatInput";
-import type { AttachedImage } from "./ImagePreviewStrip";
-import { SessionHistoryModal } from "./SessionHistoryModal";
-import { ConfirmDeleteModal } from "./ConfirmDeleteModal";
import { HeaderMenu } from "./HeaderMenu";
-// Service imports
-import { NoteMentionService } from "../../adapters/obsidian/mention-service";
-
// Utility imports
import { getLogger, Logger } from "../../shared/logger";
-import { ChatExporter } from "../../shared/chat-exporter";
// Adapter imports
import type { IAcpClient } from "../../adapters/acp/acp.adapter";
-import { ObsidianVaultAdapter } from "../../adapters/obsidian/vault.adapter";
// Hooks imports
-import { useSettings } from "../../hooks/useSettings";
-import { useMentions } from "../../hooks/useMentions";
-import { useSlashCommands } from "../../hooks/useSlashCommands";
-import { useAutoMention } from "../../hooks/useAutoMention";
-import { useAgentSession } from "../../hooks/useAgentSession";
-import { useChat } from "../../hooks/useChat";
-import { usePermission } from "../../hooks/usePermission";
-import { useAutoExport } from "../../hooks/useAutoExport";
-import { useSessionHistory } from "../../hooks/useSessionHistory";
+import { useChatController } from "../../hooks/useChatController";
// Domain model imports
-import type {
- SessionModeState,
- SessionModelState,
-} from "../../domain/models/chat-session";
import type { ImagePromptContent } from "../../domain/models/prompt-content";
// Type definitions for Obsidian internal APIs
@@ -77,32 +55,74 @@ function ChatComponent({
}
// ============================================================
- // Memoized Services & Adapters
+ // Agent ID State (synced with Obsidian view state)
+ // Must be defined before useChatController to pass as initialAgentId
// ============================================================
- const logger = getLogger();
-
- const vaultPath = useMemo(() => {
- const adapter = plugin.app.vault.adapter;
- if (adapter instanceof FileSystemAdapter) {
- return adapter.getBasePath();
- }
- // Fallback for non-FileSystemAdapter (e.g., mobile)
- return process.cwd();
- }, [plugin]);
-
- const noteMentionService = useMemo(
- () => new NoteMentionService(plugin),
- [plugin],
+ const [restoredAgentId, setRestoredAgentId] = useState(
+ view.getInitialAgentId() ?? undefined,
);
- // Cleanup NoteMentionService when component unmounts
- useEffect(() => {
- return () => {
- noteMentionService.destroy();
- };
- }, [noteMentionService]);
+ // ============================================================
+ // Chat Controller Hook (Centralized Logic)
+ // ============================================================
+ const controller = useChatController({
+ plugin,
+ viewId,
+ initialAgentId: restoredAgentId,
+ });
- // Track this view as the last active when it receives focus or interaction
+ const {
+ logger,
+ acpAdapter,
+ settings,
+ session,
+ isSessionReady,
+ messages,
+ isSending,
+ isUpdateAvailable,
+ permission,
+ mentions,
+ autoMention,
+ slashCommands,
+ sessionHistory,
+ activeAgentLabel,
+ availableAgents,
+ errorInfo,
+ handleSendMessage,
+ handleStopGeneration,
+ handleNewChat,
+ handleExportChat,
+ handleRestartAgent,
+ handleClearError,
+ handleOpenHistory,
+ handleSetMode,
+ handleSetModel,
+ inputValue,
+ setInputValue,
+ attachedImages,
+ setAttachedImages,
+ restoredMessage,
+ handleRestoredMessageConsumed,
+ } = controller;
+
+ // ============================================================
+ // Agent ID Restoration (ChatView-specific)
+ // Subscribe to agentId restoration from Obsidian's setState
+ // Note: logger is now available from controller
+ // ============================================================
+ useEffect(() => {
+ const unsubscribe = view.onAgentIdRestored((agentId) => {
+ logger.log(
+ `[ChatView] Agent ID restored from workspace: ${agentId}`,
+ );
+ setRestoredAgentId(agentId);
+ });
+ return unsubscribe;
+ }, [view, logger]);
+
+ // ============================================================
+ // Focus Tracking (ChatView-specific)
+ // ============================================================
useEffect(() => {
const handleFocus = () => {
plugin.setLastActiveChatViewId(viewId);
@@ -121,287 +141,38 @@ function ChatComponent({
};
}, [plugin, viewId, view.containerEl]);
- const acpAdapter = useMemo(
- () => plugin.getOrCreateAdapter(viewId),
- [plugin, viewId],
- );
- const acpClientRef = useRef(acpAdapter);
-
- const vaultAccessAdapter = useMemo(() => {
- return new ObsidianVaultAdapter(plugin, noteMentionService);
- }, [plugin, noteMentionService]);
-
- // ============================================================
- // Custom Hooks
- // ============================================================
- const settings = useSettings(plugin);
-
- // ============================================================
- // Agent ID State (synced with Obsidian view state)
- // ============================================================
- // Start with view's current value (may be null on initial mount before setState)
- const [restoredAgentId, setRestoredAgentId] = useState(
- view.getInitialAgentId() ?? undefined,
- );
-
- // Subscribe to agentId restoration from Obsidian's setState
- useEffect(() => {
- const unsubscribe = view.onAgentIdRestored((agentId) => {
- logger.log(
- `[ChatView] Agent ID restored from workspace: ${agentId}`,
- );
- setRestoredAgentId(agentId);
- });
- return unsubscribe;
- }, [view, logger]);
-
- const agentSession = useAgentSession(
- acpAdapter,
- plugin.settingsStore,
- vaultPath,
- restoredAgentId,
- );
-
- 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
- // Session load callback - called when a session is loaded/resumed/forked from history
- // Note: Conversation history is received via session/update notifications for load
- const handleSessionLoad = useCallback(
- (
- sessionId: string,
- modes?: SessionModeState,
- models?: SessionModelState,
- ) => {
- // Log that session was loaded
- logger.log(
- `[ChatView] Session loaded/resumed/forked: ${sessionId}`,
- {
- modes,
- models,
- },
- );
-
- // Update session state with new session ID and modes/models
- // This is critical for session/update notifications to be accepted
- agentSession.updateSessionFromLoad(sessionId, modes, models);
-
- // Conversation history for load is received via session/update notifications
- // but we ignore them and use local history instead (see handleLoadStart/handleLoadEnd)
- },
- [logger, agentSession],
- );
-
- /**
- * Called when session/load starts.
- * Sets flag to ignore history replay messages from agent.
- */
- const handleLoadStart = useCallback(() => {
- logger.log("[ChatView] session/load started, ignoring history replay");
- setIsLoadingSessionHistory(true);
- // Clear existing messages before loading local history
- chat.clearMessages();
- }, [logger, chat]);
-
- /**
- * Called when session/load ends.
- * Clears flag to resume normal message processing.
- */
- const handleLoadEnd = useCallback(() => {
- logger.log("[ChatView] 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(null);
- /** Flag to ignore history replay messages during session/load */
- const [isLoadingSessionHistory, setIsLoadingSessionHistory] =
- useState(false);
- /** Whether the settings menu is open */
- const [isMenuOpen, setIsMenuOpen] = useState(false);
-
- // ============================================================
- // Input State (lifted from ChatInput for broadcast commands)
- // ============================================================
- const [inputValue, setInputValue] = useState("");
- const [attachedImages, setAttachedImages] = useState([]);
-
// ============================================================
// Refs
// ============================================================
- /** Ref for session history modal (persisted across renders) */
- const historyModalRef = useRef(null);
+ const acpClientRef = useRef(acpAdapter);
/** Ref for settings button (for menu positioning) */
const menuButtonRef = useRef(null);
/** Track if initial agent restoration has been performed (prevent re-triggering) */
const hasRestoredAgentRef = useRef(false);
// ============================================================
- // Computed Values
+ // UI State (ChatView-specific)
// ============================================================
- 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 [isMenuOpen, setIsMenuOpen] = useState(false);
// ============================================================
- // Callbacks
+ // ChatView-specific Callbacks
// ============================================================
- /**
- * Handle new chat request.
- * @param requestedAgentId - If provided, switch to this agent (from "New chat with [Agent]" command)
- */
- const handleNewChat = useCallback(
+
+ // ChatView-specific handleNewChat wrapper (also persists agent ID via view.setAgentId)
+ // If requestedAgentId is provided, use it; otherwise keep the current agent
+ const handleNewChatWithPersist = 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);
-
+ await handleNewChat(requestedAgentId);
// Persist agent ID for this view (survives Obsidian restart)
- if (newAgentId) {
- view.setAgentId(newAgentId);
+ // Use requestedAgentId if provided; otherwise current session.agentId (effectively no-op)
+ if (requestedAgentId) {
+ view.setAgentId(requestedAgentId);
}
-
- // Invalidate session history cache when creating new session
- sessionHistory.invalidateCache();
},
- [
- messages,
- session,
- logger,
- autoExport,
- autoMention,
- chat,
- agentSession,
- sessionHistory,
- view,
- ],
+ [handleNewChat, view],
);
- 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 handleOpenSettings = useCallback(() => {
const appWithSettings = plugin.app as unknown as AppWithSettings;
appWithSettings.setting.open();
@@ -409,7 +180,7 @@ function ChatComponent({
}, [plugin]);
// ============================================================
- // Header Menu Callbacks
+ // Header Menu Callbacks (ChatView-specific)
// ============================================================
const handleToggleMenu = useCallback(() => {
setIsMenuOpen((prev) => !prev);
@@ -419,262 +190,54 @@ function ChatComponent({
setIsMenuOpen(false);
}, []);
- const handleSwitchAgent = useCallback(
+ const handleSwitchAgentWithMenu = useCallback(
(agentId: string) => {
setIsMenuOpen(false);
- if (agentId !== session.agentId) {
- void handleNewChat(agentId);
- }
+ // Use handleNewChatWithPersist to also call view.setAgentId
+ void handleNewChatWithPersist(agentId);
},
- [session.agentId, handleNewChat],
+ [handleNewChatWithPersist],
);
- const handleRestartAgent = useCallback(() => {
+ const handleRestartAgentWithMenu = useCallback(() => {
setIsMenuOpen(false);
- void (async () => {
- logger.log("[ChatView] 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]);
+ void handleRestartAgent();
+ }, [handleRestartAgent]);
const handleOpenNewView = useCallback(() => {
setIsMenuOpen(false);
void plugin.openNewChatViewWithAgent(plugin.settings.defaultAgentId);
}, [plugin]);
- /** Get available agents for settings menu */
- const availableAgents = useMemo(() => {
- return plugin.getAvailableAgents();
- }, [plugin]);
-
// ============================================================
- // Session History Modal Callbacks
+ // Agent ID Restoration Effect (ChatView-specific)
// ============================================================
- const handleHistoryRestoreSession = useCallback(
- async (sessionId: string, cwd: string) => {
- try {
- logger.log(`[ChatView] 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 handleHistoryForkSession = useCallback(
- async (sessionId: string, cwd: string) => {
- try {
- logger.log(`[ChatView] 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 handleHistoryDeleteSession = 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(`[ChatView] 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 handleHistoryLoadMore = useCallback(() => {
- void sessionHistory.loadMoreSessions();
- }, [sessionHistory]);
-
- const handleHistoryFetchSessions = 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: handleHistoryRestoreSession,
- onForkSession: handleHistoryForkSession,
- onDeleteSession: handleHistoryDeleteSession,
- onLoadMore: handleHistoryLoadMore,
- onFetchSessions: handleHistoryFetchSessions,
- });
- }
- historyModalRef.current.open();
- void sessionHistory.fetchSessions(vaultPath);
- }, [
- plugin.app,
- sessionHistory,
- vaultPath,
- isSessionReady,
- settings.debugMode,
- handleHistoryRestoreSession,
- handleHistoryForkSession,
- handleHistoryDeleteSession,
- handleHistoryLoadMore,
- handleHistoryFetchSessions,
- ]);
-
- // Update modal props when session history state changes
+ // Re-create session when agentId is restored from workspace state
+ // This handles the case where setState() is called after onOpen()
+ // Only runs ONCE for initial restoration (prevents re-triggering on agent switch)
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: handleHistoryRestoreSession,
- onForkSession: handleHistoryForkSession,
- onDeleteSession: handleHistoryDeleteSession,
- onLoadMore: handleHistoryLoadMore,
- onFetchSessions: handleHistoryFetchSessions,
- });
- }
+ if (hasRestoredAgentRef.current) return;
+ if (!restoredAgentId) return;
+ if (session.state === "initializing") return;
+
+ hasRestoredAgentRef.current = true;
+
+ if (session.agentId === restoredAgentId) return;
+
+ logger.log(
+ `[ChatView] Switching to restored agent: ${restoredAgentId} (current: ${session.agentId})`,
+ );
+ // Note: useChatController handles session creation, but we need to restart
+ // with the correct agent if it differs
+ void handleNewChat(restoredAgentId);
}, [
- sessionHistory.sessions,
- sessionHistory.loading,
- sessionHistory.error,
- sessionHistory.hasMore,
- sessionHistory.canList,
- sessionHistory.canRestore,
- sessionHistory.canFork,
- sessionHistory.isUsingLocalSessions,
- sessionHistory.localSessionIds,
- vaultPath,
- isSessionReady,
- settings.debugMode,
- handleHistoryRestoreSession,
- handleHistoryForkSession,
- handleHistoryDeleteSession,
- handleHistoryLoadMore,
- handleHistoryFetchSessions,
+ restoredAgentId,
+ session.state,
+ session.agentId,
+ logger,
+ handleNewChat,
]);
- const handleSendMessage = useCallback(
- async (content: string, images?: ImagePromptContent[]) => {
- const isFirstMessage = messages.length === 0;
-
- await chat.sendMessage(content, {
- // TODO: Refactor to handle settings inside useAutoMention hook
- // Current: Pass null when setting is OFF to disable auto-mention
- // Ideal: useAutoMention should accept settings and return effective values
- 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(
- `[ChatView] 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...");
- // Save last user message before cancel (to restore it)
- const lastMessage = chat.lastUserMessage;
- await agentSession.cancelOperation();
- // Restore the last user message to input field
- if (lastMessage) {
- setRestoredMessage(lastMessage);
- }
- }, [logger, agentSession, chat.lastUserMessage]);
-
- const handleClearError = useCallback(() => {
- chat.clearError();
- }, [chat]);
-
- const handleRestoredMessageConsumed = useCallback(() => {
- setRestoredMessage(null);
- }, []);
-
// ============================================================
// Broadcast Command Callbacks
// ============================================================
@@ -687,10 +250,13 @@ function ChatComponent({
}, [inputValue, attachedImages]);
/** Set input state from broadcast commands */
- const setInputState = useCallback((state: ChatInputState) => {
- setInputValue(state.text);
- setAttachedImages(state.images);
- }, []);
+ const setInputState = useCallback(
+ (state: ChatInputState) => {
+ setInputValue(state.text);
+ setAttachedImages(state.images);
+ },
+ [setInputValue, setAttachedImages],
+ );
/** Send message for broadcast commands (returns true if sent) */
const sendMessageForBroadcast = useCallback(async (): Promise => {
@@ -731,6 +297,8 @@ function ChatComponent({
sessionHistory.loading,
isSending,
handleSendMessage,
+ setInputValue,
+ setAttachedImages,
]);
/** Check if this view can send a message */
@@ -761,6 +329,7 @@ function ChatComponent({
// Register callbacks with ChatView class for broadcast commands
useEffect(() => {
view.registerInputCallbacks({
+ getDisplayName: () => activeAgentLabel,
getInputState,
setInputState,
sendMessage: sendMessageForBroadcast,
@@ -773,6 +342,7 @@ function ChatComponent({
};
}, [
view,
+ activeAgentLabel,
getInputState,
setInputState,
sendMessageForBroadcast,
@@ -780,190 +350,6 @@ function ChatComponent({
cancelForBroadcast,
]);
- // ============================================================
- // Effects - Session Lifecycle
- // ============================================================
- // Initialize session on mount
- useEffect(() => {
- logger.log("[Debug] Starting connection setup via useAgentSession...");
- void agentSession.createSession(restoredAgentId);
- }, [agentSession.createSession, restoredAgentId]);
-
- // Re-create session when agentId is restored from workspace state
- // This handles the case where setState() is called after onOpen()
- // Only runs ONCE for initial restoration (prevents re-triggering on agent switch)
- useEffect(() => {
- // Only run once for initial restoration
- if (hasRestoredAgentRef.current) {
- return;
- }
-
- // Skip if no restored agentId (initial mount with null)
- if (!restoredAgentId) {
- return;
- }
-
- // Skip if session is still initializing (wait for it to be ready)
- if (session.state === "initializing") {
- return;
- }
-
- // Mark as handled once we can make a decision
- hasRestoredAgentRef.current = true;
-
- // Skip if already using the correct agent
- if (session.agentId === restoredAgentId) {
- return;
- }
-
- logger.log(
- `[ChatView] Switching to restored agent: ${restoredAgentId} (current: ${session.agentId})`,
- );
- void agentSession.restartSession(restoredAgentId);
- }, [restoredAgentId, session.state, session.agentId, agentSession, 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("[ChatView] Cleanup: auto-export and close session");
- // Use refs to get latest values (avoid stale closures)
- void (async () => {
- await autoExportRef.current.autoExportIfEnabled(
- "closeChat",
- messagesRef.current,
- sessionRef.current,
- );
- await closeSessionRef.current();
- })();
- };
- // Empty dependency array - only run on unmount
- }, []);
-
- // Note: Previously monitored settings.activeAgentId to auto-switch agents.
- // Removed for multi-session support - each view manages its own agentId locally.
- // Agent switching is now done explicitly via Settings Menu.
-
- // ============================================================
- // 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(
- `[ChatView] 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) => {
- console.error("Failed to check for updates:", error);
- });
- }, [plugin]);
-
- // ============================================================
- // Effects - Save Session Messages on Turn End
- // ============================================================
- // Track previous isSending state to detect turn completion
- const prevIsSendingRef = useRef(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
- ) {
- // Fire-and-forget save via sessionHistory hook
- sessionHistory.saveSessionMessages(session.sessionId, messages);
- logger.log(
- `[ChatView] 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]);
-
// ============================================================
// Effects - Workspace Events (Hotkeys)
// ============================================================
@@ -1014,7 +400,7 @@ function ChatComponent({
) {
return;
}
- void handleNewChat(agentId);
+ void handleNewChatWithPersist(agentId);
});
return () => {
@@ -1023,7 +409,7 @@ function ChatComponent({
}, [
plugin.app.workspace,
plugin.lastActiveChatViewId,
- handleNewChat,
+ handleNewChatWithPersist,
viewId,
]);
@@ -1124,7 +510,7 @@ function ChatComponent({
agentLabel={activeAgentLabel}
isUpdateAvailable={isUpdateAvailable}
hasHistoryCapability={sessionHistory.canShowSessionHistory}
- onNewChat={() => void handleNewChat()}
+ onNewChat={() => void handleNewChatWithPersist()}
onExportChat={() => void handleExportChat()}
onToggleMenu={handleToggleMenu}
onOpenHistory={handleOpenHistory}
@@ -1136,9 +522,9 @@ function ChatComponent({
anchorRef={menuButtonRef}
currentAgentId={session.agentId || ""}
availableAgents={availableAgents}
- onSwitchAgent={handleSwitchAgent}
+ onSwitchAgent={handleSwitchAgentWithMenu}
onOpenNewView={handleOpenNewView}
- onRestartAgent={handleRestartAgent}
+ onRestartAgent={handleRestartAgentWithMenu}
onOpenPluginSettings={handleOpenSettings}
onClose={handleCloseMenu}
plugin={plugin}
@@ -1175,9 +561,9 @@ function ChatComponent({
onStopGeneration={handleStopGeneration}
onRestoredMessageConsumed={handleRestoredMessageConsumed}
modes={session.modes}
- onModeChange={(modeId) => void agentSession.setMode(modeId)}
+ onModeChange={(modeId) => void handleSetMode(modeId)}
models={session.models}
- onModelChange={(modelId) => void agentSession.setModel(modelId)}
+ onModelChange={(modelId) => void handleSetModel(modelId)}
supportsImages={session.promptCapabilities?.image ?? false}
agentId={session.agentId}
// Controlled component props (for broadcast commands)
@@ -1188,6 +574,7 @@ function ChatComponent({
// Error overlay props
errorInfo={errorInfo}
onClearError={handleClearError}
+ messages={messages}
/>
);
@@ -1199,18 +586,21 @@ interface ChatViewState extends Record {
}
// Callback types for input state access (broadcast commands)
+type GetDisplayNameCallback = () => string;
type GetInputStateCallback = () => ChatInputState | null;
type SetInputStateCallback = (state: ChatInputState) => void;
type SendMessageCallback = () => Promise;
type CanSendCallback = () => boolean;
type CancelCallback = () => Promise;
-export class ChatView extends ItemView {
+export class ChatView extends ItemView implements IChatViewContainer {
private root: Root | null = null;
private plugin: AgentClientPlugin;
private logger: Logger;
/** Unique identifier for this view instance (for multi-session support) */
readonly viewId: string;
+ /** View type for IChatViewContainer */
+ readonly viewType: ChatViewType = "sidebar";
/** Initial agent ID passed via state (for openNewChatViewWithAgent) */
private initialAgentId: string | null = null;
/** Callbacks to notify React when agentId is restored from workspace state */
@@ -1218,6 +608,7 @@ export class ChatView extends ItemView {
new Set();
// Callbacks for input state access (broadcast commands)
+ private getDisplayNameCallback: GetDisplayNameCallback | null = null;
private getInputStateCallback: GetInputStateCallback | null = null;
private setInputStateCallback: SetInputStateCallback | null = null;
private sendMessageCallback: SendMessageCallback | null = null;
@@ -1312,12 +703,14 @@ export class ChatView extends ItemView {
* Called by ChatComponent on mount.
*/
registerInputCallbacks(callbacks: {
+ getDisplayName: GetDisplayNameCallback;
getInputState: GetInputStateCallback;
setInputState: SetInputStateCallback;
sendMessage: SendMessageCallback;
canSend: CanSendCallback;
cancel: CancelCallback;
}): void {
+ this.getDisplayNameCallback = callbacks.getDisplayName;
this.getInputStateCallback = callbacks.getInputState;
this.setInputStateCallback = callbacks.setInputState;
this.sendMessageCallback = callbacks.sendMessage;
@@ -1329,6 +722,7 @@ export class ChatView extends ItemView {
* Unregister callbacks when component unmounts.
*/
unregisterInputCallbacks(): void {
+ this.getDisplayNameCallback = null;
this.getInputStateCallback = null;
this.setInputStateCallback = null;
this.sendMessageCallback = null;
@@ -1336,6 +730,10 @@ export class ChatView extends ItemView {
this.cancelCallback = null;
}
+ getDisplayName(): string {
+ return this.getDisplayNameCallback?.() ?? "Chat";
+ }
+
/**
* Get current input state (text + images).
* Returns null if React component not mounted.
@@ -1372,6 +770,62 @@ export class ChatView extends ItemView {
await this.cancelCallback?.();
}
+ // ============================================================
+ // IChatViewContainer Implementation
+ // ============================================================
+
+ /**
+ * Called when this view becomes the active/focused view.
+ */
+ onActivate(): void {
+ this.logger.log(`[ChatView] Activated: ${this.viewId}`);
+ }
+
+ /**
+ * Called when this view loses active/focused status.
+ */
+ onDeactivate(): void {
+ this.logger.log(`[ChatView] Deactivated: ${this.viewId}`);
+ }
+
+ /**
+ * Programmatically focus this view's input.
+ */
+ focus(): void {
+ const textarea = this.containerEl.querySelector(
+ "textarea.agent-client-chat-input-textarea",
+ );
+ if (textarea instanceof HTMLTextAreaElement) {
+ textarea.focus();
+ }
+ }
+
+ /**
+ * Check if this view currently has focus.
+ */
+ hasFocus(): boolean {
+ return this.containerEl.contains(document.activeElement);
+ }
+
+ /**
+ * Expand the view if it's in a collapsed state.
+ * Sidebar views don't have expand/collapse state - no-op.
+ */
+ expand(): void {
+ // Sidebar views don't have expand/collapse state - no-op
+ }
+
+ collapse(): void {
+ // Sidebar views don't have expand/collapse state - no-op
+ }
+
+ /**
+ * Get the DOM container element for this view.
+ */
+ getContainerEl(): HTMLElement {
+ return this.containerEl;
+ }
+
onOpen() {
const container = this.containerEl.children[1];
container.empty();
@@ -1384,11 +838,19 @@ export class ChatView extends ItemView {
viewId={this.viewId}
/>,
);
+
+ // Register with plugin's view registry
+ this.plugin.viewRegistry.register(this);
+
return Promise.resolve();
}
async onClose(): Promise {
this.logger.log("[ChatView] onClose() called");
+
+ // Unregister from plugin's view registry
+ this.plugin.viewRegistry.unregister(this.viewId);
+
// Cleanup is handled by React useEffect cleanup in ChatComponent
// which performs auto-export and closeSession
if (this.root) {
diff --git a/src/components/chat/ErrorOverlay.tsx b/src/components/chat/ErrorOverlay.tsx
index e847a73..bea1aec 100644
--- a/src/components/chat/ErrorOverlay.tsx
+++ b/src/components/chat/ErrorOverlay.tsx
@@ -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;
}
/**
diff --git a/src/components/chat/FloatingButton.tsx b/src/components/chat/FloatingButton.tsx
new file mode 100644
index 0000000..fa4bbe7
--- /dev/null
+++ b/src/components/chat/FloatingButton.tsx
@@ -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( );
+ }
+
+ 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(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();
+ for (const e of entries) {
+ countMap.set(e.label, (countMap.get(e.label) ?? 0) + 1);
+ }
+ const indexMap = new Map();
+ 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 (
+ <>
+
+ {floatingButtonImageSrc ? (
+
+ ) : (
+
{
+ if (el) setIcon(el, "bot-message-square");
+ }}
+ />
+ )}
+
+ {showInstanceMenu && (
+
+
+ Select session to open
+
+ {instanceLabels.map(({ viewId: id, label }) => (
+
{
+ plugin.expandFloatingChat(id);
+ plugin.viewRegistry.setFocused(id);
+ setShowInstanceMenu(false);
+ }}
+ >
+
+ {label}
+
+ {instanceLabels.length > 1 && (
+ {
+ e.stopPropagation();
+ plugin.closeFloatingChat(id);
+ if (instanceLabels.length <= 2) {
+ setShowInstanceMenu(false);
+ }
+ }}
+ title="Close session"
+ >
+ ×
+
+ )}
+
+ ))}
+
+ )}
+ >
+ );
+}
diff --git a/src/components/chat/FloatingChatView.tsx b/src/components/chat/FloatingChatView.tsx
new file mode 100644
index 0000000..1c7fe02
--- /dev/null
+++ b/src/components/chat/FloatingChatView.tsx
@@ -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
;
+ cancelOperation: () => Promise;
+ 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(
+ {
+ 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 {
+ return (await this.callbacks?.sendMessage()) ?? false;
+ }
+
+ async cancelOperation(): Promise {
+ 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(null);
+
+ const acpClientRef = useRef(acpAdapter);
+
+ // Track registered listeners for cleanup
+ const registeredListenersRef = useRef([]);
+
+ // 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;
+ }
+ ).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;
+ }
+ ).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;
+ }
+ ).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;
+ }
+ ).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;
+ }
+ ).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 (
+
+
+ 0}
+ onAgentChange={(agentId) => void handleSwitchAgent(agentId)}
+ onNewSession={() => void handleNewChat()}
+ onOpenHistory={() => void handleOpenHistory()}
+ onExportChat={() => void handleExportChat()}
+ onRestartAgent={() => void handleRestartAgent()}
+ onOpenNewWindow={handleOpenNewFloatingChat}
+ onClose={handleCloseWindow}
+ />
+
+
+
+
+
+
+
+
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}
+ />
+
+
+ );
+}
+/**
+ * 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;
+}
diff --git a/src/components/chat/HeaderMenu.tsx b/src/components/chat/HeaderMenu.tsx
index 9e6d9c1..5347833 100644
--- a/src/components/chat/HeaderMenu.tsx
+++ b/src/components/chat/HeaderMenu.tsx
@@ -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({
diff --git a/src/components/chat/InlineHeader.tsx b/src/components/chat/InlineHeader.tsx
new file mode 100644
index 0000000..bba58a8
--- /dev/null
+++ b/src/components/chat/InlineHeader.tsx
@@ -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(null);
+ const agentDropdownInstance = useRef(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 (
+
+
+ {availableAgents.length > 1 ? (
+
+
+
{
+ if (el) setIcon(el, "chevron-down");
+ }}
+ />
+
+ ) : (
+
+ {agentLabel}
+
+ )}
+
+ {isUpdateAvailable && (
+
+ Plugin update available!
+
+ )}
+
+
+
+
+ {/* */}
+ {variant === "floating" && onOpenNewWindow && (
+
+ )}
+ {variant === "floating" && onClose && (
+
+ )}
+
+
+ );
+}
diff --git a/src/components/chat/SuggestionDropdown.tsx b/src/components/chat/SuggestionDropdown.tsx
index 1530bdb..9370889 100644
--- a/src/components/chat/SuggestionDropdown.tsx
+++ b/src/components/chat/SuggestionDropdown.tsx
@@ -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;
}
/**
diff --git a/src/components/chat/types.ts b/src/components/chat/types.ts
new file mode 100644
index 0000000..22bbd9b
--- /dev/null
+++ b/src/components/chat/types.ts
@@ -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(
+ el: Window,
+ type: K,
+ callback: (this: HTMLElement, ev: WindowEventMap[K]) => unknown,
+ options?: boolean | AddEventListenerOptions,
+ ): void;
+ registerDomEvent(
+ el: Document,
+ type: K,
+ callback: (this: HTMLElement, ev: DocumentEventMap[K]) => unknown,
+ options?: boolean | AddEventListenerOptions,
+ ): void;
+ registerDomEvent(
+ el: HTMLElement,
+ type: K,
+ callback: (this: HTMLElement, ev: HTMLElementEventMap[K]) => unknown,
+ options?: boolean | AddEventListenerOptions,
+ ): void;
+}
diff --git a/src/components/settings/AgentClientSettingTab.ts b/src/components/settings/AgentClientSettingTab.ts
index f04b4a5..ce76e45 100644
--- a/src/components/settings/AgentClientSettingTab.ts
+++ b/src/components/settings/AgentClientSettingTab.ts
@@ -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
// ─────────────────────────────────────────────────────────────────────
diff --git a/src/domain/ports/chat-view-container.port.ts b/src/domain/ports/chat-view-container.port.ts
new file mode 100644
index 0000000..6cf895b
--- /dev/null
+++ b/src/domain/ports/chat-view-container.port.ts
@@ -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 - true if message was sent, false otherwise
+ */
+ sendMessage(): Promise;
+
+ /**
+ * Cancel current operation.
+ * Stops ongoing message generation.
+ */
+ cancelOperation(): Promise;
+
+ // ============================================================
+ // Container Access
+ // ============================================================
+
+ /**
+ * Get the DOM container element for this view.
+ * Used for focus detection and DOM queries.
+ */
+ getContainerEl(): HTMLElement;
+}
diff --git a/src/hooks/useAgentSession.ts b/src/hooks/useAgentSession.ts
index ee2502a..801f1c9 100644
--- a/src/hooks/useAgentSession.ts
+++ b/src/hooks/useAgentSession.ts
@@ -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
diff --git a/src/hooks/useChatController.ts b/src/hooks/useChatController.ts
new file mode 100644
index 0000000..ebdbebe
--- /dev/null
+++ b/src/hooks/useChatController.ts
@@ -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;
+ session: ReturnType["session"];
+ isSessionReady: boolean;
+ messages: ReturnType["messages"];
+ isSending: boolean;
+ isUpdateAvailable: boolean;
+ isLoadingSessionHistory: boolean;
+
+ // Hook returns
+ permission: ReturnType;
+ mentions: ReturnType;
+ autoMention: ReturnType;
+ slashCommands: ReturnType;
+ sessionHistory: ReturnType;
+ autoExport: ReturnType;
+
+ // Computed values
+ activeAgentLabel: string;
+ availableAgents: AgentInfo[];
+ errorInfo:
+ | ReturnType["errorInfo"]
+ | ReturnType["errorInfo"];
+
+ // Core callbacks
+ handleSendMessage: (
+ content: string,
+ images?: ImagePromptContent[],
+ ) => Promise;
+ handleStopGeneration: () => Promise;
+ handleNewChat: (requestedAgentId?: string) => Promise;
+ handleExportChat: () => Promise;
+ handleSwitchAgent: (agentId: string) => Promise;
+ handleRestartAgent: () => Promise;
+ handleClearError: () => void;
+ handleRestoreSession: (sessionId: string, cwd: string) => Promise;
+ handleForkSession: (sessionId: string, cwd: string) => Promise;
+ handleDeleteSession: (sessionId: string) => void;
+ handleOpenHistory: () => void;
+ handleSetMode: (modeId: string) => Promise;
+ handleSetModel: (modelId: string) => Promise;
+
+ // 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;
+}
+
+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(null);
+
+ // Input state (for broadcast commands - sidebar only)
+ const [inputValue, setInputValue] = useState("");
+ const [attachedImages, setAttachedImages] = useState([]);
+
+ // ============================================================
+ // Refs
+ // ============================================================
+ const historyModalRef = useRef(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(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,
+ };
+}
diff --git a/src/hooks/useInputHistory.ts b/src/hooks/useInputHistory.ts
new file mode 100644
index 0000000..d1d0949
--- /dev/null
+++ b/src/hooks/useInputHistory.ts
@@ -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(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 };
+}
diff --git a/src/plugin.ts b/src/plugin.ts
index e14c66f..6ee5d88 100644
--- a/src/plugin.ts
+++ b/src/plugin.ts
@@ -1,6 +1,13 @@
import { Plugin, WorkspaceLeaf, Notice, requestUrl } from "obsidian";
+import type { Root } from "react-dom/client";
import * as semver from "semver";
import { ChatView, VIEW_TYPE_CHAT } from "./components/chat/ChatView";
+import {
+ createFloatingChat,
+ FloatingViewContainer,
+} from "./components/chat/FloatingChatView";
+import { FloatingButtonContainer } from "./components/chat/FloatingButton";
+import { ChatViewRegistry } from "./shared/chat-view-registry";
import {
createSettingsStore,
type SettingsStore,
@@ -84,6 +91,13 @@ export interface AgentClientPluginSettings {
};
// Locally saved session metadata (for agents without session/list support)
savedSessions: SavedSessionInfo[];
+ // Last used model per agent (agentId → modelId)
+ lastUsedModels: Record;
+ // 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 = 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 {
- 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 {
- 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 = {};
+ for (const [key, value] of Object.entries(
+ raw as Record,
+ )) {
+ 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();
diff --git a/src/shared/chat-view-registry.ts b/src/shared/chat-view-registry.ts
new file mode 100644
index 0000000..5e12e7b
--- /dev/null
+++ b/src/shared/chat-view-registry.ts
@@ -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();
+ 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(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;
+ }
+}
diff --git a/src/shared/floating-utils.ts b/src/shared/floating-utils.ts
new file mode 100644
index 0000000..153293b
--- /dev/null
+++ b/src/shared/floating-utils.ts
@@ -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)),
+ };
+}
diff --git a/styles.css b/styles.css
index 90e4ceb..aad5b81 100644
--- a/styles.css
+++ b/styles.css
@@ -264,7 +264,6 @@ If your plugin does not need CSS, delete this file.
.agent-client-message-permission-request {
padding: 6px 0px;
margin-top: 4px;
- background-color: var(--background-secondary);
font-size: 14px;
user-select: text;
overflow-wrap: break-word;
@@ -432,6 +431,32 @@ If your plugin does not need CSS, delete this file.
gap: 8px;
}
+/* ===== Inline Header (Floating/CodeBlock) ===== */
+.agent-client-inline-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 8px 12px;
+ border-bottom: 1px solid var(--background-modifier-border);
+ background-color: var(--background-primary);
+ flex-shrink: 0;
+ flex-wrap: nowrap;
+ min-height: 36px;
+}
+
+.agent-client-inline-header-main {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.agent-client-inline-header-actions {
+ display: flex;
+ gap: 4px;
+ justify-content: flex-end;
+ flex-shrink: 0;
+}
+
/* Messages container */
.agent-client-chat-view-messages {
flex: 1;
@@ -442,6 +467,7 @@ If your plugin does not need CSS, delete this file.
flex-direction: column;
gap: 2px;
font-size: var(--ac-chat-font-size);
+ min-height: 0;
}
/* Allow horizontal scroll for wide tables within messages */
@@ -1151,6 +1177,12 @@ If your plugin does not need CSS, delete this file.
overflow-wrap: break-word;
word-wrap: break-word;
word-break: break-word;
+ max-width: 100%;
+}
+
+.agent-client-markdown-text-renderer .markdown-rendered pre {
+ max-width: 100%;
+ overflow-x: auto;
}
/* Tool Call Diff Renderer */
@@ -1791,3 +1823,304 @@ If your plugin does not need CSS, delete this file.
.agent-client-header-menu-back:hover {
background-color: var(--background-modifier-hover);
}
+
+/* ===== Floating Chat Button ===== */
+/* Floating button & menu root — higher z-index, always above windows */
+.agent-client-floating-button-root {
+ position: fixed;
+ z-index: 30;
+ pointer-events: none;
+}
+
+.agent-client-floating-button-root > * {
+ pointer-events: auto;
+}
+
+/* Floating chat window root — lower z-index */
+.agent-client-floating-view-root {
+ position: fixed;
+ z-index: 20;
+ pointer-events: none;
+}
+
+.agent-client-floating-view-root.is-focused {
+ z-index: 21;
+}
+
+.agent-client-floating-view-root > * {
+ pointer-events: auto;
+}
+
+.agent-client-floating-button {
+ position: fixed;
+ bottom: 30px;
+ right: 40px;
+ width: 48px;
+ height: 48px;
+ min-width: 48px;
+ min-height: 48px;
+ border-radius: 50%;
+ background: var(--interactive-accent);
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
+ transition:
+ transform 0.2s,
+ box-shadow 0.2s;
+ overflow: hidden;
+ padding: 0;
+}
+
+.agent-client-floating-button.has-custom-image {
+ background: transparent;
+}
+
+.agent-client-floating-button:hover {
+ transform: scale(1.05);
+ box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4);
+}
+
+.agent-client-floating-button img {
+ width: 48px;
+ height: 48px;
+ min-width: 48px;
+ min-height: 48px;
+ object-fit: cover;
+ object-position: center;
+ border-radius: 50%;
+ display: block;
+}
+
+.agent-client-floating-button-fallback {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--text-on-accent);
+}
+
+.agent-client-floating-button-fallback svg {
+ width: 24px;
+ height: 24px;
+}
+
+/* ===== Floating Chat Window ===== */
+.agent-client-floating-window {
+ position: fixed;
+ background: var(--background-primary);
+ border: 1px solid var(--background-modifier-border);
+ border-radius: 12px;
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ resize: both;
+ min-width: 300px;
+ min-height: 460px;
+}
+
+.agent-client-floating-header {
+ cursor: grab;
+ user-select: none;
+ border-radius: 11px 11px 0 0;
+ overflow: hidden;
+}
+
+.agent-client-floating-header:active {
+ cursor: grabbing;
+}
+
+.agent-client-floating-header .agent-client-inline-header {
+ border-radius: 11px 11px 0 0;
+}
+
+.agent-client-floating-content {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ padding: 4px;
+ min-height: 0;
+}
+
+.agent-client-floating-messages-container {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden; /* Let inner .agent-client-chat-view-messages handle scrolling */
+ margin-bottom: 8px;
+ min-height: 0;
+}
+
+/* Floating chat instance selector menu */
+.agent-client-floating-instance-menu {
+ position: fixed;
+ bottom: 100px;
+ right: 20px;
+ min-width: 220px;
+ background: var(--background-primary);
+ border: 1px solid var(--background-modifier-border);
+ border-radius: 6px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
+ overflow: hidden;
+}
+
+.agent-client-floating-instance-menu-header {
+ padding: 12px 16px;
+ font-weight: 600;
+ font-size: 13px;
+ color: var(--text-muted);
+ background: var(--background-secondary);
+ border-bottom: 1px solid var(--background-modifier-border);
+}
+
+.agent-client-floating-instance-menu-item {
+ padding: 10px 16px;
+ cursor: pointer;
+ font-size: 13px;
+ color: var(--text-normal);
+ transition: background-color 0.1s;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.agent-client-floating-instance-menu-label {
+ flex: 1;
+}
+
+.agent-client-floating-instance-menu-item:hover {
+ background: var(--background-modifier-hover);
+}
+
+.agent-client-floating-instance-menu-close {
+ width: 20px;
+ height: 20px;
+ padding: 0 !important;
+ margin: 0 !important;
+ border: none !important;
+ border-radius: 0 !important;
+ background-color: transparent !important;
+ box-shadow: none !important;
+ color: var(--text-muted);
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 16px;
+ outline: none !important;
+ appearance: none;
+}
+
+.agent-client-floating-instance-menu-close:hover {
+ background-color: var(--background-modifier-hover) !important;
+ color: var(--interactive-accent);
+}
+
+/* ===== Code Block Chat View ===== */
+/* TODO(code-block): Styles for future code block chat view */
+.agent-client-code-block-container {
+ display: flex;
+ flex-direction: column;
+ border: 1px solid var(--background-modifier-border);
+ border-radius: 8px;
+ background: var(--background-primary);
+ overflow: hidden;
+}
+
+.agent-client-code-block-content {
+ display: flex;
+ flex-direction: column;
+ padding: 8px;
+}
+
+.agent-client-code-block-status {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 4px 8px;
+ background: var(--background-secondary);
+ border-radius: 4px;
+ margin-bottom: 4px;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.agent-client-code-block-status-actions {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ flex-wrap: wrap;
+}
+
+.agent-client-code-block-messages {
+ max-height: 400px;
+ overflow-y: auto;
+ margin-bottom: 8px;
+}
+
+/* ===== Agent Selector (InlineHeader) ===== */
+.agent-client-agent-selector {
+ display: inline-flex;
+ align-items: center;
+ padding: 0 4px;
+ border-radius: 4px;
+ cursor: pointer;
+ transition: background-color 0.1s ease;
+ height: 20px;
+ max-width: 160px;
+}
+
+.agent-client-agent-selector:hover {
+ background-color: var(--background-modifier-hover);
+}
+
+.agent-client-agent-selector-icon {
+ display: flex;
+ align-items: center;
+ pointer-events: none;
+ color: var(--text-faint);
+ margin-left: 2px;
+ order: 2;
+}
+
+.agent-client-agent-selector-icon svg {
+ width: 12px;
+ height: 12px;
+}
+
+.agent-client-agent-selector select.dropdown {
+ padding: 0 !important;
+ margin: 0 !important;
+ border: none !important;
+ border-radius: 0 !important;
+ background: none !important;
+ background-color: transparent !important;
+ background-image: none !important;
+ box-shadow: none !important;
+ color: var(--text-normal) !important;
+ font-size: 12px !important;
+ cursor: pointer !important;
+ outline: none !important;
+ appearance: none !important;
+ -webkit-appearance: none !important;
+ -moz-appearance: none !important;
+ max-width: 140px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ overflow: hidden;
+}
+
+.agent-client-agent-selector select.dropdown:hover,
+.agent-client-agent-selector select.dropdown:focus {
+ color: var(--text-normal) !important;
+ background-color: transparent !important;
+ box-shadow: none !important;
+}
+
+/* Agent label (single agent) */
+.agent-client-agent-label {
+ color: var(--text-normal);
+ font-size: 12px;
+}