Unify chat panel under a single Preact shell

This commit is contained in:
murashit 2026-06-10 14:58:42 +09:00
parent 5e833f77a6
commit 777df21446
73 changed files with 2799 additions and 1428 deletions

View file

@ -31,7 +31,7 @@ The source tree is organized by responsibility rather than by the original singl
Keep new code near the state or API it owns. Feature code should not import from another feature directly; move shared behavior to `src/shared/`, `src/domain/`, or `src/app-server/` when more than one feature needs it.
Codex Panel's runtime UI is Preact-owned through the direct Preact APIs. Keep chat panel UI, the Threads view, and the selection rewrite popover in Preact components. Use `preact/hooks` for hooks, `preact` for component types and fragments, and the shared root adapter for mounting and unmounting Preact subtrees. Imperative DOM writes are limited to explicit bridge modules or Obsidian-owned API boundaries such as `MarkdownRenderer`, diff rendering, icon rendering, `SuggestModal`, and the Obsidian `Setting`-based settings tab. ESLint enforces this boundary with allowlisted bridge files; add a new allowlist entry only when the code is genuinely bridging an external DOM API.
Codex Panel's runtime UI is Preact-owned through the direct Preact APIs. Keep chat panel UI, the Threads view, and the selection rewrite popover in Preact components. The chat panel shell is a single Preact tree; toolbar, goal, message stream, and composer content should be composed as Preact nodes rather than mounted through HTMLElement region renderers. Use `preact/hooks` for hooks, `preact` for component types and fragments, and the shared root adapter for mounting and unmounting Preact subtrees. Imperative DOM writes are limited to explicit bridge modules or Obsidian-owned API boundaries such as `MarkdownRenderer`, TanStack Virtual measurement/lifecycle glue, diff rendering, icon rendering, `SuggestModal`, and the Obsidian `Setting`-based settings tab. ESLint enforces this boundary with allowlisted bridge files; add a new allowlist entry only when the code is genuinely bridging an external DOM API.
The bundled UI runtime should stay on direct Preact APIs. This avoids browser runtime code that creates `<script>` elements and can be flagged by Obsidian Community plugin review automation, while preserving the current component model without a compatibility adapter layer. Do not add a non-Preact UI runtime or adapter layer unless the review tradeoff is explicitly revisited.

View file

