@@ -38,7 +42,15 @@ https://github.com/user-attachments/assets/1c538349-b3fb-44dd-a163-7331cbca7824
## Installation
-### Via BRAT (Recommended)
+### From Community Plugins (Recommended)
+
+1. Open **Settings → Community Plugins → Browse**
+2. Search for **"Agent Client"**
+3. Click **Install**, then **Enable**
+
+### Via BRAT (Pre-release Versions)
+
+To try pre-release versions before they are published to Community Plugins:
1. Install the [BRAT](https://github.com/TfTHacker/obsidian42-brat) plugin
2. Go to **Settings → BRAT → Add Beta Plugin**
diff --git a/docs/getting-started/index.md b/docs/getting-started/index.md
index 3e6f40e..2fad401 100644
--- a/docs/getting-started/index.md
+++ b/docs/getting-started/index.md
@@ -1,12 +1,20 @@
# Installation
-::: info 🚧 Under Review
-This plugin is awaiting approval for **Obsidian Community Plugins**. For now, use **BRAT** (recommended) or manual installation.
-:::
-
## Install the Plugin
-### Via BRAT (Recommended)
+### From Community Plugins (Recommended)
+
+1. In Obsidian, open **Settings → Community Plugins → Browse**
+2. Search for **"Agent Client"**
+3. Click **Install**, then **Enable**
+
+
+
)}
{/* Textarea with Hint Overlay */}
@@ -1054,7 +1045,7 @@ export function InputArea({
onKeyDown={handleKeyDown}
onPaste={(e) => void handlePaste(e)}
placeholder={placeholder}
- className={`agent-client-chat-input-textarea ${autoMentionEnabled && mentions.activeNote ? "has-auto-mention" : ""}`}
+ className={`agent-client-chat-input-textarea ${mentions.activeNote ? "has-auto-mention" : ""}`}
rows={1}
spellCheck={obsidianSpellcheck}
/>
diff --git a/src/ui/MessageBubble.tsx b/src/ui/MessageBubble.tsx
index 7cb2944..e210d68 100644
--- a/src/ui/MessageBubble.tsx
+++ b/src/ui/MessageBubble.tsx
@@ -152,7 +152,6 @@ function CollapsibleThought({ text, plugin }: CollapsibleThoughtProps) {
interface ContentBlockProps {
content: MessageContent;
plugin: AgentClientPlugin;
- messageId?: string;
messageRole?: "user" | "assistant";
terminalClient?: AcpClient;
/** Callback to approve a permission request */
@@ -165,7 +164,6 @@ interface ContentBlockProps {
function ContentBlock({
content,
plugin,
- messageId,
messageRole,
terminalClient,
onApprovePermission,
@@ -247,7 +245,6 @@ function ContentBlock({
);
@@ -321,7 +318,7 @@ function CopyButton({ contents }: { contents: MessageContent[] }) {
.writeText(text)
.then(() => {
setCopied(true);
- setTimeout(() => setCopied(false), 2000);
+ window.setTimeout(() => setCopied(false), 2000);
})
.catch(() => {});
}, [contents]);
@@ -409,7 +406,6 @@ export const MessageBubble = React.memo(function MessageBubble({
key={imgIdx}
content={content}
plugin={plugin}
- messageId={message.id}
messageRole={message.role}
terminalClient={terminalClient}
onApprovePermission={onApprovePermission}
@@ -424,7 +420,6 @@ export const MessageBubble = React.memo(function MessageBubble({
{
+ window.requestAnimationFrame(() => {
virtualizer.scrollToIndex(messages.length - 1, {
align: "end",
behavior: "smooth",
@@ -142,7 +142,7 @@ export function MessageList({
if (isAtBottomRef.current) {
// Use requestAnimationFrame to ensure virtualizer has measured
- requestAnimationFrame(() => {
+ window.requestAnimationFrame(() => {
virtualizer.scrollToIndex(messages.length - 1, {
align: "end",
});
diff --git a/src/ui/PermissionBanner.tsx b/src/ui/PermissionBanner.tsx
index db044cf..626deca 100644
--- a/src/ui/PermissionBanner.tsx
+++ b/src/ui/PermissionBanner.tsx
@@ -1,4 +1,3 @@
-import type AgentClientPlugin from "../plugin";
import { getLogger } from "../utils/logger";
import type { PermissionOption } from "../types/chat";
@@ -10,8 +9,6 @@ interface PermissionBannerProps {
isCancelled?: boolean;
isActive?: boolean;
};
- toolCallId: string;
- plugin: AgentClientPlugin;
/** Callback to approve a permission request */
onApprovePermission?: (
requestId: string,
@@ -22,8 +19,6 @@ interface PermissionBannerProps {
export function PermissionBanner({
permissionRequest,
- toolCallId,
- plugin,
onApprovePermission,
onOptionSelected,
}: PermissionBannerProps) {
diff --git a/src/ui/SettingsTab.ts b/src/ui/SettingsTab.ts
index 7874220..4a0aac0 100644
--- a/src/ui/SettingsTab.ts
+++ b/src/ui/SettingsTab.ts
@@ -77,13 +77,15 @@ export class AgentClientSettingTab extends PluginSettingTab {
text.setPlaceholder("Leave blank (login shell auto-resolves)")
.setValue(this.plugin.settings.nodePath)
.onChange(async (value) => {
- this.plugin.settings.nodePath = value.trim();
- await this.plugin.saveSettings();
+ await this.plugin.settingsService.updateSettings({
+ nodePath: value.trim(),
+ });
});
});
this.addAutoDetectButton(nodePathSetting, "node", async (path) => {
- this.plugin.settings.nodePath = path;
- await this.plugin.saveSettings();
+ await this.plugin.settingsService.updateSettings({
+ nodePath: path,
+ });
});
new Setting(containerEl)
@@ -103,10 +105,11 @@ export class AgentClientSettingTab extends PluginSettingTab {
)
.setValue(this.plugin.settings.sendMessageShortcut)
.onChange(async (value) => {
- this.plugin.settings.sendMessageShortcut = value as
- | "enter"
- | "cmd-enter";
- await this.plugin.saveSettings();
+ await this.plugin.settingsService.updateSettings({
+ sendMessageShortcut: value as
+ | "enter"
+ | "cmd-enter",
+ });
}),
);
@@ -125,8 +128,9 @@ export class AgentClientSettingTab extends PluginSettingTab {
toggle
.setValue(this.plugin.settings.autoMentionActiveNote)
.onChange(async (value) => {
- this.plugin.settings.autoMentionActiveNote = value;
- await this.plugin.saveSettings();
+ await this.plugin.settingsService.updateSettings({
+ autoMentionActiveNote: value,
+ });
}),
);
@@ -146,9 +150,12 @@ export class AgentClientSettingTab extends PluginSettingTab {
.onChange(async (value) => {
const num = parseInt(value, 10);
if (!isNaN(num) && num >= 1) {
- this.plugin.settings.displaySettings.maxNoteLength =
- num;
- await this.plugin.saveSettings();
+ await this.plugin.settingsService.updateSettings({
+ displaySettings: {
+ ...this.plugin.settings.displaySettings,
+ maxNoteLength: num,
+ },
+ });
}
}),
);
@@ -170,9 +177,12 @@ export class AgentClientSettingTab extends PluginSettingTab {
.onChange(async (value) => {
const num = parseInt(value, 10);
if (!isNaN(num) && num >= 1) {
- this.plugin.settings.displaySettings.maxSelectionLength =
- num;
- await this.plugin.saveSettings();
+ await this.plugin.settingsService.updateSettings({
+ displaySettings: {
+ ...this.plugin.settings.displaySettings,
+ maxSelectionLength: num,
+ },
+ });
}
}),
);
@@ -194,9 +204,9 @@ export class AgentClientSettingTab extends PluginSettingTab {
.addOption("editor-split", "Editor area (split)")
.setValue(this.plugin.settings.chatViewLocation)
.onChange(async (value) => {
- this.plugin.settings.chatViewLocation =
- value as ChatViewLocation;
- await this.plugin.saveSettings();
+ await this.plugin.settingsService.updateSettings({
+ chatViewLocation: value as ChatViewLocation,
+ });
}),
);
@@ -306,8 +316,12 @@ export class AgentClientSettingTab extends PluginSettingTab {
toggle
.setValue(this.plugin.settings.displaySettings.showEmojis)
.onChange(async (value) => {
- this.plugin.settings.displaySettings.showEmojis = value;
- await this.plugin.saveSettings();
+ await this.plugin.settingsService.updateSettings({
+ displaySettings: {
+ ...this.plugin.settings.displaySettings,
+ showEmojis: value,
+ },
+ });
}),
);
@@ -322,9 +336,12 @@ export class AgentClientSettingTab extends PluginSettingTab {
this.plugin.settings.displaySettings.autoCollapseDiffs,
)
.onChange(async (value) => {
- this.plugin.settings.displaySettings.autoCollapseDiffs =
- value;
- await this.plugin.saveSettings();
+ await this.plugin.settingsService.updateSettings({
+ displaySettings: {
+ ...this.plugin.settings.displaySettings,
+ autoCollapseDiffs: value,
+ },
+ });
this.display();
}),
);
@@ -347,9 +364,15 @@ export class AgentClientSettingTab extends PluginSettingTab {
.onChange(async (value) => {
const num = parseInt(value, 10);
if (!isNaN(num) && num > 0) {
- this.plugin.settings.displaySettings.diffCollapseThreshold =
- num;
- await this.plugin.saveSettings();
+ await this.plugin.settingsService.updateSettings(
+ {
+ displaySettings: {
+ ...this.plugin.settings
+ .displaySettings,
+ diffCollapseThreshold: num,
+ },
+ },
+ );
}
}),
);
@@ -401,8 +424,9 @@ export class AgentClientSettingTab extends PluginSettingTab {
.setPlaceholder("https://example.com/avatar.png")
.setValue(this.plugin.settings.floatingButtonImage)
.onChange(async (value) => {
- this.plugin.settings.floatingButtonImage = value.trim();
- await this.plugin.saveSettings();
+ await this.plugin.settingsService.updateSettings({
+ floatingButtonImage: value.trim(),
+ });
}),
);
@@ -421,8 +445,9 @@ export class AgentClientSettingTab extends PluginSettingTab {
toggle
.setValue(this.plugin.settings.autoAllowPermissions)
.onChange(async (value) => {
- this.plugin.settings.autoAllowPermissions = value;
- await this.plugin.saveSettings();
+ await this.plugin.settingsService.updateSettings({
+ autoAllowPermissions: value,
+ });
// Propagate to all live AcpClient instances
this.plugin.updateAllAutoAllow(value);
}),
@@ -443,8 +468,9 @@ export class AgentClientSettingTab extends PluginSettingTab {
toggle
.setValue(this.plugin.settings.enableSystemNotifications)
.onChange(async (value) => {
- this.plugin.settings.enableSystemNotifications = value;
- await this.plugin.saveSettings();
+ await this.plugin.settingsService.updateSettings({
+ enableSystemNotifications: value,
+ });
}),
);
@@ -534,8 +560,9 @@ export class AgentClientSettingTab extends PluginSettingTab {
toggle
.setValue(this.plugin.settings.windowsWslMode)
.onChange(async (value) => {
- this.plugin.settings.windowsWslMode = value;
- await this.plugin.saveSettings();
+ await this.plugin.settingsService.updateSettings({
+ windowsWslMode: value,
+ });
this.display(); // Refresh to show/hide distribution setting
}),
);
@@ -554,9 +581,12 @@ export class AgentClientSettingTab extends PluginSettingTab {
"",
)
.onChange(async (value) => {
- this.plugin.settings.windowsWslDistribution =
- value.trim() || undefined;
- await this.plugin.saveSettings();
+ await this.plugin.settingsService.updateSettings(
+ {
+ windowsWslDistribution:
+ value.trim() || undefined,
+ },
+ );
}),
);
}
@@ -590,9 +620,12 @@ export class AgentClientSettingTab extends PluginSettingTab {
.setPlaceholder("Agent Client")
.setValue(this.plugin.settings.exportSettings.defaultFolder)
.onChange(async (value) => {
- this.plugin.settings.exportSettings.defaultFolder =
- value;
- await this.plugin.saveSettings();
+ await this.plugin.settingsService.updateSettings({
+ exportSettings: {
+ ...this.plugin.settings.exportSettings,
+ defaultFolder: value,
+ },
+ });
}),
);
@@ -608,9 +641,12 @@ export class AgentClientSettingTab extends PluginSettingTab {
this.plugin.settings.exportSettings.filenameTemplate,
)
.onChange(async (value) => {
- this.plugin.settings.exportSettings.filenameTemplate =
- value;
- await this.plugin.saveSettings();
+ await this.plugin.settingsService.updateSettings({
+ exportSettings: {
+ ...this.plugin.settings.exportSettings,
+ filenameTemplate: value,
+ },
+ });
}),
);
@@ -626,9 +662,12 @@ export class AgentClientSettingTab extends PluginSettingTab {
this.plugin.settings.exportSettings.frontmatterTag,
)
.onChange(async (value) => {
- this.plugin.settings.exportSettings.frontmatterTag =
- value;
- await this.plugin.saveSettings();
+ await this.plugin.settingsService.updateSettings({
+ exportSettings: {
+ ...this.plugin.settings.exportSettings,
+ frontmatterTag: value,
+ },
+ });
}),
);
@@ -639,9 +678,12 @@ export class AgentClientSettingTab extends PluginSettingTab {
toggle
.setValue(this.plugin.settings.exportSettings.includeImages)
.onChange(async (value) => {
- this.plugin.settings.exportSettings.includeImages =
- value;
- await this.plugin.saveSettings();
+ await this.plugin.settingsService.updateSettings({
+ exportSettings: {
+ ...this.plugin.settings.exportSettings,
+ includeImages: value,
+ },
+ });
this.display();
}),
);
@@ -665,9 +707,15 @@ export class AgentClientSettingTab extends PluginSettingTab {
this.plugin.settings.exportSettings.imageLocation,
)
.onChange(async (value) => {
- this.plugin.settings.exportSettings.imageLocation =
- value as "obsidian" | "custom" | "base64";
- await this.plugin.saveSettings();
+ await this.plugin.settingsService.updateSettings({
+ exportSettings: {
+ ...this.plugin.settings.exportSettings,
+ imageLocation: value as
+ | "obsidian"
+ | "custom"
+ | "base64",
+ },
+ });
this.display();
}),
);
@@ -688,9 +736,15 @@ export class AgentClientSettingTab extends PluginSettingTab {
.imageCustomFolder,
)
.onChange(async (value) => {
- this.plugin.settings.exportSettings.imageCustomFolder =
- value;
- await this.plugin.saveSettings();
+ await this.plugin.settingsService.updateSettings(
+ {
+ exportSettings: {
+ ...this.plugin.settings
+ .exportSettings,
+ imageCustomFolder: value,
+ },
+ },
+ );
}),
);
}
@@ -707,9 +761,12 @@ export class AgentClientSettingTab extends PluginSettingTab {
this.plugin.settings.exportSettings.autoExportOnNewChat,
)
.onChange(async (value) => {
- this.plugin.settings.exportSettings.autoExportOnNewChat =
- value;
- await this.plugin.saveSettings();
+ await this.plugin.settingsService.updateSettings({
+ exportSettings: {
+ ...this.plugin.settings.exportSettings,
+ autoExportOnNewChat: value,
+ },
+ });
}),
);
@@ -725,9 +782,12 @@ export class AgentClientSettingTab extends PluginSettingTab {
.autoExportOnCloseChat,
)
.onChange(async (value) => {
- this.plugin.settings.exportSettings.autoExportOnCloseChat =
- value;
- await this.plugin.saveSettings();
+ await this.plugin.settingsService.updateSettings({
+ exportSettings: {
+ ...this.plugin.settings.exportSettings,
+ autoExportOnCloseChat: value,
+ },
+ });
}),
);
@@ -740,9 +800,12 @@ export class AgentClientSettingTab extends PluginSettingTab {
this.plugin.settings.exportSettings.openFileAfterExport,
)
.onChange(async (value) => {
- this.plugin.settings.exportSettings.openFileAfterExport =
- value;
- await this.plugin.saveSettings();
+ await this.plugin.settingsService.updateSettings({
+ exportSettings: {
+ ...this.plugin.settings.exportSettings,
+ openFileAfterExport: value,
+ },
+ });
}),
);
@@ -761,8 +824,7 @@ export class AgentClientSettingTab extends PluginSettingTab {
toggle
.setValue(this.plugin.settings.debugMode)
.onChange(async (value) => {
- await this.plugin.saveSettingsAndNotify({
- ...this.plugin.settings,
+ await this.plugin.settingsService.updateSettings({
debugMode: value,
});
}),
@@ -892,8 +954,12 @@ export class AgentClientSettingTab extends PluginSettingTab {
new SecretComponent(this.app, el)
.setValue(gemini.apiKeySecretId)
.onChange(async (value) => {
- this.plugin.settings.gemini.apiKeySecretId = value;
- await this.plugin.saveSettings();
+ await this.plugin.settingsService.updateSettings({
+ gemini: {
+ ...this.plugin.settings.gemini,
+ apiKeySecretId: value,
+ },
+ });
}),
);
@@ -906,13 +972,21 @@ export class AgentClientSettingTab extends PluginSettingTab {
text.setPlaceholder("gemini")
.setValue(gemini.command)
.onChange(async (value) => {
- this.plugin.settings.gemini.command = value.trim();
- await this.plugin.saveSettings();
+ await this.plugin.settingsService.updateSettings({
+ gemini: {
+ ...this.plugin.settings.gemini,
+ command: value.trim(),
+ },
+ });
});
});
this.addAutoDetectButton(geminiPathSetting, "gemini", async (path) => {
- this.plugin.settings.gemini.command = path;
- await this.plugin.saveSettings();
+ await this.plugin.settingsService.updateSettings({
+ gemini: {
+ ...this.plugin.settings.gemini,
+ command: path,
+ },
+ });
});
this.addInstallHint(sectionEl, "@google/gemini-cli");
@@ -925,9 +999,12 @@ export class AgentClientSettingTab extends PluginSettingTab {
text.setPlaceholder("")
.setValue(this.formatArgs(gemini.args))
.onChange(async (value) => {
- this.plugin.settings.gemini.args =
- this.parseArgs(value);
- await this.plugin.saveSettings();
+ await this.plugin.settingsService.updateSettings({
+ gemini: {
+ ...this.plugin.settings.gemini,
+ args: this.parseArgs(value),
+ },
+ });
});
text.inputEl.rows = 3;
});
@@ -941,8 +1018,12 @@ export class AgentClientSettingTab extends PluginSettingTab {
text.setPlaceholder("GOOGLE_CLOUD_PROJECT=...")
.setValue(this.formatEnv(gemini.env))
.onChange(async (value) => {
- this.plugin.settings.gemini.env = this.parseEnv(value);
- await this.plugin.saveSettings();
+ await this.plugin.settingsService.updateSettings({
+ gemini: {
+ ...this.plugin.settings.gemini,
+ env: this.parseEnv(value),
+ },
+ });
});
text.inputEl.rows = 3;
});
@@ -964,8 +1045,12 @@ export class AgentClientSettingTab extends PluginSettingTab {
new SecretComponent(this.app, el)
.setValue(claude.apiKeySecretId)
.onChange(async (value) => {
- this.plugin.settings.claude.apiKeySecretId = value;
- await this.plugin.saveSettings();
+ await this.plugin.settingsService.updateSettings({
+ claude: {
+ ...this.plugin.settings.claude,
+ apiKeySecretId: value,
+ },
+ });
}),
);
@@ -978,16 +1063,24 @@ export class AgentClientSettingTab extends PluginSettingTab {
text.setPlaceholder("claude-agent-acp")
.setValue(claude.command)
.onChange(async (value) => {
- this.plugin.settings.claude.command = value.trim();
- await this.plugin.saveSettings();
+ await this.plugin.settingsService.updateSettings({
+ claude: {
+ ...this.plugin.settings.claude,
+ command: value.trim(),
+ },
+ });
});
});
this.addAutoDetectButton(
claudePathSetting,
"claude-agent-acp",
async (path) => {
- this.plugin.settings.claude.command = path;
- await this.plugin.saveSettings();
+ await this.plugin.settingsService.updateSettings({
+ claude: {
+ ...this.plugin.settings.claude,
+ command: path,
+ },
+ });
},
);
this.addInstallHint(sectionEl, "@agentclientprotocol/claude-agent-acp");
@@ -1001,9 +1094,12 @@ export class AgentClientSettingTab extends PluginSettingTab {
text.setPlaceholder("")
.setValue(this.formatArgs(claude.args))
.onChange(async (value) => {
- this.plugin.settings.claude.args =
- this.parseArgs(value);
- await this.plugin.saveSettings();
+ await this.plugin.settingsService.updateSettings({
+ claude: {
+ ...this.plugin.settings.claude,
+ args: this.parseArgs(value),
+ },
+ });
});
text.inputEl.rows = 3;
});
@@ -1017,8 +1113,12 @@ export class AgentClientSettingTab extends PluginSettingTab {
text.setPlaceholder("")
.setValue(this.formatEnv(claude.env))
.onChange(async (value) => {
- this.plugin.settings.claude.env = this.parseEnv(value);
- await this.plugin.saveSettings();
+ await this.plugin.settingsService.updateSettings({
+ claude: {
+ ...this.plugin.settings.claude,
+ env: this.parseEnv(value),
+ },
+ });
});
text.inputEl.rows = 3;
});
@@ -1040,8 +1140,12 @@ export class AgentClientSettingTab extends PluginSettingTab {
new SecretComponent(this.app, el)
.setValue(codex.apiKeySecretId)
.onChange(async (value) => {
- this.plugin.settings.codex.apiKeySecretId = value;
- await this.plugin.saveSettings();
+ await this.plugin.settingsService.updateSettings({
+ codex: {
+ ...this.plugin.settings.codex,
+ apiKeySecretId: value,
+ },
+ });
}),
);
@@ -1054,16 +1158,24 @@ export class AgentClientSettingTab extends PluginSettingTab {
text.setPlaceholder("codex-acp")
.setValue(codex.command)
.onChange(async (value) => {
- this.plugin.settings.codex.command = value.trim();
- await this.plugin.saveSettings();
+ await this.plugin.settingsService.updateSettings({
+ codex: {
+ ...this.plugin.settings.codex,
+ command: value.trim(),
+ },
+ });
});
});
this.addAutoDetectButton(
codexPathSetting,
"codex-acp",
async (path) => {
- this.plugin.settings.codex.command = path;
- await this.plugin.saveSettings();
+ await this.plugin.settingsService.updateSettings({
+ codex: {
+ ...this.plugin.settings.codex,
+ command: path,
+ },
+ });
},
);
this.addInstallHint(sectionEl, "@zed-industries/codex-acp");
@@ -1077,8 +1189,12 @@ export class AgentClientSettingTab extends PluginSettingTab {
text.setPlaceholder("")
.setValue(this.formatArgs(codex.args))
.onChange(async (value) => {
- this.plugin.settings.codex.args = this.parseArgs(value);
- await this.plugin.saveSettings();
+ await this.plugin.settingsService.updateSettings({
+ codex: {
+ ...this.plugin.settings.codex,
+ args: this.parseArgs(value),
+ },
+ });
});
text.inputEl.rows = 3;
});
@@ -1092,8 +1208,12 @@ export class AgentClientSettingTab extends PluginSettingTab {
text.setPlaceholder("")
.setValue(this.formatEnv(codex.env))
.onChange(async (value) => {
- this.plugin.settings.codex.env = this.parseEnv(value);
- await this.plugin.saveSettings();
+ await this.plugin.settingsService.updateSettings({
+ codex: {
+ ...this.plugin.settings.codex,
+ env: this.parseEnv(value),
+ },
+ });
});
text.inputEl.rows = 3;
});
@@ -1126,7 +1246,7 @@ export class AgentClientSettingTab extends PluginSettingTab {
env: [],
});
this.plugin.ensureDefaultAgentId();
- await this.plugin.saveSettings();
+ await this.flushSettings();
this.display();
});
});
@@ -1163,7 +1283,7 @@ export class AgentClientSettingTab extends PluginSettingTab {
this.plugin.settings.defaultAgentId = nextId;
}
this.plugin.ensureDefaultAgentId();
- await this.plugin.saveSettings();
+ await this.flushSettings();
this.refreshAgentDropdown();
});
});
@@ -1175,7 +1295,7 @@ export class AgentClientSettingTab extends PluginSettingTab {
.onClick(async () => {
this.plugin.settings.customAgents.splice(index, 1);
this.plugin.ensureDefaultAgentId();
- await this.plugin.saveSettings();
+ await this.flushSettings();
this.display();
});
});
@@ -1192,7 +1312,7 @@ export class AgentClientSettingTab extends PluginSettingTab {
trimmed.length > 0
? trimmed
: this.plugin.settings.customAgents[index].id;
- await this.plugin.saveSettings();
+ await this.flushSettings();
this.refreshAgentDropdown();
});
});
@@ -1208,7 +1328,7 @@ export class AgentClientSettingTab extends PluginSettingTab {
.onChange(async (value) => {
this.plugin.settings.customAgents[index].command =
value.trim();
- await this.plugin.saveSettings();
+ await this.flushSettings();
});
});
@@ -1223,7 +1343,7 @@ export class AgentClientSettingTab extends PluginSettingTab {
.onChange(async (value) => {
this.plugin.settings.customAgents[index].args =
this.parseArgs(value);
- await this.plugin.saveSettings();
+ await this.flushSettings();
});
text.inputEl.rows = 3;
});
@@ -1239,12 +1359,27 @@ export class AgentClientSettingTab extends PluginSettingTab {
.onChange(async (value) => {
this.plugin.settings.customAgents[index].env =
this.parseEnv(value);
- await this.plugin.saveSettings();
+ await this.flushSettings();
});
text.inputEl.rows = 3;
});
}
+ /**
+ * Flush the current `plugin.settings` state through `settingsService.updateSettings()`
+ * so that React components subscribed via `useSettings` re-render.
+ *
+ * Use this after calling legacy helpers (e.g. `ensureDefaultAgentId`) that mutate
+ * `plugin.settings` directly. Passes the current values as the "update" to trigger
+ * the notification pipeline without re-merging.
+ */
+ private async flushSettings(): Promise {
+ await this.plugin.settingsService.updateSettings({
+ customAgents: this.plugin.settings.customAgents,
+ defaultAgentId: this.plugin.settings.defaultAgentId,
+ });
+ }
+
private generateCustomAgentDisplayName(): string {
const base = "Custom agent";
const existing = new Set();
@@ -1298,15 +1433,15 @@ export class AgentClientSettingTab extends PluginSettingTab {
*/
private addInstallHint(containerEl: HTMLElement, npmPackage: string): void {
const command = `npm install -g ${npmPackage}@latest`;
- const frag = document.createDocumentFragment();
- frag.append("Not installed? Run in terminal: ");
- frag.appendChild(document.createElement("code")).textContent = command;
+ const frag = createFragment();
+ frag.appendText("Not installed? Run in terminal: ");
+ frag.createEl("code", { text: command });
new Setting(containerEl).setDesc(frag).addButton((btn) => {
btn.setButtonText("Copy").onClick(() => {
void navigator.clipboard.writeText(command).then(
() => {
btn.setButtonText("Copied!");
- setTimeout(() => {
+ window.setTimeout(() => {
btn.setButtonText("Copy");
}, 1500);
},
@@ -1349,14 +1484,14 @@ export class AgentClientSettingTab extends PluginSettingTab {
this.display();
} else {
btn.setButtonText("Not found");
- setTimeout(() => {
+ window.setTimeout(() => {
btn.setButtonText("Auto-detect");
btn.setDisabled(false);
}, 2000);
}
} catch {
btn.setButtonText("Error");
- setTimeout(() => {
+ window.setTimeout(() => {
btn.setButtonText("Auto-detect");
btn.setDisabled(false);
}, 2000);
diff --git a/src/ui/SuggestionPopup.tsx b/src/ui/SuggestionPopup.tsx
index a50d9a3..38044f3 100644
--- a/src/ui/SuggestionPopup.tsx
+++ b/src/ui/SuggestionPopup.tsx
@@ -1,7 +1,5 @@
import * as React from "react";
const { useRef, useEffect } = React;
-import type AgentClientPlugin from "../plugin";
-import type { IChatViewHost } from "./view-host";
import type { NoteMetadata } from "../services/vault-service";
import type { SlashCommand } from "../types/session";
@@ -31,12 +29,6 @@ interface SuggestionPopupProps {
/** Callback to close the dropdown */
onClose: () => void;
-
- /** Plugin instance for logging */
- plugin: AgentClientPlugin;
-
- /** View instance for event registration */
- view: IChatViewHost;
}
/**
@@ -54,8 +46,6 @@ export function SuggestionPopup({
selectedIndex,
onSelect,
onClose,
- plugin,
- view,
}: SuggestionPopupProps) {
const dropdownRef = useRef(null);
@@ -70,9 +60,10 @@ export function SuggestionPopup({
}
};
- document.addEventListener("mousedown", handleClickOutside);
+ const doc = activeDocument;
+ doc.addEventListener("mousedown", handleClickOutside);
return () => {
- document.removeEventListener("mousedown", handleClickOutside);
+ doc.removeEventListener("mousedown", handleClickOutside);
};
}, [onClose]);
diff --git a/src/ui/TerminalBlock.tsx b/src/ui/TerminalBlock.tsx
index cd0aa15..7dabc08 100644
--- a/src/ui/TerminalBlock.tsx
+++ b/src/ui/TerminalBlock.tsx
@@ -2,17 +2,14 @@ import * as React from "react";
const { useState, useRef, useEffect } = React;
import type { AcpClient } from "../acp/acp-client";
import { getLogger } from "../utils/logger";
-import type AgentClientPlugin from "../plugin";
interface TerminalBlockProps {
terminalId: string;
terminalClient: AcpClient | null;
- plugin: AgentClientPlugin;
}
export const TerminalBlock = React.memo(function TerminalBlock({
terminalId,
terminalClient,
- plugin,
}: TerminalBlockProps) {
const logger = getLogger();
const [output, setOutput] = useState("");
diff --git a/src/ui/ToolCallBlock.tsx b/src/ui/ToolCallBlock.tsx
index 9449b79..ab77d83 100644
--- a/src/ui/ToolCallBlock.tsx
+++ b/src/ui/ToolCallBlock.tsx
@@ -32,7 +32,6 @@ export const ToolCallBlock = React.memo(function ToolCallBlock({
kind,
title,
status,
- toolCallId,
permissionRequest,
locations,
rawInput,
@@ -146,7 +145,6 @@ export const ToolCallBlock = React.memo(function ToolCallBlock({
key={index}
terminalId={item.terminalId}
terminalClient={terminalClient || null}
- plugin={plugin}
/>
);
}
@@ -177,8 +175,6 @@ export const ToolCallBlock = React.memo(function ToolCallBlock({
...permissionRequest,
selectedOptionId: selectedOptionId,
}}
- toolCallId={toolCallId}
- plugin={plugin}
onApprovePermission={onApprovePermission}
onOptionSelected={setSelectedOptionId}
/>
diff --git a/src/utils/error-utils.ts b/src/utils/error-utils.ts
index d0fae61..e2b558b 100644
--- a/src/utils/error-utils.ts
+++ b/src/utils/error-utils.ts
@@ -20,7 +20,7 @@ import { AcpErrorCode, type AcpError, type ErrorInfo } from "../types/errors";
*/
export function extractErrorCode(error: unknown): number | undefined {
if (error && typeof error === "object" && "code" in error) {
- const code = (error as { code: unknown }).code;
+ const code = error.code;
if (typeof code === "number") return code;
}
return undefined;
@@ -37,16 +37,16 @@ export function extractErrorMessage(error: unknown): string {
// Check data.details first (some agents use this format)
if ("data" in error) {
- const data = (error as { data: unknown }).data;
+ const data = error.data;
if (data && typeof data === "object" && "details" in data) {
- const details = (data as { details: unknown }).details;
+ const details = data.details;
if (typeof details === "string") return details;
}
}
// Then check message
if ("message" in error) {
- const msg = (error as { message: unknown }).message;
+ const msg = error.message;
if (typeof msg === "string") return msg;
}
@@ -58,7 +58,7 @@ export function extractErrorMessage(error: unknown): string {
*/
export function extractErrorData(error: unknown): unknown {
if (error && typeof error === "object" && "data" in error) {
- return (error as { data: unknown }).data;
+ return error.data;
}
return undefined;
}
diff --git a/src/utils/platform.ts b/src/utils/platform.ts
index 350c54d..6f013c7 100644
--- a/src/utils/platform.ts
+++ b/src/utils/platform.ts
@@ -214,6 +214,23 @@ export function convertWslPathToWindows(wslPath: string): string {
return wslPath;
}
+/**
+ * Compare two directory paths accounting for Windows ↔ WSL format differences.
+ */
+export function isSameDirectory(pathA: string, pathB: string): boolean {
+ if (pathA === pathB) return true;
+
+ const normalize = (p: string): string => {
+ const win = convertWslPathToWindows(p);
+ return win.replace(/\\/g, "/").replace(/\/+$/, "");
+ };
+
+ const a = normalize(pathA);
+ const b = normalize(pathB);
+
+ return Platform.isWin ? a.toLowerCase() === b.toLowerCase() : a === b;
+}
+
/**
* Build a WSL shell wrapper that sources ~/.profile, detects the user's
* $SHELL, and falls back to /bin/sh for non-POSIX shells (fish, elvish,
diff --git a/styles.css b/styles.css
index a013908..52fc63c 100644
--- a/styles.css
+++ b/styles.css
@@ -795,8 +795,20 @@ If your plugin does not need CSS, delete this file.
align-items: center;
justify-content: space-between;
gap: 4px;
- padding: 6px 12px;
- background-color: transparent;
+ padding: 6px 12px !important;
+ width: 100%;
+ cursor: pointer;
+ background: none !important;
+ background-color: transparent !important;
+ border: none !important;
+ outline: none !important;
+ appearance: none;
+ box-shadow: none !important;
+ text-align: left;
+ font: inherit;
+ color: inherit;
+ min-width: unset !important;
+ min-height: unset !important;
}
.agent-client-mention-badge {
@@ -806,7 +818,7 @@ If your plugin does not need CSS, delete this file.
font-size: var(--font-text-size);
font-weight: 500;
line-height: var(--line-height-normal);
- cursor: default;
+ cursor: pointer;
user-select: none;
white-space: nowrap;
overflow: hidden;
@@ -819,19 +831,9 @@ If your plugin does not need CSS, delete this file.
text-decoration: line-through;
}
-.agent-client-auto-mention-toggle-btn {
- background: none;
- background-color: transparent !important;
- border: none !important;
+.agent-client-auto-mention-toggle-icon {
color: var(--text-muted);
- cursor: pointer;
- padding: 0 !important;
- margin: 0 !important;
- pointer-events: auto;
transition: color 0.2s;
- outline: none !important;
- appearance: none;
- box-shadow: none !important;
width: 16px;
height: 16px;
display: flex;
@@ -840,25 +842,16 @@ If your plugin does not need CSS, delete this file.
flex-shrink: 0;
}
-.agent-client-auto-mention-toggle-btn svg {
+.agent-client-auto-mention-toggle-icon svg {
width: 14px;
height: 14px;
color: inherit;
}
-.agent-client-auto-mention-toggle-btn:hover {
- background: none;
- box-shadow: none;
+.agent-client-auto-mention-inline:hover .agent-client-auto-mention-toggle-icon {
color: var(--interactive-accent);
}
-.agent-client-auto-mention-toggle-btn:active,
-.agent-client-auto-mention-toggle-btn:focus {
- background: none;
- box-shadow: none;
- outline: none;
-}
-
.agent-client-chat-input-textarea {
width: 100%;
padding: 12px;
From a3aa663ba30601a04e1fa984bd9bf398157cbe2c Mon Sep 17 00:00:00 2001
From: RAIT-09 <51452399+RAIT-09@users.noreply.github.com>
Date: Fri, 22 May 2026 12:46:13 +0900
Subject: [PATCH 46/88] refactor: extract truncateTitle helper to utils/text
Three sites in useSessionHistory.ts used the same
text.length > 50 ? text.substring(0, 50) + '...' : text pattern
to truncate session titles. Consolidate into a single utility and
apply to forkSession and saveSessionLocally.
---
src/hooks/useSessionHistory.ts | 16 ++++------------
src/utils/text.ts | 7 +++++++
2 files changed, 11 insertions(+), 12 deletions(-)
create mode 100644 src/utils/text.ts
diff --git a/src/hooks/useSessionHistory.ts b/src/hooks/useSessionHistory.ts
index a14336f..fb76d3f 100644
--- a/src/hooks/useSessionHistory.ts
+++ b/src/hooks/useSessionHistory.ts
@@ -12,6 +12,7 @@ import type {
AgentCapabilities,
} from "../types/session";
import type { ChatMessage } from "../types/chat";
+import { truncateTitle } from "../utils/text";
// ============================================================================
// Session Capability Helpers (from session-capability-utils.ts)
@@ -628,15 +629,9 @@ export function useSessionHistory(
);
const originalTitle = originalSession?.title ?? "Session";
- // Truncate title to 50 characters
- const maxTitleLength = 50;
+ // Keep "Fork: " prefix intact; truncate only the base.
const prefix = "Fork: ";
- const maxBaseLength = maxTitleLength - prefix.length;
- const truncatedTitle =
- originalTitle.length > maxBaseLength
- ? originalTitle.substring(0, maxBaseLength) + "..."
- : originalTitle;
- const newTitle = `${prefix}${truncatedTitle}`;
+ const newTitle = `${prefix}${truncateTitle(originalTitle, 50 - prefix.length)}`;
const now = new Date().toISOString();
@@ -760,10 +755,7 @@ export function useSessionHistory(
async (sessionId: string, messageContent: string) => {
if (!session.agentId) return;
- const title =
- messageContent.length > 50
- ? messageContent.substring(0, 50) + "..."
- : messageContent;
+ const title = truncateTitle(messageContent);
await settingsAccess.saveSession({
sessionId,
diff --git a/src/utils/text.ts b/src/utils/text.ts
new file mode 100644
index 0000000..c1c7238
--- /dev/null
+++ b/src/utils/text.ts
@@ -0,0 +1,7 @@
+/**
+ * Truncate a string to a maximum length, appending an ellipsis if cut.
+ */
+export function truncateTitle(text: string, maxLength = 50): string {
+ if (text.length <= maxLength) return text;
+ return text.slice(0, maxLength) + "...";
+}
From 88f039221d30a9ebe52b161c1a291e454a97d3e1 Mon Sep 17 00:00:00 2001
From: RAIT-09 <51452399+RAIT-09@users.noreply.github.com>
Date: Fri, 22 May 2026 12:47:37 +0900
Subject: [PATCH 47/88] refactor(session-storage): add updateSession and fix
mutation in updateSessionTitle
* Introduce SessionStorage.updateSession(sessionId, patch) for partial
updates that are atomic under sessionLock. Use it for the per-turn
updatedAt bump in useSessionHistory.saveSessionMessages, replacing
the spread-and-saveSession round trip.
* Replace direct mutation in updateSessionTitle (existing.title = ...)
with an immutable index-based update, matching saveSession's pattern
and removing the risk of subscribers observing mid-mutation state.
---
src/hooks/useSessionHistory.ts | 19 +++++----------
src/services/session-storage.ts | 40 ++++++++++++++++++++++++++++----
src/services/settings-service.ts | 16 +++++++++++++
3 files changed, 58 insertions(+), 17 deletions(-)
diff --git a/src/hooks/useSessionHistory.ts b/src/hooks/useSessionHistory.ts
index fb76d3f..f81bf65 100644
--- a/src/hooks/useSessionHistory.ts
+++ b/src/hooks/useSessionHistory.ts
@@ -793,19 +793,12 @@ export function useSessionHistory(
);
// Bump updatedAt on session metadata so "last used" ordering
- // reflects real activity. Read live snapshot (not React state)
- // to avoid races with rapid fork/rename. Skip if the metadata
- // entry hasn't landed yet — saveSessionLocally will create it
- // on the first-message path.
- const existing = settingsAccess
- .getSavedSessions()
- .find((s) => s.sessionId === sessionId);
- if (existing) {
- void settingsAccess.saveSession({
- ...existing,
- updatedAt: new Date().toISOString(),
- });
- }
+ // reflects real activity. `updateSession` is a no-op if the entry
+ // hasn't landed yet — saveSessionLocally will create it on the
+ // first-message path.
+ void settingsAccess.updateSession(sessionId, {
+ updatedAt: new Date().toISOString(),
+ });
},
[session.agentId, settingsAccess],
);
diff --git a/src/services/session-storage.ts b/src/services/session-storage.ts
index f727773..423e618 100644
--- a/src/services/session-storage.ts
+++ b/src/services/session-storage.ts
@@ -167,11 +167,16 @@ export class SessionStorage {
this.sessionLock = this.sessionLock.then(async () => {
const state = this.settingsAccess.getSnapshot();
const sessions = [...(state.savedSessions || [])];
- const existing = sessions.find((s) => s.sessionId === sessionId);
+ const idx = sessions.findIndex((s) => s.sessionId === sessionId);
- if (existing) {
- existing.title = newTitle;
- existing.updatedAt = new Date().toISOString();
+ if (idx >= 0) {
+ // Immutable update: replace the object instead of mutating it,
+ // matching saveSession's pattern and keeping state objects stable.
+ sessions[idx] = {
+ ...sessions[idx],
+ title: newTitle,
+ updatedAt: new Date().toISOString(),
+ };
} else if (createIfMissing) {
sessions.unshift({
sessionId,
@@ -192,6 +197,33 @@ export class SessionStorage {
await this.sessionLock;
}
+ /**
+ * Update fields of an existing saved session.
+ * Silently no-op if the session does not exist (no create).
+ * `updatedAt` is set to now unless explicitly provided in `patch`.
+ */
+ async updateSession(
+ sessionId: string,
+ patch: Partial>,
+ ): Promise {
+ this.sessionLock = this.sessionLock.then(async () => {
+ const state = this.settingsAccess.getSnapshot();
+ const sessions = [...(state.savedSessions || [])];
+ const idx = sessions.findIndex((s) => s.sessionId === sessionId);
+ if (idx < 0) return;
+
+ sessions[idx] = {
+ ...sessions[idx],
+ ...patch,
+ updatedAt: patch.updatedAt ?? new Date().toISOString(),
+ };
+ await this.settingsAccess.updateSettings({
+ savedSessions: sessions,
+ });
+ });
+ await this.sessionLock;
+ }
+
// ============================================================
// Session Message History Methods
// ============================================================
diff --git a/src/services/settings-service.ts b/src/services/settings-service.ts
index b693371..bd89e6c 100644
--- a/src/services/settings-service.ts
+++ b/src/services/settings-service.ts
@@ -103,6 +103,15 @@ export interface ISettingsAccess {
createIfMissing?: { agentId: string; cwd: string },
): Promise;
+ /**
+ * Update fields of an existing saved session.
+ * Silently no-op if the session does not exist.
+ */
+ updateSession(
+ sessionId: string,
+ patch: Partial>,
+ ): Promise;
+
// ============================================================
// Session Message History Methods
// ============================================================
@@ -279,6 +288,13 @@ export class SettingsService implements ISettingsAccess {
);
}
+ async updateSession(
+ sessionId: string,
+ patch: Partial>,
+ ): Promise {
+ return this.sessionStorage.updateSession(sessionId, patch);
+ }
+
async saveSessionMessages(
sessionId: string,
agentId: string,
From 60ec006e29dcd406e86af701d3ce3da56361ec8b Mon Sep 17 00:00:00 2001
From: RAIT-09 <51452399+RAIT-09@users.noreply.github.com>
Date: Fri, 22 May 2026 12:48:59 +0900
Subject: [PATCH 48/88] refactor(view-registry): add close() to
IChatViewContainer
closeView() in plugin.ts switched on `instanceof FloatingViewContainer`
to choose between unmount() and leaf.detach(). Replace that dispatch
with a polymorphic close() method on each implementation. The plugin
no longer needs to know about the concrete container class, and the
sidebar's linear getLeavesOfType-then-find lookup is replaced by a
single registry Map.get.
---
src/plugin.ts | 15 +++------------
src/services/view-registry.ts | 9 +++++++++
src/ui/ChatView.tsx | 4 ++++
src/ui/FloatingChatView.tsx | 4 ++++
4 files changed, 20 insertions(+), 12 deletions(-)
diff --git a/src/plugin.ts b/src/plugin.ts
index ee3d2a3..c54d113 100644
--- a/src/plugin.ts
+++ b/src/plugin.ts
@@ -520,20 +520,11 @@ export default class AgentClientPlugin extends Plugin {
/**
* Close a specific chat view (sidebar or floating).
+ * Dispatch is via IChatViewContainer.close(); plugin does not need to
+ * know the concrete container class.
*/
closeView(viewId: string): void {
- const container = this.viewRegistry.get(viewId);
- if (container && container instanceof FloatingViewContainer) {
- container.unmount();
- return;
- }
- const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE_CHAT);
- const leaf = leaves.find(
- (l) => (l.view as ChatView)?.viewId === viewId,
- );
- if (leaf) {
- leaf.detach();
- }
+ this.viewRegistry.get(viewId)?.close();
}
/**
diff --git a/src/services/view-registry.ts b/src/services/view-registry.ts
index d673a77..5ee6efb 100644
--- a/src/services/view-registry.ts
+++ b/src/services/view-registry.ts
@@ -158,6 +158,15 @@ export interface IChatViewContainer {
*/
getSessionId(): string | null;
+ /**
+ * Close this view permanently.
+ * - Sidebar (`ChatView`): detaches the workspace leaf
+ * - Floating (`FloatingViewContainer`): unmounts the React root
+ * Implementations are responsible for triggering proper cleanup
+ * (onClose / unmount), which transitively unregisters from the registry.
+ */
+ close(): void;
+
// ============================================================
// Container Access
// ============================================================
diff --git a/src/ui/ChatView.tsx b/src/ui/ChatView.tsx
index e3497fb..3cc2378 100644
--- a/src/ui/ChatView.tsx
+++ b/src/ui/ChatView.tsx
@@ -219,6 +219,10 @@ export class ChatView extends ItemView implements IChatViewContainer {
return this.callbacks?.getSessionId() ?? null;
}
+ close(): void {
+ this.leaf.detach();
+ }
+
/**
* Get current input state (text + images).
* Returns null if React component not mounted.
diff --git a/src/ui/FloatingChatView.tsx b/src/ui/FloatingChatView.tsx
index bd572ce..8e16771 100644
--- a/src/ui/FloatingChatView.tsx
+++ b/src/ui/FloatingChatView.tsx
@@ -154,6 +154,10 @@ export class FloatingViewContainer implements IChatViewContainer {
return this.callbacks?.getSessionId() ?? null;
}
+ close(): void {
+ this.unmount();
+ }
+
onActivate(): void {
this.containerEl.classList.add("is-focused");
}
From 13ea0136fcc04edf7b0b7a739a3ffda57e849d6e Mon Sep 17 00:00:00 2001
From: RAIT-09 <51452399+RAIT-09@users.noreply.github.com>
Date: Fri, 22 May 2026 12:50:29 +0900
Subject: [PATCH 49/88] refactor(view-registry): introduce SessionStatus type
alias for exhaustive checks
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Replace inline 'ready' | 'busy' | ... unions with a named SessionStatus
type exported from view-registry.ts. Adopt the alias in IChatViewContainer,
ChatPanelCallbacks, ChatView, FloatingChatView. In SessionManagerView's
SessionStatusIcon, narrow the prop to SessionStatus and drop the default
case from the switch so TypeScript verifies exhaustiveness — any future
status addition becomes a compile error instead of a silent fallback.
---
src/services/view-registry.ts | 13 ++++++++++++-
src/ui/ChatPanel.tsx | 3 ++-
src/ui/ChatView.tsx | 3 ++-
src/ui/FloatingChatView.tsx | 3 ++-
src/ui/SessionManagerView.tsx | 15 ++++++++-------
5 files changed, 26 insertions(+), 11 deletions(-)
diff --git a/src/services/view-registry.ts b/src/services/view-registry.ts
index 5ee6efb..3f4612f 100644
--- a/src/services/view-registry.ts
+++ b/src/services/view-registry.ts
@@ -28,6 +28,17 @@ import { getLogger } from "../utils/logger";
*/
export type ChatViewType = "sidebar" | "floating";
+/**
+ * Simplified session status for display in session lists.
+ * Derived from session state, permission state, and sending state.
+ */
+export type SessionStatus =
+ | "ready"
+ | "busy"
+ | "permission"
+ | "error"
+ | "disconnected";
+
/**
* Interface that all chat view containers must implement.
* Enables the plugin to manage views uniformly regardless of their implementation.
@@ -144,7 +155,7 @@ export interface IChatViewContainer {
* Get the current session status for display in session lists.
* Combines session state and permission status into a simplified status.
*/
- getSessionStatus(): "ready" | "busy" | "permission" | "error" | "disconnected";
+ getSessionStatus(): SessionStatus;
/**
* Get the session title for display in session lists.
diff --git a/src/ui/ChatPanel.tsx b/src/ui/ChatPanel.tsx
index 05fa2d5..794b020 100644
--- a/src/ui/ChatPanel.tsx
+++ b/src/ui/ChatPanel.tsx
@@ -40,6 +40,7 @@ import {
type SessionConfigOption,
} from "../types/session";
import { checkAgentUpdate } from "../services/update-checker";
+import type { SessionStatus } from "../services/view-registry";
/** Stable empty array for useSuggestions when no commands available */
const EMPTY_COMMANDS: SlashCommand[] = [];
@@ -61,7 +62,7 @@ import type { IChatViewHost } from "./view-host";
*/
export interface ChatPanelCallbacks {
getDisplayName: () => string;
- getSessionStatus: () => "ready" | "busy" | "permission" | "error" | "disconnected";
+ getSessionStatus: () => SessionStatus;
getSessionTitle: () => string;
getSessionId: () => string | null;
getInputState: () => ChatInputState | null;
diff --git a/src/ui/ChatView.tsx b/src/ui/ChatView.tsx
index 3cc2378..1b31570 100644
--- a/src/ui/ChatView.tsx
+++ b/src/ui/ChatView.tsx
@@ -2,6 +2,7 @@ import { ItemView, WorkspaceLeaf } from "obsidian";
import type {
IChatViewContainer,
ChatViewType,
+ SessionStatus,
} from "../services/view-registry";
import * as React from "react";
const { useState, useEffect, useMemo } = React;
@@ -207,7 +208,7 @@ export class ChatView extends ItemView implements IChatViewContainer {
return this.callbacks?.getDisplayName() ?? "Chat";
}
- getSessionStatus(): "ready" | "busy" | "permission" | "error" | "disconnected" {
+ getSessionStatus(): SessionStatus {
return this.callbacks?.getSessionStatus() ?? "disconnected";
}
diff --git a/src/ui/FloatingChatView.tsx b/src/ui/FloatingChatView.tsx
index 8e16771..7699691 100644
--- a/src/ui/FloatingChatView.tsx
+++ b/src/ui/FloatingChatView.tsx
@@ -6,6 +6,7 @@ import type AgentClientPlugin from "../plugin";
import type {
IChatViewContainer,
ChatViewType,
+ SessionStatus,
} from "../services/view-registry";
import type { ChatInputState } from "../types/chat";
@@ -142,7 +143,7 @@ export class FloatingViewContainer implements IChatViewContainer {
return this.callbacks?.getDisplayName() ?? "Chat";
}
- getSessionStatus(): "ready" | "busy" | "permission" | "error" | "disconnected" {
+ getSessionStatus(): SessionStatus {
return this.callbacks?.getSessionStatus() ?? "disconnected";
}
diff --git a/src/ui/SessionManagerView.tsx b/src/ui/SessionManagerView.tsx
index 8bb98b1..725416f 100644
--- a/src/ui/SessionManagerView.tsx
+++ b/src/ui/SessionManagerView.tsx
@@ -5,7 +5,10 @@ import { useSyncExternalStore } from "react";
import { createRoot, type Root } from "react-dom/client";
import type AgentClientPlugin from "../plugin";
-import type { IChatViewContainer } from "../services/view-registry";
+import type {
+ IChatViewContainer,
+ SessionStatus,
+} from "../services/view-registry";
import { EditTitleModal } from "./EditTitleModal";
import { useSettings } from "../hooks/useSettings";
@@ -15,11 +18,11 @@ export const VIEW_TYPE_SESSION_MANAGER = "agent-client-session-manager";
// React Components
// ============================================================================
-function SessionStatusIcon({ status }: { status: string }) {
+function SessionStatusIcon({ status }: { status: SessionStatus }) {
const iconRef = useRef(null);
- const iconName = (() => {
- switch (status) {
+ const iconName = ((s: SessionStatus): string => {
+ switch (s) {
case "ready":
return "circle-check";
case "busy":
@@ -30,10 +33,8 @@ function SessionStatusIcon({ status }: { status: string }) {
return "circle-x";
case "disconnected":
return "circle-off";
- default:
- return "circle";
}
- })();
+ })(status);
useEffect(() => {
if (iconRef.current) setIcon(iconRef.current, iconName);
From 820fd493e7bff65b41ff8a2991848c548ce4d0c9 Mon Sep 17 00:00:00 2001
From: RAIT-09 <51452399+RAIT-09@users.noreply.github.com>
Date: Fri, 22 May 2026 12:51:21 +0900
Subject: [PATCH 50/88] refactor(view-registry): include focusedId in snapshot
for explicit reactivity
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Before, SessionManagerComponent destructured `views` from
useSyncExternalStore and read `focusedId` via a separate
`getFocusedId()` call. The latter was implicitly reactive only because
setFocused() also calls notifyChange() — a non-obvious dependency.
Promote getSnapshot()'s return type to ViewRegistrySnapshot { views,
focusedId }, mirroring SettingsService's whole-state snapshot pattern.
Both fields now flow through the same useSyncExternalStore tear-free
read path.
---
src/services/view-registry.ts | 26 +++++++++++++++++++++-----
src/ui/SessionManagerView.tsx | 4 +---
2 files changed, 22 insertions(+), 8 deletions(-)
diff --git a/src/services/view-registry.ts b/src/services/view-registry.ts
index 3f4612f..44a0de2 100644
--- a/src/services/view-registry.ts
+++ b/src/services/view-registry.ts
@@ -39,6 +39,17 @@ export type SessionStatus =
| "error"
| "disconnected";
+/**
+ * Reactive snapshot of the view registry for `useSyncExternalStore`.
+ * Includes both the view list and the focused view ID so that consumers
+ * (e.g. SessionManagerView) can derive UI state from a single source —
+ * mirrors SettingsService's whole-state snapshot pattern.
+ */
+export interface ViewRegistrySnapshot {
+ views: IChatViewContainer[];
+ focusedId: string | null;
+}
+
/**
* Interface that all chat view containers must implement.
* Enables the plugin to manage views uniformly regardless of their implementation.
@@ -194,7 +205,7 @@ export class ChatViewRegistry {
private focusedViewId: string | null = null;
private logger = getLogger();
private changeListeners = new Set<() => void>();
- private snapshotCache: IChatViewContainer[] | null = null;
+ private snapshotCache: ViewRegistrySnapshot | null = null;
// ============================================================
// Registration
@@ -417,12 +428,17 @@ export class ChatViewRegistry {
}
/**
- * Get a stable snapshot of all views for useSyncExternalStore.
- * Returns the same array reference until notifyChange() invalidates it.
+ * Get a stable snapshot of the registry for useSyncExternalStore.
+ * Returns the same object reference until notifyChange() invalidates it.
+ * Includes both views and focusedId so consumers derive UI state from
+ * a single source (mirrors SettingsService's whole-state snapshot pattern).
*/
- getSnapshot = (): IChatViewContainer[] => {
+ getSnapshot = (): ViewRegistrySnapshot => {
if (!this.snapshotCache) {
- this.snapshotCache = Array.from(this.views.values());
+ this.snapshotCache = {
+ views: Array.from(this.views.values()),
+ focusedId: this.focusedViewId,
+ };
}
return this.snapshotCache;
};
diff --git a/src/ui/SessionManagerView.tsx b/src/ui/SessionManagerView.tsx
index 725416f..f399382 100644
--- a/src/ui/SessionManagerView.tsx
+++ b/src/ui/SessionManagerView.tsx
@@ -157,7 +157,7 @@ function SessionManagerComponent({
}: {
plugin: AgentClientPlugin;
}) {
- const views = useSyncExternalStore(
+ const { views, focusedId } = useSyncExternalStore(
plugin.viewRegistry.subscribe,
plugin.viewRegistry.getSnapshot,
plugin.viewRegistry.getSnapshot,
@@ -166,8 +166,6 @@ function SessionManagerComponent({
// Subscribe to settings changes so renamed titles are reflected immediately
useSettings(plugin);
- const focusedId = plugin.viewRegistry.getFocusedId();
-
if (views.length === 0) {
return (
From 5b13d00f88042fe3da5c6e8abb354edaf98ba868 Mon Sep 17 00:00:00 2001
From: RAIT-09 <51452399+RAIT-09@users.noreply.github.com>
Date: Fri, 22 May 2026 12:51:54 +0900
Subject: [PATCH 51/88] fix(session-history): re-merge local titles on settings
change
If a user renames a session via the Session Manager view while the
Session History modal is open, the modal's `sessions` state was stale
until reopened. Subscribe to settingsAccess in useSessionHistory and
re-run mergeWithLocalTitles when `savedSessions` changes.
Subscribe directly (not via useSettings) to avoid re-rendering on
unrelated settings slices like windowsWslMode or fontSize. Guard with
a reference check on savedSessions and a title-equality bail-out so
the per-turn updatedAt bump doesn't trigger unnecessary re-renders.
---
src/hooks/useSessionHistory.ts | 34 +++++++++++++++++++++++++++++++++-
1 file changed, 33 insertions(+), 1 deletion(-)
diff --git a/src/hooks/useSessionHistory.ts b/src/hooks/useSessionHistory.ts
index f81bf65..fa22049 100644
--- a/src/hooks/useSessionHistory.ts
+++ b/src/hooks/useSessionHistory.ts
@@ -1,4 +1,4 @@
-import { useState, useCallback, useRef, useMemo } from "react";
+import { useState, useCallback, useRef, useMemo, useEffect } from "react";
import type { AcpClient } from "../acp/acp-client";
import type { ISettingsAccess } from "../services/settings-service";
import type {
@@ -300,6 +300,38 @@ export function useSessionHistory(
const cacheRef = useRef(null);
const currentCwdRef = useRef(undefined);
+ // External rename detection: when `savedSessions` changes (e.g. via Session
+ // Manager's Rename), re-merge titles into the currently displayed list
+ // without re-fetching from the agent. Subscribe to settings directly (rather
+ // than via useSettings) so we don't re-render on unrelated settings slices
+ // (windowsWslMode, fontSize, etc.). Reference comparison is sufficient
+ // because SessionStorage always writes a fresh `savedSessions` array.
+ const lastSavedSessionsRef = useRef(null);
+
+ useEffect(() => {
+ return settingsAccess.subscribe(() => {
+ const next = settingsAccess.getSnapshot().savedSessions ?? [];
+ if (next === lastSavedSessionsRef.current) return;
+ lastSavedSessionsRef.current = next;
+
+ const localSessions = settingsAccess.getSavedSessions(
+ session.agentId,
+ );
+ setSessions((prev) => {
+ if (prev.length === 0) return prev;
+ const merged = mergeWithLocalTitles(prev, localSessions);
+ // Skip render if no title actually changed
+ const unchanged =
+ merged.length === prev.length &&
+ merged.every((s, i) => s.title === prev[i].title);
+ return unchanged ? prev : merged;
+ });
+ setLocalSessionIds(
+ new Set(localSessions.map((s) => s.sessionId)),
+ );
+ });
+ }, [settingsAccess, session.agentId]);
+
/**
* Check if cache is valid.
*/
From eda0382c411273f3937e5e449ac0241a1044b3f1 Mon Sep 17 00:00:00 2001
From: RAIT-09 <51452399+RAIT-09@users.noreply.github.com>
Date: Fri, 22 May 2026 12:53:22 +0900
Subject: [PATCH 52/88] refactor(ui): centralize rename session menu item
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The same three lines of menu item construction (label, pencil icon,
disabled when no saved session, open EditTitleModal calling
updateSessionTitle) lived in three places: ChatPanel's sidebar menu,
ChatPanel's floating menu, and SessionManagerView's per-item context
menu.
Extract addRenameSessionMenuItem and openRenameSessionModal into
EditTitleModal.ts. While consolidating, surface the disabled reason
in the menu label itself — when no first message has been sent yet,
the item now reads 'Rename session (send a message first)' so users
understand why it is grayed out (MenuItem has no setTooltip API).
---
src/ui/ChatPanel.tsx | 74 +++++++++--------------------------
src/ui/EditTitleModal.ts | 64 +++++++++++++++++++++++++++++-
src/ui/SessionManagerView.tsx | 36 ++++-------------
3 files changed, 90 insertions(+), 84 deletions(-)
diff --git a/src/ui/ChatPanel.tsx b/src/ui/ChatPanel.tsx
index 794b020..9ead06a 100644
--- a/src/ui/ChatPanel.tsx
+++ b/src/ui/ChatPanel.tsx
@@ -14,7 +14,7 @@ import { isSameDirectory } from "../utils/platform";
import { useHistoryModal } from "../hooks/useHistoryModal";
import { useChatActions } from "../hooks/useChatActions";
import { ChangeDirectoryModal } from "./ChangeDirectoryModal";
-import { EditTitleModal } from "./EditTitleModal";
+import { addRenameSessionMenuItem } from "./EditTitleModal";
// Service imports
import { getLogger } from "../utils/logger";
@@ -388,33 +388,15 @@ export function ChatPanel({
menu.addSeparator();
// -- Actions section --
- menu.addItem((item: MenuItem) => {
- const hasSavedSession = session.sessionId
- ? plugin.settingsService
- .getSavedSessions()
- .some((s) => s.sessionId === session.sessionId)
- : false;
- item.setTitle("Rename session")
- .setIcon("pencil")
- .setDisabled(!hasSavedSession)
- .onClick(() => {
- if (!session.sessionId || !hasSavedSession) return;
- const saved = plugin.settingsService
- .getSavedSessions()
- .find((s) => s.sessionId === session.sessionId);
- const modal = new EditTitleModal(
- plugin.app,
- saved?.title ?? "New session",
- async (newTitle) => {
- await plugin.settingsService.updateSessionTitle(
- session.sessionId!,
- newTitle,
- );
- },
- );
- modal.open();
- });
- });
+ addRenameSessionMenuItem(
+ menu,
+ plugin,
+ session.sessionId,
+ plugin.settingsService
+ .getSavedSessions()
+ .find((s) => s.sessionId === session.sessionId)?.title ??
+ "New session",
+ );
menu.addItem((item: MenuItem) => {
item.setTitle("Open new view")
@@ -511,33 +493,15 @@ export function ChatPanel({
menu.addSeparator();
- menu.addItem((item: MenuItem) => {
- const hasSavedSession = session.sessionId
- ? plugin.settingsService
- .getSavedSessions()
- .some((s) => s.sessionId === session.sessionId)
- : false;
- item.setTitle("Rename session")
- .setIcon("pencil")
- .setDisabled(!hasSavedSession)
- .onClick(() => {
- if (!session.sessionId || !hasSavedSession) return;
- const saved = plugin.settingsService
- .getSavedSessions()
- .find((s) => s.sessionId === session.sessionId);
- const modal = new EditTitleModal(
- plugin.app,
- saved?.title ?? "New session",
- async (newTitle) => {
- await plugin.settingsService.updateSessionTitle(
- session.sessionId!,
- newTitle,
- );
- },
- );
- modal.open();
- });
- });
+ addRenameSessionMenuItem(
+ menu,
+ plugin,
+ session.sessionId,
+ plugin.settingsService
+ .getSavedSessions()
+ .find((s) => s.sessionId === session.sessionId)?.title ??
+ "New session",
+ );
if (onOpenNewWindow) {
menu.addItem((item: MenuItem) => {
diff --git a/src/ui/EditTitleModal.ts b/src/ui/EditTitleModal.ts
index 033eb1c..9fc9727 100644
--- a/src/ui/EditTitleModal.ts
+++ b/src/ui/EditTitleModal.ts
@@ -5,7 +5,8 @@
* Calls onSave callback with the new title when user clicks Save.
*/
-import { Modal, App } from "obsidian";
+import { Modal, App, Menu } from "obsidian";
+import type AgentClientPlugin from "../plugin";
export class EditTitleModal extends Modal {
private currentTitle: string;
@@ -81,3 +82,64 @@ export class EditTitleModal extends Modal {
contentEl.empty();
}
}
+
+/**
+ * Open the EditTitleModal for renaming a saved session.
+ * Used by ChatPanel's More menu and SessionManagerView's item context menu.
+ */
+export function openRenameSessionModal(
+ plugin: AgentClientPlugin,
+ sessionId: string,
+ currentTitle: string,
+): void {
+ const modal = new EditTitleModal(
+ plugin.app,
+ currentTitle,
+ async (newTitle) => {
+ await plugin.settingsService.updateSessionTitle(
+ sessionId,
+ newTitle,
+ );
+ },
+ );
+ modal.open();
+}
+
+/**
+ * Add a "Rename session" menu item that opens the rename modal.
+ * Centralizes the disabled/enabled label so all call sites stay in sync.
+ *
+ * @param menu - The menu to which the item is added.
+ * @param plugin - The plugin instance.
+ * @param sessionId - The session ID to rename (null when not yet saved).
+ * @param currentTitle - The current title (used as the modal's initial value).
+ * @param options.label - Override the menu item label (default: "Rename session").
+ */
+export function addRenameSessionMenuItem(
+ menu: Menu,
+ plugin: AgentClientPlugin,
+ sessionId: string | null,
+ currentTitle: string,
+ options?: { label?: string },
+): void {
+ const baseLabel = options?.label ?? "Rename session";
+ const hasSavedSession = sessionId
+ ? plugin.settingsService
+ .getSavedSessions()
+ .some((s) => s.sessionId === sessionId)
+ : false;
+
+ menu.addItem((item) => {
+ item.setTitle(
+ hasSavedSession
+ ? baseLabel
+ : `${baseLabel} (send a message first)`,
+ )
+ .setIcon("pencil")
+ .setDisabled(!hasSavedSession)
+ .onClick(() => {
+ if (!sessionId || !hasSavedSession) return;
+ openRenameSessionModal(plugin, sessionId, currentTitle);
+ });
+ });
+}
diff --git a/src/ui/SessionManagerView.tsx b/src/ui/SessionManagerView.tsx
index f399382..7e7fa7e 100644
--- a/src/ui/SessionManagerView.tsx
+++ b/src/ui/SessionManagerView.tsx
@@ -9,7 +9,7 @@ import type {
IChatViewContainer,
SessionStatus,
} from "../services/view-registry";
-import { EditTitleModal } from "./EditTitleModal";
+import { addRenameSessionMenuItem } from "./EditTitleModal";
import { useSettings } from "../hooks/useSettings";
export const VIEW_TYPE_SESSION_MANAGER = "agent-client-session-manager";
@@ -72,33 +72,13 @@ function SessionItem({
(position: { x: number; y: number }) => {
const menu = new Menu();
- const sessionId = view.getSessionId();
- const hasSavedSession = sessionId
- ? plugin.settingsService
- .getSavedSessions()
- .some((s) => s.sessionId === sessionId)
- : false;
-
- menu.addItem((item) => {
- item.setTitle("Rename")
- .setIcon("pencil")
- .setDisabled(!hasSavedSession)
- .onClick(() => {
- if (!sessionId || !hasSavedSession) return;
- const currentTitle = view.getSessionTitle();
- const modal = new EditTitleModal(
- plugin.app,
- currentTitle,
- async (newTitle) => {
- await plugin.settingsService.updateSessionTitle(
- sessionId,
- newTitle,
- );
- },
- );
- modal.open();
- });
- });
+ addRenameSessionMenuItem(
+ menu,
+ plugin,
+ view.getSessionId(),
+ view.getSessionTitle(),
+ { label: "Rename" },
+ );
menu.addItem((item) => {
item.setTitle("Close")
From 01deb597fbc2cffb04cbb4cd5d910de2780aa147 Mon Sep 17 00:00:00 2001
From: RAIT-09 <51452399+RAIT-09@users.noreply.github.com>
Date: Fri, 22 May 2026 12:54:14 +0900
Subject: [PATCH 53/88] perf(chat-panel): debounce viewRegistry.notifyChange to
first-message transition
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The effect dependency `messages.length` caused notifyChange() to fire
on every streamed message chunk (30-100 times per turn), which in turn
invalidated viewRegistry.snapshotCache and re-ran any subscribed
SessionManagerComponent.
Replace with `hasMessages = messages.length > 0` so the effect only
fires once on the first-message transition, then stays stable. The
SessionManager-visible title and status do not depend on per-chunk
growth — first user message text is immutable once committed.
---
src/ui/ChatPanel.tsx | 15 ++++++++++++++-
1 file changed, 14 insertions(+), 1 deletion(-)
diff --git a/src/ui/ChatPanel.tsx b/src/ui/ChatPanel.tsx
index 9ead06a..0b57457 100644
--- a/src/ui/ChatPanel.tsx
+++ b/src/ui/ChatPanel.tsx
@@ -764,9 +764,22 @@ export function ChatPanel({
// ============================================================
// Effects - Notify ViewRegistry of State Changes
// ============================================================
+ // `hasMessages` flips false → true on first message and then stays stable
+ // for the rest of the conversation. The Session Manager's title and
+ // status only depend on this boolean transition, not on per-chunk growth,
+ // so we avoid notifying on every streamed token.
+ const hasMessages = messages.length > 0;
useEffect(() => {
plugin.viewRegistry.notifyChange();
- }, [plugin.viewRegistry, session.state, isSending, agent.hasActivePermission, sessionHistory.loading, messages.length]);
+ }, [
+ plugin.viewRegistry,
+ session.state,
+ session.sessionId,
+ isSending,
+ agent.hasActivePermission,
+ sessionHistory.loading,
+ hasMessages,
+ ]);
// ============================================================
// Effects - System Notification on Permission Request
From e9bb6f526e842831b70d87f6c0b62a8c77181739 Mon Sep 17 00:00:00 2001
From: RAIT-09 <51452399+RAIT-09@users.noreply.github.com>
Date: Fri, 22 May 2026 12:55:34 +0900
Subject: [PATCH 54/88] perf(session-manager): wrap SessionItem in React.memo
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The parent SessionManagerComponent re-renders whenever viewRegistry
.notifyChange() fires (snapshotCache invalidated → fresh views array
reference from Array.from). Without memoization, every SessionItem
re-rendered even for state changes in other views, re-invoking
view.getSessionStatus(), view.getSessionTitle() (O(savedSessions)
scan), and view.getDisplayName().
Wrap SessionItem in React.memo. The onClick prop, which was
recreated inline by the parent on every render and defeated memo,
is removed — SessionItem now derives the click handler internally
from the stable `view` prop via useCallback.
Aligns with the project's existing list-item memo pattern
(MessageBubble, ToolCallBlock, TerminalBlock).
---
src/ui/SessionManagerView.tsx | 13 +++++++------
1 file changed, 7 insertions(+), 6 deletions(-)
diff --git a/src/ui/SessionManagerView.tsx b/src/ui/SessionManagerView.tsx
index 7e7fa7e..5473839 100644
--- a/src/ui/SessionManagerView.tsx
+++ b/src/ui/SessionManagerView.tsx
@@ -48,15 +48,13 @@ function SessionStatusIcon({ status }: { status: SessionStatus }) {
);
}
-function SessionItem({
+const SessionItem = React.memo(function SessionItem({
view,
isFocused,
- onClick,
plugin,
}: {
view: IChatViewContainer;
isFocused: boolean;
- onClick: () => void;
plugin: AgentClientPlugin;
}) {
const status = view.getSessionStatus();
@@ -68,6 +66,10 @@ function SessionItem({
if (moreRef.current) setIcon(moreRef.current, "more-horizontal");
}, []);
+ // `view` is stable for the same viewId (registry holds the same instance),
+ // so this callback is stable across renders — keeping React.memo effective.
+ const handleClick = useCallback(() => view.focus(), [view]);
+
const showMenu = useCallback(
(position: { x: number; y: number }) => {
const menu = new Menu();
@@ -114,7 +116,7 @@ function SessionItem({
@@ -130,7 +132,7 @@ function SessionItem({
);
-}
+});
function SessionManagerComponent({
plugin,
@@ -161,7 +163,6 @@ function SessionManagerComponent({
key={view.viewId}
view={view}
isFocused={view.viewId === focusedId}
- onClick={() => view.focus()}
plugin={plugin}
/>
))}
From 10f266728bd68bb99625d8c52a378a273a1bc12d Mon Sep 17 00:00:00 2001
From: RAIT-09 <51452399+RAIT-09@users.noreply.github.com>
Date: Fri, 22 May 2026 13:36:05 +0900
Subject: [PATCH 55/88] fix(session-manager): pass session-derived props to
memoized SessionItem
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
After wrapping SessionItem in React.memo (e9bb6f5), status / title /
agentName were still read inside SessionItem via view.getSessionStatus()
etc. Because `view` / `isFocused` / `plugin` props are shallow-equal
across most renders, memo skipped the re-render and the displayed
values went stale. Symptoms: spinner kept spinning after the agent
finished, or stayed idle after a new turn started — until the user
clicked the item (which moved focus and forced a prop diff).
Fix: read status / title / agentName in SessionManagerComponent and
pass them as primitive props. The parent re-renders on every
notifyChange (which the B1 effect covers exhaustively: session.state,
session.sessionId, isSending, hasActivePermission, sessionHistory
.loading, hasMessages), so the props always reflect the latest values.
SessionItem's memo now compares primitives directly and re-renders
only when a derived value actually changes, preserving the perf win
B2 was meant to deliver. Matches the prop-driven pattern of the
existing memoized MessageBubble / ToolCallBlock / TerminalBlock.
---
src/ui/SessionManagerView.tsx | 12 +++++++++---
1 file changed, 9 insertions(+), 3 deletions(-)
diff --git a/src/ui/SessionManagerView.tsx b/src/ui/SessionManagerView.tsx
index 5473839..d49080b 100644
--- a/src/ui/SessionManagerView.tsx
+++ b/src/ui/SessionManagerView.tsx
@@ -52,14 +52,17 @@ const SessionItem = React.memo(function SessionItem({
view,
isFocused,
plugin,
+ status,
+ title,
+ agentName,
}: {
view: IChatViewContainer;
isFocused: boolean;
plugin: AgentClientPlugin;
+ status: SessionStatus;
+ title: string;
+ agentName: string;
}) {
- const status = view.getSessionStatus();
- const title = view.getSessionTitle();
- const agentName = view.getDisplayName();
const moreRef = useRef(null);
useEffect(() => {
@@ -164,6 +167,9 @@ function SessionManagerComponent({
view={view}
isFocused={view.viewId === focusedId}
plugin={plugin}
+ status={view.getSessionStatus()}
+ title={view.getSessionTitle()}
+ agentName={view.getDisplayName()}
/>
))}
From 22ae6fe79ac5b695aa2d24d7d6f78bc7b33bf1bb Mon Sep 17 00:00:00 2001
From: RAIT-09 <51452399+RAIT-09@users.noreply.github.com>
Date: Fri, 22 May 2026 13:58:28 +0900
Subject: [PATCH 56/88] fix(view-registry): rename close() to closeContainer()
to avoid Obsidian View.close() collision
B11 added a close() method to IChatViewContainer and overrode it on
ChatView (extending ItemView). Obsidian's View base class has an
internal close() method (not in obsidian.d.ts but invoked by
WorkspaceLeaf.detach() during cleanup). Our ChatView.close() shadowed
it, causing infinite recursion:
plugin.closeView(viewId)
-> ChatView.close() = this.leaf.detach()
-> Obsidian: leaf.detach() invokes view.close()
-> our overridden close() runs again
-> this.leaf.detach()
-> ... RangeError: Maximum call stack size exceeded
Stack overflow short-circuited before ChatView.onClose() could run, so
viewRegistry.unregister() was never invoked, the Session Manager kept
showing stale entries, and resource cleanup (removeAcpClient, auto-
export, vaultService.destroy) silently skipped.
FloatingViewContainer was unaffected because it does not extend
ItemView and its close() implementation calls unmount() rather than
leaf.detach().
Fix: rename the interface method to closeContainer() (and matching
implementations + the caller in plugin.closeView). Obsidian's
internal View.close() is no longer shadowed, so leaf.detach() can
run its normal cleanup chain that ends in our ChatView.onClose() ->
viewRegistry.unregister() -> notifyChange().
---
src/plugin.ts | 6 +++---
src/services/view-registry.ts | 7 ++++++-
src/ui/ChatView.tsx | 2 +-
src/ui/FloatingChatView.tsx | 2 +-
4 files changed, 11 insertions(+), 6 deletions(-)
diff --git a/src/plugin.ts b/src/plugin.ts
index c54d113..9b1851c 100644
--- a/src/plugin.ts
+++ b/src/plugin.ts
@@ -520,11 +520,11 @@ export default class AgentClientPlugin extends Plugin {
/**
* Close a specific chat view (sidebar or floating).
- * Dispatch is via IChatViewContainer.close(); plugin does not need to
- * know the concrete container class.
+ * Dispatch is via IChatViewContainer.closeContainer(); plugin does not
+ * need to know the concrete container class.
*/
closeView(viewId: string): void {
- this.viewRegistry.get(viewId)?.close();
+ this.viewRegistry.get(viewId)?.closeContainer();
}
/**
diff --git a/src/services/view-registry.ts b/src/services/view-registry.ts
index 44a0de2..867342c 100644
--- a/src/services/view-registry.ts
+++ b/src/services/view-registry.ts
@@ -186,8 +186,13 @@ export interface IChatViewContainer {
* - Floating (`FloatingViewContainer`): unmounts the React root
* Implementations are responsible for triggering proper cleanup
* (onClose / unmount), which transitively unregisters from the registry.
+ *
+ * NOTE: Named `closeContainer` (not `close`) to avoid colliding with
+ * Obsidian's internal `View.close()` — invoked by `leaf.detach()` during
+ * cleanup. Overriding it caused infinite recursion in ChatView, since
+ * our impl calls `leaf.detach()` which calls `view.close()` again.
*/
- close(): void;
+ closeContainer(): void;
// ============================================================
// Container Access
diff --git a/src/ui/ChatView.tsx b/src/ui/ChatView.tsx
index 1b31570..19e13a0 100644
--- a/src/ui/ChatView.tsx
+++ b/src/ui/ChatView.tsx
@@ -220,7 +220,7 @@ export class ChatView extends ItemView implements IChatViewContainer {
return this.callbacks?.getSessionId() ?? null;
}
- close(): void {
+ closeContainer(): void {
this.leaf.detach();
}
diff --git a/src/ui/FloatingChatView.tsx b/src/ui/FloatingChatView.tsx
index 7699691..457a592 100644
--- a/src/ui/FloatingChatView.tsx
+++ b/src/ui/FloatingChatView.tsx
@@ -155,7 +155,7 @@ export class FloatingViewContainer implements IChatViewContainer {
return this.callbacks?.getSessionId() ?? null;
}
- close(): void {
+ closeContainer(): void {
this.unmount();
}
From 3b7191337dc88fcdc2d9c0abb5d904c22f5db5e0 Mon Sep 17 00:00:00 2001
From: RAIT-09 <51452399+RAIT-09@users.noreply.github.com>
Date: Fri, 22 May 2026 14:40:10 +0900
Subject: [PATCH 57/88] fix(text): keep truncateTitle output within stated
maxLength
The previous implementation appended '...' AFTER slicing to maxLength,
so the returned string could exceed the stated 'maximum length' by 3
characters. This affected callers such as forkSession (title 'Fork:
...' could reach 53 chars instead of the intended 50) and
saveSessionLocally.
Truncate to maxLength - ELLIPSIS.length and only then append the
ellipsis, so the final length is bounded by maxLength. Handle the
edge case maxLength <= ELLIPSIS.length by omitting the ellipsis.
Drop the duplicate truncateTitle defined locally in
SessionHistoryModal.tsx and import from utils/text; it shared the
same bug and now disappears from a single fix site.
---
src/ui/SessionHistoryModal.tsx | 11 +----------
src/utils/text.ts | 11 +++++++++--
2 files changed, 10 insertions(+), 12 deletions(-)
diff --git a/src/ui/SessionHistoryModal.tsx b/src/ui/SessionHistoryModal.tsx
index 840dcce..13346da 100644
--- a/src/ui/SessionHistoryModal.tsx
+++ b/src/ui/SessionHistoryModal.tsx
@@ -11,6 +11,7 @@ import * as React from "react";
const { useState, useCallback } = React;
import { createRoot, Root } from "react-dom/client";
import type { SessionInfo } from "../types/session";
+import { truncateTitle } from "../utils/text";
// ============================================================
// ConfirmDeleteModal (internal)
@@ -209,16 +210,6 @@ function formatRelativeTime(date: Date): string {
}
}
-/**
- * Truncate session title to 50 characters with ellipsis.
- */
-function truncateTitle(title: string): string {
- if (title.length <= 50) {
- return title;
- }
- return title.slice(0, 50) + "...";
-}
-
/**
* Debug form for manual session input.
*/
diff --git a/src/utils/text.ts b/src/utils/text.ts
index c1c7238..1cd964b 100644
--- a/src/utils/text.ts
+++ b/src/utils/text.ts
@@ -1,7 +1,14 @@
+const ELLIPSIS = "...";
+
/**
- * Truncate a string to a maximum length, appending an ellipsis if cut.
+ * Truncate a string so its final length (including ellipsis) does not
+ * exceed `maxLength`. If `text` already fits, it is returned as-is.
+ *
+ * For `maxLength <= ELLIPSIS.length`, the ellipsis is omitted to honor
+ * the length contract — a small but well-defined edge case.
*/
export function truncateTitle(text: string, maxLength = 50): string {
if (text.length <= maxLength) return text;
- return text.slice(0, maxLength) + "...";
+ if (maxLength <= ELLIPSIS.length) return text.slice(0, maxLength);
+ return text.slice(0, maxLength - ELLIPSIS.length) + ELLIPSIS;
}
From e6166b41cfecd00c19c027121aa83a555dc755d3 Mon Sep 17 00:00:00 2001
From: RAIT-09 <51452399+RAIT-09@users.noreply.github.com>
Date: Fri, 22 May 2026 14:40:34 +0900
Subject: [PATCH 58/88] perf(view-registry): skip redundant notifyChange after
setFocused in register
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
setFocused() ends with notifyChange(), so the previous register()
emitted two notifications on the first view registration:
views.set(viewId, view)
setFocused(viewId) ──► notifyChange() (inside setFocused)
notifyChange() ──► (redundant second call)
Each notification invalidated snapshotCache and called every
useSyncExternalStore listener; React batched the renders but the
listener calls themselves were wasted. Subsequent registrations only
fired once, leaving the behavior asymmetric between first-time and
later registrations.
Move the explicit notifyChange() into the else branch so register()
emits exactly one notification regardless of which path is taken.
This matches unregister() / setFocused() which are also 1:1 between
state change and notification.
---
src/services/view-registry.ts | 7 +++++--
1 file changed, 5 insertions(+), 2 deletions(-)
diff --git a/src/services/view-registry.ts b/src/services/view-registry.ts
index 867342c..b182b7d 100644
--- a/src/services/view-registry.ts
+++ b/src/services/view-registry.ts
@@ -226,11 +226,14 @@ export class ChatViewRegistry {
);
this.views.set(view.viewId, view);
- // First view becomes focused by default
+ // First view becomes focused by default. setFocused() already calls
+ // notifyChange(), so only emit an explicit notify on subsequent
+ // registrations to keep this method's contract at "exactly one notify".
if (this.views.size === 1) {
this.setFocused(view.viewId);
+ } else {
+ this.notifyChange();
}
- this.notifyChange();
}
/**
From ca269d0bcf4048a1d77295f1995a5dee02f59f30 Mon Sep 17 00:00:00 2001
From: RAIT-09 <51452399+RAIT-09@users.noreply.github.com>
Date: Fri, 22 May 2026 14:41:31 +0900
Subject: [PATCH 59/88] a11y(session-manager): make session-actions a proper
keyboard-accessible button
The 'more' (...) icon next to each session was a clickable
:
no tabIndex (not focusable), no aria-label (screen readers had
nothing to announce), no Enter/Space activation (keyboard users
couldn't open the menu).
Switch to a with aria-label='Session
actions' and the Obsidian 'clickable-icon' utility class for
hover/focus styling parity with MessageBubble's CopyButton and
ErrorBanner's close button.
Update moreRef's type to HTMLButtonElement, and reset browser
defaults (background, border, padding, font) on
.agent-client-session-item-more so the visual matches the prior
diff --git a/styles.css b/styles.css
index 52fc63c..4b45ea7 100644
--- a/styles.css
+++ b/styles.css
@@ -35,6 +35,11 @@ If your plugin does not need CSS, delete this file.
cursor: pointer;
color: var(--text-muted);
margin-left: auto;
+ /* Reset browser defaults so the icon matches the prior
*/
+ background: none;
+ border: none;
+ padding: 0;
+ font: inherit;
}
.agent-client-session-manager .tree-item-self:hover .agent-client-session-item-more {
From 7b6edd2cbfcd171d4b0268c410a95c63aeb0f8a2 Mon Sep 17 00:00:00 2001
From: RAIT-09 <51452399+RAIT-09@users.noreply.github.com>
Date: Fri, 22 May 2026 14:42:34 +0900
Subject: [PATCH 60/88] refactor(chat-panel): use truncateTitle helper in
getSessionTitle callback
B7 (a3aa663) introduced the shared truncateTitle helper and replaced
the two inline implementations in useSessionHistory, but missed the
inline truncation in ChatPanel.getSessionTitle that was added by the
PR. Route this site through the same helper so all four title-
truncation call sites in the codebase share one definition (and
automatically inherit the maxLength contract fix from C1).
---
src/ui/ChatPanel.tsx | 5 ++---
1 file changed, 2 insertions(+), 3 deletions(-)
diff --git a/src/ui/ChatPanel.tsx b/src/ui/ChatPanel.tsx
index 0b57457..4bba8cd 100644
--- a/src/ui/ChatPanel.tsx
+++ b/src/ui/ChatPanel.tsx
@@ -11,6 +11,7 @@ import {
import type { AttachedFile, ChatInputState } from "../types/chat";
import { isSameDirectory } from "../utils/platform";
+import { truncateTitle } from "../utils/text";
import { useHistoryModal } from "../hooks/useHistoryModal";
import { useChatActions } from "../hooks/useChatActions";
import { ChangeDirectoryModal } from "./ChangeDirectoryModal";
@@ -1017,9 +1018,7 @@ export function ChatPanel({
c.type === "text_with_context",
);
if (textContent && "text" in textContent) {
- return textContent.text.length > 50
- ? textContent.text.substring(0, 50) + "..."
- : textContent.text;
+ return truncateTitle(textContent.text);
}
}
return "New session";
From 34e2a121e5619180d8ff6dbdc0394edd31a533a6 Mon Sep 17 00:00:00 2001
From: RAIT-09 <51452399+RAIT-09@users.noreply.github.com>
Date: Fri, 22 May 2026 15:26:09 +0900
Subject: [PATCH 61/88] feat(chat-view): show session title in tab header
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The sidebar ChatView's Obsidian tab title was a static 'Agent client'.
The Session Manager already derived a dynamic title from the session
state (saved title → first user message → 'New session'), and that
same value belongs in the tab header too — same UX consistency,
multiple chat tabs become individually identifiable.
* Extract the title derivation into a pure helper
computeSessionTitle(sessionId, savedSessions, messages) in
services/session-helpers.ts. Replaces the inline logic that was
duplicated in ChatPanel's getSessionTitle callback.
* ChatPanel adds an onSessionTitleChanged sidebar-only callback prop
(mirroring onAgentIdChanged). A useMemo over the same state inputs
fires a useEffect when the derived title actually changes (string
Object.is comparison), invoking the callback.
* ChatView.getDisplayText() now delegates to
callbacks?.getSessionTitle() ?? 'New session'. ChatView.refreshDisplayText()
invokes the undocumented WorkspaceLeaf.updateHeader() to ask
Obsidian to re-read the title — the same internal method core uses
for native tabs.
* FloatingViewContainer doesn't receive the new prop because it has
no Obsidian tab header; the optional callback stays undefined and
the effect is a no-op for it.
---
src/services/session-helpers.ts | 42 +++++++++++++++++++++++-
src/ui/ChatPanel.tsx | 57 +++++++++++++++++++--------------
src/ui/ChatView.tsx | 19 ++++++++++-
3 files changed, 92 insertions(+), 26 deletions(-)
diff --git a/src/services/session-helpers.ts b/src/services/session-helpers.ts
index fc8dcc8..6c1040d 100644
--- a/src/services/session-helpers.ts
+++ b/src/services/session-helpers.ts
@@ -10,8 +10,10 @@ import type {
GeminiAgentSettings,
CodexAgentSettings,
} from "../types/agent";
-import type { ChatSession } from "../types/session";
+import type { ChatSession, SavedSessionInfo } from "../types/session";
+import type { ChatMessage } from "../types/chat";
import { toAgentConfig } from "./settings-normalizer";
+import { truncateTitle } from "../utils/text";
// ============================================================================
// Types
@@ -187,3 +189,41 @@ export function createInitialSession(
workingDirectory,
};
}
+
+// ============================================================================
+// Session Title Derivation
+// ============================================================================
+
+/**
+ * Derive the display title for a session from its persisted metadata and
+ * in-memory message list. Returns "New session" as the well-defined fallback.
+ *
+ * Source-of-truth precedence (highest first):
+ * 1. Locally saved title (created on first message, edited via Rename UI)
+ * 2. Truncated text of the first user message (50-char limit)
+ * 3. "New session" (no sessionId yet, or no user messages)
+ *
+ * Pure function — shared by the Session Manager (via the live ChatPanelCallbacks
+ * read against settings + refs) and the chat view tab header (via a useMemo
+ * over React state).
+ */
+export function computeSessionTitle(
+ sessionId: string | null,
+ savedSessions: SavedSessionInfo[],
+ messages: ChatMessage[],
+): string {
+ if (sessionId) {
+ const saved = savedSessions.find((s) => s.sessionId === sessionId);
+ if (saved?.title) return saved.title;
+ }
+ const firstUserMessage = messages.find((m) => m.role === "user");
+ if (firstUserMessage) {
+ const textContent = firstUserMessage.content.find(
+ (c) => c.type === "text" || c.type === "text_with_context",
+ );
+ if (textContent && "text" in textContent) {
+ return truncateTitle(textContent.text);
+ }
+ }
+ return "New session";
+}
diff --git a/src/ui/ChatPanel.tsx b/src/ui/ChatPanel.tsx
index 4bba8cd..e941599 100644
--- a/src/ui/ChatPanel.tsx
+++ b/src/ui/ChatPanel.tsx
@@ -11,7 +11,7 @@ import {
import type { AttachedFile, ChatInputState } from "../types/chat";
import { isSameDirectory } from "../utils/platform";
-import { truncateTitle } from "../utils/text";
+import { computeSessionTitle } from "../services/session-helpers";
import { useHistoryModal } from "../hooks/useHistoryModal";
import { useChatActions } from "../hooks/useChatActions";
import { ChangeDirectoryModal } from "./ChangeDirectoryModal";
@@ -86,6 +86,11 @@ export interface ChatPanelProps {
onRegisterCallbacks?: (callbacks: ChatPanelCallbacks) => void;
/** Called when agent ID changes (sidebar only — persists in Obsidian state) */
onAgentIdChanged?: (agentId: string) => void;
+ /**
+ * Called when the derived session title may have changed (sidebar only —
+ * triggers Obsidian to re-read getDisplayText() and update the tab header).
+ */
+ onSessionTitleChanged?: () => void;
// Floating-specific
onMinimize?: () => void;
onClose?: () => void;
@@ -130,6 +135,7 @@ export function ChatPanel({
config,
onRegisterCallbacks,
onAgentIdChanged,
+ onSessionTitleChanged,
onMinimize,
onClose,
onOpenNewWindow,
@@ -782,6 +788,26 @@ export function ChatPanel({
hasMessages,
]);
+ // ============================================================
+ // Effects - Notify Sidebar Container of Session Title Changes
+ // ============================================================
+ // Used to refresh the ChatView's Obsidian tab header.
+ // The Session Manager UI reacts via the notifyChange effect above
+ // (it re-reads getSessionTitle() during its render), so this effect is
+ // exclusively for the sidebar tab header re-paint trigger.
+ const sessionTitle = useMemo(
+ () =>
+ computeSessionTitle(
+ session.sessionId,
+ settings.savedSessions ?? [],
+ messages,
+ ),
+ [session.sessionId, settings.savedSessions, messages],
+ );
+ useEffect(() => {
+ onSessionTitleChanged?.();
+ }, [onSessionTitleChanged, sessionTitle]);
+
// ============================================================
// Effects - System Notification on Permission Request
// ============================================================
@@ -1000,29 +1026,12 @@ export function ChatPanel({
if (state === "ready") return "ready";
return "busy";
},
- getSessionTitle: () => {
- const sessionId = sessionIdRef.current;
- if (sessionId) {
- const saved = plugin.settingsService
- .getSavedSessions()
- .find((s) => s.sessionId === sessionId);
- if (saved?.title) return saved.title;
- }
- const firstUserMessage = messagesRef.current.find(
- (m) => m.role === "user",
- );
- if (firstUserMessage) {
- const textContent = firstUserMessage.content.find(
- (c) =>
- c.type === "text" ||
- c.type === "text_with_context",
- );
- if (textContent && "text" in textContent) {
- return truncateTitle(textContent.text);
- }
- }
- return "New session";
- },
+ getSessionTitle: () =>
+ computeSessionTitle(
+ sessionIdRef.current,
+ plugin.settingsService.getSnapshot().savedSessions ?? [],
+ messagesRef.current,
+ ),
getSessionId: () => sessionIdRef.current,
getInputState: () => ({
text: inputValueRef.current,
diff --git a/src/ui/ChatView.tsx b/src/ui/ChatView.tsx
index 19e13a0..43100c5 100644
--- a/src/ui/ChatView.tsx
+++ b/src/ui/ChatView.tsx
@@ -79,6 +79,7 @@ function ChatComponent({
view.setCallbacks(callbacks)
}
onAgentIdChanged={(agentId) => view.setAgentId(agentId)}
+ onSessionTitleChanged={() => view.refreshDisplayText()}
/>
);
@@ -127,7 +128,9 @@ export class ChatView extends ItemView implements IChatViewContainer {
}
getDisplayText() {
- return "Agent client";
+ // Delegate to the session title source — same value the Session Manager
+ // shows. Until ChatPanel registers callbacks, fall back to "New session".
+ return this.callbacks?.getSessionTitle() ?? "New session";
}
getIcon() {
@@ -224,6 +227,20 @@ export class ChatView extends ItemView implements IChatViewContainer {
this.leaf.detach();
}
+ /**
+ * Trigger Obsidian to re-read getDisplayText() so the tab header
+ * reflects the latest session title.
+ *
+ * Uses an undocumented `WorkspaceLeaf.updateHeader()` method that
+ * Obsidian core invokes internally for the same purpose. Widely used
+ * by community plugins. Optional chaining keeps the call safe if a
+ * future Obsidian version ever removes or renames it.
+ */
+ refreshDisplayText(): void {
+ const leaf = this.leaf as unknown as { updateHeader?: () => void };
+ leaf.updateHeader?.();
+ }
+
/**
* Get current input state (text + images).
* Returns null if React component not mounted.
From e6293ec561a2cb10ea114bdba82228db52a365cd Mon Sep 17 00:00:00 2001
From: RAIT-09 <51452399+RAIT-09@users.noreply.github.com>
Date: Fri, 22 May 2026 15:49:30 +0900
Subject: [PATCH 62/88] fix(chat-view): hide stale title in editor-area
view-header
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The previous commit made the tab strip header reflect the session
title via WorkspaceLeaf.updateHeader(), but Obsidian's *inner*
view-header (the bar with ◀▶ nav buttons + centered title + view
actions, shown only in editor-area placements) is not refreshed by
that API. It kept showing the initial 'New session' even after the
tab updated, producing a confusing dual-title display.
Hide just the .view-header-title-container with visibility: hidden
(not display: none) so:
* The stale title text disappears.
* The flex slot is preserved, keeping view-actions (this plugin's
and any other plugins') anchored on the right.
* Nav buttons on the left and the action area on the right both
remain usable.
* In sidebar placements, .view-header is already absent so this is
a no-op.
---
styles.css | 12 ++++++++++++
1 file changed, 12 insertions(+)
diff --git a/styles.css b/styles.css
index 4b45ea7..e655c39 100644
--- a/styles.css
+++ b/styles.css
@@ -501,6 +501,18 @@ If your plugin does not need CSS, delete this file.
overflow: hidden;
}
+/* Hide only the title slot inside Obsidian's view-header (editor-area placement).
+ * The chat panel already shows the session title in the tab strip, and
+ * WorkspaceLeaf.updateHeader() does not refresh this inner title so it would
+ * otherwise be stuck at its initial value. The surrounding header bar (nav
+ * buttons, view actions from this and other plugins) is kept intact.
+ * `visibility: hidden` (not `display: none`) preserves the flex layout space,
+ * so view-actions stay on the right instead of collapsing to the left.
+ * In sidebar placement, .view-header is already absent so this is a no-op. */
+.workspace-leaf-content[data-type="agent-client-chat-view"] > .view-header .view-header-title-container {
+ visibility: hidden;
+}
+
/* Main container */
.agent-client-chat-view-container {
--ac-chat-font-size: var(--font-text-size);
From abe20d943c17ab08dbda11a7148d6f9e5ae9a3cb Mon Sep 17 00:00:00 2001
From: RAIT-09 <51452399+RAIT-09@users.noreply.github.com>
Date: Fri, 22 May 2026 16:04:15 +0900
Subject: [PATCH 63/88] perf(chat-view): stabilize onSessionTitleChanged
handler with useCallback
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The PR-#293 effect
useEffect(() => {
onSessionTitleChanged?.();
}, [onSessionTitleChanged, sessionTitle]);
would re-run on every ChatComponent render because the parent passed
an inline arrow (() => view.refreshDisplayText()) whose identity
changes each render. Although ChatComponent re-renders are rare in
practice (only on view.onAgentIdRestored notifications), the unstable
dep is a React anti-pattern flagged by Copilot review on PR #293 and
risks becoming a hot path if ChatComponent later subscribes to more
state.
Memoize the handler with useCallback([view]). `view` is a class
instance passed via prop and stable for the entire ChatView lifetime,
so the callback identity is fully stable; the effect now re-runs
only on actual `sessionTitle` string changes.
Leave the sibling inline arrows (onRegisterCallbacks, onAgentIdChanged)
unchanged — they are not directly observed by a side-effecting
useEffect and addressing them is out of scope for this PR.
---
src/ui/ChatView.tsx | 14 ++++++++++++--
1 file changed, 12 insertions(+), 2 deletions(-)
diff --git a/src/ui/ChatView.tsx b/src/ui/ChatView.tsx
index 43100c5..818bf59 100644
--- a/src/ui/ChatView.tsx
+++ b/src/ui/ChatView.tsx
@@ -5,7 +5,7 @@ import type {
SessionStatus,
} from "../services/view-registry";
import * as React from "react";
-const { useState, useEffect, useMemo } = React;
+const { useState, useEffect, useMemo, useCallback } = React;
import { createRoot, Root } from "react-dom/client";
import type AgentClientPlugin from "../plugin";
@@ -65,6 +65,16 @@ function ChatComponent({
return unsubscribe;
}, [view]);
+ // ============================================================
+ // Stable callback for ChatPanel.onSessionTitleChanged so the title-
+ // update effect's dep array is value-stable (not function-identity-
+ // dependent). `view` is a prop and stable across ChatComponent renders.
+ // ============================================================
+ const handleSessionTitleChanged = useCallback(
+ () => view.refreshDisplayText(),
+ [view],
+ );
+
// ============================================================
// Render
// ============================================================
@@ -79,7 +89,7 @@ function ChatComponent({
view.setCallbacks(callbacks)
}
onAgentIdChanged={(agentId) => view.setAgentId(agentId)}
- onSessionTitleChanged={() => view.refreshDisplayText()}
+ onSessionTitleChanged={handleSessionTitleChanged}
/>
);
From e8fd7ae34fabefa2819891154d6d2975eac8d1be Mon Sep 17 00:00:00 2001
From: RAIT-09 <51452399+RAIT-09@users.noreply.github.com>
Date: Fri, 22 May 2026 16:12:29 +0900
Subject: [PATCH 64/88] refactor(chat-view): delegate getDisplayText to
getSessionTitle
Both methods returned `this.callbacks?.getSessionTitle() ?? "New session"`
verbatim. Make getDisplayText() delegate to getSessionTitle() so the
"tab title equals session list title" invariant is expressed in code
and a future change to the fallback or callbacks lookup only needs to
touch one place. Behavior unchanged. (Copilot review on PR #293.)
---
src/ui/ChatView.tsx | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/src/ui/ChatView.tsx b/src/ui/ChatView.tsx
index 818bf59..3fbaf1d 100644
--- a/src/ui/ChatView.tsx
+++ b/src/ui/ChatView.tsx
@@ -138,9 +138,9 @@ export class ChatView extends ItemView implements IChatViewContainer {
}
getDisplayText() {
- // Delegate to the session title source — same value the Session Manager
- // shows. Until ChatPanel registers callbacks, fall back to "New session".
- return this.callbacks?.getSessionTitle() ?? "New session";
+ // Single source of truth: same value the Session Manager shows for
+ // this view. The fallback to "New session" lives in getSessionTitle().
+ return this.getSessionTitle();
}
getIcon() {
From d4e44e932f387a0792f3d6383fa362168539c9d1 Mon Sep 17 00:00:00 2001
From: RAIT-09 <51452399+RAIT-09@users.noreply.github.com>
Date: Fri, 22 May 2026 16:30:43 +0900
Subject: [PATCH 65/88] chore(chat-view): align comments with one-line project
guideline
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
CLAUDE.md mandates 'one short line max' for comments, and a Discord
PR review bot pointed out that several comments added in this PR
ranged from 2 to 13 lines. Trim them, then add one new 1-line note
that the title-update useEffect intentionally fires on initial mount
in addition to subsequent sessionTitle changes — the React-default
behavior the bot flagged as easy to misread.
* computeSessionTitle JSDoc: 13 lines → 1 line (matches sibling
helpers in session-helpers.ts).
* ChatPanel title-update effect block: 5 lines of context comment
collapsed; new 1-line note clarifies the initial-mount fire is
intentional.
* ChatComponent handleSessionTitleChanged comment: 5 → 1 line.
* ChatView.getDisplayText comment: 2 → 1 line.
* ChatView.refreshDisplayText JSDoc: 8 lines → 1-line inline comment.
* styles.css view-header-title-container comment: 8 → 1 line.
No behavior change.
---
src/services/session-helpers.ts | 14 +-------------
src/ui/ChatPanel.tsx | 5 +----
src/ui/ChatView.tsx | 19 +++----------------
styles.css | 9 +--------
4 files changed, 6 insertions(+), 41 deletions(-)
diff --git a/src/services/session-helpers.ts b/src/services/session-helpers.ts
index 6c1040d..d3aded9 100644
--- a/src/services/session-helpers.ts
+++ b/src/services/session-helpers.ts
@@ -194,19 +194,7 @@ export function createInitialSession(
// Session Title Derivation
// ============================================================================
-/**
- * Derive the display title for a session from its persisted metadata and
- * in-memory message list. Returns "New session" as the well-defined fallback.
- *
- * Source-of-truth precedence (highest first):
- * 1. Locally saved title (created on first message, edited via Rename UI)
- * 2. Truncated text of the first user message (50-char limit)
- * 3. "New session" (no sessionId yet, or no user messages)
- *
- * Pure function — shared by the Session Manager (via the live ChatPanelCallbacks
- * read against settings + refs) and the chat view tab header (via a useMemo
- * over React state).
- */
+/** Derive the session display title (saved title > first user message > "New session"). */
export function computeSessionTitle(
sessionId: string | null,
savedSessions: SavedSessionInfo[],
diff --git a/src/ui/ChatPanel.tsx b/src/ui/ChatPanel.tsx
index e941599..a2c3b41 100644
--- a/src/ui/ChatPanel.tsx
+++ b/src/ui/ChatPanel.tsx
@@ -791,10 +791,6 @@ export function ChatPanel({
// ============================================================
// Effects - Notify Sidebar Container of Session Title Changes
// ============================================================
- // Used to refresh the ChatView's Obsidian tab header.
- // The Session Manager UI reacts via the notifyChange effect above
- // (it re-reads getSessionTitle() during its render), so this effect is
- // exclusively for the sidebar tab header re-paint trigger.
const sessionTitle = useMemo(
() =>
computeSessionTitle(
@@ -804,6 +800,7 @@ export function ChatPanel({
),
[session.sessionId, settings.savedSessions, messages],
);
+ // Fires on initial mount + every sessionTitle change so the tab reflects the current title.
useEffect(() => {
onSessionTitleChanged?.();
}, [onSessionTitleChanged, sessionTitle]);
diff --git a/src/ui/ChatView.tsx b/src/ui/ChatView.tsx
index 3fbaf1d..e9f7b07 100644
--- a/src/ui/ChatView.tsx
+++ b/src/ui/ChatView.tsx
@@ -65,11 +65,7 @@ function ChatComponent({
return unsubscribe;
}, [view]);
- // ============================================================
- // Stable callback for ChatPanel.onSessionTitleChanged so the title-
- // update effect's dep array is value-stable (not function-identity-
- // dependent). `view` is a prop and stable across ChatComponent renders.
- // ============================================================
+ // Stable so ChatPanel's title-update effect deps are value-stable.
const handleSessionTitleChanged = useCallback(
() => view.refreshDisplayText(),
[view],
@@ -138,8 +134,7 @@ export class ChatView extends ItemView implements IChatViewContainer {
}
getDisplayText() {
- // Single source of truth: same value the Session Manager shows for
- // this view. The fallback to "New session" lives in getSessionTitle().
+ // Tab title == Session Manager title; fallback lives in getSessionTitle().
return this.getSessionTitle();
}
@@ -237,16 +232,8 @@ export class ChatView extends ItemView implements IChatViewContainer {
this.leaf.detach();
}
- /**
- * Trigger Obsidian to re-read getDisplayText() so the tab header
- * reflects the latest session title.
- *
- * Uses an undocumented `WorkspaceLeaf.updateHeader()` method that
- * Obsidian core invokes internally for the same purpose. Widely used
- * by community plugins. Optional chaining keeps the call safe if a
- * future Obsidian version ever removes or renames it.
- */
refreshDisplayText(): void {
+ // Undocumented WorkspaceLeaf.updateHeader() — Obsidian core uses the same internal method to refresh tab headers.
const leaf = this.leaf as unknown as { updateHeader?: () => void };
leaf.updateHeader?.();
}
diff --git a/styles.css b/styles.css
index e655c39..50afa8d 100644
--- a/styles.css
+++ b/styles.css
@@ -501,14 +501,7 @@ If your plugin does not need CSS, delete this file.
overflow: hidden;
}
-/* Hide only the title slot inside Obsidian's view-header (editor-area placement).
- * The chat panel already shows the session title in the tab strip, and
- * WorkspaceLeaf.updateHeader() does not refresh this inner title so it would
- * otherwise be stuck at its initial value. The surrounding header bar (nav
- * buttons, view actions from this and other plugins) is kept intact.
- * `visibility: hidden` (not `display: none`) preserves the flex layout space,
- * so view-actions stay on the right instead of collapsing to the left.
- * In sidebar placement, .view-header is already absent so this is a no-op. */
+/* Hide stale inner view-header title (updateHeader() doesn't refresh it); visibility:hidden preserves flex layout. */
.workspace-leaf-content[data-type="agent-client-chat-view"] > .view-header .view-header-title-container {
visibility: hidden;
}
From 7588824366f65eb57ee2aecb4eec9f2530076650 Mon Sep 17 00:00:00 2001
From: RAIT-09 <51452399+RAIT-09@users.noreply.github.com>
Date: Fri, 22 May 2026 18:43:35 +0900
Subject: [PATCH 66/88] docs(getting-started): add Obsidian 1.11.4 prerequisite
Document the minAppVersion bump driven by Obsidian's Keychain API,
which the plugin now uses to store agent API keys.
---
docs/getting-started/index.md | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/docs/getting-started/index.md b/docs/getting-started/index.md
index 2fad401..10b3439 100644
--- a/docs/getting-started/index.md
+++ b/docs/getting-started/index.md
@@ -37,6 +37,14 @@ To try pre-release versions before they are published to Community Plugins:
## Prerequisites
+### Obsidian Version
+
+Agent Client requires **Obsidian 1.11.4 or later**. The plugin uses Obsidian's Keychain (introduced in 1.11.4) to store agent API keys securely.
+
+::: tip
+Check your version under **Settings → General → Version**. If you are on an older version, update Obsidian from the [official installer](https://obsidian.md/download) or your package manager.
+:::
+
### Node.js
::: tip Not always required
From 609e673e2d08bf6069f492d03f59a1679158b668 Mon Sep 17 00:00:00 2001
From: RAIT-09 <51452399+RAIT-09@users.noreply.github.com>
Date: Fri, 22 May 2026 18:43:46 +0900
Subject: [PATCH 67/88] docs(setup): document Keychain-based API key flow and
migration
- Replace plain "Enter the API key" instructions with the new
Link... button + Select secret dialog flow in all three built-in
agent setup pages (Claude Code, Codex, Gemini CLI)
- Add an info block per agent describing the automatic v0.10.x
plaintext-to-secretStorage migration, including the fallback
ID used when the default ID collides with another plugin
- Align the troubleshooting page's authentication section with
the same Link... wording and cross-link to each setup guide
- Replace "Claude Code (ACP)" labels with "Claude Code" and
prefix nav paths with "Built-in agents" to match the actual
Settings UI section structure
---
docs/agent-setup/claude-code.md | 18 +++++++++++++++++-
docs/agent-setup/codex.md | 18 +++++++++++++++++-
docs/agent-setup/gemini-cli.md | 20 ++++++++++++++++++--
docs/help/troubleshooting.md | 6 +++---
4 files changed, 55 insertions(+), 7 deletions(-)
diff --git a/docs/agent-setup/claude-code.md b/docs/agent-setup/claude-code.md
index 2c9f0d2..2db4e9f 100644
--- a/docs/agent-setup/claude-code.md
+++ b/docs/agent-setup/claude-code.md
@@ -37,7 +37,23 @@ Choose one of the following methods:
### Option A: API Key
1. Get your API key from [Anthropic Console](https://console.anthropic.com/)
-2. Enter the API key in **Settings → Agent Client → Claude Code → API key**
+2. Open **Settings → Agent Client → Built-in agents → Claude Code → API key**
+3. Click the **Link...** button next to the API key field
+4. In the **Select secret** dialog:
+ - To use an existing secret: select it from the list and click **Save**
+ - To create a new one: click **Add secret...**, enter an ID (lowercase letters, numbers, and dashes only — e.g., `claude-api-key`), paste your API key, then click **Save**
+
+Once linked, the field shows the masked secret value with a **Change** button to swap secrets.
+
+::: tip Managing secrets
+API keys are stored in **Obsidian's Keychain** (Settings → Keychain). You can rename, edit, or delete secrets there at any time. The same secret can be shared across plugins by referencing the same ID.
+:::
+
+::: info Upgrading from a previous version
+If you previously stored your Claude API key in this plugin (v0.10.x or earlier), it is automatically migrated to Obsidian's Keychain as `claude-api-key` the first time you load the upgraded plugin. A one-time notification confirms the migration.
+
+If `claude-api-key` is already in use by another plugin with a different value, your key is preserved under `agent-client-claude-api-key` instead. You can rename it from **Settings → Keychain** if you prefer.
+:::
### Option B: Account Login
diff --git a/docs/agent-setup/codex.md b/docs/agent-setup/codex.md
index b46e593..6139c45 100644
--- a/docs/agent-setup/codex.md
+++ b/docs/agent-setup/codex.md
@@ -37,7 +37,23 @@ Choose one of the following methods:
### Option A: API Key
1. Get your API key from [OpenAI Platform](https://platform.openai.com/api-keys)
-2. Enter the API key in **Settings → Agent Client → Codex → API key**
+2. Open **Settings → Agent Client → Built-in agents → Codex → API key**
+3. Click the **Link...** button next to the API key field
+4. In the **Select secret** dialog:
+ - To use an existing secret: select it from the list and click **Save**
+ - To create a new one: click **Add secret...**, enter an ID (lowercase letters, numbers, and dashes only — e.g., `openai-api-key`), paste your API key, then click **Save**
+
+Once linked, the field shows the masked secret value with a **Change** button to swap secrets.
+
+::: tip Managing secrets
+API keys are stored in **Obsidian's Keychain** (Settings → Keychain). You can rename, edit, or delete secrets there at any time. The same secret can be shared across plugins by referencing the same ID.
+:::
+
+::: info Upgrading from a previous version
+If you previously stored your OpenAI API key in this plugin (v0.10.x or earlier), it is automatically migrated to Obsidian's Keychain as `openai-api-key` the first time you load the upgraded plugin. A one-time notification confirms the migration.
+
+If `openai-api-key` is already in use by another plugin with a different value, your key is preserved under `agent-client-openai-api-key` instead. You can rename it from **Settings → Keychain** if you prefer.
+:::
### Option B: Account Login
diff --git a/docs/agent-setup/gemini-cli.md b/docs/agent-setup/gemini-cli.md
index f846ba2..9d735cd 100644
--- a/docs/agent-setup/gemini-cli.md
+++ b/docs/agent-setup/gemini-cli.md
@@ -59,13 +59,29 @@ If you have a Gemini Code Assist License from your organization, add `GOOGLE_CLO
If you prefer to use an API key for authentication:
1. Get your API key from [Google AI Studio](https://aistudio.google.com/apikey)
-2. Enter the API key in **Settings → Agent Client → Gemini CLI → API key**
+2. Open **Settings → Agent Client → Built-in agents → Gemini CLI → API key**
+3. Click the **Link...** button next to the API key field
+4. In the **Select secret** dialog:
+ - To use an existing secret: select it from the list and click **Save**
+ - To create a new one: click **Add secret...**, enter an ID (lowercase letters, numbers, and dashes only — e.g., `gemini-api-key`), paste your API key, then click **Save**
+
+Once linked, the field shows the masked secret value with a **Change** button to swap secrets.
+
+::: tip Managing secrets
+API keys are stored in **Obsidian's Keychain** (Settings → Keychain). You can rename, edit, or delete secrets there at any time. The same secret can be shared across plugins by referencing the same ID.
+:::
+
+::: info Upgrading from a previous version
+If you previously stored your Gemini API key in this plugin (v0.10.x or earlier), it is automatically migrated to Obsidian's Keychain as `gemini-api-key` the first time you load the upgraded plugin. A one-time notification confirms the migration.
+
+If `gemini-api-key` is already in use by another plugin with a different value, your key is preserved under `agent-client-gemini-api-key` instead. You can rename it from **Settings → Keychain** if you prefer.
+:::
### Option C: Vertex AI
If you are using Vertex AI for enterprise workloads:
-1. In **Settings → Agent Client → Gemini CLI → Environment variables**, add:
+1. In **Settings → Agent Client → Built-in agents → Gemini CLI → Environment variables**, add:
```
GOOGLE_API_KEY=YOUR_API_KEY
diff --git a/docs/help/troubleshooting.md b/docs/help/troubleshooting.md
index 6edad7b..6fb78ea 100644
--- a/docs/help/troubleshooting.md
+++ b/docs/help/troubleshooting.md
@@ -42,14 +42,14 @@ The agent executable cannot be found at the specified path.
The agent requires authentication before processing requests.
**For Claude Code:**
-- **API key**: Set in **Settings → Agent Client → Claude Code (ACP) → API key**
+- **API key**: Open **Settings → Agent Client → Built-in agents → Claude Code → API key**, click **Link...**, and link or create a secret. See [Claude Code Setup](/agent-setup/claude-code#authentication).
- **Account login**: Run `claude` in Terminal first and complete the login flow
**For Codex:**
-- Set your OpenAI API key in **Settings → Agent Client → Codex → API key**
+- Open **Settings → Agent Client → Built-in agents → Codex → API key**, click **Link...**, and link or create a secret. See [Codex Setup](/agent-setup/codex#authentication).
**For Gemini CLI:**
-- Set your Google API key in **Settings → Agent Client → Gemini CLI → API key**
+- Open **Settings → Agent Client → Built-in agents → Gemini CLI → API key**, click **Link...**, and link or create a secret. See [Gemini CLI Setup](/agent-setup/gemini-cli#authentication).
- Or run `gemini` in Terminal first to authenticate with your Google account
### "No Authentication Methods" error
From a1a3ab60d399d46620dd1114041aba5f10b5a8fc Mon Sep 17 00:00:00 2001
From: RAIT-09 <51452399+RAIT-09@users.noreply.github.com>
Date: Fri, 22 May 2026 18:44:04 +0900
Subject: [PATCH 68/88] docs(usage): add Session Manager and Prompt Injection
pages
- Session Manager: explain the dedicated sidebar view that lists
open chat sessions with live status icons, how to open it (chat
header menu or command palette), the four status icons, and the
Rename/Close actions
- Prompt Injection: explain the first-message instructions that
guide agents toward Obsidian-flavored Markdown (wikilinks,
LaTeX math, table spacing), with the actual injected strings
for reference
- Add both pages to the VitePress sidebar under Usage
- Add screenshots: Session Manager view, context menu, four
status icons, and the Prompt injection settings section
---
docs/.vitepress/config.mts | 2 +
.../images/prompt-injection-settings.webp | Bin 0 -> 35284 bytes
.../images/session-manager-context-menu.webp | Bin 0 -> 7962 bytes
docs/public/images/session-manager-view.webp | Bin 0 -> 11796 bytes
docs/public/images/status-busy.webp | Bin 0 -> 280 bytes
docs/public/images/status-error.webp | Bin 0 -> 434 bytes
docs/public/images/status-permission.webp | Bin 0 -> 358 bytes
docs/public/images/status-ready.webp | Bin 0 -> 286 bytes
docs/usage/prompt-injection.md | 63 ++++++++++++++
docs/usage/session-manager.md | 77 ++++++++++++++++++
10 files changed, 142 insertions(+)
create mode 100644 docs/public/images/prompt-injection-settings.webp
create mode 100644 docs/public/images/session-manager-context-menu.webp
create mode 100644 docs/public/images/session-manager-view.webp
create mode 100644 docs/public/images/status-busy.webp
create mode 100644 docs/public/images/status-error.webp
create mode 100644 docs/public/images/status-permission.webp
create mode 100644 docs/public/images/status-ready.webp
create mode 100644 docs/usage/prompt-injection.md
create mode 100644 docs/usage/session-manager.md
diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts
index 09da5cb..35b20da 100644
--- a/docs/.vitepress/config.mts
+++ b/docs/.vitepress/config.mts
@@ -73,11 +73,13 @@ 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: "Session Manager", link: "/usage/session-manager" },
{ 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" },
{ text: "Context Files", link: "/usage/context-files" },
+ { text: "Prompt Injection", link: "/usage/prompt-injection" },
{ text: "MCP Tools", link: "/usage/mcp-tools" },
],
},
diff --git a/docs/public/images/prompt-injection-settings.webp b/docs/public/images/prompt-injection-settings.webp
new file mode 100644
index 0000000000000000000000000000000000000000..c4c3f211c8ad6fa349990fd02aeba4107e07207a
GIT binary patch
literal 35284
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