Complete single Preact chat shell integration

This commit is contained in:
murashit 2026-06-13 09:57:13 +09:00
parent aa5b1b6ed0
commit ed93f7a634
53 changed files with 1522 additions and 1459 deletions

View file

@ -3,6 +3,7 @@ import { defineConfig } from "eslint/config";
import eslintConfigPrettier from "eslint-config-prettier/flat";
import obsidianmd from "eslint-plugin-obsidianmd";
import reactHooks from "eslint-plugin-react-hooks";
import ts from "typescript";
import tseslint from "typescript-eslint";
const typeScriptFiles = ["src/**/*.{ts,tsx}", "tests/**/*.{ts,tsx}"];
@ -53,25 +54,59 @@ const unsafeIteratorRestrictions = [
message: "Avoid reading iterator.next().value directly; use for...of or inspect the typed IteratorResult first.",
},
];
const imperativeDomWriteRestrictions = [
{
selector:
"CallExpression[callee.property.name=/^(createEl|createDiv|createSpan|appendChild|replaceChildren|insertBefore|removeChild|append|prepend|before|after|replaceWith|remove|insertAdjacentHTML|insertAdjacentElement|insertAdjacentText|setAttr|empty)$/]",
message: "Keep imperative DOM writes in an explicit bridge module or Obsidian-owned UI boundary.",
},
{
selector:
"AssignmentExpression[left.type='MemberExpression'][left.property.name=/^(innerHTML|outerHTML|textContent|value|checked|onclick|ondblclick|oninput|onchange|onkeydown|onkeyup|onmousedown|onmouseup|onmousemove|onpointerdown|onpointerup|onblur|onfocus|onselect|onscroll)$/]",
message: "Keep imperative DOM writes in an explicit bridge module or Obsidian-owned UI boundary.",
},
];
const imperativeDomEventRestrictions = [
{
selector: "CallExpression[callee.property.name=/^(addEventListener|removeEventListener)$/]",
message: "Keep imperative DOM event wiring in an explicit bridge module or Obsidian-owned UI boundary.",
},
];
const imperativeDomRestrictions = [...imperativeDomWriteRestrictions, ...imperativeDomEventRestrictions];
const imperativeDomWriteMethods = new Set([
"addClass",
"addClasses",
"append",
"appendChild",
"after",
"before",
"createDiv",
"createEl",
"createSpan",
"empty",
"hide",
"insertAdjacentElement",
"insertAdjacentHTML",
"insertAdjacentText",
"insertBefore",
"prepend",
"removeClass",
"removeClasses",
"remove",
"removeChild",
"replaceChildren",
"replaceWith",
"setCssProps",
"setCssStyles",
"setText",
"show",
"setAttr",
"toggleClass",
]);
const imperativeDomEventMethods = new Set(["addEventListener", "removeEventListener"]);
const imperativeDomAssignmentProperties = new Set([
"checked",
"innerHTML",
"onblur",
"onchange",
"onclick",
"ondblclick",
"onfocus",
"oninput",
"onkeydown",
"onkeyup",
"onmousedown",
"onmousemove",
"onmouseup",
"onpointerdown",
"onpointerup",
"onscroll",
"onselect",
"outerHTML",
"textContent",
"value",
]);
const pureChatModelRestrictions = [
{
selector: "CallExpression[callee.object.name='Date'][callee.property.name='now']",
@ -116,6 +151,7 @@ const chatImperativeDomBridgeFiles = [...chatExternalDomBridgeFiles, ...chatPrea
const nonChatImperativeDomBridgeFiles = [
"src/features/selection-rewrite/popover.tsx",
"src/features/thread-picker/modal.ts",
"src/features/threads-view/renderer.tsx",
"src/settings/dynamic-sections.ts",
"src/settings/tab.ts",
"src/shared/diff/render.ts",
@ -131,9 +167,8 @@ const baseSourceSyntaxRestrictions = [
...unsafeIteratorRestrictions,
...preactFormRestrictions,
];
const sourceSyntaxRestrictions = [...baseSourceSyntaxRestrictions, ...imperativeDomRestrictions];
const sourceSyntaxRestrictions = baseSourceSyntaxRestrictions;
const domBridgeSyntaxRestrictions = baseSourceSyntaxRestrictions;
const eventBridgeSyntaxRestrictions = [...baseSourceSyntaxRestrictions, ...imperativeDomWriteRestrictions];
const pureChatModelSyntaxRestrictions = [...sourceSyntaxRestrictions, ...pureChatModelRestrictions];
const codexPanelEslintPlugin = {
rules: {
@ -186,6 +221,74 @@ const codexPanelEslintPlugin = {
};
},
},
"no-imperative-dom": {
meta: {
type: "problem",
docs: {
description: "Disallow imperative DOM writes and event wiring outside explicit bridge files.",
},
messages: {
event: "Keep imperative DOM event wiring in an explicit bridge module or Obsidian-owned UI boundary.",
write: "Keep imperative DOM writes in an explicit bridge module or Obsidian-owned UI boundary.",
},
schema: [
{
type: "object",
additionalProperties: false,
properties: {
allowEvents: { type: "boolean" },
allowWrites: { type: "boolean" },
},
},
],
},
create(context) {
const options = context.options[0] ?? {};
const allowEvents = options.allowEvents === true;
const allowWrites = options.allowWrites === true;
let parserServices = null;
let checker = null;
const typeChecker = () => {
if (!parserServices) {
parserServices = parserServicesFromContext(context);
checker = parserServices.program.getTypeChecker();
}
return checker;
};
const isDomTarget = (node) => {
const services = parserServices ?? parserServicesFromContext(context);
if (!parserServices) {
parserServices = services;
checker = services.program.getTypeChecker();
}
const tsNode = services.esTreeNodeToTSNodeMap.get(node);
return typeIncludesDom(typeChecker().getTypeAtLocation(tsNode), typeChecker());
};
return {
AssignmentExpression(node) {
if (allowWrites || !isMemberExpression(node.left)) return;
const property = staticPropertyName(node.left.property);
if (!property || !imperativeDomAssignmentProperties.has(property)) return;
if (isDomTarget(node.left.object)) context.report({ node: node.left, messageId: "write" });
},
CallExpression(node) {
if (!isMemberExpression(node.callee)) return;
const method = staticPropertyName(node.callee.property);
if (!method) return;
if (!allowWrites && imperativeDomWriteMethods.has(method) && isDomTarget(node.callee.object)) {
context.report({ node: node.callee, messageId: "write" });
return;
}
if (!allowEvents && imperativeDomEventMethods.has(method) && isDomTarget(node.callee.object)) {
context.report({ node: node.callee, messageId: "event" });
}
},
};
},
},
},
};
@ -228,6 +331,40 @@ function staticPropertyName(node) {
return node?.type === "Identifier" ? node.name : node?.type === "Literal" && typeof node.value === "string" ? node.value : null;
}
function parserServicesFromContext(context) {
const services = context.sourceCode.parserServices;
if (!services?.program || !services.esTreeNodeToTSNodeMap) {
throw new Error("codex-panel/no-imperative-dom requires TypeScript parser services.");
}
return services;
}
function typeIncludesDom(type, checker, seen = new Set()) {
if (!type || seen.has(type.id)) return false;
seen.add(type.id);
if (type.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown | ts.TypeFlags.Never)) return false;
if (type.isUnionOrIntersection()) return type.types.some((item) => typeIncludesDom(item, checker, seen));
const typeName = checker.typeToString(type);
if (domTypeName(typeName)) return true;
const symbolName = type.getSymbol()?.getName() ?? type.aliasSymbol?.getName() ?? "";
if (domTypeName(symbolName)) return true;
const apparent = checker.getApparentType(type);
if (apparent !== type && typeIncludesDom(apparent, checker, seen)) return true;
const bases = typeof type.getBaseTypes === "function" ? (type.getBaseTypes() ?? []) : [];
return bases.some((base) => typeIncludesDom(base, checker, seen));
}
function domTypeName(name) {
return /\b(?:AbortSignal|Document|Element|EventTarget|HTML[A-Za-z]*Element|HTMLElement|Node|SVG[A-Za-z]*Element|SVGElement|Window)\b/.test(
name,
);
}
function findInitializerCallbackReference(root, name) {
let reference = null;
@ -372,13 +509,17 @@ export default defineConfig([
{
files: ["src/**/*.{ts,tsx}"],
ignores: ["src/features/chat/**/*.{ts,tsx}", ...nonChatImperativeDomBridgeFiles, ...nonUiEventListenerFiles],
rules: restrictedSyntaxRule(sourceSyntaxRestrictions),
rules: {
...restrictedSyntaxRule(sourceSyntaxRestrictions),
"codex-panel/no-imperative-dom": "error",
},
},
{
files: ["src/features/chat/**/*.{ts,tsx}"],
ignores: chatImperativeDomBridgeFiles,
rules: {
...restrictedSyntaxRule(sourceSyntaxRestrictions),
"codex-panel/no-imperative-dom": "error",
"codex-panel/no-chat-state-direct-mutation": "error",
},
},
@ -395,12 +536,16 @@ export default defineConfig([
},
{
files: nonUiEventListenerFiles,
rules: restrictedSyntaxRule(eventBridgeSyntaxRestrictions),
rules: {
...restrictedSyntaxRule(sourceSyntaxRestrictions),
"codex-panel/no-imperative-dom": ["error", { allowEvents: true }],
},
},
{
files: ["src/features/chat/state/reducer.ts", "src/features/chat/display/**/*.{ts,tsx}"],
files: ["src/features/chat/state/**/*.{ts,tsx}", "src/features/chat/display/**/*.{ts,tsx}"],
rules: {
...restrictedSyntaxRule(pureChatModelSyntaxRestrictions),
"codex-panel/no-imperative-dom": "error",
"codex-panel/no-chat-state-direct-mutation": "error",
},
},

View file

@ -27,7 +27,7 @@
"lint:css": "stylelint \"src/**/*.css\" --max-warnings=0",
"lint:css:usage": "node scripts/check-css-usage.mjs",
"lint:css:usage:check": "node scripts/check-css-usage.mjs --fail-on-candidates",
"lint:ts": "eslint src tests scripts \"*.config.ts\" \"*.config.mjs\" --max-warnings=0 --cache --cache-location node_modules/.cache/eslint/ --cache-strategy content",
"lint:ts": "eslint src tests scripts \"*.config.ts\" \"*.config.mjs\" --max-warnings=0",
"lint:ts:ci": "eslint src tests scripts \"*.config.ts\" \"*.config.mjs\" --max-warnings=0",
"release:check": "node scripts/release/check.mjs",
"release:preflight": "node scripts/release/preflight.mjs",

View file

@ -11,10 +11,10 @@ import type { GoalActions } from "../threads/goal-actions";
import type { AutoTitleController } from "../threads/auto-title-controller";
import { ChatInboundController } from "../protocol/inbound/controller";
import type { ChatConnectionWorkTracker } from "../lifecycle";
import type { ChatControllerCompositionPorts } from "../composition-ports";
import type { ChatControllerPorts } from "../controller-ports";
import { runtimeSnapshotForChatState } from "../runtime/snapshot";
type ChatServerActionControllerPorts = Pick<ChatControllerCompositionPorts, "plugin" | "state">;
type ChatServerActionControllerPorts = Pick<ChatControllerPorts, "plugin" | "state">;
export function createChatServerActionControllers(
context: ChatServerActionControllerPorts,
@ -59,7 +59,7 @@ export function createChatServerActionControllers(
return { serverThreads, serverMetadata, serverDiagnostics };
}
type ChatInboundControllerPorts = Pick<ChatControllerCompositionPorts, "plugin" | "state"> & {
type ChatInboundControllerPorts = Pick<ChatControllerPorts, "plugin" | "state"> & {
render: {
schedule: () => void;
};
@ -105,7 +105,7 @@ export function createChatInboundController(
});
}
type ChatConnectionControllerPorts = Pick<ChatControllerCompositionPorts, "plugin" | "state" | "liveState"> & {
type ChatConnectionControllerPorts = Pick<ChatControllerPorts, "plugin" | "state" | "liveState"> & {
client: {
setClient: (client: ReturnType<ConnectionManager["currentClient"]>) => void;
};
@ -176,7 +176,7 @@ export function createChatConnectionControllers(
};
}
type ChatReconnectControllerGroupPorts = Pick<ChatControllerCompositionPorts, "state"> & {
type ChatReconnectControllerGroupPorts = Pick<ChatControllerPorts, "state"> & {
client: {
clear: () => void;
ensureConnected: () => Promise<void>;

View file

@ -1,5 +1,4 @@
import type { App, Component, EventRef } from "obsidian";
import type { ComponentChild as UiNode } from "preact";
import type { AppServerClient } from "../../app-server/connection/client";
import type { ArchiveExportAdapter } from "../thread-export/archive-markdown";
@ -9,8 +8,10 @@ import type { DisplayDetailSection, DisplayItem } from "./display/types";
import type { ChatConnectionWorkTracker, ChatResumeWorkTracker, ChatViewDeferredTasks } from "./lifecycle";
import type { ComposerMetaViewModel } from "./ui/composer";
import type { CodexChatHost } from "./chat-host";
import type { ChatPanelShellSlots } from "./ui/shell";
import type { ChatPanelComposerShellState } from "./ui/shell-state";
export interface ChatControllerCompositionPorts {
export interface ChatControllerPorts {
obsidian: ChatPanelObsidianContext;
plugin: CodexChatHost;
state: ChatPanelStateContext;
@ -60,18 +61,15 @@ interface ChatPanelLifecycleContext {
interface ChatPanelRenderContext {
panelRoot: () => HTMLElement | null;
toolbarNode: () => UiNode;
goalNode: () => UiNode;
messageStreamNode: () => UiNode;
composerNode: () => UiNode;
shellSlots: () => ChatPanelShellSlots;
closeToolbarPanelOnOutsidePointer: (event: PointerEvent) => void;
schedule: () => void;
}
interface ChatPanelSurfaceContext {
pendingRequestsSignature: () => string;
composerPlaceholder: () => string;
composerMetaViewModel: () => ComposerMetaViewModel;
composerPlaceholder: (state: ChatPanelComposerShellState) => string;
composerMetaViewModel: (state: ChatPanelComposerShellState) => ComposerMetaViewModel;
}
interface ChatPanelRuntimeContext {

View file

@ -10,7 +10,7 @@ import type { ChatThreadActions } from "./threads/action-context";
import type { AutoTitleController } from "./threads/auto-title-controller";
import type { HistoryController } from "./threads/history-controller";
import type { RenameController } from "./threads/rename-controller";
import type { ToolbarPanelActions } from "./panel/regions/toolbar";
import type { ToolbarPanelActions } from "./panel/toolbar-actions";
import type { ChatConnectionController } from "./connection/connection-controller";
import type { ChatReconnectActions } from "./connection/reconnect-actions";
import type { PendingRequestController } from "./conversation/pending-requests/controller";
@ -21,8 +21,8 @@ import type { RestorationController } from "./threads/restoration-controller";
import type { IdentitySync } from "./threads/identity-sync";
import type { ResumeController } from "./threads/resume-controller";
import type { SelectionActions } from "./threads/selection-actions";
import type { MessageStreamRenderer } from "./ui/message-stream/renderer";
import type { ChatControllerCompositionPorts } from "./composition-ports";
import type { MessageStreamRenderer } from "./panel/surface/message-stream-renderer";
import type { ChatControllerPorts } from "./controller-ports";
import { scheduleAppServerWarmup } from "./connection/app-server-warmup";
import { runtimeSnapshotForChatState } from "./runtime/snapshot";
import {
@ -30,10 +30,10 @@ import {
createChatConnectionControllers,
createChatInboundController,
createChatReconnectControllerGroup,
} from "./connection/composition";
import { createThreadControllerGroup, createThreadSelectionActionGroup } from "./threads/composition";
import { createConversationSurfaceControllerGroup } from "./conversation/composition";
import { createChatViewRenderer, createConnectionLifecycleControllerGroup, createPanelUiControllerGroup } from "./panel/composition";
} from "./connection/controllers";
import { createThreadControllerGroup, createThreadSelectionActionGroup } from "./threads/controllers";
import { createConversationSurfaceControllerGroup } from "./conversation/controllers";
import { createChatViewRenderer, createConnectionLifecycleControllerGroup, createPanelUiControllerGroup } from "./panel/controllers";
export interface ChatViewControllers {
connection: {
@ -83,11 +83,11 @@ export interface ChatViewControllers {
};
}
interface ChatCompositionSideEffects {
render: Pick<ChatControllerCompositionPorts["render"], "panelRoot" | "closeToolbarPanelOnOutsidePointer" | "schedule"> & {
interface ChatControllerSideEffects {
render: Pick<ChatControllerPorts["render"], "panelRoot" | "closeToolbarPanelOnOutsidePointer" | "schedule"> & {
now: () => void;
};
status: ChatControllerCompositionPorts["status"] & {
status: ChatControllerPorts["status"] & {
addSystemMessage: (text: string) => void;
addStructuredSystemMessage: (text: string, details: DisplayDetailSection[]) => void;
};
@ -96,7 +96,7 @@ interface ChatCompositionSideEffects {
};
}
export function createChatViewControllers(ports: ChatControllerCompositionPorts): ChatViewControllers {
export function createChatViewControllers(ports: ChatControllerPorts): ChatViewControllers {
const connection = new ConnectionManager(() => ports.plugin.settings.codexPath, ports.plugin.vaultPath);
const renderNow = createChatViewRenderer(ports);
let connectionController: ChatConnectionController | null = null;
@ -107,7 +107,7 @@ export function createChatViewControllers(ports: ChatControllerCompositionPorts)
const refreshSkills = (forceReload?: boolean) =>
requireComposedController(connectionController, "connection controller").refreshSkills(forceReload);
const selectThread = (threadId: string) => requireComposedController(selection, "selection actions").selectThread(threadId);
const sideEffects = createChatCompositionSideEffects(ports, {
const sideEffects = createChatControllerSideEffects(ports, {
renderNow,
setComposerText: (text) => {
requireComposedController(composerController, "composer controller").setDraft(text, { focus: true });
@ -411,17 +411,17 @@ export function createChatViewControllers(ports: ChatControllerCompositionPorts)
}
function requireComposedController<T>(controller: T | null, name: string): T {
if (!controller) throw new Error(`Chat view controller composition did not initialize ${name}.`);
if (!controller) throw new Error(`Chat view controller graph did not initialize ${name}.`);
return controller;
}
function createChatCompositionSideEffects(
ports: Pick<ChatControllerCompositionPorts, "render" | "state" | "status">,
function createChatControllerSideEffects(
ports: Pick<ChatControllerPorts, "render" | "state" | "status">,
deps: {
renderNow: () => void;
setComposerText: (text: string) => void;
},
): ChatCompositionSideEffects {
): ChatControllerSideEffects {
const render = {
panelRoot: ports.render.panelRoot,
closeToolbarPanelOnOutsidePointer: ports.render.closeToolbarPanelOnOutsidePointer,

View file

@ -1,12 +1,12 @@
import type { App, EventRef } from "obsidian";
import type { ComponentChild as UiNode } from "preact";
import type { CodexInput } from "../../../../domain/chat/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 "../../state/reducer";
import type { ComposerMetaViewModel } from "../../ui/composer";
import { composerShellNode, syncComposerHeight, type ComposerCallbacks } from "../../ui/composer";
import type { ComposerMetaViewModel, ComposerShellProps } from "../../ui/composer";
import { syncComposerHeight, type ComposerCallbacks } from "../../ui/composer";
import type { ChatPanelComposerShellState } from "../../ui/shell-state";
import { composerBoundaryScrollDirection, type ComposerBoundaryScrollAction } from "./boundary-scroll";
import { noteCandidates as appNoteCandidates, resolveWikiLinkMention as resolveAppWikiLinkMention } from "./obsidian-context";
import {
@ -26,9 +26,9 @@ export interface ChatComposerControllerOptions {
viewId: string;
sendShortcut: () => SendShortcut;
scrollThreadFromComposerEdges: () => boolean;
canInterrupt: () => boolean;
composerPlaceholder: () => string;
composerMeta: () => ComposerMetaViewModel;
canInterrupt: (state: ChatPanelComposerShellState) => boolean;
composerPlaceholder: (state: ChatPanelComposerShellState) => string;
composerMeta: (state: ChatPanelComposerShellState) => ComposerMetaViewModel;
currentModelForSuggestions: () => string | null;
togglePlan: () => void;
toggleAutoReview: () => void;
@ -78,20 +78,19 @@ export class ChatComposerController {
registerEvent(this.options.app.vault.on("modify", invalidate));
}
renderNode(): UiNode {
const state = this.state;
return composerShellNode(
this.options.viewId,
state.composer.draft,
chatTurnBusy(state),
this.options.canInterrupt(),
this.options.composerPlaceholder(),
state.composer.suggestions,
state.composer.suggestSelected,
this.composerCallbacks(),
this.options.composerMeta(),
this.setComposerElement,
);
renderState(state: ChatPanelComposerShellState = this.state): ComposerShellProps {
return {
viewId: this.options.viewId,
draft: state.composer.draft,
busy: chatTurnBusy(state),
canInterrupt: this.options.canInterrupt(state),
normalPlaceholder: this.options.composerPlaceholder(state),
suggestions: state.composer.suggestions,
selectedSuggestionIndex: state.composer.suggestSelected,
callbacks: this.composerCallbacks(),
meta: this.options.composerMeta(state),
onComposer: this.setComposerElement,
};
}
private readonly setComposerElement = (composer: HTMLTextAreaElement | null): void => {

View file

@ -14,16 +14,16 @@ import type { HistoryController } from "../threads/history-controller";
import type { ChatInboundController } from "../protocol/inbound/controller";
import { currentModel, runtimeConfigOrDefault } from "../runtime/effective";
import { runtimeSnapshotForChatState } from "../runtime/snapshot";
import { MessageStreamRenderer } from "../ui/message-stream/renderer";
import type { ChatControllerCompositionPorts } from "../composition-ports";
import { MessageStreamRenderer } from "../panel/surface/message-stream-renderer";
import type { ChatControllerPorts } from "../controller-ports";
import type { DisplayDetailSection } from "../display/types";
type ConversationSurfaceControllerGroupPorts = Pick<
ChatControllerCompositionPorts,
ChatControllerPorts,
"obsidian" | "plugin" | "state" | "lifecycle" | "surface" | "runtime" | "liveState"
> & {
client: {
getClient: ChatControllerCompositionPorts["client"]["getClient"];
getClient: ChatControllerPorts["client"]["getClient"];
ensureConnected: () => Promise<void>;
};
render: {
@ -35,15 +35,15 @@ type ConversationSurfaceControllerGroupPorts = Pick<
addSystemMessage: (text: string) => void;
addStructuredSystemMessage: (text: string, details: DisplayDetailSection[]) => void;
};
scroll: Pick<ChatControllerCompositionPorts["scroll"], "forceBottom" | "followBottom">;
scroll: Pick<ChatControllerPorts["scroll"], "forceBottom" | "followBottom">;
thread: {
ensureRestoredThreadLoaded: ChatControllerCompositionPorts["thread"]["ensureRestoredThreadLoaded"];
startNewThread: ChatControllerCompositionPorts["thread"]["startNewThread"];
ensureRestoredThreadLoaded: ChatControllerPorts["thread"]["ensureRestoredThreadLoaded"];
startNewThread: ChatControllerPorts["thread"]["startNewThread"];
selectThread: (threadId: string) => Promise<void>;
notifyIdentityChanged: () => void;
resetTurnPresence: (hadTurns: boolean) => void;
};
runtime: ChatControllerCompositionPorts["runtime"] & {
runtime: ChatControllerPorts["runtime"] & {
mcpStatusLines: () => Promise<string[]>;
};
};
@ -72,9 +72,8 @@ export function createConversationSurfaceControllerGroup(
viewId,
sendShortcut: () => plugin.settings.sendShortcut,
scrollThreadFromComposerEdges: () => plugin.settings.scrollThreadFromComposerEdges,
canInterrupt: () => {
const current = stateStore.getState();
return current.turn.lifecycle.kind !== "idle" && Boolean(current.activeThread.id && activeTurnId(current));
canInterrupt: (state) => {
return state.turn.lifecycle.kind !== "idle" && Boolean(state.activeThread.id && activeTurnId(state));
},
composerPlaceholder: surface.composerPlaceholder,
composerMeta: surface.composerMetaViewModel,

View file

@ -6,11 +6,11 @@ import type { ChatServerThreadActions } from "../connection/server-actions/threa
import type { ChatComposerController } from "../conversation/composer/controller";
import type { ChatThreadActions } from "../threads/action-context";
import { closeChatView, openChatView, type ChatViewLifecycleHost } from "./view-lifecycle";
import { createToolbarArchiveConfirmState, createToolbarPanelActions } from "./regions/toolbar";
import { createToolbarArchiveConfirmState, createToolbarPanelActions } from "./toolbar-actions";
import { applyChatViewState } from "./view-state";
import type { MessageStreamRenderer } from "../ui/message-stream/renderer";
import type { MessageStreamRenderer } from "./surface/message-stream-renderer";
import type { ChatViewDeferredTasks, RestoredThreadState } from "../lifecycle";
import type { ChatControllerCompositionPorts } from "../composition-ports";
import type { ChatControllerPorts } from "../controller-ports";
import { renderChatPanelShell } from "../ui/shell";
export interface CachedSharedAppServerStateSource {
@ -18,7 +18,7 @@ export interface CachedSharedAppServerStateSource {
cachedAppServerMetadata: () => SharedServerMetadata | null;
}
type ChatViewRendererPorts = Pick<ChatControllerCompositionPorts, "plugin" | "state" | "lifecycle" | "render">;
type ChatViewRendererPorts = Pick<ChatControllerPorts, "plugin" | "state" | "lifecycle" | "render">;
export function createChatViewRenderer(context: ChatViewRendererPorts): () => void {
const { plugin, render, lifecycle } = context;
@ -31,15 +31,12 @@ export function createChatViewRenderer(context: ChatViewRendererPorts): () => vo
renderChatPanelShell(root, {
stateStore: context.state.stateStore,
showToolbar: plugin.settings.showToolbar,
toolbarNode: context.render.toolbarNode,
goalNode: context.render.goalNode,
messageStreamNode: context.render.messageStreamNode,
composerNode: context.render.composerNode,
slots: context.render.shellSlots(),
});
};
}
type ConnectionLifecycleControllerGroupPorts = Pick<ChatControllerCompositionPorts, "plugin" | "liveState"> & {
type ConnectionLifecycleControllerGroupPorts = Pick<ChatControllerPorts, "plugin" | "liveState"> & {
obsidian: Pick<ChatViewLifecycleHost["events"], "registerEvent" | "registerPointerDown">;
plugin: CachedSharedAppServerStateSource;
client: {
@ -137,7 +134,7 @@ export function createConnectionLifecycleControllerGroup(
};
}
type PanelUiControllerGroupPorts = Pick<ChatControllerCompositionPorts, "state"> & {
type PanelUiControllerGroupPorts = Pick<ChatControllerPorts, "state"> & {
lifecycle: {
invalidateResumeWork: () => void;
clearDeferredRestoredThreadHydration: () => void;

View file

@ -1,31 +0,0 @@
import type { ComponentChild as UiNode } from "preact";
import { h } from "preact";
import { pendingRequestsSignature as requestStateSignature } from "../../conversation/pending-requests/signatures";
import { useChatPanelShellState } from "../../ui/shell";
import type { ChatPanelMessageStreamPorts } from "./ports";
export function chatPanelMessageStreamRegionNode(ports: ChatPanelMessageStreamPorts): UiNode {
return h(MessageStreamRegion, { ports });
}
function MessageStreamRegion({ ports }: { ports: ChatPanelMessageStreamPorts }): UiNode {
const { activeThread, runtime, messageStream, requests, turn, ui, renderVersion } = useChatPanelShellState();
void activeThread.value;
void runtime.value;
void messageStream.value;
void requests.value;
void turn.value;
void ui.value;
void renderVersion.value;
return chatPanelMessageStreamNode(ports);
}
function chatPanelMessageStreamNode(ports: ChatPanelMessageStreamPorts) {
return ports.render.node();
}
export function chatPanelMessageStreamPendingRequestsSignature(ports: ChatPanelMessageStreamPorts): string {
const state = ports.state.chat();
return requestStateSignature(state.requests.approvals, state.requests.pendingUserInputs, state.requests.userInputDrafts);
}

View file

@ -1,293 +0,0 @@
import type { ComponentChild as UiNode } from "preact";
import { h } from "preact";
import { useEffect, useState } from "preact/hooks";
import type { Thread } from "../../../../domain/threads/model";
import { getThreadTitle } from "../../../../domain/threads/model";
import type { ChatThreadActions } from "../../threads/action-context";
import { runtimeConfigSections, rateLimitSummary } from "../../display/status/runtime";
import { connectionDiagnosticSections } from "../../display/status/diagnostics";
import type { RuntimeSnapshot } from "../../runtime/snapshot";
import type { ChatAction, ChatState, ChatStateStore } from "../../state/reducer";
import { useChatPanelShellState, type ChatPanelShellState } from "../../ui/shell";
import { toolbarNode, type ToolbarThreadRow, type ToolbarViewModel } from "../../ui/toolbar";
import type { ChatPanelToolbarPorts } from "./ports";
export interface ToolbarViewModelInput {
state: ChatState;
snapshot: RuntimeSnapshot;
connected: boolean;
turnBusy: boolean;
vaultPath: string;
configuredCommand: string;
archiveConfirmThreadId: string | null;
archiveExportEnabled: boolean;
renameState: (threadId: string) => ToolbarThreadRow["rename"];
}
export interface ConnectionDiagnosticsModelInput {
state: ChatState;
connected: boolean;
configuredCommand: string;
}
export interface ToolbarPanelActionsHost {
stateStore: ChatStateStore;
threadActions: ChatThreadActions;
archiveConfirm: ToolbarArchiveConfirmState;
scheduleRender: () => void;
}
export interface ToolbarArchiveConfirmState {
get: () => string | null;
set: (threadId: string | null) => void;
subscribe: (listener: () => void) => () => void;
}
export interface ToolbarPanelActions {
archiveConfirmId(): string | null;
onArchiveConfirmChange(listener: () => void): () => void;
toggleHistory(): void;
toggleChatActions(): void;
closeToolbarPanels(): void;
toggleStatus(): void;
closeForThreadSelection(): void;
startArchive(threadId: string): void;
archiveThread(threadId: string, saveMarkdown: boolean): Promise<void>;
closeOnOutsidePointer(context: ToolbarOutsidePointerContext): void;
}
interface ToolbarOutsidePointerContext {
target: EventTarget | null;
viewWindow: ToolbarDomWindow | null;
contains: (element: Element) => boolean;
renameEditing: boolean;
}
type ToolbarDomWindow = Window & { Element: typeof Element };
function chatPanelToolbarViewModel(ports: ChatPanelToolbarPorts, shellState: ChatPanelShellState) {
const latestState = shellState.latestState();
return toolbarViewModel({
state: {
...latestState,
connection: shellState.connection.value,
threadList: shellState.threadList.value,
activeThread: shellState.activeThread.value,
runtime: shellState.runtime.value,
turn: shellState.turn.value,
ui: shellState.ui.value,
},
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),
});
}
export function chatPanelToolbarRegionNode(ports: ChatPanelToolbarPorts): UiNode {
return h(ToolbarRegion, { ports });
}
function ToolbarRegion({ ports }: { ports: ChatPanelToolbarPorts }): UiNode {
const shellState = useChatPanelShellState();
useToolbarArchiveConfirmSubscription(ports);
useToolbarRenameSubscription(ports);
void shellState.renderVersion.value;
return toolbarNode(chatPanelToolbarViewModel(ports, shellState), ports.actions.toolbar);
}
function useToolbarArchiveConfirmSubscription(ports: ChatPanelToolbarPorts): void {
const [, setVersion] = useState(0);
useEffect(
() =>
ports.view.toolbar.archiveConfirmSubscribe(() => {
setVersion((version) => version + 1);
}),
[ports],
);
}
function useToolbarRenameSubscription(ports: ChatPanelToolbarPorts): void {
const [, setVersion] = useState(0);
useEffect(
() =>
ports.view.toolbar.renameSubscribe(() => {
setVersion((version) => version + 1);
}),
[ports],
);
}
export function toolbarViewModel(input: ToolbarViewModelInput): ToolbarViewModel {
const { state, snapshot } = input;
const limit = rateLimitSummary(snapshot, Date.now());
const historyOpen = state.ui.toolbarPanel === "history";
const chatActionsOpen = state.ui.toolbarPanel === "chat-actions";
const statusPanelOpen = state.ui.toolbarPanel === "status-panel";
return {
newChatDisabled: input.turnBusy,
chatActionsOpen,
historyOpen,
statusPanelOpen,
rateLimit: limit,
configSections: runtimeConfigSections(snapshot, input.vaultPath),
openPanel: historyOpen ? "history" : chatActionsOpen ? "chat-actions" : statusPanelOpen ? "status" : null,
threads: toolbarThreadRows({
threads: state.threadList.listedThreads,
activeThreadId: state.activeThread.id,
turnBusy: input.turnBusy,
archiveConfirmThreadId: input.archiveConfirmThreadId,
archiveExportEnabled: input.archiveExportEnabled,
renameState: input.renameState,
}),
connectLabel: input.connected ? "Reconnect" : "Connect",
diagnostics: connectionDiagnosticsModel({
state,
connected: input.connected,
configuredCommand: input.configuredCommand,
}),
};
}
function toolbarThreadRows(input: {
threads: readonly Thread[];
activeThreadId: string | null;
turnBusy: boolean;
archiveConfirmThreadId: string | null;
archiveExportEnabled: boolean;
renameState: (threadId: string) => ToolbarThreadRow["rename"];
}): ToolbarThreadRow[] {
return input.threads.map((thread) => {
const threadId = thread.id;
return {
title: getThreadTitle(thread),
threadId,
selected: threadId === input.activeThreadId,
disabled: input.turnBusy && threadId !== input.activeThreadId,
canArchive: true,
archiveConfirm: {
active: input.archiveConfirmThreadId === threadId,
defaultSaveMarkdown: input.archiveExportEnabled,
},
rename: input.renameState(threadId),
};
});
}
export function connectionDiagnosticsModel(input: ConnectionDiagnosticsModelInput): ReturnType<typeof connectionDiagnosticSections> {
return connectionDiagnosticSections({
connected: input.connected,
configuredCommand: input.configuredCommand,
initializeResponse: input.state.connection.initializeResponse,
diagnostics: input.state.connection.serverDiagnostics,
});
}
export function createToolbarPanelActions(host: ToolbarPanelActionsHost): ToolbarPanelActions {
const state = (): ChatState => host.stateStore.getState();
const dispatch = (action: ChatAction): void => {
host.stateStore.dispatch(action);
};
const hasOpenPanel = (): boolean => state().ui.toolbarPanel !== null;
const close = (): void => {
if (!hasOpenPanel()) return;
dispatch({ type: "ui/panel-set", panel: null });
host.archiveConfirm.set(null);
host.scheduleRender();
};
return {
archiveConfirmId(): string | null {
return host.archiveConfirm.get();
},
onArchiveConfirmChange(listener: () => void): () => void {
return host.archiveConfirm.subscribe(listener);
},
toggleHistory(): void {
dispatch({ type: "ui/panel-set", panel: "history", toggle: true });
host.scheduleRender();
},
toggleChatActions(): void {
dispatch({ type: "ui/panel-set", panel: "chat-actions", toggle: true });
host.scheduleRender();
},
closeToolbarPanels(): void {
close();
},
toggleStatus(): void {
dispatch({ type: "ui/panel-set", panel: "status-panel", toggle: true });
host.scheduleRender();
},
closeForThreadSelection(): void {
host.archiveConfirm.set(null);
},
startArchive(threadId: string): void {
host.archiveConfirm.set(threadId);
},
async archiveThread(threadId: string, saveMarkdown: boolean): Promise<void> {
if (host.archiveConfirm.get() === threadId) host.archiveConfirm.set(null);
await host.threadActions.archiveThread(threadId, saveMarkdown);
host.scheduleRender();
},
closeOnOutsidePointer(context: ToolbarOutsidePointerContext): void {
if (!hasOpenPanel()) return;
const target = context.target;
if (isToolbarElement(target, context.viewWindow)) {
const insideToolbarPanel = target.closest(".codex-panel__toolbar-primary, .codex-panel__toolbar-panel");
if (insideToolbarPanel && context.contains(insideToolbarPanel)) {
if (host.archiveConfirm.get() && !target.closest(".codex-panel__archive-confirm")) {
host.archiveConfirm.set(null);
}
return;
}
}
if (host.archiveConfirm.get()) {
host.archiveConfirm.set(null);
}
if (context.renameEditing) return;
close();
},
};
}
function isToolbarElement(target: EventTarget | null, viewWindow: ToolbarDomWindow | null): target is Element {
return Boolean(viewWindow && target instanceof viewWindow.Element);
}
export function createToolbarArchiveConfirmState(): ToolbarArchiveConfirmState {
let threadId: string | null = null;
const listeners = new Set<() => void>();
return {
get: () => threadId,
set: (nextThreadId) => {
if (threadId === nextThreadId) return;
threadId = nextThreadId;
for (const listener of listeners) listener();
},
subscribe: (listener) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
};
}

View file

@ -14,19 +14,28 @@ import { contextSummary } from "../../display/status/runtime";
import { sortedModelMetadata } from "../../../../domain/catalog/metadata";
import type { ReasoningEffort } from "../../../../domain/catalog/metadata";
import type { RuntimeSnapshot } from "../../runtime/snapshot";
import { runtimeSnapshotForChatSlices } from "../../runtime/snapshot";
import type { ChatState } from "../../state/reducer";
import { messageStreamDisplayItems } from "../../state/message-stream";
import type {
ComposerContextMeterCellViewModel,
ComposerContextMeterViewModel,
ComposerMetaViewModel,
RuntimeChoice,
} from "../../ui/composer";
import { useChatPanelShellState } from "../../ui/shell";
import { ComposerShell, type ComposerShellProps } from "../../ui/composer";
import { composerStateFromShellState, useChatPanelShellState, type ChatPanelComposerShellState } from "../../ui/shell-state";
import { explicitThreadName } from "../../../../domain/threads/model";
import type { ChatPanelComposerPorts, RestoredThreadTitleSnapshot } from "./ports";
type ComposerMetaState = Pick<ChatState, "connection" | "runtime">;
export interface ChatPanelComposerController {
renderState(state: ChatPanelComposerShellState): ComposerShellProps;
}
export interface RuntimeComposerChoicesInput {
state: ChatState;
state: Pick<ChatState, "connection">;
snapshot: RuntimeSnapshot;
requestModel: (model: string) => void;
requestReasoningEffort: (effort: ReasoningEffort) => void;
@ -37,30 +46,30 @@ export function composerPlaceholder(threadName: string | null): string {
return threadName ? `Ask Codex to work on “${threadName}”...` : "Ask Codex to work on this task...";
}
export function chatPanelComposerRegionNode(node: () => UiNode): UiNode {
return h(ComposerRegion, { node });
export function ChatPanelComposer({ controller }: { controller: ChatPanelComposerController }): UiNode {
const state = composerStateFromShellState(useChatPanelShellState());
return h(ComposerShell, controller.renderState(state));
}
function ComposerRegion({ node }: { node: () => UiNode }): UiNode {
const { connection, threadList, activeThread, runtime, turn, messageStream, composer, renderVersion } = useChatPanelShellState();
void connection.value;
void threadList.value;
void activeThread.value;
void runtime.value;
void turn.value;
void messageStream.value;
void composer.value;
void renderVersion.value;
return node();
export function chatPanelComposerPlaceholder(ports: ChatPanelComposerPorts, state: ChatPanelComposerShellState): string {
return composerPlaceholder(activeComposerThreadName(state, ports.thread.restoredPlaceholder()));
}
export function chatPanelComposerPlaceholder(ports: ChatPanelComposerPorts): string {
return composerPlaceholder(activeComposerThreadName(ports.state.chat(), ports.thread.restoredPlaceholder()));
}
export function chatPanelComposerMetaViewModel(ports: ChatPanelComposerPorts) {
const state = ports.state.chat();
const snapshot = ports.runtime.snapshot();
export function chatPanelComposerMetaViewModel(
ports: ChatPanelComposerPorts,
state: ChatPanelComposerShellState,
): ComposerMetaViewModel & {
modelChoices: RuntimeChoice[];
effortChoices: RuntimeChoice[];
} {
const snapshot = runtimeSnapshotForChatSlices({
runtimeConfig: state.connection.runtimeConfig,
activeThread: state.activeThread,
runtime: state.runtime,
rateLimit: state.connection.rateLimit,
displayItems: messageStreamDisplayItems(state.messageStream),
availableModels: state.connection.availableModels,
});
return {
...composerMetaViewModel(state, snapshot),
...runtimeComposerChoices({
@ -73,7 +82,7 @@ export function chatPanelComposerMetaViewModel(ports: ChatPanelComposerPorts) {
};
}
export function composerMetaViewModel(state: ChatState, snapshot: RuntimeSnapshot): ComposerMetaViewModel {
export function composerMetaViewModel(state: ComposerMetaState, snapshot: RuntimeSnapshot): ComposerMetaViewModel {
if (state.connection.status === "Connection failed.") {
return {
fatal: "Codex app-server disconnected",
@ -181,7 +190,10 @@ function onOffLabel(active: boolean): string {
return active ? "on" : "off";
}
function activeComposerThreadName(state: ChatState, restoredThread: RestoredThreadTitleSnapshot | null): string | null {
function activeComposerThreadName(
state: Pick<ChatState, "activeThread" | "threadList">,
restoredThread: RestoredThreadTitleSnapshot | null,
): string | null {
const threadId = state.activeThread.id;
if (!threadId) return null;
const thread = state.threadList.listedThreads.find((item) => item.id === threadId);

View file

@ -1,25 +1,17 @@
import type { ComponentChild as UiNode } from "preact";
import type { CodexPanelSettings } from "../../../../settings/model";
import type { RuntimeSnapshot } from "../../runtime/snapshot";
import type { ChatViewControllers } from "../../composition";
import { type ChatAction, type ChatState, type ChatStateStore, chatTurnBusy } from "../../state/reducer";
import type { RestoredThreadTitleSnapshot, ChatPanelRegionPorts } from "./ports";
import type { ChatViewControllers } from "../../controllers";
import { type ChatAction, type ChatState, type ChatStateStore } from "../../state/reducer";
import type { RestoredThreadTitleSnapshot, ChatPanelSurfacePorts } from "./ports";
export interface ChatPanelRegionHost {
export interface ChatPanelSurfaceHost {
settings: CodexPanelSettings;
vaultPath: string;
stateStore: ChatStateStore;
runtimeSnapshot: () => RuntimeSnapshot;
restoredThreadPlaceholder: () => RestoredThreadTitleSnapshot | null;
messageStreamNode: () => UiNode;
startNewThread: () => Promise<void>;
}
export function createChatPanelRegionPorts(host: ChatPanelRegionHost, controllers: ChatViewControllers): ChatPanelRegionPorts {
const state = {
chat: () => host.stateStore.getState(),
};
export function createChatPanelSurfacePorts(host: ChatPanelSurfaceHost, controllers: ChatViewControllers): ChatPanelSurfacePorts {
const dispatch = (action: ChatAction): void => {
host.stateStore.dispatch(action);
};
@ -35,24 +27,18 @@ export function createChatPanelRegionPorts(host: ChatPanelRegionHost, controller
return {
toolbar: {
state: {
...state,
connected: () => controllers.connection.manager.isConnected(),
turnBusy: () => chatTurnBusy(host.stateStore.getState()),
},
settings: {
vaultPath: () => host.vaultPath,
configuredCommand: () => host.settings.codexPath,
archiveExportEnabled: () => host.settings.archiveExportEnabled,
},
runtime: {
snapshot: host.runtimeSnapshot,
},
view: {
toolbar: {
archiveConfirmId: () => controllers.toolbar.panels.archiveConfirmId(),
archiveConfirmSubscribe: (listener) => controllers.toolbar.panels.onArchiveConfirmChange(listener),
archiveConfirm: controllers.toolbar.panels.archiveConfirm,
renameState: (threadId) => controllers.thread.rename.editState(threadId),
renameSubscribe: (listener) => controllers.thread.rename.subscribe(listener),
renameVersion: controllers.thread.rename.version,
},
},
actions: {
@ -109,7 +95,6 @@ export function createChatPanelRegionPorts(host: ChatPanelRegionHost, controller
},
},
goal: {
state,
settings: {
sendShortcut: () => host.settings.sendShortcut,
},
@ -124,19 +109,11 @@ export function createChatPanelRegionPorts(host: ChatPanelRegionHost, controller
},
},
},
messageStream: {
state,
render: {
node: host.messageStreamNode,
},
},
composer: {
state,
thread: {
restoredPlaceholder: host.restoredThreadPlaceholder,
},
runtime: {
snapshot: host.runtimeSnapshot,
requestModel: (model) => controllers.runtime.settings.requestModelFromUi(model),
requestReasoningEffort: (effort) => controllers.runtime.settings.requestReasoningEffortFromUi(effort),
resetReasoningEffortToConfig: () => controllers.runtime.settings.resetReasoningEffortToConfigFromUi(),

View file

@ -1,36 +1,23 @@
import type { ComponentChild as UiNode } from "preact";
import { h } from "preact";
import { useComputed } from "@preact/signals";
import type { GoalRegionActions, GoalRegionOptions } from "../../ui/goal";
import { goalRegionNode } from "../../ui/goal";
import { useChatPanelShellState } from "../../ui/shell";
import type { GoalPanelActions, GoalPanelOptions } from "../../ui/goal";
import { GoalPanel } from "../../ui/goal";
import { goalStateFromShellState, useChatPanelShellState, type ChatPanelGoalShellState } from "../../ui/shell-state";
import type { ChatPanelGoalPorts } from "./ports";
export function chatPanelGoalRegionNode(ports: ChatPanelGoalPorts): UiNode {
return h(GoalRegion, { ports });
}
function GoalRegion({ ports }: { ports: ChatPanelGoalPorts }): UiNode {
const { activeThread, ui, renderVersion, latestState } = useChatPanelShellState();
const props = useComputed(() => {
void renderVersion.value;
return chatPanelGoalProps(ports, {
...latestState(),
activeThread: activeThread.value,
ui: ui.value,
});
});
return goalRegionNode(props.value.goal, props.value.actions, props.value.options);
export function ChatPanelGoal({ ports }: { ports: ChatPanelGoalPorts }): UiNode {
const props = chatPanelGoalProps(ports, goalStateFromShellState(useChatPanelShellState()));
return h(GoalPanel, props);
}
export function chatPanelGoalProps(
ports: ChatPanelGoalPorts,
state: ReturnType<ChatPanelGoalPorts["state"]["chat"]>,
state: ChatPanelGoalShellState,
): {
goal: ReturnType<ChatPanelGoalPorts["state"]["chat"]>["activeThread"]["goal"];
actions: GoalRegionActions;
options: GoalRegionOptions;
goal: ChatPanelGoalShellState["activeThread"]["goal"];
actions: GoalPanelActions;
options: GoalPanelOptions;
} {
const goal = state.activeThread.goal;
const goalThreadId = goal?.threadId ?? null;

View file

@ -1,14 +1,12 @@
import type { App, Component } from "obsidian";
import type { ComponentChild as UiNode } from "preact";
import { copyTextWithNotice } from "../../../../shared/ui/clipboard";
import { chatTurnBusy, type ChatAction, type ChatState, type ChatStateStore } from "../../state/reducer";
import type { ComposerBoundaryScrollAction } from "../../conversation/composer/boundary-scroll";
import type { MessageStreamScrollIntent, MessageStreamVirtualizerHandle } from "./virtualizer";
import type { ChatMessageStreamActionPort, ChatMessageStreamContextPort, ChatMessageStreamRequestPort } from "./ports";
import { createMessageStreamContextPort } from "./ports";
import { MarkdownMessageRenderer } from "./markdown-renderer";
import { messageStreamViewportNode, type MessageStreamViewportState } from "./viewport";
import type { MessageStreamScrollIntent, MessageStreamVirtualizerHandle } from "../../ui/message-stream/virtualizer";
import type { ChatMessageStreamActionPort, ChatMessageStreamContextPort, ChatMessageStreamRequestPort } from "./message-stream-ports";
import { createMessageStreamContextPort } from "./message-stream-ports";
import { MarkdownMessageRenderer } from "../../ui/message-stream/markdown-renderer";
import type { MessageStreamViewportState } from "../../ui/message-stream/viewport";
import type { DisplayItem } from "../../display/types";
import { implementPlanCandidateFromState } from "../../state/selectors";
import { messageStreamActiveItems, messageStreamDisplayItems, messageStreamStableItems } from "../../state/message-stream";
@ -18,8 +16,9 @@ import {
isRollbackCandidateItem,
rollbackCandidateFromItems,
} from "../../display/item-actions";
import { messageStreamBlocks } from "./stream-blocks";
import type { MessageStreamContext } from "./context";
import { messageStreamBlocks } from "../../ui/message-stream/stream-blocks";
import type { MessageStreamContext } from "../../ui/message-stream/context";
import type { ChatPanelMessageStreamShellState } from "../../ui/shell-state";
interface MessageStreamRendererObsidianPort {
app: App;
@ -72,12 +71,11 @@ export class MessageStreamRenderer {
this.options.state.store.dispatch(action);
}
renderNode(): UiNode {
const state = this.state;
return messageStreamViewportNode(this.renderStateFor(state));
renderState(state: ChatPanelMessageStreamShellState = this.state): MessageStreamViewportState {
return this.renderStateFor(state);
}
private renderStateFor(state: ChatState): MessageStreamViewportState {
private renderStateFor(state: ChatPanelMessageStreamShellState): MessageStreamViewportState {
return {
blocks: messageStreamBlocks(this.messageStreamContext(state, this.messageStreamPort())),
consumeScrollIntent: this.options.scroll.consumeIntent,
@ -104,7 +102,7 @@ export class MessageStreamRenderer {
});
}
private messageStreamContext(state: ChatState, port: ChatMessageStreamContextPort): MessageStreamContext {
private messageStreamContext(state: ChatPanelMessageStreamShellState, port: ChatMessageStreamContextPort): MessageStreamContext {
const busy = chatTurnBusy(state);
const displayItems = messageStreamDisplayItems(state.messageStream);
const rollbackCandidate = busy ? null : rollbackCandidateFromItems(displayItems);

View file

@ -0,0 +1,14 @@
import type { ComponentChild as UiNode } from "preact";
import { h } from "preact";
import { MessageStreamViewport, type MessageStreamViewportState } from "../../ui/message-stream/viewport";
import { messageStreamStateFromShellState, useChatPanelShellState, type ChatPanelMessageStreamShellState } from "../../ui/shell-state";
export interface ChatPanelMessageStreamRenderer {
renderState(state: ChatPanelMessageStreamShellState): MessageStreamViewportState;
}
export function ChatPanelMessageStream({ renderer }: { renderer: ChatPanelMessageStreamRenderer }): UiNode {
const state = messageStreamStateFromShellState(useChatPanelShellState());
return h(MessageStreamViewport, { state: renderer.renderState(state) });
}

View file

@ -1,7 +1,5 @@
import type { ComponentChild as UiNode } from "preact";
import type { ChatState } from "../../state/reducer";
import type { Signal } from "@preact/signals";
import type { ReasoningEffort } from "../../../../domain/catalog/metadata";
import type { RuntimeSnapshot } from "../../runtime/snapshot";
import type { SendShortcut } from "../../../../shared/ui/keyboard";
import type { ToolbarActions } from "../../ui/toolbar";
import type { ToolbarThreadRow } from "../../ui/toolbar";
@ -13,10 +11,9 @@ export interface RestoredThreadTitleSnapshot {
}
interface ChatPanelToolbarState {
archiveConfirmId: () => string | null;
archiveConfirmSubscribe: (listener: () => void) => () => void;
archiveConfirm: Signal<string | null>;
renameState: (threadId: string) => ToolbarThreadRow["rename"];
renameSubscribe: (listener: () => void) => () => void;
renameVersion: Signal<number>;
}
type ChatPanelToolbarActions = ToolbarActions;
@ -28,25 +25,15 @@ interface ChatPanelGoalActions {
setEditingOpen: (open: boolean) => void;
}
interface ChatPanelStatePort {
export interface ChatPanelToolbarPorts {
state: {
chat: () => ChatState;
};
}
export interface ChatPanelToolbarPorts extends ChatPanelStatePort {
state: ChatPanelStatePort["state"] & {
connected: () => boolean;
turnBusy: () => boolean;
};
settings: {
vaultPath: () => string;
configuredCommand: () => string;
archiveExportEnabled: () => boolean;
};
runtime: {
snapshot: () => RuntimeSnapshot;
};
view: {
toolbar: ChatPanelToolbarState;
};
@ -55,7 +42,7 @@ export interface ChatPanelToolbarPorts extends ChatPanelStatePort {
};
}
export interface ChatPanelGoalPorts extends ChatPanelStatePort {
export interface ChatPanelGoalPorts {
settings: {
sendShortcut: () => SendShortcut;
};
@ -64,27 +51,19 @@ export interface ChatPanelGoalPorts extends ChatPanelStatePort {
};
}
export interface ChatPanelMessageStreamPorts extends ChatPanelStatePort {
render: {
node: () => UiNode;
};
}
export interface ChatPanelComposerPorts extends ChatPanelStatePort {
export interface ChatPanelComposerPorts {
thread: {
restoredPlaceholder: () => RestoredThreadTitleSnapshot | null;
};
runtime: {
snapshot: () => RuntimeSnapshot;
requestModel: (model: string) => Promise<void>;
requestReasoningEffort: (effort: ReasoningEffort) => Promise<void>;
resetReasoningEffortToConfig: () => Promise<void>;
};
}
export interface ChatPanelRegionPorts {
export interface ChatPanelSurfacePorts {
toolbar: ChatPanelToolbarPorts;
goal: ChatPanelGoalPorts;
messageStream: ChatPanelMessageStreamPorts;
composer: ChatPanelComposerPorts;
}

View file

@ -0,0 +1,130 @@
import type { ComponentChild as UiNode } from "preact";
import { h } from "preact";
import type { Thread } from "../../../../domain/threads/model";
import { getThreadTitle } from "../../../../domain/threads/model";
import { runtimeConfigSections, rateLimitSummary } from "../../display/status/runtime";
import { connectionDiagnosticSections } from "../../display/status/diagnostics";
import type { RuntimeSnapshot } from "../../runtime/snapshot";
import { runtimeSnapshotForChatSlices } from "../../runtime/snapshot";
import { chatTurnBusy, type ChatState } from "../../state/reducer";
import { messageStreamDisplayItems } from "../../state/message-stream";
import { toolbarStateFromShellState, useChatPanelShellState, type ChatPanelToolbarShellState } from "../../ui/shell-state";
import { Toolbar, type ToolbarThreadRow, type ToolbarViewModel } from "../../ui/toolbar";
import type { ChatPanelToolbarPorts } from "./ports";
type ToolbarState = Pick<ChatState, "connection" | "threadList" | "activeThread" | "ui">;
export interface ToolbarViewModelInput {
state: ToolbarState;
snapshot: RuntimeSnapshot;
connected: boolean;
turnBusy: boolean;
vaultPath: string;
configuredCommand: string;
archiveConfirmThreadId: string | null;
archiveExportEnabled: boolean;
renameRevision: number;
renameState: (threadId: string, renameRevision: number) => ToolbarThreadRow["rename"];
}
export interface ConnectionDiagnosticsModelInput {
state: Pick<ChatState, "connection">;
connected: boolean;
configuredCommand: string;
}
function chatPanelToolbarViewModel(ports: ChatPanelToolbarPorts, state: ChatPanelToolbarShellState) {
return toolbarViewModel({
state,
snapshot: runtimeSnapshotForChatSlices({
runtimeConfig: state.connection.runtimeConfig,
activeThread: state.activeThread,
runtime: state.runtime,
rateLimit: state.connection.rateLimit,
displayItems: messageStreamDisplayItems(state.messageStream),
availableModels: state.connection.availableModels,
}),
connected: ports.state.connected(),
turnBusy: chatTurnBusy(state),
vaultPath: ports.settings.vaultPath(),
configuredCommand: ports.settings.configuredCommand(),
archiveConfirmThreadId: ports.view.toolbar.archiveConfirm.value,
archiveExportEnabled: ports.settings.archiveExportEnabled(),
renameRevision: ports.view.toolbar.renameVersion.value,
renameState: (threadId, _renameRevision) => ports.view.toolbar.renameState(threadId),
});
}
export function ChatPanelToolbar({ ports }: { ports: ChatPanelToolbarPorts }): UiNode {
const state = toolbarStateFromShellState(useChatPanelShellState());
return h(Toolbar, { model: chatPanelToolbarViewModel(ports, state), actions: ports.actions.toolbar });
}
export function toolbarViewModel(input: ToolbarViewModelInput): ToolbarViewModel {
const { state, snapshot } = input;
const limit = rateLimitSummary(snapshot, Date.now());
const historyOpen = state.ui.toolbarPanel === "history";
const chatActionsOpen = state.ui.toolbarPanel === "chat-actions";
const statusPanelOpen = state.ui.toolbarPanel === "status-panel";
return {
newChatDisabled: input.turnBusy,
chatActionsOpen,
historyOpen,
statusPanelOpen,
rateLimit: limit,
configSections: runtimeConfigSections(snapshot, input.vaultPath),
openPanel: historyOpen ? "history" : chatActionsOpen ? "chat-actions" : statusPanelOpen ? "status" : null,
threads: toolbarThreadRows({
threads: state.threadList.listedThreads,
activeThreadId: state.activeThread.id,
turnBusy: input.turnBusy,
archiveConfirmThreadId: input.archiveConfirmThreadId,
archiveExportEnabled: input.archiveExportEnabled,
renameRevision: input.renameRevision,
renameState: input.renameState,
}),
connectLabel: input.connected ? "Reconnect" : "Connect",
diagnostics: connectionDiagnosticsModel({
state,
connected: input.connected,
configuredCommand: input.configuredCommand,
}),
};
}
function toolbarThreadRows(input: {
threads: readonly Thread[];
activeThreadId: string | null;
turnBusy: boolean;
archiveConfirmThreadId: string | null;
archiveExportEnabled: boolean;
renameRevision: number;
renameState: (threadId: string, renameRevision: number) => ToolbarThreadRow["rename"];
}): ToolbarThreadRow[] {
const renameRevision = input.renameRevision;
return input.threads.map((thread) => {
const threadId = thread.id;
return {
title: getThreadTitle(thread),
threadId,
selected: threadId === input.activeThreadId,
disabled: input.turnBusy && threadId !== input.activeThreadId,
canArchive: true,
archiveConfirm: {
active: input.archiveConfirmThreadId === threadId,
defaultSaveMarkdown: input.archiveExportEnabled,
},
rename: input.renameState(threadId, renameRevision),
};
});
}
export function connectionDiagnosticsModel(input: ConnectionDiagnosticsModelInput): ReturnType<typeof connectionDiagnosticSections> {
return connectionDiagnosticSections({
connected: input.connected,
configuredCommand: input.configuredCommand,
initializeResponse: input.state.connection.initializeResponse,
diagnostics: input.state.connection.serverDiagnostics,
});
}

View file

@ -0,0 +1,134 @@
import { signal, type Signal } from "@preact/signals";
import type { ChatThreadActions } from "../threads/action-context";
import type { ChatAction, ChatState, ChatStateStore } from "../state/reducer";
export interface ToolbarPanelActionsHost {
stateStore: ChatStateStore;
threadActions: ChatThreadActions;
archiveConfirm: ToolbarArchiveConfirmState;
scheduleRender: () => void;
}
export interface ToolbarArchiveConfirmState {
id: Signal<string | null>;
get: () => string | null;
set: (threadId: string | null) => void;
}
export interface ToolbarPanelActions {
archiveConfirm: Signal<string | null>;
archiveConfirmId(): string | null;
toggleHistory(): void;
toggleChatActions(): void;
closeToolbarPanels(): void;
toggleStatus(): void;
closeForThreadSelection(): void;
startArchive(threadId: string): void;
archiveThread(threadId: string, saveMarkdown: boolean): Promise<void>;
closeOnOutsidePointer(context: ToolbarOutsidePointerContext): void;
}
interface ToolbarOutsidePointerContext {
target: EventTarget | null;
viewWindow: ToolbarDomWindow | null;
contains: (element: Element) => boolean;
renameEditing: boolean;
}
type ToolbarDomWindow = Window & { Element: typeof Element };
export function createToolbarPanelActions(host: ToolbarPanelActionsHost): ToolbarPanelActions {
const state = (): ChatState => host.stateStore.getState();
const dispatch = (action: ChatAction): void => {
host.stateStore.dispatch(action);
};
const hasOpenPanel = (): boolean => state().ui.toolbarPanel !== null;
const close = (): void => {
if (!hasOpenPanel()) return;
dispatch({ type: "ui/panel-set", panel: null });
host.archiveConfirm.set(null);
host.scheduleRender();
};
return {
archiveConfirm: host.archiveConfirm.id,
archiveConfirmId(): string | null {
return host.archiveConfirm.get();
},
toggleHistory(): void {
dispatch({ type: "ui/panel-set", panel: "history", toggle: true });
host.scheduleRender();
},
toggleChatActions(): void {
dispatch({ type: "ui/panel-set", panel: "chat-actions", toggle: true });
host.scheduleRender();
},
closeToolbarPanels(): void {
close();
},
toggleStatus(): void {
dispatch({ type: "ui/panel-set", panel: "status-panel", toggle: true });
host.scheduleRender();
},
closeForThreadSelection(): void {
host.archiveConfirm.set(null);
},
startArchive(threadId: string): void {
host.archiveConfirm.set(threadId);
},
async archiveThread(threadId: string, saveMarkdown: boolean): Promise<void> {
if (host.archiveConfirm.get() === threadId) host.archiveConfirm.set(null);
await host.threadActions.archiveThread(threadId, saveMarkdown);
host.scheduleRender();
},
closeOnOutsidePointer(context: ToolbarOutsidePointerContext): void {
if (!hasOpenPanel()) return;
const target = context.target;
if (isToolbarElement(target, context.viewWindow)) {
const insideToolbarPanel = target.closest(".codex-panel__toolbar-primary, .codex-panel__toolbar-panel");
if (insideToolbarPanel && context.contains(insideToolbarPanel)) {
if (host.archiveConfirm.get() && !target.closest(".codex-panel__archive-confirm")) {
host.archiveConfirm.set(null);
}
return;
}
}
if (host.archiveConfirm.get()) {
host.archiveConfirm.set(null);
}
if (context.renameEditing) return;
close();
},
};
}
function isToolbarElement(target: EventTarget | null, viewWindow: ToolbarDomWindow | null): target is Element {
return Boolean(viewWindow && target instanceof viewWindow.Element);
}
export function createToolbarArchiveConfirmState(): ToolbarArchiveConfirmState {
const id = signal<string | null>(null);
return {
id,
get: () => id.value,
set: (nextThreadId) => {
if (id.value === nextThreadId) return;
id.value = nextThreadId;
},
};
}

View file

@ -37,7 +37,7 @@ interface RuntimeSnapshotInput {
availableModels: ChatState["connection"]["availableModels"];
}
function runtimeSnapshotForChatSlices(input: RuntimeSnapshotInput): RuntimeSnapshot {
export function runtimeSnapshotForChatSlices(input: RuntimeSnapshotInput): RuntimeSnapshot {
return {
runtimeConfig: input.runtimeConfig,
activeThreadId: input.activeThread.id,

View file

@ -10,8 +10,6 @@ export interface ChatMessageStreamActiveSegment {
}
export interface ChatMessageStreamState {
/** Compatibility projection for tests and legacy fixtures. Runtime code should use messageStreamDisplayItems. */
displayItems: readonly DisplayItem[];
stableItems: readonly DisplayItem[];
activeSegment: ChatMessageStreamActiveSegment | null;
turnDiffs: ReadonlyMap<string, string>;
@ -61,19 +59,17 @@ export type MessageStreamAction =
| { type: "message-stream/turn-diff-updated"; turnId: string; diff: string };
export function initialChatMessageStreamState(items: readonly DisplayItem[] = []): ChatMessageStreamState {
return withDisplayItemsAccessor({
return {
stableItems: items,
activeSegment: null,
turnDiffs: new Map(),
historyCursor: null,
loadingHistory: false,
reportedLogs: new Set(),
});
};
}
export function messageStreamDisplayItems(state: Pick<ChatMessageStreamState, "stableItems" | "activeSegment">): readonly DisplayItem[] {
const legacyItems = legacyDisplayItems(state);
if (legacyItems && state.stableItems.length === 0 && (!state.activeSegment || state.activeSegment.items.length === 0)) return legacyItems;
if (!state.activeSegment || state.activeSegment.items.length === 0) return state.stableItems;
return [...state.stableItems, ...state.activeSegment.items];
}
@ -87,8 +83,6 @@ export function messageStreamActiveItems(state: Pick<ChatMessageStreamState, "ac
}
export function messageStreamIsEmpty(state: Pick<ChatMessageStreamState, "stableItems" | "activeSegment">): boolean {
const legacyItems = legacyDisplayItems(state);
if (legacyItems) return legacyItems.length === 0;
return state.stableItems.length === 0 && (!state.activeSegment || state.activeSegment.items.length === 0);
}
@ -451,35 +445,9 @@ function updatedTurnDiffs(turnDiffs: ReadonlyMap<string, string>, turnId: string
function patchObject<T extends object>(current: T, patch: Partial<T>): T {
if (Object.entries(patch).every(([key, value]) => Object.is(current[key as keyof T], value))) return current;
const next = { ...current, ...patch };
return isMessageStreamState(next) ? (withDisplayItemsAccessor(next) as unknown as T) : next;
return { ...current, ...patch };
}
function definedPatch<Key extends string, Value>(key: Key, value: Value | undefined): Partial<Record<Key, Value>> {
return value === undefined ? {} : ({ [key]: value } as Partial<Record<Key, Value>>);
}
function isMessageStreamState(value: object): value is ChatMessageStreamState {
return "stableItems" in value && "activeSegment" in value && "turnDiffs" in value && "reportedLogs" in value;
}
function legacyDisplayItems(state: object): readonly DisplayItem[] | null {
if (!Object.prototype.propertyIsEnumerable.call(state, "displayItems")) return null;
const value = (state as { displayItems?: unknown }).displayItems;
return Array.isArray(value) ? (value as readonly DisplayItem[]) : null;
}
function withDisplayItemsAccessor(state: Omit<ChatMessageStreamState, "displayItems">): ChatMessageStreamState {
Object.defineProperty(state, "displayItems", {
configurable: true,
enumerable: false,
get() {
return messageStreamDisplayItems(this as ChatMessageStreamState);
},
set(items: readonly DisplayItem[]) {
(this as ChatMessageStreamState).stableItems = items;
(this as ChatMessageStreamState).activeSegment = null;
},
});
return state as unknown as ChatMessageStreamState;
}

View file

@ -761,8 +761,7 @@ function cloneChatState(state: ChatState): ChatState {
}
function cloneMessageStreamState(state: ChatMessageStreamState): ChatMessageStreamState {
const legacyItems = !state.activeSegment && state.stableItems.length === 0 ? state.displayItems : [];
const next = initialChatMessageStreamState(legacyItems.length > 0 ? [...legacyItems] : [...state.stableItems]);
const next = initialChatMessageStreamState([...state.stableItems]);
next.activeSegment = cloneActiveSegment(state.activeSegment);
next.turnDiffs = new Map(state.turnDiffs);
next.historyCursor = state.historyCursor;

View file

@ -55,20 +55,14 @@ export function implementPlanCandidateFromState(state: {
activeThread: Pick<ChatActiveThreadState, "id">;
turn: ChatTurnState;
runtime: Pick<ChatRuntimeState, "selectedCollaborationMode">;
messageStream: Pick<ChatMessageStreamState, "stableItems" | "activeSegment"> | Pick<ChatMessageStreamState, "displayItems">;
messageStream: Pick<ChatMessageStreamState, "stableItems" | "activeSegment">;
}): DisplayItem | null {
if (!state.activeThread.id || chatTurnBusy(state) || state.runtime.selectedCollaborationMode !== "plan") {
return null;
}
return (
[...selectorDisplayItems(state.messageStream)]
[...messageStreamDisplayItems(state.messageStream)]
.reverse()
.find((item) => item.kind === "message" && item.messageKind === "proposedPlan") ?? null
);
}
function selectorDisplayItems(
messageStream: Pick<ChatMessageStreamState, "stableItems" | "activeSegment"> | Pick<ChatMessageStreamState, "displayItems">,
): readonly DisplayItem[] {
return "stableItems" in messageStream ? messageStreamDisplayItems(messageStream) : messageStream.displayItems;
}

View file

@ -15,14 +15,11 @@ import { createSelectionActions } from "./selection-actions";
import { RestorationController } from "./restoration-controller";
import type { ChatThreadActions, ChatThreadActionsHost } from "./action-context";
import type { ChatResumeWorkTracker, ChatViewDeferredTasks } from "../lifecycle";
import type { ChatControllerCompositionPorts } from "../composition-ports";
import type { ChatControllerPorts } from "../controller-ports";
type ThreadControllerGroupPorts = Pick<
ChatControllerCompositionPorts,
"obsidian" | "plugin" | "state" | "lifecycle" | "thread" | "liveState"
> & {
type ThreadControllerGroupPorts = Pick<ChatControllerPorts, "obsidian" | "plugin" | "state" | "lifecycle" | "thread" | "liveState"> & {
client: {
getClient: ChatControllerCompositionPorts["client"]["getClient"];
getClient: ChatControllerPorts["client"]["getClient"];
ensureConnected: () => Promise<void>;
};
lifecycle: {
@ -41,7 +38,7 @@ type ThreadControllerGroupPorts = Pick<
set: (status: string) => void;
addSystemMessage: (text: string) => void;
};
scroll: Pick<ChatControllerCompositionPorts["scroll"], "preservePosition" | "forceBottom">;
scroll: Pick<ChatControllerPorts["scroll"], "preservePosition" | "forceBottom">;
render: {
now: () => void;
};
@ -199,11 +196,11 @@ export function createThreadControllerGroup(
}
function requireThreadController<T>(controller: T | null, name: string): T {
if (!controller) throw new Error(`Chat thread controller composition did not initialize ${name}.`);
if (!controller) throw new Error(`Chat thread controller graph did not initialize ${name}.`);
return controller;
}
type ThreadSelectionActionGroupPorts = Pick<ChatControllerCompositionPorts, "plugin" | "state"> & {
type ThreadSelectionActionGroupPorts = Pick<ChatControllerPorts, "plugin" | "state"> & {
status: {
addSystemMessage: (text: string) => void;
};

View file

@ -1,4 +1,5 @@
import type { AppServerClient } from "../../../app-server/connection/client";
import { signal } from "@preact/signals";
import { readCompletedConversationSummariesPage } from "../../../app-server/services/threads";
import { getThreadTitle } from "../../../domain/threads/model";
import type { Thread } from "../../../domain/threads/model";
@ -42,7 +43,7 @@ export interface RenameControllerHost {
}
export class RenameController {
private readonly listeners = new Set<() => void>();
readonly version = signal(0);
private nextRenameGenerationId = 1;
private renameState: RenameLifecycleState = { kind: "idle" };
@ -64,13 +65,6 @@ export class RenameController {
return this.renameState.kind !== "idle";
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
}
start(threadId: string): void {
const thread = this.thread(threadId);
if (!thread) return;
@ -182,7 +176,7 @@ export class RenameController {
}
private notifyRenameStateChanged(): void {
for (const listener of this.listeners) listener();
this.version.value += 1;
}
private finishAutoNameDraftGeneration(threadId: string, generatingState: RenameGeneratingState): void {

View file

@ -54,67 +54,7 @@ type ButtonProps = ButtonHTMLAttributes & {
disabled?: boolean | undefined;
};
const DEFAULT_COMPOSER_META: ComposerMetaViewModel = {
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,
modelChoices: [],
effortChoices: [],
};
export function composerShellNode(
viewId: string,
draft: string,
busy: boolean,
canInterrupt: boolean,
normalPlaceholder: string,
suggestions: readonly ComposerSuggestion[],
selectedSuggestionIndex: number,
callbacks: ComposerCallbacks,
meta: ComposerMetaViewModel = DEFAULT_COMPOSER_META,
onComposer: (composer: HTMLTextAreaElement | null) => void,
): UiNode {
return (
<ComposerShell
viewId={viewId}
draft={draft}
busy={busy}
canInterrupt={canInterrupt}
normalPlaceholder={normalPlaceholder}
meta={meta}
suggestions={suggestions}
selectedSuggestionIndex={selectedSuggestionIndex}
callbacks={callbacks}
onComposer={onComposer}
/>
);
}
function ComposerShell({
viewId,
draft,
busy,
canInterrupt,
normalPlaceholder,
meta,
suggestions,
selectedSuggestionIndex,
callbacks,
onComposer,
}: {
export interface ComposerShellProps {
viewId: string;
draft: string;
busy: boolean;
@ -125,7 +65,20 @@ function ComposerShell({
selectedSuggestionIndex: number;
callbacks: ComposerCallbacks;
onComposer: (composer: HTMLTextAreaElement | null) => void;
}): UiNode {
}
export function ComposerShell({
viewId,
draft,
busy,
canInterrupt,
normalPlaceholder,
meta,
suggestions,
selectedSuggestionIndex,
callbacks,
onComposer,
}: ComposerShellProps): UiNode {
const composerRef = useRef<HTMLTextAreaElement | null>(null);
const suggestionsRef = useRef<HTMLDivElement | null>(null);
const selectedSuggestionRef = useRef<HTMLDivElement | null>(null);

View file

@ -6,31 +6,27 @@ import { isComposerSendKey, type SendShortcut } from "../../../shared/ui/keyboar
import { IconButton } from "../../../shared/ui/components";
import { syncTextareaHeight } from "../../../shared/ui/textarea-autogrow";
export interface GoalRegionActions {
export interface GoalPanelActions {
onSave: (objective: string, tokenBudget: number | null) => void;
onPause: () => void;
onResume: () => void;
onClear: () => void;
}
export interface GoalRegionOptions {
export interface GoalPanelOptions {
sendShortcut: SendShortcut;
editingRequested?: boolean | undefined;
onEditingChange?: (editing: boolean) => void;
}
export function goalRegionNode(goal: ThreadGoal | null, actions: GoalRegionActions, options: GoalRegionOptions): UiNode {
return <GoalRegion goal={goal} actions={actions} options={options} />;
}
function GoalRegion({
export function GoalPanel({
goal,
actions,
options,
}: {
goal: ThreadGoal | null;
actions: GoalRegionActions;
options: GoalRegionOptions;
actions: GoalPanelActions;
options: GoalPanelOptions;
}): UiNode {
const [editing, setEditing] = useState(false);
const [objective, setObjective] = useState(goal?.objective ?? "");

View file

@ -14,11 +14,7 @@ export interface MessageStreamViewportState {
registerVirtualizer?: (virtualizer: MessageStreamVirtualizerHandle) => () => void;
}
export function messageStreamViewportNode(state: MessageStreamViewportState): UiNode {
return <MessageStreamViewport state={state} />;
}
function MessageStreamViewport({ state }: { state: MessageStreamViewportState }): UiNode {
export function MessageStreamViewport({ state }: { state: MessageStreamViewportState }): UiNode {
const { blocks, consumeScrollIntent, registerVirtualizer } = state;
const scrollElementRef = useRef<HTMLDivElement | null>(null);
const virtualizer = useMessageStreamVirtualizer({ blocks, consumeScrollIntent, registerVirtualizer, scrollElementRef });

View file

@ -0,0 +1,107 @@
import { createContext } from "preact";
import { useContext } from "preact/hooks";
import { signal, type Signal } from "@preact/signals";
import type { ChatState } from "../state/reducer";
export interface ChatPanelShellState {
connection: Signal<ChatState["connection"]>;
threadList: Signal<ChatState["threadList"]>;
activeThread: Signal<ChatState["activeThread"]>;
runtime: Signal<ChatState["runtime"]>;
turn: Signal<ChatState["turn"]>;
messageStream: Signal<ChatState["messageStream"]>;
requests: Signal<ChatState["requests"]>;
composer: Signal<ChatState["composer"]>;
ui: Signal<ChatState["ui"]>;
}
export type ChatPanelToolbarShellState = Pick<
ChatState,
"connection" | "threadList" | "activeThread" | "runtime" | "turn" | "messageStream" | "ui"
>;
export type ChatPanelGoalShellState = Pick<ChatState, "activeThread" | "ui">;
export type ChatPanelMessageStreamShellState = Pick<ChatState, "activeThread" | "runtime" | "turn" | "messageStream" | "requests" | "ui">;
export type ChatPanelComposerShellState = Pick<
ChatState,
"connection" | "threadList" | "activeThread" | "runtime" | "turn" | "messageStream" | "composer"
>;
export const ChatPanelShellStateContext = createContext<ChatPanelShellState | null>(null);
export function createChatPanelShellState(initialState: ChatState): ChatPanelShellState {
return {
connection: signal(initialState.connection),
threadList: signal(initialState.threadList),
activeThread: signal(initialState.activeThread),
runtime: signal(initialState.runtime),
turn: signal(initialState.turn),
messageStream: signal(initialState.messageStream),
requests: signal(initialState.requests),
composer: signal(initialState.composer),
ui: signal(initialState.ui),
};
}
export function syncChatPanelShellState(shellState: ChatPanelShellState, nextState: ChatState): void {
if (shellState.connection.value !== nextState.connection) shellState.connection.value = nextState.connection;
if (shellState.threadList.value !== nextState.threadList) shellState.threadList.value = nextState.threadList;
if (shellState.activeThread.value !== nextState.activeThread) shellState.activeThread.value = nextState.activeThread;
if (shellState.runtime.value !== nextState.runtime) shellState.runtime.value = nextState.runtime;
if (shellState.turn.value !== nextState.turn) shellState.turn.value = nextState.turn;
if (shellState.messageStream.value !== nextState.messageStream) shellState.messageStream.value = nextState.messageStream;
if (shellState.requests.value !== nextState.requests) shellState.requests.value = nextState.requests;
if (shellState.composer.value !== nextState.composer) shellState.composer.value = nextState.composer;
if (shellState.ui.value !== nextState.ui) shellState.ui.value = nextState.ui;
}
export function toolbarStateFromShellState(shellState: ChatPanelShellState): ChatPanelToolbarShellState {
return {
connection: shellState.connection.value,
threadList: shellState.threadList.value,
activeThread: shellState.activeThread.value,
runtime: shellState.runtime.value,
turn: shellState.turn.value,
messageStream: shellState.messageStream.value,
ui: shellState.ui.value,
};
}
export function goalStateFromShellState(shellState: ChatPanelShellState): ChatPanelGoalShellState {
return {
activeThread: shellState.activeThread.value,
ui: shellState.ui.value,
};
}
export function messageStreamStateFromShellState(shellState: ChatPanelShellState): ChatPanelMessageStreamShellState {
return {
activeThread: shellState.activeThread.value,
runtime: shellState.runtime.value,
turn: shellState.turn.value,
messageStream: shellState.messageStream.value,
requests: shellState.requests.value,
ui: shellState.ui.value,
};
}
export function composerStateFromShellState(shellState: ChatPanelShellState): ChatPanelComposerShellState {
return {
connection: shellState.connection.value,
threadList: shellState.threadList.value,
activeThread: shellState.activeThread.value,
runtime: shellState.runtime.value,
turn: shellState.turn.value,
messageStream: shellState.messageStream.value,
composer: shellState.composer.value,
};
}
export function useChatPanelShellState(): ChatPanelShellState {
const context = useContext(ChatPanelShellStateContext);
if (!context) throw new Error("Chat panel shell state is only available inside ChatPanelShell.");
return context;
}

View file

@ -1,30 +1,24 @@
import { createContext, type ComponentChild as UiNode } from "preact";
import { useContext } from "preact/hooks";
import { signal, type Signal } from "@preact/signals";
import type { ComponentChild as UiNode } from "preact";
import { renderUiRoot, unmountUiRoot } from "../../../shared/ui/ui-root";
import type { ChatState, ChatStateStore } from "../state/reducer";
import type { ChatStateStore } from "../state/reducer";
import type { ChatPanelGoalPorts, ChatPanelToolbarPorts } from "../panel/surface/ports";
import { ChatPanelToolbar } from "../panel/surface/toolbar";
import { ChatPanelGoal } from "../panel/surface/goal";
import { ChatPanelMessageStream, type ChatPanelMessageStreamRenderer } from "../panel/surface/message-stream";
import { ChatPanelComposer, type ChatPanelComposerController } from "../panel/surface/composer";
import { ChatPanelShellStateContext, createChatPanelShellState, syncChatPanelShellState, type ChatPanelShellState } from "./shell-state";
export interface ChatPanelShellState {
connection: Signal<ChatState["connection"]>;
threadList: Signal<ChatState["threadList"]>;
activeThread: Signal<ChatState["activeThread"]>;
runtime: Signal<ChatState["runtime"]>;
turn: Signal<ChatState["turn"]>;
messageStream: Signal<ChatState["messageStream"]>;
requests: Signal<ChatState["requests"]>;
composer: Signal<ChatState["composer"]>;
ui: Signal<ChatState["ui"]>;
renderVersion: Signal<number>;
latestState: () => ChatState;
export interface ChatPanelShellSlots {
toolbar: ChatPanelToolbarPorts;
goal: ChatPanelGoalPorts;
messageStream: ChatPanelMessageStreamRenderer;
composer: ChatPanelComposerController;
}
export interface ChatPanelShellProps {
stateStore: ChatStateStore;
showToolbar: boolean;
toolbarNode: () => UiNode;
goalNode: () => UiNode;
messageStreamNode: () => UiNode;
composerNode: () => UiNode;
slots: ChatPanelShellSlots;
}
interface ChatPanelShellMount {
@ -36,14 +30,12 @@ interface ChatPanelShellMount {
}
const shellMounts = new WeakMap<HTMLElement, ChatPanelShellMount>();
const ChatPanelShellStateContext = createContext<ChatPanelShellState | null>(null);
export function renderChatPanelShell(container: HTMLElement, props: ChatPanelShellProps): void {
container.addClass("codex-panel");
const existing = shellMounts.get(container);
const mount = existing?.stateStore === props.stateStore ? existing : createShellMount(container, props);
mount.props = props;
mount.shellState.renderVersion.value += 1;
renderMountedShell(container, mount);
}
@ -61,16 +53,14 @@ function createShellMount(container: HTMLElement, props: ChatPanelShellProps): C
const existing = shellMounts.get(container);
existing?.unsubscribe();
existing?.stopStatusBarClearanceSync();
let latestState = props.stateStore.getState();
const mount: ChatPanelShellMount = {
props,
stateStore: props.stateStore,
shellState: createShellState(latestState, () => latestState),
shellState: createChatPanelShellState(props.stateStore.getState()),
unsubscribe: props.stateStore.subscribe(() => {
const current = shellMounts.get(container);
if (!current) return;
latestState = props.stateStore.getState();
syncShellState(current.shellState, latestState);
syncChatPanelShellState(current.shellState, props.stateStore.getState());
if (!uiRootIntact(container)) renderMountedShell(container, current);
}),
stopStatusBarClearanceSync: startStatusBarClearanceSync(container),
@ -79,34 +69,6 @@ function createShellMount(container: HTMLElement, props: ChatPanelShellProps): C
return mount;
}
function createShellState(initialState: ChatState, latestState: () => ChatState): ChatPanelShellState {
return {
connection: signal(initialState.connection),
threadList: signal(initialState.threadList),
activeThread: signal(initialState.activeThread),
runtime: signal(initialState.runtime),
turn: signal(initialState.turn),
messageStream: signal(initialState.messageStream),
requests: signal(initialState.requests),
composer: signal(initialState.composer),
ui: signal(initialState.ui),
renderVersion: signal(0),
latestState,
};
}
function syncShellState(shellState: ChatPanelShellState, nextState: ChatState): void {
if (shellState.connection.value !== nextState.connection) shellState.connection.value = nextState.connection;
if (shellState.threadList.value !== nextState.threadList) shellState.threadList.value = nextState.threadList;
if (shellState.activeThread.value !== nextState.activeThread) shellState.activeThread.value = nextState.activeThread;
if (shellState.runtime.value !== nextState.runtime) shellState.runtime.value = nextState.runtime;
if (shellState.turn.value !== nextState.turn) shellState.turn.value = nextState.turn;
if (shellState.messageStream.value !== nextState.messageStream) shellState.messageStream.value = nextState.messageStream;
if (shellState.requests.value !== nextState.requests) shellState.requests.value = nextState.requests;
if (shellState.composer.value !== nextState.composer) shellState.composer.value = nextState.composer;
if (shellState.ui.value !== nextState.ui) shellState.ui.value = nextState.ui;
}
function renderMountedShell(container: HTMLElement, mount: ChatPanelShellMount): void {
if (!uiRootIntact(container)) {
unmountUiRoot(container);
@ -126,32 +88,27 @@ function uiRootIntact(container: HTMLElement): boolean {
);
}
function ChatPanelShell({
showToolbar,
toolbarNode,
goalNode,
messageStreamNode,
composerNode,
shellState,
}: ChatPanelShellProps & { shellState: ChatPanelShellState }): UiNode {
function ChatPanelShell({ showToolbar, slots, shellState }: ChatPanelShellProps & { shellState: ChatPanelShellState }): UiNode {
return (
<ChatPanelShellStateContext.Provider value={shellState}>
{showToolbar ? <div className="codex-panel__toolbar">{toolbarNode()}</div> : null}
{showToolbar ? (
<div className="codex-panel__toolbar">
<ChatPanelToolbar ports={slots.toolbar} />
</div>
) : null}
<div className="codex-panel__body">
<div className="codex-panel__region codex-panel__region--goal">{goalNode()}</div>
{messageStreamNode()}
<div className="codex-panel__region codex-panel__region--composer">{composerNode()}</div>
<div className="codex-panel__region codex-panel__region--goal">
<ChatPanelGoal ports={slots.goal} />
</div>
<ChatPanelMessageStream renderer={slots.messageStream} />
<div className="codex-panel__region codex-panel__region--composer">
<ChatPanelComposer controller={slots.composer} />
</div>
</div>
</ChatPanelShellStateContext.Provider>
);
}
export function useChatPanelShellState(): ChatPanelShellState {
const context = useContext(ChatPanelShellStateContext);
if (!context) throw new Error("Chat panel shell state is only available inside ChatPanelShell.");
return context;
}
function startStatusBarClearanceSync(container: HTMLElement): () => void {
const win = container.ownerDocument.defaultView;
if (!win) return () => undefined;

View file

@ -64,11 +64,7 @@ export interface ToolbarActions {
autoNameThread: (threadId: string) => void;
}
export function toolbarNode(model: ToolbarViewModel, actions: ToolbarActions): UiNode {
return <Toolbar model={model} actions={actions} />;
}
function Toolbar({ model, actions }: { model: ToolbarViewModel; actions: ToolbarActions }): UiNode {
export function Toolbar({ model, actions }: { model: ToolbarViewModel; actions: ToolbarActions }): UiNode {
return (
<>
<div className="nav-header codex-panel__toolbar-primary">

View file

@ -19,18 +19,16 @@ import {
} from "./display/status/runtime";
import { runtimeSnapshotForChatState } from "./runtime/snapshot";
import { codexPanelDisplayTitle, getThreadTitle } from "../../domain/threads/model";
import { connectionDiagnosticsModel } from "./panel/regions/toolbar";
import { connectionDiagnosticsModel } from "./panel/surface/toolbar";
import { openPanelTurnLifecycle } from "./panel/snapshot";
import { ChatConnectionWorkTracker, ChatResumeWorkTracker, createChatViewDeferredTasks, type ChatViewDeferredTasks } from "./lifecycle";
import { createChatMessageScrollIntentState, type ChatMessageScrollIntentState } from "./ui/message-stream/scroll-intent-state";
import type { ChatControllerCompositionPorts } from "./composition-ports";
import { createChatViewControllers, type ChatViewControllers } from "./composition";
import type { ChatPanelRegionPorts } from "./panel/regions/ports";
import { createChatPanelRegionPorts } from "./panel/regions/composition";
import { chatPanelComposerMetaViewModel, chatPanelComposerPlaceholder, chatPanelComposerRegionNode } from "./panel/regions/composer";
import { chatPanelGoalRegionNode } from "./panel/regions/goal";
import { chatPanelMessageStreamPendingRequestsSignature, chatPanelMessageStreamRegionNode } from "./panel/regions/message-stream";
import { chatPanelToolbarRegionNode } from "./panel/regions/toolbar";
import type { ChatControllerPorts } from "./controller-ports";
import { createChatViewControllers, type ChatViewControllers } from "./controllers";
import type { ChatPanelSurfacePorts } from "./panel/surface/ports";
import { createChatPanelSurfacePorts } from "./panel/surface/create-ports";
import { chatPanelComposerMetaViewModel, chatPanelComposerPlaceholder } from "./panel/surface/composer";
import { pendingRequestsSignature as requestStateSignature } from "./conversation/pending-requests/signatures";
export class CodexChatView extends ItemView {
private client: AppServerClient | null = null;
@ -39,7 +37,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: ChatMessageScrollIntentState;
private readonly panelPorts: ChatPanelRegionPorts;
private readonly panelSurface: ChatPanelSurfacePorts;
private readonly connectionWork = new ChatConnectionWorkTracker();
private readonly resumeWork: ChatResumeWorkTracker;
private opened = false;
@ -54,21 +52,19 @@ export class CodexChatView extends ItemView {
this.resumeWork = new ChatResumeWorkTracker();
this.messageScrollIntent = createChatMessageScrollIntentState();
this.controllers = createChatViewControllers(this.createControllerPorts());
this.panelPorts = createChatPanelRegionPorts(
this.panelSurface = createChatPanelSurfacePorts(
{
settings: this.plugin.settings,
vaultPath: this.plugin.vaultPath,
stateStore: this.chatState,
runtimeSnapshot: () => this.runtimeSnapshot(),
restoredThreadPlaceholder: () => this.restoredThreadPlaceholder(),
messageStreamNode: () => this.controllers.render.messageStream.renderNode(),
startNewThread: () => this.startNewThread(),
},
this.controllers,
);
}
private createControllerPorts(): ChatControllerCompositionPorts {
private createControllerPorts(): ChatControllerPorts {
const refreshThreadsViewLiveState = () => {
this.plugin.refreshThreadsViewLiveState();
};
@ -118,10 +114,12 @@ export class CodexChatView extends ItemView {
},
render: {
panelRoot: () => this.panelRoot(),
toolbarNode: () => chatPanelToolbarRegionNode(this.panelPorts.toolbar),
goalNode: () => chatPanelGoalRegionNode(this.panelPorts.goal),
messageStreamNode: () => chatPanelMessageStreamRegionNode(this.panelPorts.messageStream),
composerNode: () => chatPanelComposerRegionNode(() => this.controllers.composer.controller.renderNode()),
shellSlots: () => ({
toolbar: this.panelSurface.toolbar,
goal: this.panelSurface.goal,
messageStream: this.controllers.render.messageStream,
composer: this.controllers.composer.controller,
}),
closeToolbarPanelOnOutsidePointer: (event) => {
this.closeToolbarPanelOnOutsidePointer(event);
},
@ -130,9 +128,10 @@ export class CodexChatView extends ItemView {
},
},
surface: {
pendingRequestsSignature: () => chatPanelMessageStreamPendingRequestsSignature(this.panelPorts.messageStream),
composerPlaceholder: () => chatPanelComposerPlaceholder(this.panelPorts.composer),
composerMetaViewModel: () => chatPanelComposerMetaViewModel(this.panelPorts.composer),
pendingRequestsSignature: () =>
requestStateSignature(this.state.requests.approvals, this.state.requests.pendingUserInputs, this.state.requests.userInputDrafts),
composerPlaceholder: (state) => chatPanelComposerPlaceholder(this.panelSurface.composer, state),
composerMetaViewModel: (state) => chatPanelComposerMetaViewModel(this.panelSurface.composer, state),
},
runtime: {
collaborationModeLabel: () => this.collaborationModeLabel(),

View file

@ -2,8 +2,10 @@
import type { App } from "obsidian";
import { describe, expect, it, vi } from "vitest";
import { h } from "preact";
import { ChatComposerController } from "../../../../../src/features/chat/conversation/composer/controller";
import { ComposerShell } from "../../../../../src/features/chat/ui/composer";
import { createChatStateStore } from "../../../../../src/features/chat/state/reducer";
import { renderUiRoot, unmountUiRoot } from "../../../../../src/shared/ui/ui-root";
import type { SkillMetadata } from "../../../../../src/domain/catalog/metadata";
@ -11,6 +13,10 @@ import { installObsidianDomShims } from "../../../../support/dom";
installObsidianDomShims();
function renderComposerController(parent: HTMLElement, controller: ChatComposerController): void {
renderUiRoot(parent, h(ComposerShell, controller.renderState()));
}
describe("ChatComposerController", () => {
it("updates slash suggestions in the same render as the input", () => {
const stateStore = createChatStateStore();
@ -18,7 +24,7 @@ describe("ChatComposerController", () => {
const controllerRef: { current: ChatComposerController | null } = { current: null };
const renderShell = vi.fn(() => {
if (!controllerRef.current) throw new Error("Expected controller.");
renderUiRoot(parent, controllerRef.current.renderNode());
renderComposerController(parent, controllerRef.current);
});
const controller = new ChatComposerController({
app: app(),
@ -26,9 +32,9 @@ describe("ChatComposerController", () => {
viewId: "view",
sendShortcut: () => "enter",
scrollThreadFromComposerEdges: () => false,
canInterrupt: () => false,
composerPlaceholder: () => "Ask Codex to work on this task...",
composerMeta: () => ({
canInterrupt: (_state) => false,
composerPlaceholder: (_state) => "Ask Codex to work on this task...",
composerMeta: (_state) => ({
fatal: null,
context: {
cells: [
@ -73,7 +79,7 @@ describe("ChatComposerController", () => {
let controller: ChatComposerController | null = null;
const renderShell = vi.fn(() => {
if (!controller) throw new Error("Expected controller.");
renderUiRoot(parent, controller.renderNode());
renderComposerController(parent, controller);
});
controller = new ChatComposerController({
app: app(),
@ -81,9 +87,9 @@ describe("ChatComposerController", () => {
viewId: "view",
sendShortcut: () => "enter",
scrollThreadFromComposerEdges: () => false,
canInterrupt: () => false,
composerPlaceholder: () => "Ask Codex to work on this task...",
composerMeta: () => ({
canInterrupt: (_state) => false,
composerPlaceholder: (_state) => "Ask Codex to work on this task...",
composerMeta: (_state) => ({
fatal: null,
context: {
cells: [
@ -135,7 +141,7 @@ describe("ChatComposerController", () => {
let controller: ChatComposerController | null = null;
const renderShell = vi.fn(() => {
if (!controller) throw new Error("Expected controller.");
renderUiRoot(parent, controller.renderNode());
renderComposerController(parent, controller);
});
controller = new ChatComposerController({
app: app(),
@ -143,9 +149,9 @@ describe("ChatComposerController", () => {
viewId: "view",
sendShortcut: () => "enter",
scrollThreadFromComposerEdges: () => false,
canInterrupt: () => false,
composerPlaceholder: () => "Ask Codex to work on this task...",
composerMeta: () => ({
canInterrupt: (_state) => false,
composerPlaceholder: (_state) => "Ask Codex to work on this task...",
composerMeta: (_state) => ({
fatal: null,
context: {
cells: [
@ -198,9 +204,9 @@ describe("ChatComposerController", () => {
viewId: "view",
sendShortcut: () => "enter",
scrollThreadFromComposerEdges: () => false,
canInterrupt: () => false,
composerPlaceholder: () => "Ask Codex to work on this task...",
composerMeta: () => ({
canInterrupt: (_state) => false,
composerPlaceholder: (_state) => "Ask Codex to work on this task...",
composerMeta: (_state) => ({
fatal: null,
context: {
cells: [
@ -226,7 +232,7 @@ describe("ChatComposerController", () => {
onHeightChange: vi.fn(),
});
renderUiRoot(parent, controller.renderNode());
renderComposerController(parent, controller);
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 }));
@ -246,9 +252,9 @@ describe("ChatComposerController", () => {
viewId: "view",
sendShortcut: () => "enter",
scrollThreadFromComposerEdges: () => false,
canInterrupt: () => false,
composerPlaceholder: () => "Ask Codex to work on this task...",
composerMeta: () => ({
canInterrupt: (_state) => false,
composerPlaceholder: (_state) => "Ask Codex to work on this task...",
composerMeta: (_state) => ({
fatal: null,
context: {
cells: [
@ -278,7 +284,7 @@ describe("ChatComposerController", () => {
threadScrollFromComposer: vi.fn(),
});
renderUiRoot(parent, controller.renderNode());
renderComposerController(parent, controller);
composer(parent).dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }));
expect(submit).toHaveBeenCalledOnce();
@ -294,9 +300,9 @@ describe("ChatComposerController", () => {
viewId: "view",
sendShortcut: () => "enter",
scrollThreadFromComposerEdges: () => false,
canInterrupt: () => false,
composerPlaceholder: () => "Ask Codex to work on this task...",
composerMeta: () => ({
canInterrupt: (_state) => false,
composerPlaceholder: (_state) => "Ask Codex to work on this task...",
composerMeta: (_state) => ({
fatal: null,
context: {
cells: [
@ -322,7 +328,7 @@ describe("ChatComposerController", () => {
onHeightChange: vi.fn(),
});
renderUiRoot(parent, controller.renderNode());
renderComposerController(parent, controller);
const mountedComposer = composer(parent);
setTextAreaValue(mountedComposer, "stale dom draft");
const focus = vi.spyOn(mountedComposer, "focus");

View file

@ -8,6 +8,7 @@ import {
type TurnSubmissionControllerHost,
} from "../../../../../src/features/chat/conversation/turns/turn-submission-controller";
import type { Thread } from "../../../../../src/domain/threads/model";
import { chatStateDisplayItems } from "../../support/message-stream";
const textInput = (text: string): CodexInput => [{ type: "text", text }];
@ -136,9 +137,9 @@ describe("TurnSubmissionController", () => {
expect(host.setStatus).toHaveBeenCalledWith("Steered current turn.");
const localSteerId = steerTurn.mock.calls[0]?.[3];
expect(
stateStore
.getState()
.messageStream.displayItems.some((item) => item.kind === "message" && item.id === localSteerId && item.text === "follow up"),
chatStateDisplayItems(stateStore.getState()).some(
(item) => item.kind === "message" && item.id === localSteerId && item.text === "follow up",
),
).toBe(true);
});
@ -199,7 +200,7 @@ describe("TurnSubmissionController", () => {
expect(startTurn).not.toHaveBeenCalled();
expect(host.setDraft).toHaveBeenCalledWith("", { clearSuggestions: true });
expect(host.setStatus).not.toHaveBeenCalledWith("Steered current turn.");
expect(stateStore.getState().messageStream.displayItems).toEqual([]);
expect(chatStateDisplayItems(stateStore.getState())).toEqual([]);
});
it("does not restore stale steer drafts or report stale steer failures after the active turn changes", async () => {

View file

@ -1,7 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import { createChatState, type ChatAction } from "../../../../../src/features/chat/state/reducer";
import { createMessageStreamContextPort } from "../../../../../src/features/chat/ui/message-stream/ports";
import { createMessageStreamContextPort } from "../../../../../src/features/chat/panel/surface/message-stream-ports";
describe("message stream context port", () => {
it("closes other fork action details before opening a fork action detail", () => {

View file

@ -2,6 +2,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { TFile } from "obsidian";
import { h } from "preact";
import {
chatReducer,
@ -10,7 +11,8 @@ import {
type ChatState,
type ChatStateStore,
} from "../../../../../src/features/chat/state/reducer";
import { MessageStreamRenderer } from "../../../../../src/features/chat/ui/message-stream/renderer";
import { MessageStreamRenderer } from "../../../../../src/features/chat/panel/surface/message-stream-renderer";
import { MessageStreamViewport } from "../../../../../src/features/chat/ui/message-stream/viewport";
import {
bindRenderedWikiLinks,
type RenderedMarkdownLinkContext,
@ -20,12 +22,17 @@ import type { MessageStreamScrollIntent } from "../../../../../src/features/chat
import { renderUiRoot, unmountUiRoot } from "../../../../../src/shared/ui/ui-root";
import { notices } from "../../../../mocks/obsidian";
import { installObsidianDomShims } from "../../../../support/dom";
import { installMessageViewportMetrics } from "./test-helpers";
import { installMessageViewportMetrics } from "../../ui/message-stream/test-helpers";
import { chatStateDisplayItems, setChatStateDisplayItems } from "../../support/message-stream";
const ESTIMATED_MESSAGE_BLOCK_HEIGHT = 96;
installObsidianDomShims();
function renderMessageStreamRenderer(parent: HTMLElement, renderer: MessageStreamRenderer): void {
renderUiRoot(parent, h(MessageStreamViewport, { state: renderer.renderState() }));
}
describe("MessageStreamRenderer scroll pinning", () => {
beforeEach(() => {
notices.length = 0;
@ -131,7 +138,7 @@ describe("MessageStreamRenderer scroll pinning", () => {
it("pins to the scroll container bottom without aligning the last message element", async () => {
const state = createChatState();
state.activeThread.id = "thread";
state.messageStream.displayItems = [
setChatStateDisplayItems(state, [
{
id: "message",
kind: "message",
@ -141,11 +148,11 @@ describe("MessageStreamRenderer scroll pinning", () => {
messageKind: "assistantResponse",
messageState: "completed",
},
];
]);
const parent = document.createElement("div");
const renderer = messageStreamRenderer(state);
renderUiRoot(parent, renderer.renderNode());
renderMessageStreamRenderer(parent, renderer);
const messages = messageViewport(parent);
Object.defineProperty(messages, "scrollHeight", { value: ESTIMATED_MESSAGE_BLOCK_HEIGHT, configurable: true });
installMessageViewportMetrics(messages, { clientHeight: 100 });
@ -163,7 +170,7 @@ describe("MessageStreamRenderer scroll pinning", () => {
it("can repin the current scroll container after composer growth shrinks the viewport", async () => {
const state = createChatState();
state.activeThread.id = "thread";
state.messageStream.displayItems = [
setChatStateDisplayItems(state, [
{
id: "message",
kind: "message",
@ -173,11 +180,11 @@ describe("MessageStreamRenderer scroll pinning", () => {
messageKind: "assistantResponse",
messageState: "completed",
},
];
]);
const parent = document.createElement("div");
const renderer = messageStreamRenderer(state);
renderUiRoot(parent, renderer.renderNode());
renderMessageStreamRenderer(parent, renderer);
const messages = messageViewport(parent);
Object.defineProperty(messages, "scrollHeight", { value: ESTIMATED_MESSAGE_BLOCK_HEIGHT, configurable: true });
installMessageViewportMetrics(messages, { clientHeight: 160 });
@ -195,7 +202,7 @@ describe("MessageStreamRenderer scroll pinning", () => {
it("repins after composer growth has changed the scroll viewport height", async () => {
const state = createChatState();
state.activeThread.id = "thread";
state.messageStream.displayItems = [
setChatStateDisplayItems(state, [
{
id: "message",
kind: "message",
@ -205,7 +212,7 @@ describe("MessageStreamRenderer scroll pinning", () => {
messageKind: "assistantResponse",
messageState: "completed",
},
];
]);
const parent = document.createElement("div");
const renderer = messageStreamRenderer(state);
@ -233,7 +240,7 @@ describe("MessageStreamRenderer scroll pinning", () => {
},
});
messages.scrollTop = 1000;
renderUiRoot(messages, renderer.renderNode());
renderMessageStreamRenderer(messages, renderer);
await settleMessageRender(messages);
expect(messages.scrollTop).toBe(0);
@ -262,7 +269,7 @@ describe("MessageStreamRenderer scroll pinning", () => {
it("detaches the active virtualizer when the message stream unmounts", async () => {
const state = createChatState();
state.activeThread.id = "thread";
state.messageStream.displayItems = [
setChatStateDisplayItems(state, [
{
id: "message",
kind: "message",
@ -272,10 +279,10 @@ describe("MessageStreamRenderer scroll pinning", () => {
messageKind: "assistantResponse",
messageState: "completed",
},
];
]);
const parent = document.createElement("div");
const renderer = messageStreamRenderer(state);
renderUiRoot(parent, renderer.renderNode());
renderMessageStreamRenderer(parent, renderer);
const messages = messageViewport(parent);
installMessageViewportMetrics(messages);
await settleMessageRender(messages);
@ -291,7 +298,7 @@ describe("MessageStreamRenderer scroll pinning", () => {
it("binds scroll commands to the currently mounted message viewport", async () => {
const state = createChatState();
state.activeThread.id = "thread";
state.messageStream.displayItems = [
setChatStateDisplayItems(state, [
{
id: "message",
kind: "message",
@ -301,10 +308,10 @@ describe("MessageStreamRenderer scroll pinning", () => {
messageKind: "assistantResponse",
messageState: "completed",
},
];
]);
const parent = document.createElement("div");
const renderer = messageStreamRenderer(state);
renderUiRoot(parent, renderer.renderNode());
renderMessageStreamRenderer(parent, renderer);
const oldMessages = messageViewport(parent);
installMessageViewportMetrics(oldMessages, { clientHeight: 100, scrollHeight: 1000 });
await settleMessageRender(oldMessages);
@ -312,7 +319,7 @@ describe("MessageStreamRenderer scroll pinning", () => {
unmountUiRoot(parent);
renderUiRoot(parent, renderer.renderNode());
renderMessageStreamRenderer(parent, renderer);
const newMessages = messageViewport(parent);
installMessageViewportMetrics(newMessages, { clientHeight: 100, scrollHeight: 1000 });
await settleMessageRender(newMessages);
@ -325,7 +332,7 @@ describe("MessageStreamRenderer scroll pinning", () => {
it("completes bottom pinning after the message viewport commits", async () => {
const state = createChatState();
state.activeThread.id = "thread";
state.messageStream.displayItems = [
setChatStateDisplayItems(state, [
{
id: "message",
kind: "message",
@ -335,11 +342,11 @@ describe("MessageStreamRenderer scroll pinning", () => {
messageKind: "assistantResponse",
messageState: "completed",
},
];
]);
const parent = document.createElement("div");
const renderer = messageStreamRenderer(state, vi.fn(), "/vault", [], () => "force-bottom");
renderUiRoot(parent, renderer.renderNode());
renderMessageStreamRenderer(parent, renderer);
const messages = messageViewport(parent);
installMessageViewportMetrics(messages, { clientHeight: 100, scrollHeight: 1000 });
renderer.forceMessageStreamToBottom();
@ -351,7 +358,7 @@ describe("MessageStreamRenderer scroll pinning", () => {
it("keeps bottom pinning after markdown content changes message height", async () => {
const state = createChatState();
state.activeThread.id = "thread";
state.messageStream.displayItems = [
setChatStateDisplayItems(state, [
{
id: "message",
kind: "message",
@ -361,11 +368,11 @@ describe("MessageStreamRenderer scroll pinning", () => {
messageKind: "assistantResponse",
messageState: "completed",
},
];
]);
const parent = document.createElement("div");
const renderer = messageStreamRenderer(state);
renderUiRoot(parent, renderer.renderNode());
renderMessageStreamRenderer(parent, renderer);
const messages = messageViewport(parent);
installMessageViewportMetrics(messages, { clientHeight: 100 });
let scrollHeight = 1000;
@ -390,7 +397,7 @@ describe("MessageStreamRenderer scroll pinning", () => {
it("does not force the bottom into view when the user is reading older messages", async () => {
const state = createChatState();
state.activeThread.id = "thread";
state.messageStream.displayItems = [
setChatStateDisplayItems(state, [
{
id: "message",
kind: "message",
@ -400,13 +407,13 @@ describe("MessageStreamRenderer scroll pinning", () => {
messageKind: "assistantResponse",
messageState: "completed",
},
];
]);
const parent = document.createElement("div");
const renderer = messageStreamRenderer(state);
const messages = parent.createDiv({ cls: "codex-panel__messages" });
installMessageViewportMetrics(messages);
renderUiRoot(messages, renderer.renderNode());
renderMessageStreamRenderer(messages, renderer);
await settleMessageRender(messages);
Object.defineProperty(messages, "scrollHeight", { value: 1000, configurable: true });
@ -416,7 +423,7 @@ describe("MessageStreamRenderer scroll pinning", () => {
const scrollIntoView = vi.spyOn(HTMLElement.prototype, "scrollIntoView");
scrollIntoView.mockClear();
state.messageStream.displayItems = [
setChatStateDisplayItems(state, [
{
id: "message",
kind: "message",
@ -426,8 +433,8 @@ describe("MessageStreamRenderer scroll pinning", () => {
messageKind: "assistantResponse",
messageState: "completed",
},
];
renderUiRoot(messages, renderer.renderNode());
]);
renderMessageStreamRenderer(messages, renderer);
await settleMessageRender(messages);
expect(scrollIntoView).not.toHaveBeenCalled();
@ -436,7 +443,7 @@ describe("MessageStreamRenderer scroll pinning", () => {
it("does not run a pending bottom pin after the user scrolls away", async () => {
const state = createChatState();
state.activeThread.id = "thread";
state.messageStream.displayItems = [
setChatStateDisplayItems(state, [
{
id: "message",
kind: "message",
@ -446,7 +453,7 @@ describe("MessageStreamRenderer scroll pinning", () => {
messageKind: "assistantResponse",
messageState: "completed",
},
];
]);
const parent = document.createElement("div");
const renderer = messageStreamRenderer(state);
@ -454,7 +461,7 @@ describe("MessageStreamRenderer scroll pinning", () => {
Object.defineProperty(messages, "scrollHeight", { value: 1000, configurable: true });
installMessageViewportMetrics(messages, { clientHeight: 100 });
messages.scrollTop = 920;
renderUiRoot(messages, renderer.renderNode());
renderMessageStreamRenderer(messages, renderer);
const scrollIntoView = vi.spyOn(HTMLElement.prototype, "scrollIntoView");
scrollIntoView.mockClear();
@ -468,7 +475,7 @@ describe("MessageStreamRenderer scroll pinning", () => {
it("leaves the mounted message stream content in place on dispose", async () => {
const state = createChatState();
state.activeThread.id = "thread";
state.messageStream.displayItems = [
setChatStateDisplayItems(state, [
{
id: "message",
kind: "message",
@ -478,14 +485,14 @@ describe("MessageStreamRenderer scroll pinning", () => {
messageKind: "assistantResponse",
messageState: "completed",
},
];
]);
const parent = document.createElement("div");
const renderer = messageStreamRenderer(state);
renderUiRoot(parent, renderer.renderNode());
renderMessageStreamRenderer(parent, renderer);
let messages = messageViewport(parent);
installMessageViewportMetrics(messages);
renderUiRoot(parent, renderer.renderNode());
renderMessageStreamRenderer(parent, renderer);
messages = messageViewport(parent);
await settleMessageRender(messages);
expect(parent.querySelector(".codex-panel__messages")).not.toBeNull();
@ -498,24 +505,27 @@ describe("MessageStreamRenderer scroll pinning", () => {
it("does not mount every block before the virtualizer attaches", async () => {
const state = createChatState();
state.activeThread.id = "thread";
state.messageStream.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",
}));
setChatStateDisplayItems(
state,
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 = messageStreamRenderer(state);
renderUiRoot(parent, renderer.renderNode());
renderMessageStreamRenderer(parent, renderer);
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.messageStream.displayItems.length);
expect(parent.querySelectorAll("[data-codex-panel-block-key]").length).toBeLessThan(chatStateDisplayItems(state).length);
});
});

View file

@ -3,7 +3,7 @@
import { describe, expect, it, vi } from "vitest";
import { createChatState, createChatStateStore } from "../../../../../src/features/chat/state/reducer";
import { createToolbarArchiveConfirmState, createToolbarPanelActions } from "../../../../../src/features/chat/panel/regions/toolbar";
import { createToolbarArchiveConfirmState, createToolbarPanelActions } from "../../../../../src/features/chat/panel/toolbar-actions";
import type { ChatThreadActions } from "../../../../../src/features/chat/threads/action-context";
describe("createToolbarPanelActions", () => {

View file

@ -15,6 +15,7 @@ import {
import type { ServerNotification, ServerRequest } from "../../../../../src/app-server/connection/rpc-messages";
import type { Thread as PanelThread } from "../../../../../src/domain/threads/model";
import type { TurnRecord } from "../../../../../src/app-server/protocol/turn";
import { chatStateDisplayItems, setChatStateDisplayItems } from "../../support/message-stream";
type ThreadStartedNotification = Extract<ServerNotification, { method: "thread/started" }>;
@ -68,14 +69,14 @@ describe("ChatInboundController", () => {
params: { threadId: "thread-active", turnId: "turn-active", itemId: "a1", delta: "hello" },
} satisfies Extract<ServerNotification, { method: "item/agentMessage/delta" }>);
expect(state.messageStream.displayItems).toMatchObject([{ id: "a1", text: "hello" }]);
expect(chatStateDisplayItems(state)).toMatchObject([{ id: "a1", text: "hello" }]);
});
it("marks active reasoning completed when assistant text starts", () => {
const state = createChatState();
state.activeThread.id = "thread-active";
state.turn.lifecycle = { kind: "running", turnId: "turn-active" };
state.messageStream.displayItems = [{ id: "r1", kind: "reasoning", role: "tool", text: "thinking", turnId: "turn-active" }];
setChatStateDisplayItems(state, [{ id: "r1", kind: "reasoning", role: "tool", text: "thinking", turnId: "turn-active" }]);
const controller = controllerForState(state);
controller.handleNotification({
@ -83,7 +84,7 @@ describe("ChatInboundController", () => {
params: { threadId: "thread-active", turnId: "turn-active", itemId: "a1", delta: "answer" },
} satisfies Extract<ServerNotification, { method: "item/agentMessage/delta" }>);
expect(state.messageStream.displayItems).toEqual(
expect(chatStateDisplayItems(state)).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: "r1", kind: "reasoning", status: "completed", executionState: "completed" }),
expect.objectContaining({ id: "a1", kind: "message", text: "answer" }),
@ -102,7 +103,7 @@ describe("ChatInboundController", () => {
params: { threadId: "thread-active", turnId: "turn-active", itemId: "p1", delta: "<proposed_plan>\n# Plan" },
} satisfies Extract<ServerNotification, { method: "item/plan/delta" }>);
expect(state.messageStream.displayItems).toMatchObject([
expect(chatStateDisplayItems(state)).toMatchObject([
{ id: "p1", kind: "message", messageKind: "proposedPlan", role: "assistant", text: "# Plan", messageState: "streaming" },
]);
});
@ -135,7 +136,7 @@ describe("ChatInboundController", () => {
},
} satisfies Extract<ServerNotification, { method: "turn/completed" }>);
expect(state.messageStream.displayItems).toEqual([
expect(chatStateDisplayItems(state)).toEqual([
expect.objectContaining({
id: "p1",
kind: "message",
@ -163,7 +164,7 @@ describe("ChatInboundController", () => {
},
} satisfies Extract<ServerNotification, { method: "turn/plan/updated" }>);
expect(state.messageStream.displayItems).toMatchObject([
expect(chatStateDisplayItems(state)).toMatchObject([
{
id: "plan-progress-turn-active",
kind: "taskProgress",
@ -254,7 +255,7 @@ describe("ChatInboundController", () => {
},
} satisfies Extract<ServerNotification, { method: "hook/completed" }>);
expect(state.messageStream.displayItems).toMatchObject([
expect(chatStateDisplayItems(state)).toMatchObject([
{
id: "hook-hook-1-1",
kind: "hook",
@ -307,7 +308,7 @@ describe("ChatInboundController", () => {
},
} satisfies Extract<ServerNotification, { method: "hook/completed" }>);
expect(state.messageStream.displayItems[0]).toMatchObject({
expect(chatStateDisplayItems(state)[0]).toMatchObject({
kind: "hook",
details: [
{
@ -350,7 +351,7 @@ describe("ChatInboundController", () => {
},
} satisfies Extract<ServerNotification, { method: "hook/completed" }>);
expect(state.messageStream.displayItems[0]).toMatchObject({ id: "hook-hook-1-1", kind: "hook", turnId: "turn-active" });
expect(chatStateDisplayItems(state)[0]).toMatchObject({ id: "hook-hook-1-1", kind: "hook", turnId: "turn-active" });
});
it("leaves non-prompt unscoped hook runs outside the active turn", () => {
@ -383,8 +384,8 @@ describe("ChatInboundController", () => {
},
} satisfies Extract<ServerNotification, { method: "hook/completed" }>);
expect(state.messageStream.displayItems[0]).toMatchObject({ id: "hook-hook-1-1", kind: "hook" });
expect(state.messageStream.displayItems[0]?.turnId).toBeUndefined();
expect(chatStateDisplayItems(state)[0]).toMatchObject({ id: "hook-hook-1-1", kind: "hook" });
expect(chatStateDisplayItems(state)[0]?.turnId).toBeUndefined();
});
it("keeps repeated hook runs with the same run id as separate display items", () => {
@ -418,7 +419,7 @@ describe("ChatInboundController", () => {
params: { threadId: "thread-active", turnId: "turn-active", run: { ...baseRun, startedAt: 3n } },
} satisfies Extract<ServerNotification, { method: "hook/completed" }>);
expect(state.messageStream.displayItems.map((item) => item.id)).toEqual(["hook-hook-1-1", "hook-hook-1-3"]);
expect(chatStateDisplayItems(state).map((item) => item.id)).toEqual(["hook-hook-1-1", "hook-hook-1-3"]);
});
it("attaches pre-turn prompt submit hook runs when the turn starts", () => {
@ -428,7 +429,7 @@ describe("ChatInboundController", () => {
kind: "starting",
pendingTurnStart: { anchorItemId: "local-user-1", promptSubmitHookItemIds: ["hook-hook-1-1"] },
};
state.messageStream.displayItems = [
setChatStateDisplayItems(state, [
{ id: "local-user-1", kind: "message", messageKind: "user", role: "user", text: "hello" },
{
id: "hook-hook-1-1",
@ -438,7 +439,7 @@ describe("ChatInboundController", () => {
toolLabel: "hook",
status: "completed",
},
];
]);
const controller = controllerForState(state);
controller.handleNotification({
@ -458,8 +459,8 @@ describe("ChatInboundController", () => {
},
} satisfies Extract<ServerNotification, { method: "turn/started" }>);
expect(state.messageStream.displayItems.map((item) => item.id)).toEqual(["local-user-1", "hook-hook-1-1"]);
expect(state.messageStream.displayItems[1]).toMatchObject({ id: "hook-hook-1-1", turnId: "turn-active" });
expect(chatStateDisplayItems(state).map((item) => item.id)).toEqual(["local-user-1", "hook-hook-1-1"]);
expect(chatStateDisplayItems(state)[1]).toMatchObject({ id: "hook-hook-1-1", turnId: "turn-active" });
expect(pendingTurnStart(state)).toBeNull();
});
@ -524,8 +525,8 @@ describe("ChatInboundController", () => {
},
} satisfies Extract<ServerNotification, { method: "hook/completed" }>);
expect(expectPresent(state.messageStream.displayItems[0])).toMatchObject({ id: "hook-hook-1-1", kind: "hook" });
expect(expectPresent(state.messageStream.displayItems[0]).turnId).toBeUndefined();
expect(expectPresent(chatStateDisplayItems(state)[0])).toMatchObject({ id: "hook-hook-1-1", kind: "hook" });
expect(expectPresent(chatStateDisplayItems(state)[0]).turnId).toBeUndefined();
expect(expectPresent(pendingTurnStart(state)).promptSubmitHookItemIds).toEqual(["hook-hook-1-1"]);
});
@ -533,7 +534,7 @@ describe("ChatInboundController", () => {
const state = createChatState();
state.activeThread.id = "thread-active";
state.turn.lifecycle = { kind: "starting", pendingTurnStart: { anchorItemId: "local-user-1", promptSubmitHookItemIds: [] } };
state.messageStream.displayItems = [{ id: "local-user-1", kind: "message", messageKind: "user", role: "user", text: "hello" }];
setChatStateDisplayItems(state, [{ id: "local-user-1", kind: "message", messageKind: "user", role: "user", text: "hello" }]);
const controller = controllerForState(state);
controller.handleNotification({
@ -541,8 +542,8 @@ describe("ChatInboundController", () => {
params: { threadId: "thread-active", turnId: null, run: promptSubmitHookRun("hook-1", 1n) },
} satisfies Extract<ServerNotification, { method: "hook/completed" }>);
expect(state.messageStream.displayItems.map((item) => item.id)).toEqual(["local-user-1", "hook-hook-1-1"]);
expect(expectPresent(state.messageStream.displayItems[1]).turnId).toBeUndefined();
expect(chatStateDisplayItems(state).map((item) => item.id)).toEqual(["local-user-1", "hook-hook-1-1"]);
expect(expectPresent(chatStateDisplayItems(state)[1]).turnId).toBeUndefined();
expect(expectPresent(pendingTurnStart(state)).promptSubmitHookItemIds).toEqual(["hook-hook-1-1"]);
controller.handleNotification({
@ -562,9 +563,9 @@ describe("ChatInboundController", () => {
},
} satisfies Extract<ServerNotification, { method: "turn/started" }>);
expect(state.messageStream.displayItems.map((item) => item.id)).toEqual(["local-user-1", "hook-hook-1-1"]);
expect(state.messageStream.displayItems.find((item) => item.id === "local-user-1")).not.toHaveProperty("turnId");
expect(state.messageStream.displayItems.find((item) => item.id === "hook-hook-1-1")).toMatchObject({ turnId: "turn-active" });
expect(chatStateDisplayItems(state).map((item) => item.id)).toEqual(["local-user-1", "hook-hook-1-1"]);
expect(chatStateDisplayItems(state).find((item) => item.id === "local-user-1")).not.toHaveProperty("turnId");
expect(chatStateDisplayItems(state).find((item) => item.id === "hook-hook-1-1")).toMatchObject({ turnId: "turn-active" });
expect(pendingTurnStart(state)).toBeNull();
controller.handleNotification({
@ -587,15 +588,15 @@ describe("ChatInboundController", () => {
},
} satisfies Extract<ServerNotification, { method: "turn/completed" }>);
expect(state.messageStream.displayItems.map((item) => item.id)).toEqual(["u1", "hook-hook-1-1", "a1"]);
expect(state.messageStream.displayItems).toEqual(
expect(chatStateDisplayItems(state).map((item) => item.id)).toEqual(["u1", "hook-hook-1-1", "a1"]);
expect(chatStateDisplayItems(state)).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: "u1", text: "hello", turnId: "turn-active" }),
expect.objectContaining({ id: "hook-hook-1-1", kind: "hook", turnId: "turn-active" }),
expect.objectContaining({ id: "a1", text: "done", turnId: "turn-active" }),
]),
);
expect(state.messageStream.displayItems.some((item) => item.id === "local-user-1")).toBe(false);
expect(chatStateDisplayItems(state).some((item) => item.id === "local-user-1")).toBe(false);
});
it("ignores completed turn notifications while a new turn is still starting", () => {
@ -605,7 +606,7 @@ describe("ChatInboundController", () => {
kind: "starting",
pendingTurnStart: { anchorItemId: "local-user-1", promptSubmitHookItemIds: ["hook-hook-1-1"] },
};
state.messageStream.displayItems = [
setChatStateDisplayItems(state, [
{ id: "local-user-1", kind: "message", messageKind: "user", role: "user", text: "hello" },
{
id: "hook-hook-1-1",
@ -615,7 +616,7 @@ describe("ChatInboundController", () => {
toolLabel: "hook",
status: "completed",
},
];
]);
const maybeNameThread = vi.fn();
const refreshThreads = vi.fn();
const controller = controllerForState(state, { maybeNameThread, refreshThreads });
@ -638,7 +639,7 @@ describe("ChatInboundController", () => {
} satisfies Extract<ServerNotification, { method: "turn/completed" }>);
expect(pendingTurnStart(state)).toEqual({ anchorItemId: "local-user-1", promptSubmitHookItemIds: ["hook-hook-1-1"] });
expect(state.messageStream.displayItems.map((item) => item.id)).toEqual(["local-user-1", "hook-hook-1-1"]);
expect(chatStateDisplayItems(state).map((item) => item.id)).toEqual(["local-user-1", "hook-hook-1-1"]);
expect(maybeNameThread).not.toHaveBeenCalled();
expect(refreshThreads).not.toHaveBeenCalled();
});
@ -686,7 +687,7 @@ describe("ChatInboundController", () => {
expect(recordMcpStartupStatus).toHaveBeenCalledWith("github", "failed", "missing token");
expect(publishAppServerMetadata).toHaveBeenCalledOnce();
expect(state.messageStream.displayItems).toEqual([]);
expect(chatStateDisplayItems(state)).toEqual([]);
});
});
@ -720,7 +721,7 @@ describe("ChatInboundController", () => {
controller.resolveUserInput(expectPresent(state.requests.pendingUserInputs[0]), { scope: "Narrow" });
expect(respondToServerRequest).toHaveBeenCalledWith(42, { answers: { scope: { answers: ["Narrow"] } } });
expect(state.requests.pendingUserInputs).toEqual([]);
expect(state.messageStream.displayItems.at(-1)).toMatchObject({
expect(chatStateDisplayItems(state).at(-1)).toMatchObject({
kind: "userInputResult",
role: "tool",
text: "Input submitted for 1 question.",
@ -748,7 +749,7 @@ describe("ChatInboundController", () => {
controller.cancelUserInput(expectPresent(state.requests.pendingUserInputs[0]));
expect(rejectServerRequest).toHaveBeenCalledWith(43, -32000, "User cancelled input request.");
expect(state.requests.pendingUserInputs).toEqual([]);
expect(state.messageStream.displayItems.at(-1)).toMatchObject({
expect(chatStateDisplayItems(state).at(-1)).toMatchObject({
kind: "userInputResult",
role: "tool",
text: "Input request cancelled for 1 question.",
@ -772,7 +773,7 @@ describe("ChatInboundController", () => {
expect(respondToServerRequest).not.toHaveBeenCalled();
expect(rejectServerRequest).not.toHaveBeenCalled();
expect(state.requests.pendingUserInputs).toEqual([current]);
expect(state.messageStream.displayItems).toEqual([]);
expect(chatStateDisplayItems(state)).toEqual([]);
});
it("records manual permission approvals as colored result items", () => {
@ -788,7 +789,7 @@ describe("ChatInboundController", () => {
permissions: {},
});
expect(state.requests.approvals).toEqual([]);
expect(state.messageStream.displayItems.at(-1)).toMatchObject({
expect(chatStateDisplayItems(state).at(-1)).toMatchObject({
id: "approval-12",
kind: "approvalResult",
role: "tool",
@ -822,7 +823,7 @@ describe("ChatInboundController", () => {
expect(respondToServerRequest).not.toHaveBeenCalled();
expect(state.requests.approvals).toEqual([current]);
expect(state.messageStream.displayItems).toEqual([]);
expect(chatStateDisplayItems(state)).toEqual([]);
});
it("handles known server request families and rejects unsupported requests by default", () => {
@ -853,8 +854,12 @@ describe("ChatInboundController", () => {
-32601,
"Rejected unknown app-server request: appServer/newFutureRequest",
);
expect(state.messageStream.displayItems.map((item) => item.text)).toEqual(unsupportedMessages);
expect(state.messageStream.displayItems.map((item) => item.text).join("\n")).not.toContain("do-not-render");
expect(chatStateDisplayItems(state).map((item) => item.text)).toEqual(unsupportedMessages);
expect(
chatStateDisplayItems(state)
.map((item) => item.text)
.join("\n"),
).not.toContain("do-not-render");
});
it("keeps unknown server request fallback out of the normal message stream", () => {
@ -865,7 +870,7 @@ describe("ChatInboundController", () => {
controller.handleServerRequest(unknownRequest());
expect(rejectServerRequest).toHaveBeenCalledWith(27, -32601, "Rejected unknown app-server request: appServer/newFutureRequest");
expect(state.messageStream.displayItems).toEqual([]);
expect(chatStateDisplayItems(state)).toEqual([]);
});
it("rejects server requests scoped to a different active thread or turn", () => {
@ -943,7 +948,7 @@ describe("ChatInboundController", () => {
controller.resolveUserInput(expectPresent(state.requests.pendingUserInputs[0]), { note: "Later" });
expect(state.requests.pendingUserInputs).toHaveLength(1);
expect(state.messageStream.displayItems).toEqual([
expect(chatStateDisplayItems(state)).toEqual([
expect.objectContaining({ kind: "system", text: "Could not send user input because Codex app-server is not connected." }),
]);
});
@ -997,7 +1002,7 @@ describe("ChatInboundController", () => {
params: { threadId: null, message: "careful" },
} satisfies Extract<ServerNotification, { method: "warning" }>);
expect(state.messageStream.displayItems).toEqual([
expect(chatStateDisplayItems(state)).toEqual([
expect.objectContaining({
kind: "system",
text: 'warning: {\n "threadId": null,\n "message": "careful"\n}',
@ -1018,9 +1023,9 @@ describe("ChatInboundController", () => {
};
state.messageStream.historyCursor = "cursor";
state.messageStream.loadingHistory = true;
state.messageStream.displayItems = [
setChatStateDisplayItems(state, [
{ id: "message", kind: "message", role: "assistant", text: "stale", messageKind: "assistantResponse", messageState: "completed" },
];
]);
state.messageStream.turnDiffs = new Map([["turn-active", "@@\n-stale\n+stale"]]);
state.composer.draft = "thread draft";
state.requests.approvals = [
@ -1061,7 +1066,7 @@ describe("ChatInboundController", () => {
expect(state.activeThread.tokenUsage).toBeNull();
expect(state.messageStream.historyCursor).toBeNull();
expect(state.messageStream.loadingHistory).toBe(false);
expect(state.messageStream.displayItems).toEqual([]);
expect(chatStateDisplayItems(state)).toEqual([]);
expect(state.messageStream.turnDiffs.size).toBe(0);
expect(state.composer.draft).toBe("");
expect(chatTurnBusy(state)).toBe(false);
@ -1102,7 +1107,7 @@ describe("ChatInboundController", () => {
const state = createChatState();
state.activeThread.id = "thread-active";
state.turn.lifecycle = { kind: "running", turnId: "turn-active" };
state.messageStream.displayItems = [
setChatStateDisplayItems(state, [
{ id: "local-user-1", kind: "message", messageKind: "user", role: "user", text: "hello", turnId: "turn-active" },
{
id: "a1",
@ -1114,7 +1119,7 @@ describe("ChatInboundController", () => {
text: "partial",
turnId: "turn-active",
},
];
]);
const controller = controllerForState(state);
controller.handleNotification({
@ -1137,22 +1142,22 @@ describe("ChatInboundController", () => {
},
} satisfies Extract<ServerNotification, { method: "turn/completed" }>);
expect(state.messageStream.displayItems.filter((item) => item.kind === "message" && item.role === "user")).toEqual([
expect(chatStateDisplayItems(state).filter((item) => item.kind === "message" && item.role === "user")).toEqual([
expect.objectContaining({ id: "u1", text: "hello" }),
]);
expect(state.messageStream.displayItems).toEqual(expect.arrayContaining([expect.objectContaining({ id: "a1", text: "done" })]));
expect(state.messageStream.displayItems.some((item) => item.id === "local-user-1")).toBe(false);
expect(chatStateDisplayItems(state)).toEqual(expect.arrayContaining([expect.objectContaining({ id: "a1", text: "done" })]));
expect(chatStateDisplayItems(state).some((item) => item.id === "local-user-1")).toBe(false);
});
it("reconciles optimistic user echoes by client id before falling back to text only when client ids are absent", () => {
const state = createChatState();
state.activeThread.id = "thread-active";
state.turn.lifecycle = { kind: "running", turnId: "turn-active" };
state.messageStream.displayItems = [
setChatStateDisplayItems(state, [
{ id: "local-user-1", kind: "message", messageKind: "user", role: "user", text: "same text", turnId: "turn-active" },
{ id: "local-steer-2", kind: "message", messageKind: "user", role: "user", text: "same text", turnId: "turn-active" },
{ id: "local-user-2", kind: "message", messageKind: "user", role: "user", text: "same text", turnId: "turn-other" },
];
]);
const controller = controllerForState(state);
controller.handleNotification({
@ -1180,19 +1185,19 @@ describe("ChatInboundController", () => {
},
} satisfies Extract<ServerNotification, { method: "turn/completed" }>);
expect(state.messageStream.displayItems).toEqual(
expect(chatStateDisplayItems(state)).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: "u1", clientId: "local-user-1", text: "same text" }),
expect.objectContaining({ id: "local-steer-2", text: "same text" }),
expect.objectContaining({ id: "local-user-2", text: "same text" }),
]),
);
expect(state.messageStream.displayItems.some((item) => item.id === "local-user-1")).toBe(false);
expect(chatStateDisplayItems(state).some((item) => item.id === "local-user-1")).toBe(false);
const fallbackStateWithoutClientId = createChatState();
fallbackStateWithoutClientId.activeThread.id = "thread-active";
fallbackStateWithoutClientId.turn.lifecycle = { kind: "running", turnId: "turn-active" };
fallbackStateWithoutClientId.messageStream.displayItems = [
setChatStateDisplayItems(fallbackStateWithoutClientId, [
{
id: "local-user-without-client-id",
kind: "message",
@ -1201,7 +1206,7 @@ describe("ChatInboundController", () => {
text: "fallback text",
turnId: "turn-active",
},
];
]);
const fallbackControllerWithoutClientId = controllerForState(fallbackStateWithoutClientId);
fallbackControllerWithoutClientId.handleNotification({
@ -1228,19 +1233,17 @@ describe("ChatInboundController", () => {
},
} satisfies Extract<ServerNotification, { method: "turn/completed" }>);
expect(fallbackStateWithoutClientId.messageStream.displayItems).toEqual([
expect(chatStateDisplayItems(fallbackStateWithoutClientId)).toEqual([
expect.objectContaining({ id: "server-u1", text: "fallback text" }),
]);
expect(fallbackStateWithoutClientId.messageStream.displayItems.some((item) => item.id === "local-user-without-client-id")).toBe(
false,
);
expect(chatStateDisplayItems(fallbackStateWithoutClientId).some((item) => item.id === "local-user-without-client-id")).toBe(false);
});
it("keeps the observed steer message order when completed turns reconcile by client id", () => {
const state = createChatState();
state.activeThread.id = "thread-active";
state.turn.lifecycle = { kind: "running", turnId: "turn-active" };
state.messageStream.displayItems = [
setChatStateDisplayItems(state, [
{ id: "local-user-1", kind: "message", messageKind: "user", role: "user", text: "start", turnId: "turn-active" },
{
id: "a1",
@ -1253,7 +1256,7 @@ describe("ChatInboundController", () => {
turnId: "turn-active",
},
{ id: "local-steer-1", kind: "message", messageKind: "user", role: "user", text: "steer", turnId: "turn-active" },
];
]);
const controller = controllerForState(state);
controller.handleNotification({
@ -1278,8 +1281,8 @@ describe("ChatInboundController", () => {
},
} satisfies Extract<ServerNotification, { method: "turn/completed" }>);
expect(state.messageStream.displayItems.map((item) => item.id)).toEqual(["u1", "a1", "u2", "a2"]);
expect(state.messageStream.displayItems).toEqual(
expect(chatStateDisplayItems(state).map((item) => item.id)).toEqual(["u1", "a1", "u2", "a2"]);
expect(chatStateDisplayItems(state)).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: "u1", clientId: "local-user-1", text: "start" }),
expect.objectContaining({ id: "a1", text: "first done" }),
@ -1371,7 +1374,7 @@ describe("ChatInboundController", () => {
expect(state.runtime.activeApprovalPolicy).toBe("on-request");
expect(state.runtime.activeApprovalsReviewer).toBe("auto_review");
expect(state.runtime.activePermissionProfile).toBeNull();
expect(state.messageStream.displayItems).toEqual([]);
expect(chatStateDisplayItems(state)).toEqual([]);
});
it("ignores settings notifications for inactive threads", () => {
@ -1471,21 +1474,21 @@ describe("ChatInboundController", () => {
} satisfies Extract<ServerNotification, { method: "thread/goal/updated" }>);
expect(state.activeThread.goal).toEqual(goal);
expect(state.messageStream.displayItems.at(-1)).toMatchObject({ kind: "goal", text: "set: Finish", objective: "Finish" });
expect(chatStateDisplayItems(state).at(-1)).toMatchObject({ kind: "goal", text: "set: Finish", objective: "Finish" });
const afterSetMessageCount = state.messageStream.displayItems.length;
const afterSetMessageCount = chatStateDisplayItems(state).length;
controller.handleNotification({
method: "thread/goal/updated",
params: { threadId: "thread-active", turnId: null, goal },
} satisfies Extract<ServerNotification, { method: "thread/goal/updated" }>);
expect(state.messageStream.displayItems).toHaveLength(afterSetMessageCount);
expect(chatStateDisplayItems(state)).toHaveLength(afterSetMessageCount);
const updatedGoal = { ...goal, objective: "Finish well", updatedAt: 2 };
controller.handleNotification({
method: "thread/goal/updated",
params: { threadId: "thread-active", turnId: null, goal: updatedGoal },
} satisfies Extract<ServerNotification, { method: "thread/goal/updated" }>);
expect(state.messageStream.displayItems.at(-1)).toMatchObject({
expect(chatStateDisplayItems(state).at(-1)).toMatchObject({
kind: "goal",
text: "updated: Finish well",
objective: "Finish well",
@ -1499,7 +1502,7 @@ describe("ChatInboundController", () => {
method: "thread/goal/updated",
params: { threadId: "thread-active", turnId: null, goal: pausedGoal },
} satisfies Extract<ServerNotification, { method: "thread/goal/updated" }>);
expect(state.messageStream.displayItems.at(-1)).toMatchObject({
expect(chatStateDisplayItems(state).at(-1)).toMatchObject({
kind: "goal",
text: "paused: Finish well",
objective: "Finish well",
@ -1513,18 +1516,18 @@ describe("ChatInboundController", () => {
method: "thread/goal/updated",
params: { threadId: "thread-active", turnId: null, goal: resumedGoal },
} satisfies Extract<ServerNotification, { method: "thread/goal/updated" }>);
expect(state.messageStream.displayItems.at(-1)).toMatchObject({
expect(chatStateDisplayItems(state).at(-1)).toMatchObject({
kind: "goal",
text: "resumed: Finish well",
objective: "Finish well",
});
const messageCount = state.messageStream.displayItems.length;
const messageCount = chatStateDisplayItems(state).length;
controller.handleNotification({
method: "thread/goal/updated",
params: { threadId: "thread-active", turnId: null, goal: { ...resumedGoal, tokensUsed: 10, timeUsedSeconds: 20 } },
} satisfies Extract<ServerNotification, { method: "thread/goal/updated" }>);
expect(state.messageStream.displayItems).toHaveLength(messageCount);
expect(chatStateDisplayItems(state)).toHaveLength(messageCount);
controller.handleNotification({
method: "thread/goal/cleared",
@ -1532,7 +1535,7 @@ describe("ChatInboundController", () => {
} satisfies Extract<ServerNotification, { method: "thread/goal/cleared" }>);
expect(state.activeThread.goal).toBeNull();
expect(state.messageStream.displayItems.at(-1)).toMatchObject({
expect(chatStateDisplayItems(state).at(-1)).toMatchObject({
kind: "goal",
text: "cleared: Finish well",
objective: "Finish well",
@ -1567,15 +1570,15 @@ describe("ChatInboundController", () => {
} satisfies Extract<ServerNotification, { method: "thread/goal/updated" }>);
expect(state.activeThread.goal).toEqual(completedGoal);
expect(state.messageStream.displayItems).toHaveLength(1);
expect(state.messageStream.displayItems[0]).toMatchObject({ kind: "goal", text: "completed: Finish", objective: "Finish" });
expect(chatStateDisplayItems(state)).toHaveLength(1);
expect(chatStateDisplayItems(state)[0]).toMatchObject({ kind: "goal", text: "completed: Finish", objective: "Finish" });
controller.handleNotification({
method: "thread/goal/updated",
params: { threadId: "thread-active", turnId: "turn-1", goal: { ...completedGoal, tokensUsed: 43 } },
} satisfies Extract<ServerNotification, { method: "thread/goal/updated" }>);
expect(state.messageStream.displayItems).toHaveLength(1);
expect(chatStateDisplayItems(state)).toHaveLength(1);
});
it("ignores goal notifications that do not match the active thread", () => {
@ -1621,7 +1624,7 @@ describe("ChatInboundController", () => {
params: { threadId: "thread-active", message: "Auto-review denied this command." },
} satisfies Extract<ServerNotification, { method: "guardianWarning" }>);
expect(state.messageStream.displayItems).toMatchObject([
expect(chatStateDisplayItems(state)).toMatchObject([
{
kind: "reviewResult",
role: "tool",
@ -1663,14 +1666,14 @@ describe("ChatInboundController", () => {
},
} satisfies Extract<ServerNotification, { method: "item/autoApprovalReview/completed" }>);
expect(state.messageStream.displayItems).toHaveLength(1);
expect(state.messageStream.displayItems[0]).toMatchObject({
expect(chatStateDisplayItems(state)).toHaveLength(1);
expect(chatStateDisplayItems(state)[0]).toMatchObject({
id: "review-review-1",
kind: "reviewResult",
text: "Auto-review approved: npm test",
executionState: "completed",
});
const reviewItem = expectPresent(state.messageStream.displayItems[0]);
const reviewItem = expectPresent(chatStateDisplayItems(state)[0]);
expect("details" in reviewItem ? reviewItem.details[0] : null).toMatchObject({
title: "Review",
rows: expect.arrayContaining([{ key: "status", value: "approved" }]),
@ -1702,8 +1705,8 @@ describe("ChatInboundController", () => {
},
} satisfies Extract<ServerNotification, { method: "item/autoApprovalReview/completed" }>);
expect(state.messageStream.displayItems).toHaveLength(1);
expect(state.messageStream.displayItems[0]).toMatchObject({
expect(chatStateDisplayItems(state)).toHaveLength(1);
expect(chatStateDisplayItems(state)[0]).toMatchObject({
id: "review-review-1",
kind: "reviewResult",
text: "Auto-review approved: npm test",
@ -1736,8 +1739,8 @@ describe("ChatInboundController", () => {
params: { threadId: "thread-active", message: "Auto-review approved: npm test" },
} satisfies Extract<ServerNotification, { method: "guardianWarning" }>);
expect(state.messageStream.displayItems).toHaveLength(1);
expect(state.messageStream.displayItems[0]).toMatchObject({ id: "review-review-1" });
expect(chatStateDisplayItems(state)).toHaveLength(1);
expect(chatStateDisplayItems(state)[0]).toMatchObject({ id: "review-review-1" });
});
});
});

View file

@ -14,6 +14,7 @@ import { messageStreamDisplayItems } from "../../../src/features/chat/state/mess
import type { ThreadGoal } from "../../../src/domain/threads/goal";
import type { DisplayItem } from "../../../src/features/chat/display/types";
import type { Thread } from "../../../src/domain/threads/model";
import { chatStateDisplayItems, setChatStateDisplayItems } from "./support/message-stream";
describe("chatReducer", () => {
it("clears active turn and thread-scoped state", () => {
@ -31,7 +32,7 @@ describe("chatReducer", () => {
state.messageStream.historyCursor = "cursor";
state.messageStream.loadingHistory = true;
state.composer.draft = "keep me";
state.messageStream.displayItems = [message("m1")];
setChatStateDisplayItems(state, [message("m1")]);
state.messageStream.turnDiffs = new Map([["turn", "@@"]]);
state.requests.approvals = [approval(1)];
state.requests.pendingUserInputs = [userInput(2)];
@ -62,7 +63,7 @@ describe("chatReducer", () => {
expect(next.runtime.requestedApprovalsReviewer).toEqual({ kind: "set", value: "user" });
expect(next.runtime.selectedCollaborationMode).toBe("plan");
expect(activeTurnId(next)).toBeNull();
expect(next.messageStream.displayItems).toEqual([]);
expect(chatStateDisplayItems(next)).toEqual([]);
expect(next.messageStream.turnDiffs.size).toBe(0);
expect(next.messageStream.historyCursor).toBeNull();
expect(next.messageStream.loadingHistory).toBe(false);
@ -84,7 +85,7 @@ describe("chatReducer", () => {
state.activeThread.goal = goal("previous-thread");
state.messageStream.loadingHistory = true;
state.composer.draft = "previous draft";
state.messageStream.displayItems = [message("previous-message")];
setChatStateDisplayItems(state, [message("previous-message")]);
state.messageStream.turnDiffs = new Map([["previous-turn", "@@"]]);
state.requests.approvals = [approval(1)];
state.requests.pendingUserInputs = [userInput(2)];
@ -116,7 +117,7 @@ describe("chatReducer", () => {
expect(next.messageStream.historyCursor).toBeNull();
expect(next.messageStream.loadingHistory).toBe(false);
expect(next.composer.draft).toBe("");
expect(next.messageStream.displayItems).toEqual(resumedItems);
expect(chatStateDisplayItems(next)).toEqual(resumedItems);
expect(next.messageStream.turnDiffs.size).toBe(0);
expect(next.requests.approvals).toEqual([]);
expect(next.requests.pendingUserInputs).toEqual([]);
@ -131,7 +132,7 @@ describe("chatReducer", () => {
it("starts resumed threads with empty display state when no history items are supplied", () => {
const state = createChatState();
state.messageStream.displayItems = [message("previous-message")];
setChatStateDisplayItems(state, [message("previous-message")]);
const next = chatReducer(state, {
type: "active-thread/resumed",
@ -145,7 +146,7 @@ describe("chatReducer", () => {
activePermissionProfile: null,
});
expect(next.messageStream.displayItems).toEqual([]);
expect(chatStateDisplayItems(next)).toEqual([]);
});
it("keeps composer state when restoring a thread placeholder", () => {
@ -160,7 +161,7 @@ describe("chatReducer", () => {
state.composer.suggestSelected = 1;
state.composer.suggestions = [suggestion("/resume")];
state.composer.suggestionsDismissedSignature = "dismissed";
state.messageStream.displayItems = [message("previous-message")];
setChatStateDisplayItems(state, [message("previous-message")]);
state.messageStream.turnDiffs = new Map([["previous-turn", "@@"]]);
state.requests.approvals = [approval(1)];
state.requests.pendingUserInputs = [userInput(2)];
@ -177,7 +178,7 @@ describe("chatReducer", () => {
expect(next.activeThread.goal).toBeNull();
expect(next.messageStream.historyCursor).toBeNull();
expect(next.messageStream.loadingHistory).toBe(false);
expect(next.messageStream.displayItems).toEqual([]);
expect(chatStateDisplayItems(next)).toEqual([]);
expect(next.messageStream.turnDiffs.size).toBe(0);
expect(next.requests.approvals).toEqual([]);
expect(next.requests.pendingUserInputs).toEqual([]);
@ -211,7 +212,7 @@ describe("chatReducer", () => {
const second = chatReducer(first, { type: "message-stream/deduped-log-added", text: "once", item });
expect(first.messageStream.reportedLogs).not.toBe(state.messageStream.reportedLogs);
expect(first.messageStream.displayItems).toEqual([item]);
expect(chatStateDisplayItems(first)).toEqual([item]);
expect(second).toBe(first);
});
@ -265,7 +266,7 @@ describe("chatReducer", () => {
it("appends streaming assistant deltas into the active segment without replacing stable history", () => {
const state = createChatState();
state.messageStream.displayItems = [message("history")];
setChatStateDisplayItems(state, [message("history")]);
const running = chatReducer(state, { type: "turn/started", threadId: "thread", turnId: "turn" });
const next = chatReducer(running, {
@ -325,14 +326,14 @@ describe("chatReducer", () => {
it("ignores stale turn start failures after the turn is already running", () => {
const state = createChatState();
state.turn.lifecycle = { kind: "running", turnId: "turn" };
state.messageStream.displayItems = [message("existing")];
setChatStateDisplayItems(state, [message("existing")]);
const next = chatReducer(state, { type: "turn/start-failed", displayItems: [] });
expect(next).toBe(state);
expect(chatTurnBusy(next)).toBe(true);
expect(activeTurnId(next)).toBe("turn");
expect(next.messageStream.displayItems).toBe(state.messageStream.displayItems);
expect(chatStateDisplayItems(next)).toBe(chatStateDisplayItems(state));
});
it("clears turn-scoped requests when clearing the local turn scope", () => {
@ -341,7 +342,7 @@ describe("chatReducer", () => {
state.requests.approvals = [approval(1)];
state.requests.pendingUserInputs = [userInput(2)];
state.requests.userInputDrafts = new Map([["2:note", "draft"]]);
state.messageStream.displayItems = [message("kept")];
setChatStateDisplayItems(state, [message("kept")]);
state.ui.openDetails = new Set(["approval:1:details", "request:2", "message:kept:details"]);
const next = chatReducer(state, { type: "turn/scoped-cleared" });
@ -351,7 +352,7 @@ describe("chatReducer", () => {
expect(next.requests.approvals).toEqual([]);
expect(next.requests.pendingUserInputs).toEqual([]);
expect(next.requests.userInputDrafts.size).toBe(0);
expect(next.messageStream.displayItems).toBe(state.messageStream.displayItems);
expect(chatStateDisplayItems(next)).toBe(chatStateDisplayItems(state));
expect([...next.ui.openDetails]).toEqual(["message:kept:details"]);
});
@ -363,32 +364,32 @@ describe("chatReducer", () => {
["2:note", "draft"],
["2:note:other", "other draft"],
]);
state.messageStream.displayItems = [message("existing")];
setChatStateDisplayItems(state, [message("existing")]);
state.ui.openDetails = new Set(["approval:1:details", "request:2", "request:2:other", "message:existing:details"]);
const withoutResult = chatReducer(state, { type: "request/resolved", requestId: 1 });
expect(withoutResult.requests.approvals).toEqual([]);
expect(withoutResult.requests.pendingUserInputs).toEqual([userInput(2)]);
expect(withoutResult.messageStream.displayItems).toBe(state.messageStream.displayItems);
expect(chatStateDisplayItems(withoutResult)).toBe(chatStateDisplayItems(state));
expect([...withoutResult.ui.openDetails]).toEqual(["request:2", "request:2:other", "message:existing:details"]);
const resultItem = message("result");
const withResult = chatReducer(withoutResult, { type: "request/resolved", requestId: 2, resultItem });
expect(withResult.requests.pendingUserInputs).toEqual([]);
expect(withResult.requests.userInputDrafts.size).toBe(0);
expect(withResult.messageStream.displayItems).toEqual([message("existing"), resultItem]);
expect(chatStateDisplayItems(withResult)).toEqual([message("existing"), resultItem]);
expect([...withResult.ui.openDetails]).toEqual(["message:existing:details"]);
});
it("ignores stale request resolutions without appending result items", () => {
const state = createChatState();
state.messageStream.displayItems = [message("existing")];
setChatStateDisplayItems(state, [message("existing")]);
state.ui.openDetails = new Set(["request:99", "message:existing:details"]);
const next = chatReducer(state, { type: "request/resolved", requestId: 99, resultItem: message("stale result") });
expect(next).toBe(state);
expect(next.messageStream.displayItems).toBe(state.messageStream.displayItems);
expect(chatStateDisplayItems(next)).toBe(chatStateDisplayItems(state));
expect([...next.ui.openDetails]).toEqual(["request:99", "message:existing:details"]);
});
@ -411,7 +412,7 @@ describe("chatReducer", () => {
const pending = { anchorItemId: "local-user", promptSubmitHookItemIds: ["hook"] };
const state = createChatState();
state.turn.lifecycle = { kind: "starting", pendingTurnStart: pending };
state.messageStream.displayItems = [{ id: "local-user", kind: "message", messageKind: "user", role: "user", text: "hello" }];
setChatStateDisplayItems(state, [{ id: "local-user", kind: "message", messageKind: "user", role: "user", text: "hello" }]);
const next = chatReducer(state, {
type: "turn/completed",
@ -423,7 +424,7 @@ describe("chatReducer", () => {
expect(next).toBe(state);
expect(chatTurnBusy(next)).toBe(true);
expect(pendingTurnStart(next)).toEqual(pending);
expect(next.messageStream.displayItems).toEqual(state.messageStream.displayItems);
expect(chatStateDisplayItems(next)).toEqual(chatStateDisplayItems(state));
});
it("keeps toolbar panels mutually exclusive", () => {
@ -517,13 +518,13 @@ describe("chatReducer", () => {
it("stores updates through ChatStateStore without mutating the initial snapshot", () => {
const initial = createChatState();
initial.messageStream.displayItems = [message("initial")];
setChatStateDisplayItems(initial, [message("initial")]);
const store = createChatStateStore(initial);
store.dispatch({ type: "message-stream/item-upserted", item: message("next") });
expect(initial.messageStream.displayItems).toEqual([message("initial")]);
expect(store.getState().messageStream.displayItems).toEqual([message("initial"), message("next")]);
expect(chatStateDisplayItems(initial)).toEqual([message("initial")]);
expect(chatStateDisplayItems(store.getState())).toEqual([message("initial"), message("next")]);
});
it("keeps panel-local thread, request, and composer state isolated across stores", () => {

View file

@ -0,0 +1,11 @@
import type { DisplayItem } from "../../../../src/features/chat/display/types";
import { messageStreamDisplayItems, messageStreamWithDisplayItems } from "../../../../src/features/chat/state/message-stream";
import type { ChatState } from "../../../../src/features/chat/state/reducer";
export function chatStateDisplayItems(state: Pick<ChatState, "messageStream">): readonly DisplayItem[] {
return messageStreamDisplayItems(state.messageStream);
}
export function setChatStateDisplayItems(state: ChatState, items: readonly DisplayItem[]): void {
state.messageStream = messageStreamWithDisplayItems(state.messageStream, items);
}

View file

@ -14,6 +14,7 @@ import type { DisplayItem } from "../../../../src/features/chat/display/types";
import { DEFAULT_SETTINGS } from "../../../../src/settings/model";
import { notices } from "../../../mocks/obsidian";
import { deferred, waitForAsyncWork } from "../../../support/async";
import { chatStateDisplayItems, setChatStateDisplayItems } from "../support/message-stream";
type MockArchiveExportAdapter = ArchiveExportAdapter & {
exists: ReturnType<typeof vi.fn<ArchiveExportAdapter["exists"]>>;
@ -301,7 +302,7 @@ describe("chat thread actions", () => {
await controller.rollbackThread("source");
expect(client.rollbackThread).toHaveBeenCalledWith("source");
expect(host.stateStore.getState().messageStream.displayItems.slice(0, 2)).toMatchObject([
expect(chatStateDisplayItems(host.stateStore.getState()).slice(0, 2)).toMatchObject([
{ kind: "message", role: "user", text: "kept prompt", turnId: "kept-turn" },
{ kind: "message", role: "assistant", text: "kept answer", turnId: "kept-turn" },
]);
@ -425,7 +426,8 @@ function hostMock({
settings?: Partial<typeof DEFAULT_SETTINGS>;
}) {
const state = createChatState();
const stateStore = createChatStateStore({ ...state, messageStream: { ...state.messageStream, displayItems } });
setChatStateDisplayItems(state, displayItems);
const stateStore = createChatStateStore(state);
return {
stateStore,
vaultPath: "/vault",

View file

@ -5,6 +5,7 @@ import { HistoryController } from "../../../../src/features/chat/threads/history
import type { AppServerClient } from "../../../../src/app-server/connection/client";
import type { TurnItem, TurnRecord } from "../../../../src/app-server/protocol/turn";
import { deferred } from "../../../support/async";
import { chatStateDisplayItems } from "../support/message-stream";
describe("HistoryController", () => {
it("keeps the latest history load when an older request resolves later", async () => {
@ -39,7 +40,7 @@ describe("HistoryController", () => {
pending.resolve(threadTurnsResponse([turnFixture([assistantMessage("assistant", "Stale")])], "stale-cursor"));
await loading;
expect(stateStore.getState().messageStream.displayItems).toEqual([]);
expect(chatStateDisplayItems(stateStore.getState())).toEqual([]);
expect(stateStore.getState().messageStream.historyCursor).toBeNull();
expect(stateStore.getState().messageStream.loadingHistory).toBe(false);
expect(addSystemMessage).not.toHaveBeenCalled();
@ -53,7 +54,7 @@ describe("HistoryController", () => {
expect(applied).toBe(true);
expect(threadTurnsList).not.toHaveBeenCalled();
expect(stateStore.getState().messageStream.displayItems).toEqual([
expect(chatStateDisplayItems(stateStore.getState())).toEqual([
expect.objectContaining({ id: "assistant", text: "Ready", turnId: "turn" }),
]);
expect(stateStore.getState().messageStream.historyCursor).toBe("older");
@ -66,7 +67,7 @@ describe("HistoryController", () => {
const applied = loader.applyLatestPage("other", threadTurnsResponse([turnFixture([assistantMessage("assistant", "Stale")])], "older"));
expect(applied).toBe(false);
expect(stateStore.getState().messageStream.displayItems).toEqual([]);
expect(chatStateDisplayItems(stateStore.getState())).toEqual([]);
expect(stateStore.getState().messageStream.historyCursor).toBeNull();
});
@ -78,7 +79,7 @@ describe("HistoryController", () => {
await loader.loadOlder();
expect(threadTurnsList).toHaveBeenCalledWith("thread", "cursor", 20);
expect(stateStore.getState().messageStream.displayItems.map((item) => item.id)).toEqual(["older", "current"]);
expect(chatStateDisplayItems(stateStore.getState()).map((item) => item.id)).toEqual(["older", "current"]);
expect(stateStore.getState().messageStream.historyCursor).toBe("next");
expect(keepCurrentScrollPosition).toHaveBeenCalledOnce();
expect(showLatestPageAtBottom).not.toHaveBeenCalled();

View file

@ -9,30 +9,27 @@ import { DEFAULT_SETTINGS } from "../../../../src/settings/model";
import { deferred } from "../../../support/async";
describe("RenameController", () => {
it("notifies subscribers without rerendering after updating a controlled rename draft", () => {
it("updates its signal version without rerendering after changing a controlled rename draft", () => {
const { controller, render } = controllerFixture();
const listener = vi.fn();
controller.subscribe(listener);
const initialVersion = controller.version.value;
controller.start("thread");
controller.updateDraft("thread", "New name");
expect(listener).toHaveBeenCalledTimes(2);
expect(controller.version.value).toBe(initialVersion + 2);
expect(render).not.toHaveBeenCalled();
expect(controller.editState("thread")).toEqual({ draft: "New name", generating: false });
});
it("notifies subscribers when inline rename state changes", () => {
it("updates its signal version when inline rename state changes", () => {
const { controller } = controllerFixture();
const listener = vi.fn();
const unsubscribe = controller.subscribe(listener);
controller.start("thread");
controller.updateDraft("thread", "New name");
unsubscribe();
const changedVersion = controller.version.value;
controller.cancel("thread");
expect(listener).toHaveBeenCalledTimes(2);
expect(controller.version.value).toBe(changedVersion + 1);
});
it("applies generated rename drafts and clears the generating state", async () => {

View file

@ -720,7 +720,7 @@ describe("message stream rendering and message actions", () => {
turn: { lifecycle: { kind: "idle" as const } },
runtime: { selectedCollaborationMode: "plan" as const },
messageStream: {
displayItems: [
stableItems: [
firstPlan,
{
id: "a1",
@ -732,6 +732,7 @@ describe("message stream rendering and message actions", () => {
} as const,
secondPlan,
],
activeSegment: null,
},
};

View file

@ -10,7 +10,7 @@ import { pendingRequestBlockNode } from "../../../../../src/features/chat/ui/mes
import type { ChatTurnLifecycleState } from "../../../../../src/features/chat/state/reducer";
import { messageStreamBlocks as rawMessageStreamBlocks } from "../../../../../src/features/chat/ui/message-stream/stream-blocks";
import type { MessageStreamBlock } from "../../../../../src/features/chat/ui/message-stream/context";
import { messageStreamViewportNode } from "../../../../../src/features/chat/ui/message-stream/viewport";
import { MessageStreamViewport } from "../../../../../src/features/chat/ui/message-stream/viewport";
import { renderUiRoot, unmountUiRoot } from "../../../../../src/shared/ui/ui-root";
export function messageStreamBlocks(
@ -57,10 +57,12 @@ export function renderMessageStreamBlocksInAct(parent: HTMLElement, blocks: Mess
void act(() => {
renderUiRoot(
parent,
messageStreamViewportNode({
blocks,
consumeScrollIntent: () => "auto",
}),
<MessageStreamViewport
state={{
blocks,
consumeScrollIntent: () => "auto",
}}
/>,
);
});
}

View file

@ -1,9 +1,10 @@
// @vitest-environment jsdom
import { describe, expect, it, vi } from "vitest";
import { h } from "preact";
import {
composerShellNode,
ComposerShell,
scrollComposerSuggestionIntoView,
syncComposerHeight,
type ComposerCallbacks,
@ -16,7 +17,7 @@ import { changeInputValue, composerSuggestionScrollFixture, installObsidianDomSh
installObsidianDomShims();
function mountComposerShellNode(
function mountComposerShell(
parent: HTMLElement,
viewId: string,
draft: string,
@ -31,7 +32,7 @@ function mountComposerShellNode(
const elements: { composer: HTMLTextAreaElement | null } = { composer: null };
renderUiRoot(
parent,
composerShellNode(
h(ComposerShell, {
viewId,
draft,
busy,
@ -40,11 +41,32 @@ function mountComposerShellNode(
suggestions,
selectedSuggestionIndex,
callbacks,
meta,
(composer) => {
meta:
meta ??
({
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,
modelChoices: [],
effortChoices: [],
} satisfies ComposerMetaViewModel),
onComposer: (composer) => {
if (composer) elements.composer = composer;
},
),
}),
);
if (!elements.composer) throw new Error("Expected composer shell elements to mount.");
return { composer: elements.composer };
@ -69,7 +91,7 @@ describe("composer renderer decisions", () => {
it("uses the provided composer placeholder for normal input", () => {
const parent = document.createElement("div");
const callbacks = composerCallbacks();
const { composer } = mountComposerShellNode(
const { composer } = mountComposerShell(
parent,
"view",
"",
@ -84,7 +106,7 @@ describe("composer renderer decisions", () => {
expect(composer.getAttribute("placeholder")).toBe("Ask Codex to work on “Refactor terminal streaming”...");
expect(composer.getAttribute("aria-label")).toBe("Message");
mountComposerShellNode(parent, "view", "", false, false, "Ask Codex to work on “Renamed thread”...", [], 0, callbacks);
mountComposerShell(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”...");
});
@ -92,7 +114,7 @@ describe("composer renderer decisions", () => {
it("renders composer meta as interactive context and runtime text without changing normal text", () => {
const parent = document.createElement("div");
mountComposerShellNode(parent, "view", "", false, false, "Ask Codex to work on this task...", [], 0, composerCallbacks(), {
mountComposerShell(parent, "view", "", false, false, "Ask Codex to work on this task...", [], 0, composerCallbacks(), {
fatal: null,
context: {
cells: [
@ -167,7 +189,7 @@ describe("composer renderer decisions", () => {
const selectModel = vi.fn();
const selectEffort = vi.fn();
mountComposerShellNode(parent, "view", "", false, false, "Ask Codex to work on this task...", [], 0, callbacks, {
mountComposerShell(parent, "view", "", false, false, "Ask Codex to work on this task...", [], 0, callbacks, {
fatal: null,
context: {
cells: [
@ -251,7 +273,7 @@ describe("composer renderer decisions", () => {
it("hides composer meta fields only after measured overflow", async () => {
const parent = document.createElement("div");
mountComposerShellNode(parent, "view", "", false, false, "Ask Codex to work on this task...", [], 0, composerCallbacks(), {
mountComposerShell(parent, "view", "", false, false, "Ask Codex to work on this task...", [], 0, composerCallbacks(), {
fatal: null,
context: {
cells: [
@ -294,7 +316,7 @@ describe("composer renderer decisions", () => {
it("replaces composer meta with fatal status text", () => {
const parent = document.createElement("div");
mountComposerShellNode(parent, "view", "", false, false, "Ask Codex to work on this task...", [], 0, composerCallbacks(), {
mountComposerShell(parent, "view", "", false, false, "Ask Codex to work on this task...", [], 0, composerCallbacks(), {
fatal: "Codex app-server disconnected",
context: {
cells: [
@ -321,7 +343,7 @@ describe("composer renderer decisions", () => {
it("renders composer suggestions inside the composer root", () => {
const parent = document.createElement("div");
const onSuggestionInsert = vi.fn();
const { composer } = mountComposerShellNode(
const { composer } = mountComposerShell(
parent,
"view",
"",
@ -355,7 +377,7 @@ describe("composer renderer decisions", () => {
it("clears composer suggestion accessibility state on rerender", () => {
const parent = document.createElement("div");
const callbacks = composerCallbacks();
const { composer } = mountComposerShellNode(
const { composer } = mountComposerShell(
parent,
"view",
"",
@ -367,7 +389,7 @@ describe("composer renderer decisions", () => {
callbacks,
);
mountComposerShellNode(parent, "view", "", false, false, "Ask Codex to work on this task...", [], 0, callbacks);
mountComposerShell(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");
@ -378,7 +400,7 @@ describe("composer renderer decisions", () => {
it("reports composer draft changes from the controlled input", () => {
const parent = document.createElement("div");
const callbacks = composerCallbacks();
const { composer } = mountComposerShellNode(parent, "view", "", false, false, "Ask Codex to work on this task...", [], 0, callbacks);
const { composer } = mountComposerShell(parent, "view", "", false, false, "Ask Codex to work on this task...", [], 0, callbacks);
changeInputValue(composer, "Draft text");
@ -395,7 +417,7 @@ describe("composer renderer decisions", () => {
configurable: true,
});
try {
const { composer } = mountComposerShellNode(parent, "view", "", false, false, "Ask Codex to work on this task...", [], 0, callbacks);
const { composer } = mountComposerShell(parent, "view", "", false, false, "Ask Codex to work on this task...", [], 0, callbacks);
scrollHeight = 120;
changeInputValue(composer, "line one\nline two");
@ -453,7 +475,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 } = mountComposerShellNode(parent, "view", "", true, true, "Ask Codex to work on this task...", [], 0, callbacks);
const { composer } = mountComposerShell(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");
@ -463,7 +485,7 @@ describe("composer renderer decisions", () => {
expect(sendButton?.classList.contains("is-steer")).toBe(false);
expect(sendButton?.dataset["icon"]).toBe("square");
mountComposerShellNode(parent, "view", "adjust course", true, true, "Ask Codex to work on this task...", [], 0, callbacks);
mountComposerShell(parent, "view", "adjust course", true, true, "Ask Codex to work on this task...", [], 0, callbacks);
sendButton = parent.querySelector<HTMLButtonElement>(".codex-panel__send");
expect(sendButton?.getAttribute("aria-label")).toBe("Steer");
expect(composer.getAttribute("placeholder")).toBe("Add steering message...");

View file

@ -4,7 +4,7 @@ import { describe, expect, it, vi } from "vitest";
import { act } from "preact/test-utils";
import type { ThreadGoal } from "../../../../../src/domain/threads/goal";
import { goalRegionNode, type GoalRegionActions } from "../../../../../src/features/chat/ui/goal";
import { GoalPanel, type GoalPanelActions } from "../../../../../src/features/chat/ui/goal";
import type { SendShortcut } from "../../../../../src/shared/ui/keyboard";
import { renderUiRoot } from "../../../../../src/shared/ui/ui-root";
import { installObsidianDomShims } from "../../../../support/dom";
@ -12,7 +12,7 @@ import { installObsidianDomShims } from "../../../../support/dom";
installObsidianDomShims();
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
describe("goalRegionNode", () => {
describe("GoalPanel", () => {
it("renders nothing when there is no goal", async () => {
const parent = document.createElement("div");
@ -30,7 +30,10 @@ describe("goalRegionNode", () => {
const onEditingChange = vi.fn();
await act(async () => {
renderUiRoot(parent, goalRegionNode(null, callbacks, { sendShortcut: "enter", editingRequested: true, onEditingChange }));
renderUiRoot(
parent,
<GoalPanel goal={null} actions={callbacks} options={{ sendShortcut: "enter", editingRequested: true, onEditingChange }} />,
);
});
expect(parent.textContent).toContain("Goal");
@ -234,10 +237,10 @@ describe("goalRegionNode", () => {
function renderGoal(
parent: HTMLElement,
currentGoal: ThreadGoal | null,
callbacks: GoalRegionActions = actions(),
callbacks: GoalPanelActions = actions(),
sendShortcut: SendShortcut = "enter",
): void {
renderUiRoot(parent, goalRegionNode(currentGoal, callbacks, { sendShortcut }));
renderUiRoot(parent, <GoalPanel goal={currentGoal} actions={callbacks} options={{ sendShortcut }} />);
}
function actions() {

View file

@ -1,9 +1,9 @@
// @vitest-environment jsdom
import { describe, expect, it, vi } from "vitest";
import { h } from "preact";
import type { ToolbarViewModel } from "../../../../../src/features/chat/ui/toolbar";
import { toolbarNode } from "../../../../../src/features/chat/ui/toolbar";
import { Toolbar, type ToolbarActions, type ToolbarViewModel } from "../../../../../src/features/chat/ui/toolbar";
import { renderUiRoot } from "../../../../../src/shared/ui/ui-root";
import { changeInputValue, installObsidianDomShims } from "../../../../support/dom";
@ -14,10 +14,8 @@ 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));
function mountToolbar(parent: HTMLElement, model: ToolbarViewModel, actions: ToolbarActions): void {
renderUiRoot(parent, h(Toolbar, { model, actions }));
}
describe("toolbar renderer decisions", () => {
@ -28,7 +26,7 @@ describe("toolbar renderer decisions", () => {
const toggleHistory = vi.fn();
const baseModel = toolbarModel();
mountToolbarNode(parent, baseModel, toolbarActions({ startNewThread, toggleChatActions, toggleHistory }));
mountToolbar(parent, baseModel, toolbarActions({ startNewThread, toggleChatActions, toggleHistory }));
const navHeader = parent.querySelector(".codex-panel__toolbar-primary");
expect(navHeader?.classList.contains("nav-header")).toBe(true);
@ -68,11 +66,11 @@ describe("toolbar renderer decisions", () => {
historyButton?.click();
expect(toggleHistory).toHaveBeenCalled();
parent.empty();
mountToolbarNode(parent, toolbarModel({ newChatDisabled: true }), toolbarActions());
mountToolbar(parent, toolbarModel({ newChatDisabled: true }), toolbarActions());
expect(parent.querySelector<HTMLButtonElement>(".codex-panel__new-chat")?.disabled).toBe(true);
parent.empty();
mountToolbarNode(parent, toolbarModel({ chatActionsOpen: true, historyOpen: true, statusPanelOpen: true }), toolbarActions());
mountToolbar(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");
@ -87,7 +85,7 @@ describe("toolbar renderer decisions", () => {
const compactConversation = vi.fn();
const setGoal = vi.fn();
mountToolbarNode(
mountToolbar(
parent,
toolbarModel({ chatActionsOpen: true, openPanel: "chat-actions" }),
toolbarActions({ startNewThread, compactConversation, setGoal }),
@ -107,7 +105,7 @@ describe("toolbar renderer decisions", () => {
it("keeps context out of the toolbar and Codex limits inside the status menu", () => {
const parent = document.createElement("div");
mountToolbarNode(
mountToolbar(
parent,
toolbarModel({
statusPanelOpen: true,
@ -161,7 +159,7 @@ describe("toolbar renderer decisions", () => {
const parent = document.createElement("div");
const refreshStatus = vi.fn();
mountToolbarNode(
mountToolbar(
parent,
toolbarModel({
statusPanelOpen: true,
@ -193,7 +191,7 @@ describe("toolbar renderer decisions", () => {
it("renders effective config inside the status menu without a separate toggle", () => {
const parent = document.createElement("div");
mountToolbarNode(
mountToolbar(
parent,
toolbarModel({
statusPanelOpen: true,
@ -219,7 +217,7 @@ describe("toolbar renderer decisions", () => {
const autoNameThread = vi.fn();
const actions = toolbarActions({ startRenameThread, updateRenameDraft, saveRenameThread, cancelRenameThread, autoNameThread });
mountToolbarNode(
mountToolbar(
parent,
toolbarModel({
historyOpen: true,
@ -258,7 +256,7 @@ describe("toolbar renderer decisions", () => {
changeInputValue(input, "New title");
expect(updateRenameDraft).toHaveBeenCalledWith("editing", "New title");
mountToolbarNode(
mountToolbar(
parent,
toolbarModel({
historyOpen: true,
@ -293,7 +291,7 @@ describe("toolbar renderer decisions", () => {
it("renders auto-name loading without disabling the rename draft field", () => {
const parent = document.createElement("div");
mountToolbarNode(
mountToolbar(
parent,
toolbarModel({
historyOpen: true,
@ -323,7 +321,7 @@ describe("toolbar renderer decisions", () => {
const startArchiveThread = vi.fn();
const archiveThread = vi.fn();
mountToolbarNode(
mountToolbar(
parent,
toolbarModel({
historyOpen: true,

View file

@ -2,78 +2,56 @@
import { describe, expect, it, vi } from "vitest";
import { act } from "preact/test-utils";
import { useEffect } from "preact/hooks";
import { signal } from "@preact/signals";
import { chatTurnBusy, createChatStateStore } from "../../../../src/features/chat/state/reducer";
import { renderChatPanelShell, unmountChatPanelShell, useChatPanelShellState } from "../../../../src/features/chat/ui/shell";
import { createChatStateStore } from "../../../../src/features/chat/state/reducer";
import { renderChatPanelShell, unmountChatPanelShell, type ChatPanelShellSlots } from "../../../../src/features/chat/ui/shell";
import type { ChatPanelSurfacePorts } from "../../../../src/features/chat/panel/surface/ports";
import { installObsidianDomShims } from "../../../support/dom";
import { chatStateDisplayItems } from "../support/message-stream";
installObsidianDomShims();
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
describe("ChatPanelShell", () => {
it("renders the panel regions on the existing view content element", async () => {
it("composes toolbar, goal, message stream, and composer in one Preact root", async () => {
const store = createChatStateStore();
const container = document.createElement("div");
document.body.appendChild(container);
await act(async () => {
renderChatPanelShell(container, shellRenderers(store));
renderChatPanelShell(container, shellProps(store));
await settleShellEffects();
});
expect(container.classList.contains("codex-panel")).toBe(true);
expect(container.textContent).toContain("Idle");
expect(container.textContent).toContain("no goal");
expect(container.textContent).toContain("0");
expect(container.textContent).toContain("ready");
expect(container.querySelector(".codex-panel__region--config")).toBeNull();
expect(container.querySelector(".codex-panel__toolbar .codex-panel__toolbar-primary")).not.toBeNull();
expect(container.querySelector(".codex-panel__body > .codex-panel__region--message-stream")).toBe(
container.querySelector(".codex-panel__body > .codex-panel__messages"),
);
expect(container.querySelector<HTMLTextAreaElement>(".codex-panel__region--composer textarea")?.value).toBe("");
expect(container.querySelector(".codex-panel__message-block .test-message-count")?.textContent).toBe("0");
await act(async () => {
unmountChatPanelShell(container);
});
});
it("updates rendered panel content when the store changes", async () => {
it("updates signal-aware shell components from the state store without remounting the shell", async () => {
const store = createChatStateStore();
const container = document.createElement("div");
document.body.appendChild(container);
const renderers = shellRenderers(store);
const slots = shellSlots(store);
const composerRenderState = vi.spyOn(slots.composer, "renderState");
await act(async () => {
renderChatPanelShell(container, renderers);
renderChatPanelShell(container, { stateStore: store, showToolbar: true, slots });
await settleShellEffects();
});
composerRenderState.mockClear();
await act(async () => {
store.dispatch({ type: "connection/status-set", status: "Working" });
await settleShellEffects();
});
expect(container.textContent).toContain("Working");
await act(async () => {
unmountChatPanelShell(container);
});
});
it("keeps panel content in its owning region after shell rerenders", async () => {
const store = createChatStateStore();
const container = document.createElement("div");
document.body.appendChild(container);
const renderers = nestedRootShellRenderers(store);
await act(async () => {
renderChatPanelShell(container, renderers);
await settleShellEffects();
});
await act(async () => {
store.dispatch({ type: "connection/status-set", status: "Working" });
store.dispatch({ type: "ui/panel-set", panel: "status-panel" });
store.dispatch({ type: "composer/draft-set", draft: "ready", clearSuggestions: true });
store.dispatch({
type: "message-stream/system-item-added",
item: { id: "system-1", kind: "system", role: "system", text: "Model set." },
@ -81,84 +59,60 @@ describe("ChatPanelShell", () => {
await settleShellEffects();
});
expect(container.querySelector(".codex-panel__toolbar .test-toolbar")?.textContent).toBe("Working");
expect(container.querySelector(".codex-panel__region--goal .test-goal")?.textContent).toBe("no goal");
expect(container.querySelector(".codex-panel__region--message-stream .test-messages")?.textContent).toBe("1");
expect(container.querySelector<HTMLTextAreaElement>(".codex-panel__region--composer .test-composer textarea")?.value).toBe("ready");
expect(container.querySelector(".codex-panel__region--composer .test-toolbar")).toBeNull();
expect(container.querySelector(".codex-panel__region--composer .test-messages")).toBeNull();
expect(container.querySelector<HTMLTextAreaElement>(".codex-panel__region--composer textarea")?.value).toBe("ready");
expect(container.querySelector(".codex-panel__message-block .test-message-count")?.textContent).toBe("1");
expect(composerRenderState).toHaveBeenCalled();
await act(async () => {
unmountChatPanelShell(container);
});
});
it("renders region nodes inside the single shell root", async () => {
it("does not invalidate unrelated shell surfaces for composer-only state changes", async () => {
const store = createChatStateStore();
store.dispatch({
type: "thread-list/applied",
threads: [{ id: "thread-1", name: "Thread", preview: "", archived: false, createdAt: 1, updatedAt: 1 }],
});
store.dispatch({ type: "ui/panel-set", panel: "history" });
const container = document.createElement("div");
document.body.appendChild(container);
const cleanup = vi.fn();
const renderers = nodeShellRenderers(store, cleanup);
const renameState = vi.fn(() => null);
const slots = shellSlots(store, { toolbarRenameState: renameState });
const composerRenderState = vi.spyOn(slots.composer, "renderState");
const messageStreamRenderState = vi.spyOn(slots.messageStream, "renderState");
await act(async () => {
renderChatPanelShell(container, renderers);
renderChatPanelShell(container, { stateStore: store, showToolbar: true, slots });
await settleShellEffects();
});
renameState.mockClear();
composerRenderState.mockClear();
messageStreamRenderState.mockClear();
await act(async () => {
store.dispatch({ type: "composer/draft-set", draft: "composer only", clearSuggestions: true });
await settleShellEffects();
});
expect(container.querySelector(".codex-panel__toolbar .test-toolbar")?.textContent).toBe("Idle");
expect(container.querySelector(".codex-panel__region--message-stream .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("updates subscribed regions without rerunning node factories 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.messageStreamNode.mockClear();
renderers.composerNode.mockClear();
await act(async () => {
store.dispatch({ type: "connection/status-set", status: "Working" });
await settleShellEffects();
});
expect(container.querySelector(".test-toolbar")?.textContent).toBe("Working");
expect(renderers.toolbarNode).not.toHaveBeenCalled();
expect(renderers.goalNode).not.toHaveBeenCalled();
expect(renderers.messageStreamNode).not.toHaveBeenCalled();
expect(renderers.composerNode).not.toHaveBeenCalled();
expect(container.querySelector<HTMLTextAreaElement>(".codex-panel__region--composer textarea")?.value).toBe("composer only");
expect(composerRenderState).toHaveBeenCalled();
expect(messageStreamRenderState).not.toHaveBeenCalled();
expect(renameState).not.toHaveBeenCalled();
await act(async () => {
unmountChatPanelShell(container);
});
});
it("removes and restores the toolbar region from shell props", async () => {
it("removes and restores the toolbar from shell props without replacing the body regions", async () => {
const store = createChatStateStore();
const container = document.createElement("div");
document.body.appendChild(container);
const renderers = shellRenderers(store);
const slots = shellSlots(store);
await act(async () => {
renderChatPanelShell(container, { ...renderers, showToolbar: false });
renderChatPanelShell(container, { stateStore: store, showToolbar: false, slots });
await settleShellEffects();
});
@ -167,7 +121,7 @@ describe("ChatPanelShell", () => {
expect(container.querySelector(".codex-panel__region--composer")).not.toBeNull();
await act(async () => {
renderChatPanelShell(container, { ...renderers, showToolbar: true });
renderChatPanelShell(container, { stateStore: store, showToolbar: true, slots });
await settleShellEffects();
});
@ -179,6 +133,34 @@ describe("ChatPanelShell", () => {
});
});
it("repairs a damaged shell through the single root", async () => {
const store = createChatStateStore();
const container = document.createElement("div");
document.body.appendChild(container);
const slots = shellSlots(store);
await act(async () => {
renderChatPanelShell(container, { stateStore: store, showToolbar: true, slots });
await settleShellEffects();
});
container.querySelector<HTMLElement>(":scope .codex-panel__messages")?.remove();
await act(async () => {
store.dispatch({
type: "message-stream/system-item-added",
item: { id: "system-1", kind: "system", role: "system", text: "Restored." },
});
await settleShellEffects();
});
expect(container.querySelector(".codex-panel__messages .test-message-count")?.textContent).toBe("1");
await act(async () => {
unmountChatPanelShell(container);
});
});
it("sets composer bottom clearance only for fixed visible Obsidian status bars", async () => {
const store = createChatStateStore();
const container = document.createElement("div");
@ -194,21 +176,14 @@ describe("ChatPanelShell", () => {
await act(async () => {
statusBar.style.display = "flex";
statusBar.style.position = "fixed";
renderChatPanelShell(container, shellRenderers(store));
renderChatPanelShell(container, shellProps(store));
await settleShellEffects();
});
expect(container.style.getPropertyValue("--codex-panel-status-bar-clearance")).toBe("26px");
await act(async () => {
statusBar.style.position = "static";
renderChatPanelShell(container, shellRenderers(store));
await settleShellEffects();
});
expect(container.style.getPropertyValue("--codex-panel-status-bar-clearance")).toBe("0px");
await act(async () => {
statusBar.style.display = "none";
renderChatPanelShell(container, shellRenderers(store));
renderChatPanelShell(container, shellProps(store));
await settleShellEffects();
});
expect(container.style.getPropertyValue("--codex-panel-status-bar-clearance")).toBe("0px");
@ -218,293 +193,141 @@ describe("ChatPanelShell", () => {
});
statusBar.remove();
});
it("repairs a removed ui root without inspecting shell children", async () => {
const store = createChatStateStore();
const container = document.createElement("div");
document.body.appendChild(container);
const cleanup = vi.fn();
const renderers = trackedRootShellRenderers(store, cleanup);
await act(async () => {
renderChatPanelShell(container, renderers);
await settleShellEffects();
});
container.replaceChildren();
await act(async () => {
renderChatPanelShell(container, renderers);
await settleShellEffects();
});
expect(cleanup).toHaveBeenCalledWith("toolbar");
expect(container.textContent).toContain("toolbar");
expect(container.textContent).toContain("goal");
expect(container.textContent).toContain("messages");
expect(container.textContent).toContain("composer");
await act(async () => {
unmountChatPanelShell(container);
});
});
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("repairs damaged shell regions on subscribed 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();
});
container.querySelector<HTMLElement>(":scope .codex-panel__messages")?.remove();
await act(async () => {
store.dispatch({
type: "message-stream/system-item-added",
item: { id: "system-1", kind: "system", role: "system", text: "Restored." },
});
await settleShellEffects();
});
expect(cleanup).toHaveBeenCalledWith("messages");
expect(container.querySelector(".codex-panel__messages .test-messages")?.textContent).toBe("1");
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);
const cleanup = vi.fn();
const renderers = trackedRootShellRenderers(store, cleanup);
await act(async () => {
renderChatPanelShell(container, renderers);
await settleShellEffects();
});
await act(async () => {
unmountChatPanelShell(container);
await settleShellEffects();
});
expect(cleanup).toHaveBeenCalledWith("toolbar");
expect(cleanup).toHaveBeenCalledWith("goal");
expect(cleanup).toHaveBeenCalledWith("messages");
expect(cleanup).toHaveBeenCalledWith("composer");
});
it("stops subscribed region rendering after unmount", async () => {
const store = createChatStateStore();
const container = document.createElement("div");
document.body.appendChild(container);
const renderers = shellRenderers(store);
await act(async () => {
renderChatPanelShell(container, renderers);
await settleShellEffects();
});
renderers.toolbarNode.mockClear();
await act(async () => {
unmountChatPanelShell(container);
});
store.dispatch({ type: "connection/status-set", status: "Closed" });
await settleShellEffects();
expect(renderers.toolbarNode).not.toHaveBeenCalled();
});
});
function shellRenderers(store: ReturnType<typeof createChatStateStore>) {
function shellProps(store: ReturnType<typeof createChatStateStore>) {
return {
stateStore: store,
showToolbar: true,
toolbarNode: vi.fn(() => <ShellStatus />),
goalNode: vi.fn(() => <ShellGoal />),
messageStreamNode: vi.fn(() => (
<div className="codex-panel__region codex-panel__region--message-stream codex-panel__messages">
<ShellMessageCount />
</div>
)),
composerNode: vi.fn(() => <ShellComposerStatus />),
slots: shellSlots(store),
};
}
function nestedRootShellRenderers(store: ReturnType<typeof createChatStateStore>) {
interface ShellSlotsOptions {
toolbarRenameState?: () => null;
}
function shellSlots(store: ReturnType<typeof createChatStateStore>, options: ShellSlotsOptions = {}): ChatPanelShellSlots {
const ports = surfacePorts(store, options);
return {
stateStore: store,
showToolbar: true,
toolbarNode: vi.fn(() => (
<>
<ShellStatus className="test-toolbar" />
<div className="test-toolbar-panel">panel</div>
</>
)),
goalNode: vi.fn(() => <ShellGoal className="test-goal" />),
messageStreamNode: vi.fn(() => (
<div className="codex-panel__region codex-panel__region--message-stream codex-panel__messages">
<ShellMessageCount className="test-messages" />
</div>
)),
composerNode: vi.fn(() => (
<div className="test-composer">
<ShellComposerTextarea />
<button type="button">Send</button>
</div>
)),
toolbar: ports.toolbar,
goal: ports.goal,
messageStream: {
renderState: () => ({
blocks: [
{
key: "count",
node: <div className="test-message-count">{String(chatStateDisplayItems(store.getState()).length)}</div>,
},
],
consumeScrollIntent: () => "auto",
}),
},
composer: {
renderState: () => ({
viewId: "view",
draft: store.getState().composer.draft,
busy: false,
canInterrupt: false,
normalPlaceholder: "Ask Codex to work on this task...",
suggestions: [],
selectedSuggestionIndex: 0,
callbacks: {
onInput: vi.fn(),
onUpdateSuggestions: vi.fn(),
onKeydown: vi.fn(),
onSendOrInterrupt: vi.fn(),
onHeightChange: vi.fn(),
onSuggestionHover: vi.fn(),
onSuggestionInsert: vi.fn(),
},
meta: {
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,
modelChoices: [],
effortChoices: [],
},
onComposer: () => undefined,
}),
},
};
}
function trackedRootShellRenderers(store: ReturnType<typeof createChatStateStore>, cleanup: (region: string) => void) {
function surfacePorts(store: ReturnType<typeof createChatStateStore>, options: ShellSlotsOptions): ChatPanelSurfacePorts {
return {
stateStore: store,
showToolbar: true,
toolbarNode: vi.fn(() => <TrackedSlot region="toolbar" cleanup={cleanup} />),
goalNode: vi.fn(() => <TrackedSlot region="goal" cleanup={cleanup} />),
messageStreamNode: vi.fn(() => (
<div className="codex-panel__region codex-panel__region--message-stream codex-panel__messages">
<TrackedSlot region="messages" cleanup={cleanup} />
</div>
)),
composerNode: vi.fn(() => <TrackedSlot region="composer" cleanup={cleanup} />),
toolbar: {
state: {
connected: () => false,
},
settings: {
vaultPath: () => "/vault",
configuredCommand: () => "codex",
archiveExportEnabled: () => true,
},
view: {
toolbar: {
archiveConfirm: signal(null),
renameState: options.toolbarRenameState ?? (() => null),
renameVersion: signal(0),
},
},
actions: {
toolbar: {
startNewThread: vi.fn(),
toggleChatActions: vi.fn(),
compactConversation: vi.fn(),
setGoal: vi.fn(),
toggleHistory: vi.fn(),
toggleStatusPanel: vi.fn(),
connect: vi.fn(),
refreshStatus: vi.fn(),
resumeThread: vi.fn(),
startArchiveThread: vi.fn(),
archiveThread: vi.fn(),
startRenameThread: vi.fn(),
updateRenameDraft: vi.fn(),
saveRenameThread: vi.fn(),
cancelRenameThread: vi.fn(),
autoNameThread: vi.fn(),
},
},
},
goal: {
settings: { sendShortcut: () => "enter" },
actions: {
goal: {
saveObjective: async () => undefined,
setStatus: async () => undefined,
clear: async () => undefined,
setEditingOpen: () => undefined,
},
},
},
composer: {
thread: { restoredPlaceholder: () => null },
runtime: {
requestModel: async () => undefined,
requestReasoningEffort: async () => undefined,
resetReasoningEffortToConfig: async () => undefined,
},
},
};
}
function nodeShellRenderers(store: ReturnType<typeof createChatStateStore>, cleanup: (region: string) => void) {
return {
stateStore: store,
showToolbar: true,
toolbarNode: vi.fn(() => <TrackedStateSlot region="toolbar" cleanup={cleanup} className="test-toolbar" selector="status" />),
goalNode: vi.fn(() => <TrackedStateSlot region="goal" cleanup={cleanup} className="test-goal" selector="goal" />),
messageStreamNode: vi.fn(() => (
<div className="codex-panel__region codex-panel__region--message-stream codex-panel__messages">
<TrackedStateSlot region="messages" cleanup={cleanup} className="test-messages" selector="message-count" />
</div>
)),
composerNode: vi.fn(() => (
<TrackedStateSlot region="composer" cleanup={cleanup} className="test-composer" selector="composer-status" />
)),
};
}
function TrackedSlot({
region,
cleanup,
className,
text = region,
}: {
region: string;
cleanup: (region: string) => void;
className?: string;
text?: string;
}) {
useEffect(() => {
return () => {
cleanup(region);
};
}, [cleanup, region]);
return <div className={className}>{text}</div>;
}
function TrackedStateSlot({
region,
cleanup,
className,
selector,
}: {
region: string;
cleanup: (region: string) => void;
className?: string;
selector: "status" | "goal" | "message-count" | "composer-status";
}) {
return <TrackedSlot region={region} cleanup={cleanup} {...(className ? { className } : {})} text={useShellText(selector)} />;
}
function ShellStatus({ className }: { className?: string }) {
const { connection } = useChatPanelShellState();
return <div className={className}>{connection.value.status}</div>;
}
function ShellGoal({ className }: { className?: string }) {
const { activeThread } = useChatPanelShellState();
return <div className={className}>{activeThread.value.goal?.objective ?? "no goal"}</div>;
}
function ShellMessageCount({ className }: { className?: string }) {
const { messageStream } = useChatPanelShellState();
return <div className={className}>{String(messageStream.value.displayItems.length)}</div>;
}
function ShellComposerStatus() {
return <div>{useShellText("composer-status")}</div>;
}
function ShellComposerTextarea() {
return <textarea value={useShellText("composer-status")} readOnly />;
}
function useShellText(selector: "status" | "goal" | "message-count" | "composer-status"): string {
const shellState = useChatPanelShellState();
switch (selector) {
case "status":
return shellState.connection.value.status;
case "goal":
return shellState.activeThread.value.goal?.objective ?? "no goal";
case "message-count":
return String(shellState.messageStream.value.displayItems.length);
case "composer-status":
return chatTurnBusy({ ...shellState.latestState(), turn: shellState.turn.value }) ? "busy" : "ready";
}
}
async function settleShellEffects(): Promise<void> {
await Promise.resolve();
await Promise.resolve();

View file

@ -2,19 +2,18 @@
import { describe, expect, it, vi } from "vitest";
import { act } from "preact/test-utils";
import { signal } from "@preact/signals";
import type { Thread } from "../../../../src/domain/threads/model";
import { createChatStateStore } from "../../../../src/features/chat/state/reducer";
import { runtimeSnapshotForChatState } from "../../../../src/features/chat/runtime/snapshot";
import {
createToolbarArchiveConfirmState,
createToolbarPanelActions,
type ToolbarPanelActions,
} from "../../../../src/features/chat/panel/regions/toolbar";
import type { ChatPanelToolbarPorts } from "../../../../src/features/chat/panel/regions/ports";
} from "../../../../src/features/chat/panel/toolbar-actions";
import type { ChatPanelSurfacePorts, ChatPanelToolbarPorts } from "../../../../src/features/chat/panel/surface/ports";
import type { ChatThreadActions } from "../../../../src/features/chat/threads/action-context";
import { renderChatPanelShell, unmountChatPanelShell } from "../../../../src/features/chat/ui/shell";
import { chatPanelToolbarRegionNode } from "../../../../src/features/chat/panel/regions/toolbar";
import { renderChatPanelShell, unmountChatPanelShell, type ChatPanelShellSlots } from "../../../../src/features/chat/ui/shell";
import { installObsidianDomShims } from "../../../support/dom";
installObsidianDomShims();
@ -40,10 +39,7 @@ describe("chat toolbar archive confirmation signal", () => {
renderChatPanelShell(container, {
stateStore: store,
showToolbar: true,
toolbarNode: () => chatPanelToolbarRegionNode(toolbarPorts(store, toolbarActions)),
goalNode: () => null,
messageStreamNode: () => <div className="codex-panel__region codex-panel__region--message-stream codex-panel__messages" />,
composerNode: () => null,
slots: shellSlots(store, toolbarActions),
});
await settle();
});
@ -67,24 +63,18 @@ describe("chat toolbar archive confirmation signal", () => {
function toolbarPorts(store: ReturnType<typeof createChatStateStore>, toolbarActions: ToolbarPanelActions): ChatPanelToolbarPorts {
return {
state: {
chat: () => store.getState(),
connected: () => false,
turnBusy: () => false,
},
settings: {
vaultPath: () => "/vault",
configuredCommand: () => "codex",
archiveExportEnabled: () => true,
},
runtime: {
snapshot: () => runtimeSnapshotForChatState(store.getState()),
},
view: {
toolbar: {
archiveConfirmId: () => toolbarActions.archiveConfirmId(),
archiveConfirmSubscribe: (listener) => toolbarActions.onArchiveConfirmChange(listener),
archiveConfirm: toolbarActions.archiveConfirm,
renameState: () => null,
renameSubscribe: () => () => undefined,
renameVersion: signal(0),
},
},
actions: {
@ -112,6 +102,86 @@ function toolbarPorts(store: ReturnType<typeof createChatStateStore>, toolbarAct
};
}
function shellSlots(store: ReturnType<typeof createChatStateStore>, toolbarActions: ToolbarPanelActions): ChatPanelShellSlots {
const ports = surfacePorts(store, toolbarActions);
return {
toolbar: ports.toolbar,
goal: ports.goal,
messageStream: {
renderState: () => ({
blocks: [],
consumeScrollIntent: () => "auto",
}),
},
composer: {
renderState: () => ({
viewId: "view",
draft: "",
busy: false,
canInterrupt: false,
normalPlaceholder: "Ask Codex to work on this task...",
suggestions: [],
selectedSuggestionIndex: 0,
callbacks: {
onInput: vi.fn(),
onUpdateSuggestions: vi.fn(),
onKeydown: vi.fn(),
onSendOrInterrupt: vi.fn(),
onHeightChange: vi.fn(),
onSuggestionHover: vi.fn(),
onSuggestionInsert: vi.fn(),
},
meta: {
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,
modelChoices: [],
effortChoices: [],
},
onComposer: () => undefined,
}),
},
};
}
function surfacePorts(store: ReturnType<typeof createChatStateStore>, toolbarActions: ToolbarPanelActions): ChatPanelSurfacePorts {
return {
toolbar: toolbarPorts(store, toolbarActions),
goal: {
settings: { sendShortcut: () => "enter" },
actions: {
goal: {
saveObjective: async () => undefined,
setStatus: async () => undefined,
clear: async () => undefined,
setEditingOpen: () => undefined,
},
},
},
composer: {
thread: { restoredPlaceholder: () => null },
runtime: {
requestModel: async () => undefined,
requestReasoningEffort: async () => undefined,
resetReasoningEffortToConfig: async () => undefined,
},
},
};
}
function threadFixture(id: string, name: string): Thread {
return {
id,

View file

@ -12,6 +12,7 @@ import type { ServerNotification } from "../../../src/app-server/connection/rpc-
import { notices } from "../../mocks/obsidian";
import { deferred, waitForAsyncWork } from "../../support/async";
import { installObsidianDomShims } from "../../support/dom";
import { chatStateDisplayItems } from "./support/message-stream";
const ESTIMATED_MESSAGE_BLOCK_HEIGHT = 96;
@ -256,7 +257,7 @@ describe("CodexChatView connection lifecycle", () => {
});
expect((view as unknown as { state: ChatState }).state.activeThread.id).toBe("thread-new");
expect((view as unknown as { state: ChatState }).state.activeThread.goal?.objective).toBe("Ship the feature");
expect((view as unknown as { state: ChatState }).state.messageStream.displayItems).toContainEqual(
expect(chatStateDisplayItems((view as unknown as { state: ChatState }).state)).toContainEqual(
expect.objectContaining({ kind: "goal", text: "set: Ship the feature", objective: "Ship the feature" }),
);
expect(view.containerEl.textContent).toContain("Ship the feature");

View file

@ -1,4 +1,4 @@
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import { createServerDiagnostics } from "../../../src/domain/server/diagnostics";
import {
@ -7,18 +7,19 @@ import {
type RuntimeConfigSnapshot,
} from "../../../src/app-server/protocol/runtime-config";
import { createChatState } from "../../../src/features/chat/state/reducer";
import { composerMetaViewModel, composerPlaceholder } from "../../../src/features/chat/panel/regions/composer";
import { composerMetaViewModel, composerPlaceholder } from "../../../src/features/chat/panel/surface/composer";
import { effortStatusLines, modelStatusLines, statusSummaryLines } from "../../../src/features/chat/display/status/runtime";
import { runtimeComposerChoices } from "../../../src/features/chat/panel/regions/composer";
import { runtimeComposerChoices } from "../../../src/features/chat/panel/surface/composer";
import { runtimeSnapshotForChatState } from "../../../src/features/chat/runtime/snapshot";
import { toolbarViewModel } from "../../../src/features/chat/panel/regions/toolbar";
import { toolbarViewModel } from "../../../src/features/chat/panel/surface/toolbar";
import type { ChatState } from "../../../src/features/chat/state/reducer";
import type { ModelMetadata } from "../../../src/domain/catalog/metadata";
import type { Thread } from "../../../src/domain/threads/model";
import { chatPanelComposerMetaViewModel, chatPanelComposerPlaceholder } from "../../../src/features/chat/panel/regions/composer";
import { chatPanelGoalProps } from "../../../src/features/chat/panel/regions/goal";
import type { ChatPanelComposerPorts, ChatPanelGoalPorts } from "../../../src/features/chat/panel/regions/ports";
import { chatPanelComposerMetaViewModel, chatPanelComposerPlaceholder } from "../../../src/features/chat/panel/surface/composer";
import { chatPanelGoalProps } from "../../../src/features/chat/panel/surface/goal";
import type { ChatPanelComposerPorts, ChatPanelGoalPorts } from "../../../src/features/chat/panel/surface/ports";
import type { ThreadGoal } from "../../../src/domain/threads/goal";
import { setChatStateDisplayItems } from "./support/message-stream";
describe("chat view model", () => {
it("builds toolbar rows from immutable chat state snapshots", () => {
@ -39,7 +40,8 @@ describe("chat view model", () => {
configuredCommand: "codex",
archiveConfirmThreadId: "thread-2",
archiveExportEnabled: true,
renameState: (threadId) => (threadId === "thread-1" ? { draft: "Active", generating: false } : null),
renameRevision: 1,
renameState: (threadId, _renameRevision) => (threadId === "thread-1" ? { draft: "Active", generating: false } : null),
});
expect(model.openPanel).toBe("history");
@ -89,7 +91,7 @@ describe("chat view model", () => {
it("uses a neutral composer context indicator when usage is unavailable", () => {
const state = createChatState();
state.activeThread.id = "thread-1";
state.messageStream.displayItems = [
setChatStateDisplayItems(state, [
{
id: "item",
turnId: "turn-1",
@ -99,7 +101,7 @@ describe("chat view model", () => {
text: "Existing turn",
role: "assistant",
},
];
]);
state.connection.runtimeConfig = runtimeConfigFixture({ model: "gpt-5.5" });
expect(composerMetaViewModel(state, runtimeSnapshotFixture(state))).toMatchObject({
@ -250,11 +252,6 @@ describe("chat view model", () => {
const statuses: [string, string][] = [];
const clears: string[] = [];
const ports = {
state: {
chat: () => {
throw new Error("Goal status actions should not reread the active thread.");
},
},
settings: {
sendShortcut: () => "enter",
},
@ -285,29 +282,25 @@ describe("chat view model", () => {
expect(clears).toEqual(["thread-rendered"]);
});
it("builds composer meta from one captured chat state and runtime snapshot", () => {
it("builds composer meta from one captured chat state", () => {
const state = createChatState();
state.connection.runtimeConfig = runtimeConfigFixture({ model: "gpt-5.5", model_reasoning_effort: "high" });
state.connection.availableModels = [modelFixture("gpt-5.5")];
const snapshot = runtimeSnapshotFixture(state);
const chat = vi.fn(() => state);
const runtimeSnapshot = vi.fn(() => snapshot);
const model = chatPanelComposerMetaViewModel({
state: { chat },
thread: {
restoredPlaceholder: () => null,
},
runtime: {
snapshot: runtimeSnapshot,
requestModel: async () => undefined,
requestReasoningEffort: async () => undefined,
resetReasoningEffortToConfig: async () => undefined,
},
} satisfies ChatPanelComposerPorts);
const model = chatPanelComposerMetaViewModel(
{
thread: {
restoredPlaceholder: () => null,
},
runtime: {
requestModel: async () => undefined,
requestReasoningEffort: async () => undefined,
resetReasoningEffortToConfig: async () => undefined,
},
} satisfies ChatPanelComposerPorts,
state,
);
expect(chat).toHaveBeenCalledOnce();
expect(runtimeSnapshot).toHaveBeenCalledOnce();
expect(model).toMatchObject({
model: "gpt-5.5",
effort: "high",
@ -330,18 +323,19 @@ describe("chat view model", () => {
state.threadList.listedThreads = [threadFixture("thread-1", null)];
expect(
chatPanelComposerPlaceholder({
state: { chat: () => state },
thread: {
restoredPlaceholder: () => ({ threadId: "thread-1", title: "Restored", explicitName: "Restored" }),
},
runtime: {
snapshot: () => runtimeSnapshotFixture(state),
requestModel: async () => undefined,
requestReasoningEffort: async () => undefined,
resetReasoningEffortToConfig: async () => undefined,
},
} satisfies ChatPanelComposerPorts),
chatPanelComposerPlaceholder(
{
thread: {
restoredPlaceholder: () => ({ threadId: "thread-1", title: "Restored", explicitName: "Restored" }),
},
runtime: {
requestModel: async () => undefined,
requestReasoningEffort: async () => undefined,
resetReasoningEffortToConfig: async () => undefined,
},
} satisfies ChatPanelComposerPorts,
state,
),
).toBe("Ask Codex to work on “Restored”...");
});
});

View file

@ -0,0 +1,116 @@
import path from "node:path";
import { ESLint } from "eslint";
import { describe, expect, it } from "vitest";
const repoRoot = process.cwd();
describe("eslint config", () => {
it("reports imperative DOM writes in non-bridge source files", async () => {
const messages = await lintSource(
"src/features/chat/runtime/effective.ts",
`
export function setStatus(element: HTMLElement): void {
element.textContent = "Loading";
}
`,
);
expect(messages).toContain("codex-panel/no-imperative-dom");
});
it("reports Obsidian HTMLElement mutation helpers in non-bridge source files", async () => {
const messages = await lintSource(
"src/features/chat/runtime/effective.ts",
`
export function setStatus(element: HTMLElement): void {
element.addClass("is-ready");
}
`,
);
expect(messages).toContain("codex-panel/no-imperative-dom");
});
it("does not confuse plain value properties with DOM writes", async () => {
const messages = await lintSource(
"src/features/chat/runtime/effective.ts",
`
interface Box {
value: string;
}
export function setBoxValue(box: Box): void {
box.value = "ready";
}
`,
);
expect(messages).not.toContain("codex-panel/no-imperative-dom");
});
it("does not confuse signal value writes with DOM writes", async () => {
const messages = await lintSource(
"src/features/chat/runtime/effective.ts",
`
import { signal } from "@preact/signals";
export function setSignalStatus(): string {
const status = signal("idle");
status.value = "ready";
return status.value;
}
`,
);
expect(messages).not.toContain("codex-panel/no-imperative-dom");
});
it("allows event wiring but not DOM writes in event-only bridge files", async () => {
const messages = await lintSource(
"src/shared/lifecycle/abortable.ts",
`
export function attach(signal: AbortSignal, element: HTMLElement): void {
signal.addEventListener("abort", () => undefined);
element.replaceChildren();
}
`,
);
expect(messages.filter((message) => message === "codex-panel/no-imperative-dom")).toHaveLength(1);
});
it("allows DOM root attachment in explicit Preact renderer bridge files", async () => {
const messages = await lintSource(
"src/features/threads-view/renderer.tsx",
`
export function renderThreadsView(parent: HTMLElement): void {
parent.addClass("codex-panel-threads");
}
`,
);
expect(messages).not.toContain("codex-panel/no-imperative-dom");
});
it("keeps all chat state modules deterministic", async () => {
const messages = await lintSource(
"src/features/chat/state/message-stream.ts",
`
export function timestamp(): number {
return Date.now();
}
`,
);
expect(messages).toContain("no-restricted-syntax");
});
});
async function lintSource(filePath: string, source: string): Promise<string[]> {
const eslint = new ESLint({
cwd: repoRoot,
overrideConfigFile: path.join(repoRoot, "eslint.config.mjs"),
});
const [result] = await eslint.lintText(source, { filePath: path.join(repoRoot, filePath) });
return (result?.messages ?? []).map((message) => message.ruleId ?? message.message);
}