Simplify message stream flow rendering

This commit is contained in:
murashit 2026-06-22 13:23:07 +09:00
parent 1e8a2d4dcc
commit 1e3e4c9f84
11 changed files with 227 additions and 229 deletions

View file

@ -8,7 +8,6 @@ import type { MessageStreamScrollControllerBinding } from "../../ui/message-stre
import { MarkdownMessageRenderer } from "../../ui/message-stream/markdown-renderer";
import { renderStreamMarkdown } from "../../ui/message-stream/stream-markdown-renderer";
import { MessageStreamViewport, type MessageStreamViewportState } from "../../ui/message-stream/viewport";
import { messageStreamBlocks } from "../../ui/message-stream/stream-blocks";
import { messageStreamStateFromShellState, useChatPanelShellState, type ChatPanelMessageStreamShellState } from "../shell-state";
import type { PendingRequestBlockActions, PendingRequestBlockState } from "../../application/pending-requests/block";
import type { ChatTurnDiffViewState } from "../../domain/turn-diff";
@ -99,7 +98,8 @@ export class MessageStreamPresenter {
const projection = messageStreamSurfaceProjectionFromState(state, this.messageStreamSurfaceContext());
return {
blocks: messageStreamBlocks(projection.blocks, projection.context),
blocks: projection.blocks,
context: projection.context,
scrollController: this.options.scroll.controller,
};
}

View file

@ -1,16 +1,9 @@
import type { ComponentChild as UiNode } from "preact";
import type { ApprovalAction, McpElicitationAction, PendingRequestId } from "../../../../domain/pending-requests/model";
import type { PendingRequestBlockSnapshot } from "../../presentation/pending-requests/snapshot";
import type { MessageStreamForkTarget } from "../../presentation/message-stream/text-view";
import type { PlanImplementationTarget } from "../../domain/message-stream/selectors";
import type { ChatTurnDiffViewState } from "../../domain/turn-diff";
export interface MessageStreamBlock {
key: string;
node: UiNode;
}
type MessageStreamDisclosureBucket =
| "details"
| "activityGroups"

View file

