Consolidate message stream rendering modules

This commit is contained in:
murashit 2026-06-23 10:34:14 +09:00
parent 4cb72e957d
commit 683a703d85
8 changed files with 77 additions and 86 deletions

View file

@ -5,9 +5,8 @@ import { copyTextWithNotice } from "../../../../shared/ui/clipboard";
import type { ChatAction } from "../../application/state/root-reducer";
import type { ChatStateStore } from "../../application/state/store";
import type { MessageStreamScrollControllerBinding } from "../../ui/message-stream/flow-scroll";
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 { MarkdownMessageRenderer, renderStreamMarkdown } from "../../ui/message-stream/markdown-renderer";
import { MessageStreamViewport, type MessageStreamViewportState } from "../../ui/message-stream/stream-blocks";
import { messageStreamStateFromShellState, useChatPanelShellState, type ChatPanelMessageStreamShellState } from "../shell-state";
import type { PendingRequestBlockActions } from "../../application/pending-requests/block";
import type { ChatTurnDiffViewState } from "../../domain/turn-diff";

View file

@ -1,14 +1,20 @@
import { MarkdownRenderer, Notice, type App, type Component } from "obsidian";
import { micromark } from "micromark";
import { isAbsoluteFileHref, vaultFileLinkTarget, vaultRelativeFileLinkTarget } from "../../../../shared/obsidian/file-links";
import { notifyMessageContentRendered } from "./content-events";
export interface MarkdownMessageRendererOptions {
interface MarkdownMessageRendererOptions {
app: App;
owner: Component;
vaultPath: string;
}
interface StreamMarkdownRenderContext {
app: App;
vaultPath: string;
}
export class MarkdownMessageRenderer {
private readonly renderGenerations = new WeakMap<HTMLElement, number>();
@ -29,6 +35,16 @@ export class MarkdownMessageRenderer {
}
}
export function renderStreamMarkdown(parent: HTMLElement, text: string, context: StreamMarkdownRenderContext): void {
const html = micromark(text);
const DOMParserConstructor = parent.ownerDocument.defaultView?.DOMParser ?? DOMParser;
const parser = new DOMParserConstructor();
const parsed = parser.parseFromString(html, "text/html");
const nodes = Array.from(parsed.body.childNodes).map((node) => parent.ownerDocument.importNode(node, true));
parent.replaceChildren(...nodes);
bindStreamMarkdownFileLinks(parent, context);
}
interface RenderedMarkdownLinkContext {
app: App;
vaultPath: string;
@ -65,3 +81,33 @@ function bindRenderedMarkdownFileLinks(parent: HTMLElement, sourcePath: string,
};
});
}
function bindStreamMarkdownFileLinks(parent: HTMLElement, context: StreamMarkdownRenderContext): void {
const sourcePath = context.app.workspace.getActiveFile()?.path ?? "";
parent.querySelectorAll<HTMLAnchorElement>("a[href]").forEach((link) => {
const href = link.getAttribute("href") ?? "";
const target = vaultFileLinkTarget(context.app, context.vaultPath, href);
if (target) {
link.addClass("codex-panel__filelink");
link.onclick = (event) => {
event.preventDefault();
void context.app.workspace.openLinkText(target, sourcePath, false);
};
return;
}
const vaultRelativeTarget = vaultRelativeFileLinkTarget(context.vaultPath, context.app.vault.configDir, href);
if (vaultRelativeTarget) {
link.onclick = (event) => {
event.preventDefault();
};
return;
}
if (!isAbsoluteFileHref(href)) return;
link.onclick = (event) => {
event.preventDefault();
new Notice("Cannot open files outside the vault.");
};
});
}

View file