@ -72,23 +72,28 @@ const pureChatModelRestrictions = [
message: "Keep browser globals out of chat state and display model transforms.",
},
];
const chatImperativeDomBridgeFiles = [
"src/features/chat/ui/message-stream/**/*.{ts,tsx}",
const chatExternalDomBridgeFiles = [
"src/features/chat/ui/message-stream/markdown-renderer.ts",
"src/features/chat/ui/message-stream/rendered-markdown-links.ts",
"src/features/chat/ui/message-virtualizer.ts",
];
const chatPreactDomBridgeFiles = [
"src/features/chat/ui/message-stream/message-actions.tsx",
"src/features/chat/ui/message-stream/message-item.tsx",
"src/features/chat/ui/message-stream/render.tsx",
"src/features/chat/ui/composer.tsx",
"src/features/chat/ui/goal-banner.tsx",
"src/features/chat/ui/scroll.ts",
"src/features/chat/ui/shell.tsx",
"src/features/chat/ui/tool-result.tsx",
"src/features/chat/ui/turn-diff.tsx",
];
const chatImperativeDomBridgeFiles = [...chatExternalDomBridgeFiles, ...chatPreactDomBridgeFiles];
const nonChatImperativeDomBridgeFiles = [
"src/features/selection-rewrite/popover.tsx",
"src/features/selection-rewrite/runner.ts",
"src/features/thread-picker/modal.ts",
"src/settings/dynamic-sections.ts",
"src/settings/tab.ts",
"src/shared/diff/render.ts",
"src/shared/ui/dom.ts",
"src/shared/ui/components.tsx",
"src/shared/ui/textarea-autogrow.ts",
"src/shared/ui/textarea-caret.ts",

View file

@ -178,6 +178,13 @@ type ComposerAction =
clearSuggestions?: boolean;
resetDismissedSignature?: boolean;
}
| {
type: "composer/input-set";
draft: string;
suggestions: readonly ComposerSuggestion[];
selected?: number;
dismissedSignature?: string | null;
}
| {
type: "composer/suggestions-set";
suggestions: readonly ComposerSuggestion[];
@ -575,6 +582,16 @@ function reduceComposerSlice(state: ChatComposerState, action: ChatSliceAction):
...(action.clearSuggestions ? { suggestions: [], suggestSelected: 0 } : {}),
...(action.resetDismissedSignature ? { suggestionsDismissedSignature: null } : {}),
});
case "composer/input-set":
return setComposerSuggestionsSlice(
patchObject(state, {
draft: action.draft,
suggestionsDismissedSignature: action.dismissedSignature ?? null,
}),
action.suggestions,
action.selected ?? state.suggestSelected,
action.dismissedSignature ?? null,
);
case "composer/suggestions-set":
return setComposerSuggestionsSlice(
state,

View file

@ -1,11 +1,12 @@
import type { App, EventRef } from "obsidian";
import type { ComponentChild as UiNode } from "preact";
import type { CodexInput } from "../../../app-server/request-input";
import { isComposerSendKey, type SendShortcut } from "../../../shared/ui/keyboard";
import { textareaCursorAtVisualBoundary } from "../../../shared/ui/textarea-caret";
import { chatTurnBusy, type ChatAction, type ChatState, type ChatStateStore } from "../chat-state";
import type { ComposerMetaViewModel } from "../panel/model";
import { renderComposerShell, syncComposerHeight } from "../ui/composer";
import { composerShellNode, syncComposerHeight, type ComposerCallbacks } from "../ui/composer";
import { composerBoundaryScrollDirection, type ComposerBoundaryScrollAction } from "./boundary-scroll";
import { noteCandidates as appNoteCandidates, resolveWikiLinkMention as resolveAppWikiLinkMention } from "./obsidian-context";
import {
@ -32,8 +33,8 @@ export interface ChatComposerControllerOptions {
togglePlan: () => void;
toggleAutoReview: () => void;
toggleFast: () => void;
renderIfDetached: () => void;
onDraftChange: () => void;
onHeightChange: () => void;
}
export interface ChatComposerActionHandlers {
@ -43,7 +44,6 @@ export interface ChatComposerActionHandlers {
export class ChatComposerController {
private composer: HTMLTextAreaElement | null = null;
private parent: HTMLElement | null = null;
private noteCandidatesCache: { sourcePath: string; notes: NoteCandidate[] } | null = null;
private noteEventsRegistered = false;
private actionHandlers: ChatComposerActionHandlers | null = null;
@ -78,11 +78,9 @@ export class ChatComposerController {
registerEvent(this.options.app.vault.on("modify", invalidate));
}
render(parent: HTMLElement, options: { updateSuggestions?: boolean } = {}): void {
this.parent = parent;
renderNode(): UiNode {
const state = this.state;
const elements = renderComposerShell(
parent,
return composerShellNode(
this.options.viewId,
state.composer.draft,
chatTurnBusy(state),
@ -90,68 +88,29 @@ export class ChatComposerController {
this.options.composerPlaceholder(),
state.composer.suggestions,
state.composer.suggestSelected,
{
onInput: (value) => {
this.dispatch({ type: "composer/draft-set", draft: value, resetDismissedSignature: true });
this.options.onDraftChange();
this.updateSuggestions({ renderOnChange: false });
this.refreshControls();
},
onUpdateSuggestions: () => {
this.updateSuggestions();
},
onKeydown: (event) => {
if (this.handleSuggestionKeydown(event)) {
return;
}
if (this.handleBoundaryScrollKeydown(event)) {
return;
}
if (isComposerSendKey(event, this.options.sendShortcut())) {
event.preventDefault();
this.currentActionHandlers().submit();
}
},
onSendOrInterrupt: () => {
this.currentActionHandlers().submit();
},
onTogglePlan: () => {
this.options.togglePlan();
},
onToggleAutoReview: () => {
this.options.toggleAutoReview();
},
onToggleFast: () => {
this.options.toggleFast();
},
onSuggestionHover: (index) => {
this.selectSuggestion(index);
},
onSuggestionInsert: (suggestion) => {
this.insertSuggestion(suggestion);
},
},
this.composerCallbacks(),
this.options.composerMeta(),
this.setComposerElement,
);
this.composer = elements.composer;
syncComposerHeight(this.composer);
if (options.updateSuggestions !== false) this.updateSuggestions({ renderOnChange: true });
}
setDraft(text: string, options: { focus?: boolean; clearSuggestions?: boolean; renderIfDetached?: boolean } = {}): void {
private readonly setComposerElement = (composer: HTMLTextAreaElement | null): void => {
if (!composer) {
this.composer = null;
return;
}
this.composer = composer;
syncComposerHeight(composer);
};
setDraft(text: string, options: { focus?: boolean; clearSuggestions?: boolean } = {}): void {
this.dispatch({
type: "composer/draft-set",
draft: text,
...(options.clearSuggestions === undefined ? {} : { clearSuggestions: options.clearSuggestions }),
});
this.options.onDraftChange();
if (!this.composer) {
if (options.renderIfDetached) this.options.renderIfDetached();
return;
}
this.refreshControls();
if (options.focus) this.composer.focus();
if (options.focus) this.composer?.focus();
}
focus(): void {
@ -164,12 +123,10 @@ export class ChatComposerController {
dispose(): void {
this.composer = null;
this.parent = null;
}
refreshControls(parent: HTMLElement | null = this.parent, options: { updateSuggestions?: boolean } = {}): void {
if (!parent) return;
this.render(parent, options);
refreshSuggestions(): void {
this.updateSuggestions();
}
codexInput(text: string): CodexInput {
@ -188,14 +145,11 @@ export class ChatComposerController {
const direction = composerSuggestionNavigationDirection(event);
if (direction) {
event.preventDefault();
this.dispatchSuggestions(
{
type: "composer/suggestions-set",
suggestions: state.composer.suggestions,
selected: nextComposerSuggestionIndex(state.composer.suggestSelected, state.composer.suggestions.length, direction),
},
true,
);
this.dispatchSuggestions({
type: "composer/suggestions-set",
suggestions: state.composer.suggestions,
selected: nextComposerSuggestionIndex(state.composer.suggestSelected, state.composer.suggestions.length, direction),
});
return true;
}
if (event.metaKey || event.ctrlKey) return false;
@ -229,7 +183,7 @@ export class ChatComposerController {
return true;
}
private updateSuggestions({ renderOnChange }: { renderOnChange: boolean } = { renderOnChange: true }): void {
private updateSuggestions(): void {
if (!this.composer) {
this.clearSuggestions();
return;
@ -239,7 +193,7 @@ export class ChatComposerController {
const signature = this.suggestionSignature();
const state = this.state;
if (state.composer.suggestionsDismissedSignature === signature) {
this.dispatchSuggestions({ type: "composer/suggestions-set", suggestions: [] }, renderOnChange);
this.dispatchSuggestions({ type: "composer/suggestions-set", suggestions: [] });
return;
}
const beforeCursor = this.composer.value.slice(0, cursor);
@ -252,19 +206,55 @@ export class ChatComposerController {
this.options.currentModelForSuggestions(),
);
this.dispatchSuggestions(
{
type: "composer/suggestions-set",
suggestions,
selected: state.composer.suggestSelected >= suggestions.length ? 0 : state.composer.suggestSelected,
},
renderOnChange,
this.dispatchSuggestions({
type: "composer/suggestions-set",
suggestions,
selected: state.composer.suggestSelected >= suggestions.length ? 0 : state.composer.suggestSelected,
});
}
private handleInput(value: string): void {
const suggestionState = this.inputSuggestionState();
this.dispatch({
type: "composer/input-set",
draft: value,
suggestions: suggestionState.suggestions,
selected: suggestionState.selected,
dismissedSignature: suggestionState.dismissedSignature,
});
this.options.onDraftChange();
}
private inputSuggestionState(): {
suggestions: readonly ComposerSuggestion[];
selected: number;
dismissedSignature: string | null;
} {
if (!this.composer) return { suggestions: [], selected: 0, dismissedSignature: null };
const signature = this.suggestionSignature();
const state = this.state;
if (state.composer.suggestionsDismissedSignature === signature) {
return { suggestions: [], selected: 0, dismissedSignature: signature };
}
const beforeCursor = this.composer.value.slice(0, this.composer.selectionStart);
const suggestions = activeComposerSuggestions(
beforeCursor,
this.noteCandidates(),
state.connection.availableSkills,
state.threadList.listedThreads,
state.connection.availableModels,
this.options.currentModelForSuggestions(),
);
return {
suggestions,
selected: state.composer.suggestSelected >= suggestions.length ? 0 : state.composer.suggestSelected,
dismissedSignature: null,
};
}
private selectSuggestion(index: number): void {
if (this.state.composer.suggestSelected === index) return;
this.dispatchSuggestions({ type: "composer/suggestions-set", suggestions: this.state.composer.suggestions, selected: index }, true);
this.dispatchSuggestions({ type: "composer/suggestions-set", suggestions: this.state.composer.suggestions, selected: index });
}
private insertSuggestion(suggestion: ComposerSuggestion | undefined, activation: "enter" | "tab" = "enter"): void {
@ -276,32 +266,26 @@ export class ChatComposerController {
this.dispatch({ type: "composer/draft-set", draft: insertion.value, clearSuggestions: true });
this.options.onDraftChange();
this.refreshControls(this.parent, { updateSuggestions: false });
syncComposerHeight(this.composer);
this.composer.focus();
this.composer.setSelectionRange(insertion.cursor, insertion.cursor);
}
private clearSuggestions(): void {
this.dispatchSuggestions({ type: "composer/suggestions-set", suggestions: [], selected: 0 }, true);
this.dispatchSuggestions({ type: "composer/suggestions-set", suggestions: [], selected: 0 });
}
private dismissSuggestions(): void {
this.dispatchSuggestions(
{
type: "composer/suggestions-set",
suggestions: [],
selected: 0,
dismissedSignature: this.suggestionSignature(),
},
true,
);
this.dispatchSuggestions({
type: "composer/suggestions-set",
suggestions: [],
selected: 0,
dismissedSignature: this.suggestionSignature(),
});
}
private dispatchSuggestions(action: ChatAction, renderOnChange: boolean): void {
const previous = this.state;
const next = this.options.stateStore.dispatch(action);
if (renderOnChange && next !== previous) this.refreshControls();
private dispatchSuggestions(action: ChatAction): void {
this.options.stateStore.dispatch(action);
}
private suggestionSignature(): string | null {
@ -321,4 +305,48 @@ export class ChatComposerController {
if (!this.actionHandlers) throw new Error("ChatComposerController action handlers have not been attached.");
return this.actionHandlers;
}
private composerCallbacks(): ComposerCallbacks {
return {
onInput: (value) => {
this.handleInput(value);
},
onUpdateSuggestions: () => {
this.updateSuggestions();
},
onKeydown: (event) => {
if (this.handleSuggestionKeydown(event)) {
return;
}
if (this.handleBoundaryScrollKeydown(event)) {
return;
}
if (isComposerSendKey(event, this.options.sendShortcut())) {
event.preventDefault();
this.currentActionHandlers().submit();
}
},
onSendOrInterrupt: () => {
this.currentActionHandlers().submit();
},
onHeightChange: () => {
this.options.onHeightChange();
},
onTogglePlan: () => {
this.options.togglePlan();
},
onToggleAutoReview: () => {
this.options.toggleAutoReview();
},
onToggleFast: () => {
this.options.toggleFast();
},
onSuggestionHover: (index) => {
this.selectSuggestion(index);
},
onSuggestionInsert: (suggestion) => {
this.insertSuggestion(suggestion);
},
};
}
}

View file

@ -19,7 +19,7 @@ import type { RestoredThreadController } from "../threads/restored-thread-contro
import type { ThreadIdentityActions } from "../threads/thread-identity-actions";
import type { ThreadResumeController } from "../threads/thread-resume-controller";
import type { ThreadSelectionActions } from "../threads/thread-selection-controller";
import type { ChatViewRenderController, ChatViewSlotRenderers } from "./view-render-controller";
import type { ChatViewRenderController } from "./view-render-controller";
import type { ChatMessageRenderer } from "../ui/message-stream";
import type { ChatControllerCompositionPorts } from "./controller-ports";
import { createChatControllerCompositionActions, type ChatControllerCompositionBridges } from "./controller-wiring";
@ -74,7 +74,6 @@ export interface ChatViewControllers {
render: {
controller: ChatViewRenderController;
messages: ChatMessageRenderer;
attachSlotRenderers: (slotRenderers: ChatViewSlotRenderers) => void;
openView: () => void;
closeView: () => void;
applyViewState: (state: unknown) => void;
@ -83,9 +82,8 @@ export interface ChatViewControllers {
export function createChatViewControllers(ports: ChatControllerCompositionPorts): ChatViewControllers {
const connection = new ConnectionManager(() => ports.plugin.settings.codexPath, ports.plugin.vaultPath);
const { renderController } = createViewRenderControllerGroup(ports, { connection });
const { renderController } = createViewRenderControllerGroup(ports);
const bridges: ChatControllerCompositionBridges = {
systemMessages: { controller: null },
connection: { controller: null },
threadSelection: { actions: null },
messageViewport: { renderer: null },
@ -193,7 +191,6 @@ export function createChatViewControllers(ports: ChatControllerCompositionPorts)
rejectServerRequest: (requestId, code, message) => rejectServerRequest(serverRequestHost, requestId, code, message),
},
);
bridges.systemMessages.controller = inboundController;
const connectionController = createChatConnectionControllers(
{
...ports,
@ -331,9 +328,6 @@ export function createChatViewControllers(ports: ChatControllerCompositionPorts)
render: {
controller: renderController,
messages: messageRenderer,
attachSlotRenderers: (slotRenderers) => {
renderController.setSlotRenderers(slotRenderers);
},
openView,
closeView,
applyViewState,

View file

@ -1,4 +1,5 @@
import type { App, Component, EventRef, WorkspaceLeaf } from "obsidian";
import type { App, Component, EventRef } from "obsidian";
import type { ComponentChild as UiNode } from "preact";
import type { AppServerClient } from "../../../app-server/client";
import type { ArchiveExportAdapter } from "../../../domain/threads/export";
@ -7,7 +8,7 @@ import type { ChatState, ChatStateStore } from "../chat-state";
import type { CodexChatHost } from "../chat-host";
import type { ChatMessageScrollIntentController } from "../panel/message-scroll-intent-controller";
import type { DisplayDetailSection, DisplayItem } from "../display/types";
import type { ChatConnectionWorkTracker, ChatResumeWorkTracker, ChatViewDeferredTasks, ChatViewRenderScheduleOptions } from "./lifecycle";
import type { ChatConnectionWorkTracker, ChatResumeWorkTracker, ChatViewDeferredTasks } from "./lifecycle";
import type { ComposerMetaViewModel } from "./model";
export interface ChatControllerCompositionPorts {
@ -17,6 +18,8 @@ export interface ChatControllerCompositionPorts {
client: ChatPanelClientContext;
lifecycle: ChatPanelLifecycleContext;
render: ChatPanelRenderContext;
messages: ChatPanelMessageContext;
composerView: ChatPanelComposerContext;
runtime: ChatPanelRuntimeContext;
thread: ChatThreadContext;
liveState: ChatPanelLiveStateContext;
@ -30,8 +33,6 @@ interface ChatPanelObsidianContext {
viewId: string;
registerEvent: (eventRef: EventRef) => void;
registerPointerDown: (handler: (event: PointerEvent) => void) => void;
registerActiveLeafChange: (handler: (leaf: WorkspaceLeaf | null) => void) => void;
handleActiveLeafChange: (leaf: WorkspaceLeaf | null) => void;
archiveAdapter: () => ArchiveExportAdapter;
}
@ -39,6 +40,7 @@ interface ChatPanelStateContext {
stateStore: ChatStateStore;
getState: () => ChatState;
systemItem: (text: string) => DisplayItem;
structuredSystemItem: (text: string, details: DisplayDetailSection[]) => DisplayItem;
}
interface ChatPanelClientContext {
@ -66,12 +68,21 @@ interface ChatPanelLifecycleContext {
interface ChatPanelRenderContext {
panelRoot: () => HTMLElement | null;
toolbarNode: () => UiNode;
goalNode: () => UiNode;
messagesNode: () => UiNode;
composerNode: () => UiNode;
closeToolbarPanelOnOutsidePointer: (event: PointerEvent) => void;
schedule: () => void;
}
interface ChatPanelMessageContext {
pendingRequestsSignature: () => string;
activeComposerThreadName: () => string | null;
}
interface ChatPanelComposerContext {
composerPlaceholder: () => string;
composerMetaViewModel: () => ComposerMetaViewModel;
closeToolbarPanelOnOutsidePointer: (event: PointerEvent) => void;
schedule: (options?: ChatViewRenderScheduleOptions) => void;
}
interface ChatPanelRuntimeContext {
@ -98,6 +109,7 @@ interface ChatPanelLiveStateContext {
interface ChatPanelScrollContext {
forceBottom: () => void;
followBottom: () => void;
preservePosition: () => void;
}

View file

@ -1,5 +1,5 @@
import type { ChatComposerController } from "../composer/controller";
import type { ChatInboundController } from "../inbound/controller";
import type { DisplayDetailSection } from "../display/types";
import type { ChatConnectionController } from "../session/connection-controller";
import type { ThreadSelectionActions } from "../threads/thread-selection-controller";
import type { ChatMessageRenderer } from "../ui/message-stream";
@ -7,9 +7,6 @@ import type { ChatControllerCompositionPorts } from "./controller-ports";
import type { ChatViewRenderController } from "./view-render-controller";
export interface ChatControllerCompositionBridges {
systemMessages: {
controller: Pick<ChatInboundController, "addSystemMessage" | "addStructuredSystemMessage"> | null;
};
connection: {
controller: Pick<ChatConnectionController, "ensureConnected" | "refreshThreads" | "refreshSkills"> | null;
};
@ -30,11 +27,10 @@ export interface ChatControllerCompositionActions {
};
render: ChatControllerCompositionPorts["render"] & {
now: () => void;
shellSlots: () => void;
};
status: ChatControllerCompositionPorts["status"] & {
addSystemMessage: (text: string) => void;
addStructuredSystemMessage: (text: string, details: Parameters<ChatInboundController["addStructuredSystemMessage"]>[1]) => void;
addStructuredSystemMessage: (text: string, details: DisplayDetailSection[]) => void;
};
scroll: ChatControllerCompositionPorts["scroll"];
thread: ChatControllerCompositionPorts["thread"] & {
@ -61,18 +57,18 @@ export function createChatControllerCompositionActions(
now: () => {
renderController.render();
},
shellSlots: () => {
renderController.renderShellSlots();
},
};
const status = {
...ports.status,
addSystemMessage: (text: string) => {
requireCompositionBridge(bridges.systemMessages.controller, "system message bridge").addSystemMessage(text);
ports.state.stateStore.dispatch({ type: "transcript/system-message-added", item: ports.state.systemItem(text) });
render.now();
},
addStructuredSystemMessage: (text: string, details: Parameters<ChatInboundController["addStructuredSystemMessage"]>[1]) => {
requireCompositionBridge(bridges.systemMessages.controller, "system message bridge").addStructuredSystemMessage(text, details);
addStructuredSystemMessage: (text: string, details: DisplayDetailSection[]) => {
ports.state.stateStore.dispatch({
type: "transcript/system-message-added",
item: ports.state.structuredSystemItem(text, details),
});
render.now();
},
};
@ -98,7 +94,6 @@ export function createChatControllerCompositionActions(
...ports.scroll,
forceBottom: () => {
ports.scroll.forceBottom();
requireCompositionBridge(bridges.messageViewport.renderer, "message viewport bridge").forceMessagesToBottom();
},
},
thread: {
@ -111,8 +106,8 @@ export function createChatControllerCompositionActions(
setText: (text) => {
requireCompositionBridge(bridges.composerDraft.controller, "composer draft bridge").setDraft(text, {
focus: true,
renderIfDetached: true,
});
render.now();
},
},
};

View file

@ -6,10 +6,6 @@ import {
} from "../../../shared/lifecycle/connection-work";
import { DeferredTask, type DeferredTaskWindow } from "../../../shared/lifecycle/deferred-task";
export interface ChatViewRenderScheduleOptions {
forceSlots?: boolean;
}
export interface RestoredThreadState {
threadId: string;
title: string | null;
@ -40,7 +36,6 @@ export class ChatViewDeferredTasks {
private readonly renderTask: DeferredTask;
private readonly diagnosticsTask: DeferredTask;
private readonly appServerWarmupTask: DeferredTask;
private renderForceSlots = false;
constructor(getWindow: () => DeferredTaskWindow) {
this.renderTask = new DeferredTask(getWindow, 50);
@ -49,18 +44,14 @@ export class ChatViewDeferredTasks {
this.appServerWarmupTask = new DeferredTask(getWindow, 0);
}
scheduleRender(callback: (options: ChatViewRenderScheduleOptions) => void, options: ChatViewRenderScheduleOptions = {}): void {
this.renderForceSlots ||= options.forceSlots ?? false;
scheduleRender(callback: () => void): void {
this.renderTask.schedule(() => {
const forceSlots = this.renderForceSlots;
this.renderForceSlots = false;
callback({ forceSlots });
callback();
});
}
clearRender(): void {
this.renderTask.clear();
this.renderForceSlots = false;
}
scheduleDiagnostics(callback: () => void): void {

View file

@ -13,6 +13,10 @@ export class ChatMessageScrollIntentController {
this.nextIntent = "force-bottom";
}
followBottom(): void {
this.nextIntent = "follow-bottom";
}
preservePosition(): void {
this.nextIntent = "preserve";
}

View file

@ -4,17 +4,13 @@ import {
composerPlaceholder as buildComposerPlaceholder,
runtimeComposerChoices,
} from "../model";
import type { ChatViewSlotRendererPorts } from "./types";
import type { ChatPanelComposerPorts } from "./types";
export function renderComposerSlot(parent: HTMLElement, ports: ChatViewSlotRendererPorts): void {
ports.slots.renderComposer(parent);
}
export function composerPlaceholder(ports: ChatViewSlotRendererPorts): string {
export function composerPlaceholder(ports: ChatPanelComposerPorts): string {
return buildComposerPlaceholder(activeComposerThreadName(ports));
}
export function composerMetaViewModel(ports: ChatViewSlotRendererPorts) {
export function composerMetaViewModel(ports: ChatPanelComposerPorts) {
return {
...buildComposerMetaViewModel(ports.state.chat(), ports.runtime.snapshot()),
...runtimeComposerChoices({
@ -26,6 +22,6 @@ export function composerMetaViewModel(ports: ChatViewSlotRendererPorts) {
};
}
export function activeComposerThreadName(ports: ChatViewSlotRendererPorts): string | null {
function activeComposerThreadName(ports: ChatPanelComposerPorts): string | null {
return buildActiveComposerThreadName(ports.state.chat(), ports.thread.restoredPlaceholder());
}

View file

@ -1,12 +1,20 @@
import { renderGoalBanner } from "../../ui/goal-banner";
import type { ChatViewSlotRendererPorts } from "./types";
import { goalBannerNode, type GoalBannerActions, type GoalBannerOptions } from "../../ui/goal-banner";
import type { ChatPanelGoalPorts } from "./types";
export function renderGoalSlot(goal: HTMLElement, ports: ChatViewSlotRendererPorts): void {
export function goalPanelNode(ports: ChatPanelGoalPorts) {
const { goal, actions, options } = goalPanelProps(ports);
return goalBannerNode(goal, actions, options);
}
function goalPanelProps(ports: ChatPanelGoalPorts): {
goal: ReturnType<ChatPanelGoalPorts["state"]["chat"]>["activeThread"]["goal"];
actions: GoalBannerActions;
options: GoalBannerOptions;
} {
const state = ports.state.chat();
renderGoalBanner(
goal,
state.activeThread.goal,
{
return {
goal: state.activeThread.goal,
actions: {
onSave: (objective, tokenBudget) => {
void ports.actions.goal.saveObjective(objective, tokenBudget);
},
@ -26,12 +34,12 @@ export function renderGoalSlot(goal: HTMLElement, ports: ChatViewSlotRendererPor
void ports.actions.goal.clear(threadId);
},
},
{
options: {
sendShortcut: ports.settings.sendShortcut(),
editingRequested: state.ui.openDetails.has("goal:editor"),
onEditingChange: (editing) => {
ports.actions.goal.setEditingOpen(editing);
},
},
);
};
}

View file

@ -0,0 +1,7 @@
import { pendingRequestsSignature as requestStateSignature } from "../../requests/view-model";
import type { ChatPanelMessagesPorts } from "./types";
export function pendingRequestsSignature(ports: ChatPanelMessagesPorts): string {
const state = ports.state.chat();
return requestStateSignature(state.requests.approvals, state.requests.pendingUserInputs, state.requests.userInputDrafts);
}

View file

@ -0,0 +1,21 @@
import { toolbarNode } from "../../ui/toolbar";
import { toolbarViewModel as buildToolbarViewModel } from "../model";
import type { ChatPanelToolbarPorts } from "./types";
export function toolbarPanelNode(ports: ChatPanelToolbarPorts) {
return toolbarNode(toolbarViewModel(ports), ports.actions.toolbar);
}
function toolbarViewModel(ports: ChatPanelToolbarPorts) {
return buildToolbarViewModel({
state: ports.state.chat(),
snapshot: ports.runtime.snapshot(),
connected: ports.state.connected(),
turnBusy: ports.state.turnBusy(),
vaultPath: ports.settings.vaultPath(),
configuredCommand: ports.settings.configuredCommand(),
archiveConfirmThreadId: ports.view.toolbar.archiveConfirmId(),
archiveExportEnabled: ports.settings.archiveExportEnabled(),
renameState: (threadId) => ports.view.toolbar.renameState(threadId),
});
}

View file

@ -1,26 +1,33 @@
import type { ChatState } from "../../chat-state";
import type { ReasoningEffort } from "../../../../domain/catalog/metadata";
import type { RuntimeSnapshot } from "../../runtime/effective-settings";
import type { SendShortcut } from "../../../../shared/ui/keyboard";
import type { ChatState } from "../../chat-state";
import type { ToolbarActions } from "../../ui/toolbar";
import type { ToolbarThreadRow } from "../model/types";
import type { RestoredThreadTitleSnapshot } from "../model";
interface ChatViewToolbarActions extends ToolbarActions {
interface ChatPanelToolbarState {
archiveConfirmId: () => string | null;
renameState: (threadId: string) => ToolbarThreadRow["rename"];
}
interface ChatViewGoalActions {
type ChatPanelToolbarActions = ToolbarActions;
interface ChatPanelGoalActions {
saveObjective: (objective: string, tokenBudget: number | null) => Promise<void>;
setStatus: (threadId: string, status: "active" | "paused") => Promise<unknown>;
clear: (threadId: string) => Promise<unknown>;
setEditingOpen: (open: boolean) => void;
}
export interface ChatViewSlotRendererPorts {
interface ChatPanelStatePort {
state: {
chat: () => ChatState;
};
}
export interface ChatPanelToolbarPorts extends ChatPanelStatePort {
state: ChatPanelStatePort["state"] & {
connected: () => boolean;
turnBusy: () => boolean;
};
@ -28,8 +35,30 @@ export interface ChatViewSlotRendererPorts {
vaultPath: () => string;
configuredCommand: () => string;
archiveExportEnabled: () => boolean;
};
runtime: {
snapshot: () => RuntimeSnapshot;
};
view: {
toolbar: ChatPanelToolbarState;
};
actions: {
toolbar: ChatPanelToolbarActions;
};
}
export interface ChatPanelGoalPorts extends ChatPanelStatePort {
settings: {
sendShortcut: () => SendShortcut;
};
actions: {
goal: ChatPanelGoalActions;
};
}
export type ChatPanelMessagesPorts = ChatPanelStatePort;
export interface ChatPanelComposerPorts extends ChatPanelStatePort {
thread: {
restoredPlaceholder: () => RestoredThreadTitleSnapshot | null;
};
@ -38,12 +67,6 @@ export interface ChatViewSlotRendererPorts {
setRequestedModel: (model: string | null) => Promise<void>;
setRequestedReasoningEffort: (effort: ReasoningEffort | null) => Promise<void>;
};
actions: {
toolbar: ChatViewToolbarActions;
goal: ChatViewGoalActions;
};
slots: {
renderMessages: (parent: HTMLElement) => void;
renderComposer: (parent: HTMLElement) => void;
};
}
export type ChatPanelUiPorts = ChatPanelToolbarPorts & ChatPanelGoalPorts & ChatPanelMessagesPorts & ChatPanelComposerPorts;

View file

@ -1,45 +1,31 @@
import type { ComponentChild as UiNode } from "preact";
import type { ChatStateStore } from "../chat-state";
import { renderChatPanelShell } from "../ui/shell";
import { composerSlotSnapshot, goalSlotSnapshot, messagesSlotSnapshot, toolbarSlotSnapshot } from "./snapshot";
export interface ChatShellRenderPort {
render(
root: HTMLElement,
renderVersion: number,
slots: {
renderToolbar: (toolbar: HTMLElement) => void;
renderGoal: (goal: HTMLElement) => void;
renderMessages: (parent: HTMLElement) => void;
renderComposer: (parent: HTMLElement) => void;
},
): void;
render(root: HTMLElement): void;
}
export function createChatShellRenderPort(
stateStore: ChatStateStore,
options: {
connected: () => boolean;
showToolbar: () => boolean;
pendingRequestsSignature: () => string;
activeComposerThreadName: () => string | null;
toolbarNode: () => UiNode;
goalNode: () => UiNode;
messagesNode: () => UiNode;
composerNode: () => UiNode;
},
): ChatShellRenderPort {
return {
render(root, renderVersion, slots) {
render(root) {
renderChatPanelShell(root, {
stateStore,
renderVersion,
showToolbar: options.showToolbar(),
toolbar: { render: slots.renderToolbar, snapshot: (state) => toolbarSlotSnapshot(state, options.connected()) },
goal: { render: slots.renderGoal, snapshot: goalSlotSnapshot },
messages: {
render: slots.renderMessages,
snapshot: (state) => messagesSlotSnapshot(state, options.pendingRequestsSignature()),
},
composer: {
render: slots.renderComposer,
snapshot: (state) => composerSlotSnapshot(state, options.activeComposerThreadName()),
},
toolbarNode: options.toolbarNode,
goalNode: options.goalNode,
messagesNode: options.messagesNode,
composerNode: options.composerNode,
});
},
};

View file

@ -1,11 +0,0 @@
import { pendingRequestsSignature as requestStateSignature } from "../../requests/view-model";
import type { ChatViewSlotRendererPorts } from "./types";
export function renderMessagesSlot(parent: HTMLElement, ports: ChatViewSlotRendererPorts): void {
ports.slots.renderMessages(parent);
}
export function pendingRequestsSignature(ports: ChatViewSlotRendererPorts): string {
const state = ports.state.chat();
return requestStateSignature(state.requests.approvals, state.requests.pendingUserInputs, state.requests.userInputDrafts);
}

View file

@ -1,21 +0,0 @@
import { renderToolbar } from "../../ui/toolbar";
import { toolbarViewModel as buildToolbarViewModel } from "../model";
import type { ChatViewSlotRendererPorts } from "./types";
export function renderToolbarSlot(toolbar: HTMLElement, ports: ChatViewSlotRendererPorts): void {
renderToolbar(toolbar, toolbarViewModel(ports), ports.actions.toolbar);
}
function toolbarViewModel(ports: ChatViewSlotRendererPorts) {
return buildToolbarViewModel({
state: ports.state.chat(),
snapshot: ports.runtime.snapshot(),
connected: ports.state.connected(),
turnBusy: ports.state.turnBusy(),
vaultPath: ports.settings.vaultPath(),
configuredCommand: ports.settings.configuredCommand(),
archiveConfirmThreadId: ports.actions.toolbar.archiveConfirmId(),
archiveExportEnabled: ports.settings.archiveExportEnabled(),
renameState: (threadId) => ports.actions.toolbar.renameState(threadId),
});
}

View file

@ -1,12 +1,7 @@
import type { Thread } from "../../../domain/threads/model";
import type { OpenCodexPanelSnapshot } from "../../../workspace/open-panel-snapshot";
import { currentModel, runtimeConfigOrDefault } from "../runtime/effective-settings";
import { activeTurnId, chatTurnBusy, type ChatState } from "../chat-state";
import type { ChatState } from "../chat-state";
import type { DisplayItem } from "../display/types";
import type { RestoredThreadState } from "./lifecycle";
import { runtimeSnapshotForChatSlices } from "./model";
export type ChatPanelSlotSnapshot = string | number | boolean | null;
export function openPanelTurnLifecycle(state: ChatState["turn"]["lifecycle"]): OpenCodexPanelSnapshot["turnLifecycle"] {
if (state.kind === "running") return { kind: "running", turnId: state.turnId };
@ -18,94 +13,6 @@ export function latestProposedPlanItem(items: readonly DisplayItem[]): DisplayIt
return [...items].reverse().find((item) => item.kind === "message" && item.messageKind === "proposedPlan") ?? null;
}
export function toolbarSlotSnapshot(state: ChatState, connected: boolean): ChatPanelSlotSnapshot {
return signatureParts(
state.connection.status,
chatTurnBusy(state),
state.activeThread.id,
activeTurnId(state),
state.runtime.activeModel,
state.runtime.activeReasoningEffort,
state.runtime.activeCollaborationMode,
state.runtime.activeServiceTier,
state.runtime.activeApprovalPolicy,
state.runtime.activeApprovalsReviewer,
state.runtime.activePermissionProfile,
state.runtime.selectedCollaborationMode,
state.runtime.requestedServiceTier,
state.runtime.requestedApprovalsReviewer,
state.runtime.requestedModel,
state.runtime.requestedReasoningEffort,
state.ui.toolbarPanel,
state.threadList.threadsLoaded,
threadListSignature(state.threadList.listedThreads),
modelsSignature(state.connection.availableModels),
state.connection.runtimeConfig,
state.connection.rateLimit,
state.activeThread.tokenUsage,
state.connection.appServerDiagnostics,
connected,
);
}
export function messagesSlotSnapshot(state: ChatState, pendingRequestsSignature: string): ChatPanelSlotSnapshot {
return signatureParts(
state.activeThread.id,
activeTurnId(state),
state.activeThread.cwd,
state.transcript.historyCursor,
state.transcript.loadingHistory,
chatTurnBusy(state),
state.runtime.selectedCollaborationMode,
displayItemsSignature(state.transcript.displayItems),
turnDiffsSignature(state.transcript.turnDiffs),
messageStreamOpenDetailsSignature(state.ui.openDetails),
pendingRequestsSignature,
);
}
export function goalSlotSnapshot(state: ChatState): ChatPanelSlotSnapshot {
return signatureParts(state.activeThread.id, state.activeThread.goal);
}
export function composerSlotSnapshot(state: ChatState, activeComposerThreadName: string | null): ChatPanelSlotSnapshot {
return signatureParts(
state.composer.draft,
state.connection.status,
chatTurnBusy(state),
state.activeThread.id,
activeTurnId(state),
state.runtime.activeModel,
state.runtime.activeReasoningEffort,
state.runtime.activeCollaborationMode,
state.runtime.activeServiceTier,
state.runtime.activeApprovalsReviewer,
state.runtime.selectedCollaborationMode,
state.runtime.requestedServiceTier,
state.runtime.requestedApprovalsReviewer,
state.runtime.requestedModel,
state.runtime.requestedReasoningEffort,
state.activeThread.tokenUsage,
state.connection.runtimeConfig,
currentModel(
runtimeSnapshotForChatSlices({
runtimeConfig: state.connection.runtimeConfig,
activeThread: state.activeThread,
runtime: state.runtime,
rateLimit: state.connection.rateLimit,
displayItems: state.transcript.displayItems,
availableModels: state.connection.availableModels,
}),
runtimeConfigOrDefault(state.connection.runtimeConfig),
),
state.connection.availableSkills.length,
skillsSignature(state.connection.availableSkills),
modelsSignature(state.connection.availableModels),
threadListSignature(state.threadList.listedThreads),
activeComposerThreadName,
);
}
export function parseRestoredThreadState(state: unknown): RestoredThreadState | null {
if (!state || typeof state !== "object") return null;
const record = state as Record<string, unknown>;
@ -118,45 +25,3 @@ export function parseRestoredThreadState(state: unknown): RestoredThreadState |
explicitName: null,
};
}
function signatureParts(...values: unknown[]): string {
return values.map((value) => stableSignature(value)).join("\u001f");
}
function stableSignature(value: unknown): string {
if (value === null || value === undefined) return "";
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") return String(value);
return JSON.stringify(value);
}
function messageStreamOpenDetailsSignature(openDetails: ReadonlySet<string>): string {
return [...openDetails].filter(isMessageStreamOpenDetailKey).sort().join("\n");
}
function isMessageStreamOpenDetailKey(key: string): boolean {
return key.startsWith("message:") || key.startsWith("turn:") || key.startsWith("approval:") || key.endsWith(":details");
}
function turnDiffsSignature(turnDiffs: ReadonlyMap<string, string>): string {
return [...turnDiffs]
.sort(([left], [right]) => left.localeCompare(right))
.map(([turnId, diff]) => `${turnId}:${diff}`)
.join("\n");
}
function displayItemsSignature(items: readonly DisplayItem[]): string {
return stableSignature(items);
}
function threadListSignature(threads: readonly Thread[]): string {
return threads.map((thread) => signatureParts(thread.id, thread.name, thread.preview, thread.updatedAt, thread.archived)).join("\n");
}
function modelsSignature(models: ChatState["connection"]["availableModels"]): string {
return models.map((model) => stableSignature(model)).join("\n");
}
function skillsSignature(skills: ChatState["connection"]["availableSkills"]): string {
return skills.map((skill) => stableSignature(skill)).join("\n");
}

View file

@ -1,11 +1,10 @@
import type { ChatAction, ChatState, ChatStateStore } from "../chat-state";
import type { ChatThreadActions } from "../threads/thread-actions";
import type { ChatViewRenderScheduleOptions } from "./lifecycle";
export interface ToolbarPanelControllerHost {
stateStore: ChatStateStore;
threadActions: ChatThreadActions;
scheduleRender: (options?: ChatViewRenderScheduleOptions) => void;
scheduleRender: () => void;
}
export interface ToolbarOutsidePointerContext {
@ -59,13 +58,13 @@ export class ToolbarPanelController {
startArchive(threadId: string): void {
this.archiveConfirmThreadId = threadId;
this.host.scheduleRender({ forceSlots: true });
this.host.scheduleRender();
}
async archiveThread(threadId: string, saveMarkdown: boolean): Promise<void> {
if (this.archiveConfirmThreadId === threadId) this.archiveConfirmThreadId = null;
await this.host.threadActions.archiveThread(threadId, saveMarkdown);
this.host.scheduleRender({ forceSlots: true });
this.host.scheduleRender();
}
closeOnOutsidePointer(context: ToolbarOutsidePointerContext): void {
@ -77,7 +76,7 @@ export class ToolbarPanelController {
if (insideToolbarPanel && context.contains(insideToolbarPanel)) {
if (this.archiveConfirmThreadId && !target.closest(".codex-panel__archive-confirm")) {
this.archiveConfirmThreadId = null;
this.host.scheduleRender({ forceSlots: true });
this.host.scheduleRender();
}
return;
}
@ -85,7 +84,7 @@ export class ToolbarPanelController {
if (this.archiveConfirmThreadId) {
this.archiveConfirmThreadId = null;
this.host.scheduleRender({ forceSlots: true });
this.host.scheduleRender();
}
if (context.renameEditing) return;
@ -102,7 +101,7 @@ export class ToolbarPanelController {
this.dispatch({ type: "ui/panel-set", panel: null });
this.archiveConfirmThreadId = null;
this.host.scheduleRender({ forceSlots: true });
this.host.scheduleRender();
}
}

View file

@ -1,4 +1,5 @@
import type { ConnectionManager } from "../../../app-server/connection-manager";
import type { ComponentChild as UiNode } from "preact";
import type { ChatStateStore } from "../chat-state";
import type { CodexChatHost } from "../chat-host";
import type { ChatServerMetadataActions } from "../server-actions/metadata-actions";
@ -12,7 +13,7 @@ import { ChatViewRenderController } from "./view-render-controller";
import { applyChatViewState } from "./view-state-controller";
import type { ChatMessageRenderer } from "../ui/message-stream";
import { applyCachedSharedAppServerState, type CachedSharedAppServerStateSource } from "./cached-app-server-state";
import type { ChatViewDeferredTasks, ChatViewRenderScheduleOptions, RestoredThreadState } from "./lifecycle";
import type { ChatViewDeferredTasks, RestoredThreadState } from "./lifecycle";
import { createChatShellRenderPort } from "./shell-render";
interface ViewRenderControllerGroupPorts {
@ -25,27 +26,25 @@ interface ViewRenderControllerGroupPorts {
};
render: {
panelRoot: () => HTMLElement | null;
pendingRequestsSignature: () => string;
activeComposerThreadName: () => string | null;
toolbarNode: () => UiNode;
goalNode: () => UiNode;
messagesNode: () => UiNode;
composerNode: () => UiNode;
};
}
export function createViewRenderControllerGroup(
context: ViewRenderControllerGroupPorts,
refs: {
connection: ConnectionManager;
},
) {
export function createViewRenderControllerGroup(context: ViewRenderControllerGroupPorts) {
const { plugin, render, lifecycle } = context;
const { deferredTasks } = lifecycle;
return {
renderController: new ChatViewRenderController({
shell: createChatShellRenderPort(context.state.stateStore, {
connected: () => refs.connection.isConnected(),
showToolbar: () => plugin.settings.showToolbar,
pendingRequestsSignature: render.pendingRequestsSignature,
activeComposerThreadName: render.activeComposerThreadName,
toolbarNode: context.render.toolbarNode,
goalNode: context.render.goalNode,
messagesNode: context.render.messagesNode,
composerNode: context.render.composerNode,
}),
panelRoot: render.panelRoot,
clearScheduledRender: () => {
@ -56,7 +55,7 @@ export function createViewRenderControllerGroup(
}
interface ConnectionLifecycleControllerGroupPorts {
obsidian: Pick<ChatViewLifecycleHost, "handleActiveLeafChange" | "registerActiveLeafChange" | "registerEvent" | "registerPointerDown">;
obsidian: Pick<ChatViewLifecycleHost, "registerEvent" | "registerPointerDown">;
plugin: CachedSharedAppServerStateSource;
client: {
clear: () => void;
@ -113,8 +112,6 @@ export function createConnectionLifecycleControllerGroup(
refs.composerController.registerNoteIndexInvalidation(register);
},
registerPointerDown: obsidian.registerPointerDown,
registerActiveLeafChange: obsidian.registerActiveLeafChange,
handleActiveLeafChange: obsidian.handleActiveLeafChange,
applyCachedSharedAppServerState: () => {
applyCachedSharedAppServerState(plugin, refs.serverThreads, refs.serverMetadata);
},
@ -165,7 +162,7 @@ interface PanelUiControllerGroupPorts {
scheduleDeferredAppServerWarmup: () => void;
};
render: {
schedule: (options?: ChatViewRenderScheduleOptions) => void;
schedule: () => void;
};
thread: {
clearRestoredLifecycle: () => void;

View file

@ -1,4 +1,4 @@
import type { EventRef, WorkspaceLeaf } from "obsidian";
import type { EventRef } from "obsidian";
import { unmountChatPanelShell } from "../ui/shell";
@ -8,8 +8,6 @@ export interface ChatViewLifecycleHost {
registerEvent: (eventRef: EventRef) => void;
registerComposerNoteIndexInvalidation: (register: (eventRef: EventRef) => void) => void;
registerPointerDown: (handler: (event: PointerEvent) => void) => void;
registerActiveLeafChange: (handler: (leaf: WorkspaceLeaf | null) => void) => void;
handleActiveLeafChange: (leaf: WorkspaceLeaf | null) => void;
applyCachedSharedAppServerState: () => void;
render: () => void;
scheduleDeferredAppServerWarmup: () => void;
@ -36,7 +34,6 @@ export function openChatView(host: ChatViewLifecycleHost): void {
host.registerPointerDown((event) => {
host.closeToolbarPanelOnOutsidePointer(event);
});
host.registerActiveLeafChange(host.handleActiveLeafChange);
host.applyCachedSharedAppServerState();
host.render();
host.scheduleDeferredAppServerWarmup();

View file

@ -1,4 +1,3 @@
import type { ChatViewRenderScheduleOptions } from "./lifecycle";
import type { ChatShellRenderPort } from "./shell-render";
export interface ChatViewRenderControllerHost {
@ -7,53 +6,13 @@ export interface ChatViewRenderControllerHost {
clearScheduledRender: () => void;
}
export interface ChatViewSlotRenderers {
renderToolbar: (toolbar: HTMLElement) => void;
renderGoal: (goal: HTMLElement) => void;
renderMessages: (parent: HTMLElement) => void;
renderComposer: (parent: HTMLElement) => void;
}
export class ChatViewRenderController {
private shellRenderVersion = 0;
private slotRenderers: ChatViewSlotRenderers | null = null;
constructor(private readonly host: ChatViewRenderControllerHost) {}
setSlotRenderers(slotRenderers: ChatViewSlotRenderers): void {
this.slotRenderers = slotRenderers;
}
render(options: ChatViewRenderScheduleOptions = {}): void {
render(): void {
this.host.clearScheduledRender();
const root = this.host.panelRoot();
if (!root) return;
if (options.forceSlots) this.shellRenderVersion += 1;
this.host.shell.render(root, this.shellRenderVersion, {
renderToolbar: this.renderToolbarSlot,
renderGoal: this.renderGoalSlot,
renderMessages: this.renderMessagesSlot,
renderComposer: this.renderComposerSlot,
});
this.host.shell.render(root);
}
renderShellSlots(): void {
this.render({ forceSlots: true });
}
private readonly renderToolbarSlot = (toolbar: HTMLElement): void => {
this.slotRenderers?.renderToolbar(toolbar);
};
private readonly renderGoalSlot = (goal: HTMLElement): void => {
this.slotRenderers?.renderGoal(goal);
};
private readonly renderMessagesSlot = (parent: HTMLElement): void => {
this.slotRenderers?.renderMessages(parent);
};
private readonly renderComposerSlot = (parent: HTMLElement): void => {
this.slotRenderers?.renderComposer(parent);
};
}

View file

@ -1,13 +1,11 @@
import type { ComponentChild as UiNode } from "preact";
import { setDetailOpenAction, setUserInputDraftAction } from "../chat-state-actions";
import { pendingRequestSnapshot } from "../chat-state-selectors";
import { pendingRequestSnapshot, type PendingRequestSnapshot } from "../chat-state-selectors";
import type { ChatStateStore } from "../chat-state";
import type { ApprovalAction, PendingApproval } from "../requests/approval";
import type { ChatInboundController } from "../inbound/controller";
import { pendingRequestFocusSignature } from "../requests/view-model";
import { pendingRequestMessageNode } from "../ui/pending-request-message";
import { answersForPendingUserInput, userInputDraftKey, userInputOtherDraftKey, type PendingUserInput } from "../requests/user-input";
import type { PendingRequestMessageActions } from "../ui/pending-request-message";
import { answersForPendingUserInput, type PendingUserInput } from "../requests/user-input";
export interface PendingRequestControllerHost {
stateStore: ChatStateStore;
@ -19,39 +17,32 @@ export interface PendingRequestControllerHost {
export class PendingRequestController {
private lastFocusSignature = "";
private readonly messageActions: PendingRequestMessageActions = {
resolveApproval: (approval, action) => {
this.resolveApproval(approval, action);
},
resolveUserInput: (input) => {
this.resolveUserInput(input);
},
cancelUserInput: (input) => {
this.cancelUserInput(input);
},
setOpenDetail: (key, open) => {
this.host.stateStore.dispatch(setDetailOpenAction(key, open));
},
setUserInputDraft: (key, value) => {
this.host.stateStore.dispatch(setUserInputDraftAction(key, value));
},
};
constructor(private readonly host: PendingRequestControllerHost) {}
renderNode(): UiNode {
const state = pendingRequestSnapshot(this.host.stateStore.getState());
return pendingRequestMessageNode(
state.approvals,
state.pendingUserInputs,
{
values: state.userInputDrafts,
draftKey: userInputDraftKey,
otherDraftKey: userInputOtherDraftKey,
},
state.openDetails,
{
resolveApproval: (approval, action) => {
this.resolveApproval(approval, action);
},
resolveUserInput: (input) => {
this.resolveUserInput(input);
},
cancelUserInput: (input) => {
this.cancelUserInput(input);
},
setOpenDetail: (key, open) => {
this.host.stateStore.dispatch(setDetailOpenAction(key, open));
},
setUserInputDraft: (key, value) => {
this.host.stateStore.dispatch(setUserInputDraftAction(key, value));
},
},
this.consumeAutoFocus(),
);
snapshot(): PendingRequestSnapshot {
return pendingRequestSnapshot(this.host.stateStore.getState());
}
actions(): PendingRequestMessageActions {
return this.messageActions;
}
resolveApproval(approval: PendingApproval, action: ApprovalAction): void {
@ -77,7 +68,7 @@ export class PendingRequestController {
this.host.render();
}
private consumeAutoFocus(): boolean {
consumeAutoFocus(): boolean {
const state = pendingRequestSnapshot(this.host.stateStore.getState());
const signature = pendingRequestFocusSignature(state.approvals, state.pendingUserInputs);
if (!signature) {

View file

@ -13,7 +13,7 @@ import type { rejectServerRequest, respondToServerRequest } from "../requests/se
import type { ChatThreadGoalActions } from "../threads/thread-goal-actions";
import type { ThreadRenameController } from "../threads/thread-rename-controller";
import { ChatInboundController } from "../inbound/controller";
import type { ChatConnectionWorkTracker, ChatViewRenderScheduleOptions } from "../panel/lifecycle";
import type { ChatConnectionWorkTracker } from "../panel/lifecycle";
interface ChatServerActionControllerPorts {
plugin: Pick<CodexChatHost, "applyThreadListSnapshot" | "publishAppServerMetadata" | "vaultPath">;
@ -72,7 +72,7 @@ interface ChatInboundControllerPorts {
stateStore: ChatStateStore;
};
render: {
schedule: (options?: ChatViewRenderScheduleOptions) => void;
schedule: () => void;
};
thread: {
refreshThreads: () => Promise<void>;
@ -144,7 +144,7 @@ interface ChatConnectionControllerPorts {
};
render: {
now: () => void;
schedule: (options?: ChatViewRenderScheduleOptions) => void;
schedule: () => void;
};
}

View file

@ -61,7 +61,6 @@ interface ThreadControllerGroupPorts {
};
render: {
now: () => void;
shellSlots: () => void;
};
composer: {
setText: (text: string) => void;
@ -86,7 +85,7 @@ export function createThreadControllerGroup(
ensureConnected: client.ensureConnected,
currentClient: () => refs.connection.currentClient(),
refreshThreads: thread.refreshThreads,
render: render.shellSlots,
render: render.now,
addSystemMessage: status.addSystemMessage,
notifyThreadRenamed: plugin.notifyThreadRenamed.bind(plugin),
});
@ -99,6 +98,7 @@ export function createThreadControllerGroup(
render: render.now,
addSystemMessage: status.addSystemMessage,
keepCurrentScrollPosition: scroll.preservePosition,
showLatestPageAtBottom: scroll.forceBottom,
setThreadTurnPresence: resetThreadTurnPresence,
});
const invalidateResumeWork = () => {
@ -115,7 +115,7 @@ export function createThreadControllerGroup(
addSystemMessage: status.addSystemMessage,
setStatus: status.set,
setComposerText: composer.setText,
forceRenderSlots: render.shellSlots,
render: render.now,
openThreadInNewView: (threadId) => plugin.openThreadInNewView(threadId),
openThreadInCurrentPanel: thread.selectThread,
notifyThreadArchived: plugin.notifyThreadArchived.bind(plugin),
@ -162,7 +162,6 @@ export function createThreadControllerGroup(
clearDeferredRestoredThreadHydration: lifecycle.clearDeferredRestoredThreadHydration,
notifyActiveThreadIdentityChanged: thread.notifyIdentityChanged,
addSystemMessage: status.addSystemMessage,
forceMessagesToBottom: scroll.forceBottom,
render: render.now,
refreshLiveState: liveState.refresh,
syncThreadGoal: (threadId) => goals.syncThreadGoal(threadId),

View file

@ -22,7 +22,7 @@ export interface ChatThreadActionsHost {
addSystemMessage: (text: string) => void;
setStatus: (status: string) => void;
setComposerText: (text: string) => void;
forceRenderSlots: () => void;
render: () => void;
openThreadInNewView: (threadId: string) => Promise<unknown>;
openThreadInCurrentPanel: (threadId: string) => Promise<void>;
notifyThreadArchived: (threadId: string) => void;
@ -204,7 +204,7 @@ async function rollbackThread(host: ChatThreadActionsHost, threadId: string): Pr
});
host.setComposerText(candidate.text);
host.addSystemMessage("Rolled back the latest turn. Local file changes were not reverted.");
host.forceRenderSlots();
host.render();
host.setStatus("Rolled back latest turn.");
host.notifyActiveThreadIdentityChanged();
await host.refreshThreads();

View file

@ -9,6 +9,7 @@ export interface ThreadHistoryControllerHost {
render: () => void;
addSystemMessage: (text: string) => void;
keepCurrentScrollPosition: () => void;
showLatestPageAtBottom: () => void;
setThreadTurnPresence: (hadTurns: boolean) => void;
}
@ -56,6 +57,7 @@ export class ThreadHistoryController {
applyLatestPage(threadId: string, response: ThreadTurnsPage): boolean {
if (this.state.activeThread.id !== threadId) return false;
this.host.setThreadTurnPresence(response.data.length > 0);
this.host.showLatestPageAtBottom();
this.dispatch({ type: "transcript/items-replaced", items: displayItemsFromTurns(response.data), historyCursor: response.nextCursor });
this.host.render();
return true;

View file

@ -21,7 +21,6 @@ export interface ThreadResumeControllerHost {
clearDeferredRestoredThreadHydration: () => void;
notifyActiveThreadIdentityChanged: () => void;
addSystemMessage: (text: string) => void;
forceMessagesToBottom: () => void;
render: () => void;
refreshLiveState: () => void;
syncThreadGoal: (threadId: string) => Promise<void>;
@ -59,8 +58,7 @@ export class ThreadResumeController {
if (renderFallbackMessage) {
this.host.addSystemMessage(`Resumed thread ${response.thread.id}`);
}
this.host.forceMessagesToBottom();
if (renderFallbackMessage) this.host.render();
this.host.render();
this.host.refreshLiveState();
} catch (error) {
if (this.isStale(resume)) return;

View file

@ -32,6 +32,7 @@ interface ComposerStatusPort {
interface ComposerScrollPort {
forceBottom: () => void;
followBottom: () => void;
}
export interface ComposerSubmissionActionsHost {
@ -100,7 +101,7 @@ async function sendComposerTurn(
codexInputOverride?: CodexInput,
referencedThread?: ReferencedThreadDisplay,
): Promise<void> {
host.scroll.forceBottom();
host.scroll.followBottom();
if (arguments.length > 2) {
await host.turnSubmission.sendTurnText(text, codexInputOverride, referencedThread);
return;

View file

@ -22,7 +22,6 @@ import type { CodexChatHost } from "../chat-host";
import type { DisplayDetailSection } from "../display/types";
import type { ChatMessageScrollIntentController } from "../panel/message-scroll-intent-controller";
import type { ComposerMetaViewModel } from "../panel/model";
import type { ChatViewRenderScheduleOptions } from "../panel/lifecycle";
interface ConversationSurfaceControllerGroupPorts {
obsidian: {
@ -44,8 +43,12 @@ interface ConversationSurfaceControllerGroupPorts {
};
render: {
now: () => void;
schedule: (options?: ChatViewRenderScheduleOptions) => void;
schedule: () => void;
};
messages: {
pendingRequestsSignature: () => string;
};
composerView: {
composerPlaceholder: () => string;
composerMetaViewModel: () => ComposerMetaViewModel;
};
@ -74,6 +77,7 @@ interface ConversationSurfaceControllerGroupPorts {
};
scroll: {
forceBottom: () => void;
followBottom: () => void;
};
}
@ -90,7 +94,7 @@ export function createConversationSurfaceControllerGroup(
history: ThreadHistoryController;
},
) {
const { plugin, state, render, runtime, thread, liveState, status, lifecycle, client, scroll } = context;
const { plugin, state, render, messages, composerView, runtime, thread, liveState, status, lifecycle, client, scroll } = context;
const { app, owner, viewId } = context.obsidian;
const stateStore = state.stateStore;
const currentClient = client.getClient;
@ -104,14 +108,16 @@ export function createConversationSurfaceControllerGroup(
scrollThreadFromComposerEdges: () => plugin.settings.scrollThreadFromComposerEdges,
canInterrupt: () =>
state.getState().turn.lifecycle.kind !== "idle" && Boolean(state.getState().activeThread.id && activeTurnId(state.getState())),
composerPlaceholder: render.composerPlaceholder,
composerMeta: render.composerMetaViewModel,
composerPlaceholder: composerView.composerPlaceholder,
composerMeta: composerView.composerMetaViewModel,
currentModelForSuggestions: () => currentModel(runtime.runtimeSnapshot()),
togglePlan: () => void refs.runtimeSettings.toggleCollaborationMode(),
toggleAutoReview: () => void refs.runtimeSettings.toggleAutoReview(),
toggleFast: () => void refs.runtimeSettings.toggleFastMode(),
renderIfDetached: render.now,
onDraftChange: liveState.refresh,
onHeightChange: () => {
messageRenderer.repinMessagesToBottomIfPinned();
},
});
const pendingRequests = new PendingRequestController({
stateStore,
@ -230,8 +236,10 @@ export function createConversationSurfaceControllerGroup(
openTurnDiff: (state) => void plugin.openTurnDiff(state),
},
requests: {
pendingSignature: render.pendingRequestsSignature,
renderPending: () => pendingRequests.renderNode(),
pendingSignature: messages.pendingRequestsSignature,
pendingSnapshot: () => pendingRequests.snapshot(),
pendingActions: () => pendingRequests.actions(),
consumePendingAutoFocus: () => pendingRequests.consumeAutoFocus(),
},
});
const composerSubmission = createComposerSubmissionActions({
@ -249,6 +257,7 @@ export function createConversationSurfaceControllerGroup(
},
scroll: {
forceBottom: scroll.forceBottom,
followBottom: scroll.followBottom,
},
});
composerController.setActionHandlers({

View file

@ -5,18 +5,14 @@ import { useLayoutEffect, useRef, useState } from "preact/hooks";
import type { ComposerSuggestion } from "../composer/suggestions";
import type { ComposerMetaViewModel, RuntimeChoice } from "../panel/model";
import { IconButton } from "../../../shared/ui/components";
import { renderUiRoot } from "../../../shared/ui/ui-root";
import { syncTextareaHeight } from "../../../shared/ui/textarea-autogrow";
export interface ComposerElements {
composer: HTMLTextAreaElement;
}
export interface ComposerCallbacks {
onInput: (value: string) => void;
onUpdateSuggestions: () => void;
onKeydown: (event: KeyboardEvent) => void;
onSendOrInterrupt: () => void;
onHeightChange: () => void;
onTogglePlan?: () => void;
onToggleAutoReview?: () => void;
onToggleFast?: () => void;
@ -49,8 +45,7 @@ const DEFAULT_COMPOSER_META: ComposerMetaViewModel = {
effortChoices: [],
};
export function renderComposerShell(
parent: HTMLElement,
export function composerShellNode(
viewId: string,
draft: string,
busy: boolean,
@ -60,10 +55,9 @@ export function renderComposerShell(
selectedSuggestionIndex: number,
callbacks: ComposerCallbacks,
meta: ComposerMetaViewModel = DEFAULT_COMPOSER_META,
): ComposerElements {
const elements: Partial<ComposerElements> = {};
renderUiRoot(
parent,
onComposer: (composer: HTMLTextAreaElement | null) => void,
): UiNode {
return (
<ComposerShell
viewId={viewId}
draft={draft}
@ -74,13 +68,9 @@ export function renderComposerShell(
suggestions={suggestions}
selectedSuggestionIndex={selectedSuggestionIndex}
callbacks={callbacks}
onComposer={(composer) => {
elements.composer = composer;
}}
/>,
onComposer={onComposer}
/>
);
if (!elements.composer) throw new Error("Expected composer shell elements to mount.");
return { composer: elements.composer };
}
function ComposerShell({
@ -104,7 +94,7 @@ function ComposerShell({
suggestions: readonly ComposerSuggestion[];
selectedSuggestionIndex: number;
callbacks: ComposerCallbacks;
onComposer: (composer: HTMLTextAreaElement) => void;
onComposer: (composer: HTMLTextAreaElement | null) => void;
}): UiNode {
const composerRef = useRef<HTMLTextAreaElement | null>(null);
const suggestionsRef = useRef<HTMLDivElement | null>(null);
@ -114,7 +104,10 @@ function ComposerShell({
if (!composer) return;
onComposer(composer);
syncComposerHeight(composer);
}, [callbacks, onComposer]);
return () => {
onComposer(null);
};
}, [onComposer]);
useLayoutEffect(() => {
const container = suggestionsRef.current;
const selected = selectedSuggestionRef.current;
@ -140,7 +133,7 @@ function ComposerShell({
aria-activedescendant={selectedSuggestionId}
value={draft}
onInput={(event) => {
syncComposerHeight(event.currentTarget);
if (syncComposerHeight(event.currentTarget)) callbacks.onHeightChange();
callbacks.onInput(event.currentTarget.value);
}}
onKeyUp={callbacks.onUpdateSuggestions}

View file

@ -5,7 +5,6 @@ import type { ThreadGoal, ThreadGoalStatus } from "../../../app-server/thread-go
import { isComposerSendKey, type SendShortcut } from "../../../shared/ui/keyboard";
import { IconButton } from "../../../shared/ui/components";
import { syncTextareaHeight } from "../../../shared/ui/textarea-autogrow";
import { renderUiRoot } from "../../../shared/ui/ui-root";
export interface GoalBannerActions {
onSave: (objective: string, tokenBudget: number | null) => void;
@ -20,13 +19,8 @@ export interface GoalBannerOptions {
onEditingChange?: (editing: boolean) => void;
}
export function renderGoalBanner(
parent: HTMLElement,
goal: ThreadGoal | null,
actions: GoalBannerActions,
options: GoalBannerOptions,
): void {
renderUiRoot(parent, <GoalBanner goal={goal} actions={actions} options={options} />);
export function goalBannerNode(goal: ThreadGoal | null, actions: GoalBannerActions, options: GoalBannerOptions): UiNode {
return <GoalBanner goal={goal} actions={actions} options={options} />;
}
function GoalBanner({

View file

@ -5,6 +5,8 @@ import { activeTurnId } from "../../chat-state";
import { displayBlocksForItems } from "../../display/blocks";
import type { ToolResultDisplayItem } from "../../display/tool-view";
import type { DisplayBlock, DisplayItem } from "../../display/types";
import { userInputDraftKey, userInputOtherDraftKey } from "../../requests/user-input";
import { pendingRequestMessageNode } from "../pending-request-message";
import { toolResultNode } from "../tool-result";
import { activeAgentRunSummaryBlock, agentRunSummaryNode, workItemNode, type WorkItemDisplayItem } from "../work-items";
import type { MessageStreamBlock, MessageStreamContext, RenderableTextItem } from "./context";
@ -83,10 +85,24 @@ function bottomLiveBlocks(context: MessageStreamContext, activeTurn: string | nu
const blocks: MessageStreamBlock[] = [];
if (activeTurn) blocks.push(...activeTurnLiveBlocks(context, activeTurn));
if (context.renderPendingRequests && context.pendingRequestsSignature) {
if (context.pendingRequests?.signature) {
const snapshot = context.pendingRequests.snapshot();
blocks.push({
key: "pending-requests",
node: context.renderPendingRequests(),
node: pendingRequestMessageNode(
snapshot.approvals,
snapshot.pendingUserInputs,
{
values: snapshot.userInputDrafts,
draftKey: userInputDraftKey,
otherDraftKey: userInputOtherDraftKey,
},
snapshot.openDetails,
context.pendingRequests.actions(),
false,
context.pendingRequests.consumeAutoFocus,
context.pendingRequests.signature,
),
});
}
return blocks;

View file

@ -1,6 +1,5 @@
import type { ComponentChild as UiNode } from "preact";
import type { ChatState } from "../../chat-state";
import type { PendingRequestSnapshot } from "../../chat-state-selectors";
import { chatTurnBusy } from "../../chat-state";
import type { DisplayItem } from "../../display/types";
import {
@ -11,6 +10,7 @@ import {
rollbackCandidateFromItems,
} from "../../display/action-candidates";
import type { ChatTurnDiffViewState } from "../turn-diff";
import type { PendingRequestMessageActions } from "../pending-request-message";
import type { MessageStreamContext } from "./context";
export interface ChatMessageStreamActionPort {
@ -22,7 +22,9 @@ export interface ChatMessageStreamActionPort {
export interface ChatMessageStreamRequestPort {
pendingSignature: () => string;
renderPending: () => UiNode;
pendingSnapshot: () => PendingRequestSnapshot;
pendingActions: () => PendingRequestMessageActions;
consumePendingAutoFocus: () => boolean;
}
export interface ChatMessageStreamContextPort {
@ -71,7 +73,11 @@ export function createMessageStreamContext(state: ChatState, port: ChatMessageSt
openTurnDiff: (turnDiffState) => {
port.actions.openTurnDiff(turnDiffState);
},
pendingRequestsSignature: port.requests.pendingSignature(),
renderPendingRequests: () => port.requests.renderPending(),
pendingRequests: {
signature: port.requests.pendingSignature(),
snapshot: port.requests.pendingSnapshot,
actions: port.requests.pendingActions,
consumeAutoFocus: port.requests.consumePendingAutoFocus,
},
};
}

View file

@ -1,7 +1,9 @@
import type { ComponentChild as UiNode } from "preact";
import type { ChatTurnLifecycleState } from "../../chat-state";
import type { PendingRequestSnapshot } from "../../chat-state-selectors";
import type { DisplayItem } from "../../display/types";
import type { PendingRequestMessageActions } from "../pending-request-message";
import type { ChatTurnDiffViewState } from "../turn-diff";
export interface MessageStreamBlock {
@ -46,10 +48,16 @@ interface MessageStreamLayoutContext {
turnDiffs?: ReadonlyMap<string, string>;
workspaceRoot?: string | null;
loadOlderTurns: () => void;
pendingRequestsSignature?: string;
renderPendingRequests?: () => UiNode;
pendingRequests?: PendingRequestBlockContext;
}
export interface MessageItemContext extends MessageContentContext, MessageActionContext, MessageMetadataContext {}
export interface MessageStreamContext extends MessageStreamLayoutContext, MessageItemContext {}
export interface PendingRequestBlockContext {
signature: string;
snapshot: () => PendingRequestSnapshot;
actions: () => PendingRequestMessageActions;
consumeAutoFocus: () => boolean;
}

View file

@ -1,6 +1,6 @@
export { createMessageStreamContextPort } from "./context-port";
export { messageStreamBlocks } from "./blocks";
export { renderMessageStreamBlocks } from "./render";
export { messageStreamBlocksNode } from "./render";
export { ChatMessageRenderer } from "./renderer";
export { bindRenderedWikiLinks, type RenderedMarkdownLinkContext } from "./rendered-markdown-links";
export type { MessageStreamBlock } from "./context";

View file

@ -10,12 +10,18 @@ export interface MarkdownMessageRendererOptions {
}
export class MarkdownMessageRenderer {
private readonly renderGenerations = new WeakMap<HTMLElement, number>();
constructor(private readonly options: MarkdownMessageRendererOptions) {}
renderMarkdown(parent: HTMLElement, text: string): void {
const sourcePath = this.options.app.workspace.getActiveFile()?.path ?? "";
void MarkdownRenderer.render(this.options.app, text, parent, sourcePath, this.options.owner).then(() => {
if (!parent.isConnected) return;
const generation = (this.renderGenerations.get(parent) ?? 0) + 1;
this.renderGenerations.set(parent, generation);
const staging = parent.ownerDocument.createElement("div");
void MarkdownRenderer.render(this.options.app, text, staging, sourcePath, this.options.owner).then(() => {
if (!parent.isConnected || this.renderGenerations.get(parent) !== generation) return;
parent.replaceChildren(...Array.from(staging.childNodes));
bindRenderedWikiLinks(parent, sourcePath, this.options);
bindRenderedMarkdownFileLinks(parent, sourcePath, this.options);
notifyMessageContentRendered(parent);

View file

@ -0,0 +1,20 @@
import type { ChatState } from "../../chat-state";
import type { MessageStreamScrollIntent, MessageStreamVirtualizerHandle } from "../message-virtualizer";
import { messageStreamBlocks } from "./blocks";
import { createMessageStreamContext, type ChatMessageStreamContextPort } from "./context-builder";
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,49 +1,75 @@
import type { ComponentChild as UiNode } from "preact";
import { useCallback, useLayoutEffect, useRef, useState } from "preact/hooks";
import { useCallback, useLayoutEffect, useRef } from "preact/hooks";
import { renderUiRoot } from "../../../../shared/ui/ui-root";
import { MESSAGE_VIRTUAL_ITEM_INDEX_ATTRIBUTE, type MessageStreamVirtualizer } from "../message-virtualizer";
import { type MessageStreamScrollIntent, type MessageStreamVirtualizerHandle, useMessageStreamVirtualizer } from "../message-virtualizer";
import { MESSAGE_CONTENT_RENDERED_EVENT } from "../message-content-events";
import type { MessageStreamBlock } from "./context";
export function renderMessageStreamBlocks(parent: HTMLElement, blocks: MessageStreamBlock[], virtualizer: MessageStreamVirtualizer): void {
renderUiRoot(parent, <MessageStreamBlocks blocks={blocks} virtualizer={virtualizer} />);
const MESSAGE_BLOCK_ESTIMATE_SIZE = 96;
const MESSAGE_STREAM_INITIAL_RENDER_LIMIT = 32;
export interface MessageStreamRenderState {
blocks: MessageStreamBlock[];
consumeScrollIntent: () => MessageStreamScrollIntent;
registerVirtualizer?: (virtualizer: MessageStreamVirtualizerHandle) => () => void;
}
function MessageStreamBlocks({ blocks, virtualizer }: { blocks: MessageStreamBlock[]; virtualizer: MessageStreamVirtualizer }): UiNode {
const [, setVersion] = useState(0);
useLayoutEffect(() => {
virtualizer.onChange(() => {
setVersion((version) => version + 1);
});
return () => {
virtualizer.onChange(null);
};
}, [virtualizer]);
export function messageStreamBlocksNode(state: MessageStreamRenderState): UiNode {
return <MessageStreamBlocks state={state} />;
}
function MessageStreamBlocks({ state }: { state: MessageStreamRenderState }): UiNode {
const { blocks, consumeScrollIntent, registerVirtualizer } = state;
const scrollElementRef = useRef<HTMLDivElement | null>(null);
const virtualizer = useMessageStreamVirtualizer({ blocks, consumeScrollIntent, registerVirtualizer, scrollElementRef });
const virtualItems = messageStreamVirtualItems(virtualizer.getVirtualItems(), blocks, scrollElementRef.current?.scrollTop ?? 0);
const measureBlock = useCallback(
(element: HTMLElement | null) => {
virtualizer.measureElement(element);
},
[virtualizer],
);
const virtualItems = virtualizer.getVirtualItems();
return (
<div className="codex-panel__message-virtualizer" style={{ height: `${String(virtualizer.getTotalSize())}px` }}>
{virtualItems.map((virtualItem) => (
<MessageStreamBlockHost
key={virtualItem.key}
block={blocks[virtualItem.index]}
measureBlock={measureBlock}
virtualItem={virtualItem}
/>
))}
<div ref={scrollElementRef} className="codex-panel__region codex-panel__region--messages codex-panel__messages">
<div className="codex-panel__message-virtualizer" style={{ height: `${String(virtualizer.getTotalSize())}px` }}>
{virtualItems.map((virtualItem) => (
<MessageStreamBlockHost
key={String(virtualItem.key)}
block={blocks[virtualItem.index]}
measureBlock={measureBlock}
virtualItem={virtualItem}
/>
))}
</div>
</div>
);
}
export function messageStreamVirtualItems(
virtualItems: { index: number; key: unknown; start: number }[],
blocks: readonly MessageStreamBlock[],
scrollOffset = 0,
) {
if (virtualItems.length > 0 || blocks.length === 0) return virtualItems;
const startIndex = messageStreamFallbackStartIndex(blocks.length, scrollOffset);
return blocks.slice(startIndex, startIndex + MESSAGE_STREAM_INITIAL_RENDER_LIMIT).map((block, offset) => {
const index = startIndex + offset;
return {
index,
key: block.key,
start: index * MESSAGE_BLOCK_ESTIMATE_SIZE,
};
});
}
function messageStreamFallbackStartIndex(blockCount: number, scrollOffset: number): number {
const maxStartIndex = Math.max(0, blockCount - MESSAGE_STREAM_INITIAL_RENDER_LIMIT);
const estimatedFirstVisibleIndex = Math.max(0, Math.floor(scrollOffset / MESSAGE_BLOCK_ESTIMATE_SIZE));
const centeredStartIndex = Math.max(0, estimatedFirstVisibleIndex - Math.floor(MESSAGE_STREAM_INITIAL_RENDER_LIMIT / 2));
return Math.min(centeredStartIndex, maxStartIndex);
}
function MessageStreamBlockHost({
block,
measureBlock,
@ -90,7 +116,7 @@ function MessageStreamBlockHost({
ref={setBlock}
className="codex-panel__message-block"
data-codex-panel-block-key={block.key}
{...{ [MESSAGE_VIRTUAL_ITEM_INDEX_ATTRIBUTE]: String(virtualItem.index) }}
data-index={String(virtualItem.index)}
style={{ transform: `translateY(${String(virtualItem.start)}px)` }}
>
{block.node}

View file

@ -1,20 +1,15 @@
import type { App, Component } from "obsidian";
import type { ComponentChild as UiNode } from "preact";
import { copyTextWithNotice } from "../../../../shared/ui/clipboard";
import { unmountUiRoot } from "../../../../shared/ui/ui-root";
import type { ChatAction, ChatState, ChatStateStore } from "../../chat-state";
import type { ComposerBoundaryScrollAction } from "../../composer/boundary-scroll";
import { MessageStreamVirtualizer, type MessageStreamScrollIntent } from "../message-virtualizer";
import { messageStreamBlocks } from "./blocks";
import {
createMessageStreamContext,
type ChatMessageStreamActionPort,
type ChatMessageStreamContextPort,
type ChatMessageStreamRequestPort,
} from "./context-builder";
import type { MessageStreamScrollIntent, MessageStreamVirtualizerHandle } from "../message-virtualizer";
import type { ChatMessageStreamActionPort, ChatMessageStreamContextPort, ChatMessageStreamRequestPort } from "./context-builder";
import { createMessageStreamContextPort } from "./context-port";
import { MarkdownMessageRenderer } from "./markdown-renderer";
import { renderMessageStreamBlocks } from "./render";
import { messageStreamBlocksNode, type MessageStreamRenderState } from "./render";
import { createMessageStreamRenderState } from "./render-state";
interface ChatMessageRendererObsidianPort {
app: App;
@ -48,12 +43,10 @@ export interface ChatMessageRendererOptions {
}
export class ChatMessageRenderer {
private messagesEl: HTMLElement | null = null;
private readonly messageVirtualizer: MessageStreamVirtualizer;
private messageVirtualizer: MessageStreamVirtualizerHandle | null = null;
private readonly markdownRenderer: MarkdownMessageRenderer;
constructor(private readonly options: ChatMessageRendererOptions) {
this.messageVirtualizer = new MessageStreamVirtualizer();
this.markdownRenderer = new MarkdownMessageRenderer({
app: options.obsidian.app,
owner: options.obsidian.owner,
@ -69,13 +62,18 @@ export class ChatMessageRenderer {
this.options.state.store.dispatch(action);
}
render(messagesEl: HTMLElement): void {
renderNode(): UiNode {
const state = this.state;
this.messagesEl = messagesEl;
const blocks = messageStreamBlocks(createMessageStreamContext(state, this.messageStreamPort()));
const scrollPlan = this.messageVirtualizer.prepareRender(messagesEl, this.options.scroll.consumeIntent(), blocks);
renderMessageStreamBlocks(messagesEl, blocks, this.messageVirtualizer);
this.messageVirtualizer.completeRender(scrollPlan);
return messageStreamBlocksNode(this.renderStateFor(state));
}
private renderStateFor(state: ChatState): MessageStreamRenderState {
return createMessageStreamRenderState({
state,
contextPort: this.messageStreamPort(),
consumeScrollIntent: this.options.scroll.consumeIntent,
registerVirtualizer: this.registerVirtualizer,
});
}
private messageStreamPort(): ChatMessageStreamContextPort {
@ -98,25 +96,32 @@ export class ChatMessageRenderer {
}
dispose(): void {
if (this.messagesEl) {
unmountUiRoot(this.messagesEl);
}
this.messageVirtualizer.dispose();
this.messagesEl = null;
this.messageVirtualizer = null;
}
scrollFromComposer(action: ComposerBoundaryScrollAction): void {
if (action.amount === "page") {
this.messageVirtualizer.scrollByPage(action.direction);
this.messageVirtualizer?.scrollByPage(action.direction);
} else {
this.messageVirtualizer.scrollByTextLines(action.direction);
this.messageVirtualizer?.scrollByTextLines(action.direction);
}
}
forceMessagesToBottom(): void {
this.messageVirtualizer.pinToBottom(this.messagesEl);
this.messageVirtualizer?.pinToBottom();
}
repinMessagesToBottomIfPinned(): void {
this.messageVirtualizer?.repinToBottomIfPinned();
}
private readonly registerVirtualizer = (virtualizer: MessageStreamVirtualizerHandle): (() => void) => {
this.messageVirtualizer = virtualizer;
return () => {
if (this.messageVirtualizer === virtualizer) this.messageVirtualizer = null;
};
};
private async copyMessageText(text: string): Promise<void> {
await copyTextWithNotice(text, "Copied message.", "Could not copy message.");
}

View file

@ -1,8 +1,9 @@
import { observeElementOffset, observeElementRect, Virtualizer, type VirtualItem } from "@tanstack/virtual-core";
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";
export type MessageStreamScrollIntent = "auto" | "force-bottom" | "preserve";
export type MessageStreamScrollIntent = "auto" | "force-bottom" | "follow-bottom" | "preserve";
type MessageScrollDirection = -1 | 1;
interface MessageVirtualizerRenderPlan {
@ -12,191 +13,465 @@ interface MessageVirtualizerRenderPlan {
const MESSAGE_BOTTOM_THRESHOLD = 4;
const MESSAGE_BLOCK_ESTIMATE_SIZE = 96;
export const MESSAGE_VIRTUAL_ITEM_INDEX_ATTRIBUTE = "data-codex-panel-virtual-index";
const MESSAGE_SCROLL_TO_END_SETTLE_ATTEMPTS = 4;
const MESSAGE_USER_SCROLL_INTENT_WINDOW_MS = 1000;
export interface MessageStreamVirtualizerHandle {
scrollByTextLines(direction: MessageScrollDirection): void;
scrollByPage(direction: MessageScrollDirection): void;
pinToBottom(): void;
repinToBottomIfPinned(): void;
}
export class MessageStreamVirtualizer {
private container: HTMLElement | null = null;
private virtualizer: Virtualizer<HTMLElement, HTMLElement> | null = null;
private cleanupVirtualizer: (() => void) | null = null;
private blocks: readonly MessageStreamBlock[] = [];
private renderGeneration = 0;
private onVirtualizerChange: (() => void) | null = null;
private lastObservedViewportHeight: number | null = null;
private bottomPinnedBeforeViewportResize = true;
export interface MessageStreamVirtualizerView {
getTotalSize(): number;
getVirtualItems(): VirtualItem[];
measureElement(element: HTMLElement | null): void;
}
prepareRender(
container: HTMLElement,
intent: MessageStreamScrollIntent,
blocks: readonly MessageStreamBlock[],
): MessageVirtualizerRenderPlan {
this.attach(container);
const virtualizer = this.requireVirtualizer();
const pinnedBeforeRender = isElementPinnedAtBottom(container, virtualizer.getTotalSize());
interface MessageVirtualizerRuntime {
container: HTMLElement | null;
virtualizer: Virtualizer<HTMLElement, HTMLElement>;
cleanupVirtualizer: (() => void) | null;
blocks: readonly MessageStreamBlock[];
commitGeneration: number;
bottomReconcileCommitGeneration: number | null;
renderGeneration: number;
onVirtualizerChange: (() => void) | null;
pinnedToEnd: boolean;
viewportMeasurementsInvalid: boolean;
viewportRestoreFrame: number | null;
settleScrollToEndFrame: number | null;
settleScrollToEndAttemptsRemaining: number;
userScrollIntentUntil: number;
}
this.blocks = blocks;
virtualizer.setOptions(this.virtualizerOptions());
virtualizer._willUpdate();
export interface MessageStreamVirtualizerOptions {
blocks: readonly MessageStreamBlock[];
consumeScrollIntent: () => MessageStreamScrollIntent;
registerVirtualizer: ((virtualizer: MessageStreamVirtualizerHandle) => () => void) | undefined;
scrollElementRef: { current: HTMLElement | null };
}
const shouldScrollToBottom = intent === "force-bottom" || (intent !== "preserve" && pinnedBeforeRender);
return {
generation: ++this.renderGeneration,
shouldScrollToBottom,
};
}
completeRender(plan: MessageVirtualizerRenderPlan): void {
const virtualizer = this.virtualizer;
if (!virtualizer || plan.generation !== this.renderGeneration) return;
virtualizer._willUpdate();
if (plan.shouldScrollToBottom) {
virtualizer.scrollToEnd();
this.rememberScrollMetrics(undefined, { forcePinned: true });
return;
}
this.rememberScrollMetrics(undefined, { scrollSize: virtualizer.getTotalSize() });
}
getTotalSize(): number {
return this.virtualizer?.getTotalSize() ?? 0;
}
getVirtualItems(): VirtualItem[] {
return this.virtualizer?.getVirtualItems() ?? [];
}
measureElement(element: HTMLElement | null): void {
this.virtualizer?.measureElement(element);
}
onChange(callback: (() => void) | null): void {
this.onVirtualizerChange = callback;
}
pinToBottom(container = this.container): void {
if (!container) return;
this.attach(container);
this.virtualizer?.scrollToEnd();
this.rememberScrollMetrics(undefined, { forcePinned: true });
}
scrollByTextLines(direction: MessageScrollDirection, container = this.container): void {
if (!container) return;
const delta = Math.max(1, Math.round(textLineHeight(container) * 2)) * direction;
this.scrollBy(delta);
}
scrollByPage(direction: MessageScrollDirection, container = this.container): void {
if (!container) return;
const delta = Math.max(1, Math.floor(container.clientHeight * 0.8)) * direction;
this.scrollBy(delta);
}
dispose(): void {
this.cleanupVirtualizer?.();
this.cleanupVirtualizer = null;
this.virtualizer = null;
this.container = null;
this.blocks = [];
this.onVirtualizerChange = null;
this.lastObservedViewportHeight = null;
this.bottomPinnedBeforeViewportResize = true;
}
private attach(container: HTMLElement): void {
if (this.container === container && this.virtualizer) return;
this.dispose();
this.container = container;
this.virtualizer = new Virtualizer(this.virtualizerOptions());
this.virtualizer.shouldAdjustScrollPositionOnItemSizeChange = () => this.bottomPinnedBeforeViewportResize;
this.cleanupVirtualizer = this.virtualizer._didMount();
this.virtualizer._willUpdate();
}
private requireVirtualizer(): Virtualizer<HTMLElement, HTMLElement> {
if (!this.virtualizer) throw new Error("Expected message virtualizer to be attached.");
return this.virtualizer;
}
private virtualizerOptions() {
const paddingBlock = messageBlockPadding(this.container);
return {
count: this.blocks.length,
getScrollElement: () => this.container,
estimateSize: () => MESSAGE_BLOCK_ESTIMATE_SIZE,
initialOffset: () => this.container?.scrollTop ?? 0,
getItemKey: (index: number) => this.blocks[index]?.key ?? index,
indexAttribute: MESSAGE_VIRTUAL_ITEM_INDEX_ATTRIBUTE,
anchorTo: "end" as const,
followOnAppend: true,
scrollEndThreshold: MESSAGE_BOTTOM_THRESHOLD,
paddingStart: paddingBlock,
paddingEnd: paddingBlock,
useAnimationFrameWithResizeObserver: true,
overscan: 8,
observeElementRect: (instance: Virtualizer<HTMLElement, HTMLElement>, cb: (rect: { width: number; height: number }) => void) =>
observeElementRect(instance, (rect) => {
const pinnedBeforeResize = this.bottomPinnedBeforeViewportResize;
const resized = this.lastObservedViewportHeight !== null && rect.height !== this.lastObservedViewportHeight;
cb(rect);
this.lastObservedViewportHeight = rect.height;
if (resized && pinnedBeforeResize) {
instance.scrollToEnd();
this.rememberScrollMetrics(instance.scrollElement, { forcePinned: true });
return;
}
this.rememberScrollMetrics(instance.scrollElement, { scrollSize: instance.getTotalSize() });
}),
observeElementOffset: (instance: Virtualizer<HTMLElement, HTMLElement>, callback: (offset: number, isScrolling: boolean) => void) =>
observeElementOffset(instance, (offset, isScrolling) => {
callback(offset, isScrolling);
const scrollElement = instance.scrollElement;
if (scrollElement && this.isViewportResizePending(scrollElement) && this.bottomPinnedBeforeViewportResize) {
instance.scrollToEnd();
this.rememberScrollMetrics(scrollElement, { forcePinned: true });
return;
}
this.rememberScrollMetrics(scrollElement, { scrollSize: instance.getTotalSize() });
}),
scrollToFn: scrollMessageElement,
measureElement: measureMessageElement,
onChange: () => {
this.onVirtualizerChange?.();
export function useMessageStreamVirtualizer({
blocks,
consumeScrollIntent,
registerVirtualizer,
scrollElementRef,
}: MessageStreamVirtualizerOptions): MessageStreamVirtualizerView {
const runtimeRef = useRef<MessageVirtualizerRuntime | null>(null);
const consumeScrollIntentRef = useRef(consumeScrollIntent);
consumeScrollIntentRef.current = consumeScrollIntent;
runtimeRef.current ??= createMessageVirtualizerRuntime();
const runtime = runtimeRef.current;
const virtualizerHandle = useMemo<MessageStreamVirtualizerHandle>(
() => ({
scrollByTextLines(direction) {
if (runtimeRef.current) scrollMessageVirtualizerByTextLines(runtimeRef.current, direction);
},
scrollByPage(direction) {
if (runtimeRef.current) scrollMessageVirtualizerByPage(runtimeRef.current, direction);
},
pinToBottom() {
if (runtimeRef.current) pinMessageVirtualizerToBottom(runtimeRef.current);
},
repinToBottomIfPinned() {
if (runtimeRef.current) repinMessageVirtualizerToBottomIfPinned(runtimeRef.current);
},
}),
[],
);
const [, setVersion] = useState(0);
useLayoutEffect(() => {
const unregister = registerVirtualizer?.(virtualizerHandle);
return () => {
unregister?.();
};
}
}, [registerVirtualizer, virtualizerHandle]);
private scrollBy(delta: number): void {
const container = this.container;
if (!container) return;
scrollMessageElementToTop(container, container.scrollTop + delta);
this.rememberScrollMetrics(container);
}
useLayoutEffect(() => {
return () => {
if (!runtimeRef.current) return;
disposeMessageVirtualizer(runtimeRef.current);
runtimeRef.current = null;
};
}, []);
private isViewportResizePending(element: HTMLElement): boolean {
return this.lastObservedViewportHeight !== null && element.clientHeight !== this.lastObservedViewportHeight;
}
useLayoutEffect(() => {
setMessageVirtualizerChangeHandler(runtime, () => {
setVersion((version) => version + 1);
});
return () => {
setMessageVirtualizerChangeHandler(runtime, null);
};
}, [runtime]);
private rememberScrollMetrics(
element = this.container,
options: {
forcePinned?: boolean;
scrollSize?: number;
} = {},
): void {
if (!element) {
this.bottomPinnedBeforeViewportResize = true;
return;
}
this.bottomPinnedBeforeViewportResize = options.forcePinned ? true : isElementPinnedAtBottom(element, options.scrollSize);
useLayoutEffect(() => {
// Run before the render effect below: bottom requests made during one commit are reconciled after the DOM for the next commit exists.
reconcileMessageVirtualizerBottomAfterCommit(runtime);
});
useLayoutEffect(() => {
const scrollElement = scrollElementRef.current;
if (!scrollElement) return;
renderMessageVirtualizer(runtime, scrollElement, consumeScrollIntentRef.current(), blocks);
setVersion((version) => version + 1);
}, [blocks, runtime, scrollElementRef]);
return useMemo(
() => ({
getTotalSize() {
return getMessageVirtualizerTotalSize(runtime);
},
getVirtualItems() {
return getMessageVirtualizerItems(runtime);
},
measureElement(element) {
measureMessageVirtualizerElement(runtime, element);
},
}),
[runtime],
);
}
function createMessageVirtualizerRuntime(): MessageVirtualizerRuntime {
const runtime: MessageVirtualizerRuntime = {
container: null,
blocks: [],
virtualizer: null as never,
cleanupVirtualizer: null,
commitGeneration: 0,
bottomReconcileCommitGeneration: null,
renderGeneration: 0,
onVirtualizerChange: null,
pinnedToEnd: false,
viewportMeasurementsInvalid: false,
viewportRestoreFrame: null,
settleScrollToEndFrame: null,
settleScrollToEndAttemptsRemaining: 0,
userScrollIntentUntil: 0,
};
runtime.virtualizer = new Virtualizer(messageVirtualizerOptions(runtime));
return runtime;
}
function prepareMessageVirtualizerRender(
runtime: MessageVirtualizerRuntime,
container: HTMLElement,
intent: MessageStreamScrollIntent,
blocks: readonly MessageStreamBlock[],
): MessageVirtualizerRenderPlan {
attachMessageVirtualizer(runtime, container);
handleMessageVirtualizerViewportElement(runtime, container);
const appendingBlocks = blocks.length > runtime.blocks.length;
const shouldFollowAppend =
intent === "auto" &&
appendingBlocks &&
(runtime.pinnedToEnd || isElementAtEnd(container, runtime.virtualizer.getTotalSize(), MESSAGE_BOTTOM_THRESHOLD));
runtime.blocks = blocks;
const shouldScrollToBottom = blocks.length > 0 && (intent === "force-bottom" || intent === "follow-bottom" || shouldFollowAppend);
if (intent === "preserve") {
runtime.pinnedToEnd = false;
cancelSettledMessageVirtualizerScrollToEnd(runtime);
runtime.bottomReconcileCommitGeneration = null;
}
runtime.virtualizer.setOptions(messageVirtualizerOptions(runtime));
updateMessageVirtualizer(runtime.virtualizer);
return {
generation: ++runtime.renderGeneration,
shouldScrollToBottom,
};
}
function completeMessageVirtualizerRender(runtime: MessageVirtualizerRuntime, plan: MessageVirtualizerRenderPlan): void {
if (plan.generation !== runtime.renderGeneration) return;
updateMessageVirtualizer(runtime.virtualizer);
if (plan.shouldScrollToBottom) {
requestMessageVirtualizerScrollToEnd(runtime);
return;
}
}
function isElementPinnedAtBottom(element: HTMLElement, fallbackScrollSize = 0): boolean {
const scrollSize = fallbackScrollSize > 0 ? fallbackScrollSize : element.scrollHeight;
return scrollSize - element.clientHeight - element.scrollTop <= MESSAGE_BOTTOM_THRESHOLD;
function renderMessageVirtualizer(
runtime: MessageVirtualizerRuntime,
container: HTMLElement,
intent: MessageStreamScrollIntent,
blocks: readonly MessageStreamBlock[],
): void {
completeMessageVirtualizerRender(runtime, prepareMessageVirtualizerRender(runtime, container, intent, blocks));
}
function getMessageVirtualizerTotalSize(runtime: MessageVirtualizerRuntime): number {
return runtime.virtualizer.getTotalSize();
}
function getMessageVirtualizerItems(runtime: MessageVirtualizerRuntime): VirtualItem[] {
return runtime.virtualizer.getVirtualItems();
}
function measureMessageVirtualizerElement(runtime: MessageVirtualizerRuntime, element: HTMLElement | null): void {
const shouldSettleAtEnd = shouldMessageVirtualizerFollowEnd(runtime);
runtime.virtualizer.measureElement(element);
if (element && shouldSettleAtEnd) requestMessageVirtualizerScrollToEnd(runtime);
}
function reconcileMessageVirtualizerBottomAfterCommit(runtime: MessageVirtualizerRuntime): void {
runtime.commitGeneration += 1;
const reconcileGeneration = runtime.bottomReconcileCommitGeneration;
if (reconcileGeneration === null || reconcileGeneration > runtime.commitGeneration) return;
runtime.bottomReconcileCommitGeneration = null;
if (!runtime.container || !shouldMessageVirtualizerFollowEnd(runtime)) return;
runtime.virtualizer.getTotalSize();
runtime.virtualizer.scrollToEnd();
scheduleSettledMessageVirtualizerScrollToEnd(runtime);
}
function measureRenderedMessageBlocks(runtime: MessageVirtualizerRuntime): void {
for (const element of runtime.container?.querySelectorAll<HTMLElement>(".codex-panel__message-block") ?? []) {
runtime.virtualizer.measureElement(element);
}
}
function setMessageVirtualizerChangeHandler(runtime: MessageVirtualizerRuntime, callback: (() => void) | null): void {
runtime.onVirtualizerChange = callback;
}
function pinMessageVirtualizerToBottom(runtime: MessageVirtualizerRuntime, container = runtime.container): void {
if (!container) return;
attachMessageVirtualizer(runtime, container);
runtime.virtualizer.setOptions(messageVirtualizerOptions(runtime));
updateMessageVirtualizer(runtime.virtualizer);
requestMessageVirtualizerScrollToEnd(runtime);
}
function repinMessageVirtualizerToBottomIfPinned(runtime: MessageVirtualizerRuntime): void {
if (!runtime.pinnedToEnd) return;
requestMessageVirtualizerScrollToEnd(runtime);
}
function resetMessageVirtualizerMeasurements(runtime: MessageVirtualizerRuntime): void {
const container = runtime.container;
if (!container) return;
// A hidden Obsidian pane can leave TanStack measurements stale while the transcript state is still valid.
// Reset only the virtualizer runtime, then measure the still-rendered blocks.
resetMessageVirtualizer(runtime, container);
runtime.onVirtualizerChange?.();
measureRenderedMessageBlocks(runtime);
}
function handleMessageVirtualizerViewportRect(runtime: MessageVirtualizerRuntime, rect: { width: number; height: number }): void {
if (isInvalidMessageViewportRect(rect)) {
runtime.viewportMeasurementsInvalid = true;
cancelViewportRestoreMessageVirtualizerReset(runtime);
return;
}
if (!runtime.viewportMeasurementsInvalid) return;
scheduleViewportRestoreMessageVirtualizerReset(runtime);
}
function scheduleViewportRestoreMessageVirtualizerReset(runtime: MessageVirtualizerRuntime): void {
const container = runtime.container;
if (!container || runtime.viewportRestoreFrame !== null) return;
runtime.viewportRestoreFrame = container.win.requestAnimationFrame(() => {
runtime.viewportRestoreFrame = null;
if (runtime.container !== container || !isValidMessageViewportElement(container)) return;
runtime.viewportMeasurementsInvalid = false;
resetMessageVirtualizerMeasurements(runtime);
// Viewport restore is equivalent to activating/resuming the panel: rebuild stale measurements, then return to the end.
requestMessageVirtualizerScrollToEnd(runtime);
});
}
function cancelViewportRestoreMessageVirtualizerReset(runtime: MessageVirtualizerRuntime): void {
const container = runtime.container;
if (container && runtime.viewportRestoreFrame !== null) {
container.win.cancelAnimationFrame(runtime.viewportRestoreFrame);
}
runtime.viewportRestoreFrame = null;
}
function scrollMessageVirtualizerByTextLines(runtime: MessageVirtualizerRuntime, direction: MessageScrollDirection): void {
const container = runtime.container;
if (!container) return;
const delta = Math.max(1, Math.round(textLineHeight(container) * 2)) * direction;
scrollMessageVirtualizerBy(runtime, delta);
}
function scrollMessageVirtualizerByPage(runtime: MessageVirtualizerRuntime, direction: MessageScrollDirection): void {
const container = runtime.container;
if (!container) return;
const delta = Math.max(1, Math.floor(container.clientHeight * 0.8)) * direction;
scrollMessageVirtualizerBy(runtime, delta);
}
function disposeMessageVirtualizer(runtime: MessageVirtualizerRuntime): void {
cancelViewportRestoreMessageVirtualizerReset(runtime);
cancelSettledMessageVirtualizerScrollToEnd(runtime);
runtime.cleanupVirtualizer?.();
runtime.cleanupVirtualizer = null;
runtime.container = null;
runtime.blocks = [];
runtime.commitGeneration = 0;
runtime.bottomReconcileCommitGeneration = null;
runtime.renderGeneration = 0;
runtime.onVirtualizerChange = null;
runtime.pinnedToEnd = false;
runtime.viewportMeasurementsInvalid = false;
runtime.userScrollIntentUntil = 0;
}
function attachMessageVirtualizer(runtime: MessageVirtualizerRuntime, container: HTMLElement): void {
if (runtime.container === container) return;
detachMessageVirtualizer(runtime);
resetMessageVirtualizer(runtime, container);
}
function detachMessageVirtualizer(runtime: MessageVirtualizerRuntime): void {
cancelViewportRestoreMessageVirtualizerReset(runtime);
cancelSettledMessageVirtualizerScrollToEnd(runtime);
runtime.cleanupVirtualizer?.();
runtime.cleanupVirtualizer = null;
runtime.container = null;
runtime.blocks = [];
runtime.bottomReconcileCommitGeneration = null;
runtime.renderGeneration = 0;
runtime.pinnedToEnd = false;
runtime.viewportMeasurementsInvalid = false;
runtime.userScrollIntentUntil = 0;
}
function resetMessageVirtualizer(runtime: MessageVirtualizerRuntime, container: HTMLElement): void {
cancelViewportRestoreMessageVirtualizerReset(runtime);
cancelSettledMessageVirtualizerScrollToEnd(runtime);
runtime.cleanupVirtualizer?.();
runtime.cleanupVirtualizer = null;
runtime.container = container;
runtime.pinnedToEnd = false;
runtime.virtualizer = new Virtualizer(messageVirtualizerOptions(runtime));
runtime.cleanupVirtualizer = mountMessageVirtualizer(runtime);
updateMessageVirtualizer(runtime.virtualizer);
}
function messageVirtualizerOptions(runtime: MessageVirtualizerRuntime) {
const paddingBlock = messageBlockPadding(runtime.container);
return {
count: runtime.blocks.length,
getScrollElement: () => runtime.container,
estimateSize: () => MESSAGE_BLOCK_ESTIMATE_SIZE,
initialRect: scrollElementRect(runtime.container),
initialOffset: () => runtime.container?.scrollTop ?? 0,
getItemKey: (index: number) => runtime.blocks[index]?.key ?? index,
anchorTo: "end" as const,
followOnAppend: true,
scrollEndThreshold: MESSAGE_BOTTOM_THRESHOLD,
paddingStart: paddingBlock,
paddingEnd: paddingBlock,
// Obsidian/Electron can report ResizeObserver loop warnings during resume when message rendering mutates the DOM heavily.
// Deferring measurement keeps those resume measurements from feeding back into the same observer delivery.
useAnimationFrameWithResizeObserver: true,
overscan: 8,
observeElementRect: (instance: Virtualizer<HTMLElement, HTMLElement>, callback: (rect: { width: number; height: number }) => void) =>
observeElementRect(instance, (rect) => {
handleMessageVirtualizerViewportRect(runtime, rect);
callback(rect);
}),
observeElementOffset: (instance: Virtualizer<HTMLElement, HTMLElement>, callback: (offset: number, isScrolling: boolean) => void) =>
observeElementOffset(instance, (offset, isScrolling) => {
callback(offset, isScrolling);
const element = instance.scrollElement;
const atEnd = element
? isScrollOffsetAtEnd(offset, element.clientHeight, instance.getTotalSize(), MESSAGE_BOTTOM_THRESHOLD)
: false;
if (atEnd) {
runtime.pinnedToEnd = true;
return;
}
if (element && hasRecentUserScrollIntent(runtime, element)) {
runtime.pinnedToEnd = false;
cancelSettledMessageVirtualizerScrollToEnd(runtime);
runtime.bottomReconcileCommitGeneration = null;
}
}),
scrollToFn: elementScroll,
measureElement: (element: HTMLElement, entry: ResizeObserverEntry | undefined, instance: Virtualizer<HTMLElement, HTMLElement>) =>
measureMessageElement(runtime, element, entry, instance),
onChange: () => {
runtime.onVirtualizerChange?.();
},
};
}
function scrollMessageVirtualizerBy(runtime: MessageVirtualizerRuntime, delta: number): void {
runtime.pinnedToEnd = false;
cancelSettledMessageVirtualizerScrollToEnd(runtime);
runtime.bottomReconcileCommitGeneration = null;
runtime.virtualizer.scrollBy(delta);
}
function shouldMessageVirtualizerFollowEnd(runtime: MessageVirtualizerRuntime): boolean {
return runtime.pinnedToEnd;
}
function requestMessageVirtualizerScrollToEnd(runtime: MessageVirtualizerRuntime): void {
runtime.pinnedToEnd = true;
runtime.virtualizer.getTotalSize();
runtime.virtualizer.scrollToEnd();
scheduleMessageVirtualizerBottomReconcileAfterCommit(runtime);
scheduleSettledMessageVirtualizerScrollToEnd(runtime);
}
function scheduleMessageVirtualizerBottomReconcileAfterCommit(runtime: MessageVirtualizerRuntime): void {
runtime.bottomReconcileCommitGeneration = runtime.commitGeneration + 1;
}
function scheduleSettledMessageVirtualizerScrollToEnd(runtime: MessageVirtualizerRuntime): void {
const container = runtime.container;
if (!container) return;
// scrollToEnd can be clamped before the virtualizer height reaches the DOM; keep a bounded post-render settle.
runtime.settleScrollToEndAttemptsRemaining = MESSAGE_SCROLL_TO_END_SETTLE_ATTEMPTS;
if (runtime.settleScrollToEndFrame !== null) return;
scheduleSettledMessageVirtualizerScrollToEndFrame(runtime, container, 2);
}
function scheduleSettledMessageVirtualizerScrollToEndFrame(
runtime: MessageVirtualizerRuntime,
container: HTMLElement,
delayFrames: number,
): void {
runtime.settleScrollToEndFrame = container.win.requestAnimationFrame(() => {
runtime.settleScrollToEndFrame = null;
if (runtime.container !== container) {
runtime.settleScrollToEndAttemptsRemaining = 0;
return;
}
if (delayFrames > 1) {
scheduleSettledMessageVirtualizerScrollToEndFrame(runtime, container, delayFrames - 1);
return;
}
if (!shouldMessageVirtualizerFollowEnd(runtime)) {
runtime.settleScrollToEndAttemptsRemaining = 0;
return;
}
const totalSize = runtime.virtualizer.getTotalSize();
runtime.virtualizer.scrollToEnd();
runtime.settleScrollToEndAttemptsRemaining = Math.max(0, runtime.settleScrollToEndAttemptsRemaining - 1);
if (runtime.settleScrollToEndAttemptsRemaining > 0 && !isElementAtEnd(container, totalSize, MESSAGE_BOTTOM_THRESHOLD)) {
scheduleSettledMessageVirtualizerScrollToEndFrame(runtime, container, 1);
}
});
}
function cancelSettledMessageVirtualizerScrollToEnd(runtime: MessageVirtualizerRuntime): void {
const container = runtime.container;
if (container && runtime.settleScrollToEndFrame !== null) {
container.win.cancelAnimationFrame(runtime.settleScrollToEndFrame);
}
runtime.settleScrollToEndFrame = null;
runtime.settleScrollToEndAttemptsRemaining = 0;
}
function messageBlockPadding(element: HTMLElement | null): number {
@ -207,46 +482,57 @@ function messageBlockPadding(element: HTMLElement | null): number {
return Number.isFinite(value) ? value : 0;
}
function scrollElementRect(element: HTMLElement | null): { width: number; height: number } {
return {
width: nonZeroDimension(element?.clientWidth, 240),
height: nonZeroDimension(element?.clientHeight, 320),
};
}
function handleMessageVirtualizerViewportElement(runtime: MessageVirtualizerRuntime, element: HTMLElement): void {
// ResizeObserver can miss the hidden-at-start case; render entry also checks the live viewport dimensions.
handleMessageVirtualizerViewportRect(runtime, { width: element.clientWidth, height: element.clientHeight });
}
function isInvalidMessageViewportRect(rect: { width: number; height: number }): boolean {
return rect.width <= 0 || rect.height <= 0;
}
function isValidMessageViewportElement(element: HTMLElement): boolean {
return element.clientWidth > 0 && element.clientHeight > 0;
}
function nonZeroDimension(value: number | undefined, fallback: number): number {
return value === undefined || value === 0 ? fallback : value;
}
function isElementAtEnd(element: HTMLElement, fallbackScrollSize: number, threshold: number): boolean {
const scrollSize = Math.max(element.scrollHeight, fallbackScrollSize);
return scrollSize - element.clientHeight - element.scrollTop <= threshold;
}
function isScrollOffsetAtEnd(offset: number, viewportSize: number, totalSize: number, threshold: number): boolean {
return totalSize - viewportSize - offset <= threshold;
}
function markMessageVirtualizerUserScrollIntent(runtime: MessageVirtualizerRuntime, container: HTMLElement): void {
runtime.userScrollIntentUntil = container.win.performance.now() + MESSAGE_USER_SCROLL_INTENT_WINDOW_MS;
}
function hasRecentUserScrollIntent(runtime: MessageVirtualizerRuntime, container: HTMLElement): boolean {
return container.win.performance.now() <= runtime.userScrollIntentUntil;
}
function measureMessageElement(
runtime: MessageVirtualizerRuntime,
element: HTMLElement,
entry: ResizeObserverEntry | undefined,
instance: Virtualizer<HTMLElement, HTMLElement>,
): number {
const box = entry?.borderBoxSize[0];
if (box) return Math.round(box.blockSize);
return element.offsetHeight || instance.options.estimateSize(instance.indexFromElement(element));
}
function scrollMessageElement(
offset: number,
options: { adjustments?: number; behavior?: "auto" | "smooth" | "instant" },
instance: Virtualizer<HTMLElement, HTMLElement>,
): void {
const element = instance.scrollElement;
if (!element) return;
const unclampedTop = Math.max(0, offset + (options.adjustments ?? 0));
scrollMessageElementToTop(element, unclampedTop, options.behavior, instance.getTotalSize());
}
function scrollMessageElementToTop(
element: HTMLElement,
scrollTop: number,
behavior?: "auto" | "smooth" | "instant",
fallbackScrollSize = 0,
): void {
const scrollSize = Math.max(element.scrollHeight, fallbackScrollSize);
const top = scrollSize > 0 ? Math.min(Math.max(0, scrollSize - element.clientHeight), Math.max(0, scrollTop)) : Math.max(0, scrollTop);
const scrollBehavior = behavior === "instant" ? "auto" : behavior;
if (typeof element.scrollTo === "function") {
if (scrollBehavior) {
element.scrollTo({ top, behavior: scrollBehavior });
} else {
element.scrollTo({ top });
}
} else {
element.scrollTop = top;
}
const size = box ? Math.round(box.blockSize) : element.offsetHeight || instance.options.estimateSize(instance.indexFromElement(element));
if (entry && shouldMessageVirtualizerFollowEnd(runtime)) scheduleMessageVirtualizerBottomReconcileAfterCommit(runtime);
return size;
}
function textLineHeight(element: HTMLElement): number {
@ -257,3 +543,57 @@ function textLineHeight(element: HTMLElement): number {
const fontSize = Number.parseFloat(style.fontSize);
return Number.isFinite(fontSize) ? fontSize * 1.5 : 20;
}
function mountMessageVirtualizer(runtime: MessageVirtualizerRuntime): () => void {
// Match the official TanStack framework adapters: create the core instance in userland, then call _didMount from the hook.
const cleanupVirtualizer = runtime.virtualizer._didMount();
const cleanupUserScrollTracking = mountMessageVirtualizerUserScrollTracking(runtime);
return () => {
cleanupUserScrollTracking();
cleanupVirtualizer();
};
}
function updateMessageVirtualizer(virtualizer: Virtualizer<HTMLElement, HTMLElement>): void {
// Same adapter pattern as TanStack's React/Solid/etc. bindings; this is the Preact-local _willUpdate bridge.
virtualizer._willUpdate();
}
function mountMessageVirtualizerUserScrollTracking(runtime: MessageVirtualizerRuntime): () => void {
const container = runtime.container;
if (!container) {
return () => {
// No scroll element is attached yet.
};
}
const markUserScrollIntent = () => {
markMessageVirtualizerUserScrollIntent(runtime, container);
};
const markKeyboardScrollIntent = (event: KeyboardEvent) => {
if (isKeyboardScrollEvent(event)) markUserScrollIntent();
};
container.addEventListener("wheel", markUserScrollIntent, { passive: true });
container.addEventListener("touchstart", markUserScrollIntent, { passive: true });
container.addEventListener("pointerdown", markUserScrollIntent);
container.addEventListener("mousedown", markUserScrollIntent);
container.addEventListener("keydown", markKeyboardScrollIntent);
return () => {
container.removeEventListener("wheel", markUserScrollIntent);
container.removeEventListener("touchstart", markUserScrollIntent);
container.removeEventListener("pointerdown", markUserScrollIntent);
container.removeEventListener("mousedown", markUserScrollIntent);
container.removeEventListener("keydown", markKeyboardScrollIntent);
};
}
function isKeyboardScrollEvent(event: KeyboardEvent): boolean {
return (
event.key === "ArrowUp" ||
event.key === "ArrowDown" ||
event.key === "PageUp" ||
event.key === "PageDown" ||
event.key === "Home" ||
event.key === "End" ||
event.key === " "
);
}

View file

@ -35,7 +35,9 @@ export function pendingRequestMessageNode(
drafts: PendingRequestMessageDrafts,
openDetails: ReadonlySet<string>,
actions: PendingRequestMessageActions,
autoFocus = false,
autoFocusRequested = false,
consumeAutoFocus?: () => boolean,
autoFocusSignature = "",
): UiNode {
return (
<PendingRequestMessage
@ -44,7 +46,9 @@ export function pendingRequestMessageNode(
drafts={drafts}
openDetails={openDetails}
actions={actions}
autoFocus={autoFocus}
autoFocusRequested={autoFocusRequested}
consumeAutoFocus={consumeAutoFocus}
autoFocusSignature={autoFocusSignature}
/>
);
}
@ -55,20 +59,26 @@ function PendingRequestMessage({
drafts,
openDetails,
actions,
autoFocus,
autoFocusRequested,
consumeAutoFocus,
autoFocusSignature,
}: {
approvals: readonly PendingApproval[];
pendingUserInputs: readonly PendingUserInput[];
drafts: PendingRequestMessageDrafts;
openDetails: ReadonlySet<string>;
actions: PendingRequestMessageActions;
autoFocus: boolean;
autoFocusRequested: boolean;
consumeAutoFocus: (() => boolean) | undefined;
autoFocusSignature: string;
}): UiNode {
const requestRef = useRef<HTMLDivElement | null>(null);
useLayoutEffect(() => {
if (!autoFocus) return;
const autoFocusConsumed = consumeAutoFocus?.() ?? false;
const shouldFocus = autoFocusRequested || autoFocusConsumed;
if (!shouldFocus) return;
focusPendingRequestControl(requestRef.current);
}, [autoFocus]);
}, [autoFocusRequested, consumeAutoFocus, autoFocusSignature]);
if (approvals.length === 0 && pendingUserInputs.length === 0) return null;
return (
<div ref={requestRef} className={createWorkMessageClassName("codex-panel__pending-request-message", "warning")}>

View file

@ -1,15 +1,14 @@
import { unmountUiRoot } from "../../../shared/ui/ui-root";
import type { ChatState, ChatStateStore } from "../chat-state";
import type { ChatPanelSlotSnapshot } from "../panel/snapshot";
import type { ComponentChild as UiNode } from "preact";
import { renderUiRoot, unmountUiRoot } from "../../../shared/ui/ui-root";
import type { ChatStateStore } from "../chat-state";
export interface ChatPanelShellProps {
stateStore: ChatStateStore;
renderVersion: number;
showToolbar: boolean;
toolbar: ChatPanelSlotProps;
goal: ChatPanelSlotProps;
messages: ChatPanelSlotProps;
composer: ChatPanelSlotProps;
toolbarNode: () => UiNode;
goalNode: () => UiNode;
messagesNode: () => UiNode;
composerNode: () => UiNode;
}
interface ChatPanelShellMount {
@ -17,76 +16,17 @@ interface ChatPanelShellMount {
stateStore: ChatStateStore;
unsubscribe: () => void;
stopStatusBarClearanceSync: () => void;
stateVersion: number;
}
const shellMounts = new WeakMap<HTMLElement, ChatPanelShellMount>();
const shellSlots = {
toolbar: {
selector: ":scope > .codex-panel__toolbar",
create(container: HTMLElement): HTMLElement {
const toolbar = container.createDiv({ cls: "codex-panel__toolbar" });
const body = container.querySelector<HTMLElement>(":scope > .codex-panel__body");
if (body) container.insertBefore(toolbar, body);
return toolbar;
},
props(props: ChatPanelShellProps): ChatPanelSlotProps {
return props.toolbar;
},
},
goal: {
selector: ":scope > .codex-panel__body > .codex-panel__slot--goal",
create(container: HTMLElement): HTMLElement {
return ensureBody(container).createDiv({ cls: "codex-panel__slot codex-panel__slot--goal" });
},
props(props: ChatPanelShellProps): ChatPanelSlotProps {
return props.goal;
},
},
messages: {
selector: ":scope > .codex-panel__body > .codex-panel__messages",
create(container: HTMLElement): HTMLElement {
return ensureBody(container).createDiv({ cls: "codex-panel__slot codex-panel__slot--messages codex-panel__messages" });
},
props(props: ChatPanelShellProps): ChatPanelSlotProps {
return props.messages;
},
},
composer: {
selector: ":scope > .codex-panel__body > .codex-panel__slot--composer",
create(container: HTMLElement): HTMLElement {
return ensureBody(container).createDiv({ cls: "codex-panel__slot codex-panel__slot--composer" });
},
props(props: ChatPanelShellProps): ChatPanelSlotProps {
return props.composer;
},
},
};
const shellSlotDefinitions = Object.values(shellSlots);
export function renderChatPanelShell(container: HTMLElement, props: ChatPanelShellProps): void {
container.addClass("codex-panel");
ensureShellDom(container, props.showToolbar);
const existing = shellMounts.get(container);
if (existing?.stateStore === props.stateStore) {
existing.props = props;
syncStatusBarClearance(container);
} else {
existing?.unsubscribe();
existing?.stopStatusBarClearanceSync();
shellMounts.set(container, {
props,
stateStore: props.stateStore,
unsubscribe: props.stateStore.subscribe(() => {
const mount = shellMounts.get(container);
if (!mount) return;
renderMountedSlots(container, mount.props);
}),
stopStatusBarClearanceSync: startStatusBarClearanceSync(container),
});
}
renderMountedSlots(container, props);
const mount = existing?.stateStore === props.stateStore ? existing : createShellMount(container, props);
mount.props = props;
renderMountedShell(container, mount);
}
export function unmountChatPanelShell(container: HTMLElement | null): void {
@ -95,66 +35,67 @@ export function unmountChatPanelShell(container: HTMLElement | null): void {
mount?.unsubscribe();
mount?.stopStatusBarClearanceSync();
shellMounts.delete(container);
unmountSlotRoots(container);
unmountUiRoot(container);
container.replaceChildren();
}
interface ChatPanelSlotProps {
render: (slot: HTMLElement) => void;
snapshot: (state: ChatState) => ChatPanelSlotSnapshot;
function createShellMount(container: HTMLElement, props: ChatPanelShellProps): ChatPanelShellMount {
const existing = shellMounts.get(container);
existing?.unsubscribe();
existing?.stopStatusBarClearanceSync();
const mount: ChatPanelShellMount = {
props,
stateStore: props.stateStore,
stateVersion: 0,
unsubscribe: props.stateStore.subscribe(() => {
const current = shellMounts.get(container);
if (!current) return;
current.stateVersion += 1;
renderMountedShell(container, current);
}),
stopStatusBarClearanceSync: startStatusBarClearanceSync(container),
};
shellMounts.set(container, mount);
return mount;
}
function ensureShellDom(container: HTMLElement, showToolbar: boolean): void {
if (!showToolbar) {
const toolbar = container.querySelector<HTMLElement>(shellSlots.toolbar.selector);
unmountUiRoot(toolbar);
toolbar?.remove();
}
const requiredSlots = activeShellSlotDefinitions(showToolbar);
if (requiredSlots.every((slot) => container.querySelector(slot.selector))) {
return;
}
// The shell owns the fixed Obsidian DOM scaffold; toolbar, messages, and
// composer each own their own Preact root inside that scaffold.
unmountSlotRoots(container);
container.replaceChildren();
for (const slot of requiredSlots) {
slot.create(container);
function renderMountedShell(container: HTMLElement, mount: ChatPanelShellMount): void {
if (!uiRootIntact(container)) {
unmountUiRoot(container);
container.replaceChildren();
}
syncStatusBarClearance(container);
renderUiRoot(container, <ChatPanelShell {...mount.props} stateVersion={mount.stateVersion} />);
}
function renderSlotIfNeeded(element: HTMLElement, slot: ChatPanelSlotProps, renderKey: string): void {
if (element.dataset["codexPanelSlotRenderKey"] === renderKey) return;
slot.render(element);
element.dataset["codexPanelSlotRenderKey"] = renderKey;
function uiRootIntact(container: HTMLElement): boolean {
const body = container.querySelector<HTMLElement>(":scope > .codex-panel__body");
if (!body) return false;
return Boolean(
body.querySelector<HTMLElement>(":scope > .codex-panel__region--goal") &&
body.querySelector<HTMLElement>(":scope > .codex-panel__messages") &&
body.querySelector<HTMLElement>(":scope > .codex-panel__region--composer"),
);
}
function renderMountedSlots(container: HTMLElement, props: ChatPanelShellProps): void {
const state = props.stateStore.getState();
for (const slotDefinition of activeShellSlotDefinitions(props.showToolbar)) {
const element = container.querySelector<HTMLElement>(slotDefinition.selector);
if (!element) continue;
const slot = slotDefinition.props(props);
renderSlotIfNeeded(element, slot, renderKey(props.renderVersion, slot.snapshot(state)));
}
}
function activeShellSlotDefinitions(showToolbar: boolean): typeof shellSlotDefinitions {
return showToolbar ? shellSlotDefinitions : shellSlotDefinitions.filter((slot) => slot !== shellSlots.toolbar);
}
function unmountSlotRoots(container: HTMLElement): void {
for (const slotDefinition of shellSlotDefinitions) {
unmountUiRoot(container.querySelector<HTMLElement>(slotDefinition.selector));
}
}
function renderKey(renderVersion: number, snapshot: ChatPanelSlotSnapshot): string {
return `${String(renderVersion)}\u001f${String(snapshot)}`;
}
function ensureBody(container: HTMLElement): HTMLElement {
return container.querySelector<HTMLElement>(":scope > .codex-panel__body") ?? container.createDiv({ cls: "codex-panel__body" });
function ChatPanelShell({
showToolbar,
toolbarNode,
goalNode,
messagesNode,
composerNode,
stateVersion,
}: ChatPanelShellProps & { stateVersion: number }): UiNode {
return (
<>
{showToolbar ? <div className="codex-panel__toolbar">{toolbarNode()}</div> : null}
<div className="codex-panel__body" data-codex-panel-state-version={String(stateVersion)}>
<div className="codex-panel__region codex-panel__region--goal">{goalNode()}</div>
{messagesNode()}
<div className="codex-panel__region codex-panel__region--composer">{composerNode()}</div>
</div>
</>
);
}
function startStatusBarClearanceSync(container: HTMLElement): () => void {

View file

@ -3,7 +3,6 @@ import { useLayoutEffect, useRef } from "preact/hooks";
import type { RuntimeConfigSection, RateLimitSummary } from "../runtime/status-summary";
import { IconButton } from "../../../shared/ui/components";
import { renderUiRoot } from "../../../shared/ui/ui-root";
import type { ToolbarDiagnosticSection, ToolbarThreadRow, ToolbarViewModel } from "../panel/model/types";
type ButtonProps = ButtonHTMLAttributes & {
@ -29,8 +28,8 @@ export interface ToolbarActions {
autoNameThread: (threadId: string) => void;
}
export function renderToolbar(toolbar: HTMLElement, model: ToolbarViewModel, actions: ToolbarActions): void {
renderUiRoot(toolbar, <Toolbar model={model} actions={actions} />);
export function toolbarNode(model: ToolbarViewModel, actions: ToolbarActions): UiNode {
return <Toolbar model={model} actions={actions} />;
}
function Toolbar({ model, actions }: { model: ToolbarViewModel; actions: ToolbarActions }): UiNode {

View file

@ -12,7 +12,7 @@ import { chatTurnBusy, createChatStateStore, type ChatState, type ChatAction } f
import type { OpenCodexPanelSnapshot } from "../../workspace/open-panel-snapshot";
import type { SharedAppServerMetadata } from "../../app-server/shared-cache-state";
import type { CodexChatHost } from "./chat-host";
import { createSystemItem } from "./display/system";
import { createStructuredSystemItem, createSystemItem } from "./display/system";
import {
activeThreadTitle as buildActiveThreadTitle,
chatViewDisplayTitle,
@ -23,23 +23,19 @@ import {
statusSummaryLines as buildStatusSummaryLines,
} from "./panel/model";
import { openPanelTurnLifecycle } from "./panel/snapshot";
import {
ChatConnectionWorkTracker,
ChatResumeWorkTracker,
ChatViewDeferredTasks,
type ChatViewRenderScheduleOptions,
} from "./panel/lifecycle";
import { ChatConnectionWorkTracker, ChatResumeWorkTracker, ChatViewDeferredTasks } from "./panel/lifecycle";
import { ChatMessageScrollIntentController } from "./panel/message-scroll-intent-controller";
import type { ChatControllerCompositionPorts } from "./panel/controller-ports";
import { createChatViewControllers, type ChatViewControllers } from "./panel/composition";
import { activeComposerThreadName, composerMetaViewModel, composerPlaceholder, renderComposerSlot } from "./panel/slots/composer";
import { renderGoalSlot } from "./panel/slots/goal";
import { pendingRequestsSignature, renderMessagesSlot } from "./panel/slots/messages";
import { renderToolbarSlot } from "./panel/slots/toolbar";
import type { ChatViewSlotRendererPorts } from "./panel/slots/types";
import { composerMetaViewModel, composerPlaceholder } from "./panel/nodes/composer";
import { goalPanelNode } from "./panel/nodes/goal";
import { pendingRequestsSignature } from "./panel/nodes/messages";
import { toolbarPanelNode } from "./panel/nodes/toolbar";
import type { ChatPanelUiPorts } from "./panel/nodes/types";
type ChatViewToolbarSlotActions = ChatViewSlotRendererPorts["actions"]["toolbar"];
type ChatViewGoalSlotActions = ChatViewSlotRendererPorts["actions"]["goal"];
type ChatPanelToolbarActions = ChatPanelUiPorts["actions"]["toolbar"];
type ChatPanelToolbarState = ChatPanelUiPorts["view"]["toolbar"];
type ChatPanelGoalActions = ChatPanelUiPorts["actions"]["goal"];
export class CodexChatView extends ItemView {
private client: AppServerClient | null = null;
@ -48,7 +44,7 @@ export class CodexChatView extends ItemView {
private readonly viewId = `codex-panel-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
private readonly deferredTasks: ChatViewDeferredTasks;
private readonly messageScrollIntent: ChatMessageScrollIntentController;
private readonly slotPorts: ChatViewSlotRendererPorts;
private readonly panelUiPorts: ChatPanelUiPorts;
private readonly connectionWork = new ChatConnectionWorkTracker();
private readonly resumeWork: ChatResumeWorkTracker;
private opened = false;
@ -63,21 +59,7 @@ export class CodexChatView extends ItemView {
this.resumeWork = new ChatResumeWorkTracker();
this.messageScrollIntent = new ChatMessageScrollIntentController();
this.controllers = createChatViewControllers(this.createControllerPorts());
this.slotPorts = this.createSlotRendererPorts(this.controllers);
this.controllers.render.attachSlotRenderers({
renderToolbar: (toolbar) => {
renderToolbarSlot(toolbar, this.slotPorts);
},
renderGoal: (goal) => {
renderGoalSlot(goal, this.slotPorts);
},
renderMessages: (parent) => {
renderMessagesSlot(parent, this.slotPorts);
},
renderComposer: (parent) => {
renderComposerSlot(parent, this.slotPorts);
},
});
this.panelUiPorts = this.createPanelUiPorts(this.controllers);
}
private createControllerPorts(): ChatControllerCompositionPorts {
@ -92,12 +74,6 @@ export class CodexChatView extends ItemView {
registerPointerDown: (handler) => {
this.registerDomEvent(this.containerEl.doc, "pointerdown", handler);
},
registerActiveLeafChange: (handler) => {
this.registerEvent(this.app.workspace.on("active-leaf-change", handler));
},
handleActiveLeafChange: (leaf) => {
if (leaf === this.leaf) this.forceMessagesToBottomOnFocus();
},
archiveAdapter: () => this.app.vault.adapter,
},
plugin: this.plugin,
@ -105,6 +81,7 @@ export class CodexChatView extends ItemView {
stateStore: this.chatState,
getState: () => this.state,
systemItem: (text) => this.systemItem(text),
structuredSystemItem: (text, details) => this.structuredSystemItem(text, details),
},
client: {
getClient: () => this.client,
@ -149,17 +126,24 @@ export class CodexChatView extends ItemView {
},
render: {
panelRoot: () => this.panelRoot(),
pendingRequestsSignature: () => pendingRequestsSignature(this.slotPorts),
activeComposerThreadName: () => activeComposerThreadName(this.slotPorts),
composerPlaceholder: () => composerPlaceholder(this.slotPorts),
composerMetaViewModel: () => composerMetaViewModel(this.slotPorts),
toolbarNode: () => toolbarPanelNode(this.panelUiPorts),
goalNode: () => goalPanelNode(this.panelUiPorts),
messagesNode: () => this.controllers.render.messages.renderNode(),
composerNode: () => this.controllers.composer.controller.renderNode(),
closeToolbarPanelOnOutsidePointer: (event) => {
this.closeToolbarPanelOnOutsidePointer(event);
},
schedule: (options) => {
this.scheduleRender(options);
schedule: () => {
this.scheduleRender();
},
},
messages: {
pendingRequestsSignature: () => pendingRequestsSignature(this.panelUiPorts),
},
composerView: {
composerPlaceholder: () => composerPlaceholder(this.panelUiPorts),
composerMetaViewModel: () => composerMetaViewModel(this.panelUiPorts),
},
runtime: {
runtimeSnapshot: () => this.runtimeSnapshot(),
collaborationModeLabel: () => this.collaborationModeLabel(),
@ -193,6 +177,9 @@ export class CodexChatView extends ItemView {
forceBottom: () => {
this.messageScrollIntent.forceBottom();
},
followBottom: () => {
this.messageScrollIntent.followBottom();
},
preservePosition: () => {
this.messageScrollIntent.preservePosition();
},
@ -205,7 +192,7 @@ export class CodexChatView extends ItemView {
};
}
private createSlotRendererPorts(controllers: ChatViewControllers): ChatViewSlotRendererPorts {
private createPanelUiPorts(controllers: ChatViewControllers): ChatPanelUiPorts {
return {
state: {
chat: () => this.state,
@ -226,25 +213,25 @@ export class CodexChatView extends ItemView {
setRequestedModel: (model) => this.setRequestedModelFromUi(model),
setRequestedReasoningEffort: (effort) => this.setRequestedReasoningEffortFromUi(effort),
},
actions: {
toolbar: this.createToolbarSlotActions(controllers),
goal: this.createGoalSlotActions(controllers),
view: {
toolbar: this.createToolbarPanelState(controllers),
},
slots: {
renderMessages: (parent) => {
controllers.render.messages.render(parent);
},
renderComposer: (parent) => {
controllers.composer.controller.render(parent);
},
actions: {
toolbar: this.createToolbarPanelActions(controllers),
goal: this.createGoalPanelActions(controllers),
},
};
}
private createToolbarSlotActions(controllers: ChatViewControllers): ChatViewToolbarSlotActions {
private createToolbarPanelState(controllers: ChatViewControllers): ChatPanelToolbarState {
return {
archiveConfirmId: () => controllers.toolbar.panels.archiveConfirmId(),
renameState: (threadId) => controllers.thread.rename.editState(threadId),
};
}
private createToolbarPanelActions(controllers: ChatViewControllers): ChatPanelToolbarActions {
return {
startNewThread: () => {
void this.startNewThread();
},
@ -296,7 +283,7 @@ export class CodexChatView extends ItemView {
};
}
private createGoalSlotActions(controllers: ChatViewControllers): ChatViewGoalSlotActions {
private createGoalPanelActions(controllers: ChatViewControllers): ChatPanelGoalActions {
return {
saveObjective: (objective, tokenBudget) => this.saveGoalObjective(objective, tokenBudget),
setStatus: (threadId, status) => controllers.runtime.goals.setStatus(threadId, status),
@ -320,7 +307,7 @@ export class CodexChatView extends ItemView {
private setGoalEditingOpen(open: boolean, { closeToolbarPanel = false }: { closeToolbarPanel?: boolean } = {}): void {
if (closeToolbarPanel) this.dispatch({ type: "ui/panel-set", panel: null });
this.dispatch({ type: "ui/detail-open-set", key: "goal:editor", open });
this.controllers.render.controller.render({ forceSlots: true });
this.controllers.render.controller.render();
}
private async saveGoalObjective(objective: string, tokenBudget: number | null): Promise<void> {
@ -356,6 +343,10 @@ export class CodexChatView extends ItemView {
return createSystemItem(`system-${String(Date.now())}-${Math.random().toString(36).slice(2)}`, text);
}
private structuredSystemItem(text: string, details: DisplayDetailSection[]): DisplayItem {
return createStructuredSystemItem(`system-${String(Date.now())}-${Math.random().toString(36).slice(2)}`, text, details);
}
override getViewType(): string {
return VIEW_TYPE_CODEX_PANEL;
}
@ -431,7 +422,6 @@ export class CodexChatView extends ItemView {
if (threadId && this.isRestoredThreadPending(threadId)) {
await this.ensureRestoredThreadLoaded();
}
this.forceMessagesToBottomOnFocus();
this.focusComposer();
}
@ -456,7 +446,8 @@ export class CodexChatView extends ItemView {
}
setComposerText(text: string): void {
this.controllers.composer.controller.setDraft(text, { focus: true, renderIfDetached: true });
this.controllers.composer.controller.setDraft(text, { focus: true });
this.controllers.render.controller.render();
}
async connect(): Promise<void> {
@ -523,11 +514,6 @@ export class CodexChatView extends ItemView {
this.controllers.thread.restored.clearHydration();
}
private forceMessagesToBottomOnFocus(): void {
this.messageScrollIntent.forceBottom();
this.controllers.render.messages.forceMessagesToBottom();
}
private scheduleDeferredAppServerWarmup(): void {
this.controllers.connection.scheduleWarmup();
}
@ -553,10 +539,10 @@ export class CodexChatView extends ItemView {
});
}
private scheduleRender(options: ChatViewRenderScheduleOptions = {}): void {
this.deferredTasks.scheduleRender((renderOptions) => {
this.controllers.render.controller.render(renderOptions);
}, options);
private scheduleRender(): void {
this.deferredTasks.scheduleRender(() => {
this.controllers.render.controller.render();
});
}
private scheduleDeferredDiagnostics(): void {
@ -576,7 +562,7 @@ export class CodexChatView extends ItemView {
}
private panelRoot(): HTMLElement | null {
return (this.containerEl.children[1] as HTMLElement | undefined) ?? null;
return this.contentEl;
}
private statusSummaryLines(): string[] {

View file

@ -1,4 +1,4 @@
.codex-panel__slot--goal:not(:empty) {
.codex-panel__region--goal:not(:empty) {
padding-bottom: var(--codex-panel-edge-padding-x);
}

View file

@ -6,25 +6,25 @@
min-height: 0;
}
.codex-panel__slot {
.codex-panel__region {
min-width: 0;
}
.codex-panel__slot--goal {
.codex-panel__region--goal {
grid-row: 1;
}
.codex-panel__slot--messages {
.codex-panel__region--messages {
grid-row: 2;
min-height: 0;
overflow: hidden;
}
.codex-panel__slot--composer {
.codex-panel__region--composer {
grid-row: 3;
}
.codex-panel__slot--goal:empty {
.codex-panel__region--goal:empty {
display: none;
}

View file

@ -24,9 +24,12 @@
right: 0;
left: 0;
display: flow-root;
min-width: 0;
}
.codex-panel__message {
min-width: 0;
max-width: 100%;
margin-bottom: var(--codex-panel-edge-padding-x);
padding-left: var(--codex-panel-message-indent);
border-left: var(--codex-panel-rail-transparent);
@ -129,6 +132,8 @@
}
.codex-panel__activity-group {
min-width: 0;
max-width: 100%;
margin: 0 0 var(--codex-panel-edge-padding-x);
padding-left: var(--codex-panel-message-indent);
border-left: var(--codex-panel-rail);
@ -158,6 +163,8 @@
}
.codex-panel__message-content {
min-width: 0;
max-width: 100%;
font-family: var(--codex-panel-content-font);
font-size: var(--codex-panel-content-font-size);
overflow-wrap: anywhere;
@ -178,6 +185,8 @@
}
.codex-panel__message-content.markdown-rendered pre {
max-width: 100%;
overflow-x: auto;
white-space: pre-wrap;
}
@ -298,6 +307,8 @@
}
.codex-panel__reasoning {
min-width: 0;
max-width: 100%;
margin: 0 0 var(--codex-panel-section-gap);
padding-left: var(--codex-panel-message-indent);
border-left: var(--codex-panel-rail);
@ -342,6 +353,8 @@
}
.codex-panel__work-message {
min-width: 0;
max-width: 100%;
margin-bottom: var(--codex-panel-section-gap);
padding-left: var(--codex-panel-message-indent);
border: 0;
@ -427,6 +440,8 @@
.codex-panel__tool-item,
.codex-panel__file-change {
min-width: 0;
max-width: 100%;
margin-bottom: var(--codex-panel-section-gap);
padding-left: var(--codex-panel-message-indent);
border: 0;

View file

@ -5,17 +5,21 @@ import { describe, expect, it, vi } from "vitest";
import { ChatComposerController } from "../../../../src/features/chat/composer/controller";
import { createChatStateStore } from "../../../../src/features/chat/chat-state";
import { renderUiRoot, unmountUiRoot } from "../../../../src/shared/ui/ui-root";
import type { SkillMetadata } from "../../../../src/generated/app-server/v2/SkillMetadata";
import { installObsidianDomShims } from "../../../support/dom";
installObsidianDomShims();
describe("ChatComposerController", () => {
it("keeps suggestions closed after inserting at a cursor before later trigger text", () => {
it("updates slash suggestions in the same render as the input", () => {
const stateStore = createChatStateStore();
stateStore.dispatch({ type: "connection/metadata-applied", availableSkills: [skill("obsidian-search")] });
stateStore.dispatch({ type: "composer/draft-set", draft: "/pla then $ob" });
const parent = document.createElement("div");
const controllerRef: { current: ChatComposerController | null } = { current: null };
const renderShell = vi.fn(() => {
if (!controllerRef.current) throw new Error("Expected controller.");
renderUiRoot(parent, controllerRef.current.renderNode());
});
const controller = new ChatComposerController({
app: app(),
stateStore,
@ -46,11 +50,129 @@ describe("ChatComposerController", () => {
togglePlan: vi.fn(),
toggleAutoReview: vi.fn(),
toggleFast: vi.fn(),
renderIfDetached: vi.fn(),
onDraftChange: vi.fn(),
onHeightChange: vi.fn(),
});
controllerRef.current = controller;
stateStore.subscribe(renderShell);
controller.render(parent);
renderShell();
setTextAreaValue(composer(parent), "/");
composer(parent).setSelectionRange(1, 1);
composer(parent).dispatchEvent(new Event("input", { bubbles: true }));
expect(stateStore.getState().composer.draft).toBe("/");
expect(stateStore.getState().composer.suggestions.length).toBeGreaterThan(0);
expect(parent.querySelector(".codex-panel__composer-suggestion")?.textContent).toContain("/");
expect(renderShell).toHaveBeenCalledTimes(2);
});
it("rerenders suggestion selection from keyboard navigation", () => {
const stateStore = createChatStateStore();
const parent = document.createElement("div");
let controller: ChatComposerController | null = null;
const renderShell = vi.fn(() => {
if (!controller) throw new Error("Expected controller.");
renderUiRoot(parent, controller.renderNode());
});
controller = new ChatComposerController({
app: app(),
stateStore,
viewId: "view",
sendShortcut: () => "enter",
scrollThreadFromComposerEdges: () => false,
canInterrupt: () => false,
composerPlaceholder: () => "Ask Codex to work on this task...",
composerMeta: () => ({
fatal: null,
context: {
cells: [
{ text: "⣀", placeholder: true },
{ text: "⣀", placeholder: true },
{ text: "⣀", placeholder: true },
{ text: "⣀", placeholder: true },
],
percent: "--%",
},
statusSummary: "Context unavailable, plan off, auto-review off, fast off, model default, reasoning effort default",
model: "default",
effort: null,
planActive: false,
autoReviewActive: false,
fastActive: false,
}),
currentModelForSuggestions: () => null,
togglePlan: vi.fn(),
toggleAutoReview: vi.fn(),
toggleFast: vi.fn(),
onDraftChange: vi.fn(),
onHeightChange: vi.fn(),
});
stateStore.subscribe(renderShell);
renderShell();
setTextAreaValue(composer(parent), "/");
composer(parent).setSelectionRange(1, 1);
composer(parent).dispatchEvent(new Event("input", { bubbles: true }));
const firstSelected = selectedSuggestion(parent);
expect(firstSelected.textContent).toContain("/clear");
composer(parent).dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "ArrowDown" }));
expect(stateStore.getState().composer.suggestSelected).toBe(1);
expect(selectedSuggestion(parent).textContent).not.toBe(firstSelected.textContent);
composer(parent).dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "p", ctrlKey: true }));
expect(stateStore.getState().composer.suggestSelected).toBe(0);
expect(selectedSuggestion(parent).textContent).toBe(firstSelected.textContent);
});
it("keeps suggestions closed after inserting at a cursor before later trigger text", () => {
const stateStore = createChatStateStore();
stateStore.dispatch({ type: "connection/metadata-applied", availableSkills: [skill("obsidian-search")] });
stateStore.dispatch({ type: "composer/draft-set", draft: "/pla then $ob" });
const parent = document.createElement("div");
let controller: ChatComposerController | null = null;
const renderShell = vi.fn(() => {
if (!controller) throw new Error("Expected controller.");
renderUiRoot(parent, controller.renderNode());
});
controller = new ChatComposerController({
app: app(),
stateStore,
viewId: "view",
sendShortcut: () => "enter",
scrollThreadFromComposerEdges: () => false,
canInterrupt: () => false,
composerPlaceholder: () => "Ask Codex to work on this task...",
composerMeta: () => ({
fatal: null,
context: {
cells: [
{ text: "⣀", placeholder: true },
{ text: "⣀", placeholder: true },
{ text: "⣀", placeholder: true },
{ text: "⣀", placeholder: true },
],
percent: "--%",
},
statusSummary: "Context unavailable, plan off, auto-review off, fast off, model default, reasoning effort default",
model: "default",
effort: null,
planActive: false,
autoReviewActive: false,
fastActive: false,
}),
currentModelForSuggestions: () => null,
togglePlan: vi.fn(),
toggleAutoReview: vi.fn(),
toggleFast: vi.fn(),
onDraftChange: vi.fn(),
onHeightChange: vi.fn(),
});
stateStore.subscribe(renderShell);
renderShell();
composer(parent).setSelectionRange(4, 4);
composer(parent).dispatchEvent(new KeyboardEvent("keyup", { bubbles: true, key: "a" }));
@ -100,11 +222,11 @@ describe("ChatComposerController", () => {
togglePlan,
toggleAutoReview: vi.fn(),
toggleFast: vi.fn(),
renderIfDetached: vi.fn(),
onDraftChange: vi.fn(),
onHeightChange: vi.fn(),
});
controller.render(parent);
renderUiRoot(parent, controller.renderNode());
expect(parent.querySelector<HTMLElement>(".codex-panel__composer-meta-icon")?.classList.contains("is-active")).toBe(false);
parent.querySelector<HTMLElement>(".codex-panel__composer-meta-icon")?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
@ -148,19 +270,69 @@ describe("ChatComposerController", () => {
togglePlan: vi.fn(),
toggleAutoReview: vi.fn(),
toggleFast: vi.fn(),
renderIfDetached: vi.fn(),
onDraftChange: vi.fn(),
onHeightChange: vi.fn(),
});
controller.setActionHandlers({
submit,
threadScrollFromComposer: vi.fn(),
});
controller.render(parent);
renderUiRoot(parent, controller.renderNode());
composer(parent).dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }));
expect(submit).toHaveBeenCalledOnce();
});
it("clears the Preact-owned textarea ref when the composer unmounts", () => {
const stateStore = createChatStateStore();
stateStore.dispatch({ type: "composer/draft-set", draft: "state draft" });
const parent = document.createElement("div");
const controller = new ChatComposerController({
app: app(),
stateStore,
viewId: "view",
sendShortcut: () => "enter",
scrollThreadFromComposerEdges: () => false,
canInterrupt: () => false,
composerPlaceholder: () => "Ask Codex to work on this task...",
composerMeta: () => ({
fatal: null,
context: {
cells: [
{ text: "⣀", placeholder: true },
{ text: "⣀", placeholder: true },
{ text: "⣀", placeholder: true },
{ text: "⣀", placeholder: true },
],
percent: "--%",
},
statusSummary: "Context unavailable, plan off, auto-review off, fast off, model default, reasoning effort default",
model: "default",
effort: null,
planActive: false,
autoReviewActive: false,
fastActive: false,
}),
currentModelForSuggestions: () => null,
togglePlan: vi.fn(),
toggleAutoReview: vi.fn(),
toggleFast: vi.fn(),
onDraftChange: vi.fn(),
onHeightChange: vi.fn(),
});
renderUiRoot(parent, controller.renderNode());
const mountedComposer = composer(parent);
setTextAreaValue(mountedComposer, "stale dom draft");
const focus = vi.spyOn(mountedComposer, "focus");
unmountUiRoot(parent);
controller.setDraft("state draft", { focus: true });
expect(controller.trimmedDraft).toBe("state draft");
expect(focus).not.toHaveBeenCalled();
});
});
function app(): App {
@ -189,6 +361,16 @@ function composer(parent: HTMLElement): HTMLTextAreaElement {
return expectPresent(parent.querySelector<HTMLTextAreaElement>(".codex-panel__composer-input"));
}
function selectedSuggestion(parent: HTMLElement): HTMLElement {
return expectPresent(parent.querySelector<HTMLElement>(".codex-panel__composer-suggestion.is-selected"));
}
function setTextAreaValue(textarea: HTMLTextAreaElement, value: string): void {
const descriptor = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value");
if (!descriptor?.set) throw new Error("Missing textarea value setter.");
descriptor.set.call(textarea, value);
}
function expectPresent<T>(value: T | null | undefined): T {
expect(value).not.toBeNull();
expect(value).not.toBeUndefined();

View file

@ -1,7 +1,7 @@
// @vitest-environment jsdom
import { describe, expect, it, vi } from "vitest";
import type { App, Component, EventRef, WorkspaceLeaf } from "obsidian";
import type { App, Component, EventRef } from "obsidian";
import type { RuntimeSnapshot } from "../../../../src/features/chat/runtime/effective-settings";
import { createChatStateStore } from "../../../../src/features/chat/chat-state";
@ -13,7 +13,7 @@ import type { ComposerMetaViewModel } from "../../../../src/features/chat/panel/
import { DEFAULT_SETTINGS } from "../../../../src/settings/model";
describe("createChatViewControllers", () => {
it("constructs the chat controller graph and exposes slot renderer attachment", () => {
it("constructs the chat controller graph with direct shell nodes", () => {
const controllers = createChatViewControllers(createPorts());
expect(controllers.connection.controller).toBeTruthy();
@ -21,14 +21,7 @@ describe("createChatViewControllers", () => {
expect(controllers.thread.resume).toBeTruthy();
expect(controllers.render.messages).toBeTruthy();
expect(controllers.composer.controller).toBeTruthy();
expect(() => {
controllers.render.attachSlotRenderers({
renderToolbar: vi.fn(),
renderGoal: vi.fn(),
renderMessages: vi.fn(),
renderComposer: vi.fn(),
});
}).not.toThrow();
expect(controllers.render.controller).toBeTruthy();
});
});
@ -44,8 +37,6 @@ function createPorts(): ChatControllerCompositionPorts {
viewId: "test-view",
registerEvent: vi.fn(),
registerPointerDown: vi.fn(),
registerActiveLeafChange: vi.fn(),
handleActiveLeafChange: vi.fn((_leaf: WorkspaceLeaf | null) => undefined),
archiveAdapter: () => ({
exists: vi.fn().mockResolvedValue(false),
mkdir: vi.fn().mockResolvedValue(undefined),
@ -72,6 +63,7 @@ function createPorts(): ChatControllerCompositionPorts {
stateStore,
getState: () => stateStore.getState(),
systemItem: (text) => ({ id: "system", kind: "system", role: "system", text }),
structuredSystemItem: (text, details) => ({ id: "system", kind: "system", role: "system", text, details }),
},
client: {
getClient: () => null,
@ -96,13 +88,20 @@ function createPorts(): ChatControllerCompositionPorts {
},
render: {
panelRoot: () => root,
pendingRequestsSignature: () => "",
activeComposerThreadName: () => null,
composerPlaceholder: () => "",
composerMetaViewModel: () => composerMeta(),
toolbarNode: () => null,
goalNode: () => null,
messagesNode: () => null,
composerNode: () => null,
closeToolbarPanelOnOutsidePointer: vi.fn(),
schedule: vi.fn(),
},
messages: {
pendingRequestsSignature: () => "",
},
composerView: {
composerPlaceholder: () => "",
composerMetaViewModel: () => composerMeta(),
},
runtime: {
runtimeSnapshot: () => ({}) as RuntimeSnapshot,
collaborationModeLabel: () => "Plan",
@ -124,6 +123,7 @@ function createPorts(): ChatControllerCompositionPorts {
},
scroll: {
forceBottom: vi.fn(),
followBottom: vi.fn(),
preservePosition: vi.fn(),
},
status: {

View file

@ -19,7 +19,7 @@ describe("ToolbarPanelController", () => {
controller.startArchive("thread");
expect(controller.archiveConfirmId()).toBe("thread");
expect(scheduleRender).toHaveBeenCalledWith({ forceSlots: true });
expect(scheduleRender).toHaveBeenCalledWith();
await controller.archiveThread("thread", true);
@ -46,6 +46,6 @@ describe("ToolbarPanelController", () => {
});
expect(stateStore.getState().ui.toolbarPanel).toBeNull();
expect(scheduleRender).toHaveBeenCalledWith({ forceSlots: true });
expect(scheduleRender).toHaveBeenCalledWith();
});
});

View file

@ -20,8 +20,6 @@ function createHost(overrides: Partial<ChatViewLifecycleHost> = {}) {
register({} as EventRef);
}),
registerPointerDown: vi.fn(),
registerActiveLeafChange: vi.fn(),
handleActiveLeafChange: vi.fn(),
applyCachedSharedAppServerState: vi.fn(),
render: vi.fn(),
scheduleDeferredAppServerWarmup: vi.fn(),
@ -56,7 +54,6 @@ describe("chat view lifecycle", () => {
expect(host.setClosing).toHaveBeenCalledWith(false);
expect(host.registerEvent).toHaveBeenCalledOnce();
expect(host.registerPointerDown).toHaveBeenCalledOnce();
expect(host.registerActiveLeafChange).toHaveBeenCalledWith(host.handleActiveLeafChange);
expect(host.applyCachedSharedAppServerState).toHaveBeenCalledOnce();
expect(host.render).toHaveBeenCalledOnce();
expect(host.scheduleDeferredAppServerWarmup).toHaveBeenCalledOnce();

View file

@ -5,48 +5,23 @@ import { describe, expect, it, vi } from "vitest";
import { ChatViewRenderController, type ChatViewRenderControllerHost } from "../../../../src/features/chat/panel/view-render-controller";
describe("ChatViewRenderController", () => {
it("renders every shell slot without coupling sibling slot layout to message scrolling", () => {
it("renders the shell through the configured panel root", () => {
const root = document.createElement("div");
const toolbar = document.createElement("div");
const goal = document.createElement("div");
const messages = document.createElement("div");
const composer = document.createElement("div");
const host = renderHost(root, { toolbar, goal, messages, composer });
const slotRenderers = {
renderToolbar: vi.fn(),
renderGoal: vi.fn(),
renderMessages: vi.fn(),
renderComposer: vi.fn(),
};
const renderShell = vi.fn();
const host = renderHost(root, renderShell);
const controller = new ChatViewRenderController(host);
controller.setSlotRenderers(slotRenderers);
controller.render();
expect(slotRenderers.renderToolbar).toHaveBeenCalledWith(toolbar);
expect(slotRenderers.renderGoal).toHaveBeenCalledWith(goal);
expect(slotRenderers.renderMessages).toHaveBeenCalledWith(messages);
expect(slotRenderers.renderComposer).toHaveBeenCalledWith(composer);
expect(host.clearScheduledRender).toHaveBeenCalledOnce();
expect(renderShell).toHaveBeenCalledWith(root);
});
});
function renderHost(
root: HTMLElement,
slots: {
toolbar: HTMLElement;
goal: HTMLElement;
messages: HTMLElement;
composer: HTMLElement;
},
): ChatViewRenderControllerHost {
function renderHost(root: HTMLElement, renderShell: (root: HTMLElement) => void): ChatViewRenderControllerHost {
return {
shell: {
render: (_root, _renderVersion, renderers) => {
renderers.renderToolbar(slots.toolbar);
renderers.renderGoal(slots.goal);
renderers.renderMessages(slots.messages);
renderers.renderComposer(slots.composer);
},
render: renderShell,
},
panelRoot: () => root,
clearScheduledRender: vi.fn(),

View file

@ -199,10 +199,10 @@ describe("createChatThreadActions", () => {
{ kind: "message", role: "user", text: "kept prompt", turnId: "kept-turn" },
{ kind: "message", role: "assistant", text: "kept answer", turnId: "kept-turn" },
]);
expect(host.forceRenderSlots).toHaveBeenCalledOnce();
expect(host.render).toHaveBeenCalledOnce();
expect(callOrder(host.setComposerText)).toBeLessThan(callOrder(host.addSystemMessage));
expect(callOrder(host.addSystemMessage)).toBeLessThan(callOrder(host.forceRenderSlots));
expect(callOrder(host.forceRenderSlots)).toBeLessThan(callOrder(vi.mocked(host.refreshThreads)));
expect(callOrder(host.addSystemMessage)).toBeLessThan(callOrder(host.render));
expect(callOrder(host.render)).toBeLessThan(callOrder(vi.mocked(host.refreshThreads)));
expect(callOrder(vi.mocked(host.refreshThreads))).toBeLessThan(callOrder(host.refreshSharedThreadListFromOpenSurface));
});
});
@ -276,7 +276,7 @@ function hostMock({
addSystemMessage: vi.fn(),
setStatus: vi.fn(),
setComposerText: vi.fn(),
forceRenderSlots: vi.fn(),
render: vi.fn(),
openThreadInNewView: vi.fn().mockResolvedValue(undefined),
openThreadInCurrentPanel: vi.fn().mockResolvedValue(undefined),
notifyThreadArchived: vi.fn(),

View file

@ -48,7 +48,7 @@ describe("ThreadHistoryController", () => {
it("applies an already returned latest turns page without requesting history", () => {
const threadTurnsList = vi.fn();
const { loader, stateStore } = historyFixture({ threadTurnsList });
const { loader, stateStore, showLatestPageAtBottom } = historyFixture({ threadTurnsList });
const applied = loader.applyLatestPage("thread", threadTurnsResponse([turnFixture([assistantMessage("assistant", "Ready")])], "older"));
@ -58,6 +58,7 @@ describe("ThreadHistoryController", () => {
expect.objectContaining({ id: "assistant", text: "Ready", turnId: "turn" }),
]);
expect(stateStore.getState().transcript.historyCursor).toBe("older");
expect(showLatestPageAtBottom).toHaveBeenCalledOnce();
});
it("ignores already returned latest turns pages for stale threads", () => {
@ -72,7 +73,7 @@ describe("ThreadHistoryController", () => {
it("loads older history without coupling transcript replacement to bottom pin state", async () => {
const threadTurnsList = vi.fn().mockResolvedValue(threadTurnsResponse([turnFixture([assistantMessage("older", "Older")])], "next"));
const { loader, stateStore, dispatch, keepCurrentScrollPosition } = historyFixture({ threadTurnsList });
const { loader, stateStore, dispatch, keepCurrentScrollPosition, showLatestPageAtBottom } = historyFixture({ threadTurnsList });
stateStore.dispatch({ type: "transcript/items-replaced", items: [message("current", "Current")], historyCursor: "cursor" });
await loader.loadOlder();
@ -81,6 +82,7 @@ describe("ThreadHistoryController", () => {
expect(stateStore.getState().transcript.displayItems.map((item) => item.id)).toEqual(["older", "current"]);
expect(stateStore.getState().transcript.historyCursor).toBe("next");
expect(keepCurrentScrollPosition).toHaveBeenCalledOnce();
expect(showLatestPageAtBottom).not.toHaveBeenCalled();
expect(dispatch).toHaveBeenCalledWith(expect.objectContaining({ type: "transcript/items-replaced" }));
});
});
@ -94,6 +96,7 @@ function historyFixture(options: { threadTurnsList: ReturnType<typeof vi.fn> })
const dispatch = vi.spyOn(stateStore, "dispatch");
const addSystemMessage = vi.fn();
const keepCurrentScrollPosition = vi.fn();
const showLatestPageAtBottom = vi.fn();
const loader = new ThreadHistoryController({
stateStore,
currentClient: () =>
@ -103,9 +106,10 @@ function historyFixture(options: { threadTurnsList: ReturnType<typeof vi.fn> })
render: vi.fn(),
addSystemMessage,
keepCurrentScrollPosition,
showLatestPageAtBottom,
setThreadTurnPresence: vi.fn(),
});
return { loader, stateStore, addSystemMessage, dispatch, keepCurrentScrollPosition };
return { loader, stateStore, addSystemMessage, dispatch, keepCurrentScrollPosition, showLatestPageAtBottom };
}
function threadTurnsResponse(data: Turn[], nextCursor: string | null): ThreadTurnsListResponse {

View file

@ -81,7 +81,6 @@ function createController(
clearDeferredRestoredThreadHydration: vi.fn(),
notifyActiveThreadIdentityChanged: vi.fn(),
addSystemMessage: vi.fn(),
forceMessagesToBottom: vi.fn(),
render: vi.fn(),
refreshLiveState: vi.fn(),
syncThreadGoal: vi.fn().mockResolvedValue(undefined),
@ -131,13 +130,12 @@ describe("ThreadResumeController", () => {
expect(loadLatest).not.toHaveBeenCalled();
});
it("pins to the bottom after resumed history and goal sync finish", async () => {
it("renders after resumed history and goal sync finish", async () => {
const { controller, host } = createController();
await controller.resumeThread("thread");
expect(host.forceMessagesToBottom).toHaveBeenCalledTimes(1);
expect(vi.mocked(host.forceMessagesToBottom).mock.invocationCallOrder[0]).toBeGreaterThan(
expect(vi.mocked(host.render).mock.invocationCallOrder.at(-1)).toBeGreaterThan(
vi.mocked(host.syncThreadGoal).mock.invocationCallOrder[0] ?? 0,
);
});

View file

@ -39,6 +39,7 @@ function createController(draft: string) {
const sendTurnText = vi.fn().mockResolvedValue(undefined);
const execute = vi.fn().mockResolvedValue(undefined);
const forceBottom = vi.fn();
const followBottom = vi.fn();
const controller = createComposerSubmissionActions({
stateStore,
composer: {
@ -57,20 +58,20 @@ function createController(draft: string) {
setStatus: vi.fn(),
addSystemMessage: vi.fn(),
},
scroll: { forceBottom },
scroll: { forceBottom, followBottom },
});
return { controller, execute, forceBottom, interruptTurn, sendTurnText, setDraft, stateStore };
return { controller, execute, followBottom, forceBottom, interruptTurn, sendTurnText, setDraft, stateStore };
}
describe("createComposerSubmissionActions", () => {
it("sends plain drafts as turn text", async () => {
const { controller, forceBottom, sendTurnText } = createController("hello");
const { controller, followBottom, sendTurnText } = createController("hello");
await controller.submit();
expect(forceBottom).toHaveBeenCalledOnce();
expect(followBottom).toHaveBeenCalledOnce();
expect(sendTurnText).toHaveBeenCalledWith("hello");
const [forceBottomOrder] = forceBottom.mock.invocationCallOrder;
const [forceBottomOrder] = followBottom.mock.invocationCallOrder;
const [sendTurnTextOrder] = sendTurnText.mock.invocationCallOrder;
if (forceBottomOrder === undefined || sendTurnTextOrder === undefined) {
throw new Error("Expected forceBottom and sendTurnText to be called");
@ -79,14 +80,14 @@ describe("createComposerSubmissionActions", () => {
});
it("executes slash commands and forwards command send results", async () => {
const { controller, execute, forceBottom, sendTurnText, setDraft } = createController("/clear hello");
const { controller, execute, followBottom, sendTurnText, setDraft } = createController("/clear hello");
execute.mockResolvedValue({ sendText: "hello" });
await controller.submit();
expect(setDraft).toHaveBeenCalledWith("", { clearSuggestions: true });
expect(execute).toHaveBeenCalledWith("clear", "hello");
expect(forceBottom).toHaveBeenCalledOnce();
expect(followBottom).toHaveBeenCalledOnce();
expect(sendTurnText).toHaveBeenCalledWith("hello", undefined, undefined);
});

View file

@ -443,7 +443,7 @@ describe("message stream rendering and message actions", () => {
expect(copyText).toHaveBeenNthCalledWith(2, "# Answer");
});
it("expands assistant fork actions in the copy slot and defaults repeat clicks to plain fork", () => {
it("expands assistant fork actions in the copy action region and defaults repeat clicks to plain fork", () => {
const onDetailsToggle = vi.fn();
const onForkItem = vi.fn();
const item: DisplayItem = {
@ -808,6 +808,41 @@ describe("message stream rendering and message actions", () => {
unmountUiRootInAct(parent);
});
it("ignores stale async markdown renders targeting the same connected content element", async () => {
const parent = document.createElement("div");
document.body.appendChild(parent);
const firstRender = deferred<undefined>();
const renderMarkdown = vi.spyOn(MarkdownRenderer, "render");
renderMarkdown
.mockImplementationOnce((_app, text: string, element: HTMLElement) =>
firstRender.promise.then(() => {
element.textContent = `stale:${text}`;
}),
)
.mockImplementation((_app, text: string, element: HTMLElement) => {
element.textContent = `fresh:${text}`;
return Promise.resolve();
});
const markdownRenderer = new MarkdownMessageRenderer({
app: { workspace: { getActiveFile: vi.fn(() => null) } } as never,
owner: {} as never,
vaultPath: "/vault",
});
markdownRenderer.renderMarkdown(parent, "old");
markdownRenderer.renderMarkdown(parent, "new");
await Promise.resolve();
expect(parent.textContent).toBe("fresh:new");
firstRender.resolve(undefined);
await Promise.resolve();
await Promise.resolve();
expect(parent.textContent).toBe("fresh:new");
renderMarkdown.mockRestore();
parent.remove();
});
it("hides copy action for the active assistant message while a turn is running", () => {
const item = {
id: "a-running",

View file

@ -26,7 +26,14 @@ describe("message stream context port", () => {
},
requests: {
pendingSignature: () => "",
renderPending: () => null,
pendingSnapshot: () => ({ approvals: [], pendingUserInputs: [], userInputDrafts: new Map(), openDetails: new Set() }),
pendingActions: () => ({
resolveApproval: vi.fn(),
resolveUserInput: vi.fn(),
cancelUserInput: vi.fn(),
setUserInputDraft: vi.fn(),
}),
consumePendingAutoFocus: () => false,
},
});
@ -60,7 +67,14 @@ describe("message stream context port", () => {
},
requests: {
pendingSignature: () => "",
renderPending: () => null,
pendingSnapshot: () => ({ approvals: [], pendingUserInputs: [], userInputDrafts: new Map(), openDetails: new Set() }),
pendingActions: () => ({
resolveApproval: vi.fn(),
resolveUserInput: vi.fn(),
cancelUserInput: vi.fn(),
setUserInputDraft: vi.fn(),
}),
consumePendingAutoFocus: () => false,
},
});

View file

@ -2,9 +2,10 @@
import { describe, expect, it, vi } from "vitest";
import type { PendingRequestSnapshot } from "../../../../../src/features/chat/chat-state-selectors";
import type { PendingApproval } from "../../../../../src/features/chat/requests/approval";
import type { PendingUserInput } from "../../../../../src/features/chat/requests/user-input";
import { pendingRequestMessageNode } from "../../../../../src/features/chat/ui/pending-request-message";
import type { PendingRequestBlockContext } from "../../../../../src/features/chat/ui/message-stream/context";
import type { DisplayItem } from "../../../../../src/features/chat/display/types";
import { changeInputValue } from "../../../../support/dom";
import "./setup";
@ -467,14 +468,99 @@ describe("pending request renderer decisions", () => {
openDetails: new Set(),
loadOlderTurns: vi.fn(),
renderMarkdown: (parent, text) => parent.createDiv({ text }),
pendingRequestsSignature: "request:1",
renderPendingRequests: () => "Request",
pendingRequests: pendingRequestContext({
signature: "request:1",
}),
});
expect(blocks.map((block) => block.key)).toEqual(["item:a1", "pending-requests"]);
expect(expectPresent(blocks[1]).node).not.toBeUndefined();
});
it("does not consume pending request autofocus while building message stream blocks", () => {
const consumeAutoFocus = vi.fn(() => true);
const blocks = messageStreamBlocks({
activeThreadId: "thread",
turnLifecycle: idleTurnLifecycle(),
historyCursor: null,
loadingHistory: false,
displayItems: [
{ id: "a1", kind: "message", role: "assistant", text: "Done", messageKind: "assistantResponse", messageState: "completed" },
],
openDetails: new Set(),
loadOlderTurns: vi.fn(),
renderMarkdown: (parent, text) => parent.createDiv({ text }),
pendingRequests: pendingRequestContext({
signature: "request:1",
consumeAutoFocus,
}),
});
expect(blocks.map((block) => block.key)).toEqual(["item:a1", "pending-requests"]);
expect(consumeAutoFocus).not.toHaveBeenCalled();
});
it("consumes pending request autofocus when the pending block is mounted", () => {
const parent = document.createElement("div");
document.body.appendChild(parent);
const consumeAutoFocus = vi.fn(() => true);
try {
renderMessageStreamBlocksInAct(
parent,
messageStreamBlocks({
activeThreadId: "thread",
turnLifecycle: idleTurnLifecycle(),
historyCursor: null,
loadingHistory: false,
displayItems: [
{ id: "a1", kind: "message", role: "assistant", text: "Done", messageKind: "assistantResponse", messageState: "completed" },
],
openDetails: new Set(),
loadOlderTurns: vi.fn(),
renderMarkdown: (element, text) => element.createDiv({ text }),
pendingRequests: pendingRequestContext({
signature: "approval:1",
snapshot: emptyPendingRequestSnapshot({ approvals: [pendingApproval()] }),
consumeAutoFocus,
}),
}),
);
expect(consumeAutoFocus).toHaveBeenCalledOnce();
expect(document.activeElement).toBe(parent.querySelector(".codex-panel__pending-request-button.mod-cta"));
} finally {
unmountUiRootInAct(parent);
parent.remove();
}
});
it("does not build pending request nodes when no pending block is inserted", () => {
const pendingSnapshot = vi.fn(() => emptyPendingRequestSnapshot());
const consumeAutoFocus = vi.fn(() => true);
const blocks = messageStreamBlocks({
activeThreadId: "thread",
turnLifecycle: idleTurnLifecycle(),
historyCursor: null,
loadingHistory: false,
displayItems: [],
openDetails: new Set(),
loadOlderTurns: vi.fn(),
renderMarkdown: (parent, text) => parent.createDiv({ text }),
pendingRequests: pendingRequestContext({
signature: "request:1",
snapshot: pendingSnapshot,
consumeAutoFocus,
}),
});
expect(blocks.map((block) => block.key)).toEqual(["empty"]);
expect(pendingSnapshot).not.toHaveBeenCalled();
expect(consumeAutoFocus).not.toHaveBeenCalled();
});
it("keeps pending request Preact events mounted in the message stream host", () => {
const parent = document.createElement("div");
const approval = pendingApproval();
@ -493,19 +579,11 @@ describe("pending request renderer decisions", () => {
openDetails: new Set(),
loadOlderTurns: vi.fn(),
renderMarkdown: (element, text) => element.createDiv({ text }),
pendingRequestsSignature: "approval:1",
renderPendingRequests: () =>
pendingRequestMessageNode(
[approval],
[],
{
values: new Map(),
draftKey: (requestId, questionId) => `${String(requestId)}:${questionId}`,
otherDraftKey: (requestId, questionId) => `${String(requestId)}:${questionId}:other`,
},
new Set(),
pendingRequestActions({ resolveApproval }),
),
pendingRequests: pendingRequestContext({
signature: "approval:1",
snapshot: emptyPendingRequestSnapshot({ approvals: [approval] }),
actions: pendingRequestActions({ resolveApproval }),
}),
}),
);
@ -536,8 +614,10 @@ describe("pending request renderer decisions", () => {
parent,
messageStreamBlocks({
...baseContext,
pendingRequestsSignature: "request:1",
renderPendingRequests: () => <button type="button">Resolve</button>,
pendingRequests: pendingRequestContext({
signature: "request:1",
snapshot: emptyPendingRequestSnapshot({ approvals: [pendingApproval()] }),
}),
}),
);
expect(parent.querySelector('[data-codex-panel-block-key="pending-requests"]')).not.toBeNull();
@ -549,3 +629,30 @@ describe("pending request renderer decisions", () => {
unmountUiRootInAct(parent);
});
});
function pendingRequestContext(options: {
signature: string;
snapshot?: PendingRequestSnapshot | (() => PendingRequestSnapshot);
actions?: ReturnType<typeof pendingRequestActions>;
consumeAutoFocus?: () => boolean;
}): PendingRequestBlockContext {
const snapshot = options.snapshot;
const snapshotFn =
typeof snapshot === "function" ? (snapshot as () => PendingRequestSnapshot) : () => snapshot ?? emptyPendingRequestSnapshot();
return {
signature: options.signature,
snapshot: snapshotFn,
actions: () => options.actions ?? pendingRequestActions(),
consumeAutoFocus: options.consumeAutoFocus ?? (() => false),
};
}
function emptyPendingRequestSnapshot(overrides: Partial<PendingRequestSnapshot> = {}): PendingRequestSnapshot {
return {
approvals: [],
pendingUserInputs: [],
userInputDrafts: new Map(),
openDetails: new Set(),
...overrides,
};
}

View file

@ -0,0 +1,31 @@
// @vitest-environment jsdom
import { describe, expect, it } from "vitest";
import { messageStreamVirtualItems } from "../../../../../src/features/chat/ui/message-stream/render";
import type { MessageStreamBlock } from "../../../../../src/features/chat/ui/message-stream";
describe("message stream virtual item fallback", () => {
it("starts fallback rendering near the current top scroll offset", () => {
const items = messageStreamVirtualItems([], messageBlocks(40), 0);
expect(items[0]).toMatchObject({ index: 0, key: "block-0", start: 0 });
});
it("centers fallback rendering around the estimated scroll offset", () => {
const items = messageStreamVirtualItems([], messageBlocks(80), 96 * 40);
expect(items[0]).toMatchObject({ index: 24, key: "block-24", start: 96 * 24 });
expect(items).toHaveLength(32);
});
it("uses virtualizer items when TanStack has produced a range", () => {
const virtualItems = [{ index: 12, key: "virtual", start: 1234 }];
expect(messageStreamVirtualItems(virtualItems, messageBlocks(80), 0)).toBe(virtualItems);
});
});
function messageBlocks(count: number): MessageStreamBlock[] {
return Array.from({ length: count }, (_, index) => ({ key: `block-${String(index)}`, node: null }));
}

View file

@ -15,6 +15,8 @@ import {
bindRenderedWikiLinks,
type RenderedMarkdownLinkContext,
} from "../../../../../src/features/chat/ui/message-stream";
import type { MessageStreamScrollIntent } from "../../../../../src/features/chat/ui/message-virtualizer";
import { renderUiRoot, unmountUiRoot } from "../../../../../src/shared/ui/ui-root";
import { notices } from "../../../../mocks/obsidian";
import { installObsidianDomShims } from "../../../../support/dom";
import { installMessageViewportMetrics } from "./test-helpers";
@ -142,14 +144,15 @@ describe("ChatMessageRenderer scroll pinning", () => {
const parent = document.createElement("div");
const renderer = chatMessageRenderer(state);
const messages = parent.createDiv({ cls: "codex-panel__messages" });
renderUiRoot(parent, renderer.renderNode());
const messages = messageViewport(parent);
Object.defineProperty(messages, "scrollHeight", { value: ESTIMATED_MESSAGE_BLOCK_HEIGHT, configurable: true });
installMessageViewportMetrics(messages, { clientHeight: 100 });
messages.scrollTop = 920;
const scrollIntoView = vi.spyOn(HTMLElement.prototype, "scrollIntoView");
scrollIntoView.mockClear();
renderer.render(messages);
renderer.forceMessagesToBottom();
await settleMessageRender(messages);
expect(messages.scrollTop).toBe(0);
@ -173,11 +176,11 @@ describe("ChatMessageRenderer scroll pinning", () => {
const parent = document.createElement("div");
const renderer = chatMessageRenderer(state);
const messages = parent.createDiv({ cls: "codex-panel__messages" });
renderUiRoot(parent, renderer.renderNode());
const messages = messageViewport(parent);
Object.defineProperty(messages, "scrollHeight", { value: ESTIMATED_MESSAGE_BLOCK_HEIGHT, configurable: true });
installMessageViewportMetrics(messages, { clientHeight: 160 });
messages.scrollTop = 1000;
renderer.render(messages);
await settleMessageRender(messages);
installMessageViewportMetrics(messages, { clientHeight: 100 });
@ -229,7 +232,7 @@ describe("ChatMessageRenderer scroll pinning", () => {
},
});
messages.scrollTop = 1000;
renderer.render(messages);
renderUiRoot(messages, renderer.renderNode());
await settleMessageRender(messages);
expect(messages.scrollTop).toBe(0);
@ -242,6 +245,106 @@ describe("ChatMessageRenderer scroll pinning", () => {
expect(messages.scrollTop).toBe(0);
});
it("treats scroll commands as no-ops when no message stream virtualizer is mounted", () => {
const renderer = chatMessageRenderer();
expect(() => {
renderer.forceMessagesToBottom();
renderer.repinMessagesToBottomIfPinned();
renderer.scrollFromComposer({ direction: 1, amount: "text-lines" });
renderer.scrollFromComposer({ direction: -1, amount: "page" });
renderer.dispose();
renderer.forceMessagesToBottom();
}).not.toThrow();
});
it("detaches the active virtualizer when the message stream unmounts", async () => {
const state = createChatState();
state.activeThread.id = "thread";
state.transcript.displayItems = [
{
id: "message",
kind: "message",
role: "assistant",
text: "Rendered message",
turnId: "turn",
messageKind: "assistantResponse",
messageState: "completed",
},
];
const parent = document.createElement("div");
const renderer = chatMessageRenderer(state);
renderUiRoot(parent, renderer.renderNode());
const messages = messageViewport(parent);
installMessageViewportMetrics(messages);
await settleMessageRender(messages);
unmountUiRoot(parent);
expect(() => {
renderer.forceMessagesToBottom();
renderer.scrollFromComposer({ direction: 1, amount: "page" });
}).not.toThrow();
});
it("binds scroll commands to the currently mounted message viewport", async () => {
const state = createChatState();
state.activeThread.id = "thread";
state.transcript.displayItems = [
{
id: "message",
kind: "message",
role: "assistant",
text: "Rendered message",
turnId: "turn",
messageKind: "assistantResponse",
messageState: "completed",
},
];
const parent = document.createElement("div");
const renderer = chatMessageRenderer(state);
renderUiRoot(parent, renderer.renderNode());
const oldMessages = messageViewport(parent);
installMessageViewportMetrics(oldMessages, { clientHeight: 100, scrollHeight: 1000 });
await settleMessageRender(oldMessages);
unmountUiRoot(parent);
renderUiRoot(parent, renderer.renderNode());
const newMessages = messageViewport(parent);
installMessageViewportMetrics(newMessages, { clientHeight: 100, scrollHeight: 1000 });
await settleMessageRender(newMessages);
renderer.forceMessagesToBottom();
expect(newMessages.scrollTop).toBe(900);
});
it("completes bottom pinning after the message viewport commits", async () => {
const state = createChatState();
state.activeThread.id = "thread";
state.transcript.displayItems = [
{
id: "message",
kind: "message",
role: "assistant",
text: "Streaming message",
turnId: "turn",
messageKind: "assistantResponse",
messageState: "completed",
},
];
const parent = document.createElement("div");
const renderer = chatMessageRenderer(state, vi.fn(), "/vault", [], () => "force-bottom");
renderUiRoot(parent, renderer.renderNode());
const messages = messageViewport(parent);
installMessageViewportMetrics(messages, { clientHeight: 100, scrollHeight: 1000 });
renderer.forceMessagesToBottom();
await settleMessageRender(messages);
expect(messages.scrollTop).toBe(900);
});
it("does not force the bottom into view when the user is reading older messages", async () => {
const state = createChatState();
state.activeThread.id = "thread";
@ -261,7 +364,7 @@ describe("ChatMessageRenderer scroll pinning", () => {
const messages = parent.createDiv({ cls: "codex-panel__messages" });
installMessageViewportMetrics(messages);
renderer.render(messages);
renderUiRoot(messages, renderer.renderNode());
await settleMessageRender(messages);
Object.defineProperty(messages, "scrollHeight", { value: 1000, configurable: true });
@ -282,7 +385,7 @@ describe("ChatMessageRenderer scroll pinning", () => {
messageState: "completed",
},
];
renderer.render(messages);
renderUiRoot(messages, renderer.renderNode());
await settleMessageRender(messages);
expect(scrollIntoView).not.toHaveBeenCalled();
@ -309,7 +412,7 @@ describe("ChatMessageRenderer scroll pinning", () => {
Object.defineProperty(messages, "scrollHeight", { value: 1000, configurable: true });
installMessageViewportMetrics(messages, { clientHeight: 100 });
messages.scrollTop = 920;
renderer.render(messages);
renderUiRoot(messages, renderer.renderNode());
const scrollIntoView = vi.spyOn(HTMLElement.prototype, "scrollIntoView");
scrollIntoView.mockClear();
@ -320,7 +423,7 @@ describe("ChatMessageRenderer scroll pinning", () => {
expect(scrollIntoView).not.toHaveBeenCalled();
});
it("unmounts the Preact message stream root on dispose", () => {
it("leaves the mounted message stream content in place on dispose", async () => {
const state = createChatState();
state.activeThread.id = "thread";
state.transcript.displayItems = [
@ -337,14 +440,40 @@ describe("ChatMessageRenderer scroll pinning", () => {
const parent = document.createElement("div");
const renderer = chatMessageRenderer(state);
const messages = parent.createDiv({ cls: "codex-panel__messages" });
renderUiRoot(parent, renderer.renderNode());
let messages = messageViewport(parent);
installMessageViewportMetrics(messages);
renderer.render(messages);
expect(messages.querySelector('[data-codex-panel-block-key="item:message"]')).not.toBeNull();
renderUiRoot(parent, renderer.renderNode());
messages = messageViewport(parent);
await settleMessageRender(messages);
expect(parent.querySelector(".codex-panel__messages")).not.toBeNull();
renderer.dispose();
expect(messages.querySelector('[data-codex-panel-block-key="item:message"]')).toBeNull();
expect(parent.querySelector(".codex-panel__messages")).not.toBeNull();
});
it("does not mount every block before the virtualizer attaches", async () => {
const state = createChatState();
state.activeThread.id = "thread";
state.transcript.displayItems = Array.from({ length: 200 }, (_value, index) => ({
id: `message-${String(index)}`,
kind: "message",
role: "assistant",
text: `Message ${String(index)}`,
turnId: "turn",
messageKind: "assistantResponse",
messageState: "completed",
}));
const parent = document.createElement("div");
const renderer = chatMessageRenderer(state);
renderUiRoot(parent, renderer.renderNode());
const messages = messageViewport(parent);
installMessageViewportMetrics(messages, { clientHeight: 320, scrollHeight: 19_200 });
await settleMessageRender(messages);
expect(parent.querySelectorAll("[data-codex-panel-block-key]").length).toBeLessThan(state.transcript.displayItems.length);
});
});
@ -370,6 +499,7 @@ function chatMessageRenderer(
openLinkText = vi.fn(),
vaultPath = "/vault",
vaultFiles: string[] = [],
consumeIntent: () => MessageStreamScrollIntent = () => "auto",
): ChatMessageRenderer {
const files = new Map(vaultFiles.map((path) => [path, tFile(path)]));
return new ChatMessageRenderer({
@ -393,7 +523,7 @@ function chatMessageRenderer(
vaultPath,
},
scroll: {
consumeIntent: () => "auto",
consumeIntent,
},
history: {
loadOlderTurns: vi.fn(),
@ -406,7 +536,14 @@ function chatMessageRenderer(
},
requests: {
pendingSignature: () => "",
renderPending: () => null,
pendingSnapshot: () => ({ approvals: [], pendingUserInputs: [], userInputDrafts: new Map(), openDetails: new Set() }),
pendingActions: () => ({
resolveApproval: vi.fn(),
resolveUserInput: vi.fn(),
cancelUserInput: vi.fn(),
setUserInputDraft: vi.fn(),
}),
consumePendingAutoFocus: () => false,
},
});
}
@ -428,6 +565,12 @@ function tFile(path: string): TFile {
return Object.assign(new TFile(), { path, basename });
}
function messageViewport(parent: HTMLElement): HTMLElement {
const viewport = parent.querySelector<HTMLElement>(":scope > .codex-panel__messages");
if (!viewport) throw new Error("Expected message viewport to be mounted.");
return viewport;
}
async function settleMessageRender(element: HTMLElement): Promise<void> {
await Promise.resolve();
await new Promise<void>((resolve) => {

View file

@ -8,10 +8,9 @@ import { pendingRequestMessageNode, type PendingRequestMessageActions } from "..
import type { ChatTurnLifecycleState } from "../../../../../src/features/chat/chat-state";
import {
type MessageStreamBlock,
messageStreamBlocksNode,
messageStreamBlocks as rawMessageStreamBlocks,
renderMessageStreamBlocks,
} from "../../../../../src/features/chat/ui/message-stream";
import { MessageStreamVirtualizer } from "../../../../../src/features/chat/ui/message-virtualizer";
import { renderUiRoot, unmountUiRoot } from "../../../../../src/shared/ui/ui-root";
export function messageStreamBlocks(
@ -52,23 +51,36 @@ export function actEvent(action: () => void): void {
}
export function renderMessageStreamBlocksInAct(parent: HTMLElement, blocks: MessageStreamBlock[]): void {
parent.addClass("codex-panel__messages");
installMessageViewportMetrics(parent);
if (!parent.isConnected) document.body.appendChild(parent);
const virtualizer = new MessageStreamVirtualizer();
void act(() => {
const plan = virtualizer.prepareRender(parent, "auto", blocks);
renderMessageStreamBlocks(parent, blocks, virtualizer);
virtualizer.completeRender(plan);
renderUiRoot(
parent,
messageStreamBlocksNode({
blocks,
consumeScrollIntent: () => "auto",
}),
);
});
}
export function installMessageViewportMetrics(element: HTMLElement, metrics: { clientHeight?: number; clientWidth?: number } = {}): void {
export function installMessageViewportMetrics(
element: HTMLElement,
metrics: { clientHeight?: number; clientWidth?: number; scrollHeight?: number } = {},
): void {
const clientHeight = metrics.clientHeight ?? 320;
const clientWidth = metrics.clientWidth ?? 240;
const scrollHeight = metrics.scrollHeight ?? element.scrollHeight;
Object.defineProperty(element, "scrollHeight", { value: scrollHeight, configurable: true });
Object.defineProperty(element, "clientHeight", { value: clientHeight, configurable: true });
Object.defineProperty(element, "offsetHeight", { value: clientHeight, configurable: true });
Object.defineProperty(element, "clientWidth", { value: clientWidth, configurable: true });
Object.defineProperty(element, "offsetWidth", { value: clientWidth, configurable: true });
element.scrollTo = ((optionsOrX?: ScrollToOptions | number, y?: number) => {
const top = typeof optionsOrX === "number" ? (y ?? element.scrollTop) : (optionsOrX?.top ?? element.scrollTop);
element.scrollTop = Math.max(0, top);
}) as typeof element.scrollTo;
}
export function renderUiRootInAct(parent: HTMLElement, node: UiNode): void {

View file

@ -596,8 +596,22 @@ describe("work log renderer decisions", () => {
openDetails: new Set(),
loadOlderTurns: vi.fn(),
renderMarkdown: (parent, text) => parent.createDiv({ text }),
pendingRequestsSignature: "request:1",
renderPendingRequests: () => "Request",
pendingRequests: {
signature: "request:1",
snapshot: () => ({
approvals: [],
pendingUserInputs: [],
userInputDrafts: new Map(),
openDetails: new Set(),
}),
actions: () => ({
resolveApproval: vi.fn(),
resolveUserInput: vi.fn(),
cancelUserInput: vi.fn(),
setUserInputDraft: vi.fn(),
}),
consumeAutoFocus: () => false,
},
});
expect(blocks.map((block) => block.key)).toEqual([

View file

@ -1,22 +1,31 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it } from "vitest";
import { h } from "preact";
import { useLayoutEffect, useRef } from "preact/hooks";
import { act } from "preact/test-utils";
import { MessageStreamVirtualizer, MESSAGE_VIRTUAL_ITEM_INDEX_ATTRIBUTE } from "../../../../src/features/chat/ui/message-virtualizer";
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";
installObsidianDomShims();
describe("MessageStreamVirtualizer", () => {
describe("TestMessageStreamVirtualizer", () => {
afterEach(() => {
document.body.replaceChildren();
});
it("pins to the virtualized end when content is appended while pinned", () => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
const controller = new MessageStreamVirtualizer();
const controller = createMessageStreamVirtualizerDriver(container);
renderVirtualItems(controller, container, ["first"], [180], "force-bottom");
renderVirtualItems(controller, container, ["first"], [180], "follow-bottom");
expect(container.scrollTop).toBe(80);
renderVirtualItems(controller, container, ["first", "second"], [180, 220], "auto");
@ -25,10 +34,75 @@ describe("MessageStreamVirtualizer", () => {
controller.dispose();
});
it("requests the end before measurements and remains there after measurements resolve", () => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
const controller = createMessageStreamVirtualizerDriver(container);
const blocks = [{ key: "first", node: null }];
container.dataset["testTotalSize"] = "180";
controller.render(blocks, "follow-bottom");
expect(container.scrollTop).toBe(80);
measureVirtualItem(controller, "first", 0, 180);
expect(controller.getTotalSize()).toBe(180);
expect(container.scrollTop).toBe(80);
controller.dispose();
});
it("settles at the end after the rendered virtualizer height reaches the DOM", () => {
withAnimationFrame((flushFrames) => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
const controller = createMessageStreamVirtualizerDriver(container);
const blocks = [{ key: "first", node: null }];
container.dataset["testTotalSize"] = "180";
container.dataset["testScrollHeight"] = "100";
container.dataset["testClampToScrollHeight"] = "true";
controller.render(blocks, "follow-bottom");
expect(container.scrollTop).toBe(0);
flushFrames();
expect(container.scrollTop).toBe(0);
delete container.dataset["testScrollHeight"];
flushFrames();
expect(container.scrollTop).toBe(80);
controller.dispose();
});
});
it("keeps settling for a few frames when the rendered virtualizer height is delayed", () => {
withAnimationFrame((flushFrames) => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
const controller = createMessageStreamVirtualizerDriver(container);
const blocks = [{ key: "first", node: null }];
container.dataset["testTotalSize"] = "180";
container.dataset["testScrollHeight"] = "100";
container.dataset["testClampToScrollHeight"] = "true";
controller.render(blocks, "follow-bottom");
expect(container.scrollTop).toBe(0);
flushFrames();
expect(container.scrollTop).toBe(0);
flushFrames();
expect(container.scrollTop).toBe(0);
delete container.dataset["testScrollHeight"];
flushFrames();
expect(container.scrollTop).toBe(80);
controller.dispose();
});
});
it("includes message container block padding in the virtualized end", () => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
container.style.paddingLeft = "12px";
const controller = new MessageStreamVirtualizer();
const controller = createMessageStreamVirtualizerDriver(container);
renderVirtualItems(controller, container, ["first"], [180], "force-bottom");
@ -39,13 +113,12 @@ describe("MessageStreamVirtualizer", () => {
it("does not follow appended content after the user scrolls away from the end", () => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
const controller = new MessageStreamVirtualizer();
const controller = createMessageStreamVirtualizerDriver(container);
renderVirtualItems(controller, container, ["first"], [300], "force-bottom");
expect(container.scrollTop).toBe(200);
container.scrollTop = 80;
container.dispatchEvent(new Event("scroll"));
userScrollTo(container, 80);
renderVirtualItems(controller, container, ["first", "second"], [300, 180], "auto");
@ -55,16 +128,14 @@ describe("MessageStreamVirtualizer", () => {
it("repins when the user scrolls back to the end", () => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
const controller = new MessageStreamVirtualizer();
const controller = createMessageStreamVirtualizerDriver(container);
renderVirtualItems(controller, container, ["first", "second"], [300, 180], "force-bottom");
expect(container.scrollTop).toBe(380);
container.scrollTop = 80;
container.dispatchEvent(new Event("scroll"));
userScrollTo(container, 80);
container.scrollTop = 380;
container.dispatchEvent(new Event("scroll"));
userScrollTo(container, 380);
renderVirtualItems(controller, container, ["first", "second", "third"], [300, 180, 140], "auto");
@ -74,9 +145,9 @@ describe("MessageStreamVirtualizer", () => {
it("keeps forced bottom pinned when the programmatic scroll event sees stale DOM scroll size", () => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
const controller = new MessageStreamVirtualizer();
const controller = createMessageStreamVirtualizerDriver(container);
renderVirtualItems(controller, container, ["first", "submitted"], [300, 100], "force-bottom");
renderVirtualItems(controller, container, ["first", "submitted"], [300, 100], "follow-bottom");
expect(container.scrollTop).toBe(300);
container.dataset["testScrollHeight"] = "420";
@ -89,11 +160,27 @@ describe("MessageStreamVirtualizer", () => {
controller.dispose();
});
it("keeps pinned intent when a programmatic scroll event observes a stale non-bottom offset", () => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
const controller = createMessageStreamVirtualizerDriver(container);
renderVirtualItems(controller, container, ["first"], [300], "follow-bottom");
expect(container.scrollTop).toBe(200);
container.dataset["testTotalSize"] = "450";
container.dispatchEvent(new Event("scroll"));
renderVirtualItems(controller, container, ["first", "second"], [300, 150], "auto");
expect(container.scrollTop).toBe(350);
controller.dispose();
});
it("keeps following the end when a pinned item grows after measurement", () => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
const controller = new MessageStreamVirtualizer();
const controller = createMessageStreamVirtualizerDriver(container);
renderVirtualItems(controller, container, ["first", "command"], [200, 100], "force-bottom");
renderVirtualItems(controller, container, ["first", "command"], [200, 100], "follow-bottom");
expect(container.scrollTop).toBe(200);
container.dataset["testTotalSize"] = "450";
@ -103,11 +190,170 @@ describe("MessageStreamVirtualizer", () => {
controller.dispose();
});
it("settles at the end when a pinned item grows before the DOM scroll height catches up", () => {
withAnimationFrame((flushFrames) => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
const controller = createMessageStreamVirtualizerDriver(container);
renderVirtualItems(controller, container, ["first", "command"], [200, 100], "follow-bottom");
flushFrames();
flushFrames();
expect(container.scrollTop).toBe(200);
container.dataset["testTotalSize"] = "450";
container.dataset["testScrollHeight"] = "300";
container.dataset["testClampToScrollHeight"] = "true";
measureVirtualItem(controller, "command", 1, 250);
expect(container.scrollTop).toBe(200);
flushFrames();
expect(container.scrollTop).toBe(200);
delete container.dataset["testScrollHeight"];
flushFrames();
expect(container.scrollTop).toBe(350);
controller.dispose();
});
});
it("keeps the pending end settle when a stale programmatic scroll event fires during pinned item growth", () => {
withAnimationFrame((flushFrames) => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
const controller = createMessageStreamVirtualizerDriver(container);
renderVirtualItems(controller, container, ["first", "command"], [200, 100], "follow-bottom");
flushFrames();
flushFrames();
expect(container.scrollTop).toBe(200);
container.dataset["testTotalSize"] = "450";
container.dataset["testScrollHeight"] = "300";
container.dataset["testClampToScrollHeight"] = "true";
measureVirtualItem(controller, "command", 1, 250);
container.dispatchEvent(new Event("scroll"));
delete container.dataset["testScrollHeight"];
flushFrames();
flushFrames();
expect(container.scrollTop).toBe(350);
controller.dispose();
});
});
it("does not postpone the pending end settle while small pinned growth measurements keep arriving", () => {
withAnimationFrame((flushFrames) => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
const controller = createMessageStreamVirtualizerDriver(container);
renderVirtualItems(controller, container, ["first", "command"], [200, 100], "follow-bottom");
flushFrames();
flushFrames();
expect(container.scrollTop).toBe(200);
container.dataset["testScrollHeight"] = "300";
container.dataset["testClampToScrollHeight"] = "true";
container.dataset["testTotalSize"] = "320";
measureVirtualItem(controller, "command", 1, 120);
flushFrames();
container.dataset["testTotalSize"] = "340";
measureVirtualItem(controller, "command", 1, 140);
delete container.dataset["testScrollHeight"];
flushFrames();
expect(container.scrollTop).toBe(240);
controller.dispose();
});
});
it("settles at the end when ResizeObserver reports pinned item growth before DOM scroll height catches up", () => {
withResizeObserverEntries((resizeElement, flushFrames) => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
const controller = createMessageStreamVirtualizerDriver(container);
const command = measuredElement("command", 1, 100);
container.dataset["testTotalSize"] = "300";
controller.render(
[
{ key: "first", node: null },
{ key: "command", node: null },
],
"follow-bottom",
);
controller.getTotalSize();
measureVirtualItem(controller, "first", 0, 200);
controller.measureElement(command);
flushFrames();
flushFrames();
expect(container.scrollTop).toBe(200);
container.dataset["testTotalSize"] = "450";
container.dataset["testScrollHeight"] = "300";
container.dataset["testClampToScrollHeight"] = "true";
resizeElement(command, 250);
flushFrames();
expect(container.scrollTop).toBe(200);
flushFrames();
expect(container.scrollTop).toBe(200);
delete container.dataset["testScrollHeight"];
flushFrames();
expect(container.scrollTop).toBe(350);
controller.dispose();
});
});
it("does not postpone the pending end settle while ResizeObserver keeps reporting small pinned growth", () => {
withResizeObserverEntries((resizeElement, flushFrames) => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
const controller = createMessageStreamVirtualizerDriver(container);
const command = measuredElement("command", 1, 100);
container.dataset["testTotalSize"] = "300";
controller.render(
[
{ key: "first", node: null },
{ key: "command", node: null },
],
"follow-bottom",
);
controller.getTotalSize();
measureVirtualItem(controller, "first", 0, 200);
controller.measureElement(command);
flushFrames();
flushFrames();
expect(container.scrollTop).toBe(200);
container.dataset["testScrollHeight"] = "300";
container.dataset["testClampToScrollHeight"] = "true";
container.dataset["testTotalSize"] = "320";
resizeElement(command, 120);
flushFrames();
container.dataset["testTotalSize"] = "340";
resizeElement(command, 140);
delete container.dataset["testScrollHeight"];
flushFrames();
flushFrames();
expect(container.scrollTop).toBe(240);
controller.dispose();
});
});
it("uses the pre-render bottom position when appending after an item resize", () => {
const container = messageContainer({ scrollTop: 0, clientHeight: 160 });
const controller = new MessageStreamVirtualizer();
const controller = createMessageStreamVirtualizerDriver(container);
renderVirtualItems(controller, container, ["first", "second"], [200, 200], "force-bottom");
renderVirtualItems(controller, container, ["first", "second"], [200, 200], "follow-bottom");
expect(container.scrollTop).toBe(240);
container.dataset["testTotalSize"] = "480";
@ -121,7 +367,7 @@ describe("MessageStreamVirtualizer", () => {
it("keeps the current reading position when an item below the viewport grows while unpinned", () => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
const controller = new MessageStreamVirtualizer();
const controller = createMessageStreamVirtualizerDriver(container);
renderVirtualItems(controller, container, ["first", "streaming"], [300, 100], "preserve");
container.scrollTop = 100;
@ -134,46 +380,15 @@ describe("MessageStreamVirtualizer", () => {
controller.dispose();
});
it("keeps the end pinned when a layout scroll arrives before the viewport resize observer", () => {
withResizeObserver((triggerResize) => {
const container = messageContainer({ scrollTop: 0, clientHeight: 160 });
const controller = new MessageStreamVirtualizer();
renderVirtualItems(controller, container, ["first", "second"], [240, 260], "force-bottom");
expect(container.scrollTop).toBe(340);
setContainerClientHeight(container, 130);
container.scrollTop = 329;
container.dispatchEvent(new Event("scroll"));
expect(container.scrollTop).toBe(370);
triggerResize();
expect(container.scrollTop).toBe(370);
setContainerClientHeight(container, 100);
container.scrollTop = 340;
container.dispatchEvent(new Event("scroll"));
expect(container.scrollTop).toBe(400);
triggerResize();
expect(container.scrollTop).toBe(400);
controller.dispose();
});
});
it("keeps the reading position when the message viewport shrinks away from the end", () => {
withResizeObserver((triggerResize) => {
const container = messageContainer({ scrollTop: 0, clientHeight: 160 });
const controller = new MessageStreamVirtualizer();
const controller = createMessageStreamVirtualizerDriver(container);
renderVirtualItems(controller, container, ["first", "second"], [240, 260], "force-bottom");
expect(container.scrollTop).toBe(340);
container.scrollTop = 120;
container.dispatchEvent(new Event("scroll"));
userScrollTo(container, 120);
setContainerClientHeight(container, 100);
triggerResize();
@ -182,24 +397,57 @@ describe("MessageStreamVirtualizer", () => {
});
});
it("delegates older-content prepends to keyed end anchoring", () => {
const container = messageContainer({ scrollTop: 140, clientHeight: 120 });
const controller = new MessageStreamVirtualizer();
it("resets stale measurements when the message viewport is restored from zero size", () => {
withResizeObserver((triggerResize, flushFrames) => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
const controller = createMessageStreamVirtualizerDriver(container);
container.append(renderedMeasuredElement("first", 0, 300), renderedMeasuredElement("second", 1, 100));
renderVirtualItems(controller, container, ["first", "second"], [120, 180], "preserve");
container.scrollTop = 140;
container.dispatchEvent(new Event("scroll"));
renderVirtualItems(controller, container, ["first", "second"], [300, 100], "follow-bottom");
expect(container.scrollTop).toBe(300);
renderVirtualItems(controller, container, ["older", "first", "second"], [90, 120, 180], "preserve");
userScrollTo(container, 120);
setContainerClientSize(container, { width: 0, height: 0 });
triggerResize();
setContainerClientSize(container, { width: 240, height: 100 });
triggerResize();
flushFrames();
expect(container.scrollTop).toBe(116);
controller.dispose();
expect(controller.getTotalSize()).toBe(400);
expect(container.scrollTop).toBe(300);
controller.dispose();
});
});
it("resets stale measurements when a hidden-at-start message viewport first renders at a usable size", () => {
withResizeObserver((_triggerResize, flushFrames) => {
const container = messageContainer({ scrollTop: 0, clientHeight: 0 });
const controller = createMessageStreamVirtualizerDriver(container);
container.append(renderedMeasuredElement("first", 0, 300), renderedMeasuredElement("second", 1, 100));
setContainerClientSize(container, { width: 0, height: 0 });
renderVirtualItems(controller, container, ["first", "second"], [300, 100], "preserve");
setContainerClientSize(container, { width: 240, height: 100 });
controller.render(
[
{ key: "first", node: null },
{ key: "second", node: null },
],
"preserve",
);
flushFrames();
expect(controller.getTotalSize()).toBe(400);
expect(container.scrollTop).toBe(300);
controller.dispose();
});
});
it("scrolls by two text lines for composer edge shortcuts", () => {
const container = messageContainer({ scrollTop: 120, clientHeight: 100 });
container.style.lineHeight = "18px";
const controller = new MessageStreamVirtualizer();
const controller = createMessageStreamVirtualizerDriver(container);
renderVirtualItems(controller, container, ["first", "second"], [240, 240], "preserve");
container.scrollTop = 120;
@ -217,7 +465,7 @@ describe("MessageStreamVirtualizer", () => {
it("scrolls by a viewport step for composer PageUp and PageDown shortcuts", () => {
const container = messageContainer({ scrollTop: 220, clientHeight: 200 });
const controller = new MessageStreamVirtualizer();
const controller = createMessageStreamVirtualizerDriver(container);
renderVirtualItems(controller, container, ["first", "second", "third"], [240, 240, 240], "preserve");
container.scrollTop = 220;
@ -238,49 +486,220 @@ describe("MessageStreamVirtualizer", () => {
expect(container.scrollTop).toBe(520);
controller.dispose();
});
it("drops stale same-key measurements when force-bottom replaces the transcript", () => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
const controller = createMessageStreamVirtualizerDriver(container);
renderVirtualItems(controller, container, ["same-key"], [360], "force-bottom");
expect(controller.getTotalSize()).toBe(360);
expect(container.scrollTop).toBe(260);
renderVirtualItems(controller, container, ["same-key"], [120], "force-bottom");
expect(controller.getTotalSize()).toBe(120);
expect(container.scrollTop).toBe(20);
controller.dispose();
});
it("keeps same-key measurements while preserving the current reading position", () => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
const controller = createMessageStreamVirtualizerDriver(container);
renderVirtualItems(controller, container, ["same-key"], [360], "force-bottom");
userScrollTo(container, 140);
renderVirtualItems(controller, container, ["same-key"], [360], "preserve");
expect(controller.getTotalSize()).toBe(360);
expect(container.scrollTop).toBe(140);
controller.dispose();
});
it("keeps measured earlier blocks when following appended content", () => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
const controller = createMessageStreamVirtualizerDriver(container);
renderVirtualItems(controller, container, ["first"], [240], "follow-bottom");
renderVirtualItems(controller, container, ["first", "second"], [240, 160], "auto");
expect(controller.getTotalSize()).toBe(400);
expect(container.scrollTop).toBe(300);
controller.dispose();
});
it("renders the new transcript from the end after force-bottom replaces a long reading position", () => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
const controller = createMessageStreamVirtualizerDriver(container);
renderVirtualItems(
controller,
container,
numberedKeys("old", 80),
Array.from({ length: 80 }, () => 40),
"force-bottom",
);
userScrollTo(container, 2400);
controller.render(
numberedKeys("new", 8).map((key) => ({ key, node: null })),
"force-bottom",
);
expect(controller.getVirtualItems().map((item) => item.index)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]);
controller.dispose();
});
});
function renderVirtualItems(
controller: MessageStreamVirtualizer,
controller: MessageStreamVirtualizerDriver,
container: HTMLElement,
keys: string[],
heights: number[],
intent: "auto" | "force-bottom" | "preserve",
intent: "auto" | "force-bottom" | "follow-bottom" | "preserve",
): void {
const blocks = keys.map((key) => ({ key, node: null }));
const plan = controller.prepareRender(container, intent, blocks);
container.dataset["testTotalSize"] = String(heights.reduce((total, height) => total + height, 0) + messageBlockPadding(container) * 2);
controller.render(blocks, intent);
controller.getTotalSize();
keys.forEach((key, index) => {
measureVirtualItem(controller, key, index, heights[index] ?? 0);
});
controller.getTotalSize();
const previousTop = container.scrollTop;
controller.completeRender(plan);
if (container.scrollTop !== previousTop) {
container.dispatchEvent(new Event("scroll"));
}
container.dispatchEvent(new Event("scroll"));
}
function measureVirtualItem(controller: MessageStreamVirtualizer, key: string, index: number, height: number): void {
function userScrollTo(container: HTMLElement, scrollTop: number): void {
container.dispatchEvent(new Event("wheel"));
container.scrollTop = scrollTop;
container.dispatchEvent(new Event("scroll"));
}
function measureVirtualItem(controller: MessageStreamVirtualizerDriver, key: string, index: number, height: number): void {
controller.measureElement(measuredElement(key, index, height));
}
function measuredElement(key: string, index: number, height: number): HTMLElement {
const element = document.createElement("div");
element.setAttribute("data-codex-panel-block-key", key);
element.setAttribute(MESSAGE_VIRTUAL_ITEM_INDEX_ATTRIBUTE, String(index));
element.setAttribute("data-index", String(index));
Object.defineProperty(element, "offsetHeight", { value: height, configurable: true });
Object.defineProperty(element, "isConnected", { value: true, configurable: true });
controller.measureElement(element);
return element;
}
function renderedMeasuredElement(key: string, index: number, height: number): HTMLElement {
const element = measuredElement(key, index, height);
element.classList.add("codex-panel__message-block");
return element;
}
interface MessageStreamVirtualizerDriver extends MessageStreamVirtualizerHandle {
render(blocks: readonly MessageStreamBlock[], intent: "auto" | "force-bottom" | "follow-bottom" | "preserve"): void;
getTotalSize(): number;
getVirtualItems(): ReturnType<MessageStreamVirtualizerView["getVirtualItems"]>;
measureElement(element: HTMLElement | null): void;
dispose(): void;
}
function createMessageStreamVirtualizerDriver(container: HTMLElement): MessageStreamVirtualizerDriver {
const host = document.createElement("div");
document.body.appendChild(host);
let view: MessageStreamVirtualizerView | null = null;
let handle: MessageStreamVirtualizerHandle | null = null;
return {
render(blocks, intent) {
const renderBlocks = [...blocks];
void act(() => {
renderUiRoot(
host,
h(VirtualizerHarness, {
blocks: renderBlocks,
container,
intent,
onHandle: (next) => (handle = next),
onView: (next) => (view = next),
}),
);
});
},
getTotalSize() {
return view?.getTotalSize() ?? 0;
},
getVirtualItems() {
return view?.getVirtualItems() ?? [];
},
measureElement(element) {
view?.measureElement(element);
},
scrollByTextLines(direction) {
handle?.scrollByTextLines(direction);
},
scrollByPage(direction) {
handle?.scrollByPage(direction);
},
pinToBottom() {
handle?.pinToBottom();
},
repinToBottomIfPinned() {
handle?.repinToBottomIfPinned();
},
dispose() {
unmountUiRoot(host);
host.remove();
},
};
}
function VirtualizerHarness({
blocks,
container,
intent,
onHandle,
onView,
}: {
blocks: readonly MessageStreamBlock[];
container: HTMLElement;
intent: "auto" | "force-bottom" | "follow-bottom" | "preserve";
onHandle: (handle: MessageStreamVirtualizerHandle | null) => void;
onView: (view: MessageStreamVirtualizerView) => void;
}) {
const scrollElementRef = useRef<HTMLElement | null>(container);
const intentRef = useRef(intent);
intentRef.current = intent;
const view = useMessageStreamVirtualizer({
blocks,
scrollElementRef,
consumeScrollIntent: () => intentRef.current,
registerVirtualizer: (handle) => {
onHandle(handle);
return () => {
onHandle(null);
};
},
});
useLayoutEffect(() => {
onView(view);
}, [onView, view]);
return null;
}
function messageContainer(metrics: { scrollTop: number; clientHeight: number }): HTMLElement {
const container = document.createElement("div");
let scrollTop = metrics.scrollTop;
let clientHeight = metrics.clientHeight;
let clientWidth = 240;
container.dataset["testClientHeight"] = String(metrics.clientHeight);
container.dataset["testClientWidth"] = "240";
Object.defineProperties(container, {
scrollTop: {
get: () => scrollTop,
set: (value: number) => {
const scrollSize = Math.max(container.scrollHeight, controllerTotalSize(container));
const scrollSize =
container.dataset["testClampToScrollHeight"] === "true"
? container.scrollHeight
: Math.max(container.scrollHeight, controllerTotalSize(container));
scrollTop = Math.max(0, Math.min(value, Math.max(0, scrollSize - clientHeight)));
},
configurable: true,
@ -295,7 +714,7 @@ function messageContainer(metrics: { scrollTop: number; clientHeight: number }):
clientHeight: {
get: () => {
const nextClientHeight = Number(container.dataset["testClientHeight"]);
clientHeight = Number.isFinite(nextClientHeight) && nextClientHeight > 0 ? nextClientHeight : clientHeight;
clientHeight = Number.isFinite(nextClientHeight) && nextClientHeight >= 0 ? nextClientHeight : clientHeight;
return clientHeight;
},
configurable: true,
@ -304,10 +723,24 @@ function messageContainer(metrics: { scrollTop: number; clientHeight: number }):
get: () => container.clientHeight,
configurable: true,
},
clientWidth: { value: 240, configurable: true },
offsetWidth: { value: 240, configurable: true },
clientWidth: {
get: () => {
const nextClientWidth = Number(container.dataset["testClientWidth"]);
clientWidth = Number.isFinite(nextClientWidth) && nextClientWidth >= 0 ? nextClientWidth : clientWidth;
return clientWidth;
},
configurable: true,
},
offsetWidth: {
get: () => container.clientWidth,
configurable: true,
},
});
document.body.appendChild(container);
container.scrollTo = ((optionsOrX?: ScrollToOptions | number, y?: number) => {
const top = typeof optionsOrX === "number" ? (y ?? container.scrollTop) : (optionsOrX?.top ?? container.scrollTop);
container.scrollTop = top;
}) as typeof container.scrollTo;
return container;
}
@ -315,6 +748,15 @@ function setContainerClientHeight(container: HTMLElement, clientHeight: number):
container.dataset["testClientHeight"] = String(clientHeight);
}
function setContainerClientSize(container: HTMLElement, size: { width: number; height: number }): void {
container.dataset["testClientWidth"] = String(size.width);
container.dataset["testClientHeight"] = String(size.height);
}
function numberedKeys(prefix: string, count: number): string[] {
return Array.from({ length: count }, (_value, index) => `${prefix}-${String(index)}`);
}
function controllerTotalSize(container: HTMLElement): number {
const totalSize = Number(container.dataset["testTotalSize"]);
return Number.isFinite(totalSize) && totalSize > 0 ? totalSize : 0;
@ -325,7 +767,7 @@ function messageBlockPadding(container: HTMLElement): number {
return Number.isFinite(value) ? value : 0;
}
function withResizeObserver(run: (triggerResize: () => void) => void): void {
function withResizeObserver(run: (triggerResize: () => void, flushFrames: () => void) => void): void {
const previousResizeObserver = window.ResizeObserver;
const previousRequestAnimationFrame = window.requestAnimationFrame;
const previousCancelAnimationFrame = window.cancelAnimationFrame;
@ -355,12 +797,107 @@ function withResizeObserver(run: (triggerResize: () => void) => void): void {
}) as typeof window.requestAnimationFrame;
window.cancelAnimationFrame = (() => undefined) as typeof window.cancelAnimationFrame;
try {
run(() => {
frames.splice(0);
const flushFrames = () => {
for (const callback of callbacks) callback([], {} as ResizeObserver);
const pending = frames.splice(0);
for (const frame of pending) frame(0);
});
};
run(
() => {
frames.splice(0);
flushFrames();
},
() => {
const pending = frames.splice(0);
for (const frame of pending) frame(0);
},
);
} finally {
window.ResizeObserver = previousResizeObserver;
window.requestAnimationFrame = previousRequestAnimationFrame;
window.cancelAnimationFrame = previousCancelAnimationFrame;
}
}
function withAnimationFrame(run: (flushFrames: () => void) => void): void {
const previousRequestAnimationFrame = window.requestAnimationFrame;
const previousCancelAnimationFrame = window.cancelAnimationFrame;
const frames: { id: number; callback: FrameRequestCallback }[] = [];
let nextFrameId = 1;
window.requestAnimationFrame = ((callback: FrameRequestCallback) => {
const id = nextFrameId++;
frames.push({ id, callback });
return id;
}) as typeof window.requestAnimationFrame;
window.cancelAnimationFrame = ((id: number) => {
const index = frames.findIndex((frame) => frame.id === id);
if (index >= 0) frames.splice(index, 1);
}) as typeof window.cancelAnimationFrame;
try {
run(() => {
const pending = frames.splice(0);
for (const frame of pending) frame.callback(0);
});
} finally {
window.requestAnimationFrame = previousRequestAnimationFrame;
window.cancelAnimationFrame = previousCancelAnimationFrame;
}
}
function withResizeObserverEntries(
run: (resizeElement: (element: HTMLElement, height: number) => void, flushFrames: () => void) => void,
): void {
const previousResizeObserver = window.ResizeObserver;
const previousRequestAnimationFrame = window.requestAnimationFrame;
const previousCancelAnimationFrame = window.cancelAnimationFrame;
const callbacks: ResizeObserverCallback[] = [];
const frames: { id: number; callback: FrameRequestCallback }[] = [];
let nextFrameId = 1;
class TestResizeObserver implements ResizeObserver {
constructor(callback: ResizeObserverCallback) {
callbacks.push(callback);
}
observe(): void {
// Resize entries are triggered explicitly by the test.
}
unobserve(): void {
// Resize entries are triggered explicitly by the test.
}
disconnect(): void {
// Resize entries are triggered explicitly by the test.
}
}
window.ResizeObserver = TestResizeObserver;
window.requestAnimationFrame = ((callback: FrameRequestCallback) => {
const id = nextFrameId++;
frames.push({ id, callback });
return id;
}) as typeof window.requestAnimationFrame;
window.cancelAnimationFrame = ((id: number) => {
const index = frames.findIndex((frame) => frame.id === id);
if (index >= 0) frames.splice(index, 1);
}) as typeof window.cancelAnimationFrame;
try {
run(
(element, height) => {
Object.defineProperty(element, "offsetHeight", { value: height, configurable: true });
const entry = {
target: element,
borderBoxSize: [{ blockSize: height }],
contentBoxSize: [],
contentRect: element.getBoundingClientRect(),
devicePixelContentBoxSize: [],
} as unknown as ResizeObserverEntry;
for (const callback of callbacks) callback([entry], {} as ResizeObserver);
},
() => {
const pending = frames.splice(0);
for (const frame of pending) frame.callback(0);
},
);
} finally {
window.ResizeObserver = previousResizeObserver;
window.requestAnimationFrame = previousRequestAnimationFrame;

View file

@ -2,18 +2,61 @@
import { describe, expect, it, vi } from "vitest";
import { renderComposerShell, scrollComposerSuggestionIntoView, syncComposerHeight } from "../../../../../src/features/chat/ui/composer";
import {
composerShellNode,
scrollComposerSuggestionIntoView,
syncComposerHeight,
type ComposerCallbacks,
} from "../../../../../src/features/chat/ui/composer";
import type { ComposerSuggestion } from "../../../../../src/features/chat/composer/suggestions";
import type { ComposerMetaViewModel } from "../../../../../src/features/chat/panel/model";
import { renderUiRoot } from "../../../../../src/shared/ui/ui-root";
import { waitForAsyncWork } from "../../../../support/async";
import { changeInputValue, composerSuggestionScrollFixture, installObsidianDomShims } from "../../../../support/dom";
installObsidianDomShims();
function mountComposerShellNode(
parent: HTMLElement,
viewId: string,
draft: string,
busy: boolean,
canInterrupt: boolean,
normalPlaceholder: string,
suggestions: readonly ComposerSuggestion[],
selectedSuggestionIndex: number,
callbacks: ComposerCallbacks,
meta?: ComposerMetaViewModel,
): { composer: HTMLTextAreaElement } {
const elements: { composer: HTMLTextAreaElement | null } = { composer: null };
renderUiRoot(
parent,
composerShellNode(
viewId,
draft,
busy,
canInterrupt,
normalPlaceholder,
suggestions,
selectedSuggestionIndex,
callbacks,
meta,
(composer) => {
if (composer) elements.composer = composer;
},
),
);
if (!elements.composer) throw new Error("Expected composer shell elements to mount.");
return { composer: elements.composer };
}
function composerCallbacks() {
return {
onInput: vi.fn(),
onUpdateSuggestions: vi.fn(),
onKeydown: vi.fn(),
onSendOrInterrupt: vi.fn(),
onHeightChange: vi.fn(),
onTogglePlan: vi.fn(),
onToggleAutoReview: vi.fn(),
onToggleFast: vi.fn(),
@ -26,7 +69,7 @@ describe("composer renderer decisions", () => {
it("uses the provided composer placeholder for normal input", () => {
const parent = document.createElement("div");
const callbacks = composerCallbacks();
const { composer } = renderComposerShell(
const { composer } = mountComposerShellNode(
parent,
"view",
"",
@ -40,7 +83,7 @@ describe("composer renderer decisions", () => {
expect(composer.getAttribute("placeholder")).toBe("Ask Codex to work on “Refactor terminal streaming”...");
renderComposerShell(parent, "view", "", false, false, "Ask Codex to work on “Renamed thread”...", [], 0, callbacks);
mountComposerShellNode(parent, "view", "", false, false, "Ask Codex to work on “Renamed thread”...", [], 0, callbacks);
expect(composer.getAttribute("placeholder")).toBe("Ask Codex to work on “Renamed thread”...");
});
@ -48,7 +91,7 @@ describe("composer renderer decisions", () => {
it("renders composer meta as interactive context and runtime text without changing normal text", () => {
const parent = document.createElement("div");
renderComposerShell(parent, "view", "", false, false, "Ask Codex to work on this task...", [], 0, composerCallbacks(), {
mountComposerShellNode(parent, "view", "", false, false, "Ask Codex to work on this task...", [], 0, composerCallbacks(), {
fatal: null,
context: {
cells: [
@ -122,7 +165,7 @@ describe("composer renderer decisions", () => {
const selectModel = vi.fn();
const selectEffort = vi.fn();
renderComposerShell(parent, "view", "", false, false, "Ask Codex to work on this task...", [], 0, callbacks, {
mountComposerShellNode(parent, "view", "", false, false, "Ask Codex to work on this task...", [], 0, callbacks, {
fatal: null,
context: {
cells: [
@ -206,7 +249,7 @@ describe("composer renderer decisions", () => {
it("hides composer meta fields only after measured overflow", async () => {
const parent = document.createElement("div");
renderComposerShell(parent, "view", "", false, false, "Ask Codex to work on this task...", [], 0, composerCallbacks(), {
mountComposerShellNode(parent, "view", "", false, false, "Ask Codex to work on this task...", [], 0, composerCallbacks(), {
fatal: null,
context: {
cells: [
@ -249,7 +292,7 @@ describe("composer renderer decisions", () => {
it("replaces composer meta with fatal status text", () => {
const parent = document.createElement("div");
renderComposerShell(parent, "view", "", false, false, "Ask Codex to work on this task...", [], 0, composerCallbacks(), {
mountComposerShellNode(parent, "view", "", false, false, "Ask Codex to work on this task...", [], 0, composerCallbacks(), {
fatal: "Codex app-server disconnected",
context: {
cells: [
@ -276,7 +319,7 @@ describe("composer renderer decisions", () => {
it("renders composer suggestions inside the composer root", () => {
const parent = document.createElement("div");
const onSuggestionInsert = vi.fn();
const { composer } = renderComposerShell(
const { composer } = mountComposerShellNode(
parent,
"view",
"",
@ -290,6 +333,7 @@ describe("composer renderer decisions", () => {
onUpdateSuggestions: vi.fn(),
onKeydown: vi.fn(),
onSendOrInterrupt: vi.fn(),
onHeightChange: vi.fn(),
onSuggestionHover: vi.fn(),
onSuggestionInsert,
},
@ -309,7 +353,7 @@ describe("composer renderer decisions", () => {
it("clears composer suggestion accessibility state on rerender", () => {
const parent = document.createElement("div");
const callbacks = composerCallbacks();
const { composer } = renderComposerShell(
const { composer } = mountComposerShellNode(
parent,
"view",
"",
@ -321,7 +365,7 @@ describe("composer renderer decisions", () => {
callbacks,
);
renderComposerShell(parent, "view", "", false, false, "Ask Codex to work on this task...", [], 0, callbacks);
mountComposerShellNode(parent, "view", "", false, false, "Ask Codex to work on this task...", [], 0, callbacks);
const suggestions = parent.querySelector<HTMLElement>(".codex-panel__composer-suggestions");
expect(composer.getAttribute("aria-expanded")).toBe("false");
@ -332,7 +376,7 @@ describe("composer renderer decisions", () => {
it("reports composer draft changes from the controlled input", () => {
const parent = document.createElement("div");
const callbacks = composerCallbacks();
const { composer } = renderComposerShell(parent, "view", "", false, false, "Ask Codex to work on this task...", [], 0, callbacks);
const { composer } = mountComposerShellNode(parent, "view", "", false, false, "Ask Codex to work on this task...", [], 0, callbacks);
changeInputValue(composer, "Draft text");
@ -349,7 +393,7 @@ describe("composer renderer decisions", () => {
configurable: true,
});
try {
const { composer } = renderComposerShell(parent, "view", "", false, false, "Ask Codex to work on this task...", [], 0, callbacks);
const { composer } = mountComposerShellNode(parent, "view", "", false, false, "Ask Codex to work on this task...", [], 0, callbacks);
scrollHeight = 120;
changeInputValue(composer, "line one\nline two");
@ -407,7 +451,7 @@ describe("composer renderer decisions", () => {
it("uses the composer action for interrupt only when a running turn has no steering text", () => {
const parent = document.createElement("div");
const callbacks = composerCallbacks();
const { composer } = renderComposerShell(parent, "view", "", true, true, "Ask Codex to work on this task...", [], 0, callbacks);
const { composer } = mountComposerShellNode(parent, "view", "", true, true, "Ask Codex to work on this task...", [], 0, callbacks);
let sendButton = parent.querySelector<HTMLButtonElement>(".codex-panel__send");
expect(sendButton?.getAttribute("aria-label")).toBe("Interrupt");
@ -416,7 +460,7 @@ describe("composer renderer decisions", () => {
expect(sendButton?.classList.contains("is-steer")).toBe(false);
expect(sendButton?.dataset["icon"]).toBe("square");
renderComposerShell(parent, "view", "adjust course", true, true, "Ask Codex to work on this task...", [], 0, callbacks);
mountComposerShellNode(parent, "view", "adjust course", true, true, "Ask Codex to work on this task...", [], 0, callbacks);
sendButton = parent.querySelector<HTMLButtonElement>(".codex-panel__send");
expect(sendButton?.getAttribute("aria-label")).toBe("Steer");
expect(composer.getAttribute("placeholder")).toBe("Add steering message...");

View file

@ -4,14 +4,15 @@ import { describe, expect, it, vi } from "vitest";
import { act } from "preact/test-utils";
import type { ThreadGoal } from "../../../../../src/app-server/thread-goal";
import { renderGoalBanner } from "../../../../../src/features/chat/ui/goal-banner";
import { goalBannerNode, type GoalBannerActions } from "../../../../../src/features/chat/ui/goal-banner";
import type { SendShortcut } from "../../../../../src/shared/ui/keyboard";
import { renderUiRoot } from "../../../../../src/shared/ui/ui-root";
import { installObsidianDomShims } from "../../../../support/dom";
installObsidianDomShims();
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
describe("renderGoalBanner", () => {
describe("goalBannerNode", () => {
it("renders nothing when there is no goal", async () => {
const parent = document.createElement("div");
@ -29,7 +30,7 @@ describe("renderGoalBanner", () => {
const onEditingChange = vi.fn();
await act(async () => {
renderGoalBanner(parent, null, callbacks, { sendShortcut: "enter", editingRequested: true, onEditingChange });
renderUiRoot(parent, goalBannerNode(null, callbacks, { sendShortcut: "enter", editingRequested: true, onEditingChange }));
});
expect(parent.textContent).toContain("Goal");
@ -232,10 +233,10 @@ describe("renderGoalBanner", () => {
function renderGoal(
parent: HTMLElement,
currentGoal: ThreadGoal | null,
callbacks = actions(),
callbacks: GoalBannerActions = actions(),
sendShortcut: SendShortcut = "enter",
): void {
renderGoalBanner(parent, currentGoal, callbacks, { sendShortcut });
renderUiRoot(parent, goalBannerNode(currentGoal, callbacks, { sendShortcut }));
}
function actions() {

View file

@ -3,7 +3,8 @@
import { describe, expect, it, vi } from "vitest";
import type { ToolbarViewModel } from "../../../../../src/features/chat/panel/model/types";
import { renderToolbar } from "../../../../../src/features/chat/ui/toolbar";
import { toolbarNode } from "../../../../../src/features/chat/ui/toolbar";
import { renderUiRoot } from "../../../../../src/shared/ui/ui-root";
import { changeInputValue, installObsidianDomShims } from "../../../../support/dom";
installObsidianDomShims();
@ -13,6 +14,12 @@ function expectPresent<T>(value: T | null | undefined): T {
return value;
}
type ToolbarActions = Parameters<typeof toolbarNode>[1];
function mountToolbarNode(parent: HTMLElement, model: ToolbarViewModel, actions: ToolbarActions): void {
renderUiRoot(parent, toolbarNode(model, actions));
}
describe("toolbar renderer decisions", () => {
it("renders toolbar controls as Obsidian-style action buttons", () => {
const parent = document.createElement("div");
@ -21,7 +28,7 @@ describe("toolbar renderer decisions", () => {
const toggleHistory = vi.fn();
const baseModel = toolbarModel();
renderToolbar(parent, baseModel, toolbarActions({ startNewThread, toggleChatActions, toggleHistory }));
mountToolbarNode(parent, baseModel, toolbarActions({ startNewThread, toggleChatActions, toggleHistory }));
const navHeader = parent.querySelector(".codex-panel__toolbar-primary");
expect(navHeader?.classList.contains("nav-header")).toBe(true);
@ -61,11 +68,11 @@ describe("toolbar renderer decisions", () => {
historyButton?.click();
expect(toggleHistory).toHaveBeenCalled();
parent.empty();
renderToolbar(parent, toolbarModel({ newChatDisabled: true }), toolbarActions());
mountToolbarNode(parent, toolbarModel({ newChatDisabled: true }), toolbarActions());
expect(parent.querySelector<HTMLButtonElement>(".codex-panel__new-chat")?.disabled).toBe(true);
parent.empty();
renderToolbar(parent, toolbarModel({ chatActionsOpen: true, historyOpen: true, statusPanelOpen: true }), toolbarActions());
mountToolbarNode(parent, toolbarModel({ chatActionsOpen: true, historyOpen: true, statusPanelOpen: true }), toolbarActions());
expect(parent.querySelector(".codex-panel__history-toggle")?.getAttribute("aria-label")).toBe("Hide thread list");
expect(parent.querySelector(".codex-panel__history-toggle")?.classList.contains("is-active")).toBe(true);
expect(parent.querySelector(".codex-panel__new-chat")?.getAttribute("aria-label")).toBe("Hide chat actions");
@ -80,7 +87,7 @@ describe("toolbar renderer decisions", () => {
const compactConversation = vi.fn();
const setGoal = vi.fn();
renderToolbar(
mountToolbarNode(
parent,
toolbarModel({ chatActionsOpen: true, openPanel: "chat-actions" }),
toolbarActions({ startNewThread, compactConversation, setGoal }),
@ -100,7 +107,7 @@ describe("toolbar renderer decisions", () => {
it("keeps context out of the toolbar and Codex limits inside the status menu", () => {
const parent = document.createElement("div");
renderToolbar(
mountToolbarNode(
parent,
toolbarModel({
statusPanelOpen: true,
@ -151,7 +158,7 @@ describe("toolbar renderer decisions", () => {
const parent = document.createElement("div");
const refreshStatus = vi.fn();
renderToolbar(
mountToolbarNode(
parent,
toolbarModel({
statusPanelOpen: true,
@ -183,7 +190,7 @@ describe("toolbar renderer decisions", () => {
it("renders effective config inside the status menu without a separate toggle", () => {
const parent = document.createElement("div");
renderToolbar(
mountToolbarNode(
parent,
toolbarModel({
statusPanelOpen: true,
@ -193,7 +200,7 @@ describe("toolbar renderer decisions", () => {
toolbarActions(),
);
expect(parent.querySelector(".codex-panel__slot--config")).toBeNull();
expect(parent.querySelector(".codex-panel__region--config")).toBeNull();
expect(parent.querySelector(".codex-panel__toolbar-panel .codex-panel__config")?.textContent).toContain("Effective Codex config");
expect(parent.textContent).not.toContain("Show effective config");
expect(parent.textContent).not.toContain("Hide effective config");
@ -209,7 +216,7 @@ describe("toolbar renderer decisions", () => {
const autoNameThread = vi.fn();
const actions = toolbarActions({ startRenameThread, updateRenameDraft, saveRenameThread, cancelRenameThread, autoNameThread });
renderToolbar(
mountToolbarNode(
parent,
toolbarModel({
historyOpen: true,
@ -248,7 +255,7 @@ describe("toolbar renderer decisions", () => {
changeInputValue(input, "New title");
expect(updateRenameDraft).toHaveBeenCalledWith("editing", "New title");
renderToolbar(
mountToolbarNode(
parent,
toolbarModel({
historyOpen: true,
@ -283,7 +290,7 @@ describe("toolbar renderer decisions", () => {
it("renders auto-name loading without disabling the rename draft field", () => {
const parent = document.createElement("div");
renderToolbar(
mountToolbarNode(
parent,
toolbarModel({
historyOpen: true,
@ -313,7 +320,7 @@ describe("toolbar renderer decisions", () => {
const startArchiveThread = vi.fn();
const archiveThread = vi.fn();
renderToolbar(
mountToolbarNode(
parent,
toolbarModel({
historyOpen: true,
@ -367,7 +374,7 @@ function toolbarModel(overrides: Partial<ToolbarViewModel> = {}): ToolbarViewMod
};
}
function toolbarActions(overrides: Partial<Parameters<typeof renderToolbar>[2]> = {}): Parameters<typeof renderToolbar>[2] {
function toolbarActions(overrides: Partial<ToolbarActions> = {}): ToolbarActions {
return {
toggleHistory: vi.fn(),
startNewThread: vi.fn(),

View file

@ -6,14 +6,13 @@ import { useEffect } from "preact/hooks";
import { chatTurnBusy, createChatStateStore } from "../../../../src/features/chat/chat-state";
import { renderChatPanelShell, unmountChatPanelShell } from "../../../../src/features/chat/ui/shell";
import { renderUiRoot } from "../../../../src/shared/ui/ui-root";
import { installObsidianDomShims } from "../../../support/dom";
installObsidianDomShims();
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
describe("ChatPanelShell", () => {
it("renders the panel slots on the existing view content element", async () => {
it("renders the panel regions on the existing view content element", async () => {
const store = createChatStateStore();
const container = document.createElement("div");
document.body.appendChild(container);
@ -28,8 +27,8 @@ describe("ChatPanelShell", () => {
expect(container.textContent).toContain("no goal");
expect(container.textContent).toContain("0");
expect(container.textContent).toContain("ready");
expect(container.querySelector(".codex-panel__slot--config")).toBeNull();
expect(container.querySelector(".codex-panel__body > .codex-panel__slot--messages")).toBe(
expect(container.querySelector(".codex-panel__region--config")).toBeNull();
expect(container.querySelector(".codex-panel__body > .codex-panel__region--messages")).toBe(
container.querySelector(".codex-panel__body > .codex-panel__messages"),
);
@ -38,7 +37,7 @@ describe("ChatPanelShell", () => {
});
});
it("updates rendered slot content when the store changes", async () => {
it("updates rendered panel content when the store changes", async () => {
const store = createChatStateStore();
const container = document.createElement("div");
document.body.appendChild(container);
@ -61,7 +60,7 @@ describe("ChatPanelShell", () => {
});
});
it("keeps nested root content mounted in its owning slot after shell rerenders", async () => {
it("keeps panel content in its owning region after shell rerenders", async () => {
const store = createChatStateStore();
const container = document.createElement("div");
document.body.appendChild(container);
@ -83,18 +82,75 @@ describe("ChatPanelShell", () => {
});
expect(container.querySelector(".codex-panel__toolbar .test-toolbar")?.textContent).toBe("Working");
expect(container.querySelector(".codex-panel__slot--goal .test-goal")?.textContent).toBe("no goal");
expect(container.querySelector(".codex-panel__slot--messages .test-messages")?.textContent).toBe("1");
expect(container.querySelector<HTMLTextAreaElement>(".codex-panel__slot--composer .test-composer textarea")?.value).toBe("ready");
expect(container.querySelector(".codex-panel__slot--composer .test-toolbar")).toBeNull();
expect(container.querySelector(".codex-panel__slot--composer .test-messages")).toBeNull();
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<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();
await act(async () => {
unmountChatPanelShell(container);
});
});
it("removes and restores the toolbar slot from shell props", async () => {
it("renders region nodes inside the single shell root", async () => {
const store = createChatStateStore();
const container = document.createElement("div");
document.body.appendChild(container);
const cleanup = vi.fn();
const renderers = nodeShellRenderers(store, cleanup);
await act(async () => {
renderChatPanelShell(container, renderers);
await settleShellEffects();
});
expect(container.querySelector(".codex-panel__toolbar .test-toolbar")?.textContent).toBe("Idle");
expect(container.querySelector(".codex-panel__region--messages .test-messages")?.textContent).toBe("0");
await act(async () => {
unmountChatPanelShell(container);
await settleShellEffects();
});
expect(cleanup).toHaveBeenCalledWith("toolbar");
expect(cleanup).toHaveBeenCalledWith("goal");
expect(cleanup).toHaveBeenCalledWith("messages");
expect(cleanup).toHaveBeenCalledWith("composer");
});
it("rerenders shell regions when the state store updates", async () => {
const store = createChatStateStore();
const container = document.createElement("div");
document.body.appendChild(container);
const cleanup = vi.fn();
const renderers = nodeShellRenderers(store, cleanup);
await act(async () => {
renderChatPanelShell(container, renderers);
await settleShellEffects();
});
renderers.toolbarNode.mockClear();
renderers.goalNode.mockClear();
renderers.messagesNode.mockClear();
renderers.composerNode.mockClear();
await act(async () => {
store.dispatch({ type: "connection/status-set", status: "Working" });
await settleShellEffects();
});
expect(renderers.toolbarNode).toHaveBeenCalledTimes(1);
expect(renderers.goalNode).toHaveBeenCalledTimes(1);
expect(renderers.messagesNode).toHaveBeenCalledTimes(1);
expect(renderers.composerNode).toHaveBeenCalledTimes(1);
await act(async () => {
unmountChatPanelShell(container);
});
});
it("removes and restores the toolbar region from shell props", async () => {
const store = createChatStateStore();
const container = document.createElement("div");
document.body.appendChild(container);
@ -106,8 +162,8 @@ describe("ChatPanelShell", () => {
});
expect(container.querySelector(".codex-panel__toolbar")).toBeNull();
expect(container.querySelector(".codex-panel__slot--messages")).not.toBeNull();
expect(container.querySelector(".codex-panel__slot--composer")).not.toBeNull();
expect(container.querySelector(".codex-panel__region--messages")).not.toBeNull();
expect(container.querySelector(".codex-panel__region--composer")).not.toBeNull();
await act(async () => {
renderChatPanelShell(container, { ...renderers, showToolbar: true });
@ -162,7 +218,7 @@ describe("ChatPanelShell", () => {
statusBar.remove();
});
it("unmounts existing slot roots before rebuilding a damaged shell scaffold", async () => {
it("repairs a removed ui root without inspecting shell children", async () => {
const store = createChatStateStore();
const container = document.createElement("div");
document.body.appendChild(container);
@ -174,7 +230,7 @@ describe("ChatPanelShell", () => {
await settleShellEffects();
});
container.querySelector<HTMLElement>(":scope > .codex-panel__body")?.remove();
container.replaceChildren();
await act(async () => {
renderChatPanelShell(container, renderers);
@ -192,7 +248,34 @@ describe("ChatPanelShell", () => {
});
});
it("unmounts every slot root when the shell unmounts", async () => {
it("repairs damaged shell regions through the single root", async () => {
const store = createChatStateStore();
const container = document.createElement("div");
document.body.appendChild(container);
const cleanup = vi.fn();
const renderers = nodeShellRenderers(store, cleanup);
await act(async () => {
renderChatPanelShell(container, renderers);
await settleShellEffects();
});
container.querySelector<HTMLElement>(":scope .codex-panel__messages")?.remove();
await act(async () => {
renderChatPanelShell(container, renderers);
await settleShellEffects();
});
expect(cleanup).toHaveBeenCalledWith("messages");
expect(container.querySelector(".codex-panel__messages .test-messages")?.textContent).toBe("0");
await act(async () => {
unmountChatPanelShell(container);
});
});
it("unmounts every shell region when the shell unmounts", async () => {
const store = createChatStateStore();
const container = document.createElement("div");
document.body.appendChild(container);
@ -215,7 +298,7 @@ describe("ChatPanelShell", () => {
expect(cleanup).toHaveBeenCalledWith("composer");
});
it("stops subscribed slot rendering after unmount", async () => {
it("stops subscribed region rendering after unmount", async () => {
const store = createChatStateStore();
const container = document.createElement("div");
document.body.appendChild(container);
@ -225,7 +308,7 @@ describe("ChatPanelShell", () => {
renderChatPanelShell(container, renderers);
await settleShellEffects();
});
vi.mocked(renderers.toolbar.render).mockClear();
renderers.toolbarNode.mockClear();
await act(async () => {
unmountChatPanelShell(container);
@ -233,125 +316,125 @@ describe("ChatPanelShell", () => {
store.dispatch({ type: "connection/status-set", status: "Closed" });
await settleShellEffects();
expect(renderers.toolbar.render).not.toHaveBeenCalled();
expect(renderers.toolbarNode).not.toHaveBeenCalled();
});
});
function shellRenderers(store: ReturnType<typeof createChatStateStore>) {
return {
stateStore: store,
renderVersion: 0,
showToolbar: true,
toolbar: {
render: vi.fn((toolbar: HTMLElement) => {
toolbar.textContent = store.getState().connection.status;
}),
snapshot: () => store.getState().connection.status,
},
goal: {
render: vi.fn((goal: HTMLElement) => {
goal.textContent = store.getState().activeThread.goal?.objective ?? "no goal";
}),
snapshot: () => store.getState().activeThread.goal?.objective ?? "",
},
messages: {
render: vi.fn((messages: HTMLElement) => {
messages.textContent = String(store.getState().transcript.displayItems.length);
}),
snapshot: () => store.getState().transcript.displayItems.length,
},
composer: {
render: vi.fn((composer: HTMLElement) => {
composer.textContent = chatTurnBusy(store.getState()) ? "busy" : "ready";
}),
snapshot: () => chatTurnBusy(store.getState()),
},
toolbarNode: vi.fn(() => <div>{store.getState().connection.status}</div>),
goalNode: vi.fn(() => <div>{store.getState().activeThread.goal?.objective ?? "no goal"}</div>),
messagesNode: vi.fn(() => (
<div className="codex-panel__region codex-panel__region--messages codex-panel__messages">
{String(store.getState().transcript.displayItems.length)}
</div>
)),
composerNode: vi.fn(() => <div>{chatTurnBusy(store.getState()) ? "busy" : "ready"}</div>),
};
}
function nestedRootShellRenderers(store: ReturnType<typeof createChatStateStore>) {
return {
stateStore: store,
renderVersion: 0,
showToolbar: true,
toolbar: {
render: vi.fn((toolbar: HTMLElement) => {
renderUiRoot(
toolbar,
<>
<div className="test-toolbar">{store.getState().connection.status}</div>
<div className="test-toolbar-panel">panel</div>
</>,
);
}),
snapshot: () => store.getState().connection.status,
},
goal: {
render: vi.fn((goal: HTMLElement) => {
renderUiRoot(goal, <div className="test-goal">{store.getState().activeThread.goal?.objective ?? "no goal"}</div>);
}),
snapshot: () => store.getState().activeThread.goal?.objective ?? "",
},
messages: {
render: vi.fn((messages: HTMLElement) => {
renderUiRoot(messages, <div className="test-messages">{String(store.getState().transcript.displayItems.length)}</div>);
}),
snapshot: () => store.getState().transcript.displayItems.length,
},
composer: {
render: vi.fn((composer: HTMLElement) => {
renderUiRoot(
composer,
<div className="test-composer">
<textarea value={chatTurnBusy(store.getState()) ? "busy" : "ready"} readOnly />
<button type="button">Send</button>
</div>,
);
}),
snapshot: () => chatTurnBusy(store.getState()),
},
toolbarNode: vi.fn(() => (
<>
<div className="test-toolbar">{store.getState().connection.status}</div>
<div className="test-toolbar-panel">panel</div>
</>
)),
goalNode: vi.fn(() => <div className="test-goal">{store.getState().activeThread.goal?.objective ?? "no goal"}</div>),
messagesNode: vi.fn(() => (
<div className="codex-panel__region codex-panel__region--messages codex-panel__messages">
<div className="test-messages">{String(store.getState().transcript.displayItems.length)}</div>
</div>
)),
composerNode: vi.fn(() => (
<div className="test-composer">
<textarea value={chatTurnBusy(store.getState()) ? "busy" : "ready"} readOnly />
<button type="button">Send</button>
</div>
)),
};
}
function trackedRootShellRenderers(store: ReturnType<typeof createChatStateStore>, cleanup: (slot: string) => void) {
function trackedRootShellRenderers(store: ReturnType<typeof createChatStateStore>, cleanup: (region: string) => void) {
return {
stateStore: store,
renderVersion: 0,
showToolbar: true,
toolbar: {
render: vi.fn((toolbar: HTMLElement) => {
renderUiRoot(toolbar, <TrackedSlot slot="toolbar" cleanup={cleanup} />);
}),
snapshot: () => store.getState().connection.status,
},
goal: {
render: vi.fn((goal: HTMLElement) => {
renderUiRoot(goal, <TrackedSlot slot="goal" cleanup={cleanup} />);
}),
snapshot: () => store.getState().activeThread.goal?.objective ?? "",
},
messages: {
render: vi.fn((messages: HTMLElement) => {
renderUiRoot(messages, <TrackedSlot slot="messages" cleanup={cleanup} />);
}),
snapshot: () => store.getState().transcript.displayItems.length,
},
composer: {
render: vi.fn((composer: HTMLElement) => {
renderUiRoot(composer, <TrackedSlot slot="composer" cleanup={cleanup} />);
}),
snapshot: () => chatTurnBusy(store.getState()),
},
toolbarNode: vi.fn(() => <TrackedSlot region="toolbar" cleanup={cleanup} />),
goalNode: vi.fn(() => <TrackedSlot region="goal" cleanup={cleanup} />),
messagesNode: vi.fn(() => (
<div className="codex-panel__region codex-panel__region--messages codex-panel__messages">
<TrackedSlot region="messages" cleanup={cleanup} />
</div>
)),
composerNode: vi.fn(() => <TrackedSlot region="composer" cleanup={cleanup} />),
};
}
function TrackedSlot({ slot, cleanup }: { slot: string; cleanup: (slot: string) => void }) {
function nodeShellRenderers(store: ReturnType<typeof createChatStateStore>, cleanup: (region: string) => void) {
return {
stateStore: store,
showToolbar: true,
toolbarNode: vi.fn(() => (
<TrackedSlot region="toolbar" cleanup={cleanup} className="test-toolbar" text={store.getState().connection.status} />
)),
goalNode: vi.fn(() => (
<TrackedSlot
region="goal"
cleanup={cleanup}
className="test-goal"
text={store.getState().activeThread.goal?.objective ?? "no goal"}
/>
)),
messagesNode: vi.fn(() => (
<div className="codex-panel__region codex-panel__region--messages codex-panel__messages">
<TrackedSlot
region="messages"
cleanup={cleanup}
className="test-messages"
text={String(store.getState().transcript.displayItems.length)}
/>
</div>
)),
composerNode: vi.fn(() => (
<TrackedSlot region="composer" cleanup={cleanup} className="test-composer" text={chatTurnBusy(store.getState()) ? "busy" : "ready"} />
)),
};
}
function TrackedSlot({
region,
cleanup,
className,
text = region,
}: {
region: string;
cleanup: (region: string) => void;
className?: string;
text?: string;
}) {
useEffect(() => {
return () => {
cleanup(slot);
cleanup(region);
};
}, [cleanup, slot]);
return <div>{slot}</div>;
}, [cleanup, region]);
return <div className={className}>{text}</div>;
}
async function settleShellEffects(): Promise<void> {

View file

@ -7,8 +7,7 @@ import type { CodexChatHost } from "../../../src/features/chat/chat-host";
import { createAppServerDiagnostics } from "../../../src/app-server/diagnostics";
import { emptyRuntimeConfigSnapshot } from "../../../src/app-server/runtime-config";
import { threadFromAppServerThread } from "../../../src/app-server/thread-model";
import { createChatState, type ChatState } from "../../../src/features/chat/chat-state";
import { composerSlotSnapshot } from "../../../src/features/chat/panel/snapshot";
import type { ChatState } from "../../../src/features/chat/chat-state";
import type { ServerNotification } from "../../../src/generated/app-server/ServerNotification";
import type { Thread } from "../../../src/generated/app-server/v2/Thread";
import { notices } from "../../mocks/obsidian";
@ -184,18 +183,31 @@ describe("CodexChatView connection lifecycle", () => {
it("renders the chat shell on the view content root", async () => {
const view = await chatView();
const siblingRoots = Array.from(view.containerEl.children).filter((child) => child !== view.contentEl);
await view.onOpen();
const root = view.containerEl.children[1] as HTMLElement;
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__slot--messages")).not.toBeNull();
expect(root.querySelector(":scope > .codex-panel__body .codex-panel__slot--composer")).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--composer")).not.toBeNull();
expect(siblingRoots.every((sibling) => !sibling.classList.contains("codex-panel") && sibling.childElementCount === 0)).toBe(true);
});
it("unmounts the chat shell from the view content root on close", async () => {
const view = await chatView();
const siblingRoots = Array.from(view.containerEl.children).filter((child) => child !== view.contentEl);
await view.onOpen();
await view.onClose();
expect(view.contentEl.classList.contains("codex-panel")).toBe(true);
expect(view.contentEl.childElementCount).toBe(0);
expect(siblingRoots.every((sibling) => !sibling.classList.contains("codex-panel") && sibling.childElementCount === 0)).toBe(true);
});
it("wires open lifecycle registrations through the view controller host", async () => {
const activeLeafChangeListeners: ((leaf: unknown) => void)[] = [];
const addEventListener = vi.spyOn(document, "addEventListener");
const cachedThreadList = vi.fn(() => null);
const cachedAppServerMetadata = vi.fn(() => null);
@ -203,12 +215,11 @@ describe("CodexChatView connection lifecycle", () => {
cachedThreadList,
cachedAppServerMetadata,
});
const view = await chatView({ activeLeafChangeListeners, host });
const view = await chatView({ host });
await view.onOpen();
expect(addEventListener).toHaveBeenCalledWith("pointerdown", expect.any(Function));
expect(activeLeafChangeListeners).toHaveLength(1);
expect(cachedThreadList).toHaveBeenCalledOnce();
expect(cachedAppServerMetadata).toHaveBeenCalledOnce();
@ -432,35 +443,6 @@ describe("CodexChatView connection lifecycle", () => {
expect(state.connection.availableSkills).toEqual([{ name: "writer", enabled: true }]);
});
it("tracks composer slot dependencies for model and skill suggestions", async () => {
await chatView();
const state = createChatState();
state.connection.runtimeConfig = runtimeConfig("gpt-configured");
state.connection.availableSkills = [skillFixture("writer")];
const base = composerSlotSnapshot(state, null);
expect(
composerSlotSnapshot({ ...state, connection: { ...state.connection, runtimeConfig: runtimeConfig("gpt-updated") } }, null),
).not.toBe(base);
expect(
composerSlotSnapshot({ ...state, runtime: { ...state.runtime, requestedModel: { kind: "set", value: "gpt-requested" } } }, null),
).not.toBe(base);
expect(composerSlotSnapshot({ ...state, runtime: { ...state.runtime, selectedCollaborationMode: "plan" } }, null)).not.toBe(base);
expect(
composerSlotSnapshot(
{ ...state, runtime: { ...state.runtime, requestedApprovalsReviewer: { kind: "set", value: "auto_review" } } },
null,
),
).not.toBe(base);
expect(
composerSlotSnapshot({ ...state, runtime: { ...state.runtime, requestedServiceTier: { kind: "set", value: "fast" } } }, null),
).not.toBe(base);
expect(
composerSlotSnapshot({ ...state, connection: { ...state.connection, availableSkills: [skillFixture("reader")] } }, null),
).not.toBe(base);
});
it("hydrates a focused restored thread immediately", async () => {
vi.useFakeTimers();
const client = connectedClient();
@ -1095,20 +1077,6 @@ function threadFixture(threadId: string): Thread {
};
}
function runtimeConfig(model: string): ChatState["connection"]["runtimeConfig"] {
return { ...emptyRuntimeConfigSnapshot(), model };
}
function skillFixture(name: string): ChatState["connection"]["availableSkills"][number] {
return {
name,
description: `${name} skill`,
path: `/skills/${name}/SKILL.md`,
scope: "repo",
enabled: true,
} as ChatState["connection"]["availableSkills"][number];
}
function turnWithUserMessage(text: string) {
return {
id: "turn-1",

View file

@ -17,17 +17,16 @@ describe("ChatViewDeferredTasks", () => {
vi.useFakeTimers();
});
it("coalesces scheduled renders and preserves forced slot rendering", async () => {
it("coalesces scheduled renders", async () => {
const tasks = new ChatViewDeferredTasks(() => window);
const render = vi.fn();
tasks.scheduleRender(render);
tasks.scheduleRender(render, { forceSlots: true });
tasks.scheduleRender(render);
await vi.advanceTimersByTimeAsync(50);
expect(render).toHaveBeenCalledOnce();
expect(render).toHaveBeenCalledWith({ forceSlots: true });
});
it("clears scheduled deferred work", async () => {
@ -37,7 +36,7 @@ describe("ChatViewDeferredTasks", () => {
const hydration = vi.fn();
const warmup = vi.fn();
tasks.scheduleRender(render, { forceSlots: true });
tasks.scheduleRender(render);
tasks.scheduleDiagnostics(diagnostics);
tasks.scheduleRestoredThreadHydration(hydration);
tasks.scheduleAppServerWarmup(warmup);

View file

@ -1,12 +1,6 @@
import { describe, expect, it } from "vitest";
import { createChatState } from "../../../src/features/chat/chat-state";
import {
latestProposedPlanItem,
messagesSlotSnapshot,
openPanelTurnLifecycle,
parseRestoredThreadState,
} from "../../../src/features/chat/panel/snapshot";
import { latestProposedPlanItem, openPanelTurnLifecycle, parseRestoredThreadState } from "../../../src/features/chat/panel/snapshot";
describe("chat view snapshots", () => {
it("projects open panel turn lifecycle without exposing full chat state", () => {
@ -27,18 +21,6 @@ describe("chat view snapshots", () => {
).toBe("latest");
});
it("scopes message slot detail invalidation to message stream details", () => {
const state = createChatState();
const base = messagesSlotSnapshot(state, "");
state.ui.toolbarPanel = "history";
state.ui.openDetails = new Set(["goal:editor"]);
expect(messagesSlotSnapshot(state, "")).toBe(base);
state.ui.openDetails = new Set(["message:item:expanded"]);
expect(messagesSlotSnapshot(state, "")).not.toBe(base);
});
it("parses restored thread view state defensively", () => {
expect(parseRestoredThreadState({ threadId: "thread", threadTitle: "Title" })).toEqual({
threadId: "thread",

View file

@ -20,7 +20,7 @@ describe("panel CSS token scope", () => {
});
describe("chat toolbar CSS", () => {
it("lets the message slot size come from its grid row", () => {
it("lets the message region size come from its grid row", () => {
const messages = /\.codex-panel__messages \{(?<body>[^}]+)\}/.exec(styles)?.groups?.["body"] ?? "";
expect(messages).toContain("overflow-y: auto");
@ -34,7 +34,7 @@ describe("chat toolbar CSS", () => {
});
it("aligns goal banner spacing with the message rhythm", () => {
const goalSlot = /\.codex-panel__slot--goal:not\(:empty\) \{(?<body>[^}]+)\}/.exec(styles)?.groups?.["body"] ?? "";
const goalSlot = /\.codex-panel__region--goal:not\(:empty\) \{(?<body>[^}]+)\}/.exec(styles)?.groups?.["body"] ?? "";
const goal = /\.codex-panel__goal \{(?<body>[^}]+)\}/.exec(styles)?.groups?.["body"] ?? "";
expect(goalSlot).toContain("padding-bottom: var(--codex-panel-edge-padding-x)");