Clarify chat shell ownership and test boundaries

This commit is contained in:
murashit 2026-06-02 13:44:40 +09:00
parent 3f94bc0dd6
commit b2c4876bb3
10 changed files with 163 additions and 226 deletions

View file

@ -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();

View file

@ -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" });
}

View file

@ -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" };

View file

@ -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);

View file

@ -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 {

View file

@ -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);

View file

@ -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);
});

View file

@ -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]);

View file

@ -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();

View file

@ -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",