From 552e53681b698e5032d80a04523aa21d97b2dd9b Mon Sep 17 00:00:00 2001 From: murashit Date: Fri, 12 Jun 2026 16:07:35 +0900 Subject: [PATCH] Organize message stream UI rendering --- src/features/chat/composition-ports.ts | 8 +- src/features/chat/composition.ts | 16 +-- src/features/chat/conversation/composition.ts | 18 +-- .../pending-requests/controller.ts | 38 +++-- .../conversation/pending-requests/snapshot.ts | 26 +++- .../pending-requests/view-model.ts | 99 +++++++++++++ src/features/chat/panel/composition.ts | 10 +- src/features/chat/panel/regions/goal.ts | 6 +- .../{messages.ts => message-stream.ts} | 6 +- src/features/chat/panel/regions/ports.ts | 2 +- src/features/chat/panel/regions/render.tsx | 16 +-- src/features/chat/panel/shell-render.ts | 4 +- .../chat/ui/{goal-banner.tsx => goal.tsx} | 14 +- .../content-events.ts} | 0 .../chat/ui/message-stream/context-model.ts | 57 -------- .../chat/ui/message-stream/context.ts | 2 +- .../ui/message-stream/markdown-renderer.ts | 43 +++++- .../pending-request-block.tsx | 131 +++++++----------- src/features/chat/ui/message-stream/ports.ts | 2 +- .../chat/ui/message-stream/render-model.ts | 21 --- .../message-stream/rendered-markdown-links.ts | 40 ------ .../chat/ui/message-stream/renderer.ts | 103 ++++++++++---- .../scroll-intent-controller.ts} | 2 +- .../chat/ui/message-stream/stream-blocks.tsx | 15 +- .../chat/ui/message-stream/text-item.tsx | 2 +- .../tool-result-view-model.ts} | 6 +- .../ui/{ => message-stream}/tool-result.tsx | 4 +- .../{render.tsx => viewport.tsx} | 14 +- .../virtualizer.ts} | 2 +- .../ui/{ => message-stream}/work-items.tsx | 8 +- .../ui/{ => message-stream}/work-message.ts | 0 src/features/chat/ui/shell.tsx | 6 +- src/features/chat/view.ts | 24 ++-- src/styles/30-chat-layout.css | 2 +- .../pending-requests/controller.test.ts | 2 +- .../message-stream/pending-requests.test.tsx | 9 +- .../chat/ui/message-stream/renderer.test.ts | 60 ++++---- .../scroll-intent-controller.test.ts} | 2 +- .../chat/ui/message-stream/test-helpers.tsx | 44 +++++- .../{render.test.tsx => viewport.test.tsx} | 2 +- .../virtualizer.test.ts} | 8 +- .../{goal-banner.test.tsx => goal.test.tsx} | 10 +- tests/features/chat/ui/shell.test.tsx | 28 ++-- .../chat/ui/toolbar-archive-signal.test.tsx | 2 +- tests/features/chat/view-connection.test.ts | 2 +- 45 files changed, 509 insertions(+), 407 deletions(-) create mode 100644 src/features/chat/conversation/pending-requests/view-model.ts rename src/features/chat/panel/regions/{messages.ts => message-stream.ts} (55%) rename src/features/chat/ui/{goal-banner.tsx => goal.tsx} (96%) rename src/features/chat/ui/{message-content-events.ts => message-stream/content-events.ts} (100%) delete mode 100644 src/features/chat/ui/message-stream/context-model.ts rename src/features/chat/ui/{ => message-stream}/pending-request-block.tsx (75%) delete mode 100644 src/features/chat/ui/message-stream/render-model.ts delete mode 100644 src/features/chat/ui/message-stream/rendered-markdown-links.ts rename src/features/chat/ui/{message-scroll-intent-controller.ts => message-stream/scroll-intent-controller.ts} (85%) rename src/features/chat/ui/{tool-result-view.ts => message-stream/tool-result-view-model.ts} (97%) rename src/features/chat/ui/{ => message-stream}/tool-result.tsx (98%) rename src/features/chat/ui/message-stream/{render.tsx => viewport.tsx} (90%) rename src/features/chat/ui/{message-virtualizer.ts => message-stream/virtualizer.ts} (99%) rename src/features/chat/ui/{ => message-stream}/work-items.tsx (97%) rename src/features/chat/ui/{ => message-stream}/work-message.ts (100%) rename tests/features/chat/ui/{message-scroll-intent-controller.test.ts => message-stream/scroll-intent-controller.test.ts} (87%) rename tests/features/chat/ui/message-stream/{render.test.tsx => viewport.test.tsx} (97%) rename tests/features/chat/ui/{message-virtualizer.test.ts => message-stream/virtualizer.test.ts} (99%) rename tests/features/chat/ui/renderers/{goal-banner.test.tsx => goal.test.tsx} (97%) diff --git a/src/features/chat/composition-ports.ts b/src/features/chat/composition-ports.ts index 66ddc460..9b2f4fc8 100644 --- a/src/features/chat/composition-ports.ts +++ b/src/features/chat/composition-ports.ts @@ -9,7 +9,7 @@ import type { CodexPanelSettings } from "../../settings/model"; import type { RuntimeSnapshot } from "./runtime/snapshot"; import type { ChatState, ChatStateStore } from "./state/reducer"; import type { ChatTurnDiffViewState } from "./turn-diff/model"; -import type { ChatMessageScrollIntentController } from "./ui/message-scroll-intent-controller"; +import type { ChatMessageScrollIntentController } from "./ui/message-stream/scroll-intent-controller"; import type { DisplayDetailSection, DisplayItem } from "./display/types"; import type { ChatConnectionWorkTracker, ChatResumeWorkTracker, ChatViewDeferredTasks } from "./lifecycle"; import type { ComposerMetaViewModel } from "./ui/composer"; @@ -21,7 +21,7 @@ export interface ChatControllerCompositionPorts { client: ChatPanelClientContext; lifecycle: ChatPanelLifecycleContext; render: ChatPanelRenderContext; - messages: ChatPanelMessageContext; + messageStream: ChatPanelMessageStreamContext; composerView: ChatPanelComposerContext; runtime: ChatPanelRuntimeContext; thread: ChatThreadContext; @@ -90,13 +90,13 @@ interface ChatPanelRenderContext { panelRoot: () => HTMLElement | null; toolbarNode: () => UiNode; goalNode: () => UiNode; - messagesNode: () => UiNode; + messageStreamNode: () => UiNode; composerNode: () => UiNode; closeToolbarPanelOnOutsidePointer: (event: PointerEvent) => void; schedule: () => void; } -interface ChatPanelMessageContext { +interface ChatPanelMessageStreamContext { pendingRequestsSignature: () => string; } diff --git a/src/features/chat/composition.ts b/src/features/chat/composition.ts index e022a9c9..cae5039e 100644 --- a/src/features/chat/composition.ts +++ b/src/features/chat/composition.ts @@ -21,7 +21,7 @@ import type { IdentitySync } from "./threads/identity-sync"; import type { ResumeController } from "./threads/resume-controller"; import type { SelectionActions } from "./threads/selection-actions"; import type { ChatViewRenderController } from "./panel/view-render-controller"; -import type { ChatMessageRenderer } from "./ui/message-stream/renderer"; +import type { MessageStreamRenderer } from "./ui/message-stream/renderer"; import type { ChatControllerCompositionPorts } from "./composition-ports"; import { createChatControllerCompositionActions } from "./composition-actions"; import { @@ -79,7 +79,7 @@ export interface ChatViewControllers { }; render: { controller: ChatViewRenderController; - messages: ChatMessageRenderer; + messageStream: MessageStreamRenderer; openView: () => void; closeView: () => void; applyViewState: (state: unknown) => void; @@ -102,7 +102,7 @@ export function createChatViewControllers(ports: ChatControllerCompositionPorts) panelRoot: ports.render.panelRoot, toolbarNode: ports.render.toolbarNode, goalNode: ports.render.goalNode, - messagesNode: ports.render.messagesNode, + messageStreamNode: ports.render.messageStreamNode, composerNode: ports.render.composerNode, }, }); @@ -408,8 +408,8 @@ export function createChatViewControllers(ports: ChatControllerCompositionPorts) lifecycle: { messageScrollIntent: ports.lifecycle.messageScrollIntent, }, - messages: { - pendingRequestsSignature: ports.messages.pendingRequestsSignature, + messageStream: { + pendingRequestsSignature: ports.messageStream.pendingRequestsSignature, }, composerView: { composerPlaceholder: ports.composerView.composerPlaceholder, @@ -430,7 +430,7 @@ export function createChatViewControllers(ports: ChatControllerCompositionPorts) }, ); const { pendingRequests, composerSubmit } = conversationControllers; - const { messageRenderer } = conversationControllers; + const { messageStreamRenderer } = conversationControllers; composerController = conversationControllers.composerController; const { scheduleAppServerWarmup, openView, closeView } = createConnectionLifecycleControllerGroup( { @@ -470,7 +470,7 @@ export function createChatViewControllers(ports: ChatControllerCompositionPorts) { connection, composerController, - messageRenderer, + messageStreamRenderer, serverThreads, serverMetadata, }, @@ -517,7 +517,7 @@ export function createChatViewControllers(ports: ChatControllerCompositionPorts) }, render: { controller: renderController, - messages: messageRenderer, + messageStream: messageStreamRenderer, openView, closeView, applyViewState, diff --git a/src/features/chat/conversation/composition.ts b/src/features/chat/conversation/composition.ts index 4a9850ff..07924e7e 100644 --- a/src/features/chat/conversation/composition.ts +++ b/src/features/chat/conversation/composition.ts @@ -17,9 +17,9 @@ import type { HistoryController } from "../threads/history-controller"; import type { ChatInboundController } from "../protocol/inbound/controller"; import { currentModel, runtimeConfigOrDefault } from "../runtime/effective"; import type { RuntimeSnapshot } from "../runtime/snapshot"; -import { ChatMessageRenderer } from "../ui/message-stream/renderer"; +import { MessageStreamRenderer } from "../ui/message-stream/renderer"; import type { DisplayDetailSection } from "../display/types"; -import type { ChatMessageScrollIntentController } from "../ui/message-scroll-intent-controller"; +import type { ChatMessageScrollIntentController } from "../ui/message-stream/scroll-intent-controller"; import type { ChatTurnDiffViewState } from "../turn-diff/model"; import type { ComposerMetaViewModel } from "../ui/composer"; import type { CodexPanelSettings } from "../../../settings/model"; @@ -50,7 +50,7 @@ interface ConversationSurfaceControllerGroupPorts { now: () => void; schedule: () => void; }; - messages: { + messageStream: { pendingRequestsSignature: () => string; }; composerView: { @@ -98,7 +98,7 @@ export function createConversationSurfaceControllerGroup( history: HistoryController; }, ) { - const { plugin, state, render, messages, composerView, runtime, thread, liveState, status, lifecycle, client, scroll } = context; + const { plugin, state, render, messageStream, composerView, runtime, thread, liveState, status, lifecycle, client, scroll } = context; const { app, owner, viewId } = context.obsidian; const stateStore = state.stateStore; const currentClient = client.getClient; @@ -125,7 +125,7 @@ export function createConversationSurfaceControllerGroup( toggleFast: () => void refs.runtimeSettings.toggleFastMode(), onDraftChange: liveState.refresh, onHeightChange: () => { - messageRenderer.repinMessagesToBottomIfPinned(); + messageStreamRenderer.repinMessageStreamToBottomIfPinned(); }, }); const pendingRequests = new PendingRequestController({ @@ -228,7 +228,7 @@ export function createConversationSurfaceControllerGroup( }, }); - const messageRenderer = new ChatMessageRenderer({ + const messageStreamRenderer = new MessageStreamRenderer({ obsidian: { app, owner, @@ -252,7 +252,7 @@ export function createConversationSurfaceControllerGroup( openTurnDiff: (state) => void plugin.openTurnDiff(state), }, requests: { - pendingSignature: messages.pendingRequestsSignature, + pendingSignature: messageStream.pendingRequestsSignature, pendingSnapshot: () => pendingRequests.snapshot(), pendingActions: () => pendingRequests.actions(), consumePendingAutoFocus: () => pendingRequests.consumeAutoFocus(), @@ -278,13 +278,13 @@ export function createConversationSurfaceControllerGroup( composerController.setActionHandlers({ submit: () => void composerSubmit.submit(), threadScrollFromComposer: (action) => { - messageRenderer.scrollFromComposer(action); + messageStreamRenderer.scrollFromComposer(action); }, }); return { pendingRequests, - messageRenderer, + messageStreamRenderer, composerController, composerSubmit, }; diff --git a/src/features/chat/conversation/pending-requests/controller.ts b/src/features/chat/conversation/pending-requests/controller.ts index 9049b423..9f02031a 100644 --- a/src/features/chat/conversation/pending-requests/controller.ts +++ b/src/features/chat/conversation/pending-requests/controller.ts @@ -3,8 +3,8 @@ import type { ApprovalAction, PendingApproval } from "../../protocol/server-requ import type { ChatInboundController } from "../../protocol/inbound/controller"; import { pendingRequestFocusSignature } from "./signatures"; import { pendingRequestBlockSnapshot, type PendingRequestBlockSnapshot } from "./snapshot"; -import type { PendingRequestBlockActions } from "../../ui/pending-request-block"; import { answersForPendingUserInput, type PendingUserInput } from "../../protocol/server-requests/user-input"; +import type { PendingRequestBlockActions, PendingRequestId } from "./view-model"; export interface PendingRequestControllerHost { stateStore: ChatStateStore; @@ -17,14 +17,14 @@ export interface PendingRequestControllerHost { export class PendingRequestController { private lastFocusSignature = ""; private readonly blockActions: PendingRequestBlockActions = { - resolveApproval: (approval, action) => { - this.resolveApproval(approval, action); + resolveApproval: (requestId, action) => { + this.resolveApproval(requestId, action); }, - resolveUserInput: (input) => { - this.resolveUserInput(input); + resolveUserInput: (requestId) => { + this.resolveUserInput(requestId); }, - cancelUserInput: (input) => { - this.cancelUserInput(input); + cancelUserInput: (requestId) => { + this.cancelUserInput(requestId); }, setOpenDetail: (key, open) => { this.host.stateStore.dispatch({ type: "ui/detail-open-set", key, open }); @@ -44,12 +44,16 @@ export class PendingRequestController { return this.blockActions; } - resolveApproval(approval: PendingApproval, action: ApprovalAction): void { + resolveApproval(requestId: PendingRequestId, action: ApprovalAction): void { + const approval = this.pendingApproval(requestId); + if (!approval) return; this.host.controller.resolveApproval(approval, action); this.commitRequestAction(); } - resolveUserInput(input: PendingUserInput): void { + resolveUserInput(requestId: PendingRequestId): void { + const input = this.pendingUserInput(requestId); + if (!input) return; this.host.controller.resolveUserInput( input, answersForPendingUserInput(input, pendingRequestBlockSnapshot(this.host.stateStore.getState()).userInputDrafts), @@ -57,19 +61,29 @@ export class PendingRequestController { this.commitRequestAction(); } - cancelUserInput(input: PendingUserInput): void { + cancelUserInput(requestId: PendingRequestId): void { + const input = this.pendingUserInput(requestId); + if (!input) return; this.host.controller.cancelUserInput(input); this.commitRequestAction(); } + private pendingApproval(requestId: PendingRequestId): PendingApproval | null { + return this.host.stateStore.getState().requests.approvals.find((approval) => approval.requestId === requestId) ?? null; + } + + private pendingUserInput(requestId: PendingRequestId): PendingUserInput | null { + return this.host.stateStore.getState().requests.pendingUserInputs.find((input) => input.requestId === requestId) ?? null; + } + private commitRequestAction(): void { this.host.refreshLiveState(); this.host.render(); } consumeAutoFocus(): boolean { - const state = pendingRequestBlockSnapshot(this.host.stateStore.getState()); - const signature = pendingRequestFocusSignature(state.approvals, state.pendingUserInputs); + const state = this.host.stateStore.getState(); + const signature = pendingRequestFocusSignature(state.requests.approvals, state.requests.pendingUserInputs); if (!signature) { this.lastFocusSignature = ""; return false; diff --git a/src/features/chat/conversation/pending-requests/snapshot.ts b/src/features/chat/conversation/pending-requests/snapshot.ts index 76ad2f3f..af0153c0 100644 --- a/src/features/chat/conversation/pending-requests/snapshot.ts +++ b/src/features/chat/conversation/pending-requests/snapshot.ts @@ -1,19 +1,39 @@ import type { ChatState } from "../../state/reducer"; import type { PendingApproval } from "../../protocol/server-requests/approval"; import type { PendingUserInput } from "../../protocol/server-requests/user-input"; +import { + pendingApprovalViewModel, + pendingUserInputViewModel, + type PendingApprovalViewModel, + type PendingUserInputViewModel, +} from "./view-model"; export interface PendingRequestBlockSnapshot { - approvals: readonly PendingApproval[]; - pendingUserInputs: readonly PendingUserInput[]; + approvals: readonly PendingApprovalViewModel[]; + pendingUserInputs: readonly PendingUserInputViewModel[]; userInputDrafts: ReadonlyMap; openDetails: ReadonlySet; } export function pendingRequestBlockSnapshot(state: ChatState): PendingRequestBlockSnapshot { - return { + return pendingRequestBlockSnapshotFromRequests({ approvals: state.requests.approvals, pendingUserInputs: state.requests.pendingUserInputs, userInputDrafts: state.requests.userInputDrafts, openDetails: state.ui.openDetails, + }); +} + +export function pendingRequestBlockSnapshotFromRequests(source: { + approvals: readonly PendingApproval[]; + pendingUserInputs: readonly PendingUserInput[]; + userInputDrafts: ReadonlyMap; + openDetails: ReadonlySet; +}): PendingRequestBlockSnapshot { + return { + approvals: source.approvals.map(pendingApprovalViewModel), + pendingUserInputs: source.pendingUserInputs.map(pendingUserInputViewModel), + userInputDrafts: source.userInputDrafts, + openDetails: source.openDetails, }; } diff --git a/src/features/chat/conversation/pending-requests/view-model.ts b/src/features/chat/conversation/pending-requests/view-model.ts new file mode 100644 index 00000000..53143ece --- /dev/null +++ b/src/features/chat/conversation/pending-requests/view-model.ts @@ -0,0 +1,99 @@ +import type { RequestId } from "../../../../app-server/connection/rpc-messages"; +import { + approvalActionOptions, + approvalDetails, + approvalSummary, + approvalTitle, + type ApprovalAction, + type PendingApproval, +} from "../../protocol/server-requests/approval"; +import { + questionDefaultAnswer, + userInputDraftKey, + userInputOtherDraftKey, + type PendingUserInput, +} from "../../protocol/server-requests/user-input"; + +export type PendingRequestId = RequestId; +export type PendingRequestApprovalAction = ApprovalAction; + +export interface PendingRequestApprovalOption { + label: string; + className: string; + action: PendingRequestApprovalAction; +} + +export interface PendingRequestDetailRow { + key: string; + value: string; +} + +export interface PendingApprovalViewModel { + requestId: PendingRequestId; + title: string; + summary: string; + details: PendingRequestDetailRow[]; + actions: PendingRequestApprovalOption[]; +} + +export interface PendingUserInputOptionViewModel { + label: string; + description?: string | null; +} + +export interface PendingUserInputQuestionViewModel { + id: string; + header?: string | null; + question: string; + isOther: boolean; + isSecret: boolean; + defaultAnswer: string; + draftKey: string; + otherDraftKey: string; + options: PendingUserInputOptionViewModel[] | null; +} + +export interface PendingUserInputViewModel { + requestId: PendingRequestId; + title: string; + body: string; + questions: PendingUserInputQuestionViewModel[]; +} + +export interface PendingRequestBlockActions { + resolveApproval: (requestId: PendingRequestId, action: PendingRequestApprovalAction) => void; + resolveUserInput: (requestId: PendingRequestId) => void; + cancelUserInput: (requestId: PendingRequestId) => void; + setOpenDetail?: (key: string, open: boolean) => void; + setUserInputDraft: (key: string, value: string) => void; +} + +export function pendingApprovalViewModel(approval: PendingApproval): PendingApprovalViewModel { + return { + requestId: approval.requestId, + title: approvalTitle(approval), + summary: approvalSummary(approval), + details: approvalDetails(approval), + actions: approvalActionOptions(approval), + }; +} + +export function pendingUserInputViewModel(input: PendingUserInput): PendingUserInputViewModel { + return { + requestId: input.requestId, + title: "Codex needs input", + body: `Answer ${String(input.params.questions.length)} Plan mode question${input.params.questions.length === 1 ? "" : "s"} to continue.`, + questions: input.params.questions.map((question) => ({ + id: question.id, + header: question.header, + question: question.question, + isOther: question.isOther, + isSecret: question.isSecret, + defaultAnswer: questionDefaultAnswer(question), + draftKey: userInputDraftKey(input.requestId, question.id), + otherDraftKey: userInputOtherDraftKey(input.requestId, question.id), + options: question.options, + })), + }; +} + diff --git a/src/features/chat/panel/composition.ts b/src/features/chat/panel/composition.ts index 796512ea..8c139440 100644 --- a/src/features/chat/panel/composition.ts +++ b/src/features/chat/panel/composition.ts @@ -11,7 +11,7 @@ import { closeChatView, openChatView, type ChatViewLifecycleHost } from "./view- import { createToolbarArchiveConfirmState, ToolbarPanelController } from "./regions/toolbar"; import { ChatViewRenderController } from "./view-render-controller"; import { applyChatViewState } from "./view-state"; -import type { ChatMessageRenderer } from "../ui/message-stream/renderer"; +import type { MessageStreamRenderer } from "../ui/message-stream/renderer"; import { applyCachedSharedAppServerState, type CachedSharedAppServerStateSource } from "./cached-app-server-state"; import type { ChatViewDeferredTasks, RestoredThreadState } from "../lifecycle"; import { createChatShellRenderPort } from "./shell-render"; @@ -30,7 +30,7 @@ interface ViewRenderControllerGroupPorts { panelRoot: () => HTMLElement | null; toolbarNode: () => UiNode; goalNode: () => UiNode; - messagesNode: () => UiNode; + messageStreamNode: () => UiNode; composerNode: () => UiNode; }; } @@ -45,7 +45,7 @@ export function createViewRenderControllerGroup(context: ViewRenderControllerGro showToolbar: () => plugin.settings.showToolbar, toolbarNode: context.render.toolbarNode, goalNode: context.render.goalNode, - messagesNode: context.render.messagesNode, + messageStreamNode: context.render.messageStreamNode, composerNode: context.render.composerNode, }), panelRoot: render.panelRoot, @@ -90,7 +90,7 @@ export function createConnectionLifecycleControllerGroup( refs: { connection: ConnectionManager; composerController: ChatComposerController; - messageRenderer: ChatMessageRenderer; + messageStreamRenderer: MessageStreamRenderer; serverThreads: ChatServerThreadActions; serverMetadata: ChatServerMetadataActions; }, @@ -128,7 +128,7 @@ export function createConnectionLifecycleControllerGroup( }, panelRoot: render.panelRoot, disposeMessages: () => { - refs.messageRenderer.dispose(); + refs.messageStreamRenderer.dispose(); }, disposeComposer: () => { refs.composerController.dispose(); diff --git a/src/features/chat/panel/regions/goal.ts b/src/features/chat/panel/regions/goal.ts index 0fb5efe9..ea752b93 100644 --- a/src/features/chat/panel/regions/goal.ts +++ b/src/features/chat/panel/regions/goal.ts @@ -1,4 +1,4 @@ -import type { GoalBannerActions, GoalBannerOptions } from "../../ui/goal-banner"; +import type { GoalRegionActions, GoalRegionOptions } from "../../ui/goal"; import type { ChatPanelGoalPorts } from "./ports"; export function chatPanelGoalProps( @@ -6,8 +6,8 @@ export function chatPanelGoalProps( state: ReturnType, ): { goal: ReturnType["activeThread"]["goal"]; - actions: GoalBannerActions; - options: GoalBannerOptions; + actions: GoalRegionActions; + options: GoalRegionOptions; } { const goal = state.activeThread.goal; const goalThreadId = goal?.threadId ?? null; diff --git a/src/features/chat/panel/regions/messages.ts b/src/features/chat/panel/regions/message-stream.ts similarity index 55% rename from src/features/chat/panel/regions/messages.ts rename to src/features/chat/panel/regions/message-stream.ts index d08d0105..85fa3f1f 100644 --- a/src/features/chat/panel/regions/messages.ts +++ b/src/features/chat/panel/regions/message-stream.ts @@ -1,11 +1,11 @@ import { pendingRequestsSignature as requestStateSignature } from "../../conversation/pending-requests/signatures"; -import type { ChatPanelMessagesPorts } from "./ports"; +import type { ChatPanelMessageStreamPorts } from "./ports"; -export function chatPanelMessagesNode(ports: ChatPanelMessagesPorts) { +export function chatPanelMessageStreamNode(ports: ChatPanelMessageStreamPorts) { return ports.render.node(); } -export function chatPanelPendingRequestsSignature(ports: ChatPanelMessagesPorts): string { +export function chatPanelMessageStreamPendingRequestsSignature(ports: ChatPanelMessageStreamPorts): string { const state = ports.state.chat(); return requestStateSignature(state.requests.approvals, state.requests.pendingUserInputs, state.requests.userInputDrafts); } diff --git a/src/features/chat/panel/regions/ports.ts b/src/features/chat/panel/regions/ports.ts index 7ab2a19c..d385222f 100644 --- a/src/features/chat/panel/regions/ports.ts +++ b/src/features/chat/panel/regions/ports.ts @@ -64,7 +64,7 @@ export interface ChatPanelGoalPorts extends ChatPanelStatePort { }; } -export interface ChatPanelMessagesPorts extends ChatPanelStatePort { +export interface ChatPanelMessageStreamPorts extends ChatPanelStatePort { render: { node: () => UiNode; }; diff --git a/src/features/chat/panel/regions/render.tsx b/src/features/chat/panel/regions/render.tsx index b139ed46..1f341734 100644 --- a/src/features/chat/panel/regions/render.tsx +++ b/src/features/chat/panel/regions/render.tsx @@ -2,12 +2,12 @@ import type { ComponentChild as UiNode } from "preact"; import { useComputed } from "@preact/signals"; import { useEffect, useState } from "preact/hooks"; -import { goalBannerNode } from "../../ui/goal-banner"; +import { goalRegionNode } from "../../ui/goal"; import { useChatPanelShellState } from "../../ui/shell"; import { toolbarNode } from "../../ui/toolbar"; import { chatPanelGoalProps } from "./goal"; -import { chatPanelMessagesNode } from "./messages"; -import type { ChatPanelGoalPorts, ChatPanelMessagesPorts, ChatPanelToolbarPorts } from "./ports"; +import { chatPanelMessageStreamNode } from "./message-stream"; +import type { ChatPanelGoalPorts, ChatPanelMessageStreamPorts, ChatPanelToolbarPorts } from "./ports"; import { chatPanelToolbarViewModel } from "./toolbar"; export function chatPanelToolbarRegionNode(ports: ChatPanelToolbarPorts): UiNode { @@ -18,8 +18,8 @@ export function chatPanelGoalRegionNode(ports: ChatPanelGoalPorts): UiNode { return ; } -export function chatPanelMessagesRegionNode(ports: ChatPanelMessagesPorts): UiNode { - return ; +export function chatPanelMessageStreamRegionNode(ports: ChatPanelMessageStreamPorts): UiNode { + return ; } export function chatPanelComposerRegionNode(node: () => UiNode): UiNode { @@ -44,10 +44,10 @@ function GoalRegion({ ports }: { ports: ChatPanelGoalPorts }): UiNode { ui: ui.value, }); }); - return goalBannerNode(props.value.goal, props.value.actions, props.value.options); + return goalRegionNode(props.value.goal, props.value.actions, props.value.options); } -function MessagesRegion({ ports }: { ports: ChatPanelMessagesPorts }): UiNode { +function MessageStreamRegion({ ports }: { ports: ChatPanelMessageStreamPorts }): UiNode { const { activeThread, runtime, messageStream, requests, turn, ui, renderVersion } = useChatPanelShellState(); void activeThread.value; void runtime.value; @@ -56,7 +56,7 @@ function MessagesRegion({ ports }: { ports: ChatPanelMessagesPorts }): UiNode { void turn.value; void ui.value; void renderVersion.value; - return chatPanelMessagesNode(ports); + return chatPanelMessageStreamNode(ports); } function ComposerRegion({ node }: { node: () => UiNode }): UiNode { diff --git a/src/features/chat/panel/shell-render.ts b/src/features/chat/panel/shell-render.ts index 4160c42b..956b825e 100644 --- a/src/features/chat/panel/shell-render.ts +++ b/src/features/chat/panel/shell-render.ts @@ -13,7 +13,7 @@ export function createChatShellRenderPort( showToolbar: () => boolean; toolbarNode: () => UiNode; goalNode: () => UiNode; - messagesNode: () => UiNode; + messageStreamNode: () => UiNode; composerNode: () => UiNode; }, ): ChatShellRenderPort { @@ -24,7 +24,7 @@ export function createChatShellRenderPort( showToolbar: options.showToolbar(), toolbarNode: options.toolbarNode, goalNode: options.goalNode, - messagesNode: options.messagesNode, + messageStreamNode: options.messageStreamNode, composerNode: options.composerNode, }); }, diff --git a/src/features/chat/ui/goal-banner.tsx b/src/features/chat/ui/goal.tsx similarity index 96% rename from src/features/chat/ui/goal-banner.tsx rename to src/features/chat/ui/goal.tsx index cef019fa..9070589e 100644 --- a/src/features/chat/ui/goal-banner.tsx +++ b/src/features/chat/ui/goal.tsx @@ -6,31 +6,31 @@ import { isComposerSendKey, type SendShortcut } from "../../../shared/ui/keyboar import { IconButton } from "../../../shared/ui/components"; import { syncTextareaHeight } from "../../../shared/ui/textarea-autogrow"; -export interface GoalBannerActions { +export interface GoalRegionActions { onSave: (objective: string, tokenBudget: number | null) => void; onPause: () => void; onResume: () => void; onClear: () => void; } -export interface GoalBannerOptions { +export interface GoalRegionOptions { sendShortcut: SendShortcut; editingRequested?: boolean | undefined; onEditingChange?: (editing: boolean) => void; } -export function goalBannerNode(goal: ThreadGoal | null, actions: GoalBannerActions, options: GoalBannerOptions): UiNode { - return ; +export function goalRegionNode(goal: ThreadGoal | null, actions: GoalRegionActions, options: GoalRegionOptions): UiNode { + return ; } -function GoalBanner({ +function GoalRegion({ goal, actions, options, }: { goal: ThreadGoal | null; - actions: GoalBannerActions; - options: GoalBannerOptions; + actions: GoalRegionActions; + options: GoalRegionOptions; }): UiNode { const [editing, setEditing] = useState(false); const [objective, setObjective] = useState(goal?.objective ?? ""); diff --git a/src/features/chat/ui/message-content-events.ts b/src/features/chat/ui/message-stream/content-events.ts similarity index 100% rename from src/features/chat/ui/message-content-events.ts rename to src/features/chat/ui/message-stream/content-events.ts diff --git a/src/features/chat/ui/message-stream/context-model.ts b/src/features/chat/ui/message-stream/context-model.ts deleted file mode 100644 index b7ce0eda..00000000 --- a/src/features/chat/ui/message-stream/context-model.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { ChatState } from "../../state/reducer"; -import { chatTurnBusy } from "../../state/reducer"; -import type { DisplayItem } from "../../display/types"; -import { implementPlanCandidateFromState } from "../../state/selectors"; -import { - forkCandidatesFromItems, - isForkCandidateItem, - isRollbackCandidateItem, - rollbackCandidateFromItems, -} from "../../display/item-actions"; -import type { MessageStreamContext } from "./context"; -import type { ChatMessageStreamContextPort } from "./ports"; - -export function createMessageStreamContext(state: ChatState, port: ChatMessageStreamContextPort): MessageStreamContext { - const busy = chatTurnBusy(state); - const rollbackCandidate = busy ? null : rollbackCandidateFromItems(state.messageStream.displayItems); - const forkCandidates = busy ? [] : forkCandidatesFromItems(state.messageStream.displayItems); - const implementPlanCandidate = implementPlanCandidateFromState(state); - - return { - activeThreadId: state.activeThread.id, - turnLifecycle: state.turn.lifecycle, - historyCursor: state.messageStream.historyCursor, - loadingHistory: state.messageStream.loadingHistory, - displayItems: state.messageStream.displayItems, - turnDiffs: state.messageStream.turnDiffs, - workspaceRoot: state.activeThread.cwd ?? port.vaultPath, - openDetails: state.ui.openDetails, - onDetailsToggle: port.setOpenDetail, - loadOlderTurns: port.loadOlderTurns, - renderMarkdown: port.renderMarkdown, - copyText: port.copyMessageText, - canImplementPlanItem: (item: DisplayItem) => item.id === implementPlanCandidate?.id, - onImplementPlanItem: (item) => { - port.actions.implementPlan(item); - }, - canRollbackItem: (item: DisplayItem) => isRollbackCandidateItem(item, rollbackCandidate), - onRollbackItem: () => { - if (state.activeThread.id) port.actions.rollbackThread(state.activeThread.id); - }, - canForkItem: (item: DisplayItem) => isForkCandidateItem(item, forkCandidates), - onForkItem: (item, archiveSource) => { - if (state.activeThread.id && item.turnId) { - port.actions.forkThreadFromTurn(state.activeThread.id, item.turnId, archiveSource); - } - }, - openTurnDiff: (turnDiffState) => { - port.actions.openTurnDiff(turnDiffState); - }, - pendingRequests: { - signature: port.requests.pendingSignature(), - snapshot: port.requests.pendingSnapshot, - actions: port.requests.pendingActions, - consumeAutoFocus: port.requests.consumePendingAutoFocus, - }, - }; -} diff --git a/src/features/chat/ui/message-stream/context.ts b/src/features/chat/ui/message-stream/context.ts index 53b8d177..ba6598d6 100644 --- a/src/features/chat/ui/message-stream/context.ts +++ b/src/features/chat/ui/message-stream/context.ts @@ -2,8 +2,8 @@ import type { ComponentChild as UiNode } from "preact"; import type { ChatTurnLifecycleState } from "../../state/reducer"; import type { PendingRequestBlockSnapshot } from "../../conversation/pending-requests/snapshot"; +import type { PendingRequestBlockActions } from "../../conversation/pending-requests/view-model"; import type { DisplayItem } from "../../display/types"; -import type { PendingRequestBlockActions } from "../pending-request-block"; import type { ChatTurnDiffViewState } from "../../turn-diff/model"; export interface MessageStreamBlock { diff --git a/src/features/chat/ui/message-stream/markdown-renderer.ts b/src/features/chat/ui/message-stream/markdown-renderer.ts index 9e0f2582..40fb27d0 100644 --- a/src/features/chat/ui/message-stream/markdown-renderer.ts +++ b/src/features/chat/ui/message-stream/markdown-renderer.ts @@ -1,7 +1,7 @@ -import { MarkdownRenderer, type App, type Component } from "obsidian"; +import { MarkdownRenderer, Notice, type App, type Component } from "obsidian"; -import { notifyMessageContentRendered } from "../message-content-events"; -import { bindRenderedMarkdownFileLinks, bindRenderedWikiLinks } from "./rendered-markdown-links"; +import { isAbsoluteFileHref, vaultFileLinkTarget, vaultRelativeFileLinkTarget } from "../../../../shared/obsidian/file-links"; +import { notifyMessageContentRendered } from "./content-events"; export interface MarkdownMessageRendererOptions { app: App; @@ -28,3 +28,40 @@ export class MarkdownMessageRenderer { }); } } + +export interface RenderedMarkdownLinkContext { + app: App; + vaultPath: string; +} + +export function bindRenderedWikiLinks(parent: HTMLElement, sourcePath: string, context: RenderedMarkdownLinkContext): void { + parent.querySelectorAll("a.internal-link").forEach((link) => { + link.addClass("codex-panel__wikilink"); + link.onclick = (event) => { + event.preventDefault(); + const href = link.getAttribute("data-href") ?? link.getAttribute("href") ?? link.textContent; + const target = vaultRelativeFileLinkTarget(context.vaultPath, context.app.vault.configDir, href) ?? href; + if (target === href && isAbsoluteFileHref(href)) { + new Notice("Cannot open files outside the vault."); + return; + } + if (target.trim().length > 0) { + void context.app.workspace.openLinkText(target, sourcePath, false); + } + }; + }); +} + +export function bindRenderedMarkdownFileLinks(parent: HTMLElement, sourcePath: string, context: RenderedMarkdownLinkContext): void { + parent.querySelectorAll("a[href]:not(.internal-link)").forEach((link) => { + const href = link.getAttribute("href") ?? ""; + const target = vaultFileLinkTarget(context.app, context.vaultPath, href); + if (!target) return; + + link.addClass("codex-panel__filelink"); + link.onclick = (event) => { + event.preventDefault(); + void context.app.workspace.openLinkText(target, sourcePath, false); + }; + }); +} diff --git a/src/features/chat/ui/pending-request-block.tsx b/src/features/chat/ui/message-stream/pending-request-block.tsx similarity index 75% rename from src/features/chat/ui/pending-request-block.tsx rename to src/features/chat/ui/message-stream/pending-request-block.tsx index 378c039c..3c25bc82 100644 --- a/src/features/chat/ui/pending-request-block.tsx +++ b/src/features/chat/ui/message-stream/pending-request-block.tsx @@ -2,37 +2,17 @@ import type { ComponentChild as UiNode } from "preact"; import { useEffect, useLayoutEffect, useRef, useState } from "preact/hooks"; import { - approvalActionOptions, - approvalDetails, - approvalSummary, - approvalTitle, - type ApprovalAction, - type PendingApproval, -} from "../protocol/server-requests/approval"; -import type { PendingUserInput } from "../protocol/server-requests/user-input"; -import { questionDefaultAnswer } from "../protocol/server-requests/user-input"; + type PendingApprovalViewModel, + type PendingRequestBlockActions, + type PendingUserInputQuestionViewModel, + type PendingUserInputViewModel, +} from "../../conversation/pending-requests/view-model"; import { createWorkMessageClassName } from "./work-message"; -type PendingUserInputRequestId = PendingUserInput["requestId"]; - -export interface PendingRequestBlockActions { - resolveApproval: (approval: PendingApproval, action: ApprovalAction) => void; - resolveUserInput: (input: PendingUserInput) => void; - cancelUserInput: (input: PendingUserInput) => void; - setOpenDetail?: (key: string, open: boolean) => void; - setUserInputDraft: (key: string, value: string) => void; -} - -export interface PendingRequestBlockDrafts { - values: ReadonlyMap; - draftKey: (requestId: PendingUserInputRequestId, questionId: string) => string; - otherDraftKey: (requestId: PendingUserInputRequestId, questionId: string) => string; -} - export function pendingRequestBlockNode( - approvals: readonly PendingApproval[], - pendingUserInputs: readonly PendingUserInput[], - drafts: PendingRequestBlockDrafts, + approvals: readonly PendingApprovalViewModel[], + pendingUserInputs: readonly PendingUserInputViewModel[], + userInputDrafts: ReadonlyMap, openDetails: ReadonlySet, actions: PendingRequestBlockActions, autoFocusRequested = false, @@ -43,7 +23,7 @@ export function pendingRequestBlockNode( ; openDetails: ReadonlySet; actions: PendingRequestBlockActions; autoFocusRequested: boolean; @@ -87,7 +67,7 @@ function PendingRequestBlock({ ))} {pendingUserInputs.map((input) => ( - + ))} ); @@ -114,25 +94,25 @@ function ApprovalCard({ openDetails, actions, }: { - approval: PendingApproval; + approval: PendingApprovalViewModel; openDetails: ReadonlySet; actions: PendingRequestBlockActions; }): UiNode { return (
-
{approvalTitle(approval)}
-
{approvalSummary(approval)}
+
{approval.title}
+
{approval.summary}
- {approvalActionOptions(approval).map((option) => ( + {approval.actions.map((option) => ( { - actions.resolveApproval(approval, option.action); + actions.resolveApproval(approval.requestId, option.action); }} /> ))} @@ -146,7 +126,7 @@ function ApprovalDetails({ openDetails, actions, }: { - approval: PendingApproval; + approval: PendingApprovalViewModel; openDetails: ReadonlySet; actions: PendingRequestBlockActions; }): UiNode { @@ -161,7 +141,7 @@ function ApprovalDetails({ > Request details
- {approvalDetails(approval).map((row) => ( + {approval.details.map((row) => ( ))}
@@ -171,35 +151,33 @@ function ApprovalDetails({ function UserInputCard({ input, - drafts, + userInputDrafts, actions, }: { - input: PendingUserInput; - drafts: PendingRequestBlockDrafts; + input: PendingUserInputViewModel; + userInputDrafts: ReadonlyMap; actions: PendingRequestBlockActions; }): UiNode { return (
-
Codex needs input
-
- Answer {String(input.params.questions.length)} Plan mode question{input.params.questions.length === 1 ? "" : "s"} to continue. -
- +
{input.title}
+
{input.body}
+
{ - actions.resolveUserInput(input); + actions.resolveUserInput(input.requestId); }} /> { - actions.cancelUserInput(input); + actions.cancelUserInput(input.requestId); }} />
@@ -213,18 +191,17 @@ function PendingRequestCard({ className, children }: { className: string; childr function UserInputQuestions({ input, - drafts, + userInputDrafts, actions, }: { - input: PendingUserInput; - drafts: PendingRequestBlockDrafts; + input: PendingUserInputViewModel; + userInputDrafts: ReadonlyMap; actions: PendingRequestBlockActions; }): UiNode { return ( <> - {input.params.questions.map((question) => { - const draftKey = drafts.draftKey(input.requestId, question.id); - const current = drafts.values.get(draftKey) ?? questionDefaultAnswer(question); + {input.questions.map((question) => { + const current = userInputDrafts.get(question.draftKey) ?? question.defaultAnswer; return (
{question.header ?
{question.header}
: null} @@ -243,7 +220,7 @@ function UserInputQuestions({ value={option.label} checked={current === option.label} onChange={(event) => { - if (event.currentTarget.checked) actions.setUserInputDraft(draftKey, option.label); + if (event.currentTarget.checked) actions.setUserInputDraft(question.draftKey, option.label); }} /> {option.label} @@ -255,25 +232,22 @@ function UserInputQuestions({ })} {question.isOther ? ( option.label))} - drafts={drafts} + question={question} + userInputDrafts={userInputDrafts} actions={actions} /> ) : null} ) : ( )} @@ -286,30 +260,28 @@ function UserInputQuestions({ } function OtherUserInputOption({ - input, - questionId, questionText, groupName, current, optionLabels, - drafts, + question, + userInputDrafts, actions, }: { - input: PendingUserInput; - questionId: string; questionText: string; groupName: string; current: string; optionLabels: ReadonlySet; - drafts: PendingRequestBlockDrafts; + question: PendingUserInputQuestionViewModel; + userInputDrafts: ReadonlyMap; actions: PendingRequestBlockActions; }): UiNode { - const draftKey = drafts.draftKey(input.requestId, questionId); - const otherKey = drafts.otherDraftKey(input.requestId, questionId); - const otherValue = drafts.values.get(otherKey) ?? ""; + const draftKey = question.draftKey; + const otherKey = question.otherDraftKey; + const otherValue = userInputDrafts.get(otherKey) ?? ""; const [inputValue, setInputValue] = useState(otherValue); const composingRef = useRef(false); - const otherSelected = drafts.values.has(draftKey) && current === otherValue && !optionLabels.has(current); + const otherSelected = userInputDrafts.has(draftKey) && current === otherValue && !optionLabels.has(current); useEffect(() => { if (!composingRef.current) setInputValue(otherValue); }, [otherValue]); @@ -369,23 +341,18 @@ function OtherUserInputOption({ } function FreeformUserInput({ - input, - questionId, questionText, isSecret, current, - drafts, + question, actions, }: { - input: PendingUserInput; - questionId: string; questionText: string; isSecret: boolean; current: string; - drafts: PendingRequestBlockDrafts; + question: PendingUserInputQuestionViewModel; actions: PendingRequestBlockActions; }): UiNode { - const draftKey = drafts.draftKey(input.requestId, questionId); return ( { - actions.setUserInputDraft(draftKey, event.currentTarget.value); + actions.setUserInputDraft(question.draftKey, event.currentTarget.value); }} /> ); diff --git a/src/features/chat/ui/message-stream/ports.ts b/src/features/chat/ui/message-stream/ports.ts index 79b11d6c..ee107957 100644 --- a/src/features/chat/ui/message-stream/ports.ts +++ b/src/features/chat/ui/message-stream/ports.ts @@ -1,8 +1,8 @@ import type { ChatAction, ChatState } from "../../state/reducer"; import type { PendingRequestBlockSnapshot } from "../../conversation/pending-requests/snapshot"; +import type { PendingRequestBlockActions } from "../../conversation/pending-requests/view-model"; import type { DisplayItem } from "../../display/types"; import type { ChatTurnDiffViewState } from "../../turn-diff/model"; -import type { PendingRequestBlockActions } from "../pending-request-block"; export interface ChatMessageStreamActionPort { rollbackThread: (threadId: string) => void; diff --git a/src/features/chat/ui/message-stream/render-model.ts b/src/features/chat/ui/message-stream/render-model.ts deleted file mode 100644 index 5af87431..00000000 --- a/src/features/chat/ui/message-stream/render-model.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { ChatState } from "../../state/reducer"; -import type { MessageStreamScrollIntent, MessageStreamVirtualizerHandle } from "../message-virtualizer"; -import { messageStreamBlocks } from "./stream-blocks"; -import { createMessageStreamContext } from "./context-model"; -import type { ChatMessageStreamContextPort } from "./ports"; -import type { MessageStreamRenderState } from "./render"; - -export interface MessageStreamRenderStateOptions { - state: ChatState; - contextPort: ChatMessageStreamContextPort; - consumeScrollIntent: () => MessageStreamScrollIntent; - registerVirtualizer: (virtualizer: MessageStreamVirtualizerHandle) => () => void; -} - -export function createMessageStreamRenderState(options: MessageStreamRenderStateOptions): MessageStreamRenderState { - return { - blocks: messageStreamBlocks(createMessageStreamContext(options.state, options.contextPort)), - consumeScrollIntent: options.consumeScrollIntent, - registerVirtualizer: options.registerVirtualizer, - }; -} diff --git a/src/features/chat/ui/message-stream/rendered-markdown-links.ts b/src/features/chat/ui/message-stream/rendered-markdown-links.ts deleted file mode 100644 index b0590ccd..00000000 --- a/src/features/chat/ui/message-stream/rendered-markdown-links.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Notice, type App } from "obsidian"; - -import { isAbsoluteFileHref, vaultFileLinkTarget, vaultRelativeFileLinkTarget } from "../../../../shared/obsidian/file-links"; - -export interface RenderedMarkdownLinkContext { - app: App; - vaultPath: string; -} - -export function bindRenderedWikiLinks(parent: HTMLElement, sourcePath: string, context: RenderedMarkdownLinkContext): void { - parent.querySelectorAll("a.internal-link").forEach((link) => { - link.addClass("codex-panel__wikilink"); - link.onclick = (event) => { - event.preventDefault(); - const href = link.getAttribute("data-href") ?? link.getAttribute("href") ?? link.textContent; - const target = vaultRelativeFileLinkTarget(context.vaultPath, context.app.vault.configDir, href) ?? href; - if (target === href && isAbsoluteFileHref(href)) { - new Notice("Cannot open files outside the vault."); - return; - } - if (target.trim().length > 0) { - void context.app.workspace.openLinkText(target, sourcePath, false); - } - }; - }); -} - -export function bindRenderedMarkdownFileLinks(parent: HTMLElement, sourcePath: string, context: RenderedMarkdownLinkContext): void { - parent.querySelectorAll("a[href]:not(.internal-link)").forEach((link) => { - const href = link.getAttribute("href") ?? ""; - const target = vaultFileLinkTarget(context.app, context.vaultPath, href); - if (!target) return; - - link.addClass("codex-panel__filelink"); - link.onclick = (event) => { - event.preventDefault(); - void context.app.workspace.openLinkText(target, sourcePath, false); - }; - }); -} diff --git a/src/features/chat/ui/message-stream/renderer.ts b/src/features/chat/ui/message-stream/renderer.ts index cd55cb8a..c377a77f 100644 --- a/src/features/chat/ui/message-stream/renderer.ts +++ b/src/features/chat/ui/message-stream/renderer.ts @@ -2,51 +2,60 @@ import type { App, Component } from "obsidian"; import type { ComponentChild as UiNode } from "preact"; import { copyTextWithNotice } from "../../../../shared/ui/clipboard"; -import type { ChatAction, ChatState, ChatStateStore } from "../../state/reducer"; +import { chatTurnBusy, type ChatAction, type ChatState, type ChatStateStore } from "../../state/reducer"; import type { ComposerBoundaryScrollAction } from "../../conversation/composer/boundary-scroll"; -import type { MessageStreamScrollIntent, MessageStreamVirtualizerHandle } from "../message-virtualizer"; +import type { MessageStreamScrollIntent, MessageStreamVirtualizerHandle } from "./virtualizer"; import type { ChatMessageStreamActionPort, ChatMessageStreamContextPort, ChatMessageStreamRequestPort } from "./ports"; import { createMessageStreamContextPort } from "./ports"; import { MarkdownMessageRenderer } from "./markdown-renderer"; -import { messageStreamBlocksNode, type MessageStreamRenderState } from "./render"; -import { createMessageStreamRenderState } from "./render-model"; +import { messageStreamViewportNode, type MessageStreamViewportState } from "./viewport"; +import type { DisplayItem } from "../../display/types"; +import { implementPlanCandidateFromState } from "../../state/selectors"; +import { + forkCandidatesFromItems, + isForkCandidateItem, + isRollbackCandidateItem, + rollbackCandidateFromItems, +} from "../../display/item-actions"; +import { messageStreamBlocks } from "./stream-blocks"; +import type { MessageStreamContext } from "./context"; -interface ChatMessageRendererObsidianPort { +interface MessageStreamRendererObsidianPort { app: App; owner: Component; } -interface ChatMessageRendererStatePort { +interface MessageStreamRendererStatePort { store: ChatStateStore; } -interface ChatMessageRendererWorkspacePort { +interface MessageStreamRendererWorkspacePort { vaultPath: string; } -interface ChatMessageRendererScrollPort { +interface MessageStreamRendererScrollPort { consumeIntent: () => MessageStreamScrollIntent; } -interface ChatMessageRendererHistoryPort { +interface MessageStreamRendererHistoryPort { loadOlderTurns: () => void; } -export interface ChatMessageRendererOptions { - obsidian: ChatMessageRendererObsidianPort; - state: ChatMessageRendererStatePort; - workspace: ChatMessageRendererWorkspacePort; - scroll: ChatMessageRendererScrollPort; - history: ChatMessageRendererHistoryPort; +export interface MessageStreamRendererOptions { + obsidian: MessageStreamRendererObsidianPort; + state: MessageStreamRendererStatePort; + workspace: MessageStreamRendererWorkspacePort; + scroll: MessageStreamRendererScrollPort; + history: MessageStreamRendererHistoryPort; actions: ChatMessageStreamActionPort; requests: ChatMessageStreamRequestPort; } -export class ChatMessageRenderer { +export class MessageStreamRenderer { private messageVirtualizer: MessageStreamVirtualizerHandle | null = null; private readonly markdownRenderer: MarkdownMessageRenderer; - constructor(private readonly options: ChatMessageRendererOptions) { + constructor(private readonly options: MessageStreamRendererOptions) { this.markdownRenderer = new MarkdownMessageRenderer({ app: options.obsidian.app, owner: options.obsidian.owner, @@ -64,16 +73,15 @@ export class ChatMessageRenderer { renderNode(): UiNode { const state = this.state; - return messageStreamBlocksNode(this.renderStateFor(state)); + return messageStreamViewportNode(this.renderStateFor(state)); } - private renderStateFor(state: ChatState): MessageStreamRenderState { - return createMessageStreamRenderState({ - state, - contextPort: this.messageStreamPort(), + private renderStateFor(state: ChatState): MessageStreamViewportState { + return { + blocks: messageStreamBlocks(this.messageStreamContext(state, this.messageStreamPort())), consumeScrollIntent: this.options.scroll.consumeIntent, registerVirtualizer: this.registerVirtualizer, - }); + }; } private messageStreamPort(): ChatMessageStreamContextPort { @@ -95,6 +103,51 @@ export class ChatMessageRenderer { }); } + private messageStreamContext(state: ChatState, port: ChatMessageStreamContextPort): MessageStreamContext { + const busy = chatTurnBusy(state); + const rollbackCandidate = busy ? null : rollbackCandidateFromItems(state.messageStream.displayItems); + const forkCandidates = busy ? [] : forkCandidatesFromItems(state.messageStream.displayItems); + const implementPlanCandidate = implementPlanCandidateFromState(state); + + return { + activeThreadId: state.activeThread.id, + turnLifecycle: state.turn.lifecycle, + historyCursor: state.messageStream.historyCursor, + loadingHistory: state.messageStream.loadingHistory, + displayItems: state.messageStream.displayItems, + turnDiffs: state.messageStream.turnDiffs, + workspaceRoot: state.activeThread.cwd ?? port.vaultPath, + openDetails: state.ui.openDetails, + onDetailsToggle: port.setOpenDetail, + loadOlderTurns: port.loadOlderTurns, + renderMarkdown: port.renderMarkdown, + copyText: port.copyMessageText, + canImplementPlanItem: (item: DisplayItem) => item.id === implementPlanCandidate?.id, + onImplementPlanItem: (item) => { + port.actions.implementPlan(item); + }, + canRollbackItem: (item: DisplayItem) => isRollbackCandidateItem(item, rollbackCandidate), + onRollbackItem: () => { + if (state.activeThread.id) port.actions.rollbackThread(state.activeThread.id); + }, + canForkItem: (item: DisplayItem) => isForkCandidateItem(item, forkCandidates), + onForkItem: (item, archiveSource) => { + if (state.activeThread.id && item.turnId) { + port.actions.forkThreadFromTurn(state.activeThread.id, item.turnId, archiveSource); + } + }, + openTurnDiff: (turnDiffState) => { + port.actions.openTurnDiff(turnDiffState); + }, + pendingRequests: { + signature: port.requests.pendingSignature(), + snapshot: port.requests.pendingSnapshot, + actions: port.requests.pendingActions, + consumeAutoFocus: port.requests.consumePendingAutoFocus, + }, + }; + } + dispose(): void { this.messageVirtualizer = null; } @@ -107,11 +160,11 @@ export class ChatMessageRenderer { } } - forceMessagesToBottom(): void { + forceMessageStreamToBottom(): void { this.messageVirtualizer?.pinToBottom(); } - repinMessagesToBottomIfPinned(): void { + repinMessageStreamToBottomIfPinned(): void { this.messageVirtualizer?.repinToBottomIfPinned(); } diff --git a/src/features/chat/ui/message-scroll-intent-controller.ts b/src/features/chat/ui/message-stream/scroll-intent-controller.ts similarity index 85% rename from src/features/chat/ui/message-scroll-intent-controller.ts rename to src/features/chat/ui/message-stream/scroll-intent-controller.ts index c1505934..c9058b99 100644 --- a/src/features/chat/ui/message-scroll-intent-controller.ts +++ b/src/features/chat/ui/message-stream/scroll-intent-controller.ts @@ -1,4 +1,4 @@ -import type { MessageStreamScrollIntent } from "../ui/message-virtualizer"; +import type { MessageStreamScrollIntent } from "./virtualizer"; export class ChatMessageScrollIntentController { private nextIntent: MessageStreamScrollIntent = "auto"; diff --git a/src/features/chat/ui/message-stream/stream-blocks.tsx b/src/features/chat/ui/message-stream/stream-blocks.tsx index e78d3595..e99b4c0c 100644 --- a/src/features/chat/ui/message-stream/stream-blocks.tsx +++ b/src/features/chat/ui/message-stream/stream-blocks.tsx @@ -3,12 +3,11 @@ import { useLayoutEffect, useState } from "preact/hooks"; import { activeTurnId } from "../../state/reducer"; import { displayBlocksForItems } from "../../display/stream/blocks"; -import type { ToolResultDisplayItem } from "../tool-result-view"; +import type { ToolResultDisplayItem } from "./tool-result-view-model"; import type { DisplayBlock, DisplayItem } from "../../display/types"; -import { userInputDraftKey, userInputOtherDraftKey } from "../../protocol/server-requests/user-input"; -import { pendingRequestBlockNode } from "../pending-request-block"; -import { toolResultNode } from "../tool-result"; -import { activeAgentRunSummaryBlock, agentRunSummaryNode, workItemNode, type WorkItemDisplayItem } from "../work-items"; +import { pendingRequestBlockNode } from "./pending-request-block"; +import { toolResultNode } from "./tool-result"; +import { activeAgentRunSummaryBlock, agentRunSummaryNode, workItemNode, type WorkItemDisplayItem } from "./work-items"; import type { MessageStreamBlock, MessageStreamContext, TextDisplayItem } from "./context"; import { textItemNode } from "./text-item"; @@ -92,11 +91,7 @@ function bottomLiveBlocks(context: MessageStreamContext, activeTurn: string | nu node: pendingRequestBlockNode( snapshot.approvals, snapshot.pendingUserInputs, - { - values: snapshot.userInputDrafts, - draftKey: userInputDraftKey, - otherDraftKey: userInputOtherDraftKey, - }, + snapshot.userInputDrafts, snapshot.openDetails, context.pendingRequests.actions(), false, diff --git a/src/features/chat/ui/message-stream/text-item.tsx b/src/features/chat/ui/message-stream/text-item.tsx index 838ba590..41a90edf 100644 --- a/src/features/chat/ui/message-stream/text-item.tsx +++ b/src/features/chat/ui/message-stream/text-item.tsx @@ -2,7 +2,7 @@ import { type ComponentChild as UiNode, type Ref } from "preact"; import { useEffect, useLayoutEffect, useRef, useState } from "preact/hooks"; import type { DisplayItem, ExecutionState } from "../../display/types"; -import { MESSAGE_CONTENT_RENDERED_EVENT } from "../message-content-events"; +import { MESSAGE_CONTENT_RENDERED_EVENT } from "./content-events"; import type { TextItemContentContext, TextItemContext, TextDisplayItem } from "./context"; import { TextItemHeader } from "./text-item-actions"; import { AutoReviewSummaries, EditedFiles, MentionedFiles, TextItemDetails, ReferencedThread, SystemDetails } from "./text-item-metadata"; diff --git a/src/features/chat/ui/tool-result-view.ts b/src/features/chat/ui/message-stream/tool-result-view-model.ts similarity index 97% rename from src/features/chat/ui/tool-result-view.ts rename to src/features/chat/ui/message-stream/tool-result-view-model.ts index f4417b42..2ca05b95 100644 --- a/src/features/chat/ui/tool-result-view.ts +++ b/src/features/chat/ui/message-stream/tool-result-view-model.ts @@ -1,5 +1,5 @@ -import { pathRelativeToRoot } from "../display/details/path-labels"; -import { definedProp } from "../../../utils"; +import { pathRelativeToRoot } from "../../display/details/path-labels"; +import { definedProp } from "../../../../utils"; import type { ApprovalResultDisplayItem, CommandDisplayItem, @@ -12,7 +12,7 @@ import type { HookDisplayItem, ReviewResultDisplayItem, ToolCallDisplayItem, -} from "../display/types"; +} from "../../display/types"; export type ToolResultDisplayItem = | CommandDisplayItem diff --git a/src/features/chat/ui/tool-result.tsx b/src/features/chat/ui/message-stream/tool-result.tsx similarity index 98% rename from src/features/chat/ui/tool-result.tsx rename to src/features/chat/ui/message-stream/tool-result.tsx index e8167b0b..851d163e 100644 --- a/src/features/chat/ui/tool-result.tsx +++ b/src/features/chat/ui/message-stream/tool-result.tsx @@ -1,8 +1,8 @@ import type { ComponentChild as UiNode } from "preact"; import { useLayoutEffect, useRef, useState } from "preact/hooks"; -import { toolResultView, type ToolResultDetailSection, type ToolResultDisplayItem, type ToolResultView } from "./tool-result-view"; -import { renderRawDiffLines } from "../../../shared/diff/render"; +import { toolResultView, type ToolResultDetailSection, type ToolResultDisplayItem, type ToolResultView } from "./tool-result-view-model"; +import { renderRawDiffLines } from "../../../../shared/diff/render"; export interface ToolResultRenderContext { workspaceRoot?: string | null; diff --git a/src/features/chat/ui/message-stream/render.tsx b/src/features/chat/ui/message-stream/viewport.tsx similarity index 90% rename from src/features/chat/ui/message-stream/render.tsx rename to src/features/chat/ui/message-stream/viewport.tsx index 924d15c6..f6964340 100644 --- a/src/features/chat/ui/message-stream/render.tsx +++ b/src/features/chat/ui/message-stream/viewport.tsx @@ -1,24 +1,24 @@ import type { ComponentChild as UiNode } from "preact"; import { useCallback, useLayoutEffect, useRef } from "preact/hooks"; -import { type MessageStreamScrollIntent, type MessageStreamVirtualizerHandle, useMessageStreamVirtualizer } from "../message-virtualizer"; -import { MESSAGE_CONTENT_RENDERED_EVENT } from "../message-content-events"; +import { type MessageStreamScrollIntent, type MessageStreamVirtualizerHandle, useMessageStreamVirtualizer } from "./virtualizer"; +import { MESSAGE_CONTENT_RENDERED_EVENT } from "./content-events"; import type { MessageStreamBlock } from "./context"; const MESSAGE_BLOCK_ESTIMATE_SIZE = 96; const MESSAGE_STREAM_INITIAL_RENDER_LIMIT = 32; -export interface MessageStreamRenderState { +export interface MessageStreamViewportState { blocks: MessageStreamBlock[]; consumeScrollIntent: () => MessageStreamScrollIntent; registerVirtualizer?: (virtualizer: MessageStreamVirtualizerHandle) => () => void; } -export function messageStreamBlocksNode(state: MessageStreamRenderState): UiNode { - return ; +export function messageStreamViewportNode(state: MessageStreamViewportState): UiNode { + return ; } -function MessageStreamBlocks({ state }: { state: MessageStreamRenderState }): UiNode { +function MessageStreamViewport({ state }: { state: MessageStreamViewportState }): UiNode { const { blocks, consumeScrollIntent, registerVirtualizer } = state; const scrollElementRef = useRef(null); const virtualizer = useMessageStreamVirtualizer({ blocks, consumeScrollIntent, registerVirtualizer, scrollElementRef }); @@ -31,7 +31,7 @@ function MessageStreamBlocks({ state }: { state: MessageStreamRenderState }): Ui ); return ( -
+
{virtualItems.map((virtualItem) => ( UiNode; goalNode: () => UiNode; - messagesNode: () => UiNode; + messageStreamNode: () => UiNode; composerNode: () => UiNode; } @@ -130,7 +130,7 @@ function ChatPanelShell({ showToolbar, toolbarNode, goalNode, - messagesNode, + messageStreamNode, composerNode, shellState, }: ChatPanelShellProps & { shellState: ChatPanelShellState }): UiNode { @@ -139,7 +139,7 @@ function ChatPanelShell({ {showToolbar ?
{toolbarNode()}
: null}
{goalNode()}
- {messagesNode()} + {messageStreamNode()}
{composerNode()}
diff --git a/src/features/chat/view.ts b/src/features/chat/view.ts index e7f26223..46c2a43c 100644 --- a/src/features/chat/view.ts +++ b/src/features/chat/view.ts @@ -23,16 +23,16 @@ import { codexPanelDisplayTitle, getThreadTitle } from "../../domain/threads/mod import { connectionDiagnosticsModel } from "./panel/regions/toolbar"; import { openPanelTurnLifecycle } from "./panel/snapshot"; import { ChatConnectionWorkTracker, ChatResumeWorkTracker, ChatViewDeferredTasks } from "./lifecycle"; -import { ChatMessageScrollIntentController } from "./ui/message-scroll-intent-controller"; +import { ChatMessageScrollIntentController } from "./ui/message-stream/scroll-intent-controller"; import type { ChatControllerCompositionPorts } from "./composition-ports"; import { createChatViewControllers, type ChatViewControllers } from "./composition"; -import type { ChatPanelComposerPorts, ChatPanelGoalPorts, ChatPanelMessagesPorts, ChatPanelToolbarPorts } from "./panel/regions/ports"; +import type { ChatPanelComposerPorts, ChatPanelGoalPorts, ChatPanelMessageStreamPorts, ChatPanelToolbarPorts } from "./panel/regions/ports"; import { chatPanelComposerMetaViewModel, chatPanelComposerPlaceholder } from "./panel/regions/composer"; -import { chatPanelPendingRequestsSignature } from "./panel/regions/messages"; +import { chatPanelMessageStreamPendingRequestsSignature } from "./panel/regions/message-stream"; import { chatPanelComposerRegionNode, chatPanelGoalRegionNode, - chatPanelMessagesRegionNode, + chatPanelMessageStreamRegionNode, chatPanelToolbarRegionNode, } from "./panel/regions/render"; @@ -49,7 +49,7 @@ export class CodexChatView extends ItemView { private readonly messageScrollIntent: ChatMessageScrollIntentController; private readonly toolbarPorts: ChatPanelToolbarPorts; private readonly goalPorts: ChatPanelGoalPorts; - private readonly messagesPorts: ChatPanelMessagesPorts; + private readonly messageStreamPorts: ChatPanelMessageStreamPorts; private readonly composerPorts: ChatPanelComposerPorts; private readonly connectionWork = new ChatConnectionWorkTracker(); private readonly resumeWork: ChatResumeWorkTracker; @@ -68,7 +68,7 @@ export class CodexChatView extends ItemView { const panelPorts = this.createPanelRegionPorts(this.controllers); this.toolbarPorts = panelPorts.toolbar; this.goalPorts = panelPorts.goal; - this.messagesPorts = panelPorts.messages; + this.messageStreamPorts = panelPorts.messageStream; this.composerPorts = panelPorts.composer; } @@ -167,7 +167,7 @@ export class CodexChatView extends ItemView { panelRoot: () => this.panelRoot(), toolbarNode: () => chatPanelToolbarRegionNode(this.toolbarPorts), goalNode: () => chatPanelGoalRegionNode(this.goalPorts), - messagesNode: () => chatPanelMessagesRegionNode(this.messagesPorts), + messageStreamNode: () => chatPanelMessageStreamRegionNode(this.messageStreamPorts), composerNode: () => chatPanelComposerRegionNode(() => this.controllers.composer.controller.renderNode()), closeToolbarPanelOnOutsidePointer: (event) => { this.closeToolbarPanelOnOutsidePointer(event); @@ -176,8 +176,8 @@ export class CodexChatView extends ItemView { this.scheduleRender(); }, }, - messages: { - pendingRequestsSignature: () => chatPanelPendingRequestsSignature(this.messagesPorts), + messageStream: { + pendingRequestsSignature: () => chatPanelMessageStreamPendingRequestsSignature(this.messageStreamPorts), }, composerView: { composerPlaceholder: () => chatPanelComposerPlaceholder(this.composerPorts), @@ -234,7 +234,7 @@ export class CodexChatView extends ItemView { private createPanelRegionPorts(controllers: ChatViewControllers): { toolbar: ChatPanelToolbarPorts; goal: ChatPanelGoalPorts; - messages: ChatPanelMessagesPorts; + messageStream: ChatPanelMessageStreamPorts; composer: ChatPanelComposerPorts; } { const state = { @@ -271,10 +271,10 @@ export class CodexChatView extends ItemView { goal: this.createGoalPanelActions(controllers), }, }, - messages: { + messageStream: { state, render: { - node: () => this.controllers.render.messages.renderNode(), + node: () => this.controllers.render.messageStream.renderNode(), }, }, composer: { diff --git a/src/styles/30-chat-layout.css b/src/styles/30-chat-layout.css index dfa67dcd..05e3a2e9 100644 --- a/src/styles/30-chat-layout.css +++ b/src/styles/30-chat-layout.css @@ -14,7 +14,7 @@ grid-row: 1; } -.codex-panel__region--messages { +.codex-panel__region--message-stream { grid-row: 2; min-height: 0; overflow: hidden; diff --git a/tests/features/chat/conversation/pending-requests/controller.test.ts b/tests/features/chat/conversation/pending-requests/controller.test.ts index 638bfbd6..0fbd6077 100644 --- a/tests/features/chat/conversation/pending-requests/controller.test.ts +++ b/tests/features/chat/conversation/pending-requests/controller.test.ts @@ -40,7 +40,7 @@ describe("PendingRequestController", () => { stateStore.dispatch({ type: "request/user-input-queued", input }); stateStore.dispatch({ type: "request/user-input-draft-set", key: "7:direction", value: "Left" }); - pendingRequests.resolveUserInput(input); + pendingRequests.resolveUserInput(input.requestId); expect(respondToServerRequest).toHaveBeenCalledWith(7, { answers: { direction: { answers: ["Left"] } } }); expect(stateStore.getState().requests.pendingUserInputs).toEqual([]); diff --git a/tests/features/chat/ui/message-stream/pending-requests.test.tsx b/tests/features/chat/ui/message-stream/pending-requests.test.tsx index 2ee8420d..13495f7d 100644 --- a/tests/features/chat/ui/message-stream/pending-requests.test.tsx +++ b/tests/features/chat/ui/message-stream/pending-requests.test.tsx @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest"; import type { PendingRequestBlockSnapshot } from "../../../../../src/features/chat/conversation/pending-requests/snapshot"; +import { pendingApprovalViewModel } from "../../../../../src/features/chat/conversation/pending-requests/view-model"; import type { PendingApproval } from "../../../../../src/features/chat/protocol/server-requests/approval"; import type { PendingUserInput } from "../../../../../src/features/chat/protocol/server-requests/user-input"; import type { PendingRequestBlockContext } from "../../../../../src/features/chat/ui/message-stream/context"; @@ -60,7 +61,7 @@ describe("pending request renderer decisions", () => { actEvent(() => { parent.querySelector(".mod-cta")?.click(); }); - expect(resolveUserInput).toHaveBeenCalledWith(input); + expect(resolveUserInput).toHaveBeenCalledWith(input.requestId); }); it("selects the other Plan mode answer from controlled drafts", () => { @@ -349,7 +350,7 @@ describe("pending request renderer decisions", () => { actEvent(() => { allowButton.click(); }); - expect(resolveApproval).toHaveBeenCalledWith(approval, { + expect(resolveApproval).toHaveBeenCalledWith(approval.requestId, { kind: "command-decision", decision: { applyNetworkPolicyAmendment: { network_policy_amendment: { host: "registry.npmjs.org", action: "allow" } } }, }); @@ -527,7 +528,7 @@ describe("pending request renderer decisions", () => { renderMarkdown: (element, text) => element.createDiv({ text }), pendingRequests: pendingRequestContext({ signature: "approval:1", - snapshot: emptyPendingRequestBlockSnapshot({ approvals: [pendingApproval()] }), + snapshot: emptyPendingRequestBlockSnapshot({ approvals: [pendingApprovalViewModel(pendingApproval())] }), consumeAutoFocus, }), }), @@ -587,7 +588,7 @@ describe("pending request renderer decisions", () => { ...baseContext, pendingRequests: pendingRequestContext({ signature: "request:1", - snapshot: emptyPendingRequestBlockSnapshot({ approvals: [pendingApproval()] }), + snapshot: emptyPendingRequestBlockSnapshot({ approvals: [pendingApprovalViewModel(pendingApproval())] }), }), }), ); diff --git a/tests/features/chat/ui/message-stream/renderer.test.ts b/tests/features/chat/ui/message-stream/renderer.test.ts index 059fd7c7..37bc3188 100644 --- a/tests/features/chat/ui/message-stream/renderer.test.ts +++ b/tests/features/chat/ui/message-stream/renderer.test.ts @@ -10,13 +10,13 @@ import { type ChatState, type ChatStateStore, } from "../../../../../src/features/chat/state/reducer"; -import { ChatMessageRenderer } from "../../../../../src/features/chat/ui/message-stream/renderer"; +import { MessageStreamRenderer } from "../../../../../src/features/chat/ui/message-stream/renderer"; import { bindRenderedWikiLinks, type RenderedMarkdownLinkContext, -} from "../../../../../src/features/chat/ui/message-stream/rendered-markdown-links"; -import { MESSAGE_CONTENT_RENDERED_EVENT } from "../../../../../src/features/chat/ui/message-content-events"; -import type { MessageStreamScrollIntent } from "../../../../../src/features/chat/ui/message-virtualizer"; +} from "../../../../../src/features/chat/ui/message-stream/markdown-renderer"; +import { MESSAGE_CONTENT_RENDERED_EVENT } from "../../../../../src/features/chat/ui/message-stream/content-events"; +import type { MessageStreamScrollIntent } from "../../../../../src/features/chat/ui/message-stream/virtualizer"; import { renderUiRoot, unmountUiRoot } from "../../../../../src/shared/ui/ui-root"; import { notices } from "../../../../mocks/obsidian"; import { installObsidianDomShims } from "../../../../support/dom"; @@ -26,7 +26,7 @@ const ESTIMATED_MESSAGE_BLOCK_HEIGHT = 96; installObsidianDomShims(); -describe("ChatMessageRenderer scroll pinning", () => { +describe("MessageStreamRenderer scroll pinning", () => { beforeEach(() => { notices.length = 0; }); @@ -143,7 +143,7 @@ describe("ChatMessageRenderer scroll pinning", () => { }, ]; const parent = document.createElement("div"); - const renderer = chatMessageRenderer(state); + const renderer = messageStreamRenderer(state); renderUiRoot(parent, renderer.renderNode()); const messages = messageViewport(parent); @@ -153,7 +153,7 @@ describe("ChatMessageRenderer scroll pinning", () => { const scrollIntoView = vi.spyOn(HTMLElement.prototype, "scrollIntoView"); scrollIntoView.mockClear(); - renderer.forceMessagesToBottom(); + renderer.forceMessageStreamToBottom(); await settleMessageRender(messages); expect(messages.scrollTop).toBe(0); @@ -175,7 +175,7 @@ describe("ChatMessageRenderer scroll pinning", () => { }, ]; const parent = document.createElement("div"); - const renderer = chatMessageRenderer(state); + const renderer = messageStreamRenderer(state); renderUiRoot(parent, renderer.renderNode()); const messages = messageViewport(parent); @@ -187,7 +187,7 @@ describe("ChatMessageRenderer scroll pinning", () => { installMessageViewportMetrics(messages, { clientHeight: 100 }); messages.scrollTop = 940; - renderer.forceMessagesToBottom(); + renderer.forceMessageStreamToBottom(); expect(messages.scrollTop).toBe(0); }); @@ -207,7 +207,7 @@ describe("ChatMessageRenderer scroll pinning", () => { }, ]; const parent = document.createElement("div"); - const renderer = chatMessageRenderer(state); + const renderer = messageStreamRenderer(state); const messages = parent.createDiv({ cls: "codex-panel__messages" }); let scrollTop = 0; @@ -237,7 +237,7 @@ describe("ChatMessageRenderer scroll pinning", () => { await settleMessageRender(messages); expect(messages.scrollTop).toBe(0); - renderer.forceMessagesToBottom(); + renderer.forceMessageStreamToBottom(); expect(messages.scrollTop).toBe(0); layoutSettled = true; @@ -247,15 +247,15 @@ describe("ChatMessageRenderer scroll pinning", () => { }); it("treats scroll commands as no-ops when no message stream virtualizer is mounted", () => { - const renderer = chatMessageRenderer(); + const renderer = messageStreamRenderer(); expect(() => { - renderer.forceMessagesToBottom(); - renderer.repinMessagesToBottomIfPinned(); + renderer.forceMessageStreamToBottom(); + renderer.repinMessageStreamToBottomIfPinned(); renderer.scrollFromComposer({ direction: 1, amount: "text-lines" }); renderer.scrollFromComposer({ direction: -1, amount: "page" }); renderer.dispose(); - renderer.forceMessagesToBottom(); + renderer.forceMessageStreamToBottom(); }).not.toThrow(); }); @@ -274,7 +274,7 @@ describe("ChatMessageRenderer scroll pinning", () => { }, ]; const parent = document.createElement("div"); - const renderer = chatMessageRenderer(state); + const renderer = messageStreamRenderer(state); renderUiRoot(parent, renderer.renderNode()); const messages = messageViewport(parent); installMessageViewportMetrics(messages); @@ -283,7 +283,7 @@ describe("ChatMessageRenderer scroll pinning", () => { unmountUiRoot(parent); expect(() => { - renderer.forceMessagesToBottom(); + renderer.forceMessageStreamToBottom(); renderer.scrollFromComposer({ direction: 1, amount: "page" }); }).not.toThrow(); }); @@ -303,7 +303,7 @@ describe("ChatMessageRenderer scroll pinning", () => { }, ]; const parent = document.createElement("div"); - const renderer = chatMessageRenderer(state); + const renderer = messageStreamRenderer(state); renderUiRoot(parent, renderer.renderNode()); const oldMessages = messageViewport(parent); installMessageViewportMetrics(oldMessages, { clientHeight: 100, scrollHeight: 1000 }); @@ -316,7 +316,7 @@ describe("ChatMessageRenderer scroll pinning", () => { const newMessages = messageViewport(parent); installMessageViewportMetrics(newMessages, { clientHeight: 100, scrollHeight: 1000 }); await settleMessageRender(newMessages); - renderer.forceMessagesToBottom(); + renderer.forceMessageStreamToBottom(); expect(newMessages.scrollTop).toBe(900); expect(oldMessages.scrollTop).toBe(125); @@ -337,12 +337,12 @@ describe("ChatMessageRenderer scroll pinning", () => { }, ]; const parent = document.createElement("div"); - const renderer = chatMessageRenderer(state, vi.fn(), "/vault", [], () => "force-bottom"); + const renderer = messageStreamRenderer(state, vi.fn(), "/vault", [], () => "force-bottom"); renderUiRoot(parent, renderer.renderNode()); const messages = messageViewport(parent); installMessageViewportMetrics(messages, { clientHeight: 100, scrollHeight: 1000 }); - renderer.forceMessagesToBottom(); + renderer.forceMessageStreamToBottom(); await settleMessageRender(messages); expect(messages.scrollTop).toBe(900); @@ -363,7 +363,7 @@ describe("ChatMessageRenderer scroll pinning", () => { }, ]; const parent = document.createElement("div"); - const renderer = chatMessageRenderer(state); + const renderer = messageStreamRenderer(state); renderUiRoot(parent, renderer.renderNode()); const messages = messageViewport(parent); @@ -374,7 +374,7 @@ describe("ChatMessageRenderer scroll pinning", () => { configurable: true, }); - renderer.forceMessagesToBottom(); + renderer.forceMessageStreamToBottom(); await settleMessageRender(messages); expect(messages.scrollTop).toBe(900); @@ -402,7 +402,7 @@ describe("ChatMessageRenderer scroll pinning", () => { }, ]; const parent = document.createElement("div"); - const renderer = chatMessageRenderer(state); + const renderer = messageStreamRenderer(state); const messages = parent.createDiv({ cls: "codex-panel__messages" }); installMessageViewportMetrics(messages); @@ -448,7 +448,7 @@ describe("ChatMessageRenderer scroll pinning", () => { }, ]; const parent = document.createElement("div"); - const renderer = chatMessageRenderer(state); + const renderer = messageStreamRenderer(state); const messages = parent.createDiv({ cls: "codex-panel__messages" }); Object.defineProperty(messages, "scrollHeight", { value: 1000, configurable: true }); @@ -480,7 +480,7 @@ describe("ChatMessageRenderer scroll pinning", () => { }, ]; const parent = document.createElement("div"); - const renderer = chatMessageRenderer(state); + const renderer = messageStreamRenderer(state); renderUiRoot(parent, renderer.renderNode()); let messages = messageViewport(parent); @@ -508,7 +508,7 @@ describe("ChatMessageRenderer scroll pinning", () => { messageState: "completed", })); const parent = document.createElement("div"); - const renderer = chatMessageRenderer(state); + const renderer = messageStreamRenderer(state); renderUiRoot(parent, renderer.renderNode()); const messages = messageViewport(parent); @@ -536,15 +536,15 @@ function markdownLinkContext(openLinkText = vi.fn(), vaultPath = "/vault", vault }; } -function chatMessageRenderer( +function messageStreamRenderer( state = createChatState(), openLinkText = vi.fn(), vaultPath = "/vault", vaultFiles: string[] = [], consumeIntent: () => MessageStreamScrollIntent = () => "auto", -): ChatMessageRenderer { +): MessageStreamRenderer { const files = new Map(vaultFiles.map((path) => [path, tFile(path)])); - return new ChatMessageRenderer({ + return new MessageStreamRenderer({ obsidian: { app: { workspace: { diff --git a/tests/features/chat/ui/message-scroll-intent-controller.test.ts b/tests/features/chat/ui/message-stream/scroll-intent-controller.test.ts similarity index 87% rename from tests/features/chat/ui/message-scroll-intent-controller.test.ts rename to tests/features/chat/ui/message-stream/scroll-intent-controller.test.ts index 5a103268..849a7c2a 100644 --- a/tests/features/chat/ui/message-scroll-intent-controller.test.ts +++ b/tests/features/chat/ui/message-stream/scroll-intent-controller.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { ChatMessageScrollIntentController } from "../../../../src/features/chat/ui/message-scroll-intent-controller"; +import { ChatMessageScrollIntentController } from "../../../../../src/features/chat/ui/message-stream/scroll-intent-controller"; describe("ChatMessageScrollIntentController", () => { it("consumes one-shot scroll intents", () => { diff --git a/tests/features/chat/ui/message-stream/test-helpers.tsx b/tests/features/chat/ui/message-stream/test-helpers.tsx index 1eaf79d8..4dfe2362 100644 --- a/tests/features/chat/ui/message-stream/test-helpers.tsx +++ b/tests/features/chat/ui/message-stream/test-helpers.tsx @@ -4,11 +4,13 @@ import { act } from "preact/test-utils"; import type { PendingApproval } from "../../../../../src/features/chat/protocol/server-requests/approval"; import type { PendingUserInput } from "../../../../../src/features/chat/protocol/server-requests/user-input"; -import { pendingRequestBlockNode, type PendingRequestBlockActions } from "../../../../../src/features/chat/ui/pending-request-block"; +import { pendingRequestBlockSnapshotFromRequests } from "../../../../../src/features/chat/conversation/pending-requests/snapshot"; +import type { PendingRequestBlockActions } from "../../../../../src/features/chat/conversation/pending-requests/view-model"; +import { pendingRequestBlockNode } from "../../../../../src/features/chat/ui/message-stream/pending-request-block"; import type { ChatTurnLifecycleState } from "../../../../../src/features/chat/state/reducer"; import { messageStreamBlocks as rawMessageStreamBlocks } from "../../../../../src/features/chat/ui/message-stream/stream-blocks"; import type { MessageStreamBlock } from "../../../../../src/features/chat/ui/message-stream/context"; -import { messageStreamBlocksNode } from "../../../../../src/features/chat/ui/message-stream/render"; +import { messageStreamViewportNode } from "../../../../../src/features/chat/ui/message-stream/viewport"; import { renderUiRoot, unmountUiRoot } from "../../../../../src/shared/ui/ui-root"; export function messageStreamBlocks( @@ -55,7 +57,7 @@ export function renderMessageStreamBlocksInAct(parent: HTMLElement, blocks: Mess void act(() => { renderUiRoot( parent, - messageStreamBlocksNode({ + messageStreamViewportNode({ blocks, consumeScrollIntent: () => "auto", }), @@ -93,8 +95,40 @@ export function unmountUiRootInAct(parent: HTMLElement): void { }); } -export function renderPendingRequestNode(parent: HTMLElement, ...args: Parameters): void { - renderUiRootInAct(parent, pendingRequestBlockNode(...args)); +export function renderPendingRequestNode( + parent: HTMLElement, + approvals: readonly PendingApproval[], + pendingUserInputs: readonly PendingUserInput[], + drafts: { + values: ReadonlyMap; + draftKey?: (requestId: PendingUserInput["requestId"], questionId: string) => string; + otherDraftKey?: (requestId: PendingUserInput["requestId"], questionId: string) => string; + }, + openDetails: ReadonlySet, + actions: PendingRequestBlockActions, + autoFocusRequested = false, + consumeAutoFocus?: () => boolean, + autoFocusSignature = "", +): void { + const snapshot = pendingRequestBlockSnapshotFromRequests({ + approvals, + pendingUserInputs, + userInputDrafts: drafts.values, + openDetails, + }); + renderUiRootInAct( + parent, + pendingRequestBlockNode( + snapshot.approvals, + snapshot.pendingUserInputs, + snapshot.userInputDrafts, + snapshot.openDetails, + actions, + autoFocusRequested, + consumeAutoFocus, + autoFocusSignature, + ), + ); } export function pendingRequestActions(overrides: Partial = {}): PendingRequestBlockActions { diff --git a/tests/features/chat/ui/message-stream/render.test.tsx b/tests/features/chat/ui/message-stream/viewport.test.tsx similarity index 97% rename from tests/features/chat/ui/message-stream/render.test.tsx rename to tests/features/chat/ui/message-stream/viewport.test.tsx index d0abd928..5bdfc2b1 100644 --- a/tests/features/chat/ui/message-stream/render.test.tsx +++ b/tests/features/chat/ui/message-stream/viewport.test.tsx @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; -import { messageStreamVirtualItems } from "../../../../../src/features/chat/ui/message-stream/render"; +import { messageStreamVirtualItems } from "../../../../../src/features/chat/ui/message-stream/viewport"; import type { MessageStreamBlock } from "../../../../../src/features/chat/ui/message-stream/context"; describe("message stream virtual item fallback", () => { diff --git a/tests/features/chat/ui/message-virtualizer.test.ts b/tests/features/chat/ui/message-stream/virtualizer.test.ts similarity index 99% rename from tests/features/chat/ui/message-virtualizer.test.ts rename to tests/features/chat/ui/message-stream/virtualizer.test.ts index e3ef60c6..531859f1 100644 --- a/tests/features/chat/ui/message-virtualizer.test.ts +++ b/tests/features/chat/ui/message-stream/virtualizer.test.ts @@ -9,10 +9,10 @@ import { type MessageStreamVirtualizerHandle, type MessageStreamVirtualizerView, useMessageStreamVirtualizer, -} from "../../../../src/features/chat/ui/message-virtualizer"; -import type { MessageStreamBlock } from "../../../../src/features/chat/ui/message-stream/context"; -import { renderUiRoot, unmountUiRoot } from "../../../../src/shared/ui/ui-root"; -import { installObsidianDomShims } from "../../../support/dom"; +} from "../../../../../src/features/chat/ui/message-stream/virtualizer"; +import type { MessageStreamBlock } from "../../../../../src/features/chat/ui/message-stream/context"; +import { renderUiRoot, unmountUiRoot } from "../../../../../src/shared/ui/ui-root"; +import { installObsidianDomShims } from "../../../../support/dom"; installObsidianDomShims(); diff --git a/tests/features/chat/ui/renderers/goal-banner.test.tsx b/tests/features/chat/ui/renderers/goal.test.tsx similarity index 97% rename from tests/features/chat/ui/renderers/goal-banner.test.tsx rename to tests/features/chat/ui/renderers/goal.test.tsx index 4a11e01e..4a213b8c 100644 --- a/tests/features/chat/ui/renderers/goal-banner.test.tsx +++ b/tests/features/chat/ui/renderers/goal.test.tsx @@ -4,7 +4,7 @@ import { describe, expect, it, vi } from "vitest"; import { act } from "preact/test-utils"; import type { ThreadGoal } from "../../../../../src/app-server/protocol/thread-goal"; -import { goalBannerNode, type GoalBannerActions } from "../../../../../src/features/chat/ui/goal-banner"; +import { goalRegionNode, type GoalRegionActions } from "../../../../../src/features/chat/ui/goal"; import type { SendShortcut } from "../../../../../src/shared/ui/keyboard"; import { renderUiRoot } from "../../../../../src/shared/ui/ui-root"; import { installObsidianDomShims } from "../../../../support/dom"; @@ -12,7 +12,7 @@ import { installObsidianDomShims } from "../../../../support/dom"; installObsidianDomShims(); (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; -describe("goalBannerNode", () => { +describe("goalRegionNode", () => { it("renders nothing when there is no goal", async () => { const parent = document.createElement("div"); @@ -30,7 +30,7 @@ describe("goalBannerNode", () => { const onEditingChange = vi.fn(); await act(async () => { - renderUiRoot(parent, goalBannerNode(null, callbacks, { sendShortcut: "enter", editingRequested: true, onEditingChange })); + renderUiRoot(parent, goalRegionNode(null, callbacks, { sendShortcut: "enter", editingRequested: true, onEditingChange })); }); expect(parent.textContent).toContain("Goal"); @@ -234,10 +234,10 @@ describe("goalBannerNode", () => { function renderGoal( parent: HTMLElement, currentGoal: ThreadGoal | null, - callbacks: GoalBannerActions = actions(), + callbacks: GoalRegionActions = actions(), sendShortcut: SendShortcut = "enter", ): void { - renderUiRoot(parent, goalBannerNode(currentGoal, callbacks, { sendShortcut })); + renderUiRoot(parent, goalRegionNode(currentGoal, callbacks, { sendShortcut })); } function actions() { diff --git a/tests/features/chat/ui/shell.test.tsx b/tests/features/chat/ui/shell.test.tsx index 917ba951..55ce0482 100644 --- a/tests/features/chat/ui/shell.test.tsx +++ b/tests/features/chat/ui/shell.test.tsx @@ -28,7 +28,7 @@ describe("ChatPanelShell", () => { expect(container.textContent).toContain("0"); expect(container.textContent).toContain("ready"); expect(container.querySelector(".codex-panel__region--config")).toBeNull(); - expect(container.querySelector(".codex-panel__body > .codex-panel__region--messages")).toBe( + expect(container.querySelector(".codex-panel__body > .codex-panel__region--message-stream")).toBe( container.querySelector(".codex-panel__body > .codex-panel__messages"), ); @@ -83,7 +83,7 @@ describe("ChatPanelShell", () => { expect(container.querySelector(".codex-panel__toolbar .test-toolbar")?.textContent).toBe("Working"); expect(container.querySelector(".codex-panel__region--goal .test-goal")?.textContent).toBe("no goal"); - expect(container.querySelector(".codex-panel__region--messages .test-messages")?.textContent).toBe("1"); + expect(container.querySelector(".codex-panel__region--message-stream .test-messages")?.textContent).toBe("1"); expect(container.querySelector(".codex-panel__region--composer .test-composer textarea")?.value).toBe("ready"); expect(container.querySelector(".codex-panel__region--composer .test-toolbar")).toBeNull(); expect(container.querySelector(".codex-panel__region--composer .test-messages")).toBeNull(); @@ -106,7 +106,7 @@ describe("ChatPanelShell", () => { }); expect(container.querySelector(".codex-panel__toolbar .test-toolbar")?.textContent).toBe("Idle"); - expect(container.querySelector(".codex-panel__region--messages .test-messages")?.textContent).toBe("0"); + expect(container.querySelector(".codex-panel__region--message-stream .test-messages")?.textContent).toBe("0"); await act(async () => { unmountChatPanelShell(container); @@ -132,7 +132,7 @@ describe("ChatPanelShell", () => { }); renderers.toolbarNode.mockClear(); renderers.goalNode.mockClear(); - renderers.messagesNode.mockClear(); + renderers.messageStreamNode.mockClear(); renderers.composerNode.mockClear(); await act(async () => { @@ -143,7 +143,7 @@ describe("ChatPanelShell", () => { expect(container.querySelector(".test-toolbar")?.textContent).toBe("Working"); expect(renderers.toolbarNode).not.toHaveBeenCalled(); expect(renderers.goalNode).not.toHaveBeenCalled(); - expect(renderers.messagesNode).not.toHaveBeenCalled(); + expect(renderers.messageStreamNode).not.toHaveBeenCalled(); expect(renderers.composerNode).not.toHaveBeenCalled(); await act(async () => { @@ -163,7 +163,7 @@ describe("ChatPanelShell", () => { }); expect(container.querySelector(".codex-panel__toolbar")).toBeNull(); - expect(container.querySelector(".codex-panel__region--messages")).not.toBeNull(); + expect(container.querySelector(".codex-panel__region--message-stream")).not.toBeNull(); expect(container.querySelector(".codex-panel__region--composer")).not.toBeNull(); await act(async () => { @@ -359,8 +359,8 @@ function shellRenderers(store: ReturnType) { goalNode: vi.fn(() => ), - messagesNode: vi.fn(() => ( -
+ messageStreamNode: vi.fn(() => ( +
)), @@ -382,8 +382,8 @@ function nestedRootShellRenderers(store: ReturnType goalNode: vi.fn(() => ), - messagesNode: vi.fn(() => ( -
+ messageStreamNode: vi.fn(() => ( +
)), @@ -405,8 +405,8 @@ function trackedRootShellRenderers(store: ReturnType ), - messagesNode: vi.fn(() => ( -
+ messageStreamNode: vi.fn(() => ( +
)), @@ -423,8 +423,8 @@ function nodeShellRenderers(store: ReturnType, clea goalNode: vi.fn(() => ), - messagesNode: vi.fn(() => ( -
+ messageStreamNode: vi.fn(() => ( +
)), diff --git a/tests/features/chat/ui/toolbar-archive-signal.test.tsx b/tests/features/chat/ui/toolbar-archive-signal.test.tsx index a5a7ab81..7ae63caf 100644 --- a/tests/features/chat/ui/toolbar-archive-signal.test.tsx +++ b/tests/features/chat/ui/toolbar-archive-signal.test.tsx @@ -38,7 +38,7 @@ describe("chat toolbar archive confirmation signal", () => { showToolbar: true, toolbarNode: () => chatPanelToolbarRegionNode(toolbarPorts(store, controller)), goalNode: () => null, - messagesNode: () =>
, + messageStreamNode: () =>
, composerNode: () => null, }); await settle(); diff --git a/tests/features/chat/view-connection.test.ts b/tests/features/chat/view-connection.test.ts index 4e132275..3e2e886e 100644 --- a/tests/features/chat/view-connection.test.ts +++ b/tests/features/chat/view-connection.test.ts @@ -192,7 +192,7 @@ describe("CodexChatView connection lifecycle", () => { const root = view.contentEl; expect(root.classList.contains("codex-panel")).toBe(true); expect(root.querySelector(":scope > .codex-panel__toolbar")).not.toBeNull(); - expect(root.querySelector(":scope > .codex-panel__body .codex-panel__region--messages")).not.toBeNull(); + expect(root.querySelector(":scope > .codex-panel__body .codex-panel__region--message-stream")).not.toBeNull(); expect(root.querySelector(":scope > .codex-panel__body .codex-panel__region--composer")).not.toBeNull(); expect(siblingRoots.every((sibling) => !sibling.classList.contains("codex-panel") && sibling.childElementCount === 0)).toBe(true); });