Fix React island cleanup and rename click guard

This commit is contained in:
murashit 2026-05-27 22:04:46 +09:00
parent 45c471a331
commit b5ad9ea0fd
6 changed files with 45 additions and 2 deletions

View file

@ -123,6 +123,12 @@ export class ChatComposerController {
this.composer?.focus({ preventScroll: true });
}
dispose(): void {
unmountReactRoot(this.suggestionsEl);
this.composer = null;
this.suggestionsEl = null;
}
syncControls(parent: HTMLElement | null): void {
syncComposerControls(parent, this.composer, this.options.state.busy, this.options.canInterrupt(), this.options.composerPlaceholder());
}

View file

@ -10,6 +10,7 @@ import type { ChatTurnDiffViewState } from "./ui/turn-diff";
import { isAbsoluteFileHref, vaultFileLinkTarget, vaultRelativeFileLinkTarget } from "./markdown-file-links";
import { isRollbackCandidateItem, rollbackCandidateFromItems } from "./rollback";
import type { ChatState } from "./chat-state";
import { unmountReactRoot } from "../../shared/ui/react-root";
export interface ChatMessageRendererOptions {
app: App;
@ -30,6 +31,7 @@ export type ChatMessageScrollIntent = "auto" | "force-bottom" | "preserve";
export class ChatMessageRenderer {
private renderGeneration = 0;
private messagesEl: HTMLElement | null = null;
constructor(private readonly options: ChatMessageRendererOptions) {}
@ -37,6 +39,7 @@ export class ChatMessageRenderer {
const generation = ++this.renderGeneration;
const { state } = this.options;
const messagesEl = parent.querySelector<HTMLElement>(".codex-panel__messages") ?? parent.createDiv({ cls: "codex-panel__messages" });
this.messagesEl = messagesEl;
messagesEl.onscroll = () => {
state.messagesPinnedToBottom = isNearScrollBottom(messagesEl);
};
@ -103,6 +106,16 @@ export class ChatMessageRenderer {
});
}
dispose(): void {
this.renderGeneration += 1;
if (this.messagesEl) {
this.messagesEl.onscroll = null;
unmountReactRoot(this.messagesEl);
}
this.messagesEl = null;
this.options.blockSignatures.clear();
}
private async copyMessageText(text: string): Promise<void> {
await copyTextWithNotice(text, "Copied message.", "Could not copy message.");
}

View file

@ -450,6 +450,8 @@ export class CodexChatView extends ItemView {
}
this.clearDeferredDiagnostics();
unmountReactRoot(this.toolbarEl);
this.messageRenderer.dispose();
this.composerController.dispose();
unmountReactRoot(this.messagesSlotEl);
unmountReactRoot(this.composerSlotEl);
this.connection.disconnect();

View file

@ -80,7 +80,7 @@ function ThreadRow({ row, actions }: { row: ThreadsRowModel; actions: ThreadsVie
.join(" ");
const open = () => {
if (archiveConfirm.active) return;
if (row.rename.active || archiveConfirm.active) return;
actions.openThread(row.thread.id);
};
useLayoutEffect(() => {

View file

@ -185,6 +185,24 @@ describe("ChatMessageRenderer scroll pinning", () => {
expect(scrollIntoView).not.toHaveBeenCalled();
expect(state.messagesPinnedToBottom).toBe(false);
});
it("unmounts the React message stream root on dispose", () => {
const state = createChatState();
state.activeThreadId = "thread";
state.displayItems = [{ id: "message", kind: "message", role: "assistant", text: "Rendered message", turnId: "turn" }];
const parent = document.createElement("div");
const blockSignatures = new Map<string, string>();
const renderer = chatMessageRenderer(state, vi.fn(), "/vault", [], blockSignatures);
renderer.render(parent);
expect(parent.querySelector('[data-codex-panel-block-key="item:message"]')).not.toBeNull();
expect(blockSignatures.size).toBeGreaterThan(0);
renderer.dispose();
expect(parent.querySelector('[data-codex-panel-block-key="item:message"]')).toBeNull();
expect(blockSignatures.size).toBe(0);
});
});
function chatMessageRenderer(
@ -192,6 +210,7 @@ function chatMessageRenderer(
openLinkText = vi.fn(),
vaultPath = "/vault",
vaultFiles: string[] = [],
blockSignatures = new Map<string, string>(),
): ChatMessageRenderer {
const files = new Map(vaultFiles.map((path) => [path, tFile(path)]));
return new ChatMessageRenderer({
@ -208,7 +227,7 @@ function chatMessageRenderer(
owner: {} as never,
state,
vaultPath,
blockSignatures: new Map(),
blockSignatures,
consumeScrollIntent: () => "auto",
loadOlderTurns: vi.fn(),
rollbackThread: vi.fn(),

View file

@ -2184,6 +2184,9 @@ describe("threads view renderer decisions", () => {
expect(actions.updateRename).toHaveBeenCalledWith("thread", "New name");
expect(actions.saveRename).toHaveBeenCalledWith("thread", "New name");
expectPresent(parent.querySelector<HTMLElement>(".codex-panel-threads__row")).click();
expect(actions.openThread).not.toHaveBeenCalled();
});
it("renders threads view rename actions inline with auto-name", () => {