From dc467695f4236be7bd11ea2ca11557087bc2acd7 Mon Sep 17 00:00:00 2001 From: murashit Date: Sat, 18 Jul 2026 22:11:39 +0900 Subject: [PATCH] refactor(chat): replace thread stream presenter facade --- .../chat/host/bundles/shell-bundle.ts | 37 ++--- src/features/chat/host/session-runtime.ts | 2 +- src/features/chat/panel/shell.dom.tsx | 29 +++- .../panel/surface/thread-stream-presenter.ts | 136 ------------------ .../panel/surface/thread-stream-projection.ts | 4 +- .../surface/thread-stream-surface.obsidian.ts | 54 +++++++ .../chat/ui/thread-stream/stream-blocks.tsx | 2 +- tests/features/chat/panel/shell.test.tsx | 54 +++---- ....test.ts => thread-stream-surface.test.ts} | 109 +++++++------- .../chat/panel/toolbar-archive-state.test.tsx | 44 +++--- 10 files changed, 207 insertions(+), 264 deletions(-) delete mode 100644 src/features/chat/panel/surface/thread-stream-presenter.ts create mode 100644 src/features/chat/panel/surface/thread-stream-surface.obsidian.ts rename tests/features/chat/panel/surface/{thread-stream-presenter.test.ts => thread-stream-surface.test.ts} (88%) diff --git a/src/features/chat/host/bundles/shell-bundle.ts b/src/features/chat/host/bundles/shell-bundle.ts index 6ce64f06..7b1b054c 100644 --- a/src/features/chat/host/bundles/shell-bundle.ts +++ b/src/features/chat/host/bundles/shell-bundle.ts @@ -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(); - }, }; } diff --git a/src/features/chat/host/session-runtime.ts b/src/features/chat/host/session-runtime.ts index 5cd6a310..bb14be3b 100644 --- a/src/features/chat/host/session-runtime.ts +++ b/src/features/chat/host/session-runtime.ts @@ -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(); }, }; diff --git a/src/features/chat/panel/shell.dom.tsx b/src/features/chat/panel/shell.dom.tsx index bb19cfb7..2d200942 100644 --- a/src/features/chat/panel/shell.dom.tsx +++ b/src/features/chat/panel/shell.dom.tsx @@ -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)
- +
@@ -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(() => , [model, presenter]); + return useMemo(() => { + const projection = threadStreamSurfaceProjectionFromModel(model, surface.context); + return ( + + ); + }, [model, surface]); } function ChatPanelComposerRegion({ diff --git a/src/features/chat/panel/surface/thread-stream-presenter.ts b/src/features/chat/panel/surface/thread-stream-presenter.ts deleted file mode 100644 index 5a2234f0..00000000 --- a/src/features/chat/panel/surface/thread-stream-presenter.ts +++ /dev/null @@ -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 { - await copyTextWithNotice(text, "Copied message.", "Could not copy message."); - } -} diff --git a/src/features/chat/panel/surface/thread-stream-projection.ts b/src/features/chat/panel/surface/thread-stream-projection.ts index 79f01b99..d398b0b2 100644 --- a/src/features/chat/panel/surface/thread-stream-projection.ts +++ b/src/features/chat/panel/surface/thread-stream-projection.ts @@ -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; } diff --git a/src/features/chat/panel/surface/thread-stream-surface.obsidian.ts b/src/features/chat/panel/surface/thread-stream-surface.obsidian.ts new file mode 100644 index 00000000..c4530d89 --- /dev/null +++ b/src/features/chat/panel/surface/thread-stream-surface.obsidian.ts @@ -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, + }; +} diff --git a/src/features/chat/ui/thread-stream/stream-blocks.tsx b/src/features/chat/ui/thread-stream/stream-blocks.tsx index 8b4dcb5d..961022d9 100644 --- a/src/features/chat/ui/thread-stream/stream-blocks.tsx +++ b/src/features/chat/ui/thread-stream/stream-blocks.tsx @@ -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; diff --git a/tests/features/chat/panel/shell.test.tsx b/tests/features/chat/panel/shell.test.tsx index 874557bb..f84438c5 100644 --- a/tests/features/chat/panel/shell.test.tsx +++ b/tests/features/chat/panel/shell.test.tsx @@ -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" } = {}): { diff --git a/tests/features/chat/panel/surface/thread-stream-presenter.test.ts b/tests/features/chat/panel/surface/thread-stream-surface.test.ts similarity index 88% rename from tests/features/chat/panel/surface/thread-stream-presenter.test.ts rename to tests/features/chat/panel/surface/thread-stream-surface.test.ts index 62db71bc..6a218ff3 100644 --- a/tests/features/chat/panel/surface/thread-stream-presenter.test.ts +++ b/tests/features/chat/panel/surface/thread-stream-surface.test.ts @@ -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 { diff --git a/tests/features/chat/panel/toolbar-archive-state.test.tsx b/tests/features/chat/panel/toolbar-archive-state.test.tsx index 42902b5a..5221fd45 100644 --- a/tests/features/chat/panel/toolbar-archive-state.test.tsx +++ b/tests/features/chat/panel/toolbar-archive-state.test.tsx @@ -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, 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 {