mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Clarify chat shell ownership and test boundaries
This commit is contained in:
parent
3f94bc0dd6
commit
b2c4876bb3
10 changed files with 163 additions and 226 deletions
|
|
@ -1,6 +1,5 @@
|
|||
import type { EventRef, WorkspaceLeaf } from "obsidian";
|
||||
|
||||
import { unmountUiRoot } from "../../../../shared/ui/ui-root";
|
||||
import { unmountChatPanelShell } from "../../ui/shell";
|
||||
|
||||
export interface ChatViewOpenCloseControllerHost {
|
||||
|
|
@ -57,10 +56,8 @@ export class ChatViewOpenCloseController {
|
|||
this.host.invalidateResumeWork();
|
||||
this.host.clearDeferredTasks();
|
||||
const panelRoot = this.host.panelRoot();
|
||||
unmountUiRoot(panelRoot?.querySelector<HTMLElement>(".codex-panel__toolbar") ?? null);
|
||||
this.host.disposeMessages();
|
||||
this.host.disposeComposer();
|
||||
unmountUiRoot(panelRoot?.querySelector<HTMLElement>(".codex-panel__slot--composer") ?? null);
|
||||
unmountChatPanelShell(panelRoot);
|
||||
this.host.disconnect();
|
||||
this.host.clearClient();
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { unmountUiRoot } from "../../../shared/ui/ui-root";
|
||||
import type { ChatState, ChatStateStore } from "../chat-state";
|
||||
|
||||
export type ChatPanelSlotSnapshot = string | number | boolean | null;
|
||||
import type { ChatPanelSlotSnapshot } from "../view-snapshot";
|
||||
|
||||
export interface ChatPanelShellProps {
|
||||
stateStore: ChatStateStore;
|
||||
|
|
@ -19,6 +18,40 @@ interface ChatPanelShellMount {
|
|||
|
||||
const shellMounts = new WeakMap<HTMLElement, ChatPanelShellMount>();
|
||||
|
||||
const shellSlots = {
|
||||
toolbar: {
|
||||
selector: ":scope > .codex-panel__toolbar",
|
||||
create(container: HTMLElement): HTMLElement {
|
||||
return container.createDiv({ cls: "codex-panel__toolbar" });
|
||||
},
|
||||
props(props: ChatPanelShellProps): ChatPanelSlotProps {
|
||||
return props.toolbar;
|
||||
},
|
||||
},
|
||||
messages: {
|
||||
selector: ":scope > .codex-panel__body > .codex-panel__slot--messages > .codex-panel__messages",
|
||||
create(container: HTMLElement): HTMLElement {
|
||||
const body = ensureBody(container);
|
||||
const messagesSlot = body.createDiv({ cls: "codex-panel__slot codex-panel__slot--messages" });
|
||||
return messagesSlot.createDiv({ cls: "codex-panel__messages" });
|
||||
},
|
||||
props(props: ChatPanelShellProps): ChatPanelSlotProps {
|
||||
return props.messages;
|
||||
},
|
||||
},
|
||||
composer: {
|
||||
selector: ":scope > .codex-panel__body > .codex-panel__slot--composer",
|
||||
create(container: HTMLElement): HTMLElement {
|
||||
return ensureBody(container).createDiv({ cls: "codex-panel__slot codex-panel__slot--composer" });
|
||||
},
|
||||
props(props: ChatPanelShellProps): ChatPanelSlotProps {
|
||||
return props.composer;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const shellSlotDefinitions = Object.values(shellSlots);
|
||||
|
||||
export function renderChatPanelShell(container: HTMLElement, props: ChatPanelShellProps): void {
|
||||
container.addClass("codex-panel");
|
||||
ensureShellDom(container);
|
||||
|
|
@ -54,21 +87,18 @@ interface ChatPanelSlotProps {
|
|||
}
|
||||
|
||||
function ensureShellDom(container: HTMLElement): void {
|
||||
if (
|
||||
container.querySelector(":scope > .codex-panel__toolbar") &&
|
||||
container.querySelector(":scope > .codex-panel__body > .codex-panel__slot--messages > .codex-panel__messages") &&
|
||||
container.querySelector(":scope > .codex-panel__body > .codex-panel__slot--composer")
|
||||
) {
|
||||
if (shellSlotDefinitions.every((slot) => container.querySelector(slot.selector))) {
|
||||
return;
|
||||
}
|
||||
// The shell owns the fixed Obsidian DOM scaffold; toolbar, messages, and
|
||||
// composer each own their own Preact root inside that scaffold.
|
||||
unmountSlotRoots(container);
|
||||
container.replaceChildren();
|
||||
container.createDiv({ cls: "codex-panel__toolbar" });
|
||||
const body = container.createDiv({ cls: "codex-panel__body" });
|
||||
shellSlots.toolbar.create(container);
|
||||
const body = ensureBody(container);
|
||||
body.createDiv({ cls: "codex-panel__slot codex-panel__slot--config" });
|
||||
const messagesSlot = body.createDiv({ cls: "codex-panel__slot codex-panel__slot--messages" });
|
||||
messagesSlot.createDiv({ cls: "codex-panel__messages" });
|
||||
body.createDiv({ cls: "codex-panel__slot codex-panel__slot--composer" });
|
||||
shellSlots.messages.create(container);
|
||||
shellSlots.composer.create(container);
|
||||
}
|
||||
|
||||
function renderSlotIfNeeded(element: HTMLElement, slot: ChatPanelSlotProps, renderKey: string): void {
|
||||
|
|
@ -79,24 +109,24 @@ function renderSlotIfNeeded(element: HTMLElement, slot: ChatPanelSlotProps, rend
|
|||
|
||||
function renderMountedSlots(container: HTMLElement, props: ChatPanelShellProps): void {
|
||||
const state = props.stateStore.getState();
|
||||
const toolbar = container.querySelector<HTMLElement>(":scope > .codex-panel__toolbar");
|
||||
const messages = container.querySelector<HTMLElement>(
|
||||
":scope > .codex-panel__body > .codex-panel__slot--messages > .codex-panel__messages",
|
||||
);
|
||||
const composer = container.querySelector<HTMLElement>(":scope > .codex-panel__body > .codex-panel__slot--composer");
|
||||
if (toolbar) renderSlotIfNeeded(toolbar, props.toolbar, renderKey(props.renderVersion, props.toolbar.snapshot(state)));
|
||||
if (messages) renderSlotIfNeeded(messages, props.messages, renderKey(props.renderVersion, props.messages.snapshot(state)));
|
||||
if (composer) renderSlotIfNeeded(composer, props.composer, renderKey(props.renderVersion, props.composer.snapshot(state)));
|
||||
for (const slotDefinition of shellSlotDefinitions) {
|
||||
const element = container.querySelector<HTMLElement>(slotDefinition.selector);
|
||||
if (!element) continue;
|
||||
const slot = slotDefinition.props(props);
|
||||
renderSlotIfNeeded(element, slot, renderKey(props.renderVersion, slot.snapshot(state)));
|
||||
}
|
||||
}
|
||||
|
||||
function unmountSlotRoots(container: HTMLElement): void {
|
||||
unmountUiRoot(container.querySelector<HTMLElement>(":scope > .codex-panel__toolbar"));
|
||||
unmountUiRoot(
|
||||
container.querySelector<HTMLElement>(":scope > .codex-panel__body > .codex-panel__slot--messages > .codex-panel__messages"),
|
||||
);
|
||||
unmountUiRoot(container.querySelector<HTMLElement>(":scope > .codex-panel__body > .codex-panel__slot--composer"));
|
||||
for (const slotDefinition of shellSlotDefinitions) {
|
||||
unmountUiRoot(container.querySelector<HTMLElement>(slotDefinition.selector));
|
||||
}
|
||||
}
|
||||
|
||||
function renderKey(renderVersion: number, snapshot: ChatPanelSlotSnapshot): string {
|
||||
return `${String(renderVersion)}\u001f${String(snapshot)}`;
|
||||
}
|
||||
|
||||
function ensureBody(container: HTMLElement): HTMLElement {
|
||||
return container.querySelector<HTMLElement>(":scope > .codex-panel__body") ?? container.createDiv({ cls: "codex-panel__body" });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,12 +3,13 @@ import type { Thread } from "../../generated/app-server/v2/Thread";
|
|||
import type { OpenCodexPanelSnapshot } from "../../runtime/open-panel-snapshot";
|
||||
import { currentModel } from "../../runtime/state";
|
||||
import { readRuntimeConfig } from "../../runtime/config";
|
||||
import type { ChatPanelSlotSnapshot } from "./ui/shell";
|
||||
import { activeTurnId, chatTurnBusy, type ChatState } from "./chat-state";
|
||||
import type { DisplayItem } from "./display/types";
|
||||
import { runtimeSnapshotForChatState } from "./view-model";
|
||||
import type { RestoredThreadState } from "./view-lifecycle";
|
||||
|
||||
export type ChatPanelSlotSnapshot = string | number | boolean | null;
|
||||
|
||||
export function openPanelTurnLifecycle(state: ChatState["turnLifecycle"]): OpenCodexPanelSnapshot["turnLifecycle"] {
|
||||
if (state.kind === "running") return { kind: "running", turnId: state.turnId };
|
||||
if (state.kind === "starting") return { kind: "starting" };
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@ const roots = new WeakSet<HTMLElement>();
|
|||
const guardedContainers = new WeakSet<HTMLElement>();
|
||||
const internalMutationContainers = new WeakSet<HTMLElement>();
|
||||
|
||||
// Obsidian-owned containers can be emptied outside Preact during view rebuilds.
|
||||
// Keep each host element as an explicit root boundary and unmount before an
|
||||
// external replaceChildren call detaches Preact-managed nodes.
|
||||
export function renderUiRoot(container: HTMLElement, node: UiNode): void {
|
||||
prepareRootContainer(container);
|
||||
internalMutationContainers.add(container);
|
||||
|
|
|
|||
|
|
@ -269,24 +269,6 @@ describe("chatReducer", () => {
|
|||
store.dispatch({ type: "status/set", status: "Done" });
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps equal composer suggestion updates as no-ops", () => {
|
||||
const store = createChatStateStore();
|
||||
const listener = vi.fn();
|
||||
const suggestions = [{ display: "/plan", detail: "Plan mode", replacement: "/plan", start: 0, appendSpaceOnInsert: true }];
|
||||
store.subscribe(listener);
|
||||
|
||||
store.dispatch({ type: "composer/suggestions-set", suggestions, selected: 0 });
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
|
||||
store.dispatch({ type: "composer/suggestions-set", suggestions: suggestions.map((item) => ({ ...item })), selected: 0 });
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
|
||||
store.dispatch({ type: "composer/suggestions-set", suggestions: [], selected: 0 });
|
||||
expect(listener).toHaveBeenCalledTimes(2);
|
||||
store.dispatch({ type: "composer/suggestions-set", suggestions: [], selected: 0 });
|
||||
expect(listener).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
function message(id: string): DisplayItem {
|
||||
|
|
|
|||
|
|
@ -8,24 +8,13 @@ import {
|
|||
type ChatViewOpenCloseControllerHost,
|
||||
} from "../../../../../src/features/chat/controllers/view/view-open-close-controller";
|
||||
import { unmountChatPanelShell } from "../../../../../src/features/chat/ui/shell";
|
||||
import { unmountUiRoot } from "../../../../../src/shared/ui/ui-root";
|
||||
|
||||
vi.mock("../../../../../src/features/chat/ui/shell", () => ({
|
||||
unmountChatPanelShell: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../../../../src/shared/ui/ui-root", () => ({
|
||||
unmountUiRoot: vi.fn(),
|
||||
}));
|
||||
|
||||
function createHost(overrides: Partial<ChatViewOpenCloseControllerHost> = {}) {
|
||||
const root = document.createElement("div");
|
||||
const toolbar = document.createElement("div");
|
||||
toolbar.className = "codex-panel__toolbar";
|
||||
root.appendChild(toolbar);
|
||||
const composer = document.createElement("div");
|
||||
composer.className = "codex-panel__slot--composer";
|
||||
root.appendChild(composer);
|
||||
const host: ChatViewOpenCloseControllerHost = {
|
||||
setOpened: vi.fn(),
|
||||
setClosing: vi.fn(),
|
||||
|
|
@ -60,7 +49,6 @@ function createHost(overrides: Partial<ChatViewOpenCloseControllerHost> = {}) {
|
|||
describe("ChatViewOpenCloseController", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(unmountChatPanelShell).mockClear();
|
||||
vi.mocked(unmountUiRoot).mockClear();
|
||||
});
|
||||
|
||||
it("registers open events and schedules startup work", () => {
|
||||
|
|
@ -87,7 +75,6 @@ describe("ChatViewOpenCloseController", () => {
|
|||
expect(host.setOpened).toHaveBeenCalledWith(false);
|
||||
expect(host.setClosing).toHaveBeenCalledWith(true);
|
||||
expect(host.clearDeferredTasks).toHaveBeenCalledOnce();
|
||||
expect(unmountUiRoot).toHaveBeenCalledWith(root.querySelector(".codex-panel__toolbar"));
|
||||
expect(host.disposeMessages).toHaveBeenCalledOnce();
|
||||
expect(host.disposeComposer).toHaveBeenCalledOnce();
|
||||
expect(unmountChatPanelShell).toHaveBeenCalledWith(root);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createElement } from "preact";
|
||||
import { act } from "preact/test-utils";
|
||||
|
||||
import type { DisplayItem } from "../../../../../src/features/chat/display/types";
|
||||
|
|
@ -17,59 +16,12 @@ import {
|
|||
renderUiRootInAct,
|
||||
runningTurnLifecycle,
|
||||
startingTurnLifecycle,
|
||||
testMessageStreamBlock,
|
||||
unmountUiRootInAct,
|
||||
withMessageContentScrollHeight,
|
||||
} from "./test-helpers";
|
||||
|
||||
describe("message stream block identity and message actions", () => {
|
||||
it("reuses keyed Preact message block hosts across rerenders", () => {
|
||||
const parent = document.createElement("div");
|
||||
renderMessageStreamBlocksInAct(parent, [testMessageStreamBlock("one", createElement("section", null, "first"))]);
|
||||
|
||||
const host = expectPresent(parent.querySelector<HTMLElement>('[data-codex-panel-block-key="one"]'));
|
||||
expect(host.classList.contains("codex-panel__message-block")).toBe(true);
|
||||
expect(host.firstElementChild?.tagName).toBe("SECTION");
|
||||
expect(host.textContent).toBe("first");
|
||||
|
||||
renderMessageStreamBlocksInAct(parent, [testMessageStreamBlock("one", createElement("section", null, "still first"))]);
|
||||
|
||||
expect(parent.querySelector('[data-codex-panel-block-key="one"]')).toBe(host);
|
||||
expect(host.firstElementChild?.tagName).toBe("SECTION");
|
||||
expect(host.textContent).toBe("still first");
|
||||
|
||||
renderMessageStreamBlocksInAct(parent, [testMessageStreamBlock("one", createElement("article", null, "replacement"))]);
|
||||
|
||||
expect(parent.querySelector('[data-codex-panel-block-key="one"]')).toBe(host);
|
||||
expect(host.firstElementChild?.tagName).toBe("ARTICLE");
|
||||
renderMessageStreamBlocksInAct(parent, []);
|
||||
expect(parent.querySelector('[data-codex-panel-block-key="one"]')).toBeNull();
|
||||
unmountUiRootInAct(parent);
|
||||
});
|
||||
|
||||
it("leaves stable ordered Preact message block hosts in place during repeated renders", () => {
|
||||
const parent = document.createElement("div");
|
||||
renderMessageStreamBlocksInAct(parent, [
|
||||
testMessageStreamBlock("one", createElement("section", null, "one")),
|
||||
testMessageStreamBlock("two", createElement("article", null, "two")),
|
||||
]);
|
||||
const firstHost = expectPresent(parent.querySelector<HTMLElement>('[data-codex-panel-block-key="one"]'));
|
||||
const secondHost = expectPresent(parent.querySelector<HTMLElement>('[data-codex-panel-block-key="two"]'));
|
||||
|
||||
const insertBefore = vi.spyOn(parent, "insertBefore");
|
||||
renderMessageStreamBlocksInAct(parent, [
|
||||
testMessageStreamBlock("one", createElement("section", null, "one again")),
|
||||
testMessageStreamBlock("two", createElement("article", null, "two again")),
|
||||
]);
|
||||
|
||||
expect(insertBefore).not.toHaveBeenCalled();
|
||||
expect([...parent.children]).toEqual([firstHost, secondHost]);
|
||||
expect(firstHost.firstElementChild?.tagName).toBe("SECTION");
|
||||
expect(secondHost.firstElementChild?.tagName).toBe("ARTICLE");
|
||||
unmountUiRootInAct(parent);
|
||||
});
|
||||
|
||||
it("inserts completed-turn activity groups without replacing stable conversation nodes", () => {
|
||||
describe("message stream rendering and message actions", () => {
|
||||
it("inserts completed-turn activity groups between conversation blocks", () => {
|
||||
const parent = document.createElement("div");
|
||||
const baseContext = {
|
||||
activeThreadId: "thread",
|
||||
|
|
@ -92,9 +44,6 @@ describe("message stream block identity and message actions", () => {
|
|||
],
|
||||
}),
|
||||
);
|
||||
const userNode = parent.querySelector<HTMLElement>('[data-codex-panel-block-key="item:u1"]');
|
||||
const assistantNode = parent.querySelector<HTMLElement>('[data-codex-panel-block-key="item:a1"]');
|
||||
|
||||
renderMessageStreamBlocksInAct(
|
||||
parent,
|
||||
messageStreamBlocks({
|
||||
|
|
@ -115,8 +64,6 @@ describe("message stream block identity and message actions", () => {
|
|||
}),
|
||||
);
|
||||
|
||||
expect(parent.querySelector('[data-codex-panel-block-key="item:u1"]')).toBe(userNode);
|
||||
expect(parent.querySelector('[data-codex-panel-block-key="item:a1"]')).toBe(assistantNode);
|
||||
expect([...parent.children].map((element) => element.getAttribute("data-codex-panel-block-key"))).toEqual([
|
||||
"item:u1",
|
||||
"activity:turn-t1-activity",
|
||||
|
|
@ -639,11 +586,11 @@ describe("message stream block identity and message actions", () => {
|
|||
unmountUiRootInAct(parent);
|
||||
});
|
||||
|
||||
it("updates keyed message content without replacing the stream block host", () => {
|
||||
it("updates keyed message content", () => {
|
||||
const parent = document.createElement("div");
|
||||
const renderMarkdown = vi.fn((element: HTMLElement, text: string) => {
|
||||
const renderMarkdown = (element: HTMLElement, text: string) => {
|
||||
element.createDiv({ text: `markdown:${text}` });
|
||||
});
|
||||
};
|
||||
const baseContext = {
|
||||
activeThreadId: "thread",
|
||||
turnLifecycle: idleTurnLifecycle(),
|
||||
|
|
@ -662,8 +609,9 @@ describe("message stream block identity and message actions", () => {
|
|||
displayItems: [{ id: "a1", kind: "message", role: "assistant", text: "first", turnId: "turn-1", markdown: true }],
|
||||
}),
|
||||
);
|
||||
const host = expectPresent(parent.querySelector<HTMLElement>('[data-codex-panel-block-key="item:a1"]'));
|
||||
expect(host.querySelector(".codex-panel__message-content")?.textContent).toBe("markdown:first");
|
||||
expect(parent.querySelector('[data-codex-panel-block-key="item:a1"] .codex-panel__message-content')?.textContent).toBe(
|
||||
"markdown:first",
|
||||
);
|
||||
|
||||
renderMessageStreamBlocksInAct(
|
||||
parent,
|
||||
|
|
@ -673,35 +621,9 @@ describe("message stream block identity and message actions", () => {
|
|||
}),
|
||||
);
|
||||
|
||||
expect(parent.querySelector('[data-codex-panel-block-key="item:a1"]')).toBe(host);
|
||||
expect(host.querySelector(".codex-panel__message-content")?.textContent).toBe("markdown:second");
|
||||
expect(renderMarkdown).toHaveBeenCalledTimes(2);
|
||||
unmountUiRootInAct(parent);
|
||||
});
|
||||
|
||||
it("does not rerender unchanged message markdown when the stream rerenders", () => {
|
||||
const parent = document.createElement("div");
|
||||
const renderMarkdown = vi.fn((element: HTMLElement, text: string) => {
|
||||
element.createDiv({ text });
|
||||
});
|
||||
const context = {
|
||||
activeThreadId: "thread",
|
||||
turnLifecycle: idleTurnLifecycle(),
|
||||
historyCursor: null,
|
||||
loadingHistory: false,
|
||||
displayItems: [
|
||||
{ id: "a1", kind: "message", role: "assistant", text: "**answer**", turnId: "turn-1", markdown: true },
|
||||
] satisfies DisplayItem[],
|
||||
openDetails: new Set<string>(),
|
||||
loadOlderTurns: vi.fn(),
|
||||
renderMarkdown,
|
||||
renderTextWithWikiLinks: (element: HTMLElement, text: string) => element.createDiv({ text }),
|
||||
};
|
||||
|
||||
renderMessageStreamBlocksInAct(parent, messageStreamBlocks(context));
|
||||
renderMessageStreamBlocksInAct(parent, messageStreamBlocks({ ...context, loadingHistory: true }));
|
||||
|
||||
expect(renderMarkdown).toHaveBeenCalledOnce();
|
||||
expect(parent.querySelector('[data-codex-panel-block-key="item:a1"] .codex-panel__message-content')?.textContent).toBe(
|
||||
"markdown:second",
|
||||
);
|
||||
unmountUiRootInAct(parent);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -39,10 +39,6 @@ export function dispatchComposingInputValue(input: HTMLInputElement, value: stri
|
|||
input.dispatchEvent(event);
|
||||
}
|
||||
|
||||
export function testMessageStreamBlock(key: string, node: UiNode): ReturnType<typeof rawMessageStreamBlocks>[number] {
|
||||
return { key, node };
|
||||
}
|
||||
|
||||
export function renderMessageBlockElement(block: ReturnType<typeof rawMessageStreamBlocks>[number]): HTMLElement {
|
||||
const parent = document.createElement("div");
|
||||
renderMessageStreamBlocksInAct(parent, [block]);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { act } from "preact/test-utils";
|
||||
import { useEffect } from "preact/hooks";
|
||||
|
||||
import { chatTurnBusy, createChatStateStore } from "../../../../src/features/chat/chat-state";
|
||||
import { renderChatPanelShell, unmountChatPanelShell } from "../../../../src/features/chat/ui/shell";
|
||||
|
|
@ -23,19 +24,16 @@ describe("ChatPanelShell", () => {
|
|||
});
|
||||
|
||||
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--messages > .codex-panel__messages")).not.toBeNull();
|
||||
expect(container.querySelector(".codex-panel__slot--composer")?.textContent).toBe("ready");
|
||||
expect(container.textContent).toContain("Idle");
|
||||
expect(container.textContent).toContain("0");
|
||||
expect(container.textContent).toContain("ready");
|
||||
|
||||
await act(async () => {
|
||||
unmountChatPanelShell(container);
|
||||
});
|
||||
});
|
||||
|
||||
it("rerenders child slots when the subscribed store changes", async () => {
|
||||
it("updates rendered slot content when the store changes", async () => {
|
||||
const store = createChatStateStore();
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
|
|
@ -45,47 +43,13 @@ describe("ChatPanelShell", () => {
|
|||
renderChatPanelShell(container, renderers);
|
||||
await settleShellEffects();
|
||||
});
|
||||
vi.mocked(renderers.toolbar.render).mockClear();
|
||||
vi.mocked(renderers.messages.render).mockClear();
|
||||
vi.mocked(renderers.composer.render).mockClear();
|
||||
|
||||
await act(async () => {
|
||||
store.dispatch({ type: "status/set", status: "Working" });
|
||||
await settleShellEffects();
|
||||
});
|
||||
|
||||
expect(renderers.toolbar.render).toHaveBeenCalledTimes(1);
|
||||
expect(renderers.messages.render).not.toHaveBeenCalled();
|
||||
expect(renderers.composer.render).not.toHaveBeenCalled();
|
||||
expect(container.querySelector(".codex-panel__toolbar")?.textContent).toBe("Working");
|
||||
|
||||
await act(async () => {
|
||||
unmountChatPanelShell(container);
|
||||
});
|
||||
});
|
||||
|
||||
it("forces all slots to rerender when the render version 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.toolbar.render).mockClear();
|
||||
vi.mocked(renderers.messages.render).mockClear();
|
||||
vi.mocked(renderers.composer.render).mockClear();
|
||||
|
||||
await act(async () => {
|
||||
renderChatPanelShell(container, { ...renderers, renderVersion: 1 });
|
||||
await settleShellEffects();
|
||||
});
|
||||
|
||||
expect(renderers.toolbar.render).toHaveBeenCalledTimes(1);
|
||||
expect(renderers.messages.render).toHaveBeenCalledTimes(1);
|
||||
expect(renderers.composer.render).toHaveBeenCalledTimes(1);
|
||||
expect(container.textContent).toContain("Working");
|
||||
|
||||
await act(async () => {
|
||||
unmountChatPanelShell(container);
|
||||
|
|
@ -121,6 +85,57 @@ describe("ChatPanelShell", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("unmounts existing slot roots before rebuilding a damaged shell scaffold", async () => {
|
||||
const store = createChatStateStore();
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const cleanup = vi.fn();
|
||||
const renderers = trackedRootShellRenderers(store, cleanup);
|
||||
|
||||
await act(async () => {
|
||||
renderChatPanelShell(container, renderers);
|
||||
await settleShellEffects();
|
||||
});
|
||||
|
||||
container.querySelector<HTMLElement>(":scope > .codex-panel__body")?.remove();
|
||||
|
||||
await act(async () => {
|
||||
renderChatPanelShell(container, renderers);
|
||||
await settleShellEffects();
|
||||
});
|
||||
|
||||
expect(cleanup).toHaveBeenCalledWith("toolbar");
|
||||
expect(container.textContent).toContain("toolbar");
|
||||
expect(container.textContent).toContain("messages");
|
||||
expect(container.textContent).toContain("composer");
|
||||
|
||||
await act(async () => {
|
||||
unmountChatPanelShell(container);
|
||||
});
|
||||
});
|
||||
|
||||
it("unmounts every slot root when the shell unmounts", async () => {
|
||||
const store = createChatStateStore();
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const cleanup = vi.fn();
|
||||
const renderers = trackedRootShellRenderers(store, cleanup);
|
||||
|
||||
await act(async () => {
|
||||
renderChatPanelShell(container, renderers);
|
||||
await settleShellEffects();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
unmountChatPanelShell(container);
|
||||
await settleShellEffects();
|
||||
});
|
||||
|
||||
expect(cleanup).toHaveBeenCalledWith("toolbar");
|
||||
expect(cleanup).toHaveBeenCalledWith("messages");
|
||||
expect(cleanup).toHaveBeenCalledWith("composer");
|
||||
});
|
||||
|
||||
it("stops subscribed slot rendering after unmount", async () => {
|
||||
const store = createChatStateStore();
|
||||
const container = document.createElement("div");
|
||||
|
|
@ -205,6 +220,40 @@ function nestedRootShellRenderers(store: ReturnType<typeof createChatStateStore>
|
|||
};
|
||||
}
|
||||
|
||||
function trackedRootShellRenderers(store: ReturnType<typeof createChatStateStore>, cleanup: (slot: string) => void) {
|
||||
return {
|
||||
stateStore: store,
|
||||
renderVersion: 0,
|
||||
toolbar: {
|
||||
render: vi.fn((toolbar: HTMLElement) => {
|
||||
renderUiRoot(toolbar, <TrackedSlot slot="toolbar" cleanup={cleanup} />);
|
||||
}),
|
||||
snapshot: () => store.getState().status,
|
||||
},
|
||||
messages: {
|
||||
render: vi.fn((messages: HTMLElement) => {
|
||||
renderUiRoot(messages, <TrackedSlot slot="messages" cleanup={cleanup} />);
|
||||
}),
|
||||
snapshot: () => store.getState().displayItems.length,
|
||||
},
|
||||
composer: {
|
||||
render: vi.fn((composer: HTMLElement) => {
|
||||
renderUiRoot(composer, <TrackedSlot slot="composer" cleanup={cleanup} />);
|
||||
}),
|
||||
snapshot: () => chatTurnBusy(store.getState()),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function TrackedSlot({ slot, cleanup }: { slot: string; cleanup: (slot: string) => void }) {
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cleanup(slot);
|
||||
};
|
||||
}, [cleanup, slot]);
|
||||
return <div>{slot}</div>;
|
||||
}
|
||||
|
||||
async function settleShellEffects(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
|
|
|||
|
|
@ -1,14 +1,6 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createChatState } from "../../../src/features/chat/chat-state";
|
||||
import {
|
||||
composerSlotSnapshot,
|
||||
latestProposedPlanItem,
|
||||
messagesSlotSnapshot,
|
||||
openPanelTurnLifecycle,
|
||||
parseRestoredThreadState,
|
||||
toolbarSlotSnapshot,
|
||||
} from "../../../src/features/chat/view-snapshot";
|
||||
import { latestProposedPlanItem, openPanelTurnLifecycle, parseRestoredThreadState } from "../../../src/features/chat/view-snapshot";
|
||||
|
||||
describe("chat view snapshots", () => {
|
||||
it("projects open panel turn lifecycle without exposing full chat state", () => {
|
||||
|
|
@ -29,28 +21,6 @@ describe("chat view snapshots", () => {
|
|||
).toBe("latest");
|
||||
});
|
||||
|
||||
it("changes slot snapshots only for state each slot cares about", () => {
|
||||
const state = createChatState();
|
||||
const toolbar = toolbarSlotSnapshot(state, false);
|
||||
const messages = messagesSlotSnapshot(state, "");
|
||||
const composer = composerSlotSnapshot(state, null);
|
||||
|
||||
const changedDraft = { ...state, composerDraft: "hello" };
|
||||
expect(toolbarSlotSnapshot(changedDraft, false)).toBe(toolbar);
|
||||
expect(messagesSlotSnapshot(changedDraft, "")).not.toBe(messages);
|
||||
expect(composerSlotSnapshot(changedDraft, null)).not.toBe(composer);
|
||||
|
||||
const changedNonEmptyDraftText = { ...state, composerDraft: "hello again" };
|
||||
expect(messagesSlotSnapshot(changedNonEmptyDraftText, "")).not.toBe(messages);
|
||||
expect(messagesSlotSnapshot(changedNonEmptyDraftText, "")).toBe(messagesSlotSnapshot(changedDraft, ""));
|
||||
expect(composerSlotSnapshot(changedNonEmptyDraftText, null)).not.toBe(composer);
|
||||
|
||||
const changedStatus = { ...state, status: "Connected." };
|
||||
expect(toolbarSlotSnapshot(changedStatus, false)).not.toBe(toolbar);
|
||||
expect(messagesSlotSnapshot(changedStatus, "")).toBe(messages);
|
||||
expect(composerSlotSnapshot(changedStatus, null)).toBe(composer);
|
||||
});
|
||||
|
||||
it("parses restored thread view state defensively", () => {
|
||||
expect(parseRestoredThreadState({ threadId: "thread", threadTitle: "Title" })).toEqual({
|
||||
threadId: "thread",
|
||||
|
|
|
|||
Loading…
Reference in a new issue