diff --git a/src/features/chat/panel/surface/message-stream-presenter.ts b/src/features/chat/panel/surface/message-stream-presenter.ts index e88094c4..565cc313 100644 --- a/src/features/chat/panel/surface/message-stream-presenter.ts +++ b/src/features/chat/panel/surface/message-stream-presenter.ts @@ -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, }; } diff --git a/src/features/chat/ui/message-stream/context.ts b/src/features/chat/ui/message-stream/context.ts index da6f6deb..63f77bb3 100644 --- a/src/features/chat/ui/message-stream/context.ts +++ b/src/features/chat/ui/message-stream/context.ts @@ -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" diff --git a/src/features/chat/ui/message-stream/flow-scroll.ts b/src/features/chat/ui/message-stream/flow-scroll.ts index 31644a54..3c056c1d 100644 --- a/src/features/chat/ui/message-stream/flow-scroll.ts +++ b/src/features/chat/ui/message-stream/flow-scroll.ts @@ -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 { + blocks: readonly Block[]; + rootAttributes?: Partial>; + 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 extends Component> { + 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(null); - runtimeRef.current ??= createMessageFlowRuntime(); - const runtime = runtimeRef.current; - const scrollPort = useMemo( - () => ({ - 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>): MessageFlowSnapshot | null { + return captureMessageFlowSnapshot(this.runtime, this.scrollElement, previousProps.blocks, this.props.blocks); + } + + override componentDidUpdate( + previousProps: Readonly>, + _previousState: Readonly>, + 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): 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); diff --git a/src/features/chat/ui/message-stream/stream-blocks.tsx b/src/features/chat/ui/message-stream/stream-blocks.tsx index b5f8e1e6..7227cf99 100644 --- a/src/features/chat/ui/message-stream/stream-blocks.tsx +++ b/src/features/chat/ui/message-stream/stream-blocks.tsx @@ -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: , - }; + return ; } if (block.kind === "empty") { - return { - key: block.key, - node: , - }; + return ; } if (block.kind === "activityGroup") { - return { - key: block.key, - node: , - }; + return ; } 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 { diff --git a/src/features/chat/ui/message-stream/viewport.tsx b/src/features/chat/ui/message-stream/viewport.tsx index b093fe58..86be9d24 100644 --- a/src/features/chat/ui/message-stream/viewport.tsx +++ b/src/features/chat/ui/message-stream/viewport.tsx @@ -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(null); - const flowScroll = useMessageStreamFlowScroll({ blocks, scrollController, scrollElementRef }); - const notifyBlockLayout = useCallback( - (element: HTMLElement | null) => { - flowScroll.notifyBlockLayout(element); - }, - [flowScroll], - ); - + const { blocks, context, scrollController } = state; return ( -
-
- {blocks.map((block, index) => ( - - ))} -
-
- ); -} - -function MessageStreamBlockHost({ - block, - index, - notifyBlockLayout, -}: { - block: MessageStreamBlock; - index: number; - notifyBlockLayout: (element: HTMLElement | null) => void; -}): UiNode { - const blockRef = useRef(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 ( -
- {block.node} -
+ } + {...(rootAttributes ? { rootAttributes } : {})} + /> ); } diff --git a/tests/features/chat/panel/shell.test.tsx b/tests/features/chat/panel/shell.test.tsx index 575e77a5..d902a42d 100644 --- a/tests/features/chat/panel/shell.test.tsx +++ b/tests/features/chat/panel/shell.test.tsx @@ -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(".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(".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:
{String(messageStreamItems(state.messageStream).length)}
, - }, - ], + 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; diff --git a/tests/features/chat/panel/toolbar-archive-state.test.tsx b/tests/features/chat/panel/toolbar-archive-state.test.tsx index 1c0213f1..185833c9 100644 --- a/tests/features/chat/panel/toolbar-archive-state.test.tsx +++ b/tests/features/chat/panel/toolbar-archive-state.test.tsx @@ -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, 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, diff --git a/tests/features/chat/ui/message-stream/blocks-and-messages.test.tsx b/tests/features/chat/ui/message-stream/blocks-and-messages.test.tsx index 38ecbe2e..d0156dae 100644 --- a/tests/features/chat/ui/message-stream/blocks-and-messages.test.tsx +++ b/tests/features/chat/ui/message-stream/blocks-and-messages.test.tsx @@ -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:
expanded
}; + 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:
collapsed
}]); + 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(""); diff --git a/tests/features/chat/ui/message-stream/flow-scroll.test.ts b/tests/features/chat/ui/message-stream/flow-scroll.test.ts index 1dbcad7c..4f3427df 100644 --- a/tests/features/chat/ui/message-stream/flow-scroll.test.ts +++ b/tests/features/chat/ui/message-stream/flow-scroll.test.ts @@ -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, 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(".codex-panel__messages"); if (!element) throw new Error("Expected message viewport."); diff --git a/tests/features/chat/ui/message-stream/pending-requests.test.tsx b/tests/features/chat/ui/message-stream/pending-requests.test.tsx index 39590105..28d05797 100644 --- a/tests/features/chat/ui/message-stream/pending-requests.test.tsx +++ b/tests/features/chat/ui/message-stream/pending-requests.test.tsx @@ -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", () => { diff --git a/tests/features/chat/ui/message-stream/test-helpers.tsx b/tests/features/chat/ui/message-stream/test-helpers.tsx index c56184ea..bbcc2ce5 100644 --- a/tests/features/chat/ui/message-stream/test-helpers.tsx +++ b/tests/features/chat/ui/message-stream/test-helpers.tsx @@ -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[number], ...ReturnType] { +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[number], ...ReturnType]; + for (const block of blocks) messageStreamContextByBlock.set(block, normalized); + return blocks as [MessageStreamViewBlock, ...MessageStreamViewBlock[]]; } +const messageStreamContextByBlock = new WeakMap(); + function pendingRequestBlockInput( context: TestMessageStreamContext, ): { signature: string; snapshot: ReturnType } | null { @@ -133,7 +134,7 @@ export function dispatchComposingInputValue(input: HTMLInputElement, value: stri input.dispatchEvent(event); } -export function renderMessageBlockElement(block: ReturnType[number]): HTMLElement { +export function renderMessageBlockElement(block: MessageStreamViewBlock): HTMLElement { const parent = document.createElement("div"); renderMessageStreamBlocksInAct(parent, [block]); const host = expectPresent(parent.querySelector(`[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, , @@ -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 } = {},