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 {