mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
refactor(chat): replace thread stream presenter facade
This commit is contained in:
parent
1ef344b02a
commit
dc467695f4
10 changed files with 207 additions and 264 deletions
|
|
@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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" } = {}): {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue