Render ChatPanel shell with React

This commit is contained in:
murashit 2026-05-27 23:14:57 +09:00
parent 39cd7d1fa2
commit 8b3657d2b6
3 changed files with 191 additions and 29 deletions

View file

@ -0,0 +1,91 @@
import { useLayoutEffect, useRef, useSyncExternalStore, type ReactNode } from "react";
import { renderReactRoot, unmountReactRoot } from "../../../shared/ui/react-root";
import type { ChatStateStore } from "../chat-state";
export interface ChatPanelShellProps {
stateStore: ChatStateStore;
renderToolbar: (toolbar: HTMLElement) => void;
renderMessages: (parent: HTMLElement) => void;
renderComposer: (parent: HTMLElement) => void;
}
interface ChatPanelShellSlots {
toolbar: HTMLElement;
messages: HTMLElement;
composer: HTMLElement;
}
export function renderChatPanelShell(container: HTMLElement, props: ChatPanelShellProps): void {
const mountedSlots: { current: ChatPanelShellSlots | null } = { current: null };
container.addClass("codex-panel");
renderReactRoot(
container,
<ChatPanelShell
{...props}
onSlotsReady={(slots) => {
mountedSlots.current = slots;
}}
/>,
);
const slots = mountedSlots.current;
if (slots) renderChatPanelShellSlots(slots, props);
}
export function unmountChatPanelShell(container: HTMLElement | null): void {
if (!container) return;
unmountReactRoot(container);
}
function ChatPanelShell({
stateStore,
renderToolbar,
renderMessages,
renderComposer,
onSlotsReady,
}: ChatPanelShellProps & { onSlotsReady: (slots: ChatPanelShellSlots) => void }): ReactNode {
const state = useSyncExternalStore(
(listener) => stateStore.subscribe(listener),
() => stateStore.getState(),
() => stateStore.getState(),
);
const toolbarRef = useRef<HTMLDivElement | null>(null);
const messagesRef = useRef<HTMLDivElement | null>(null);
const composerRef = useRef<HTMLDivElement | null>(null);
const renderGeneration = useRef(0);
useLayoutEffect(() => {
const toolbar = toolbarRef.current;
const messages = messagesRef.current;
const composer = composerRef.current;
if (!toolbar || !messages || !composer) return;
const slots = { toolbar, messages, composer };
onSlotsReady(slots);
const generation = ++renderGeneration.current;
void Promise.resolve().then(() => {
if (generation !== renderGeneration.current || !toolbar.isConnected || !messages.isConnected || !composer.isConnected) return;
renderChatPanelShellSlots(slots, { renderToolbar, renderMessages, renderComposer });
});
}, [state, stateStore, renderToolbar, renderMessages, renderComposer, onSlotsReady]);
return (
<>
<div ref={toolbarRef} className="codex-panel__toolbar" />
<div className="codex-panel__body">
<div className="codex-panel__slot codex-panel__slot--config" />
<div ref={messagesRef} className="codex-panel__slot codex-panel__slot--messages" />
<div ref={composerRef} className="codex-panel__slot codex-panel__slot--composer" />
</div>
</>
);
}
function renderChatPanelShellSlots(
slots: ChatPanelShellSlots,
{ renderToolbar, renderMessages, renderComposer }: Pick<ChatPanelShellProps, "renderToolbar" | "renderMessages" | "renderComposer">,
): void {
renderToolbar(slots.toolbar);
renderMessages(slots.messages);
renderComposer(slots.composer);
}

View file

