Manage window migrated event in agent view (#2590)

This commit is contained in:
Zero Liu 2026-06-10 15:04:51 +08:00 committed by GitHub
parent 8ba39517d8
commit 4844e60e4f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 77 additions and 64 deletions

View file

@ -3,14 +3,13 @@ import { attachChatViewLayoutObservers } from "@/components/chat-components/atta
import { CHAT_AGENT_VIEWTYPE, COPILOT_AGENT_ICON_ID } from "@/constants";
import { ChatViewEventTarget, EventTargetContext } from "@/context";
import CopilotPlugin from "@/main";
import { createPluginRoot } from "@/utils/react/createPluginRoot";
import { mountPluginViewRoot, type PluginViewRootHandle } from "@/utils/react/mountPluginViewRoot";
import * as Tooltip from "@radix-ui/react-tooltip";
import { ItemView, WorkspaceLeaf } from "obsidian";
import * as React from "react";
import { Root } from "react-dom/client";
export default class CopilotAgentView extends ItemView {
private root: Root | null = null;
private viewRoot: PluginViewRootHandle | null = null;
private handleSaveAsNote: (() => Promise<void>) | null = null;
private disposeLayoutObservers: (() => void) | null = null;
eventTarget: ChatViewEventTarget;
@ -42,8 +41,7 @@ export default class CopilotAgentView extends ItemView {
}
async onOpen(): Promise<void> {
this.root = createPluginRoot(this.containerEl.children[1], this.app);
this.renderChat();
this.viewRoot = mountPluginViewRoot(this.containerEl, this.app, () => this.renderTree());
const observers = attachChatViewLayoutObservers(this.containerEl);
this.disposeLayoutObservers = observers.dispose;
@ -55,10 +53,8 @@ export default class CopilotAgentView extends ItemView {
);
}
private renderChat(): void {
if (!this.root) return;
this.root.render(
private renderTree(): React.ReactNode {
return (
<EventTargetContext.Provider value={this.eventTarget}>
<Tooltip.Provider delayDuration={0}>
<AgentModeChat
@ -80,16 +76,14 @@ export default class CopilotAgentView extends ItemView {
}
updateView(): void {
this.renderChat();
this.viewRoot?.rerender();
}
async onClose(): Promise<void> {
this.disposeLayoutObservers?.();
this.disposeLayoutObservers = null;
if (this.root) {
this.root.unmount();
this.root = null;
}
this.viewRoot?.unmount();
this.viewRoot = null;
}
}

View file

@ -4,11 +4,10 @@ import { CHAT_VIEWTYPE } from "@/constants";
import { ChatViewEventTarget, EventTargetContext } from "@/context";
import CopilotPlugin from "@/main";
import { FileParserManager } from "@/tools/FileParserManager";
import { createPluginRoot } from "@/utils/react/createPluginRoot";
import { mountPluginViewRoot, type PluginViewRootHandle } from "@/utils/react/mountPluginViewRoot";
import * as Tooltip from "@radix-ui/react-tooltip";
import { ItemView, Platform, WorkspaceLeaf } from "obsidian";
import * as React from "react";
import { Root } from "react-dom/client";
export default class CopilotView extends ItemView {
private get chainManager(): ChainManager {
@ -16,12 +15,11 @@ export default class CopilotView extends ItemView {
}
private fileParserManager: FileParserManager;
private root: Root | null = null;
private viewRoot: PluginViewRootHandle | null = null;
private handleSaveAsNote: (() => Promise<void>) | null = null;
private keyboardObserver: MutationObserver | null = null;
private drawerHideObserver: MutationObserver | null = null;
private lastDrawerEl: HTMLElement | null = null;
private windowMigrationDestroy: (() => void) | null = null;
eventTarget: ChatViewEventTarget;
constructor(
@ -54,31 +52,10 @@ export default class CopilotView extends ItemView {
}
async onOpen(): Promise<void> {
this.root = createPluginRoot(this.containerEl.children[1], this.app);
const handleSaveAsNote = (saveFunction: () => Promise<void>) => {
this.handleSaveAsNote = saveFunction;
};
const updateUserMessageHistory = (newMessage: string) => {
this.plugin.updateUserMessageHistory(newMessage);
};
this.renderView(handleSaveAsNote, updateUserMessageHistory);
this.viewRoot = mountPluginViewRoot(this.containerEl, this.app, () => this.renderTree());
this.setupMobileKeyboardObserver();
this.setupDrawerHideObserver();
// Reason: When the leaf is dragged to (or back from) an Obsidian popout, the
// containerEl is reparented into a different window's document but onOpen
// does not re-fire. Lexical's editor._window stays bound to the original
// window, so input events fired in the popout never reach the editor.
// Tearing down and recreating the React root forces Lexical to re-register
// its root element under the new window — typing works again.
this.windowMigrationDestroy = this.containerEl.onWindowMigrated(() => {
if (!this.root) return;
this.root.unmount();
this.root = createPluginRoot(this.containerEl.children[1], this.app);
this.renderView(handleSaveAsNote, updateUserMessageHistory);
});
// Reason: The view can move between containers (e.g. editor tab → drawer)
// without onOpen firing again. Re-bind the drawer observer on layout changes
// so it always watches the correct drawer element.
@ -173,21 +150,24 @@ export default class CopilotView extends ItemView {
});
}
private renderView(
handleSaveAsNote: (saveFunction: () => Promise<void>) => void,
updateUserMessageHistory: (newMessage: string) => void
): void {
if (!this.root) return;
private setSaveHandler = (saveFunction: () => Promise<void>): void => {
this.handleSaveAsNote = saveFunction;
};
this.root.render(
private handleUpdateUserMessageHistory = (newMessage: string): void => {
this.plugin.updateUserMessageHistory(newMessage);
};
private renderTree(): React.ReactNode {
return (
<EventTargetContext.Provider value={this.eventTarget}>
<Tooltip.Provider delayDuration={0}>
<Chat
chainManager={this.chainManager}
updateUserMessageHistory={updateUserMessageHistory}
updateUserMessageHistory={this.handleUpdateUserMessageHistory}
fileParserManager={this.fileParserManager}
plugin={this.plugin}
onSaveChat={handleSaveAsNote}
onSaveChat={this.setSaveHandler}
chatUIState={this.plugin.chatUIState}
/>
</Tooltip.Provider>
@ -202,16 +182,9 @@ export default class CopilotView extends ItemView {
}
updateView(): void {
// Note: The new architecture handles message loading through ChatManager
// The messages will be loaded when the Chat component initializes
const handleSaveAsNote = (saveFunction: () => Promise<void>) => {
this.handleSaveAsNote = saveFunction;
};
const updateUserMessageHistory = (newMessage: string) => {
this.plugin.updateUserMessageHistory(newMessage);
};
this.renderView(handleSaveAsNote, updateUserMessageHistory);
// The new architecture loads messages through ChatManager when the Chat
// component initializes; this just re-renders the existing tree.
this.viewRoot?.rerender();
}
async onClose(): Promise<void> {
@ -219,17 +192,13 @@ export default class CopilotView extends ItemView {
this.keyboardObserver = null;
this.drawerHideObserver?.disconnect();
this.drawerHideObserver = null;
this.windowMigrationDestroy?.();
this.windowMigrationDestroy = null;
// Reason: Clean up the class on the tracked drawer element when the view is closed.
// Use lastDrawerEl instead of querying closest(), because the view may have already
// been detached from the drawer DOM by the time onClose fires.
this.lastDrawerEl?.classList.remove("copilot-keyboard-open");
this.lastDrawerEl = null;
if (this.root) {
this.root.unmount();
this.root = null;
}
this.viewRoot?.unmount();
this.viewRoot = null;
}
}

View file

@ -0,0 +1,50 @@
import { createPluginRoot } from "@/utils/react/createPluginRoot";
import { App } from "obsidian";
import { ReactNode } from "react";
import { Root } from "react-dom/client";
export interface PluginViewRootHandle {
/** Re-render the current tree (e.g. after external state changes). */
rerender(): void;
/** Unmount the root and detach the popout-migration listener. */
unmount(): void;
}
/**
* Mount a React root into an Obsidian ItemView's content element and keep it
* working when the leaf moves between windows.
*
* Reason: when a leaf is dragged to (or back from) an Obsidian popout, its
* containerEl is reparented into a different window's document but the view's
* onOpen does not re-fire. Anything bound to the original window notably the
* Lexical editor inside ChatInput, whose `_window` stays latched stops
* receiving input. Tearing down and recreating the root forces React (and
* Lexical) to re-register under the new window.
*
* `render` is invoked on every (re)mount, so it must return a fresh element
* tree each call rather than a cached node.
*/
export function mountPluginViewRoot(
containerEl: HTMLElement,
app: App,
render: () => ReactNode
): PluginViewRootHandle {
let root: Root = createPluginRoot(containerEl.children[1], app);
root.render(render());
const detachMigration = containerEl.onWindowMigrated(() => {
root.unmount();
root = createPluginRoot(containerEl.children[1], app);
root.render(render());
});
return {
rerender() {
root.render(render());
},
unmount() {
detachMigration();
root.unmount();
},
};
}