@ -1,7 +1,6 @@
import { useLayoutEffect, useMemo, useRef } from "preact/hooks";
import { Component, h, type ComponentChild as UiNode } from "preact";
import { MESSAGE_CONTENT_RENDERED_EVENT } from "./content-events";
import type { MessageStreamBlock } from "./context";
type MessageScrollDirection = -1 | 1;
@ -17,8 +16,15 @@ export interface MessageStreamScrollControllerBinding {
mountScrollPort(port: MessageStreamScrollPort): () => void;
}
export interface MessageStreamFlowScrollView {
notifyBlockLayout(element: HTMLElement | null): void;
export interface MessageStreamFlowBlockIdentity {
key: string;
}
export interface MessageStreamFlowFrameProps<Block extends MessageStreamFlowBlockIdentity> {
blocks: readonly Block[];
rootAttributes?: Partial<Record<`data-${string}`, string>>;
scrollController: MessageStreamScrollControllerBinding;
renderBlockContent: (block: Block) => UiNode;
}
interface MessageFlowReadingAnchor {
@ -26,100 +32,112 @@ interface MessageFlowReadingAnchor {
top: number;
}
type MessageFlowSnapshot = MessageFlowReadingAnchor | null;
interface MessageFlowRuntime {
container: HTMLElement | null;
blocks: readonly MessageStreamBlock[];
followingEnd: boolean;
pendingAnchor: MessageFlowReadingAnchor | null;
restoreFrame: number | null;
resizeObserver: ResizeObserver | null;
}
export interface MessageStreamFlowScrollOptions {
blocks: readonly MessageStreamBlock[];
scrollController: MessageStreamScrollControllerBinding;
scrollElementRef: { current: HTMLElement | null };
}
export class MessageStreamFlowFrame<Block extends MessageStreamFlowBlockIdentity> extends Component<MessageStreamFlowFrameProps<Block>> {
private readonly runtime = createMessageFlowRuntime();
private readonly scrollPort: MessageStreamScrollPort = {
dispatchScrollCommand: (command) => {
applyMessageFlowScrollCommand(this.runtime, command);
},
};
private scrollElement: HTMLElement | null = null;
private unmountScrollPort: (() => void) | null = null;
export function useMessageStreamFlowScroll({
blocks,
scrollController,
scrollElementRef,
}: MessageStreamFlowScrollOptions): MessageStreamFlowScrollView {
const runtimeRef = useRef<MessageFlowRuntime | null>(null);
runtimeRef.current ??= createMessageFlowRuntime();
const runtime = runtimeRef.current;
const scrollPort = useMemo<MessageStreamScrollPort>(
() => ({
dispatchScrollCommand(command) {
if (runtimeRef.current) applyMessageFlowScrollCommand(runtimeRef.current, command);
override componentDidMount(): void {
this.mountScrollPort();
attachMessageFlowContainer(this.runtime, this.scrollElement);
completeMessageFlowRender(this.runtime, null);
}
override getSnapshotBeforeUpdate(previousProps: Readonly<MessageStreamFlowFrameProps<Block>>): MessageFlowSnapshot | null {
return captureMessageFlowSnapshot(this.runtime, this.scrollElement, previousProps.blocks, this.props.blocks);
}
override componentDidUpdate(
previousProps: Readonly<MessageStreamFlowFrameProps<Block>>,
_previousState: Readonly<Record<string, never>>,
snapshot: MessageFlowSnapshot | null,
): void {
if (previousProps.scrollController !== this.props.scrollController) this.mountScrollPort();
if (this.runtime.container !== this.scrollElement) attachMessageFlowContainer(this.runtime, this.scrollElement);
completeMessageFlowRender(this.runtime, snapshot);
}
override componentWillUnmount(): void {
this.unmountScrollPort?.();
this.unmountScrollPort = null;
disposeMessageFlowRuntime(this.runtime);
}
override render({ blocks, renderBlockContent, rootAttributes }: MessageStreamFlowFrameProps<Block>): UiNode {
return h(
"div",
{
...rootAttributes,
ref: this.setScrollElement,
className: "codex-panel__region codex-panel__region--message-stream codex-panel__messages",
},
}),
[],
);
h(
"div",
{ className: "codex-panel__message-flow" },
blocks.map((block) =>
h(
"div",
{
ref: this.notifyBlockLayout,
key: block.key,
className: "codex-panel__message-block",
"data-codex-panel-block-key": block.key,
},
renderBlockContent(block),
),
),
),
);
}
if (scrollElementRef.current) prepareMessageFlowRender(runtime, scrollElementRef.current, blocks);
private readonly setScrollElement = (element: HTMLElement | null): void => {
this.scrollElement = element;
};
useLayoutEffect(() => {
const unregister = scrollController.mountScrollPort(scrollPort);
return () => {
unregister();
};
}, [scrollController, scrollPort]);
private readonly notifyBlockLayout = (element: HTMLElement | null): void => {
handleMessageFlowBlockLayout(this.runtime, element);
};
useLayoutEffect(() => {
return () => {
if (!runtimeRef.current) return;
disposeMessageFlowRuntime(runtimeRef.current);
runtimeRef.current = null;
};
}, []);
useLayoutEffect(() => {
if (runtime.container !== scrollElementRef.current) {
prepareMessageFlowRender(runtime, scrollElementRef.current, blocks);
}
completeMessageFlowRender(runtime);
});
return useMemo(
() => ({
notifyBlockLayout(element) {
handleMessageFlowBlockLayout(runtime, element);
},
}),
[runtime],
);
private mountScrollPort(): void {
this.unmountScrollPort?.();
this.unmountScrollPort = this.props.scrollController.mountScrollPort(this.scrollPort);
}
}
function createMessageFlowRuntime(): MessageFlowRuntime {
return {
container: null,
blocks: [],
followingEnd: false,
pendingAnchor: null,
restoreFrame: null,
resizeObserver: null,
};
}
function prepareMessageFlowRender(runtime: MessageFlowRuntime, container: HTMLElement | null, blocks: readonly MessageStreamBlock[]): void {
attachMessageFlowContainer(runtime, container);
if (!container) {
runtime.blocks = blocks;
runtime.pendingAnchor = null;
return;
}
if (runtime.followingEnd || !messageFlowBlocksShifted(runtime.blocks, blocks)) {
runtime.pendingAnchor = null;
} else {
runtime.pendingAnchor = captureMessageFlowReadingAnchor(container);
}
runtime.blocks = blocks;
function captureMessageFlowSnapshot(
runtime: MessageFlowRuntime,
container: HTMLElement | null,
previousBlocks: readonly MessageStreamFlowBlockIdentity[],
nextBlocks: readonly MessageStreamFlowBlockIdentity[],
): MessageFlowSnapshot | null {
if (!container || runtime.followingEnd || !messageFlowBlocksShifted(previousBlocks, nextBlocks)) return null;
return captureMessageFlowReadingAnchor(container);
}
function completeMessageFlowRender(runtime: MessageFlowRuntime): void {
function completeMessageFlowRender(runtime: MessageFlowRuntime, snapshot: MessageFlowSnapshot | null): void {
const container = runtime.container;
if (!container) return;
@ -128,9 +146,7 @@ function completeMessageFlowRender(runtime: MessageFlowRuntime): void {
return;
}
const anchor = runtime.pendingAnchor;
runtime.pendingAnchor = null;
if (anchor) restoreMessageFlowReadingAnchor(container, anchor);
if (snapshot) restoreMessageFlowReadingAnchor(container, snapshot);
}
function attachMessageFlowContainer(runtime: MessageFlowRuntime, container: HTMLElement | null): void {
@ -160,7 +176,6 @@ function attachMessageFlowContainer(runtime: MessageFlowRuntime, container: HTML
runtime.resizeObserver.observe(container);
}
runtime.pendingAnchor = null;
runtime.restoreFrame = null;
cleanupMessageFlowContainer.set(runtime, () => {
container.removeEventListener("scroll", handleScroll);
@ -178,12 +193,10 @@ function detachMessageFlowContainer(runtime: MessageFlowRuntime): void {
cleanupMessageFlowContainer.get(runtime)?.();
cleanupMessageFlowContainer.delete(runtime);
runtime.container = null;
runtime.pendingAnchor = null;
}
function disposeMessageFlowRuntime(runtime: MessageFlowRuntime): void {
detachMessageFlowContainer(runtime);
runtime.blocks = [];
runtime.followingEnd = false;
}
@ -243,7 +256,10 @@ function cancelMessageFlowEndRestore(runtime: MessageFlowRuntime): void {
runtime.restoreFrame = null;
}
function messageFlowBlocksShifted(previous: readonly MessageStreamBlock[], next: readonly MessageStreamBlock[]): boolean {
function messageFlowBlocksShifted(
previous: readonly MessageStreamFlowBlockIdentity[],
next: readonly MessageStreamFlowBlockIdentity[],
): boolean {
if (previous.length === 0 || next.length === 0) return false;
if (previous.length !== next.length) return true;
return previous.some((block, index) => block.key !== next[index]?.key);

View file

@ -8,7 +8,7 @@ import {
import { pendingRequestBlockNode } from "./pending-request-block";
import { detailNode } from "./detail";
import { agentRunSummaryNode, statusNode } from "./status";
import type { MessageStreamBlock, MessageStreamContext, PendingRequestBlockContext } from "./context";
import type { MessageStreamContext, PendingRequestBlockContext } from "./context";
import { textNode } from "./text";
function streamItemNode(item: MessageStreamRenderedItemView, context: MessageStreamContext): UiNode {
@ -17,51 +17,39 @@ function streamItemNode(item: MessageStreamRenderedItemView, context: MessageStr
return statusNode(item.view);
}
export function messageStreamBlocks(viewBlocks: readonly MessageStreamViewBlock[], context: MessageStreamContext): MessageStreamBlock[] {
return viewBlocks.map((block) => presentationBlockNode(block, context));
export function MessageStreamBlockContent({ block, context }: { block: MessageStreamViewBlock; context: MessageStreamContext }): UiNode {
return presentationBlockNode(block, context);
}
function presentationBlockNode(block: MessageStreamViewBlock, context: MessageStreamContext): MessageStreamBlock {
function presentationBlockNode(block: MessageStreamViewBlock, context: MessageStreamContext): UiNode {
if (block.kind === "historyBar") {
return {
key: block.key,
node: <HistoryBar loadingHistory={block.loadingHistory} loadOlderTurns={context.loadOlderTurns} />,
};
return <HistoryBar loadingHistory={block.loadingHistory} loadOlderTurns={context.loadOlderTurns} />;
}
if (block.kind === "empty") {
return {
key: block.key,
node: <EmptyMessage />,
};
return <EmptyMessage />;
}
if (block.kind === "activityGroup") {
return {
key: block.key,
node: <ActivityGroup group={block} context={context} />,
};
return <ActivityGroup group={block} context={context} />;
}
if (block.kind === "liveAgentSummary") {
return { key: block.key, node: agentRunSummaryNode(block.view) };
return agentRunSummaryNode(block.view);
}
if (block.kind === "pendingRequests") {
const pendingRequests = pendingRequestContext(context);
return {
key: block.key,
node: pendingRequestBlockNode(
block.snapshot.approvals,
block.snapshot.pendingUserInputs,
block.snapshot.pendingMcpElicitations,
block.snapshot.userInputDrafts,
block.snapshot.mcpElicitationDrafts,
block.snapshot.approvalDetails,
pendingRequests.actions(),
false,
pendingRequests.consumeAutoFocus,
block.signature,
),
};
return pendingRequestBlockNode(
block.snapshot.approvals,
block.snapshot.pendingUserInputs,
block.snapshot.pendingMcpElicitations,
block.snapshot.userInputDrafts,
block.snapshot.mcpElicitationDrafts,
block.snapshot.approvalDetails,
pendingRequests.actions(),
false,
pendingRequests.consumeAutoFocus,
block.signature,
);
}
return { key: block.key, node: streamItemNode(block, context) };
return streamItemNode(block, context);
}
function pendingRequestContext(context: MessageStreamContext): PendingRequestBlockContext {

View file

@ -1,12 +1,13 @@
import type { ComponentChild as UiNode } from "preact";
import { useCallback, useLayoutEffect, useRef } from "preact/hooks";
import { type MessageStreamScrollControllerBinding, useMessageStreamFlowScroll } from "./flow-scroll";
import { MESSAGE_CONTENT_RENDERED_EVENT } from "./content-events";
import type { MessageStreamBlock } from "./context";
import { MessageStreamFlowFrame, type MessageStreamScrollControllerBinding } from "./flow-scroll";
import type { MessageStreamContext } from "./context";
import { MessageStreamBlockContent } from "./stream-blocks";
import type { MessageStreamViewBlock } from "../../presentation/message-stream/view-model";
export interface MessageStreamViewportState {
blocks: MessageStreamBlock[];
blocks: readonly MessageStreamViewBlock[];
context: MessageStreamContext;
scrollController: MessageStreamScrollControllerBinding;
}
@ -16,77 +17,13 @@ export interface MessageStreamViewportProps {
}
export function MessageStreamViewport({ state, rootAttributes }: MessageStreamViewportProps): UiNode {
const { blocks, scrollController } = state;
const scrollElementRef = useRef<HTMLDivElement | null>(null);
const flowScroll = useMessageStreamFlowScroll({ blocks, scrollController, scrollElementRef });
const notifyBlockLayout = useCallback(
(element: HTMLElement | null) => {
flowScroll.notifyBlockLayout(element);
},
[flowScroll],
);
const { blocks, context, scrollController } = state;
return (
<div
{...rootAttributes}
ref={scrollElementRef}
className="codex-panel__region codex-panel__region--message-stream codex-panel__messages"
>
<div className="codex-panel__message-flow">
{blocks.map((block, index) => (
<MessageStreamBlockHost key={block.key} block={block} notifyBlockLayout={notifyBlockLayout} index={index} />
))}
</div>
</div>
);
}
function MessageStreamBlockHost({
block,
index,
notifyBlockLayout,
}: {
block: MessageStreamBlock;
index: number;
notifyBlockLayout: (element: HTMLElement | null) => void;
}): UiNode {
const blockRef = useRef<HTMLDivElement | null>(null);
const cleanupBlockListeners = useRef<(() => void) | null>(null);
const setBlock = useCallback(
(element: HTMLDivElement | null) => {
cleanupBlockListeners.current?.();
cleanupBlockListeners.current = null;
blockRef.current = element;
notifyBlockLayout(element);
if (!element) return;
const notifyLayoutChange = () => {
if (blockRef.current === element && element.isConnected) notifyBlockLayout(element);
};
element.addEventListener("toggle", notifyLayoutChange, true);
element.addEventListener(MESSAGE_CONTENT_RENDERED_EVENT, notifyLayoutChange, true);
cleanupBlockListeners.current = () => {
element.removeEventListener("toggle", notifyLayoutChange, true);
element.removeEventListener(MESSAGE_CONTENT_RENDERED_EVENT, notifyLayoutChange, true);
};
},
[notifyBlockLayout],
);
useLayoutEffect(() => {
const element = blockRef.current;
if (element?.isConnected) notifyBlockLayout(element);
}, [block, notifyBlockLayout]);
useLayoutEffect(() => {
return () => {
cleanupBlockListeners.current?.();
cleanupBlockListeners.current = null;
};
}, []);
return (
<div ref={setBlock} className="codex-panel__message-block" data-codex-panel-block-key={block.key} data-index={String(index)}>
{block.node}
</div>
<MessageStreamFlowFrame
blocks={blocks}
scrollController={scrollController}
renderBlockContent={(block) => <MessageStreamBlockContent block={block} context={context} />}
{...(rootAttributes ? { rootAttributes } : {})}
/>
);
}

View file

@ -10,6 +10,8 @@ import type { ChatPanelComposerSurface } from "../../../../src/features/chat/pan
import type { ChatPanelGoalSurface } from "../../../../src/features/chat/panel/surface/goal-projection";
import type { ChatPanelToolbarSurface } from "../../../../src/features/chat/panel/surface/toolbar-projection";
import type { MessageStreamScrollControllerBinding } from "../../../../src/features/chat/ui/message-stream/flow-scroll";
import type { MessageStreamContext } from "../../../../src/features/chat/ui/message-stream/context";
import { messageStreamViewBlocks } from "../../../../src/features/chat/presentation/message-stream/view-model";
import { installObsidianDomShims } from "../../../support/dom";
installObsidianDomShims();
@ -32,7 +34,7 @@ describe("ChatPanelShell", () => {
container.querySelector(".codex-panel__body > .codex-panel__messages"),
);
expect(container.querySelector<HTMLTextAreaElement>(".codex-panel__region--composer textarea")?.value).toBe("");
expect(container.querySelector(".codex-panel__message-block .test-message-count")?.textContent).toBe("0");
expect(container.querySelector(".codex-panel__message-block")?.textContent).toContain("Send a message");
await act(async () => {
unmountChatPanelShell(container);
@ -59,7 +61,7 @@ describe("ChatPanelShell", () => {
});
expect(container.querySelector<HTMLTextAreaElement>(".codex-panel__region--composer textarea")?.value).toBe("ready");
expect(container.querySelector(".codex-panel__message-block .test-message-count")?.textContent).toBe("1");
expect(container.querySelector(".codex-panel__message-block")?.textContent).toContain("Model set.");
await act(async () => {
unmountChatPanelShell(container);
@ -202,12 +204,14 @@ function shellParts(): ChatPanelShellParts {
goal: surface.goal,
messageStream: {
renderState: (state) => ({
blocks: [
{
key: "count",
node: <div className="test-message-count">{String(messageStreamItems(state.messageStream).length)}</div>,
},
],
blocks: messageStreamViewBlocks({
activeThreadId: state.activeThread.id,
activeTurnId: null,
historyCursor: state.messageStream.historyCursor,
loadingHistory: state.messageStream.loadingHistory,
items: messageStreamItems(state.messageStream),
}),
context: testMessageStreamContext,
scrollController: noOpMessageStreamScrollController,
}),
},
@ -260,6 +264,23 @@ function shellParts(): ChatPanelShellParts {
};
}
const testMessageStreamContext: MessageStreamContext = {
activeThreadId: "thread",
workspaceRoot: "/vault",
loadOlderTurns: () => undefined,
disclosures: {
details: new Set(),
activityGroups: new Set(),
textDetails: new Set(),
userMessageExpanded: new Set(),
goalObjectiveExpanded: new Set(),
approvalDetails: new Set(),
},
forkMenuItemId: null,
renderObsidianMarkdown: () => undefined,
renderStreamMarkdown: () => undefined,
};
function surfaceFixture(options: { toolbarConnected?: () => boolean } = {}): {
toolbar: ChatPanelToolbarSurface;
goal: ChatPanelGoalSurface;

View file

@ -12,6 +12,7 @@ import type { ChatPanelToolbarSurface } from "../../../../src/features/chat/pane
import type { ThreadManagementActions } from "../../../../src/features/chat/application/threads/thread-management-actions";
import { renderChatPanelShell, unmountChatPanelShell, type ChatPanelShellParts } from "../../../../src/features/chat/panel/shell";
import type { MessageStreamScrollControllerBinding } from "../../../../src/features/chat/ui/message-stream/flow-scroll";
import type { MessageStreamContext } from "../../../../src/features/chat/ui/message-stream/context";
import { installObsidianDomShims } from "../../../support/dom";
installObsidianDomShims();
@ -98,6 +99,7 @@ function shellParts(store: ReturnType<typeof createChatStateStore>, toolbarActio
messageStream: {
renderState: () => ({
blocks: [],
context: testMessageStreamContext,
scrollController: noOpMessageStreamScrollController,
}),
},
@ -188,6 +190,23 @@ const noOpMessageStreamScrollController: MessageStreamScrollControllerBinding =
mountScrollPort: () => () => undefined,
};
const testMessageStreamContext: MessageStreamContext = {
activeThreadId: "thread",
workspaceRoot: "/vault",
loadOlderTurns: () => undefined,
disclosures: {
details: new Set(),
activityGroups: new Set(),
textDetails: new Set(),
userMessageExpanded: new Set(),
goalObjectiveExpanded: new Set(),
approvalDetails: new Set(),
},
forkMenuItemId: null,
renderObsidianMarkdown: () => undefined,
renderStreamMarkdown: () => undefined,
};
function threadFixture(id: string, name: string): Thread {
return {
id,

View file

@ -159,7 +159,18 @@ describe("message stream rendering and message action menu", () => {
it("keeps blocks in the flow after their rendered content shrinks on rerender", () => {
const parent = document.createElement("div");
const block = { key: "item:u1", node: <div>expanded</div> };
const baseContext = {
activeThreadId: "thread",
turnLifecycle: idleTurnLifecycle(),
historyCursor: null,
loadingHistory: false,
loadOlderTurns: vi.fn(),
renderMarkdown: (element: HTMLElement, text: string) => element.createDiv({ text }),
};
const block = messageStreamBlocks({
...baseContext,
items: [{ id: "u1", kind: "message", messageKind: "user", role: "user", text: "expanded", turnId: "t1" }],
})[0];
renderMessageStreamBlocksInAct(parent, [block]);
@ -174,7 +185,12 @@ describe("message stream rendering and message action menu", () => {
expect(host.style.transform).toBe("");
Object.defineProperty(host, "offsetHeight", { value: 120, configurable: true });
renderMessageStreamBlocksInAct(parent, [{ ...block, node: <div>collapsed</div> }]);
renderMessageStreamBlocksInAct(parent, [
messageStreamBlocks({
...baseContext,
items: [{ id: "u1", kind: "message", messageKind: "user", role: "user", text: "collapsed", turnId: "t1" }],
})[0],
]);
expect(messageFlow.style.height).toBe("");
expect(host.style.transform).toBe("");

View file

@ -1,16 +1,15 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { h } from "preact";
import { h, type ComponentChild as UiNode } from "preact";
import { act } from "preact/test-utils";
import {
MessageStreamFlowFrame,
type MessageStreamScrollCommand,
type MessageStreamScrollControllerBinding,
type MessageStreamScrollPort,
} from "../../../../../src/features/chat/ui/message-stream/flow-scroll";
import type { MessageStreamBlock } from "../../../../../src/features/chat/ui/message-stream/context";
import { MessageStreamViewport } from "../../../../../src/features/chat/ui/message-stream/viewport";
import { MESSAGE_CONTENT_RENDERED_EVENT } from "../../../../../src/features/chat/ui/message-stream/content-events";
import { renderUiRoot } from "../../../../../src/shared/ui/ui-root";
import { installObsidianDomShims } from "../../../../support/dom";
@ -208,7 +207,7 @@ function renderFlowMessageStream(
keys: readonly string[],
heights: Record<string, number>,
options: {
blockNode?: (key: string) => MessageStreamBlock["node"];
blockNode?: (key: string) => UiNode;
viewport?: { width: number; height: number };
} = {},
): {
@ -232,7 +231,11 @@ function renderFlowMessageStream(
void act(() => {
renderUiRoot(
parent,
h(MessageStreamViewport, { state: { blocks: blockList(nextKeys, options.blockNode), scrollController: controller } }),
h(MessageStreamFlowFrame, {
blocks: nextKeys.map((key) => ({ key })),
scrollController: controller,
renderBlockContent: (block) => options.blockNode?.(block.key) ?? h("div", null, block.key),
}),
);
});
};
@ -264,10 +267,6 @@ function renderFlowMessageStream(
};
}
function blockList(keys: readonly string[], blockNode: ((key: string) => MessageStreamBlock["node"]) | undefined): MessageStreamBlock[] {
return keys.map((key) => ({ key, node: blockNode?.(key) ?? h("div", null, key) }));
}
function messageViewport(parent: HTMLElement): HTMLElement {
const element = parent.querySelector<HTMLElement>(".codex-panel__messages");
if (!element) throw new Error("Expected message viewport.");

View file

@ -497,7 +497,7 @@ describe("pending request renderer decisions", () => {
});
expect(blocks.map((block) => block.key)).toEqual(["item:a1", "pending-requests"]);
expect(expectPresent(blocks[1]).node).not.toBeUndefined();
expect(expectPresent(blocks[1]).kind).toBe("pendingRequests");
});
it("renders MCP elicitation fields and resolves them separately from Plan mode input", () => {

View file

@ -5,26 +5,25 @@ import { act } from "preact/test-utils";
import type { PendingApproval, PendingUserInput } from "../../../../../src/domain/pending-requests/model";
import { pendingRequestBlockSnapshotFromState } from "../../../../../src/features/chat/presentation/pending-requests/snapshot";
import { pendingRequestBlockNode } from "../../../../../src/features/chat/ui/message-stream/pending-request-block";
import { messageStreamBlocks as rawMessageStreamBlocks } from "../../../../../src/features/chat/ui/message-stream/stream-blocks";
import type {
MessageStreamBlock,
MessageStreamContext,
MessageStreamDisclosureState,
PendingRequestBlockActions,
PendingRequestBlockContext,
} from "../../../../../src/features/chat/ui/message-stream/context";
import type { MessageStreamItem } from "../../../../../src/features/chat/domain/message-stream/items";
import { messageStreamViewBlocks } from "../../../../../src/features/chat/presentation/message-stream/view-model";
import {
messageStreamViewBlocks,
type MessageStreamViewBlock,
} from "../../../../../src/features/chat/presentation/message-stream/view-model";
import type { MessageStreamTextActionTargets } from "../../../../../src/features/chat/presentation/message-stream/text-view";
import { MessageStreamViewport } from "../../../../../src/features/chat/ui/message-stream/viewport";
import type { MessageStreamScrollControllerBinding } from "../../../../../src/features/chat/ui/message-stream/flow-scroll";
import { renderUiRoot, unmountUiRoot } from "../../../../../src/shared/ui/ui-root";
export function messageStreamBlocks(
context: TestMessageStreamContext,
): [ReturnType<typeof rawMessageStreamBlocks>[number], ...ReturnType<typeof rawMessageStreamBlocks>] {
export function messageStreamBlocks(context: TestMessageStreamContext): [MessageStreamViewBlock, ...MessageStreamViewBlock[]] {
const normalized = normalizeMessageStreamContext(context);
const viewBlocks = messageStreamViewBlocks({
const blocks = messageStreamViewBlocks({
activeThreadId: normalized.activeThreadId,
activeTurnId: activeTurnIdForMessageStream(context.turnLifecycle),
historyCursor: context.historyCursor,
@ -37,11 +36,13 @@ export function messageStreamBlocks(
textActionTargetsByItemId: context.textActionTargetsByItemId,
pendingRequests: pendingRequestBlockInput(context),
});
const blocks = rawMessageStreamBlocks(viewBlocks, normalized);
if (blocks.length === 0) throw new Error("Expected at least one message stream block.");
return blocks as [ReturnType<typeof rawMessageStreamBlocks>[number], ...ReturnType<typeof rawMessageStreamBlocks>];
for (const block of blocks) messageStreamContextByBlock.set(block, normalized);
return blocks as [MessageStreamViewBlock, ...MessageStreamViewBlock[]];
}
const messageStreamContextByBlock = new WeakMap<MessageStreamViewBlock, MessageStreamContext>();
function pendingRequestBlockInput(
context: TestMessageStreamContext,
): { signature: string; snapshot: ReturnType<PendingRequestBlockContext["snapshot"]> } | null {
@ -133,7 +134,7 @@ export function dispatchComposingInputValue(input: HTMLInputElement, value: stri
input.dispatchEvent(event);
}
export function renderMessageBlockElement(block: ReturnType<typeof rawMessageStreamBlocks>[number]): HTMLElement {
export function renderMessageBlockElement(block: MessageStreamViewBlock): HTMLElement {
const parent = document.createElement("div");
renderMessageStreamBlocksInAct(parent, [block]);
const host = expectPresent(parent.querySelector<HTMLElement>(`[data-codex-panel-block-key="${block.key}"]`));
@ -144,16 +145,18 @@ export function actEvent(action: () => void): void {
void act(action);
}
export function renderMessageStreamBlocksInAct(parent: HTMLElement, blocks: MessageStreamBlock[]): void {
export function renderMessageStreamBlocksInAct(parent: HTMLElement, blocks: MessageStreamViewBlock[]): void {
parent.addClass("codex-panel__messages");
installMessageViewportMetrics(parent);
if (!parent.isConnected) document.body.appendChild(parent);
const context = messageStreamContextForBlocks(blocks);
void act(() => {
renderUiRoot(
parent,
<MessageStreamViewport
state={{
blocks,
context,
scrollController: noOpMessageStreamScrollController,
}}
/>,
@ -165,6 +168,12 @@ const noOpMessageStreamScrollController: MessageStreamScrollControllerBinding =
mountScrollPort: () => () => undefined,
};
function messageStreamContextForBlocks(blocks: readonly MessageStreamViewBlock[]): MessageStreamContext {
const context = blocks.map((block) => messageStreamContextByBlock.get(block)).find((candidate) => candidate !== undefined);
if (!context) throw new Error("Expected message stream blocks created by messageStreamBlocks().");
return context;
}
export function installMessageViewportMetrics(
element: HTMLElement,
metrics: { clientHeight?: number; clientWidth?: number; scrollHeight?: number } = {},