@ -65,6 +65,7 @@ import {
} from "../../domain/threads/reference";
import { pendingRequestMessageNode } from "./ui/pending-request-message";
import { renderToolbar, type ToolbarChoice, type ToolbarViewModel } from "./ui/toolbar";
import { renderChatPanelShell, unmountChatPanelShell } from "./ui/shell";
import type { ChatTurnDiffViewState } from "./ui/turn-diff";
import { ChatMessageRenderer, type ChatMessageScrollIntent } from "./chat-message-renderer";
import type { OpenCodexPanelSnapshot } from "../../runtime/open-panel-snapshot";
@ -108,10 +109,6 @@ export class CodexChatView extends ItemView {
private readonly composerController: ChatComposerController;
private readonly messageRenderer: ChatMessageRenderer;
private readonly blockSignatures = new Map<string, string>();
private toolbarEl: HTMLElement | null = null;
private configSlotEl: HTMLElement | null = null;
private messagesSlotEl: HTMLElement | null = null;
private composerSlotEl: HTMLElement | null = null;
private scheduledRestoredThreadHydrationTimer: number | null = null;
private scheduledRenderTimer: number | null = null;
private scheduledDiagnosticsTimer: number | null = null;
@ -455,11 +452,12 @@ export class CodexChatView extends ItemView {
this.scheduledRenderTimer = null;
}
this.clearDeferredDiagnostics();
unmountReactRoot(this.toolbarEl);
const panelRoot = this.panelRoot();
unmountReactRoot(panelRoot?.querySelector<HTMLElement>(".codex-panel__toolbar") ?? null);
this.messageRenderer.dispose();
this.composerController.dispose();
unmountReactRoot(this.messagesSlotEl);
unmountReactRoot(this.composerSlotEl);
unmountReactRoot(panelRoot?.querySelector<HTMLElement>(".codex-panel__slot--composer") ?? null);
unmountChatPanelShell(panelRoot);
this.connection.disconnect();
this.client = null;
this.plugin.refreshThreadsViewLiveState();
@ -1191,20 +1189,20 @@ export class CodexChatView extends ItemView {
window.clearTimeout(this.scheduledRenderTimer);
this.scheduledRenderTimer = null;
}
const root = this.containerEl.children[1] as HTMLElement;
if (!this.toolbarEl || !this.configSlotEl || !this.messagesSlotEl || !this.composerSlotEl) {
this.renderShell(root);
}
if (!this.toolbarEl || !this.configSlotEl || !this.messagesSlotEl || !this.composerSlotEl) {
return;
}
this.renderToolbar(this.toolbarEl);
this.configSlotEl.empty();
this.renderMessages(this.messagesSlotEl);
this.renderComposer(this.composerSlotEl);
const root = this.panelRoot();
if (!root) return;
renderChatPanelShell(root, {
stateStore: this.chatState,
renderToolbar: (toolbar) => {
this.renderToolbar(toolbar);
},
renderMessages: (parent) => {
this.renderMessages(parent);
},
renderComposer: (parent) => {
this.renderComposer(parent);
},
});
}
private renderToolbar(toolbar: HTMLElement): void {
@ -1461,14 +1459,8 @@ export class CodexChatView extends ItemView {
this.render();
}
private renderShell(root: HTMLElement): void {
root.empty();
root.addClass("codex-panel");
this.toolbarEl = root.createDiv({ cls: "codex-panel__toolbar" });
const body = root.createDiv({ cls: "codex-panel__body" });
this.configSlotEl = body.createDiv({ cls: "codex-panel__slot codex-panel__slot--config" });
this.messagesSlotEl = body.createDiv({ cls: "codex-panel__slot codex-panel__slot--messages" });
this.composerSlotEl = body.createDiv({ cls: "codex-panel__slot codex-panel__slot--composer" });
private panelRoot(): HTMLElement | null {
return (this.containerEl.children[1] as HTMLElement | undefined) ?? null;
}
private toggleStatusPanel(): void {

View file

@ -0,0 +1,79 @@
// @vitest-environment jsdom
import { describe, expect, it, vi } from "vitest";
import { act } from "react";
import { createChatStateStore } from "../../../../src/features/chat/chat-state";
import { renderChatPanelShell, unmountChatPanelShell } from "../../../../src/features/chat/ui/shell";
import { installObsidianDomShims } from "./dom-test-helpers";
installObsidianDomShims();
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
describe("ChatPanelShell", () => {
it("renders the panel slots on the existing view content element", async () => {
const store = createChatStateStore();
const container = document.createElement("div");
document.body.appendChild(container);
await act(async () => {
renderChatPanelShell(container, shellRenderers(store));
await settleShellEffects();
});
expect(container.classList.contains("codex-panel")).toBe(true);
expect(container.querySelector(".codex-panel__toolbar")?.textContent).toBe("Idle");
expect(container.querySelector(".codex-panel__body")).not.toBeNull();
expect(container.querySelector(".codex-panel__slot--config")).not.toBeNull();
expect(container.querySelector(".codex-panel__slot--messages")?.textContent).toBe("0");
expect(container.querySelector(".codex-panel__slot--composer")?.textContent).toBe("ready");
unmountChatPanelShell(container);
});
it("rerenders child slots when the subscribed store changes", async () => {
const store = createChatStateStore();
const container = document.createElement("div");
document.body.appendChild(container);
const renderers = shellRenderers(store);
await act(async () => {
renderChatPanelShell(container, renderers);
await settleShellEffects();
});
vi.mocked(renderers.renderToolbar).mockClear();
await act(async () => {
store.dispatch({ type: "status/set", status: "Working" });
await settleShellEffects();
});
expect(renderers.renderToolbar).toHaveBeenCalled();
expect(container.querySelector(".codex-panel__toolbar")?.textContent).toBe("Working");
unmountChatPanelShell(container);
});
});
function shellRenderers(store: ReturnType<typeof createChatStateStore>) {
return {
stateStore: store,
renderToolbar: vi.fn((toolbar: HTMLElement) => {
toolbar.textContent = store.getState().status;
}),
renderMessages: vi.fn((messages: HTMLElement) => {
messages.textContent = String(store.getState().displayItems.length);
}),
renderComposer: vi.fn((composer: HTMLElement) => {
composer.textContent = store.getState().busy ? "busy" : "ready";
}),
};
}
async function settleShellEffects(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
await new Promise<void>((resolve) => {
setTimeout(resolve, 0);
});
}