diff --git a/src/tools/README.md b/docs/TOOLS.md
similarity index 82%
rename from src/tools/README.md
rename to docs/TOOLS.md
index 1439fdf9..952fa1e0 100644
--- a/src/tools/README.md
+++ b/docs/TOOLS.md
@@ -318,64 +318,26 @@ function updateMcpToolSetting(toolId: string, enabled: boolean) {
3. **Settings UI**: `ToolSettingsSection` component reads from the registry to generate UI
4. **Tool Execution**: `AutonomousAgentChainRunner.getAvailableTools()` filters tools based on settings
-### Tool Execution Flow
+## Tool Call Rendering Roots
-1. Agent calls `getAvailableTools()` which:
+React invariant #409 surfaced when tool-call banners attempted to render into React roots that had already been unmounted. To prevent this regression, the plugin routes all banner rendering through the shared manager in `src/components/chat-components/toolCallRootManager.tsx`.
- - Gets enabled tool IDs from settings array (`autonomousAgentEnabledToolIds`)
- - Calls `registry.getEnabledTools()` to get actual tool implementations
- - Filters based on vault availability and user preferences
+### Manager Responsibilities
-2. Model adapter receives tool list and:
+- Tracks `{ root, isUnmounting }` per message/tool call via `window.__copilotToolCallRoots`.
+- `ensureToolCallRoot` finalises pending disposals and creates a new `createRoot` when needed.
+- `renderToolCallBanner` renders `` into the managed root; components never call `root.render` directly.
+- `removeToolCallRoot` and `cleanupMessageToolCallRoots` schedule unmounts on the next tick and drop entries only after disposal completes.
+- `cleanupStaleToolCallRoots` purges message IDs older than one hour to avoid leaking historical roots.
- - Generates tool descriptions for the system prompt
- - Includes tool-specific instructions based on enabled tools
+### Integration Notes
-3. When tool is called:
- - XML parsing extracts tool name and parameters
- - Tool is executed via its `func` implementation
- - Results are formatted and returned to the agent
+`ChatSingleMessage` keeps `const rootsRef = useRef(getMessageToolCallRoots(messageId))`, which provides a stable registry for each message. The component delegates all lifecycle calls to the manager and snapshots `rootsRef.current` inside effect cleanup to satisfy `react-hooks/exhaustive-deps`.
-## Benefits of This Architecture
+### Verification
-1. **Modularity**: Each tool is self-contained with metadata
-2. **Extensibility**: New tools can be added without core changes
-3. **Backward Compatibility**: Settings structure preserved while supporting new tools
-4. **Dynamic UI**: Settings automatically adapt to registered tools
-5. **Smart Prompts**: System prompts include only relevant tool instructions
-6. **MCP Ready**: Architecture supports dynamic tool registration from external sources
+Run the focused test to cover the streaming behaviour and tool-call integration:
-## Testing Your Tools
-
-```typescript
-// Test tool registration
-const registry = ToolRegistry.getInstance();
-registry.clear();
-initializeBuiltinTools(vault);
-
-// Verify tool is registered
-const allTools = registry.getAllTools();
-console.log(
- "Registered tools:",
- allTools.map((t) => t.metadata.id)
-);
-
-// Test with settings
-const enabledIds = new Set(["myNewTool", "localSearch"]);
-const enabledTools = registry.getEnabledTools(enabledIds, true);
-console.log(
- "Enabled tools:",
- enabledTools.map((t) => t.name)
-);
```
-
-This architecture provides a clean, extensible foundation for the tool system while maintaining simplicity and backward compatibility.
-
-## Summary
-
-The tool system's layered approach allows for:
-
-- Clear, comprehensive tool documentation at the schema level
-- Model-agnostic instructions that work for most LLMs
-- Targeted model-specific adaptations when necessary
-- Easy extension for new tools without modifying core infrastructure
+npm test -- src/components/chat-components/ChatSingleMessage.test.tsx
+```
diff --git a/src/components/chat-components/ChatSingleMessage.tsx b/src/components/chat-components/ChatSingleMessage.tsx
index de03515d..9bb6173b 100644
--- a/src/components/chat-components/ChatSingleMessage.tsx
+++ b/src/components/chat-components/ChatSingleMessage.tsx
@@ -1,5 +1,4 @@
import { ChatButtons } from "@/components/chat-components/ChatButtons";
-import { ToolCallBanner } from "@/components/chat-components/ToolCallBanner";
import { SourcesModal } from "@/components/modals/SourcesModal";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import {
@@ -9,6 +8,15 @@ import {
ContextFolderBadge,
} from "@/components/chat-components/ContextBadges";
import { InlineMessageEditor } from "@/components/chat-components/InlineMessageEditor";
+import {
+ cleanupMessageToolCallRoots,
+ cleanupStaleToolCallRoots,
+ ensureToolCallRoot,
+ getMessageToolCallRoots,
+ renderToolCallBanner,
+ removeToolCallRoot,
+ type ToolCallRootRecord,
+} from "@/components/chat-components/toolCallRootManager";
import { USER_SENDER } from "@/constants";
import { cn } from "@/lib/utils";
import { parseToolCallMarkers } from "@/LLMProviders/chainRunner/utils/toolCallParser";
@@ -18,13 +26,6 @@ import { ChatMessage } from "@/types/message";
import { cleanMessageForCopy, insertIntoEditor } from "@/utils";
import { App, Component, MarkdownRenderer, MarkdownView, TFile } from "obsidian";
import React, { useCallback, useEffect, useRef, useState } from "react";
-import ReactDOM, { Root } from "react-dom/client";
-
-declare global {
- interface Window {
- __copilotToolCallRoots?: Map>;
- }
-}
const FOOTNOTE_SUFFIX_PATTERN = /^\d+-\d+$/;
@@ -151,22 +152,9 @@ const ChatSingleMessage: React.FC = ({
);
// Store roots in a global map to preserve them across component instances
- const getGlobalRootsMap = () => {
- if (!window.__copilotToolCallRoots) {
- window.__copilotToolCallRoots = new Map>();
- }
- return window.__copilotToolCallRoots;
- };
-
- const getRootsForMessage = () => {
- const globalMap = getGlobalRootsMap();
- if (!globalMap.has(messageId.current)) {
- globalMap.set(messageId.current, new Map());
- }
- return globalMap.get(messageId.current)!;
- };
-
- const rootsRef = useRef