Organize message stream UI rendering

This commit is contained in:
murashit 2026-06-12 16:07:35 +09:00
parent 31e30b637a
commit 552e53681b
45 changed files with 509 additions and 407 deletions

View file

@ -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;
}

View file

@ -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,

View file

@ -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,
};

View file

@ -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;

View file

@ -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<string, string>;
openDetails: ReadonlySet<string>;
}
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<string, string>;
openDetails: ReadonlySet<string>;
}): PendingRequestBlockSnapshot {
return {
approvals: source.approvals.map(pendingApprovalViewModel),
pendingUserInputs: source.pendingUserInputs.map(pendingUserInputViewModel),
userInputDrafts: source.userInputDrafts,
openDetails: source.openDetails,
};
}

View file

@ -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,
})),
};
}

View file

@ -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();

View file

@ -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<ChatPanelGoalPorts["state"]["chat"]>,
): {
goal: ReturnType<ChatPanelGoalPorts["state"]["chat"]>["activeThread"]["goal"];
actions: GoalBannerActions;
options: GoalBannerOptions;
actions: GoalRegionActions;
options: GoalRegionOptions;
} {
const goal = state.activeThread.goal;
const goalThreadId = goal?.threadId ?? null;

View file

@ -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);
}

View file

@ -64,7 +64,7 @@ export interface ChatPanelGoalPorts extends ChatPanelStatePort {
};
}
export interface ChatPanelMessagesPorts extends ChatPanelStatePort {
export interface ChatPanelMessageStreamPorts extends ChatPanelStatePort {
render: {
node: () => UiNode;
};

View file

@ -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 <GoalRegion ports={ports} />;
}
export function chatPanelMessagesRegionNode(ports: ChatPanelMessagesPorts): UiNode {
return <MessagesRegion ports={ports} />;
export function chatPanelMessageStreamRegionNode(ports: ChatPanelMessageStreamPorts): UiNode {
return <MessageStreamRegion ports={ports} />;
}
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 {

View file

@ -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,
});
},

View file

@ -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 <GoalBanner goal={goal} actions={actions} options={options} />;
export function goalRegionNode(goal: ThreadGoal | null, actions: GoalRegionActions, options: GoalRegionOptions): UiNode {
return <GoalRegion goal={goal} actions={actions} options={options} />;
}
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 ?? "");

View file

@ -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,
},
};
}

View file

@ -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 {

View file

@ -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<HTMLAnchorElement>("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<HTMLAnchorElement>("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);
};
});
}

View file

