diff --git a/src/features/chat/ui/shell.tsx b/src/features/chat/ui/shell.tsx new file mode 100644 index 00000000..ead082e2 --- /dev/null +++ b/src/features/chat/ui/shell.tsx @@ -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, + { + 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(null); + const messagesRef = useRef(null); + const composerRef = useRef(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 ( + <> +
+
+
+
+
+
+ + ); +} + +function renderChatPanelShellSlots( + slots: ChatPanelShellSlots, + { renderToolbar, renderMessages, renderComposer }: Pick, +): void { + renderToolbar(slots.toolbar); + renderMessages(slots.messages); + renderComposer(slots.composer); +} diff --git a/src/features/chat/view.ts b/src/features/chat/view.ts index e72d0150..79dc4ddd 100644 --- a/src/features/chat/view.ts +++ b/src/features/chat/view.ts @@ -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(); - 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(".codex-panel__toolbar") ?? null); this.messageRenderer.dispose(); this.composerController.dispose(); - unmountReactRoot(this.messagesSlotEl); - unmountReactRoot(this.composerSlotEl); + unmountReactRoot(panelRoot?.querySelector(".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 { diff --git a/tests/features/chat/ui/shell.test.tsx b/tests/features/chat/ui/shell.test.tsx new file mode 100644 index 00000000..eccd8cb6 --- /dev/null +++ b/tests/features/chat/ui/shell.test.tsx @@ -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) { + 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 { + await Promise.resolve(); + await Promise.resolve(); + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); +}