mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Simplify message stream flow rendering
This commit is contained in:
parent
1e8a2d4dcc
commit
1e3e4c9f84
11 changed files with 227 additions and 229 deletions
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 } : {})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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("");
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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 } = {},
|
||||
|
|
|
|||
Loading…
Reference in a new issue