@ -5,19 +5,43 @@ import {
type MessageStreamRenderedItemView,
type MessageStreamViewBlock,
} from "../../presentation/message-stream/view-model";
import { MessageStreamFlowFrame, type MessageStreamScrollControllerBinding } from "./flow-scroll";
import { pendingRequestBlockNode } from "./pending-request-block";
import { detailNode } from "./detail";
import { agentRunSummaryNode, statusNode } from "./status";
import type { MessageStreamContext, PendingRequestBlockContext } from "./context";
import { textNode } from "./text";
export interface MessageStreamViewportState {
blocks: readonly MessageStreamViewBlock[];
context: MessageStreamContext;
scrollController: MessageStreamScrollControllerBinding;
}
interface MessageStreamViewportProps {
state: MessageStreamViewportState;
rootAttributes?: Partial<Record<`data-${string}`, string>>;
}
export function MessageStreamViewport({ state, rootAttributes }: MessageStreamViewportProps): UiNode {
const { blocks, context, scrollController } = state;
return (
<MessageStreamFlowFrame
blocks={blocks}
scrollController={scrollController}
renderBlockContent={(block) => <MessageStreamBlockContent block={block} context={context} />}
{...(rootAttributes ? { rootAttributes } : {})}
/>
);
}
function streamItemNode(item: MessageStreamRenderedItemView, context: MessageStreamContext): UiNode {
if (item.kind === "text") return textNode(item.view, context);
if (item.kind === "detail") return detailNode(item.view, context);
return statusNode(item.view);
}
export function MessageStreamBlockContent({ block, context }: { block: MessageStreamViewBlock; context: MessageStreamContext }): UiNode {
function MessageStreamBlockContent({ block, context }: { block: MessageStreamViewBlock; context: MessageStreamContext }): UiNode {
return presentationBlockNode(block, context);
}

View file

@ -1,49 +0,0 @@
import { Notice, type App } from "obsidian";
import { micromark } from "micromark";
import { isAbsoluteFileHref, vaultFileLinkTarget, vaultRelativeFileLinkTarget } from "../../../../shared/obsidian/file-links";
export interface StreamMarkdownRenderContext {
app: App;
vaultPath: string;
}
export function renderStreamMarkdown(parent: HTMLElement, text: string, context: StreamMarkdownRenderContext): void {
const html = micromark(text);
const DOMParserConstructor = parent.ownerDocument.defaultView?.DOMParser ?? DOMParser;
const parser = new DOMParserConstructor();
const parsed = parser.parseFromString(html, "text/html");
const nodes = Array.from(parsed.body.childNodes).map((node) => parent.ownerDocument.importNode(node, true));
parent.replaceChildren(...nodes);
bindStreamMarkdownFileLinks(parent, context);
}
function bindStreamMarkdownFileLinks(parent: HTMLElement, context: StreamMarkdownRenderContext): void {
const sourcePath = context.app.workspace.getActiveFile()?.path ?? "";
parent.querySelectorAll<HTMLAnchorElement>("a[href]").forEach((link) => {
const href = link.getAttribute("href") ?? "";
const target = vaultFileLinkTarget(context.app, context.vaultPath, href);
if (target) {
link.addClass("codex-panel__filelink");
link.onclick = (event) => {
event.preventDefault();
void context.app.workspace.openLinkText(target, sourcePath, false);
};
return;
}
const vaultRelativeTarget = vaultRelativeFileLinkTarget(context.vaultPath, context.app.vault.configDir, href);
if (vaultRelativeTarget) {
link.onclick = (event) => {
event.preventDefault();
};
return;
}
if (!isAbsoluteFileHref(href)) return;
link.onclick = (event) => {
event.preventDefault();
new Notice("Cannot open files outside the vault.");
};
});
}

View file

@ -1,29 +0,0 @@
import type { ComponentChild as UiNode } from "preact";
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: readonly MessageStreamViewBlock[];
context: MessageStreamContext;
scrollController: MessageStreamScrollControllerBinding;
}
export interface MessageStreamViewportProps {
state: MessageStreamViewportState;
rootAttributes?: Partial<Record<`data-${string}`, string>>;
}
export function MessageStreamViewport({ state, rootAttributes }: MessageStreamViewportProps): UiNode {
const { blocks, context, scrollController } = state;
return (
<MessageStreamFlowFrame
blocks={blocks}
scrollController={scrollController}
renderBlockContent={(block) => <MessageStreamBlockContent block={block} context={context} />}
{...(rootAttributes ? { rootAttributes } : {})}
/>
);
}

View file

@ -15,7 +15,7 @@ import {
createChatMessageScrollController,
type ChatMessageScrollController,
} from "../../../../../src/features/chat/panel/surface/message-stream-scroll";
import { MessageStreamViewport } from "../../../../../src/features/chat/ui/message-stream/viewport";
import { MessageStreamViewport } from "../../../../../src/features/chat/ui/message-stream/stream-blocks";
import { MarkdownMessageRenderer } from "../../../../../src/features/chat/ui/message-stream/markdown-renderer";
import { MESSAGE_CONTENT_RENDERED_EVENT } from "../../../../../src/features/chat/ui/message-stream/content-events";
import { renderUiRoot, unmountUiRoot } from "../../../../../src/shared/ui/ui-root";

View file

@ -3,7 +3,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { TFile } from "obsidian";
import { renderStreamMarkdown } from "../../../../../src/features/chat/ui/message-stream/stream-markdown-renderer";
import { renderStreamMarkdown } from "../../../../../src/features/chat/ui/message-stream/markdown-renderer";
import { notices } from "../../../../mocks/obsidian";
import { installObsidianDomShims } from "../../../../support/dom";

View file

@ -17,7 +17,7 @@ import {
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 { MessageStreamViewport } from "../../../../../src/features/chat/ui/message-stream/stream-blocks";
import type { MessageStreamScrollControllerBinding } from "../../../../../src/features/chat/ui/message-stream/flow-scroll";
import { renderUiRoot, unmountUiRoot } from "../../../../../src/shared/ui/ui-root";