@ -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<string, string>;
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<string, string>,
openDetails: ReadonlySet<string>,
actions: PendingRequestBlockActions,
autoFocusRequested = false,
@ -43,7 +23,7 @@ export function pendingRequestBlockNode(
<PendingRequestBlock
approvals={approvals}
pendingUserInputs={pendingUserInputs}
drafts={drafts}
userInputDrafts={userInputDrafts}
openDetails={openDetails}
actions={actions}
autoFocusRequested={autoFocusRequested}
@ -56,16 +36,16 @@ export function pendingRequestBlockNode(
function PendingRequestBlock({
approvals,
pendingUserInputs,
drafts,
userInputDrafts,
openDetails,
actions,
autoFocusRequested,
consumeAutoFocus,
autoFocusSignature,
}: {
approvals: readonly PendingApproval[];
pendingUserInputs: readonly PendingUserInput[];
drafts: PendingRequestBlockDrafts;
approvals: readonly PendingApprovalViewModel[];
pendingUserInputs: readonly PendingUserInputViewModel[];
userInputDrafts: ReadonlyMap<string, string>;
openDetails: ReadonlySet<string>;
actions: PendingRequestBlockActions;
autoFocusRequested: boolean;
@ -87,7 +67,7 @@ function PendingRequestBlock({
<ApprovalCard key={String(approval.requestId)} approval={approval} openDetails={openDetails} actions={actions} />
))}
{pendingUserInputs.map((input) => (
<UserInputCard key={String(input.requestId)} input={input} drafts={drafts} actions={actions} />
<UserInputCard key={String(input.requestId)} input={input} userInputDrafts={userInputDrafts} actions={actions} />
))}
</div>
);
@ -114,25 +94,25 @@ function ApprovalCard({
openDetails,
actions,
}: {
approval: PendingApproval;
approval: PendingApprovalViewModel;
openDetails: ReadonlySet<string>;
actions: PendingRequestBlockActions;
}): UiNode {
return (
<PendingRequestCard className="codex-panel__approval">
<div className="codex-panel__pending-request-info">
<div className="codex-panel__pending-request-title">{approvalTitle(approval)}</div>
<div className="codex-panel__pending-request-body">{approvalSummary(approval)}</div>
<div className="codex-panel__pending-request-title">{approval.title}</div>
<div className="codex-panel__pending-request-body">{approval.summary}</div>
<ApprovalDetails approval={approval} openDetails={openDetails} actions={actions} />
</div>
<div className="codex-panel__pending-request-actions">
{approvalActionOptions(approval).map((option) => (
{approval.actions.map((option) => (
<ActionButton
key={option.label}
label={option.label}
className={option.className}
onClick={() => {
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<string>;
actions: PendingRequestBlockActions;
}): UiNode {
@ -161,7 +141,7 @@ function ApprovalDetails({
>
<summary tabIndex={-1}>Request details</summary>
<dl className="codex-panel__meta-grid">
{approvalDetails(approval).map((row) => (
{approval.details.map((row) => (
<MetaPair key={`${row.key}:${row.value}`} name={row.key} value={row.value} />
))}
</dl>
@ -171,35 +151,33 @@ function ApprovalDetails({
function UserInputCard({
input,
drafts,
userInputDrafts,
actions,
}: {
input: PendingUserInput;
drafts: PendingRequestBlockDrafts;
input: PendingUserInputViewModel;
userInputDrafts: ReadonlyMap<string, string>;
actions: PendingRequestBlockActions;
}): UiNode {
return (
<PendingRequestCard className="codex-panel__user-input">
<div className="codex-panel__pending-request-info">
<div className="codex-panel__pending-request-title">Codex needs input</div>
<div className="codex-panel__pending-request-body">
Answer {String(input.params.questions.length)} Plan mode question{input.params.questions.length === 1 ? "" : "s"} to continue.
</div>
<UserInputQuestions input={input} drafts={drafts} actions={actions} />
<div className="codex-panel__pending-request-title">{input.title}</div>
<div className="codex-panel__pending-request-body">{input.body}</div>
<UserInputQuestions input={input} userInputDrafts={userInputDrafts} actions={actions} />
</div>
<div className="codex-panel__pending-request-actions">
<ActionButton
label="Submit"
className="mod-cta"
onClick={() => {
actions.resolveUserInput(input);
actions.resolveUserInput(input.requestId);
}}
/>
<ActionButton
label="Cancel"
className=""
onClick={() => {
actions.cancelUserInput(input);
actions.cancelUserInput(input.requestId);
}}
/>
</div>
@ -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<string, string>;
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 (
<div key={question.id} className="codex-panel__user-input-question">
{question.header ? <div className="codex-panel__user-input-header">{question.header}</div> : 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);
}}
/>
<span className="codex-panel__user-input-option-label">{option.label}</span>
@ -255,25 +232,22 @@ function UserInputQuestions({
})}
{question.isOther ? (
<OtherUserInputOption
input={input}
questionId={question.id}
questionText={question.question}
groupName={`codex-panel-${String(input.requestId)}-${question.id}`}
current={current}
optionLabels={new Set(question.options.map((option) => option.label))}
drafts={drafts}
question={question}
userInputDrafts={userInputDrafts}
actions={actions}
/>
) : null}
</>
) : (
<FreeformUserInput
input={input}
questionId={question.id}
questionText={question.question}
isSecret={question.isSecret}
current={current}
drafts={drafts}
question={question}
actions={actions}
/>
)}
@ -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<string>;
drafts: PendingRequestBlockDrafts;
question: PendingUserInputQuestionViewModel;
userInputDrafts: ReadonlyMap<string, string>;
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 (
<input
className="codex-panel__user-input-text"
@ -393,7 +360,7 @@ function FreeformUserInput({
aria-label={questionText}
value={current}
onInput={(event) => {
actions.setUserInputDraft(draftKey, event.currentTarget.value);
actions.setUserInputDraft(question.draftKey, event.currentTarget.value);
}}
/>
);

View file

@ -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;

View file

@ -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,
};
}

View file

@ -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<HTMLAnchorElement>("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<HTMLAnchorElement>("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);
};
});
}

View file

@ -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();
}

View file

@ -1,4 +1,4 @@
import type { MessageStreamScrollIntent } from "../ui/message-virtualizer";
import type { MessageStreamScrollIntent } from "./virtualizer";
export class ChatMessageScrollIntentController {
private nextIntent: MessageStreamScrollIntent = "auto";

View file

@ -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,

View file

@ -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";

View file

@ -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

View file

@ -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;

View file

@ -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 <MessageStreamBlocks state={state} />;
export function messageStreamViewportNode(state: MessageStreamViewportState): UiNode {
return <MessageStreamViewport state={state} />;
}
function MessageStreamBlocks({ state }: { state: MessageStreamRenderState }): UiNode {
function MessageStreamViewport({ state }: { state: MessageStreamViewportState }): UiNode {
const { blocks, consumeScrollIntent, registerVirtualizer } = state;
const scrollElementRef = useRef<HTMLDivElement | null>(null);
const virtualizer = useMessageStreamVirtualizer({ blocks, consumeScrollIntent, registerVirtualizer, scrollElementRef });
@ -31,7 +31,7 @@ function MessageStreamBlocks({ state }: { state: MessageStreamRenderState }): Ui
);
return (
<div ref={scrollElementRef} className="codex-panel__region codex-panel__region--messages codex-panel__messages">
<div ref={scrollElementRef} className="codex-panel__region codex-panel__region--message-stream codex-panel__messages">
<div className="codex-panel__message-virtualizer" style={{ height: `${String(virtualizer.getTotalSize())}px` }}>
{virtualItems.map((virtualItem) => (
<MessageStreamBlockHost

View file

@ -1,7 +1,7 @@
import { elementScroll, observeElementOffset, observeElementRect, Virtualizer, type VirtualItem } from "@tanstack/virtual-core";
import { useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
import type { MessageStreamBlock } from "./message-stream/context";
import type { MessageStreamBlock } from "./context";
export type MessageStreamScrollIntent = "auto" | "force-bottom" | "follow-bottom" | "preserve";
type MessageScrollDirection = -1 | 1;

View file

@ -1,7 +1,7 @@
import type { ComponentChild as UiNode } from "preact";
import { useLayoutEffect, useState } from "preact/hooks";
import { activeAgentRunSummary, agentActivityMetaLabel, agentMessagePreview, agentRunSummaryLabel } from "../display/stream/agent-summary";
import { activeAgentRunSummary, agentActivityMetaLabel, agentMessagePreview, agentRunSummaryLabel } from "../../display/stream/agent-summary";
import type {
AgentDisplayItem,
AgentRunSummary,
@ -11,10 +11,10 @@ import type {
ExecutionState,
ReasoningDisplayItem,
TaskProgressDisplayItem,
} from "../display/types";
import { activeTurnId, type ChatTurnLifecycleState } from "../state/reducer";
} from "../../display/types";
import { activeTurnId, type ChatTurnLifecycleState } from "../../state/reducer";
import { createWorkMessageClassName } from "./work-message";
import { shortThreadId, truncate } from "../../../utils";
import { shortThreadId, truncate } from "../../../../utils";
const AGENT_ROW_MESSAGE_PREVIEW_LIMIT = 120;
const AGENT_ACTIVITY_PROMPT_PREVIEW_LIMIT = 96;

View file

@ -23,7 +23,7 @@ export interface ChatPanelShellProps {
showToolbar: boolean;
toolbarNode: () => 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 ? <div className="codex-panel__toolbar">{toolbarNode()}</div> : null}
<div className="codex-panel__body">
<div className="codex-panel__region codex-panel__region--goal">{goalNode()}</div>
{messagesNode()}
{messageStreamNode()}
<div className="codex-panel__region codex-panel__region--composer">{composerNode()}</div>
</div>
</ChatPanelShellStateContext.Provider>

View file

@ -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: {

View file

@ -14,7 +14,7 @@
grid-row: 1;
}
.codex-panel__region--messages {
.codex-panel__region--message-stream {
grid-row: 2;
min-height: 0;
overflow: hidden;

View file

@ -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([]);

View file

@ -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<HTMLButtonElement>(".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())] }),
}),
}),
);

View file

@ -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: {

View file

@ -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", () => {

View file

@ -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<typeof pendingRequestBlockNode>): void {
renderUiRootInAct(parent, pendingRequestBlockNode(...args));
export function renderPendingRequestNode(
parent: HTMLElement,
approvals: readonly PendingApproval[],
pendingUserInputs: readonly PendingUserInput[],
drafts: {
values: ReadonlyMap<string, string>;
draftKey?: (requestId: PendingUserInput["requestId"], questionId: string) => string;
otherDraftKey?: (requestId: PendingUserInput["requestId"], questionId: string) => string;
},
openDetails: ReadonlySet<string>,
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> = {}): PendingRequestBlockActions {

View file

@ -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", () => {

View file

@ -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();

View file

@ -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() {

View file

@ -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<HTMLTextAreaElement>(".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<typeof createChatStateStore>) {
goalNode: vi.fn(() => <ShellGoal />),
messagesNode: vi.fn(() => (
<div className="codex-panel__region codex-panel__region--messages codex-panel__messages">
messageStreamNode: vi.fn(() => (
<div className="codex-panel__region codex-panel__region--message-stream codex-panel__messages">
<ShellMessageCount />
</div>
)),
@ -382,8 +382,8 @@ function nestedRootShellRenderers(store: ReturnType<typeof createChatStateStore>
goalNode: vi.fn(() => <ShellGoal className="test-goal" />),
messagesNode: vi.fn(() => (
<div className="codex-panel__region codex-panel__region--messages codex-panel__messages">
messageStreamNode: vi.fn(() => (
<div className="codex-panel__region codex-panel__region--message-stream codex-panel__messages">
<ShellMessageCount className="test-messages" />
</div>
)),
@ -405,8 +405,8 @@ function trackedRootShellRenderers(store: ReturnType<typeof createChatStateStore
goalNode: vi.fn(() => <TrackedSlot region="goal" cleanup={cleanup} />),
messagesNode: vi.fn(() => (
<div className="codex-panel__region codex-panel__region--messages codex-panel__messages">
messageStreamNode: vi.fn(() => (
<div className="codex-panel__region codex-panel__region--message-stream codex-panel__messages">
<TrackedSlot region="messages" cleanup={cleanup} />
</div>
)),
@ -423,8 +423,8 @@ function nodeShellRenderers(store: ReturnType<typeof createChatStateStore>, clea
goalNode: vi.fn(() => <TrackedStateSlot region="goal" cleanup={cleanup} className="test-goal" selector="goal" />),
messagesNode: vi.fn(() => (
<div className="codex-panel__region codex-panel__region--messages codex-panel__messages">
messageStreamNode: vi.fn(() => (
<div className="codex-panel__region codex-panel__region--message-stream codex-panel__messages">
<TrackedStateSlot region="messages" cleanup={cleanup} className="test-messages" selector="message-count" />
</div>
)),

View file

@ -38,7 +38,7 @@ describe("chat toolbar archive confirmation signal", () => {
showToolbar: true,
toolbarNode: () => chatPanelToolbarRegionNode(toolbarPorts(store, controller)),
goalNode: () => null,
messagesNode: () => <div className="codex-panel__region codex-panel__region--messages codex-panel__messages" />,
messageStreamNode: () => <div className="codex-panel__region codex-panel__region--message-stream codex-panel__messages" />,
composerNode: () => null,
});
await settle();

View file

@ -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);
});