mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Render ChatPanel shell with React
This commit is contained in:
parent
39cd7d1fa2
commit
8b3657d2b6
3 changed files with 191 additions and 29 deletions
91
src/features/chat/ui/shell.tsx
Normal file
91
src/features/chat/ui/shell.tsx
Normal 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);
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
79
tests/features/chat/ui/shell.test.tsx
Normal file
79
tests/features/chat/ui/shell.test.tsx
Normal 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);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue