mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Unify chat panel under a single Preact shell
This commit is contained in:
parent
5e833f77a6
commit
777df21446
73 changed files with 2799 additions and 1428 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,10 @@ export class ChatMessageScrollIntentController {
|
|||
this.nextIntent = "force-bottom";
|
||||
}
|
||||
|
||||
followBottom(): void {
|
||||
this.nextIntent = "follow-bottom";
|
||||
}
|
||||
|
||||
preservePosition(): void {
|
||||
this.nextIntent = "preserve";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -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);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
}
|
||||
7
src/features/chat/panel/nodes/messages.ts
Normal file
7
src/features/chat/panel/nodes/messages.ts
Normal 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);
|
||||
}
|
||||
21
src/features/chat/panel/nodes/toolbar.ts
Normal file
21
src/features/chat/panel/nodes/toolbar.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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),
|
||||
});
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
20
src/features/chat/ui/message-stream/render-state.ts
Normal file
20
src/features/chat/ui/message-stream/render-state.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 === " "
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")}>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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[] {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
.codex-panel__slot--goal:not(:empty) {
|
||||
.codex-panel__region--goal:not(:empty) {
|
||||
padding-bottom: var(--codex-panel-edge-padding-x);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
31
tests/features/chat/ui/message-stream/render.test.tsx
Normal file
31
tests/features/chat/ui/message-stream/render.test.tsx
Normal 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 }));
|
||||
}
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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...");
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)");
|
||||
|
|
|
|||
Loading…
Reference in a new issue