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. + +

+ Floating chat window open over the editor +

+ +## 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 + +

+ Floating button in the bottom-right corner +

+ +## 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: + +

+ Instance menu with multiple sessions listed +

+ +- 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 ? ( + Open chat + ) : ( +
{ + 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 && ( + + )} +
+ ))} +
+ )} + + ); +} 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; +}