From ed93f7a6345ee1b30e421b066097cfce2169fe76 Mon Sep 17 00:00:00 2001 From: murashit Date: Sat, 13 Jun 2026 09:57:13 +0900 Subject: [PATCH] Complete single Preact chat shell integration --- eslint.config.mjs | 193 +++++- package.json | 2 +- .../{composition.ts => controllers.ts} | 10 +- ...mposition-ports.ts => controller-ports.ts} | 14 +- .../chat/{composition.ts => controllers.ts} | 32 +- .../chat/conversation/composer/controller.ts | 39 +- .../{composition.ts => controllers.ts} | 21 +- .../panel/{composition.ts => controllers.ts} | 17 +- .../chat/panel/regions/message-stream.ts | 31 - src/features/chat/panel/regions/toolbar.ts | 293 --------- .../panel/{regions => surface}/composer.ts | 60 +- .../create-ports.ts} | 37 +- .../chat/panel/{regions => surface}/goal.ts | 33 +- .../surface/message-stream-ports.ts} | 0 .../surface/message-stream-renderer.ts} | 26 +- .../chat/panel/surface/message-stream.ts | 14 + .../chat/panel/{regions => surface}/ports.ts | 35 +- src/features/chat/panel/surface/toolbar.ts | 130 ++++ src/features/chat/panel/toolbar-actions.ts | 134 +++++ src/features/chat/runtime/snapshot.ts | 2 +- src/features/chat/state/message-stream.ts | 38 +- src/features/chat/state/reducer.ts | 3 +- src/features/chat/state/selectors.ts | 10 +- .../{composition.ts => controllers.ts} | 15 +- .../chat/threads/rename-controller.ts | 12 +- src/features/chat/ui/composer.tsx | 77 +-- src/features/chat/ui/goal.tsx | 14 +- .../chat/ui/message-stream/viewport.tsx | 6 +- src/features/chat/ui/shell-state.tsx | 107 ++++ src/features/chat/ui/shell.tsx | 101 +--- src/features/chat/ui/toolbar.tsx | 6 +- src/features/chat/view.ts | 41 +- .../conversation/composer/controller.test.ts | 54 +- .../turns/turn-submission-controller.test.ts | 9 +- .../surface/message-stream-ports.test.ts} | 2 +- .../surface/message-stream-renderer.test.ts} | 106 ++-- .../{regions => surface}/toolbar.test.ts | 2 +- .../chat/protocol/inbound/controller.test.ts | 171 +++--- tests/features/chat/state-reducer.test.ts | 49 +- tests/features/chat/support/message-stream.ts | 11 + tests/features/chat/threads/actions.test.ts | 6 +- .../chat/threads/history-controller.test.ts | 9 +- .../chat/threads/rename-controller.test.ts | 15 +- .../blocks-and-messages.test.tsx | 3 +- .../chat/ui/message-stream/test-helpers.tsx | 12 +- .../chat/ui/renderers/composer.test.ts | 60 +- .../features/chat/ui/renderers/goal.test.tsx | 13 +- .../chat/ui/renderers/toolbar.test.ts | 32 +- tests/features/chat/ui/shell.test.tsx | 567 ++++++------------ .../chat/ui/toolbar-archive-signal.test.tsx | 104 +++- tests/features/chat/view-connection.test.ts | 3 +- tests/features/chat/view-model.test.ts | 84 ++- tests/scripts/eslint-config.test.ts | 116 ++++ 53 files changed, 1522 insertions(+), 1459 deletions(-) rename src/features/chat/connection/{composition.ts => controllers.ts} (94%) rename src/features/chat/{composition-ports.ts => controller-ports.ts} (89%) rename src/features/chat/{composition.ts => controllers.ts} (93%) rename src/features/chat/conversation/{composition.ts => controllers.ts} (92%) rename src/features/chat/panel/{composition.ts => controllers.ts} (90%) delete mode 100644 src/features/chat/panel/regions/message-stream.ts delete mode 100644 src/features/chat/panel/regions/toolbar.ts rename src/features/chat/panel/{regions => surface}/composer.ts (79%) rename src/features/chat/panel/{regions/composition.ts => surface/create-ports.ts} (80%) rename src/features/chat/panel/{regions => surface}/goal.ts (52%) rename src/features/chat/{ui/message-stream/ports.ts => panel/surface/message-stream-ports.ts} (100%) rename src/features/chat/{ui/message-stream/renderer.ts => panel/surface/message-stream-renderer.ts} (86%) create mode 100644 src/features/chat/panel/surface/message-stream.ts rename src/features/chat/panel/{regions => surface}/ports.ts (62%) create mode 100644 src/features/chat/panel/surface/toolbar.ts create mode 100644 src/features/chat/panel/toolbar-actions.ts rename src/features/chat/threads/{composition.ts => controllers.ts} (92%) create mode 100644 src/features/chat/ui/shell-state.tsx rename tests/features/chat/{ui/message-stream/ports.test.ts => panel/surface/message-stream-ports.test.ts} (98%) rename tests/features/chat/{ui/message-stream/renderer.test.ts => panel/surface/message-stream-renderer.test.ts} (89%) rename tests/features/chat/panel/{regions => surface}/toolbar.test.ts (96%) create mode 100644 tests/features/chat/support/message-stream.ts create mode 100644 tests/scripts/eslint-config.test.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index 4ab150a5..10954e3f 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -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", }, }, diff --git a/package.json b/package.json index cd4276bf..8f563f07 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/features/chat/connection/composition.ts b/src/features/chat/connection/controllers.ts similarity index 94% rename from src/features/chat/connection/composition.ts rename to src/features/chat/connection/controllers.ts index 7b4e7664..d8799e7d 100644 --- a/src/features/chat/connection/composition.ts +++ b/src/features/chat/connection/controllers.ts @@ -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; +type ChatServerActionControllerPorts = Pick; export function createChatServerActionControllers( context: ChatServerActionControllerPorts, @@ -59,7 +59,7 @@ export function createChatServerActionControllers( return { serverThreads, serverMetadata, serverDiagnostics }; } -type ChatInboundControllerPorts = Pick & { +type ChatInboundControllerPorts = Pick & { render: { schedule: () => void; }; @@ -105,7 +105,7 @@ export function createChatInboundController( }); } -type ChatConnectionControllerPorts = Pick & { +type ChatConnectionControllerPorts = Pick & { client: { setClient: (client: ReturnType) => void; }; @@ -176,7 +176,7 @@ export function createChatConnectionControllers( }; } -type ChatReconnectControllerGroupPorts = Pick & { +type ChatReconnectControllerGroupPorts = Pick & { client: { clear: () => void; ensureConnected: () => Promise; diff --git a/src/features/chat/composition-ports.ts b/src/features/chat/controller-ports.ts similarity index 89% rename from src/features/chat/composition-ports.ts rename to src/features/chat/controller-ports.ts index f0923999..06b8cea3 100644 --- a/src/features/chat/composition-ports.ts +++ b/src/features/chat/controller-ports.ts @@ -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 { diff --git a/src/features/chat/composition.ts b/src/features/chat/controllers.ts similarity index 93% rename from src/features/chat/composition.ts rename to src/features/chat/controllers.ts index 692e9b96..38594f0c 100644 --- a/src/features/chat/composition.ts +++ b/src/features/chat/controllers.ts @@ -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 & { +interface ChatControllerSideEffects { + render: Pick & { 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(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, +function createChatControllerSideEffects( + ports: Pick, deps: { renderNow: () => void; setComposerText: (text: string) => void; }, -): ChatCompositionSideEffects { +): ChatControllerSideEffects { const render = { panelRoot: ports.render.panelRoot, closeToolbarPanelOnOutsidePointer: ports.render.closeToolbarPanelOnOutsidePointer, diff --git a/src/features/chat/conversation/composer/controller.ts b/src/features/chat/conversation/composer/controller.ts index 54d32c88..0823d9a1 100644 --- a/src/features/chat/conversation/composer/controller.ts +++ b/src/features/chat/conversation/composer/controller.ts @@ -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 => { diff --git a/src/features/chat/conversation/composition.ts b/src/features/chat/conversation/controllers.ts similarity index 92% rename from src/features/chat/conversation/composition.ts rename to src/features/chat/conversation/controllers.ts index c2869df3..437d3fa7 100644 --- a/src/features/chat/conversation/composition.ts +++ b/src/features/chat/conversation/controllers.ts @@ -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; }; render: { @@ -35,15 +35,15 @@ type ConversationSurfaceControllerGroupPorts = Pick< addSystemMessage: (text: string) => void; addStructuredSystemMessage: (text: string, details: DisplayDetailSection[]) => void; }; - scroll: Pick; + scroll: Pick; thread: { - ensureRestoredThreadLoaded: ChatControllerCompositionPorts["thread"]["ensureRestoredThreadLoaded"]; - startNewThread: ChatControllerCompositionPorts["thread"]["startNewThread"]; + ensureRestoredThreadLoaded: ChatControllerPorts["thread"]["ensureRestoredThreadLoaded"]; + startNewThread: ChatControllerPorts["thread"]["startNewThread"]; selectThread: (threadId: string) => Promise; notifyIdentityChanged: () => void; resetTurnPresence: (hadTurns: boolean) => void; }; - runtime: ChatControllerCompositionPorts["runtime"] & { + runtime: ChatControllerPorts["runtime"] & { mcpStatusLines: () => Promise; }; }; @@ -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, diff --git a/src/features/chat/panel/composition.ts b/src/features/chat/panel/controllers.ts similarity index 90% rename from src/features/chat/panel/composition.ts rename to src/features/chat/panel/controllers.ts index 6c0f59e4..1c30eb6e 100644 --- a/src/features/chat/panel/composition.ts +++ b/src/features/chat/panel/controllers.ts @@ -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; +type ChatViewRendererPorts = Pick; 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 & { +type ConnectionLifecycleControllerGroupPorts = Pick & { obsidian: Pick; plugin: CachedSharedAppServerStateSource; client: { @@ -137,7 +134,7 @@ export function createConnectionLifecycleControllerGroup( }; } -type PanelUiControllerGroupPorts = Pick & { +type PanelUiControllerGroupPorts = Pick & { lifecycle: { invalidateResumeWork: () => void; clearDeferredRestoredThreadHydration: () => void; diff --git a/src/features/chat/panel/regions/message-stream.ts b/src/features/chat/panel/regions/message-stream.ts deleted file mode 100644 index f22587e3..00000000 --- a/src/features/chat/panel/regions/message-stream.ts +++ /dev/null @@ -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); -} diff --git a/src/features/chat/panel/regions/toolbar.ts b/src/features/chat/panel/regions/toolbar.ts deleted file mode 100644 index f2411253..00000000 --- a/src/features/chat/panel/regions/toolbar.ts +++ /dev/null @@ -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; - 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 { - 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 { - 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); - }; - }, - }; -} diff --git a/src/features/chat/panel/regions/composer.ts b/src/features/chat/panel/surface/composer.ts similarity index 79% rename from src/features/chat/panel/regions/composer.ts rename to src/features/chat/panel/surface/composer.ts index fedd000c..b98571e5 100644 --- a/src/features/chat/panel/regions/composer.ts +++ b/src/features/chat/panel/surface/composer.ts @@ -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; + +export interface ChatPanelComposerController { + renderState(state: ChatPanelComposerShellState): ComposerShellProps; +} + export interface RuntimeComposerChoicesInput { - state: ChatState; + state: Pick; 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, + restoredThread: RestoredThreadTitleSnapshot | null, +): string | null { const threadId = state.activeThread.id; if (!threadId) return null; const thread = state.threadList.listedThreads.find((item) => item.id === threadId); diff --git a/src/features/chat/panel/regions/composition.ts b/src/features/chat/panel/surface/create-ports.ts similarity index 80% rename from src/features/chat/panel/regions/composition.ts rename to src/features/chat/panel/surface/create-ports.ts index 2d3f8108..e0b18f25 100644 --- a/src/features/chat/panel/regions/composition.ts +++ b/src/features/chat/panel/surface/create-ports.ts @@ -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; } -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(), diff --git a/src/features/chat/panel/regions/goal.ts b/src/features/chat/panel/surface/goal.ts similarity index 52% rename from src/features/chat/panel/regions/goal.ts rename to src/features/chat/panel/surface/goal.ts index 00219182..5ea52c0d 100644 --- a/src/features/chat/panel/regions/goal.ts +++ b/src/features/chat/panel/surface/goal.ts @@ -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, + state: ChatPanelGoalShellState, ): { - goal: ReturnType["activeThread"]["goal"]; - actions: GoalRegionActions; - options: GoalRegionOptions; + goal: ChatPanelGoalShellState["activeThread"]["goal"]; + actions: GoalPanelActions; + options: GoalPanelOptions; } { const goal = state.activeThread.goal; const goalThreadId = goal?.threadId ?? null; diff --git a/src/features/chat/ui/message-stream/ports.ts b/src/features/chat/panel/surface/message-stream-ports.ts similarity index 100% rename from src/features/chat/ui/message-stream/ports.ts rename to src/features/chat/panel/surface/message-stream-ports.ts diff --git a/src/features/chat/ui/message-stream/renderer.ts b/src/features/chat/panel/surface/message-stream-renderer.ts similarity index 86% rename from src/features/chat/ui/message-stream/renderer.ts rename to src/features/chat/panel/surface/message-stream-renderer.ts index a9ffaf41..896ba2f3 100644 --- a/src/features/chat/ui/message-stream/renderer.ts +++ b/src/features/chat/panel/surface/message-stream-renderer.ts @@ -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); diff --git a/src/features/chat/panel/surface/message-stream.ts b/src/features/chat/panel/surface/message-stream.ts new file mode 100644 index 00000000..c3b381e5 --- /dev/null +++ b/src/features/chat/panel/surface/message-stream.ts @@ -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) }); +} diff --git a/src/features/chat/panel/regions/ports.ts b/src/features/chat/panel/surface/ports.ts similarity index 62% rename from src/features/chat/panel/regions/ports.ts rename to src/features/chat/panel/surface/ports.ts index 126fdbbe..90e24f11 100644 --- a/src/features/chat/panel/regions/ports.ts +++ b/src/features/chat/panel/surface/ports.ts @@ -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; renameState: (threadId: string) => ToolbarThreadRow["rename"]; - renameSubscribe: (listener: () => void) => () => void; + renameVersion: Signal; } 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; requestReasoningEffort: (effort: ReasoningEffort) => Promise; resetReasoningEffortToConfig: () => Promise; }; } -export interface ChatPanelRegionPorts { +export interface ChatPanelSurfacePorts { toolbar: ChatPanelToolbarPorts; goal: ChatPanelGoalPorts; - messageStream: ChatPanelMessageStreamPorts; composer: ChatPanelComposerPorts; } diff --git a/src/features/chat/panel/surface/toolbar.ts b/src/features/chat/panel/surface/toolbar.ts new file mode 100644 index 00000000..3f65afe7 --- /dev/null +++ b/src/features/chat/panel/surface/toolbar.ts @@ -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; + +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; + 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 { + return connectionDiagnosticSections({ + connected: input.connected, + configuredCommand: input.configuredCommand, + initializeResponse: input.state.connection.initializeResponse, + diagnostics: input.state.connection.serverDiagnostics, + }); +} diff --git a/src/features/chat/panel/toolbar-actions.ts b/src/features/chat/panel/toolbar-actions.ts new file mode 100644 index 00000000..7b77476a --- /dev/null +++ b/src/features/chat/panel/toolbar-actions.ts @@ -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; + get: () => string | null; + set: (threadId: string | null) => void; +} + +export interface ToolbarPanelActions { + archiveConfirm: Signal; + archiveConfirmId(): string | null; + toggleHistory(): void; + toggleChatActions(): void; + closeToolbarPanels(): void; + toggleStatus(): void; + closeForThreadSelection(): void; + startArchive(threadId: string): void; + archiveThread(threadId: string, saveMarkdown: boolean): Promise; + 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 { + 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(null); + return { + id, + get: () => id.value, + set: (nextThreadId) => { + if (id.value === nextThreadId) return; + id.value = nextThreadId; + }, + }; +} diff --git a/src/features/chat/runtime/snapshot.ts b/src/features/chat/runtime/snapshot.ts index 457c1ba2..adc693f1 100644 --- a/src/features/chat/runtime/snapshot.ts +++ b/src/features/chat/runtime/snapshot.ts @@ -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, diff --git a/src/features/chat/state/message-stream.ts b/src/features/chat/state/message-stream.ts index c8edd176..ba5538fc 100644 --- a/src/features/chat/state/message-stream.ts +++ b/src/features/chat/state/message-stream.ts @@ -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; @@ -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): 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): 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, turnId: string function patchObject(current: T, patch: Partial): 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: Key, value: Value | undefined): Partial> { return value === undefined ? {} : ({ [key]: value } as Partial>); } - -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 { - 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; -} diff --git a/src/features/chat/state/reducer.ts b/src/features/chat/state/reducer.ts index 0df402da..94b8de4b 100644 --- a/src/features/chat/state/reducer.ts +++ b/src/features/chat/state/reducer.ts @@ -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; diff --git a/src/features/chat/state/selectors.ts b/src/features/chat/state/selectors.ts index f8c0553b..dd6f54f2 100644 --- a/src/features/chat/state/selectors.ts +++ b/src/features/chat/state/selectors.ts @@ -55,20 +55,14 @@ export function implementPlanCandidateFromState(state: { activeThread: Pick; turn: ChatTurnState; runtime: Pick; - messageStream: Pick | Pick; + messageStream: Pick; }): 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 | Pick, -): readonly DisplayItem[] { - return "stableItems" in messageStream ? messageStreamDisplayItems(messageStream) : messageStream.displayItems; -} diff --git a/src/features/chat/threads/composition.ts b/src/features/chat/threads/controllers.ts similarity index 92% rename from src/features/chat/threads/composition.ts rename to src/features/chat/threads/controllers.ts index 6fa8f188..5ba6060a 100644 --- a/src/features/chat/threads/composition.ts +++ b/src/features/chat/threads/controllers.ts @@ -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 & { client: { - getClient: ChatControllerCompositionPorts["client"]["getClient"]; + getClient: ChatControllerPorts["client"]["getClient"]; ensureConnected: () => Promise; }; lifecycle: { @@ -41,7 +38,7 @@ type ThreadControllerGroupPorts = Pick< set: (status: string) => void; addSystemMessage: (text: string) => void; }; - scroll: Pick; + scroll: Pick; render: { now: () => void; }; @@ -199,11 +196,11 @@ export function createThreadControllerGroup( } function requireThreadController(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 & { +type ThreadSelectionActionGroupPorts = Pick & { status: { addSystemMessage: (text: string) => void; }; diff --git a/src/features/chat/threads/rename-controller.ts b/src/features/chat/threads/rename-controller.ts index f544fb78..ba2998f8 100644 --- a/src/features/chat/threads/rename-controller.ts +++ b/src/features/chat/threads/rename-controller.ts @@ -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 { diff --git a/src/features/chat/ui/composer.tsx b/src/features/chat/ui/composer.tsx index 79370cff..e7a9fcf1 100644 --- a/src/features/chat/ui/composer.tsx +++ b/src/features/chat/ui/composer.tsx @@ -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 ( - - ); -} - -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(null); const suggestionsRef = useRef(null); const selectedSuggestionRef = useRef(null); diff --git a/src/features/chat/ui/goal.tsx b/src/features/chat/ui/goal.tsx index dfc933a7..1b277ded 100644 --- a/src/features/chat/ui/goal.tsx +++ b/src/features/chat/ui/goal.tsx @@ -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 ; -} - -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 ?? ""); diff --git a/src/features/chat/ui/message-stream/viewport.tsx b/src/features/chat/ui/message-stream/viewport.tsx index f6964340..1cc2de46 100644 --- a/src/features/chat/ui/message-stream/viewport.tsx +++ b/src/features/chat/ui/message-stream/viewport.tsx @@ -14,11 +14,7 @@ export interface MessageStreamViewportState { registerVirtualizer?: (virtualizer: MessageStreamVirtualizerHandle) => () => void; } -export function messageStreamViewportNode(state: MessageStreamViewportState): UiNode { - return ; -} - -function MessageStreamViewport({ state }: { state: MessageStreamViewportState }): UiNode { +export function MessageStreamViewport({ state }: { state: MessageStreamViewportState }): UiNode { const { blocks, consumeScrollIntent, registerVirtualizer } = state; const scrollElementRef = useRef(null); const virtualizer = useMessageStreamVirtualizer({ blocks, consumeScrollIntent, registerVirtualizer, scrollElementRef }); diff --git a/src/features/chat/ui/shell-state.tsx b/src/features/chat/ui/shell-state.tsx new file mode 100644 index 00000000..838f5c0d --- /dev/null +++ b/src/features/chat/ui/shell-state.tsx @@ -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; + threadList: Signal; + activeThread: Signal; + runtime: Signal; + turn: Signal; + messageStream: Signal; + requests: Signal; + composer: Signal; + ui: Signal; +} + +export type ChatPanelToolbarShellState = Pick< + ChatState, + "connection" | "threadList" | "activeThread" | "runtime" | "turn" | "messageStream" | "ui" +>; + +export type ChatPanelGoalShellState = Pick; + +export type ChatPanelMessageStreamShellState = Pick; + +export type ChatPanelComposerShellState = Pick< + ChatState, + "connection" | "threadList" | "activeThread" | "runtime" | "turn" | "messageStream" | "composer" +>; + +export const ChatPanelShellStateContext = createContext(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; +} diff --git a/src/features/chat/ui/shell.tsx b/src/features/chat/ui/shell.tsx index 2c8efaa1..93a9b0ec 100644 --- a/src/features/chat/ui/shell.tsx +++ b/src/features/chat/ui/shell.tsx @@ -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; - threadList: Signal; - activeThread: Signal; - runtime: Signal; - turn: Signal; - messageStream: Signal; - requests: Signal; - composer: Signal; - ui: Signal; - renderVersion: Signal; - 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(); -const ChatPanelShellStateContext = createContext(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 ( - {showToolbar ?
{toolbarNode()}
: null} + {showToolbar ? ( +
+ +
+ ) : null}
-
{goalNode()}
- {messageStreamNode()} -
{composerNode()}
+
+ +
+ +
+ +
); } -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; diff --git a/src/features/chat/ui/toolbar.tsx b/src/features/chat/ui/toolbar.tsx index e4fb6294..f07a6a80 100644 --- a/src/features/chat/ui/toolbar.tsx +++ b/src/features/chat/ui/toolbar.tsx @@ -64,11 +64,7 @@ export interface ToolbarActions { autoNameThread: (threadId: string) => void; } -export function toolbarNode(model: ToolbarViewModel, actions: ToolbarActions): UiNode { - return ; -} - -function Toolbar({ model, actions }: { model: ToolbarViewModel; actions: ToolbarActions }): UiNode { +export function Toolbar({ model, actions }: { model: ToolbarViewModel; actions: ToolbarActions }): UiNode { return ( <>
diff --git a/src/features/chat/view.ts b/src/features/chat/view.ts index f2285fca..be89c16d 100644 --- a/src/features/chat/view.ts +++ b/src/features/chat/view.ts @@ -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(), diff --git a/tests/features/chat/conversation/composer/controller.test.ts b/tests/features/chat/conversation/composer/controller.test.ts index 8412bd96..3e3651f8 100644 --- a/tests/features/chat/conversation/composer/controller.test.ts +++ b/tests/features/chat/conversation/composer/controller.test.ts @@ -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(".codex-panel__composer-meta-icon")?.classList.contains("is-active")).toBe(false); parent.querySelector(".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"); diff --git a/tests/features/chat/conversation/turns/turn-submission-controller.test.ts b/tests/features/chat/conversation/turns/turn-submission-controller.test.ts index e7f44a69..365130c3 100644 --- a/tests/features/chat/conversation/turns/turn-submission-controller.test.ts +++ b/tests/features/chat/conversation/turns/turn-submission-controller.test.ts @@ -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 () => { diff --git a/tests/features/chat/ui/message-stream/ports.test.ts b/tests/features/chat/panel/surface/message-stream-ports.test.ts similarity index 98% rename from tests/features/chat/ui/message-stream/ports.test.ts rename to tests/features/chat/panel/surface/message-stream-ports.test.ts index 86887390..4a2d8b52 100644 --- a/tests/features/chat/ui/message-stream/ports.test.ts +++ b/tests/features/chat/panel/surface/message-stream-ports.test.ts @@ -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", () => { diff --git a/tests/features/chat/ui/message-stream/renderer.test.ts b/tests/features/chat/panel/surface/message-stream-renderer.test.ts similarity index 89% rename from tests/features/chat/ui/message-stream/renderer.test.ts rename to tests/features/chat/panel/surface/message-stream-renderer.test.ts index 37bc3188..6c5cdca5 100644 --- a/tests/features/chat/ui/message-stream/renderer.test.ts +++ b/tests/features/chat/panel/surface/message-stream-renderer.test.ts @@ -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); }); }); diff --git a/tests/features/chat/panel/regions/toolbar.test.ts b/tests/features/chat/panel/surface/toolbar.test.ts similarity index 96% rename from tests/features/chat/panel/regions/toolbar.test.ts rename to tests/features/chat/panel/surface/toolbar.test.ts index c7eb0840..2a2f221e 100644 --- a/tests/features/chat/panel/regions/toolbar.test.ts +++ b/tests/features/chat/panel/surface/toolbar.test.ts @@ -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", () => { diff --git a/tests/features/chat/protocol/inbound/controller.test.ts b/tests/features/chat/protocol/inbound/controller.test.ts index aced7546..74f8d860 100644 --- a/tests/features/chat/protocol/inbound/controller.test.ts +++ b/tests/features/chat/protocol/inbound/controller.test.ts @@ -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; @@ -68,14 +69,14 @@ describe("ChatInboundController", () => { params: { threadId: "thread-active", turnId: "turn-active", itemId: "a1", delta: "hello" }, } satisfies Extract); - 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); - 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: "\n# Plan" }, } satisfies Extract); - 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); - expect(state.messageStream.displayItems).toEqual([ + expect(chatStateDisplayItems(state)).toEqual([ expect.objectContaining({ id: "p1", kind: "message", @@ -163,7 +164,7 @@ describe("ChatInboundController", () => { }, } satisfies Extract); - expect(state.messageStream.displayItems).toMatchObject([ + expect(chatStateDisplayItems(state)).toMatchObject([ { id: "plan-progress-turn-active", kind: "taskProgress", @@ -254,7 +255,7 @@ describe("ChatInboundController", () => { }, } satisfies Extract); - expect(state.messageStream.displayItems).toMatchObject([ + expect(chatStateDisplayItems(state)).toMatchObject([ { id: "hook-hook-1-1", kind: "hook", @@ -307,7 +308,7 @@ describe("ChatInboundController", () => { }, } satisfies Extract); - expect(state.messageStream.displayItems[0]).toMatchObject({ + expect(chatStateDisplayItems(state)[0]).toMatchObject({ kind: "hook", details: [ { @@ -350,7 +351,7 @@ describe("ChatInboundController", () => { }, } satisfies Extract); - 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); - 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); - 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); - 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); - 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); - 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); - 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); - 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); 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); - 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); - 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); - 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); - 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); - 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); 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); - 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); - 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); - 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); - 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); - 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); 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); 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); - 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); - expect(state.messageStream.displayItems).toMatchObject([ + expect(chatStateDisplayItems(state)).toMatchObject([ { kind: "reviewResult", role: "tool", @@ -1663,14 +1666,14 @@ describe("ChatInboundController", () => { }, } satisfies Extract); - 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); - 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); - 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" }); }); }); }); diff --git a/tests/features/chat/state-reducer.test.ts b/tests/features/chat/state-reducer.test.ts index fb8a2765..9002e587 100644 --- a/tests/features/chat/state-reducer.test.ts +++ b/tests/features/chat/state-reducer.test.ts @@ -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", () => { diff --git a/tests/features/chat/support/message-stream.ts b/tests/features/chat/support/message-stream.ts new file mode 100644 index 00000000..b60fd945 --- /dev/null +++ b/tests/features/chat/support/message-stream.ts @@ -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): readonly DisplayItem[] { + return messageStreamDisplayItems(state.messageStream); +} + +export function setChatStateDisplayItems(state: ChatState, items: readonly DisplayItem[]): void { + state.messageStream = messageStreamWithDisplayItems(state.messageStream, items); +} diff --git a/tests/features/chat/threads/actions.test.ts b/tests/features/chat/threads/actions.test.ts index f24a6aa6..96e67968 100644 --- a/tests/features/chat/threads/actions.test.ts +++ b/tests/features/chat/threads/actions.test.ts @@ -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>; @@ -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; }) { const state = createChatState(); - const stateStore = createChatStateStore({ ...state, messageStream: { ...state.messageStream, displayItems } }); + setChatStateDisplayItems(state, displayItems); + const stateStore = createChatStateStore(state); return { stateStore, vaultPath: "/vault", diff --git a/tests/features/chat/threads/history-controller.test.ts b/tests/features/chat/threads/history-controller.test.ts index 3cb00c3f..48abced9 100644 --- a/tests/features/chat/threads/history-controller.test.ts +++ b/tests/features/chat/threads/history-controller.test.ts @@ -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(); diff --git a/tests/features/chat/threads/rename-controller.test.ts b/tests/features/chat/threads/rename-controller.test.ts index ddd00281..83e7c111 100644 --- a/tests/features/chat/threads/rename-controller.test.ts +++ b/tests/features/chat/threads/rename-controller.test.ts @@ -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 () => { diff --git a/tests/features/chat/ui/message-stream/blocks-and-messages.test.tsx b/tests/features/chat/ui/message-stream/blocks-and-messages.test.tsx index c9b3a18f..df5889a9 100644 --- a/tests/features/chat/ui/message-stream/blocks-and-messages.test.tsx +++ b/tests/features/chat/ui/message-stream/blocks-and-messages.test.tsx @@ -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, }, }; diff --git a/tests/features/chat/ui/message-stream/test-helpers.tsx b/tests/features/chat/ui/message-stream/test-helpers.tsx index 4dfe2362..96068938 100644 --- a/tests/features/chat/ui/message-stream/test-helpers.tsx +++ b/tests/features/chat/ui/message-stream/test-helpers.tsx @@ -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", - }), + "auto", + }} + />, ); }); } diff --git a/tests/features/chat/ui/renderers/composer.test.ts b/tests/features/chat/ui/renderers/composer.test.ts index abb48a1c..931cff12 100644 --- a/tests/features/chat/ui/renderers/composer.test.ts +++ b/tests/features/chat/ui/renderers/composer.test.ts @@ -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(".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(".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(".codex-panel__send"); expect(sendButton?.getAttribute("aria-label")).toBe("Steer"); expect(composer.getAttribute("placeholder")).toBe("Add steering message..."); diff --git a/tests/features/chat/ui/renderers/goal.test.tsx b/tests/features/chat/ui/renderers/goal.test.tsx index d4ca7a85..b8aea810 100644 --- a/tests/features/chat/ui/renderers/goal.test.tsx +++ b/tests/features/chat/ui/renderers/goal.test.tsx @@ -4,7 +4,7 @@ import { describe, expect, it, vi } from "vitest"; import { act } from "preact/test-utils"; import type { ThreadGoal } from "../../../../../src/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, + , + ); }); 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, ); } function actions() { diff --git a/tests/features/chat/ui/renderers/toolbar.test.ts b/tests/features/chat/ui/renderers/toolbar.test.ts index 8c09e756..16c8fcbe 100644 --- a/tests/features/chat/ui/renderers/toolbar.test.ts +++ b/tests/features/chat/ui/renderers/toolbar.test.ts @@ -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(value: T | null | undefined): T { return value; } -type ToolbarActions = Parameters[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(".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, diff --git a/tests/features/chat/ui/shell.test.tsx b/tests/features/chat/ui/shell.test.tsx index 55ce0482..df1ff174 100644 --- a/tests/features/chat/ui/shell.test.tsx +++ b/tests/features/chat/ui/shell.test.tsx @@ -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(".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(".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(".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(".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(":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(":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(":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) { +function shellProps(store: ReturnType) { return { stateStore: store, showToolbar: true, - toolbarNode: vi.fn(() => ), - - goalNode: vi.fn(() => ), - - messageStreamNode: vi.fn(() => ( -
- -
- )), - - composerNode: vi.fn(() => ), + slots: shellSlots(store), }; } -function nestedRootShellRenderers(store: ReturnType) { +interface ShellSlotsOptions { + toolbarRenameState?: () => null; +} + +function shellSlots(store: ReturnType, options: ShellSlotsOptions = {}): ChatPanelShellSlots { + const ports = surfacePorts(store, options); return { - stateStore: store, - showToolbar: true, - toolbarNode: vi.fn(() => ( - <> - -
panel
- - )), - - goalNode: vi.fn(() => ), - - messageStreamNode: vi.fn(() => ( -
- -
- )), - - composerNode: vi.fn(() => ( -
- - -
- )), + toolbar: ports.toolbar, + goal: ports.goal, + messageStream: { + renderState: () => ({ + blocks: [ + { + key: "count", + node:
{String(chatStateDisplayItems(store.getState()).length)}
, + }, + ], + 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, cleanup: (region: string) => void) { +function surfacePorts(store: ReturnType, options: ShellSlotsOptions): ChatPanelSurfacePorts { return { - stateStore: store, - showToolbar: true, - toolbarNode: vi.fn(() => ), - - goalNode: vi.fn(() => ), - - messageStreamNode: vi.fn(() => ( -
- -
- )), - - composerNode: vi.fn(() => ), + 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, cleanup: (region: string) => void) { - return { - stateStore: store, - showToolbar: true, - toolbarNode: vi.fn(() => ), - - goalNode: vi.fn(() => ), - - messageStreamNode: vi.fn(() => ( -
- -
- )), - - composerNode: vi.fn(() => ( - - )), - }; -} - -function TrackedSlot({ - region, - cleanup, - className, - text = region, -}: { - region: string; - cleanup: (region: string) => void; - className?: string; - text?: string; -}) { - useEffect(() => { - return () => { - cleanup(region); - }; - }, [cleanup, region]); - return
{text}
; -} - -function TrackedStateSlot({ - region, - cleanup, - className, - selector, -}: { - region: string; - cleanup: (region: string) => void; - className?: string; - selector: "status" | "goal" | "message-count" | "composer-status"; -}) { - return ; -} - -function ShellStatus({ className }: { className?: string }) { - const { connection } = useChatPanelShellState(); - return
{connection.value.status}
; -} - -function ShellGoal({ className }: { className?: string }) { - const { activeThread } = useChatPanelShellState(); - return
{activeThread.value.goal?.objective ?? "no goal"}
; -} - -function ShellMessageCount({ className }: { className?: string }) { - const { messageStream } = useChatPanelShellState(); - return
{String(messageStream.value.displayItems.length)}
; -} - -function ShellComposerStatus() { - return
{useShellText("composer-status")}
; -} - -function ShellComposerTextarea() { - return