refactor(chat): replace thread stream presenter facade

This commit is contained in:
murashit 2026-07-18 22:11:39 +09:00
parent 1ef344b02a
commit dc467695f4
10 changed files with 207 additions and 264 deletions

View file

@ -8,7 +8,7 @@ import type { ThreadRenameEditorActions } from "../../application/threads/rename
import type { ChatComposerController } from "../../panel/composer-controller";
import type { ChatPanelShellParts } from "../../panel/shell.dom";
import type { ChatPanelGoalSurface } from "../../panel/surface/goal-projection";
import { ThreadStreamPresenter } from "../../panel/surface/thread-stream-presenter";
import { createChatThreadStreamSurfaceContext } from "../../panel/surface/thread-stream-surface.obsidian";
import type { ChatPanelToolbarSurface } from "../../panel/surface/toolbar-projection";
import type { ChatThreadStreamScrollBinding } from "../../panel/thread-stream-scroll-binding";
import { createToolbarUiActions, type ToolbarPanelActions } from "../../panel/toolbar-actions";
@ -42,7 +42,6 @@ interface ChatPanelShellBundleInput {
export interface ChatPanelShellBundle {
parts: ChatPanelShellParts;
closeToolbarPanelOnOutsidePointer(event: PointerEvent): void;
dispose(): void;
}
export function createShellBundle(host: ChatPanelShellBundleHost, input: ChatPanelShellBundleInput): ChatPanelShellBundle {
@ -98,27 +97,13 @@ export function createShellBundle(host: ChatPanelShellBundleHost, input: ChatPan
sendShortcut: () => environment.plugin.settingsRef.settings.sendShortcut(),
actions: goals,
};
const threadStreamPresenter = new ThreadStreamPresenter({
const threadStreamContext = createChatThreadStreamSurfaceContext({
panelId: environment.obsidian.viewId,
obsidian: {
app: environment.obsidian.app,
owner: environment.obsidian.owner,
},
state: {
store: stateStore,
},
workspace: {
vaultPath: environment.plugin.settingsRef.vaultPath,
},
scroll: {
portBinding: host.threadStreamScrollBinding,
dispose: () => {
host.threadStreamScrollBinding.dispose();
},
},
history: {
loadOlderTurns: () => void history.loadOlder(),
},
app: environment.obsidian.app,
owner: environment.obsidian.owner,
stateStore,
vaultPath: environment.plugin.settingsRef.vaultPath,
loadOlderTurns: () => void history.loadOlder(),
actions: {
rollbackThread: (threadId) => void threadActions.rollbackThread(threadId),
forkThreadFromTurn: (threadId, turnId, archiveSource) => void threadActions.forkThreadFromTurn(threadId, turnId, archiveSource),
@ -137,7 +122,10 @@ export function createShellBundle(host: ChatPanelShellBundleHost, input: ChatPan
actions: toolbarActions,
},
goal: goalSurface,
threadStream: threadStreamPresenter,
threadStream: {
context: threadStreamContext,
scrollPortBinding: host.threadStreamScrollBinding,
},
composer: {
presenter: composerController,
actions: {
@ -154,8 +142,5 @@ export function createShellBundle(host: ChatPanelShellBundleHost, input: ChatPan
renameEditing: rename.isEditing(),
});
},
dispose: () => {
threadStreamPresenter.dispose();
},
};
}

View file

@ -104,6 +104,7 @@ export class ChatPanelSessionRuntime {
this.runtime.sharedState.unsubscribe();
await this.thread.ephemeral.dispose();
this.disposeOwnedResources();
this.host.threadStreamScrollBinding.dispose();
unmount();
this.connection.manager.disconnect();
}
@ -344,7 +345,6 @@ function composeChatPanelSessionRuntime(host: ChatPanelSessionRuntimeHost): Chat
},
disposeOwnedResources: () => {
connectionBundle.invalidateConnectionScope();
shell.dispose();
composerController.dispose();
},
};

View file

@ -3,12 +3,14 @@ import { useMemo } from "preact/hooks";
import { listenDomEvent } from "../../../shared/dom/events.dom";
import { renderUiRoot, unmountUiRoot } from "../../../shared/dom/preact-root.dom";
import type { ChatStateStore } from "../application/state/store";
import type { ThreadStreamScrollPortBinding } from "../ui/thread-stream/flow-scroll.measure";
import { ThreadStreamViewport } from "../ui/thread-stream/stream-blocks";
import type { ToolbarActions } from "../ui/toolbar";
import { useChatSelector } from "./chat-state-selector";
import { selectChatPanelComposer, selectChatPanelGoal, selectChatPanelThreadStream, selectChatPanelToolbar } from "./shell-selectors";
import { ChatPanelComposer, type ChatPanelComposerActions, type ChatPanelComposerPresenter } from "./surface/composer-projection";
import { ChatPanelGoal, type ChatPanelGoalSurface } from "./surface/goal-projection";
import { ChatPanelThreadStream, type ChatPanelThreadStreamPresenter } from "./surface/thread-stream-presenter";
import { type ChatThreadStreamSurfaceContext, threadStreamSurfaceProjectionFromModel } from "./surface/thread-stream-projection";
import { ChatPanelToolbar, type ChatPanelToolbarSurface } from "./surface/toolbar-projection";
export interface ChatPanelShellParts {
@ -17,7 +19,10 @@ export interface ChatPanelShellParts {
actions: ToolbarActions;
};
goal: ChatPanelGoalSurface;
threadStream: ChatPanelThreadStreamPresenter;
threadStream: {
context: ChatThreadStreamSurfaceContext;
scrollPortBinding: ThreadStreamScrollPortBinding;
};
composer: {
presenter: ChatPanelComposerPresenter;
actions: ChatPanelComposerActions;
@ -106,7 +111,7 @@ function ChatPanelShell({ stateStore, showToolbar, parts }: ChatPanelShellProps)
<div className="codex-panel__region codex-panel__region--goal" data-codex-panel-shell-region="goal">
<ChatPanelGoalRegion stateStore={stateStore} surface={parts.goal} />
</div>
<ChatPanelThreadStreamRegion stateStore={stateStore} presenter={parts.threadStream} />
<ChatPanelThreadStreamRegion stateStore={stateStore} surface={parts.threadStream} />
<div className="codex-panel__region codex-panel__region--composer" data-codex-panel-shell-region="composer">
<ChatPanelComposerRegion stateStore={stateStore} presenter={parts.composer.presenter} actions={parts.composer.actions} />
</div>
@ -138,13 +143,25 @@ function ChatPanelGoalRegion({ stateStore, surface }: { stateStore: ChatStateSto
function ChatPanelThreadStreamRegion({
stateStore,
presenter,
surface,
}: {
stateStore: ChatStateStore;
presenter: ChatPanelThreadStreamPresenter;
surface: ChatPanelShellParts["threadStream"];
}): UiNode {
const model = useChatSelector(stateStore, selectChatPanelThreadStream);
return useMemo(() => <ChatPanelThreadStream model={model} presenter={presenter} />, [model, presenter]);
return useMemo(() => {
const projection = threadStreamSurfaceProjectionFromModel(model, surface.context);
return (
<ThreadStreamViewport
state={{
blocks: projection.blocks,
context: projection.context,
scrollPortBinding: surface.scrollPortBinding,
}}
rootAttributes={{ "data-codex-panel-shell-region": "thread-stream" }}
/>
);
}, [model, surface]);
}
function ChatPanelComposerRegion({

View file

@ -1,136 +0,0 @@
import type { App, Component } from "obsidian";
import type { ComponentChild as UiNode } from "preact";
import { h } from "preact";
import { copyTextWithNotice } from "../../../../shared/obsidian/clipboard.obsidian";
import type { TurnDiffViewState } from "../../../turn-diff/model";
import type { PendingRequestBlockActions } from "../../application/pending-requests/block";
import type { ChatAction } from "../../application/state/root-reducer";
import type { ChatStateStore } from "../../application/state/store";
import type { ThreadStreamScrollPortBinding } from "../../ui/thread-stream/flow-scroll.measure";
import { renderStreamMarkdown, ThreadStreamMarkdownRenderer } from "../../ui/thread-stream/markdown-renderer.obsidian";
import { ThreadStreamViewport, type ThreadStreamViewportState } from "../../ui/thread-stream/stream-blocks";
import type { ChatPanelThreadStreamModel } from "../shell-selectors";
import { type ChatThreadStreamSurfaceContext, threadStreamSurfaceProjectionFromModel } from "./thread-stream-projection";
export interface ChatPanelThreadStreamPresenter {
renderState(model: ChatPanelThreadStreamModel): ThreadStreamViewportState;
}
export function ChatPanelThreadStream({
model,
presenter,
}: {
model: ChatPanelThreadStreamModel;
presenter: ChatPanelThreadStreamPresenter;
}): UiNode {
return h(ThreadStreamViewport, {
state: presenter.renderState(model),
rootAttributes: { "data-codex-panel-shell-region": "thread-stream" },
});
}
interface ChatThreadStreamActions {
rollbackThread: (threadId: string) => void;
forkThreadFromTurn: (threadId: string, turnId: string, archiveSource: boolean) => void;
implementPlan: (itemId: string) => void;
openThreadInNewView: (threadId: string) => void;
openTurnDiff: (state: TurnDiffViewState) => void;
}
interface ChatThreadStreamRequests {
pendingActions: () => PendingRequestBlockActions;
consumePendingAutoFocus: () => boolean;
}
interface ThreadStreamPresenterObsidianContext {
app: App;
owner: Component;
}
interface ThreadStreamPresenterStateContext {
store: ChatStateStore;
}
interface ThreadStreamPresenterWorkspaceContext {
vaultPath: string;
}
interface ThreadStreamPresenterScrollContext {
portBinding: ThreadStreamScrollPortBinding;
dispose: () => void;
}
interface ThreadStreamPresenterHistoryContext {
loadOlderTurns: () => void;
}
export interface ThreadStreamPresenterOptions {
panelId: string;
obsidian: ThreadStreamPresenterObsidianContext;
state: ThreadStreamPresenterStateContext;
workspace: ThreadStreamPresenterWorkspaceContext;
scroll: ThreadStreamPresenterScrollContext;
history: ThreadStreamPresenterHistoryContext;
actions: ChatThreadStreamActions;
requests: ChatThreadStreamRequests;
}
export class ThreadStreamPresenter {
private readonly obsidianMarkdownRenderer: ThreadStreamMarkdownRenderer;
private readonly surfaceContext: ChatThreadStreamSurfaceContext;
constructor(private readonly options: ThreadStreamPresenterOptions) {
this.obsidianMarkdownRenderer = new ThreadStreamMarkdownRenderer({
app: options.obsidian.app,
owner: options.obsidian.owner,
vaultPath: options.workspace.vaultPath,
});
this.surfaceContext = {
panelId: options.panelId,
vaultPath: options.workspace.vaultPath,
setDisclosureOpen: (bucket, id, open) => {
this.dispatch({ type: "ui/disclosure-set", bucket, id, open });
},
setForkMenuItem: (itemId) => {
this.dispatch({ type: "ui/thread-stream-fork-menu-set", itemId });
},
loadOlderTurns: () => {
options.history.loadOlderTurns();
},
renderObsidianMarkdown: (element, text) => {
this.obsidianMarkdownRenderer.renderObsidianMarkdown(element, text);
},
renderStreamMarkdown: (element, text) => {
renderStreamMarkdown(element, text, {
app: options.obsidian.app,
vaultPath: options.workspace.vaultPath,
});
},
copyDialogueText: (text) => void this.copyDialogueText(text),
actions: options.actions,
requests: options.requests,
};
}
renderState(model: ChatPanelThreadStreamModel): ThreadStreamViewportState {
const projection = threadStreamSurfaceProjectionFromModel(model, this.surfaceContext);
return {
blocks: projection.blocks,
context: projection.context,
scrollPortBinding: this.options.scroll.portBinding,
};
}
private dispatch(action: ChatAction): void {
this.options.state.store.dispatch(action);
}
dispose(): void {
this.options.scroll.dispose();
}
private async copyDialogueText(text: string): Promise<void> {
await copyTextWithNotice(text, "Copied message.", "Could not copy message.");
}
}

View file

@ -27,7 +27,7 @@ import { type ThreadStreamViewBlock, threadStreamViewBlocks } from "../../presen
import type { ThreadStreamContext, ThreadStreamDisclosureBucket, ThreadStreamDisclosureState } from "../../ui/thread-stream/context";
import type { ChatPanelThreadStreamModel } from "../shell-selectors";
interface ChatThreadStreamActions {
export interface ChatThreadStreamActions {
rollbackThread: (threadId: string) => void;
forkThreadFromTurn: (threadId: string, turnId: string, archiveSource: boolean) => void;
implementPlan: (itemId: string) => void;
@ -35,7 +35,7 @@ interface ChatThreadStreamActions {
openTurnDiff: (state: TurnDiffViewState) => void;
}
interface ChatThreadStreamRequests {
export interface ChatThreadStreamRequests {
pendingActions: () => PendingRequestBlockActions;
consumePendingAutoFocus: () => boolean;
}

View file

@ -0,0 +1,54 @@
import type { App, Component } from "obsidian";
import { copyTextWithNotice } from "../../../../shared/obsidian/clipboard.obsidian";
import type { ChatAction } from "../../application/state/root-reducer";
import type { ChatStateStore } from "../../application/state/store";
import { renderStreamMarkdown, ThreadStreamMarkdownRenderer } from "../../ui/thread-stream/markdown-renderer.obsidian";
import type { ChatThreadStreamActions, ChatThreadStreamRequests, ChatThreadStreamSurfaceContext } from "./thread-stream-projection";
interface ChatThreadStreamSurfaceOptions {
panelId: string;
app: App;
owner: Component;
stateStore: ChatStateStore;
vaultPath: string;
loadOlderTurns: () => void;
actions: ChatThreadStreamActions;
requests: ChatThreadStreamRequests;
}
export function createChatThreadStreamSurfaceContext(options: ChatThreadStreamSurfaceOptions): ChatThreadStreamSurfaceContext {
const obsidianMarkdownRenderer = new ThreadStreamMarkdownRenderer({
app: options.app,
owner: options.owner,
vaultPath: options.vaultPath,
});
const dispatch = (action: ChatAction): void => {
options.stateStore.dispatch(action);
};
return {
panelId: options.panelId,
vaultPath: options.vaultPath,
setDisclosureOpen: (bucket, id, open) => {
dispatch({ type: "ui/disclosure-set", bucket, id, open });
},
setForkMenuItem: (itemId) => {
dispatch({ type: "ui/thread-stream-fork-menu-set", itemId });
},
loadOlderTurns: options.loadOlderTurns,
renderObsidianMarkdown: (element, text) => {
obsidianMarkdownRenderer.renderObsidianMarkdown(element, text);
},
renderStreamMarkdown: (element, text) => {
renderStreamMarkdown(element, text, {
app: options.app,
vaultPath: options.vaultPath,
});
},
copyDialogueText: (text) => {
void copyTextWithNotice(text, "Copied message.", "Could not copy message.");
},
actions: options.actions,
requests: options.requests,
};
}

View file

@ -12,7 +12,7 @@ import { pendingRequestBlockNode } from "./pending-request-block";
import { agentRunSummaryNode, statusNode } from "./status";
import { textNode } from "./text";
export interface ThreadStreamViewportState {
interface ThreadStreamViewportState {
blocks: readonly ThreadStreamViewBlock[];
context: ThreadStreamContext;
scrollPortBinding: ThreadStreamScrollPortBinding;

View file

@ -9,9 +9,8 @@ import { ChatComposerController } from "../../../../src/features/chat/panel/comp
import { type ChatPanelShellParts, renderChatPanelShell, unmountChatPanelShell } from "../../../../src/features/chat/panel/shell.dom";
import type { ChatPanelComposerModel } from "../../../../src/features/chat/panel/shell-selectors";
import type { ChatPanelGoalSurface } from "../../../../src/features/chat/panel/surface/goal-projection";
import type { ChatThreadStreamSurfaceContext } from "../../../../src/features/chat/panel/surface/thread-stream-projection";
import type { ChatPanelToolbarSurface } from "../../../../src/features/chat/panel/surface/toolbar-projection";
import { threadStreamViewBlocks } from "../../../../src/features/chat/presentation/thread-stream/view-model";
import type { ThreadStreamContext } from "../../../../src/features/chat/ui/thread-stream/context";
import type { ThreadStreamScrollPortBinding } from "../../../../src/features/chat/ui/thread-stream/flow-scroll.measure";
import { installObsidianDomShims } from "../../../support/dom";
@ -266,20 +265,8 @@ function shellParts(
},
goal: surface.goal,
threadStream: {
renderState: (model) => {
void model.activeThreadCwd;
return {
blocks: threadStreamViewBlocks({
activeThreadId: model.activeThreadId,
activeTurnId: null,
historyCursor: model.threadStream.historyCursor,
loadingHistory: model.threadStream.loadingHistory,
items: model.threadStream.stableItems,
}),
context: testThreadStreamContext,
scrollPortBinding: noOpThreadStreamScrollPortBinding,
};
},
context: testThreadStreamContext,
scrollPortBinding: noOpThreadStreamScrollPortBinding,
},
composer: {
presenter: {
@ -392,20 +379,33 @@ function composerProjectionFixture(_model: ChatPanelComposerModel) {
};
}
const testThreadStreamContext: ThreadStreamContext = {
activeThreadId: "thread",
workspaceRoot: "/vault",
const testThreadStreamContext: ChatThreadStreamSurfaceContext = {
panelId: "test-panel",
vaultPath: "/vault",
setDisclosureOpen: vi.fn(),
setForkMenuItem: vi.fn(),
loadOlderTurns: () => undefined,
disclosures: {
details: new Set(),
activityGroups: new Set(),
textDetails: new Set(),
userDialogueExpanded: new Set(),
approvalDetails: new Set(),
},
forkMenuItemId: null,
renderObsidianMarkdown: () => undefined,
renderStreamMarkdown: () => undefined,
copyDialogueText: () => undefined,
actions: {
rollbackThread: vi.fn(),
forkThreadFromTurn: vi.fn(),
implementPlan: vi.fn(),
openThreadInNewView: vi.fn(),
openTurnDiff: vi.fn(),
},
requests: {
pendingActions: () => ({
resolveApproval: vi.fn(),
resolveUserInput: vi.fn(),
cancelUserInput: vi.fn(),
resolveMcpElicitation: vi.fn(),
setUserInputDraft: vi.fn(),
setMcpElicitationDraft: vi.fn(),
}),
consumePendingAutoFocus: () => false,
},
};
function surfaceFixture(options: { toolbarConnected?: () => boolean; goalSendShortcut?: () => "enter" | "mod-enter" } = {}): {

View file

@ -7,11 +7,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { type ChatAction, type ChatState, chatReducer } from "../../../../../src/features/chat/application/state/root-reducer";
import { type ChatStateStore, createChatStateStore } from "../../../../../src/features/chat/application/state/store";
import { pendingWebSubmissionItem } from "../../../../../src/features/chat/application/turns/web-submission";
import { ThreadStreamPresenter } from "../../../../../src/features/chat/panel/surface/thread-stream-presenter";
import {
type ChatThreadStreamSurfaceContext,
threadStreamSurfaceProjectionFromModel,
} from "../../../../../src/features/chat/panel/surface/thread-stream-projection";
import { createChatThreadStreamSurfaceContext } from "../../../../../src/features/chat/panel/surface/thread-stream-surface.obsidian";
import {
type ChatThreadStreamScrollBinding,
createChatThreadStreamScrollBinding,
@ -28,11 +28,26 @@ import { installThreadStreamViewportMetrics, pendingApproval } from "../../ui/th
installObsidianDomShims();
function renderThreadStreamPresenter(parent: HTMLElement, presenter: ThreadStreamPresenter, state: ChatState): void {
renderUiRoot(parent, h(ThreadStreamViewport, { state: presenter.renderState(threadStreamModelFromChatState(state)) }));
function renderThreadStreamSurface(
parent: HTMLElement,
context: ChatThreadStreamSurfaceContext,
scrollPortBinding: ChatThreadStreamScrollBinding,
state: ChatState,
): void {
const projection = threadStreamSurfaceProjectionFromModel(threadStreamModelFromChatState(state), context);
renderUiRoot(
parent,
h(ThreadStreamViewport, {
state: {
blocks: projection.blocks,
context: projection.context,
scrollPortBinding,
},
}),
);
}
describe("ThreadStreamPresenter scroll pinning", () => {
describe("thread stream surface", () => {
beforeEach(() => {
notices.length = 0;
});
@ -96,6 +111,16 @@ describe("ThreadStreamPresenter scroll pinning", () => {
expect(store.getState().ui.disclosures.textDetails.has("message:details")).toBe(true);
});
it("binds reducer-owned disclosure and fork menu actions in the surface factory", () => {
const { context, stateStore } = threadStreamSurface();
context.setDisclosureOpen("activityGroups", "turn-1", true);
context.setForkMenuItem("message-1");
expect(stateStore.getState().ui.disclosures.activityGroups.has("turn-1")).toBe(true);
expect(stateStore.getState().ui.threadStreamActionMenu.forkMenuItemId).toBe("message-1");
});
it("projects pending requests from the captured thread stream state", () => {
let state = chatStateFixture();
state = withChatStateThreadStreamItems(state, [{ id: "system", kind: "system", role: "system", text: "Waiting for approval." }]);
@ -281,9 +306,9 @@ describe("ThreadStreamPresenter scroll pinning", () => {
},
]);
const parent = document.createElement("div");
const { presenter, scrollPortBinding } = threadStreamPresenter(state);
const { context, scrollPortBinding } = threadStreamSurface(state);
renderThreadStreamPresenter(parent, presenter, state);
renderThreadStreamSurface(parent, context, scrollPortBinding, state);
const viewport = threadStreamViewport(parent);
installThreadStreamViewportMetrics(viewport, { clientHeight: 100, scrollHeight: 1000 });
viewport.scrollTop = 100;
@ -309,8 +334,8 @@ describe("ThreadStreamPresenter scroll pinning", () => {
},
]);
const parent = document.createElement("div");
const { presenter, scrollPortBinding } = threadStreamPresenter(state);
renderThreadStreamPresenter(parent, presenter, state);
const { context, scrollPortBinding } = threadStreamSurface(state);
renderThreadStreamSurface(parent, context, scrollPortBinding, state);
const viewport = threadStreamViewport(parent);
let scrollTop = 0;
let layoutSettled = false;
@ -362,9 +387,9 @@ describe("ThreadStreamPresenter scroll pinning", () => {
},
]);
const parent = document.createElement("div");
const { presenter, scrollPortBinding } = threadStreamPresenter(state, vi.fn(), "/vault");
const { context, scrollPortBinding } = threadStreamSurface(state, vi.fn(), "/vault");
renderThreadStreamPresenter(parent, presenter, state);
renderThreadStreamSurface(parent, context, scrollPortBinding, state);
const viewport = threadStreamViewport(parent);
installThreadStreamViewportMetrics(viewport, { clientHeight: 100, scrollHeight: 1000 });
scrollPortBinding.showLatest();
@ -388,11 +413,11 @@ describe("ThreadStreamPresenter scroll pinning", () => {
},
]);
const parent = document.createElement("div");
const { presenter } = threadStreamPresenter(state);
const { context, scrollPortBinding } = threadStreamSurface(state);
const viewport = parent.createDiv({ cls: "codex-panel__thread-stream" });
installThreadStreamViewportMetrics(viewport);
renderThreadStreamPresenter(viewport, presenter, state);
renderThreadStreamSurface(viewport, context, scrollPortBinding, state);
await settleThreadStreamRender(viewport);
Object.defineProperty(viewport, "scrollHeight", { value: 1000, configurable: true });
@ -411,7 +436,7 @@ describe("ThreadStreamPresenter scroll pinning", () => {
dialogueState: "completed",
},
]);
renderThreadStreamPresenter(viewport, presenter, state);
renderThreadStreamSurface(viewport, context, scrollPortBinding, state);
await settleThreadStreamRender(viewport);
expect(viewport.scrollTop).toBe(100);
@ -432,13 +457,13 @@ describe("ThreadStreamPresenter scroll pinning", () => {
},
]);
const parent = document.createElement("div");
const { presenter } = threadStreamPresenter(state);
const { context, scrollPortBinding } = threadStreamSurface(state);
const viewport = parent.createDiv({ cls: "codex-panel__thread-stream" });
Object.defineProperty(viewport, "scrollHeight", { value: 1000, configurable: true });
installThreadStreamViewportMetrics(viewport, { clientHeight: 100 });
viewport.scrollTop = 920;
renderThreadStreamPresenter(viewport, presenter, state);
renderThreadStreamSurface(viewport, context, scrollPortBinding, state);
viewport.scrollTop = 100;
viewport.dispatchEvent(new Event("scroll"));
@ -567,49 +592,37 @@ async function renderedTag(
};
}
interface TestThreadStreamPresenter {
presenter: ThreadStreamPresenter;
interface TestThreadStreamSurface {
context: ChatThreadStreamSurfaceContext;
scrollPortBinding: ChatThreadStreamScrollBinding;
stateStore: ChatStateStore;
}
function threadStreamPresenter(
function threadStreamSurface(
state = chatStateFixture(),
openLinkText = vi.fn(),
vaultPath = "/vault",
vaultFiles: string[] = [],
): TestThreadStreamPresenter {
): TestThreadStreamSurface {
const files = new Map(vaultFiles.map((path) => [path, tFile(path)]));
const scrollPortBinding = createChatThreadStreamScrollBinding();
const presenter = new ThreadStreamPresenter({
const stateStore = testStoreForState(state);
const context = createChatThreadStreamSurfaceContext({
panelId: "test-panel",
obsidian: {
app: {
workspace: {
getActiveFile: vi.fn(() => null),
openLinkText,
},
vault: {
configDir: "vault-config",
getAbstractFileByPath: (path: string) => files.get(path) ?? null,
},
} as never,
owner: {} as never,
},
state: {
store: testStoreForState(state),
},
workspace: {
vaultPath,
},
scroll: {
portBinding: scrollPortBinding,
dispose: () => {
scrollPortBinding.dispose();
app: {
workspace: {
getActiveFile: vi.fn(() => null),
openLinkText,
},
},
history: {
loadOlderTurns: vi.fn(),
},
vault: {
configDir: "vault-config",
getAbstractFileByPath: (path: string) => files.get(path) ?? null,
},
} as never,
owner: {} as never,
stateStore,
vaultPath,
loadOlderTurns: vi.fn(),
actions: {
rollbackThread: vi.fn(),
forkThreadFromTurn: vi.fn(),
@ -629,7 +642,7 @@ function threadStreamPresenter(
consumePendingAutoFocus: () => false,
},
});
return { presenter, scrollPortBinding };
return { context, scrollPortBinding, stateStore };
}
function testStoreForState(state: ChatState): ChatStateStore {

View file

@ -8,9 +8,9 @@ import { createChatStateStore } from "../../../../src/features/chat/application/
import type { ThreadManagementActions } from "../../../../src/features/chat/application/threads/thread-management-actions";
import { type ChatPanelShellParts, renderChatPanelShell, unmountChatPanelShell } from "../../../../src/features/chat/panel/shell.dom";
import type { ChatPanelGoalSurface } from "../../../../src/features/chat/panel/surface/goal-projection";
import type { ChatThreadStreamSurfaceContext } from "../../../../src/features/chat/panel/surface/thread-stream-projection";
import type { ChatPanelToolbarSurface } from "../../../../src/features/chat/panel/surface/toolbar-projection";
import { createToolbarPanelActions, type ToolbarPanelActions } from "../../../../src/features/chat/panel/toolbar-actions";
import type { ThreadStreamContext } from "../../../../src/features/chat/ui/thread-stream/context";
import type { ThreadStreamScrollPortBinding } from "../../../../src/features/chat/ui/thread-stream/flow-scroll.measure";
import { installObsidianDomShims } from "../../../support/dom";
@ -110,11 +110,8 @@ function shellParts(store: ReturnType<typeof createChatStateStore>, toolbarPanel
},
goal: surface.goal,
threadStream: {
renderState: () => ({
blocks: [],
context: testThreadStreamContext,
scrollPortBinding: noOpThreadStreamScrollPortBinding,
}),
context: testThreadStreamContext,
scrollPortBinding: noOpThreadStreamScrollPortBinding,
},
composer: {
presenter: {
@ -196,20 +193,33 @@ const noOpThreadStreamScrollPortBinding: ThreadStreamScrollPortBinding = {
mountScrollPort: () => () => undefined,
};
const testThreadStreamContext: ThreadStreamContext = {
activeThreadId: "thread",
workspaceRoot: "/vault",
const testThreadStreamContext: ChatThreadStreamSurfaceContext = {
panelId: "test-panel",
vaultPath: "/vault",
setDisclosureOpen: vi.fn(),
setForkMenuItem: vi.fn(),
loadOlderTurns: () => undefined,
disclosures: {
details: new Set(),
activityGroups: new Set(),
textDetails: new Set(),
userDialogueExpanded: new Set(),
approvalDetails: new Set(),
},
forkMenuItemId: null,
renderObsidianMarkdown: () => undefined,
renderStreamMarkdown: () => undefined,
copyDialogueText: () => undefined,
actions: {
rollbackThread: vi.fn(),
forkThreadFromTurn: vi.fn(),
implementPlan: vi.fn(),
openThreadInNewView: vi.fn(),
openTurnDiff: vi.fn(),
},
requests: {
pendingActions: () => ({
resolveApproval: vi.fn(),
resolveUserInput: vi.fn(),
cancelUserInput: vi.fn(),
resolveMcpElicitation: vi.fn(),
setUserInputDraft: vi.fn(),
setMcpElicitationDraft: vi.fn(),
}),
consumePendingAutoFocus: () => false,
},
};
function threadFixture(id: string, name: string): Thread {