mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Complete single Preact chat shell integration
This commit is contained in:
parent
aa5b1b6ed0
commit
ed93f7a634
53 changed files with 1522 additions and 1459 deletions
|
|
@ -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",
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -11,10 +11,10 @@ import type { GoalActions } from "../threads/goal-actions";
|
|||
import type { AutoTitleController } from "../threads/auto-title-controller";
|
||||
import { ChatInboundController } from "../protocol/inbound/controller";
|
||||
import type { ChatConnectionWorkTracker } from "../lifecycle";
|
||||
import type { ChatControllerCompositionPorts } from "../composition-ports";
|
||||
import type { ChatControllerPorts } from "../controller-ports";
|
||||
import { runtimeSnapshotForChatState } from "../runtime/snapshot";
|
||||
|
||||
type ChatServerActionControllerPorts = Pick<ChatControllerCompositionPorts, "plugin" | "state">;
|
||||
type ChatServerActionControllerPorts = Pick<ChatControllerPorts, "plugin" | "state">;
|
||||
|
||||
export function createChatServerActionControllers(
|
||||
context: ChatServerActionControllerPorts,
|
||||
|
|
@ -59,7 +59,7 @@ export function createChatServerActionControllers(
|
|||
return { serverThreads, serverMetadata, serverDiagnostics };
|
||||
}
|
||||
|
||||
type ChatInboundControllerPorts = Pick<ChatControllerCompositionPorts, "plugin" | "state"> & {
|
||||
type ChatInboundControllerPorts = Pick<ChatControllerPorts, "plugin" | "state"> & {
|
||||
render: {
|
||||
schedule: () => void;
|
||||
};
|
||||
|
|
@ -105,7 +105,7 @@ export function createChatInboundController(
|
|||
});
|
||||
}
|
||||
|
||||
type ChatConnectionControllerPorts = Pick<ChatControllerCompositionPorts, "plugin" | "state" | "liveState"> & {
|
||||
type ChatConnectionControllerPorts = Pick<ChatControllerPorts, "plugin" | "state" | "liveState"> & {
|
||||
client: {
|
||||
setClient: (client: ReturnType<ConnectionManager["currentClient"]>) => void;
|
||||
};
|
||||
|
|
@ -176,7 +176,7 @@ export function createChatConnectionControllers(
|
|||
};
|
||||
}
|
||||
|
||||
type ChatReconnectControllerGroupPorts = Pick<ChatControllerCompositionPorts, "state"> & {
|
||||
type ChatReconnectControllerGroupPorts = Pick<ChatControllerPorts, "state"> & {
|
||||
client: {
|
||||
clear: () => void;
|
||||
ensureConnected: () => Promise<void>;
|
||||
|
|
@ -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 {
|
||||
|
|
@ -10,7 +10,7 @@ import type { ChatThreadActions } from "./threads/action-context";
|
|||
import type { AutoTitleController } from "./threads/auto-title-controller";
|
||||
import type { HistoryController } from "./threads/history-controller";
|
||||
import type { RenameController } from "./threads/rename-controller";
|
||||
import type { ToolbarPanelActions } from "./panel/regions/toolbar";
|
||||
import type { ToolbarPanelActions } from "./panel/toolbar-actions";
|
||||
import type { ChatConnectionController } from "./connection/connection-controller";
|
||||
import type { ChatReconnectActions } from "./connection/reconnect-actions";
|
||||
import type { PendingRequestController } from "./conversation/pending-requests/controller";
|
||||
|
|
@ -21,8 +21,8 @@ import type { RestorationController } from "./threads/restoration-controller";
|
|||
import type { IdentitySync } from "./threads/identity-sync";
|
||||
import type { ResumeController } from "./threads/resume-controller";
|
||||
import type { SelectionActions } from "./threads/selection-actions";
|
||||
import type { MessageStreamRenderer } from "./ui/message-stream/renderer";
|
||||
import type { ChatControllerCompositionPorts } from "./composition-ports";
|
||||
import type { MessageStreamRenderer } from "./panel/surface/message-stream-renderer";
|
||||
import type { ChatControllerPorts } from "./controller-ports";
|
||||
import { scheduleAppServerWarmup } from "./connection/app-server-warmup";
|
||||
import { runtimeSnapshotForChatState } from "./runtime/snapshot";
|
||||
import {
|
||||
|
|
@ -30,10 +30,10 @@ import {
|
|||
createChatConnectionControllers,
|
||||
createChatInboundController,
|
||||
createChatReconnectControllerGroup,
|
||||
} from "./connection/composition";
|
||||
import { createThreadControllerGroup, createThreadSelectionActionGroup } from "./threads/composition";
|
||||
import { createConversationSurfaceControllerGroup } from "./conversation/composition";
|
||||
import { createChatViewRenderer, createConnectionLifecycleControllerGroup, createPanelUiControllerGroup } from "./panel/composition";
|
||||
} from "./connection/controllers";
|
||||
import { createThreadControllerGroup, createThreadSelectionActionGroup } from "./threads/controllers";
|
||||
import { createConversationSurfaceControllerGroup } from "./conversation/controllers";
|
||||
import { createChatViewRenderer, createConnectionLifecycleControllerGroup, createPanelUiControllerGroup } from "./panel/controllers";
|
||||
|
||||
export interface ChatViewControllers {
|
||||
connection: {
|
||||
|
|
@ -83,11 +83,11 @@ export interface ChatViewControllers {
|
|||
};
|
||||
}
|
||||
|
||||
interface ChatCompositionSideEffects {
|
||||
render: Pick<ChatControllerCompositionPorts["render"], "panelRoot" | "closeToolbarPanelOnOutsidePointer" | "schedule"> & {
|
||||
interface ChatControllerSideEffects {
|
||||
render: Pick<ChatControllerPorts["render"], "panelRoot" | "closeToolbarPanelOnOutsidePointer" | "schedule"> & {
|
||||
now: () => void;
|
||||
};
|
||||
status: ChatControllerCompositionPorts["status"] & {
|
||||
status: ChatControllerPorts["status"] & {
|
||||
addSystemMessage: (text: string) => void;
|
||||
addStructuredSystemMessage: (text: string, details: DisplayDetailSection[]) => void;
|
||||
};
|
||||
|
|
@ -96,7 +96,7 @@ interface ChatCompositionSideEffects {
|
|||
};
|
||||
}
|
||||
|
||||
export function createChatViewControllers(ports: ChatControllerCompositionPorts): ChatViewControllers {
|
||||
export function createChatViewControllers(ports: ChatControllerPorts): ChatViewControllers {
|
||||
const connection = new ConnectionManager(() => ports.plugin.settings.codexPath, ports.plugin.vaultPath);
|
||||
const renderNow = createChatViewRenderer(ports);
|
||||
let connectionController: ChatConnectionController | null = null;
|
||||
|
|
@ -107,7 +107,7 @@ export function createChatViewControllers(ports: ChatControllerCompositionPorts)
|
|||
const refreshSkills = (forceReload?: boolean) =>
|
||||
requireComposedController(connectionController, "connection controller").refreshSkills(forceReload);
|
||||
const selectThread = (threadId: string) => requireComposedController(selection, "selection actions").selectThread(threadId);
|
||||
const sideEffects = createChatCompositionSideEffects(ports, {
|
||||
const sideEffects = createChatControllerSideEffects(ports, {
|
||||
renderNow,
|
||||
setComposerText: (text) => {
|
||||
requireComposedController(composerController, "composer controller").setDraft(text, { focus: true });
|
||||
|
|
@ -411,17 +411,17 @@ export function createChatViewControllers(ports: ChatControllerCompositionPorts)
|
|||
}
|
||||
|
||||
function requireComposedController<T>(controller: T | null, name: string): T {
|
||||
if (!controller) throw new Error(`Chat view controller composition did not initialize ${name}.`);
|
||||
if (!controller) throw new Error(`Chat view controller graph did not initialize ${name}.`);
|
||||
return controller;
|
||||
}
|
||||
|
||||
function createChatCompositionSideEffects(
|
||||
ports: Pick<ChatControllerCompositionPorts, "render" | "state" | "status">,
|
||||
function createChatControllerSideEffects(
|
||||
ports: Pick<ChatControllerPorts, "render" | "state" | "status">,
|
||||
deps: {
|
||||
renderNow: () => void;
|
||||
setComposerText: (text: string) => void;
|
||||
},
|
||||
): ChatCompositionSideEffects {
|
||||
): ChatControllerSideEffects {
|
||||
const render = {
|
||||
panelRoot: ports.render.panelRoot,
|
||||
closeToolbarPanelOnOutsidePointer: ports.render.closeToolbarPanelOnOutsidePointer,
|
||||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -14,16 +14,16 @@ import type { HistoryController } from "../threads/history-controller";
|
|||
import type { ChatInboundController } from "../protocol/inbound/controller";
|
||||
import { currentModel, runtimeConfigOrDefault } from "../runtime/effective";
|
||||
import { runtimeSnapshotForChatState } from "../runtime/snapshot";
|
||||
import { MessageStreamRenderer } from "../ui/message-stream/renderer";
|
||||
import type { ChatControllerCompositionPorts } from "../composition-ports";
|
||||
import { MessageStreamRenderer } from "../panel/surface/message-stream-renderer";
|
||||
import type { ChatControllerPorts } from "../controller-ports";
|
||||
import type { DisplayDetailSection } from "../display/types";
|
||||
|
||||
type ConversationSurfaceControllerGroupPorts = Pick<
|
||||
ChatControllerCompositionPorts,
|
||||
ChatControllerPorts,
|
||||
"obsidian" | "plugin" | "state" | "lifecycle" | "surface" | "runtime" | "liveState"
|
||||
> & {
|
||||
client: {
|
||||
getClient: ChatControllerCompositionPorts["client"]["getClient"];
|
||||
getClient: ChatControllerPorts["client"]["getClient"];
|
||||
ensureConnected: () => Promise<void>;
|
||||
};
|
||||
render: {
|
||||
|
|
@ -35,15 +35,15 @@ type ConversationSurfaceControllerGroupPorts = Pick<
|
|||
addSystemMessage: (text: string) => void;
|
||||
addStructuredSystemMessage: (text: string, details: DisplayDetailSection[]) => void;
|
||||
};
|
||||
scroll: Pick<ChatControllerCompositionPorts["scroll"], "forceBottom" | "followBottom">;
|
||||
scroll: Pick<ChatControllerPorts["scroll"], "forceBottom" | "followBottom">;
|
||||
thread: {
|
||||
ensureRestoredThreadLoaded: ChatControllerCompositionPorts["thread"]["ensureRestoredThreadLoaded"];
|
||||
startNewThread: ChatControllerCompositionPorts["thread"]["startNewThread"];
|
||||
ensureRestoredThreadLoaded: ChatControllerPorts["thread"]["ensureRestoredThreadLoaded"];
|
||||
startNewThread: ChatControllerPorts["thread"]["startNewThread"];
|
||||
selectThread: (threadId: string) => Promise<void>;
|
||||
notifyIdentityChanged: () => void;
|
||||
resetTurnPresence: (hadTurns: boolean) => void;
|
||||
};
|
||||
runtime: ChatControllerCompositionPorts["runtime"] & {
|
||||
runtime: ChatControllerPorts["runtime"] & {
|
||||
mcpStatusLines: () => Promise<string[]>;
|
||||
};
|
||||
};
|
||||
|
|
@ -72,9 +72,8 @@ export function createConversationSurfaceControllerGroup(
|
|||
viewId,
|
||||
sendShortcut: () => plugin.settings.sendShortcut,
|
||||
scrollThreadFromComposerEdges: () => plugin.settings.scrollThreadFromComposerEdges,
|
||||
canInterrupt: () => {
|
||||
const current = stateStore.getState();
|
||||
return current.turn.lifecycle.kind !== "idle" && Boolean(current.activeThread.id && activeTurnId(current));
|
||||
canInterrupt: (state) => {
|
||||
return state.turn.lifecycle.kind !== "idle" && Boolean(state.activeThread.id && activeTurnId(state));
|
||||
},
|
||||
composerPlaceholder: surface.composerPlaceholder,
|
||||
composerMeta: surface.composerMetaViewModel,
|
||||
|
|
@ -6,11 +6,11 @@ import type { ChatServerThreadActions } from "../connection/server-actions/threa
|
|||
import type { ChatComposerController } from "../conversation/composer/controller";
|
||||
import type { ChatThreadActions } from "../threads/action-context";
|
||||
import { closeChatView, openChatView, type ChatViewLifecycleHost } from "./view-lifecycle";
|
||||
import { createToolbarArchiveConfirmState, createToolbarPanelActions } from "./regions/toolbar";
|
||||
import { createToolbarArchiveConfirmState, createToolbarPanelActions } from "./toolbar-actions";
|
||||
import { applyChatViewState } from "./view-state";
|
||||
import type { MessageStreamRenderer } from "../ui/message-stream/renderer";
|
||||
import type { MessageStreamRenderer } from "./surface/message-stream-renderer";
|
||||
import type { ChatViewDeferredTasks, RestoredThreadState } from "../lifecycle";
|
||||
import type { ChatControllerCompositionPorts } from "../composition-ports";
|
||||
import type { ChatControllerPorts } from "../controller-ports";
|
||||
import { renderChatPanelShell } from "../ui/shell";
|
||||
|
||||
export interface CachedSharedAppServerStateSource {
|
||||
|
|
@ -18,7 +18,7 @@ export interface CachedSharedAppServerStateSource {
|
|||
cachedAppServerMetadata: () => SharedServerMetadata | null;
|
||||
}
|
||||
|
||||
type ChatViewRendererPorts = Pick<ChatControllerCompositionPorts, "plugin" | "state" | "lifecycle" | "render">;
|
||||
type ChatViewRendererPorts = Pick<ChatControllerPorts, "plugin" | "state" | "lifecycle" | "render">;
|
||||
|
||||
export function createChatViewRenderer(context: ChatViewRendererPorts): () => void {
|
||||
const { plugin, render, lifecycle } = context;
|
||||
|
|
@ -31,15 +31,12 @@ export function createChatViewRenderer(context: ChatViewRendererPorts): () => vo
|
|||
renderChatPanelShell(root, {
|
||||
stateStore: context.state.stateStore,
|
||||
showToolbar: plugin.settings.showToolbar,
|
||||
toolbarNode: context.render.toolbarNode,
|
||||
goalNode: context.render.goalNode,
|
||||
messageStreamNode: context.render.messageStreamNode,
|
||||
composerNode: context.render.composerNode,
|
||||
slots: context.render.shellSlots(),
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
type ConnectionLifecycleControllerGroupPorts = Pick<ChatControllerCompositionPorts, "plugin" | "liveState"> & {
|
||||
type ConnectionLifecycleControllerGroupPorts = Pick<ChatControllerPorts, "plugin" | "liveState"> & {
|
||||
obsidian: Pick<ChatViewLifecycleHost["events"], "registerEvent" | "registerPointerDown">;
|
||||
plugin: CachedSharedAppServerStateSource;
|
||||
client: {
|
||||
|
|
@ -137,7 +134,7 @@ export function createConnectionLifecycleControllerGroup(
|
|||
};
|
||||
}
|
||||
|
||||
type PanelUiControllerGroupPorts = Pick<ChatControllerCompositionPorts, "state"> & {
|
||||
type PanelUiControllerGroupPorts = Pick<ChatControllerPorts, "state"> & {
|
||||
lifecycle: {
|
||||
invalidateResumeWork: () => void;
|
||||
clearDeferredRestoredThreadHydration: () => void;
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -1,293 +0,0 @@
|
|||
import type { ComponentChild as UiNode } from "preact";
|
||||
import { h } from "preact";
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
|
||||
import type { Thread } from "../../../../domain/threads/model";
|
||||
import { getThreadTitle } from "../../../../domain/threads/model";
|
||||
import type { ChatThreadActions } from "../../threads/action-context";
|
||||
import { runtimeConfigSections, rateLimitSummary } from "../../display/status/runtime";
|
||||
import { connectionDiagnosticSections } from "../../display/status/diagnostics";
|
||||
import type { RuntimeSnapshot } from "../../runtime/snapshot";
|
||||
import type { ChatAction, ChatState, ChatStateStore } from "../../state/reducer";
|
||||
import { useChatPanelShellState, type ChatPanelShellState } from "../../ui/shell";
|
||||
import { toolbarNode, type ToolbarThreadRow, type ToolbarViewModel } from "../../ui/toolbar";
|
||||
import type { ChatPanelToolbarPorts } from "./ports";
|
||||
|
||||
export interface ToolbarViewModelInput {
|
||||
state: ChatState;
|
||||
snapshot: RuntimeSnapshot;
|
||||
connected: boolean;
|
||||
turnBusy: boolean;
|
||||
vaultPath: string;
|
||||
configuredCommand: string;
|
||||
archiveConfirmThreadId: string | null;
|
||||
archiveExportEnabled: boolean;
|
||||
renameState: (threadId: string) => ToolbarThreadRow["rename"];
|
||||
}
|
||||
|
||||
export interface ConnectionDiagnosticsModelInput {
|
||||
state: ChatState;
|
||||
connected: boolean;
|
||||
configuredCommand: string;
|
||||
}
|
||||
|
||||
export interface ToolbarPanelActionsHost {
|
||||
stateStore: ChatStateStore;
|
||||
threadActions: ChatThreadActions;
|
||||
archiveConfirm: ToolbarArchiveConfirmState;
|
||||
scheduleRender: () => void;
|
||||
}
|
||||
|
||||
export interface ToolbarArchiveConfirmState {
|
||||
get: () => string | null;
|
||||
set: (threadId: string | null) => void;
|
||||
subscribe: (listener: () => void) => () => void;
|
||||
}
|
||||
|
||||
export interface ToolbarPanelActions {
|
||||
archiveConfirmId(): string | null;
|
||||
onArchiveConfirmChange(listener: () => void): () => void;
|
||||
toggleHistory(): void;
|
||||
toggleChatActions(): void;
|
||||
closeToolbarPanels(): void;
|
||||
toggleStatus(): void;
|
||||
closeForThreadSelection(): void;
|
||||
startArchive(threadId: string): void;
|
||||
archiveThread(threadId: string, saveMarkdown: boolean): Promise<void>;
|
||||
closeOnOutsidePointer(context: ToolbarOutsidePointerContext): void;
|
||||
}
|
||||
|
||||
interface ToolbarOutsidePointerContext {
|
||||
target: EventTarget | null;
|
||||
viewWindow: ToolbarDomWindow | null;
|
||||
contains: (element: Element) => boolean;
|
||||
renameEditing: boolean;
|
||||
}
|
||||
|
||||
type ToolbarDomWindow = Window & { Element: typeof Element };
|
||||
|
||||
function chatPanelToolbarViewModel(ports: ChatPanelToolbarPorts, shellState: ChatPanelShellState) {
|
||||
const latestState = shellState.latestState();
|
||||
return toolbarViewModel({
|
||||
state: {
|
||||
...latestState,
|
||||
connection: shellState.connection.value,
|
||||
threadList: shellState.threadList.value,
|
||||
activeThread: shellState.activeThread.value,
|
||||
runtime: shellState.runtime.value,
|
||||
turn: shellState.turn.value,
|
||||
ui: shellState.ui.value,
|
||||
},
|
||||
snapshot: ports.runtime.snapshot(),
|
||||
connected: ports.state.connected(),
|
||||
turnBusy: ports.state.turnBusy(),
|
||||
vaultPath: ports.settings.vaultPath(),
|
||||
configuredCommand: ports.settings.configuredCommand(),
|
||||
archiveConfirmThreadId: ports.view.toolbar.archiveConfirmId(),
|
||||
archiveExportEnabled: ports.settings.archiveExportEnabled(),
|
||||
renameState: (threadId) => ports.view.toolbar.renameState(threadId),
|
||||
});
|
||||
}
|
||||
|
||||
export function chatPanelToolbarRegionNode(ports: ChatPanelToolbarPorts): UiNode {
|
||||
return h(ToolbarRegion, { ports });
|
||||
}
|
||||
|
||||
function ToolbarRegion({ ports }: { ports: ChatPanelToolbarPorts }): UiNode {
|
||||
const shellState = useChatPanelShellState();
|
||||
useToolbarArchiveConfirmSubscription(ports);
|
||||
useToolbarRenameSubscription(ports);
|
||||
void shellState.renderVersion.value;
|
||||
return toolbarNode(chatPanelToolbarViewModel(ports, shellState), ports.actions.toolbar);
|
||||
}
|
||||
|
||||
function useToolbarArchiveConfirmSubscription(ports: ChatPanelToolbarPorts): void {
|
||||
const [, setVersion] = useState(0);
|
||||
useEffect(
|
||||
() =>
|
||||
ports.view.toolbar.archiveConfirmSubscribe(() => {
|
||||
setVersion((version) => version + 1);
|
||||
}),
|
||||
[ports],
|
||||
);
|
||||
}
|
||||
|
||||
function useToolbarRenameSubscription(ports: ChatPanelToolbarPorts): void {
|
||||
const [, setVersion] = useState(0);
|
||||
useEffect(
|
||||
() =>
|
||||
ports.view.toolbar.renameSubscribe(() => {
|
||||
setVersion((version) => version + 1);
|
||||
}),
|
||||
[ports],
|
||||
);
|
||||
}
|
||||
|
||||
export function toolbarViewModel(input: ToolbarViewModelInput): ToolbarViewModel {
|
||||
const { state, snapshot } = input;
|
||||
const limit = rateLimitSummary(snapshot, Date.now());
|
||||
const historyOpen = state.ui.toolbarPanel === "history";
|
||||
const chatActionsOpen = state.ui.toolbarPanel === "chat-actions";
|
||||
const statusPanelOpen = state.ui.toolbarPanel === "status-panel";
|
||||
return {
|
||||
newChatDisabled: input.turnBusy,
|
||||
chatActionsOpen,
|
||||
historyOpen,
|
||||
statusPanelOpen,
|
||||
rateLimit: limit,
|
||||
configSections: runtimeConfigSections(snapshot, input.vaultPath),
|
||||
openPanel: historyOpen ? "history" : chatActionsOpen ? "chat-actions" : statusPanelOpen ? "status" : null,
|
||||
threads: toolbarThreadRows({
|
||||
threads: state.threadList.listedThreads,
|
||||
activeThreadId: state.activeThread.id,
|
||||
turnBusy: input.turnBusy,
|
||||
archiveConfirmThreadId: input.archiveConfirmThreadId,
|
||||
archiveExportEnabled: input.archiveExportEnabled,
|
||||
renameState: input.renameState,
|
||||
}),
|
||||
connectLabel: input.connected ? "Reconnect" : "Connect",
|
||||
diagnostics: connectionDiagnosticsModel({
|
||||
state,
|
||||
connected: input.connected,
|
||||
configuredCommand: input.configuredCommand,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function toolbarThreadRows(input: {
|
||||
threads: readonly Thread[];
|
||||
activeThreadId: string | null;
|
||||
turnBusy: boolean;
|
||||
archiveConfirmThreadId: string | null;
|
||||
archiveExportEnabled: boolean;
|
||||
renameState: (threadId: string) => ToolbarThreadRow["rename"];
|
||||
}): ToolbarThreadRow[] {
|
||||
return input.threads.map((thread) => {
|
||||
const threadId = thread.id;
|
||||
return {
|
||||
title: getThreadTitle(thread),
|
||||
threadId,
|
||||
selected: threadId === input.activeThreadId,
|
||||
disabled: input.turnBusy && threadId !== input.activeThreadId,
|
||||
canArchive: true,
|
||||
archiveConfirm: {
|
||||
active: input.archiveConfirmThreadId === threadId,
|
||||
defaultSaveMarkdown: input.archiveExportEnabled,
|
||||
},
|
||||
rename: input.renameState(threadId),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function connectionDiagnosticsModel(input: ConnectionDiagnosticsModelInput): ReturnType<typeof connectionDiagnosticSections> {
|
||||
return connectionDiagnosticSections({
|
||||
connected: input.connected,
|
||||
configuredCommand: input.configuredCommand,
|
||||
initializeResponse: input.state.connection.initializeResponse,
|
||||
diagnostics: input.state.connection.serverDiagnostics,
|
||||
});
|
||||
}
|
||||
|
||||
export function createToolbarPanelActions(host: ToolbarPanelActionsHost): ToolbarPanelActions {
|
||||
const state = (): ChatState => host.stateStore.getState();
|
||||
const dispatch = (action: ChatAction): void => {
|
||||
host.stateStore.dispatch(action);
|
||||
};
|
||||
const hasOpenPanel = (): boolean => state().ui.toolbarPanel !== null;
|
||||
const close = (): void => {
|
||||
if (!hasOpenPanel()) return;
|
||||
|
||||
dispatch({ type: "ui/panel-set", panel: null });
|
||||
host.archiveConfirm.set(null);
|
||||
host.scheduleRender();
|
||||
};
|
||||
|
||||
return {
|
||||
archiveConfirmId(): string | null {
|
||||
return host.archiveConfirm.get();
|
||||
},
|
||||
|
||||
onArchiveConfirmChange(listener: () => void): () => void {
|
||||
return host.archiveConfirm.subscribe(listener);
|
||||
},
|
||||
|
||||
toggleHistory(): void {
|
||||
dispatch({ type: "ui/panel-set", panel: "history", toggle: true });
|
||||
host.scheduleRender();
|
||||
},
|
||||
|
||||
toggleChatActions(): void {
|
||||
dispatch({ type: "ui/panel-set", panel: "chat-actions", toggle: true });
|
||||
host.scheduleRender();
|
||||
},
|
||||
|
||||
closeToolbarPanels(): void {
|
||||
close();
|
||||
},
|
||||
|
||||
toggleStatus(): void {
|
||||
dispatch({ type: "ui/panel-set", panel: "status-panel", toggle: true });
|
||||
host.scheduleRender();
|
||||
},
|
||||
|
||||
closeForThreadSelection(): void {
|
||||
host.archiveConfirm.set(null);
|
||||
},
|
||||
|
||||
startArchive(threadId: string): void {
|
||||
host.archiveConfirm.set(threadId);
|
||||
},
|
||||
|
||||
async archiveThread(threadId: string, saveMarkdown: boolean): Promise<void> {
|
||||
if (host.archiveConfirm.get() === threadId) host.archiveConfirm.set(null);
|
||||
await host.threadActions.archiveThread(threadId, saveMarkdown);
|
||||
host.scheduleRender();
|
||||
},
|
||||
|
||||
closeOnOutsidePointer(context: ToolbarOutsidePointerContext): void {
|
||||
if (!hasOpenPanel()) return;
|
||||
|
||||
const target = context.target;
|
||||
if (isToolbarElement(target, context.viewWindow)) {
|
||||
const insideToolbarPanel = target.closest(".codex-panel__toolbar-primary, .codex-panel__toolbar-panel");
|
||||
if (insideToolbarPanel && context.contains(insideToolbarPanel)) {
|
||||
if (host.archiveConfirm.get() && !target.closest(".codex-panel__archive-confirm")) {
|
||||
host.archiveConfirm.set(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (host.archiveConfirm.get()) {
|
||||
host.archiveConfirm.set(null);
|
||||
}
|
||||
|
||||
if (context.renameEditing) return;
|
||||
|
||||
close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isToolbarElement(target: EventTarget | null, viewWindow: ToolbarDomWindow | null): target is Element {
|
||||
return Boolean(viewWindow && target instanceof viewWindow.Element);
|
||||
}
|
||||
|
||||
export function createToolbarArchiveConfirmState(): ToolbarArchiveConfirmState {
|
||||
let threadId: string | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
return {
|
||||
get: () => threadId,
|
||||
set: (nextThreadId) => {
|
||||
if (threadId === nextThreadId) return;
|
||||
threadId = nextThreadId;
|
||||
for (const listener of listeners) listener();
|
||||
},
|
||||
subscribe: (listener) => {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -14,19 +14,28 @@ import { contextSummary } from "../../display/status/runtime";
|
|||
import { sortedModelMetadata } from "../../../../domain/catalog/metadata";
|
||||
import type { ReasoningEffort } from "../../../../domain/catalog/metadata";
|
||||
import type { RuntimeSnapshot } from "../../runtime/snapshot";
|
||||
import { runtimeSnapshotForChatSlices } from "../../runtime/snapshot";
|
||||
import type { ChatState } from "../../state/reducer";
|
||||
import { messageStreamDisplayItems } from "../../state/message-stream";
|
||||
import type {
|
||||
ComposerContextMeterCellViewModel,
|
||||
ComposerContextMeterViewModel,
|
||||
ComposerMetaViewModel,
|
||||
RuntimeChoice,
|
||||
} from "../../ui/composer";
|
||||
import { useChatPanelShellState } from "../../ui/shell";
|
||||
import { ComposerShell, type ComposerShellProps } from "../../ui/composer";
|
||||
import { composerStateFromShellState, useChatPanelShellState, type ChatPanelComposerShellState } from "../../ui/shell-state";
|
||||
import { explicitThreadName } from "../../../../domain/threads/model";
|
||||
import type { ChatPanelComposerPorts, RestoredThreadTitleSnapshot } from "./ports";
|
||||
|
||||
type ComposerMetaState = Pick<ChatState, "connection" | "runtime">;
|
||||
|
||||
export interface ChatPanelComposerController {
|
||||
renderState(state: ChatPanelComposerShellState): ComposerShellProps;
|
||||
}
|
||||
|
||||
export interface RuntimeComposerChoicesInput {
|
||||
state: ChatState;
|
||||
state: Pick<ChatState, "connection">;
|
||||
snapshot: RuntimeSnapshot;
|
||||
requestModel: (model: string) => void;
|
||||
requestReasoningEffort: (effort: ReasoningEffort) => void;
|
||||
|
|
@ -37,30 +46,30 @@ export function composerPlaceholder(threadName: string | null): string {
|
|||
return threadName ? `Ask Codex to work on “${threadName}”...` : "Ask Codex to work on this task...";
|
||||
}
|
||||
|
||||
export function chatPanelComposerRegionNode(node: () => UiNode): UiNode {
|
||||
return h(ComposerRegion, { node });
|
||||
export function ChatPanelComposer({ controller }: { controller: ChatPanelComposerController }): UiNode {
|
||||
const state = composerStateFromShellState(useChatPanelShellState());
|
||||
return h(ComposerShell, controller.renderState(state));
|
||||
}
|
||||
|
||||
function ComposerRegion({ node }: { node: () => UiNode }): UiNode {
|
||||
const { connection, threadList, activeThread, runtime, turn, messageStream, composer, renderVersion } = useChatPanelShellState();
|
||||
void connection.value;
|
||||
void threadList.value;
|
||||
void activeThread.value;
|
||||
void runtime.value;
|
||||
void turn.value;
|
||||
void messageStream.value;
|
||||
void composer.value;
|
||||
void renderVersion.value;
|
||||
return node();
|
||||
export function chatPanelComposerPlaceholder(ports: ChatPanelComposerPorts, state: ChatPanelComposerShellState): string {
|
||||
return composerPlaceholder(activeComposerThreadName(state, ports.thread.restoredPlaceholder()));
|
||||
}
|
||||
|
||||
export function chatPanelComposerPlaceholder(ports: ChatPanelComposerPorts): string {
|
||||
return composerPlaceholder(activeComposerThreadName(ports.state.chat(), ports.thread.restoredPlaceholder()));
|
||||
}
|
||||
|
||||
export function chatPanelComposerMetaViewModel(ports: ChatPanelComposerPorts) {
|
||||
const state = ports.state.chat();
|
||||
const snapshot = ports.runtime.snapshot();
|
||||
export function chatPanelComposerMetaViewModel(
|
||||
ports: ChatPanelComposerPorts,
|
||||
state: ChatPanelComposerShellState,
|
||||
): ComposerMetaViewModel & {
|
||||
modelChoices: RuntimeChoice[];
|
||||
effortChoices: RuntimeChoice[];
|
||||
} {
|
||||
const snapshot = runtimeSnapshotForChatSlices({
|
||||
runtimeConfig: state.connection.runtimeConfig,
|
||||
activeThread: state.activeThread,
|
||||
runtime: state.runtime,
|
||||
rateLimit: state.connection.rateLimit,
|
||||
displayItems: messageStreamDisplayItems(state.messageStream),
|
||||
availableModels: state.connection.availableModels,
|
||||
});
|
||||
return {
|
||||
...composerMetaViewModel(state, snapshot),
|
||||
...runtimeComposerChoices({
|
||||
|
|
@ -73,7 +82,7 @@ export function chatPanelComposerMetaViewModel(ports: ChatPanelComposerPorts) {
|
|||
};
|
||||
}
|
||||
|
||||
export function composerMetaViewModel(state: ChatState, snapshot: RuntimeSnapshot): ComposerMetaViewModel {
|
||||
export function composerMetaViewModel(state: ComposerMetaState, snapshot: RuntimeSnapshot): ComposerMetaViewModel {
|
||||
if (state.connection.status === "Connection failed.") {
|
||||
return {
|
||||
fatal: "Codex app-server disconnected",
|
||||
|
|
@ -181,7 +190,10 @@ function onOffLabel(active: boolean): string {
|
|||
return active ? "on" : "off";
|
||||
}
|
||||
|
||||
function activeComposerThreadName(state: ChatState, restoredThread: RestoredThreadTitleSnapshot | null): string | null {
|
||||
function activeComposerThreadName(
|
||||
state: Pick<ChatState, "activeThread" | "threadList">,
|
||||
restoredThread: RestoredThreadTitleSnapshot | null,
|
||||
): string | null {
|
||||
const threadId = state.activeThread.id;
|
||||
if (!threadId) return null;
|
||||
const thread = state.threadList.listedThreads.find((item) => item.id === threadId);
|
||||
|
|
@ -1,25 +1,17 @@
|
|||
import type { ComponentChild as UiNode } from "preact";
|
||||
|
||||
import type { CodexPanelSettings } from "../../../../settings/model";
|
||||
import type { RuntimeSnapshot } from "../../runtime/snapshot";
|
||||
import type { ChatViewControllers } from "../../composition";
|
||||
import { type ChatAction, type ChatState, type ChatStateStore, chatTurnBusy } from "../../state/reducer";
|
||||
import type { RestoredThreadTitleSnapshot, ChatPanelRegionPorts } from "./ports";
|
||||
import type { ChatViewControllers } from "../../controllers";
|
||||
import { type ChatAction, type ChatState, type ChatStateStore } from "../../state/reducer";
|
||||
import type { RestoredThreadTitleSnapshot, ChatPanelSurfacePorts } from "./ports";
|
||||
|
||||
export interface ChatPanelRegionHost {
|
||||
export interface ChatPanelSurfaceHost {
|
||||
settings: CodexPanelSettings;
|
||||
vaultPath: string;
|
||||
stateStore: ChatStateStore;
|
||||
runtimeSnapshot: () => RuntimeSnapshot;
|
||||
restoredThreadPlaceholder: () => RestoredThreadTitleSnapshot | null;
|
||||
messageStreamNode: () => UiNode;
|
||||
startNewThread: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function createChatPanelRegionPorts(host: ChatPanelRegionHost, controllers: ChatViewControllers): ChatPanelRegionPorts {
|
||||
const state = {
|
||||
chat: () => host.stateStore.getState(),
|
||||
};
|
||||
export function createChatPanelSurfacePorts(host: ChatPanelSurfaceHost, controllers: ChatViewControllers): ChatPanelSurfacePorts {
|
||||
const dispatch = (action: ChatAction): void => {
|
||||
host.stateStore.dispatch(action);
|
||||
};
|
||||
|
|
@ -35,24 +27,18 @@ export function createChatPanelRegionPorts(host: ChatPanelRegionHost, controller
|
|||
return {
|
||||
toolbar: {
|
||||
state: {
|
||||
...state,
|
||||
connected: () => controllers.connection.manager.isConnected(),
|
||||
turnBusy: () => chatTurnBusy(host.stateStore.getState()),
|
||||
},
|
||||
settings: {
|
||||
vaultPath: () => host.vaultPath,
|
||||
configuredCommand: () => host.settings.codexPath,
|
||||
archiveExportEnabled: () => host.settings.archiveExportEnabled,
|
||||
},
|
||||
runtime: {
|
||||
snapshot: host.runtimeSnapshot,
|
||||
},
|
||||
view: {
|
||||
toolbar: {
|
||||
archiveConfirmId: () => controllers.toolbar.panels.archiveConfirmId(),
|
||||
archiveConfirmSubscribe: (listener) => controllers.toolbar.panels.onArchiveConfirmChange(listener),
|
||||
archiveConfirm: controllers.toolbar.panels.archiveConfirm,
|
||||
renameState: (threadId) => controllers.thread.rename.editState(threadId),
|
||||
renameSubscribe: (listener) => controllers.thread.rename.subscribe(listener),
|
||||
renameVersion: controllers.thread.rename.version,
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
|
|
@ -109,7 +95,6 @@ export function createChatPanelRegionPorts(host: ChatPanelRegionHost, controller
|
|||
},
|
||||
},
|
||||
goal: {
|
||||
state,
|
||||
settings: {
|
||||
sendShortcut: () => host.settings.sendShortcut,
|
||||
},
|
||||
|
|
@ -124,19 +109,11 @@ export function createChatPanelRegionPorts(host: ChatPanelRegionHost, controller
|
|||
},
|
||||
},
|
||||
},
|
||||
messageStream: {
|
||||
state,
|
||||
render: {
|
||||
node: host.messageStreamNode,
|
||||
},
|
||||
},
|
||||
composer: {
|
||||
state,
|
||||
thread: {
|
||||
restoredPlaceholder: host.restoredThreadPlaceholder,
|
||||
},
|
||||
runtime: {
|
||||
snapshot: host.runtimeSnapshot,
|
||||
requestModel: (model) => controllers.runtime.settings.requestModelFromUi(model),
|
||||
requestReasoningEffort: (effort) => controllers.runtime.settings.requestReasoningEffortFromUi(effort),
|
||||
resetReasoningEffortToConfig: () => controllers.runtime.settings.resetReasoningEffortToConfigFromUi(),
|
||||
|
|
@ -1,36 +1,23 @@
|
|||
import type { ComponentChild as UiNode } from "preact";
|
||||
import { h } from "preact";
|
||||
import { useComputed } from "@preact/signals";
|
||||
|
||||
import type { GoalRegionActions, GoalRegionOptions } from "../../ui/goal";
|
||||
import { goalRegionNode } from "../../ui/goal";
|
||||
import { useChatPanelShellState } from "../../ui/shell";
|
||||
import type { GoalPanelActions, GoalPanelOptions } from "../../ui/goal";
|
||||
import { GoalPanel } from "../../ui/goal";
|
||||
import { goalStateFromShellState, useChatPanelShellState, type ChatPanelGoalShellState } from "../../ui/shell-state";
|
||||
import type { ChatPanelGoalPorts } from "./ports";
|
||||
|
||||
export function chatPanelGoalRegionNode(ports: ChatPanelGoalPorts): UiNode {
|
||||
return h(GoalRegion, { ports });
|
||||
}
|
||||
|
||||
function GoalRegion({ ports }: { ports: ChatPanelGoalPorts }): UiNode {
|
||||
const { activeThread, ui, renderVersion, latestState } = useChatPanelShellState();
|
||||
const props = useComputed(() => {
|
||||
void renderVersion.value;
|
||||
return chatPanelGoalProps(ports, {
|
||||
...latestState(),
|
||||
activeThread: activeThread.value,
|
||||
ui: ui.value,
|
||||
});
|
||||
});
|
||||
return goalRegionNode(props.value.goal, props.value.actions, props.value.options);
|
||||
export function ChatPanelGoal({ ports }: { ports: ChatPanelGoalPorts }): UiNode {
|
||||
const props = chatPanelGoalProps(ports, goalStateFromShellState(useChatPanelShellState()));
|
||||
return h(GoalPanel, props);
|
||||
}
|
||||
|
||||
export function chatPanelGoalProps(
|
||||
ports: ChatPanelGoalPorts,
|
||||
state: ReturnType<ChatPanelGoalPorts["state"]["chat"]>,
|
||||
state: ChatPanelGoalShellState,
|
||||
): {
|
||||
goal: ReturnType<ChatPanelGoalPorts["state"]["chat"]>["activeThread"]["goal"];
|
||||
actions: GoalRegionActions;
|
||||
options: GoalRegionOptions;
|
||||
goal: ChatPanelGoalShellState["activeThread"]["goal"];
|
||||
actions: GoalPanelActions;
|
||||
options: GoalPanelOptions;
|
||||
} {
|
||||
const goal = state.activeThread.goal;
|
||||
const goalThreadId = goal?.threadId ?? null;
|
||||
|
|
@ -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);
|
||||
14
src/features/chat/panel/surface/message-stream.ts
Normal file
14
src/features/chat/panel/surface/message-stream.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import type { ComponentChild as UiNode } from "preact";
|
||||
import { h } from "preact";
|
||||
|
||||
import { MessageStreamViewport, type MessageStreamViewportState } from "../../ui/message-stream/viewport";
|
||||
import { messageStreamStateFromShellState, useChatPanelShellState, type ChatPanelMessageStreamShellState } from "../../ui/shell-state";
|
||||
|
||||
export interface ChatPanelMessageStreamRenderer {
|
||||
renderState(state: ChatPanelMessageStreamShellState): MessageStreamViewportState;
|
||||
}
|
||||
|
||||
export function ChatPanelMessageStream({ renderer }: { renderer: ChatPanelMessageStreamRenderer }): UiNode {
|
||||
const state = messageStreamStateFromShellState(useChatPanelShellState());
|
||||
return h(MessageStreamViewport, { state: renderer.renderState(state) });
|
||||
}
|
||||
|
|
@ -1,7 +1,5 @@
|
|||
import type { ComponentChild as UiNode } from "preact";
|
||||
import type { ChatState } from "../../state/reducer";
|
||||
import type { Signal } from "@preact/signals";
|
||||
import type { ReasoningEffort } from "../../../../domain/catalog/metadata";
|
||||
import type { RuntimeSnapshot } from "../../runtime/snapshot";
|
||||
import type { SendShortcut } from "../../../../shared/ui/keyboard";
|
||||
import type { ToolbarActions } from "../../ui/toolbar";
|
||||
import type { ToolbarThreadRow } from "../../ui/toolbar";
|
||||
|
|
@ -13,10 +11,9 @@ export interface RestoredThreadTitleSnapshot {
|
|||
}
|
||||
|
||||
interface ChatPanelToolbarState {
|
||||
archiveConfirmId: () => string | null;
|
||||
archiveConfirmSubscribe: (listener: () => void) => () => void;
|
||||
archiveConfirm: Signal<string | null>;
|
||||
renameState: (threadId: string) => ToolbarThreadRow["rename"];
|
||||
renameSubscribe: (listener: () => void) => () => void;
|
||||
renameVersion: Signal<number>;
|
||||
}
|
||||
|
||||
type ChatPanelToolbarActions = ToolbarActions;
|
||||
|
|
@ -28,25 +25,15 @@ interface ChatPanelGoalActions {
|
|||
setEditingOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
interface ChatPanelStatePort {
|
||||
export interface ChatPanelToolbarPorts {
|
||||
state: {
|
||||
chat: () => ChatState;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ChatPanelToolbarPorts extends ChatPanelStatePort {
|
||||
state: ChatPanelStatePort["state"] & {
|
||||
connected: () => boolean;
|
||||
turnBusy: () => boolean;
|
||||
};
|
||||
settings: {
|
||||
vaultPath: () => string;
|
||||
configuredCommand: () => string;
|
||||
archiveExportEnabled: () => boolean;
|
||||
};
|
||||
runtime: {
|
||||
snapshot: () => RuntimeSnapshot;
|
||||
};
|
||||
view: {
|
||||
toolbar: ChatPanelToolbarState;
|
||||
};
|
||||
|
|
@ -55,7 +42,7 @@ export interface ChatPanelToolbarPorts extends ChatPanelStatePort {
|
|||
};
|
||||
}
|
||||
|
||||
export interface ChatPanelGoalPorts extends ChatPanelStatePort {
|
||||
export interface ChatPanelGoalPorts {
|
||||
settings: {
|
||||
sendShortcut: () => SendShortcut;
|
||||
};
|
||||
|
|
@ -64,27 +51,19 @@ export interface ChatPanelGoalPorts extends ChatPanelStatePort {
|
|||
};
|
||||
}
|
||||
|
||||
export interface ChatPanelMessageStreamPorts extends ChatPanelStatePort {
|
||||
render: {
|
||||
node: () => UiNode;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ChatPanelComposerPorts extends ChatPanelStatePort {
|
||||
export interface ChatPanelComposerPorts {
|
||||
thread: {
|
||||
restoredPlaceholder: () => RestoredThreadTitleSnapshot | null;
|
||||
};
|
||||
runtime: {
|
||||
snapshot: () => RuntimeSnapshot;
|
||||
requestModel: (model: string) => Promise<void>;
|
||||
requestReasoningEffort: (effort: ReasoningEffort) => Promise<void>;
|
||||
resetReasoningEffortToConfig: () => Promise<void>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ChatPanelRegionPorts {
|
||||
export interface ChatPanelSurfacePorts {
|
||||
toolbar: ChatPanelToolbarPorts;
|
||||
goal: ChatPanelGoalPorts;
|
||||
messageStream: ChatPanelMessageStreamPorts;
|
||||
composer: ChatPanelComposerPorts;
|
||||
}
|
||||
130
src/features/chat/panel/surface/toolbar.ts
Normal file
130
src/features/chat/panel/surface/toolbar.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import type { ComponentChild as UiNode } from "preact";
|
||||
import { h } from "preact";
|
||||
|
||||
import type { Thread } from "../../../../domain/threads/model";
|
||||
import { getThreadTitle } from "../../../../domain/threads/model";
|
||||
import { runtimeConfigSections, rateLimitSummary } from "../../display/status/runtime";
|
||||
import { connectionDiagnosticSections } from "../../display/status/diagnostics";
|
||||
import type { RuntimeSnapshot } from "../../runtime/snapshot";
|
||||
import { runtimeSnapshotForChatSlices } from "../../runtime/snapshot";
|
||||
import { chatTurnBusy, type ChatState } from "../../state/reducer";
|
||||
import { messageStreamDisplayItems } from "../../state/message-stream";
|
||||
import { toolbarStateFromShellState, useChatPanelShellState, type ChatPanelToolbarShellState } from "../../ui/shell-state";
|
||||
import { Toolbar, type ToolbarThreadRow, type ToolbarViewModel } from "../../ui/toolbar";
|
||||
import type { ChatPanelToolbarPorts } from "./ports";
|
||||
|
||||
type ToolbarState = Pick<ChatState, "connection" | "threadList" | "activeThread" | "ui">;
|
||||
|
||||
export interface ToolbarViewModelInput {
|
||||
state: ToolbarState;
|
||||
snapshot: RuntimeSnapshot;
|
||||
connected: boolean;
|
||||
turnBusy: boolean;
|
||||
vaultPath: string;
|
||||
configuredCommand: string;
|
||||
archiveConfirmThreadId: string | null;
|
||||
archiveExportEnabled: boolean;
|
||||
renameRevision: number;
|
||||
renameState: (threadId: string, renameRevision: number) => ToolbarThreadRow["rename"];
|
||||
}
|
||||
|
||||
export interface ConnectionDiagnosticsModelInput {
|
||||
state: Pick<ChatState, "connection">;
|
||||
connected: boolean;
|
||||
configuredCommand: string;
|
||||
}
|
||||
|
||||
function chatPanelToolbarViewModel(ports: ChatPanelToolbarPorts, state: ChatPanelToolbarShellState) {
|
||||
return toolbarViewModel({
|
||||
state,
|
||||
snapshot: runtimeSnapshotForChatSlices({
|
||||
runtimeConfig: state.connection.runtimeConfig,
|
||||
activeThread: state.activeThread,
|
||||
runtime: state.runtime,
|
||||
rateLimit: state.connection.rateLimit,
|
||||
displayItems: messageStreamDisplayItems(state.messageStream),
|
||||
availableModels: state.connection.availableModels,
|
||||
}),
|
||||
connected: ports.state.connected(),
|
||||
turnBusy: chatTurnBusy(state),
|
||||
vaultPath: ports.settings.vaultPath(),
|
||||
configuredCommand: ports.settings.configuredCommand(),
|
||||
archiveConfirmThreadId: ports.view.toolbar.archiveConfirm.value,
|
||||
archiveExportEnabled: ports.settings.archiveExportEnabled(),
|
||||
renameRevision: ports.view.toolbar.renameVersion.value,
|
||||
renameState: (threadId, _renameRevision) => ports.view.toolbar.renameState(threadId),
|
||||
});
|
||||
}
|
||||
|
||||
export function ChatPanelToolbar({ ports }: { ports: ChatPanelToolbarPorts }): UiNode {
|
||||
const state = toolbarStateFromShellState(useChatPanelShellState());
|
||||
return h(Toolbar, { model: chatPanelToolbarViewModel(ports, state), actions: ports.actions.toolbar });
|
||||
}
|
||||
|
||||
export function toolbarViewModel(input: ToolbarViewModelInput): ToolbarViewModel {
|
||||
const { state, snapshot } = input;
|
||||
const limit = rateLimitSummary(snapshot, Date.now());
|
||||
const historyOpen = state.ui.toolbarPanel === "history";
|
||||
const chatActionsOpen = state.ui.toolbarPanel === "chat-actions";
|
||||
const statusPanelOpen = state.ui.toolbarPanel === "status-panel";
|
||||
return {
|
||||
newChatDisabled: input.turnBusy,
|
||||
chatActionsOpen,
|
||||
historyOpen,
|
||||
statusPanelOpen,
|
||||
rateLimit: limit,
|
||||
configSections: runtimeConfigSections(snapshot, input.vaultPath),
|
||||
openPanel: historyOpen ? "history" : chatActionsOpen ? "chat-actions" : statusPanelOpen ? "status" : null,
|
||||
threads: toolbarThreadRows({
|
||||
threads: state.threadList.listedThreads,
|
||||
activeThreadId: state.activeThread.id,
|
||||
turnBusy: input.turnBusy,
|
||||
archiveConfirmThreadId: input.archiveConfirmThreadId,
|
||||
archiveExportEnabled: input.archiveExportEnabled,
|
||||
renameRevision: input.renameRevision,
|
||||
renameState: input.renameState,
|
||||
}),
|
||||
connectLabel: input.connected ? "Reconnect" : "Connect",
|
||||
diagnostics: connectionDiagnosticsModel({
|
||||
state,
|
||||
connected: input.connected,
|
||||
configuredCommand: input.configuredCommand,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function toolbarThreadRows(input: {
|
||||
threads: readonly Thread[];
|
||||
activeThreadId: string | null;
|
||||
turnBusy: boolean;
|
||||
archiveConfirmThreadId: string | null;
|
||||
archiveExportEnabled: boolean;
|
||||
renameRevision: number;
|
||||
renameState: (threadId: string, renameRevision: number) => ToolbarThreadRow["rename"];
|
||||
}): ToolbarThreadRow[] {
|
||||
const renameRevision = input.renameRevision;
|
||||
return input.threads.map((thread) => {
|
||||
const threadId = thread.id;
|
||||
return {
|
||||
title: getThreadTitle(thread),
|
||||
threadId,
|
||||
selected: threadId === input.activeThreadId,
|
||||
disabled: input.turnBusy && threadId !== input.activeThreadId,
|
||||
canArchive: true,
|
||||
archiveConfirm: {
|
||||
active: input.archiveConfirmThreadId === threadId,
|
||||
defaultSaveMarkdown: input.archiveExportEnabled,
|
||||
},
|
||||
rename: input.renameState(threadId, renameRevision),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function connectionDiagnosticsModel(input: ConnectionDiagnosticsModelInput): ReturnType<typeof connectionDiagnosticSections> {
|
||||
return connectionDiagnosticSections({
|
||||
connected: input.connected,
|
||||
configuredCommand: input.configuredCommand,
|
||||
initializeResponse: input.state.connection.initializeResponse,
|
||||
diagnostics: input.state.connection.serverDiagnostics,
|
||||
});
|
||||
}
|
||||
134
src/features/chat/panel/toolbar-actions.ts
Normal file
134
src/features/chat/panel/toolbar-actions.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import { signal, type Signal } from "@preact/signals";
|
||||
|
||||
import type { ChatThreadActions } from "../threads/action-context";
|
||||
import type { ChatAction, ChatState, ChatStateStore } from "../state/reducer";
|
||||
|
||||
export interface ToolbarPanelActionsHost {
|
||||
stateStore: ChatStateStore;
|
||||
threadActions: ChatThreadActions;
|
||||
archiveConfirm: ToolbarArchiveConfirmState;
|
||||
scheduleRender: () => void;
|
||||
}
|
||||
|
||||
export interface ToolbarArchiveConfirmState {
|
||||
id: Signal<string | null>;
|
||||
get: () => string | null;
|
||||
set: (threadId: string | null) => void;
|
||||
}
|
||||
|
||||
export interface ToolbarPanelActions {
|
||||
archiveConfirm: Signal<string | null>;
|
||||
archiveConfirmId(): string | null;
|
||||
toggleHistory(): void;
|
||||
toggleChatActions(): void;
|
||||
closeToolbarPanels(): void;
|
||||
toggleStatus(): void;
|
||||
closeForThreadSelection(): void;
|
||||
startArchive(threadId: string): void;
|
||||
archiveThread(threadId: string, saveMarkdown: boolean): Promise<void>;
|
||||
closeOnOutsidePointer(context: ToolbarOutsidePointerContext): void;
|
||||
}
|
||||
|
||||
interface ToolbarOutsidePointerContext {
|
||||
target: EventTarget | null;
|
||||
viewWindow: ToolbarDomWindow | null;
|
||||
contains: (element: Element) => boolean;
|
||||
renameEditing: boolean;
|
||||
}
|
||||
|
||||
type ToolbarDomWindow = Window & { Element: typeof Element };
|
||||
|
||||
export function createToolbarPanelActions(host: ToolbarPanelActionsHost): ToolbarPanelActions {
|
||||
const state = (): ChatState => host.stateStore.getState();
|
||||
const dispatch = (action: ChatAction): void => {
|
||||
host.stateStore.dispatch(action);
|
||||
};
|
||||
const hasOpenPanel = (): boolean => state().ui.toolbarPanel !== null;
|
||||
const close = (): void => {
|
||||
if (!hasOpenPanel()) return;
|
||||
|
||||
dispatch({ type: "ui/panel-set", panel: null });
|
||||
host.archiveConfirm.set(null);
|
||||
host.scheduleRender();
|
||||
};
|
||||
|
||||
return {
|
||||
archiveConfirm: host.archiveConfirm.id,
|
||||
|
||||
archiveConfirmId(): string | null {
|
||||
return host.archiveConfirm.get();
|
||||
},
|
||||
|
||||
toggleHistory(): void {
|
||||
dispatch({ type: "ui/panel-set", panel: "history", toggle: true });
|
||||
host.scheduleRender();
|
||||
},
|
||||
|
||||
toggleChatActions(): void {
|
||||
dispatch({ type: "ui/panel-set", panel: "chat-actions", toggle: true });
|
||||
host.scheduleRender();
|
||||
},
|
||||
|
||||
closeToolbarPanels(): void {
|
||||
close();
|
||||
},
|
||||
|
||||
toggleStatus(): void {
|
||||
dispatch({ type: "ui/panel-set", panel: "status-panel", toggle: true });
|
||||
host.scheduleRender();
|
||||
},
|
||||
|
||||
closeForThreadSelection(): void {
|
||||
host.archiveConfirm.set(null);
|
||||
},
|
||||
|
||||
startArchive(threadId: string): void {
|
||||
host.archiveConfirm.set(threadId);
|
||||
},
|
||||
|
||||
async archiveThread(threadId: string, saveMarkdown: boolean): Promise<void> {
|
||||
if (host.archiveConfirm.get() === threadId) host.archiveConfirm.set(null);
|
||||
await host.threadActions.archiveThread(threadId, saveMarkdown);
|
||||
host.scheduleRender();
|
||||
},
|
||||
|
||||
closeOnOutsidePointer(context: ToolbarOutsidePointerContext): void {
|
||||
if (!hasOpenPanel()) return;
|
||||
|
||||
const target = context.target;
|
||||
if (isToolbarElement(target, context.viewWindow)) {
|
||||
const insideToolbarPanel = target.closest(".codex-panel__toolbar-primary, .codex-panel__toolbar-panel");
|
||||
if (insideToolbarPanel && context.contains(insideToolbarPanel)) {
|
||||
if (host.archiveConfirm.get() && !target.closest(".codex-panel__archive-confirm")) {
|
||||
host.archiveConfirm.set(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (host.archiveConfirm.get()) {
|
||||
host.archiveConfirm.set(null);
|
||||
}
|
||||
|
||||
if (context.renameEditing) return;
|
||||
|
||||
close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isToolbarElement(target: EventTarget | null, viewWindow: ToolbarDomWindow | null): target is Element {
|
||||
return Boolean(viewWindow && target instanceof viewWindow.Element);
|
||||
}
|
||||
|
||||
export function createToolbarArchiveConfirmState(): ToolbarArchiveConfirmState {
|
||||
const id = signal<string | null>(null);
|
||||
return {
|
||||
id,
|
||||
get: () => id.value,
|
||||
set: (nextThreadId) => {
|
||||
if (id.value === nextThreadId) return;
|
||||
id.value = nextThreadId;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -10,8 +10,6 @@ export interface ChatMessageStreamActiveSegment {
|
|||
}
|
||||
|
||||
export interface ChatMessageStreamState {
|
||||
/** Compatibility projection for tests and legacy fixtures. Runtime code should use messageStreamDisplayItems. */
|
||||
displayItems: readonly DisplayItem[];
|
||||
stableItems: readonly DisplayItem[];
|
||||
activeSegment: ChatMessageStreamActiveSegment | null;
|
||||
turnDiffs: ReadonlyMap<string, string>;
|
||||
|
|
@ -61,19 +59,17 @@ export type MessageStreamAction =
|
|||
| { type: "message-stream/turn-diff-updated"; turnId: string; diff: string };
|
||||
|
||||
export function initialChatMessageStreamState(items: readonly DisplayItem[] = []): ChatMessageStreamState {
|
||||
return withDisplayItemsAccessor({
|
||||
return {
|
||||
stableItems: items,
|
||||
activeSegment: null,
|
||||
turnDiffs: new Map(),
|
||||
historyCursor: null,
|
||||
loadingHistory: false,
|
||||
reportedLogs: new Set(),
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function messageStreamDisplayItems(state: Pick<ChatMessageStreamState, "stableItems" | "activeSegment">): readonly DisplayItem[] {
|
||||
const legacyItems = legacyDisplayItems(state);
|
||||
if (legacyItems && state.stableItems.length === 0 && (!state.activeSegment || state.activeSegment.items.length === 0)) return legacyItems;
|
||||
if (!state.activeSegment || state.activeSegment.items.length === 0) return state.stableItems;
|
||||
return [...state.stableItems, ...state.activeSegment.items];
|
||||
}
|
||||
|
|
@ -87,8 +83,6 @@ export function messageStreamActiveItems(state: Pick<ChatMessageStreamState, "ac
|
|||
}
|
||||
|
||||
export function messageStreamIsEmpty(state: Pick<ChatMessageStreamState, "stableItems" | "activeSegment">): boolean {
|
||||
const legacyItems = legacyDisplayItems(state);
|
||||
if (legacyItems) return legacyItems.length === 0;
|
||||
return state.stableItems.length === 0 && (!state.activeSegment || state.activeSegment.items.length === 0);
|
||||
}
|
||||
|
||||
|
|
@ -451,35 +445,9 @@ function updatedTurnDiffs(turnDiffs: ReadonlyMap<string, string>, turnId: string
|
|||
|
||||
function patchObject<T extends object>(current: T, patch: Partial<T>): T {
|
||||
if (Object.entries(patch).every(([key, value]) => Object.is(current[key as keyof T], value))) return current;
|
||||
const next = { ...current, ...patch };
|
||||
return isMessageStreamState(next) ? (withDisplayItemsAccessor(next) as unknown as T) : next;
|
||||
return { ...current, ...patch };
|
||||
}
|
||||
|
||||
function definedPatch<Key extends string, Value>(key: Key, value: Value | undefined): Partial<Record<Key, Value>> {
|
||||
return value === undefined ? {} : ({ [key]: value } as Partial<Record<Key, Value>>);
|
||||
}
|
||||
|
||||
function isMessageStreamState(value: object): value is ChatMessageStreamState {
|
||||
return "stableItems" in value && "activeSegment" in value && "turnDiffs" in value && "reportedLogs" in value;
|
||||
}
|
||||
|
||||
function legacyDisplayItems(state: object): readonly DisplayItem[] | null {
|
||||
if (!Object.prototype.propertyIsEnumerable.call(state, "displayItems")) return null;
|
||||
const value = (state as { displayItems?: unknown }).displayItems;
|
||||
return Array.isArray(value) ? (value as readonly DisplayItem[]) : null;
|
||||
}
|
||||
|
||||
function withDisplayItemsAccessor(state: Omit<ChatMessageStreamState, "displayItems">): ChatMessageStreamState {
|
||||
Object.defineProperty(state, "displayItems", {
|
||||
configurable: true,
|
||||
enumerable: false,
|
||||
get() {
|
||||
return messageStreamDisplayItems(this as ChatMessageStreamState);
|
||||
},
|
||||
set(items: readonly DisplayItem[]) {
|
||||
(this as ChatMessageStreamState).stableItems = items;
|
||||
(this as ChatMessageStreamState).activeSegment = null;
|
||||
},
|
||||
});
|
||||
return state as unknown as ChatMessageStreamState;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -55,20 +55,14 @@ export function implementPlanCandidateFromState(state: {
|
|||
activeThread: Pick<ChatActiveThreadState, "id">;
|
||||
turn: ChatTurnState;
|
||||
runtime: Pick<ChatRuntimeState, "selectedCollaborationMode">;
|
||||
messageStream: Pick<ChatMessageStreamState, "stableItems" | "activeSegment"> | Pick<ChatMessageStreamState, "displayItems">;
|
||||
messageStream: Pick<ChatMessageStreamState, "stableItems" | "activeSegment">;
|
||||
}): DisplayItem | null {
|
||||
if (!state.activeThread.id || chatTurnBusy(state) || state.runtime.selectedCollaborationMode !== "plan") {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
[...selectorDisplayItems(state.messageStream)]
|
||||
[...messageStreamDisplayItems(state.messageStream)]
|
||||
.reverse()
|
||||
.find((item) => item.kind === "message" && item.messageKind === "proposedPlan") ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function selectorDisplayItems(
|
||||
messageStream: Pick<ChatMessageStreamState, "stableItems" | "activeSegment"> | Pick<ChatMessageStreamState, "displayItems">,
|
||||
): readonly DisplayItem[] {
|
||||
return "stableItems" in messageStream ? messageStreamDisplayItems(messageStream) : messageStream.displayItems;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,14 +15,11 @@ import { createSelectionActions } from "./selection-actions";
|
|||
import { RestorationController } from "./restoration-controller";
|
||||
import type { ChatThreadActions, ChatThreadActionsHost } from "./action-context";
|
||||
import type { ChatResumeWorkTracker, ChatViewDeferredTasks } from "../lifecycle";
|
||||
import type { ChatControllerCompositionPorts } from "../composition-ports";
|
||||
import type { ChatControllerPorts } from "../controller-ports";
|
||||
|
||||
type ThreadControllerGroupPorts = Pick<
|
||||
ChatControllerCompositionPorts,
|
||||
"obsidian" | "plugin" | "state" | "lifecycle" | "thread" | "liveState"
|
||||
> & {
|
||||
type ThreadControllerGroupPorts = Pick<ChatControllerPorts, "obsidian" | "plugin" | "state" | "lifecycle" | "thread" | "liveState"> & {
|
||||
client: {
|
||||
getClient: ChatControllerCompositionPorts["client"]["getClient"];
|
||||
getClient: ChatControllerPorts["client"]["getClient"];
|
||||
ensureConnected: () => Promise<void>;
|
||||
};
|
||||
lifecycle: {
|
||||
|
|
@ -41,7 +38,7 @@ type ThreadControllerGroupPorts = Pick<
|
|||
set: (status: string) => void;
|
||||
addSystemMessage: (text: string) => void;
|
||||
};
|
||||
scroll: Pick<ChatControllerCompositionPorts["scroll"], "preservePosition" | "forceBottom">;
|
||||
scroll: Pick<ChatControllerPorts["scroll"], "preservePosition" | "forceBottom">;
|
||||
render: {
|
||||
now: () => void;
|
||||
};
|
||||
|
|
@ -199,11 +196,11 @@ export function createThreadControllerGroup(
|
|||
}
|
||||
|
||||
function requireThreadController<T>(controller: T | null, name: string): T {
|
||||
if (!controller) throw new Error(`Chat thread controller composition did not initialize ${name}.`);
|
||||
if (!controller) throw new Error(`Chat thread controller graph did not initialize ${name}.`);
|
||||
return controller;
|
||||
}
|
||||
|
||||
type ThreadSelectionActionGroupPorts = Pick<ChatControllerCompositionPorts, "plugin" | "state"> & {
|
||||
type ThreadSelectionActionGroupPorts = Pick<ChatControllerPorts, "plugin" | "state"> & {
|
||||
status: {
|
||||
addSystemMessage: (text: string) => void;
|
||||
};
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -54,67 +54,7 @@ type ButtonProps = ButtonHTMLAttributes & {
|
|||
disabled?: boolean | undefined;
|
||||
};
|
||||
|
||||
const DEFAULT_COMPOSER_META: ComposerMetaViewModel = {
|
||||
fatal: null,
|
||||
context: {
|
||||
cells: [
|
||||
{ text: "⣀", placeholder: true },
|
||||
{ text: "⣀", placeholder: true },
|
||||
{ text: "⣀", placeholder: true },
|
||||
{ text: "⣀", placeholder: true },
|
||||
],
|
||||
percent: "--%",
|
||||
},
|
||||
statusSummary: "Context unavailable, plan off, auto-review off, fast off, model default, reasoning effort default",
|
||||
model: "default",
|
||||
effort: null,
|
||||
planActive: false,
|
||||
autoReviewActive: false,
|
||||
fastActive: false,
|
||||
modelChoices: [],
|
||||
effortChoices: [],
|
||||
};
|
||||
|
||||
export function composerShellNode(
|
||||
viewId: string,
|
||||
draft: string,
|
||||
busy: boolean,
|
||||
canInterrupt: boolean,
|
||||
normalPlaceholder: string,
|
||||
suggestions: readonly ComposerSuggestion[],
|
||||
selectedSuggestionIndex: number,
|
||||
callbacks: ComposerCallbacks,
|
||||
meta: ComposerMetaViewModel = DEFAULT_COMPOSER_META,
|
||||
onComposer: (composer: HTMLTextAreaElement | null) => void,
|
||||
): UiNode {
|
||||
return (
|
||||
<ComposerShell
|
||||
viewId={viewId}
|
||||
draft={draft}
|
||||
busy={busy}
|
||||
canInterrupt={canInterrupt}
|
||||
normalPlaceholder={normalPlaceholder}
|
||||
meta={meta}
|
||||
suggestions={suggestions}
|
||||
selectedSuggestionIndex={selectedSuggestionIndex}
|
||||
callbacks={callbacks}
|
||||
onComposer={onComposer}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ComposerShell({
|
||||
viewId,
|
||||
draft,
|
||||
busy,
|
||||
canInterrupt,
|
||||
normalPlaceholder,
|
||||
meta,
|
||||
suggestions,
|
||||
selectedSuggestionIndex,
|
||||
callbacks,
|
||||
onComposer,
|
||||
}: {
|
||||
export interface ComposerShellProps {
|
||||
viewId: string;
|
||||
draft: string;
|
||||
busy: boolean;
|
||||
|
|
@ -125,7 +65,20 @@ function ComposerShell({
|
|||
selectedSuggestionIndex: number;
|
||||
callbacks: ComposerCallbacks;
|
||||
onComposer: (composer: HTMLTextAreaElement | null) => void;
|
||||
}): UiNode {
|
||||
}
|
||||
|
||||
export function ComposerShell({
|
||||
viewId,
|
||||
draft,
|
||||
busy,
|
||||
canInterrupt,
|
||||
normalPlaceholder,
|
||||
meta,
|
||||
suggestions,
|
||||
selectedSuggestionIndex,
|
||||
callbacks,
|
||||
onComposer,
|
||||
}: ComposerShellProps): UiNode {
|
||||
const composerRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const suggestionsRef = useRef<HTMLDivElement | null>(null);
|
||||
const selectedSuggestionRef = useRef<HTMLDivElement | null>(null);
|
||||
|
|
|
|||
|
|
@ -6,31 +6,27 @@ import { isComposerSendKey, type SendShortcut } from "../../../shared/ui/keyboar
|
|||
import { IconButton } from "../../../shared/ui/components";
|
||||
import { syncTextareaHeight } from "../../../shared/ui/textarea-autogrow";
|
||||
|
||||
export interface GoalRegionActions {
|
||||
export interface GoalPanelActions {
|
||||
onSave: (objective: string, tokenBudget: number | null) => void;
|
||||
onPause: () => void;
|
||||
onResume: () => void;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
export interface GoalRegionOptions {
|
||||
export interface GoalPanelOptions {
|
||||
sendShortcut: SendShortcut;
|
||||
editingRequested?: boolean | undefined;
|
||||
onEditingChange?: (editing: boolean) => void;
|
||||
}
|
||||
|
||||
export function goalRegionNode(goal: ThreadGoal | null, actions: GoalRegionActions, options: GoalRegionOptions): UiNode {
|
||||
return <GoalRegion goal={goal} actions={actions} options={options} />;
|
||||
}
|
||||
|
||||
function GoalRegion({
|
||||
export function GoalPanel({
|
||||
goal,
|
||||
actions,
|
||||
options,
|
||||
}: {
|
||||
goal: ThreadGoal | null;
|
||||
actions: GoalRegionActions;
|
||||
options: GoalRegionOptions;
|
||||
actions: GoalPanelActions;
|
||||
options: GoalPanelOptions;
|
||||
}): UiNode {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [objective, setObjective] = useState(goal?.objective ?? "");
|
||||
|
|
|
|||
|
|
@ -14,11 +14,7 @@ export interface MessageStreamViewportState {
|
|||
registerVirtualizer?: (virtualizer: MessageStreamVirtualizerHandle) => () => void;
|
||||
}
|
||||
|
||||
export function messageStreamViewportNode(state: MessageStreamViewportState): UiNode {
|
||||
return <MessageStreamViewport state={state} />;
|
||||
}
|
||||
|
||||
function MessageStreamViewport({ state }: { state: MessageStreamViewportState }): UiNode {
|
||||
export function MessageStreamViewport({ state }: { state: MessageStreamViewportState }): UiNode {
|
||||
const { blocks, consumeScrollIntent, registerVirtualizer } = state;
|
||||
const scrollElementRef = useRef<HTMLDivElement | null>(null);
|
||||
const virtualizer = useMessageStreamVirtualizer({ blocks, consumeScrollIntent, registerVirtualizer, scrollElementRef });
|
||||
|
|
|
|||
107
src/features/chat/ui/shell-state.tsx
Normal file
107
src/features/chat/ui/shell-state.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import { createContext } from "preact";
|
||||
import { useContext } from "preact/hooks";
|
||||
import { signal, type Signal } from "@preact/signals";
|
||||
|
||||
import type { ChatState } from "../state/reducer";
|
||||
|
||||
export interface ChatPanelShellState {
|
||||
connection: Signal<ChatState["connection"]>;
|
||||
threadList: Signal<ChatState["threadList"]>;
|
||||
activeThread: Signal<ChatState["activeThread"]>;
|
||||
runtime: Signal<ChatState["runtime"]>;
|
||||
turn: Signal<ChatState["turn"]>;
|
||||
messageStream: Signal<ChatState["messageStream"]>;
|
||||
requests: Signal<ChatState["requests"]>;
|
||||
composer: Signal<ChatState["composer"]>;
|
||||
ui: Signal<ChatState["ui"]>;
|
||||
}
|
||||
|
||||
export type ChatPanelToolbarShellState = Pick<
|
||||
ChatState,
|
||||
"connection" | "threadList" | "activeThread" | "runtime" | "turn" | "messageStream" | "ui"
|
||||
>;
|
||||
|
||||
export type ChatPanelGoalShellState = Pick<ChatState, "activeThread" | "ui">;
|
||||
|
||||
export type ChatPanelMessageStreamShellState = Pick<ChatState, "activeThread" | "runtime" | "turn" | "messageStream" | "requests" | "ui">;
|
||||
|
||||
export type ChatPanelComposerShellState = Pick<
|
||||
ChatState,
|
||||
"connection" | "threadList" | "activeThread" | "runtime" | "turn" | "messageStream" | "composer"
|
||||
>;
|
||||
|
||||
export const ChatPanelShellStateContext = createContext<ChatPanelShellState | null>(null);
|
||||
|
||||
export function createChatPanelShellState(initialState: ChatState): ChatPanelShellState {
|
||||
return {
|
||||
connection: signal(initialState.connection),
|
||||
threadList: signal(initialState.threadList),
|
||||
activeThread: signal(initialState.activeThread),
|
||||
runtime: signal(initialState.runtime),
|
||||
turn: signal(initialState.turn),
|
||||
messageStream: signal(initialState.messageStream),
|
||||
requests: signal(initialState.requests),
|
||||
composer: signal(initialState.composer),
|
||||
ui: signal(initialState.ui),
|
||||
};
|
||||
}
|
||||
|
||||
export function syncChatPanelShellState(shellState: ChatPanelShellState, nextState: ChatState): void {
|
||||
if (shellState.connection.value !== nextState.connection) shellState.connection.value = nextState.connection;
|
||||
if (shellState.threadList.value !== nextState.threadList) shellState.threadList.value = nextState.threadList;
|
||||
if (shellState.activeThread.value !== nextState.activeThread) shellState.activeThread.value = nextState.activeThread;
|
||||
if (shellState.runtime.value !== nextState.runtime) shellState.runtime.value = nextState.runtime;
|
||||
if (shellState.turn.value !== nextState.turn) shellState.turn.value = nextState.turn;
|
||||
if (shellState.messageStream.value !== nextState.messageStream) shellState.messageStream.value = nextState.messageStream;
|
||||
if (shellState.requests.value !== nextState.requests) shellState.requests.value = nextState.requests;
|
||||
if (shellState.composer.value !== nextState.composer) shellState.composer.value = nextState.composer;
|
||||
if (shellState.ui.value !== nextState.ui) shellState.ui.value = nextState.ui;
|
||||
}
|
||||
|
||||
export function toolbarStateFromShellState(shellState: ChatPanelShellState): ChatPanelToolbarShellState {
|
||||
return {
|
||||
connection: shellState.connection.value,
|
||||
threadList: shellState.threadList.value,
|
||||
activeThread: shellState.activeThread.value,
|
||||
runtime: shellState.runtime.value,
|
||||
turn: shellState.turn.value,
|
||||
messageStream: shellState.messageStream.value,
|
||||
ui: shellState.ui.value,
|
||||
};
|
||||
}
|
||||
|
||||
export function goalStateFromShellState(shellState: ChatPanelShellState): ChatPanelGoalShellState {
|
||||
return {
|
||||
activeThread: shellState.activeThread.value,
|
||||
ui: shellState.ui.value,
|
||||
};
|
||||
}
|
||||
|
||||
export function messageStreamStateFromShellState(shellState: ChatPanelShellState): ChatPanelMessageStreamShellState {
|
||||
return {
|
||||
activeThread: shellState.activeThread.value,
|
||||
runtime: shellState.runtime.value,
|
||||
turn: shellState.turn.value,
|
||||
messageStream: shellState.messageStream.value,
|
||||
requests: shellState.requests.value,
|
||||
ui: shellState.ui.value,
|
||||
};
|
||||
}
|
||||
|
||||
export function composerStateFromShellState(shellState: ChatPanelShellState): ChatPanelComposerShellState {
|
||||
return {
|
||||
connection: shellState.connection.value,
|
||||
threadList: shellState.threadList.value,
|
||||
activeThread: shellState.activeThread.value,
|
||||
runtime: shellState.runtime.value,
|
||||
turn: shellState.turn.value,
|
||||
messageStream: shellState.messageStream.value,
|
||||
composer: shellState.composer.value,
|
||||
};
|
||||
}
|
||||
|
||||
export function useChatPanelShellState(): ChatPanelShellState {
|
||||
const context = useContext(ChatPanelShellStateContext);
|
||||
if (!context) throw new Error("Chat panel shell state is only available inside ChatPanelShell.");
|
||||
return context;
|
||||
}
|
||||
|
|
@ -1,30 +1,24 @@
|
|||
import { createContext, type ComponentChild as UiNode } from "preact";
|
||||
import { useContext } from "preact/hooks";
|
||||
import { signal, type Signal } from "@preact/signals";
|
||||
import type { ComponentChild as UiNode } from "preact";
|
||||
import { renderUiRoot, unmountUiRoot } from "../../../shared/ui/ui-root";
|
||||
import type { ChatState, ChatStateStore } from "../state/reducer";
|
||||
import type { ChatStateStore } from "../state/reducer";
|
||||
import type { ChatPanelGoalPorts, ChatPanelToolbarPorts } from "../panel/surface/ports";
|
||||
import { ChatPanelToolbar } from "../panel/surface/toolbar";
|
||||
import { ChatPanelGoal } from "../panel/surface/goal";
|
||||
import { ChatPanelMessageStream, type ChatPanelMessageStreamRenderer } from "../panel/surface/message-stream";
|
||||
import { ChatPanelComposer, type ChatPanelComposerController } from "../panel/surface/composer";
|
||||
import { ChatPanelShellStateContext, createChatPanelShellState, syncChatPanelShellState, type ChatPanelShellState } from "./shell-state";
|
||||
|
||||
export interface ChatPanelShellState {
|
||||
connection: Signal<ChatState["connection"]>;
|
||||
threadList: Signal<ChatState["threadList"]>;
|
||||
activeThread: Signal<ChatState["activeThread"]>;
|
||||
runtime: Signal<ChatState["runtime"]>;
|
||||
turn: Signal<ChatState["turn"]>;
|
||||
messageStream: Signal<ChatState["messageStream"]>;
|
||||
requests: Signal<ChatState["requests"]>;
|
||||
composer: Signal<ChatState["composer"]>;
|
||||
ui: Signal<ChatState["ui"]>;
|
||||
renderVersion: Signal<number>;
|
||||
latestState: () => ChatState;
|
||||
export interface ChatPanelShellSlots {
|
||||
toolbar: ChatPanelToolbarPorts;
|
||||
goal: ChatPanelGoalPorts;
|
||||
messageStream: ChatPanelMessageStreamRenderer;
|
||||
composer: ChatPanelComposerController;
|
||||
}
|
||||
|
||||
export interface ChatPanelShellProps {
|
||||
stateStore: ChatStateStore;
|
||||
showToolbar: boolean;
|
||||
toolbarNode: () => UiNode;
|
||||
goalNode: () => UiNode;
|
||||
messageStreamNode: () => UiNode;
|
||||
composerNode: () => UiNode;
|
||||
slots: ChatPanelShellSlots;
|
||||
}
|
||||
|
||||
interface ChatPanelShellMount {
|
||||
|
|
@ -36,14 +30,12 @@ interface ChatPanelShellMount {
|
|||
}
|
||||
|
||||
const shellMounts = new WeakMap<HTMLElement, ChatPanelShellMount>();
|
||||
const ChatPanelShellStateContext = createContext<ChatPanelShellState | null>(null);
|
||||
|
||||
export function renderChatPanelShell(container: HTMLElement, props: ChatPanelShellProps): void {
|
||||
container.addClass("codex-panel");
|
||||
const existing = shellMounts.get(container);
|
||||
const mount = existing?.stateStore === props.stateStore ? existing : createShellMount(container, props);
|
||||
mount.props = props;
|
||||
mount.shellState.renderVersion.value += 1;
|
||||
renderMountedShell(container, mount);
|
||||
}
|
||||
|
||||
|
|
@ -61,16 +53,14 @@ function createShellMount(container: HTMLElement, props: ChatPanelShellProps): C
|
|||
const existing = shellMounts.get(container);
|
||||
existing?.unsubscribe();
|
||||
existing?.stopStatusBarClearanceSync();
|
||||
let latestState = props.stateStore.getState();
|
||||
const mount: ChatPanelShellMount = {
|
||||
props,
|
||||
stateStore: props.stateStore,
|
||||
shellState: createShellState(latestState, () => latestState),
|
||||
shellState: createChatPanelShellState(props.stateStore.getState()),
|
||||
unsubscribe: props.stateStore.subscribe(() => {
|
||||
const current = shellMounts.get(container);
|
||||
if (!current) return;
|
||||
latestState = props.stateStore.getState();
|
||||
syncShellState(current.shellState, latestState);
|
||||
syncChatPanelShellState(current.shellState, props.stateStore.getState());
|
||||
if (!uiRootIntact(container)) renderMountedShell(container, current);
|
||||
}),
|
||||
stopStatusBarClearanceSync: startStatusBarClearanceSync(container),
|
||||
|
|
@ -79,34 +69,6 @@ function createShellMount(container: HTMLElement, props: ChatPanelShellProps): C
|
|||
return mount;
|
||||
}
|
||||
|
||||
function createShellState(initialState: ChatState, latestState: () => ChatState): ChatPanelShellState {
|
||||
return {
|
||||
connection: signal(initialState.connection),
|
||||
threadList: signal(initialState.threadList),
|
||||
activeThread: signal(initialState.activeThread),
|
||||
runtime: signal(initialState.runtime),
|
||||
turn: signal(initialState.turn),
|
||||
messageStream: signal(initialState.messageStream),
|
||||
requests: signal(initialState.requests),
|
||||
composer: signal(initialState.composer),
|
||||
ui: signal(initialState.ui),
|
||||
renderVersion: signal(0),
|
||||
latestState,
|
||||
};
|
||||
}
|
||||
|
||||
function syncShellState(shellState: ChatPanelShellState, nextState: ChatState): void {
|
||||
if (shellState.connection.value !== nextState.connection) shellState.connection.value = nextState.connection;
|
||||
if (shellState.threadList.value !== nextState.threadList) shellState.threadList.value = nextState.threadList;
|
||||
if (shellState.activeThread.value !== nextState.activeThread) shellState.activeThread.value = nextState.activeThread;
|
||||
if (shellState.runtime.value !== nextState.runtime) shellState.runtime.value = nextState.runtime;
|
||||
if (shellState.turn.value !== nextState.turn) shellState.turn.value = nextState.turn;
|
||||
if (shellState.messageStream.value !== nextState.messageStream) shellState.messageStream.value = nextState.messageStream;
|
||||
if (shellState.requests.value !== nextState.requests) shellState.requests.value = nextState.requests;
|
||||
if (shellState.composer.value !== nextState.composer) shellState.composer.value = nextState.composer;
|
||||
if (shellState.ui.value !== nextState.ui) shellState.ui.value = nextState.ui;
|
||||
}
|
||||
|
||||
function renderMountedShell(container: HTMLElement, mount: ChatPanelShellMount): void {
|
||||
if (!uiRootIntact(container)) {
|
||||
unmountUiRoot(container);
|
||||
|
|
@ -126,32 +88,27 @@ function uiRootIntact(container: HTMLElement): boolean {
|
|||
);
|
||||
}
|
||||
|
||||
function ChatPanelShell({
|
||||
showToolbar,
|
||||
toolbarNode,
|
||||
goalNode,
|
||||
messageStreamNode,
|
||||
composerNode,
|
||||
shellState,
|
||||
}: ChatPanelShellProps & { shellState: ChatPanelShellState }): UiNode {
|
||||
function ChatPanelShell({ showToolbar, slots, shellState }: ChatPanelShellProps & { shellState: ChatPanelShellState }): UiNode {
|
||||
return (
|
||||
<ChatPanelShellStateContext.Provider value={shellState}>
|
||||
{showToolbar ? <div className="codex-panel__toolbar">{toolbarNode()}</div> : null}
|
||||
{showToolbar ? (
|
||||
<div className="codex-panel__toolbar">
|
||||
<ChatPanelToolbar ports={slots.toolbar} />
|
||||
</div>
|
||||
) : null}
|
||||
<div className="codex-panel__body">
|
||||
<div className="codex-panel__region codex-panel__region--goal">{goalNode()}</div>
|
||||
{messageStreamNode()}
|
||||
<div className="codex-panel__region codex-panel__region--composer">{composerNode()}</div>
|
||||
<div className="codex-panel__region codex-panel__region--goal">
|
||||
<ChatPanelGoal ports={slots.goal} />
|
||||
</div>
|
||||
<ChatPanelMessageStream renderer={slots.messageStream} />
|
||||
<div className="codex-panel__region codex-panel__region--composer">
|
||||
<ChatPanelComposer controller={slots.composer} />
|
||||
</div>
|
||||
</div>
|
||||
</ChatPanelShellStateContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useChatPanelShellState(): ChatPanelShellState {
|
||||
const context = useContext(ChatPanelShellStateContext);
|
||||
if (!context) throw new Error("Chat panel shell state is only available inside ChatPanelShell.");
|
||||
return context;
|
||||
}
|
||||
|
||||
function startStatusBarClearanceSync(container: HTMLElement): () => void {
|
||||
const win = container.ownerDocument.defaultView;
|
||||
if (!win) return () => undefined;
|
||||
|
|
|
|||
|
|
@ -64,11 +64,7 @@ export interface ToolbarActions {
|
|||
autoNameThread: (threadId: string) => void;
|
||||
}
|
||||
|
||||
export function toolbarNode(model: ToolbarViewModel, actions: ToolbarActions): UiNode {
|
||||
return <Toolbar model={model} actions={actions} />;
|
||||
}
|
||||
|
||||
function Toolbar({ model, actions }: { model: ToolbarViewModel; actions: ToolbarActions }): UiNode {
|
||||
export function Toolbar({ model, actions }: { model: ToolbarViewModel; actions: ToolbarActions }): UiNode {
|
||||
return (
|
||||
<>
|
||||
<div className="nav-header codex-panel__toolbar-primary">
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@
|
|||
|
||||
import type { App } from "obsidian";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { h } from "preact";
|
||||
|
||||
import { ChatComposerController } from "../../../../../src/features/chat/conversation/composer/controller";
|
||||
import { ComposerShell } from "../../../../../src/features/chat/ui/composer";
|
||||
import { createChatStateStore } from "../../../../../src/features/chat/state/reducer";
|
||||
import { renderUiRoot, unmountUiRoot } from "../../../../../src/shared/ui/ui-root";
|
||||
import type { SkillMetadata } from "../../../../../src/domain/catalog/metadata";
|
||||
|
|
@ -11,6 +13,10 @@ import { installObsidianDomShims } from "../../../../support/dom";
|
|||
|
||||
installObsidianDomShims();
|
||||
|
||||
function renderComposerController(parent: HTMLElement, controller: ChatComposerController): void {
|
||||
renderUiRoot(parent, h(ComposerShell, controller.renderState()));
|
||||
}
|
||||
|
||||
describe("ChatComposerController", () => {
|
||||
it("updates slash suggestions in the same render as the input", () => {
|
||||
const stateStore = createChatStateStore();
|
||||
|
|
@ -18,7 +24,7 @@ describe("ChatComposerController", () => {
|
|||
const controllerRef: { current: ChatComposerController | null } = { current: null };
|
||||
const renderShell = vi.fn(() => {
|
||||
if (!controllerRef.current) throw new Error("Expected controller.");
|
||||
renderUiRoot(parent, controllerRef.current.renderNode());
|
||||
renderComposerController(parent, controllerRef.current);
|
||||
});
|
||||
const controller = new ChatComposerController({
|
||||
app: app(),
|
||||
|
|
@ -26,9 +32,9 @@ describe("ChatComposerController", () => {
|
|||
viewId: "view",
|
||||
sendShortcut: () => "enter",
|
||||
scrollThreadFromComposerEdges: () => false,
|
||||
canInterrupt: () => false,
|
||||
composerPlaceholder: () => "Ask Codex to work on this task...",
|
||||
composerMeta: () => ({
|
||||
canInterrupt: (_state) => false,
|
||||
composerPlaceholder: (_state) => "Ask Codex to work on this task...",
|
||||
composerMeta: (_state) => ({
|
||||
fatal: null,
|
||||
context: {
|
||||
cells: [
|
||||
|
|
@ -73,7 +79,7 @@ describe("ChatComposerController", () => {
|
|||
let controller: ChatComposerController | null = null;
|
||||
const renderShell = vi.fn(() => {
|
||||
if (!controller) throw new Error("Expected controller.");
|
||||
renderUiRoot(parent, controller.renderNode());
|
||||
renderComposerController(parent, controller);
|
||||
});
|
||||
controller = new ChatComposerController({
|
||||
app: app(),
|
||||
|
|
@ -81,9 +87,9 @@ describe("ChatComposerController", () => {
|
|||
viewId: "view",
|
||||
sendShortcut: () => "enter",
|
||||
scrollThreadFromComposerEdges: () => false,
|
||||
canInterrupt: () => false,
|
||||
composerPlaceholder: () => "Ask Codex to work on this task...",
|
||||
composerMeta: () => ({
|
||||
canInterrupt: (_state) => false,
|
||||
composerPlaceholder: (_state) => "Ask Codex to work on this task...",
|
||||
composerMeta: (_state) => ({
|
||||
fatal: null,
|
||||
context: {
|
||||
cells: [
|
||||
|
|
@ -135,7 +141,7 @@ describe("ChatComposerController", () => {
|
|||
let controller: ChatComposerController | null = null;
|
||||
const renderShell = vi.fn(() => {
|
||||
if (!controller) throw new Error("Expected controller.");
|
||||
renderUiRoot(parent, controller.renderNode());
|
||||
renderComposerController(parent, controller);
|
||||
});
|
||||
controller = new ChatComposerController({
|
||||
app: app(),
|
||||
|
|
@ -143,9 +149,9 @@ describe("ChatComposerController", () => {
|
|||
viewId: "view",
|
||||
sendShortcut: () => "enter",
|
||||
scrollThreadFromComposerEdges: () => false,
|
||||
canInterrupt: () => false,
|
||||
composerPlaceholder: () => "Ask Codex to work on this task...",
|
||||
composerMeta: () => ({
|
||||
canInterrupt: (_state) => false,
|
||||
composerPlaceholder: (_state) => "Ask Codex to work on this task...",
|
||||
composerMeta: (_state) => ({
|
||||
fatal: null,
|
||||
context: {
|
||||
cells: [
|
||||
|
|
@ -198,9 +204,9 @@ describe("ChatComposerController", () => {
|
|||
viewId: "view",
|
||||
sendShortcut: () => "enter",
|
||||
scrollThreadFromComposerEdges: () => false,
|
||||
canInterrupt: () => false,
|
||||
composerPlaceholder: () => "Ask Codex to work on this task...",
|
||||
composerMeta: () => ({
|
||||
canInterrupt: (_state) => false,
|
||||
composerPlaceholder: (_state) => "Ask Codex to work on this task...",
|
||||
composerMeta: (_state) => ({
|
||||
fatal: null,
|
||||
context: {
|
||||
cells: [
|
||||
|
|
@ -226,7 +232,7 @@ describe("ChatComposerController", () => {
|
|||
onHeightChange: vi.fn(),
|
||||
});
|
||||
|
||||
renderUiRoot(parent, controller.renderNode());
|
||||
renderComposerController(parent, controller);
|
||||
expect(parent.querySelector<HTMLElement>(".codex-panel__composer-meta-icon")?.classList.contains("is-active")).toBe(false);
|
||||
|
||||
parent.querySelector<HTMLElement>(".codex-panel__composer-meta-icon")?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
|
||||
|
|
@ -246,9 +252,9 @@ describe("ChatComposerController", () => {
|
|||
viewId: "view",
|
||||
sendShortcut: () => "enter",
|
||||
scrollThreadFromComposerEdges: () => false,
|
||||
canInterrupt: () => false,
|
||||
composerPlaceholder: () => "Ask Codex to work on this task...",
|
||||
composerMeta: () => ({
|
||||
canInterrupt: (_state) => false,
|
||||
composerPlaceholder: (_state) => "Ask Codex to work on this task...",
|
||||
composerMeta: (_state) => ({
|
||||
fatal: null,
|
||||
context: {
|
||||
cells: [
|
||||
|
|
@ -278,7 +284,7 @@ describe("ChatComposerController", () => {
|
|||
threadScrollFromComposer: vi.fn(),
|
||||
});
|
||||
|
||||
renderUiRoot(parent, controller.renderNode());
|
||||
renderComposerController(parent, controller);
|
||||
composer(parent).dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }));
|
||||
|
||||
expect(submit).toHaveBeenCalledOnce();
|
||||
|
|
@ -294,9 +300,9 @@ describe("ChatComposerController", () => {
|
|||
viewId: "view",
|
||||
sendShortcut: () => "enter",
|
||||
scrollThreadFromComposerEdges: () => false,
|
||||
canInterrupt: () => false,
|
||||
composerPlaceholder: () => "Ask Codex to work on this task...",
|
||||
composerMeta: () => ({
|
||||
canInterrupt: (_state) => false,
|
||||
composerPlaceholder: (_state) => "Ask Codex to work on this task...",
|
||||
composerMeta: (_state) => ({
|
||||
fatal: null,
|
||||
context: {
|
||||
cells: [
|
||||
|
|
@ -322,7 +328,7 @@ describe("ChatComposerController", () => {
|
|||
onHeightChange: vi.fn(),
|
||||
});
|
||||
|
||||
renderUiRoot(parent, controller.renderNode());
|
||||
renderComposerController(parent, controller);
|
||||
const mountedComposer = composer(parent);
|
||||
setTextAreaValue(mountedComposer, "stale dom draft");
|
||||
const focus = vi.spyOn(mountedComposer, "focus");
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -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", () => {
|
||||
|
|
@ -15,6 +15,7 @@ import {
|
|||
import type { ServerNotification, ServerRequest } from "../../../../../src/app-server/connection/rpc-messages";
|
||||
import type { Thread as PanelThread } from "../../../../../src/domain/threads/model";
|
||||
import type { TurnRecord } from "../../../../../src/app-server/protocol/turn";
|
||||
import { chatStateDisplayItems, setChatStateDisplayItems } from "../../support/message-stream";
|
||||
|
||||
type ThreadStartedNotification = Extract<ServerNotification, { method: "thread/started" }>;
|
||||
|
||||
|
|
@ -68,14 +69,14 @@ describe("ChatInboundController", () => {
|
|||
params: { threadId: "thread-active", turnId: "turn-active", itemId: "a1", delta: "hello" },
|
||||
} satisfies Extract<ServerNotification, { method: "item/agentMessage/delta" }>);
|
||||
|
||||
expect(state.messageStream.displayItems).toMatchObject([{ id: "a1", text: "hello" }]);
|
||||
expect(chatStateDisplayItems(state)).toMatchObject([{ id: "a1", text: "hello" }]);
|
||||
});
|
||||
|
||||
it("marks active reasoning completed when assistant text starts", () => {
|
||||
const state = createChatState();
|
||||
state.activeThread.id = "thread-active";
|
||||
state.turn.lifecycle = { kind: "running", turnId: "turn-active" };
|
||||
state.messageStream.displayItems = [{ id: "r1", kind: "reasoning", role: "tool", text: "thinking", turnId: "turn-active" }];
|
||||
setChatStateDisplayItems(state, [{ id: "r1", kind: "reasoning", role: "tool", text: "thinking", turnId: "turn-active" }]);
|
||||
const controller = controllerForState(state);
|
||||
|
||||
controller.handleNotification({
|
||||
|
|
@ -83,7 +84,7 @@ describe("ChatInboundController", () => {
|
|||
params: { threadId: "thread-active", turnId: "turn-active", itemId: "a1", delta: "answer" },
|
||||
} satisfies Extract<ServerNotification, { method: "item/agentMessage/delta" }>);
|
||||
|
||||
expect(state.messageStream.displayItems).toEqual(
|
||||
expect(chatStateDisplayItems(state)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ id: "r1", kind: "reasoning", status: "completed", executionState: "completed" }),
|
||||
expect.objectContaining({ id: "a1", kind: "message", text: "answer" }),
|
||||
|
|
@ -102,7 +103,7 @@ describe("ChatInboundController", () => {
|
|||
params: { threadId: "thread-active", turnId: "turn-active", itemId: "p1", delta: "<proposed_plan>\n# Plan" },
|
||||
} satisfies Extract<ServerNotification, { method: "item/plan/delta" }>);
|
||||
|
||||
expect(state.messageStream.displayItems).toMatchObject([
|
||||
expect(chatStateDisplayItems(state)).toMatchObject([
|
||||
{ id: "p1", kind: "message", messageKind: "proposedPlan", role: "assistant", text: "# Plan", messageState: "streaming" },
|
||||
]);
|
||||
});
|
||||
|
|
@ -135,7 +136,7 @@ describe("ChatInboundController", () => {
|
|||
},
|
||||
} satisfies Extract<ServerNotification, { method: "turn/completed" }>);
|
||||
|
||||
expect(state.messageStream.displayItems).toEqual([
|
||||
expect(chatStateDisplayItems(state)).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "p1",
|
||||
kind: "message",
|
||||
|
|
@ -163,7 +164,7 @@ describe("ChatInboundController", () => {
|
|||
},
|
||||
} satisfies Extract<ServerNotification, { method: "turn/plan/updated" }>);
|
||||
|
||||
expect(state.messageStream.displayItems).toMatchObject([
|
||||
expect(chatStateDisplayItems(state)).toMatchObject([
|
||||
{
|
||||
id: "plan-progress-turn-active",
|
||||
kind: "taskProgress",
|
||||
|
|
@ -254,7 +255,7 @@ describe("ChatInboundController", () => {
|
|||
},
|
||||
} satisfies Extract<ServerNotification, { method: "hook/completed" }>);
|
||||
|
||||
expect(state.messageStream.displayItems).toMatchObject([
|
||||
expect(chatStateDisplayItems(state)).toMatchObject([
|
||||
{
|
||||
id: "hook-hook-1-1",
|
||||
kind: "hook",
|
||||
|
|
@ -307,7 +308,7 @@ describe("ChatInboundController", () => {
|
|||
},
|
||||
} satisfies Extract<ServerNotification, { method: "hook/completed" }>);
|
||||
|
||||
expect(state.messageStream.displayItems[0]).toMatchObject({
|
||||
expect(chatStateDisplayItems(state)[0]).toMatchObject({
|
||||
kind: "hook",
|
||||
details: [
|
||||
{
|
||||
|
|
@ -350,7 +351,7 @@ describe("ChatInboundController", () => {
|
|||
},
|
||||
} satisfies Extract<ServerNotification, { method: "hook/completed" }>);
|
||||
|
||||
expect(state.messageStream.displayItems[0]).toMatchObject({ id: "hook-hook-1-1", kind: "hook", turnId: "turn-active" });
|
||||
expect(chatStateDisplayItems(state)[0]).toMatchObject({ id: "hook-hook-1-1", kind: "hook", turnId: "turn-active" });
|
||||
});
|
||||
|
||||
it("leaves non-prompt unscoped hook runs outside the active turn", () => {
|
||||
|
|
@ -383,8 +384,8 @@ describe("ChatInboundController", () => {
|
|||
},
|
||||
} satisfies Extract<ServerNotification, { method: "hook/completed" }>);
|
||||
|
||||
expect(state.messageStream.displayItems[0]).toMatchObject({ id: "hook-hook-1-1", kind: "hook" });
|
||||
expect(state.messageStream.displayItems[0]?.turnId).toBeUndefined();
|
||||
expect(chatStateDisplayItems(state)[0]).toMatchObject({ id: "hook-hook-1-1", kind: "hook" });
|
||||
expect(chatStateDisplayItems(state)[0]?.turnId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps repeated hook runs with the same run id as separate display items", () => {
|
||||
|
|
@ -418,7 +419,7 @@ describe("ChatInboundController", () => {
|
|||
params: { threadId: "thread-active", turnId: "turn-active", run: { ...baseRun, startedAt: 3n } },
|
||||
} satisfies Extract<ServerNotification, { method: "hook/completed" }>);
|
||||
|
||||
expect(state.messageStream.displayItems.map((item) => item.id)).toEqual(["hook-hook-1-1", "hook-hook-1-3"]);
|
||||
expect(chatStateDisplayItems(state).map((item) => item.id)).toEqual(["hook-hook-1-1", "hook-hook-1-3"]);
|
||||
});
|
||||
|
||||
it("attaches pre-turn prompt submit hook runs when the turn starts", () => {
|
||||
|
|
@ -428,7 +429,7 @@ describe("ChatInboundController", () => {
|
|||
kind: "starting",
|
||||
pendingTurnStart: { anchorItemId: "local-user-1", promptSubmitHookItemIds: ["hook-hook-1-1"] },
|
||||
};
|
||||
state.messageStream.displayItems = [
|
||||
setChatStateDisplayItems(state, [
|
||||
{ id: "local-user-1", kind: "message", messageKind: "user", role: "user", text: "hello" },
|
||||
{
|
||||
id: "hook-hook-1-1",
|
||||
|
|
@ -438,7 +439,7 @@ describe("ChatInboundController", () => {
|
|||
toolLabel: "hook",
|
||||
status: "completed",
|
||||
},
|
||||
];
|
||||
]);
|
||||
const controller = controllerForState(state);
|
||||
|
||||
controller.handleNotification({
|
||||
|
|
@ -458,8 +459,8 @@ describe("ChatInboundController", () => {
|
|||
},
|
||||
} satisfies Extract<ServerNotification, { method: "turn/started" }>);
|
||||
|
||||
expect(state.messageStream.displayItems.map((item) => item.id)).toEqual(["local-user-1", "hook-hook-1-1"]);
|
||||
expect(state.messageStream.displayItems[1]).toMatchObject({ id: "hook-hook-1-1", turnId: "turn-active" });
|
||||
expect(chatStateDisplayItems(state).map((item) => item.id)).toEqual(["local-user-1", "hook-hook-1-1"]);
|
||||
expect(chatStateDisplayItems(state)[1]).toMatchObject({ id: "hook-hook-1-1", turnId: "turn-active" });
|
||||
expect(pendingTurnStart(state)).toBeNull();
|
||||
});
|
||||
|
||||
|
|
@ -524,8 +525,8 @@ describe("ChatInboundController", () => {
|
|||
},
|
||||
} satisfies Extract<ServerNotification, { method: "hook/completed" }>);
|
||||
|
||||
expect(expectPresent(state.messageStream.displayItems[0])).toMatchObject({ id: "hook-hook-1-1", kind: "hook" });
|
||||
expect(expectPresent(state.messageStream.displayItems[0]).turnId).toBeUndefined();
|
||||
expect(expectPresent(chatStateDisplayItems(state)[0])).toMatchObject({ id: "hook-hook-1-1", kind: "hook" });
|
||||
expect(expectPresent(chatStateDisplayItems(state)[0]).turnId).toBeUndefined();
|
||||
expect(expectPresent(pendingTurnStart(state)).promptSubmitHookItemIds).toEqual(["hook-hook-1-1"]);
|
||||
});
|
||||
|
||||
|
|
@ -533,7 +534,7 @@ describe("ChatInboundController", () => {
|
|||
const state = createChatState();
|
||||
state.activeThread.id = "thread-active";
|
||||
state.turn.lifecycle = { kind: "starting", pendingTurnStart: { anchorItemId: "local-user-1", promptSubmitHookItemIds: [] } };
|
||||
state.messageStream.displayItems = [{ id: "local-user-1", kind: "message", messageKind: "user", role: "user", text: "hello" }];
|
||||
setChatStateDisplayItems(state, [{ id: "local-user-1", kind: "message", messageKind: "user", role: "user", text: "hello" }]);
|
||||
const controller = controllerForState(state);
|
||||
|
||||
controller.handleNotification({
|
||||
|
|
@ -541,8 +542,8 @@ describe("ChatInboundController", () => {
|
|||
params: { threadId: "thread-active", turnId: null, run: promptSubmitHookRun("hook-1", 1n) },
|
||||
} satisfies Extract<ServerNotification, { method: "hook/completed" }>);
|
||||
|
||||
expect(state.messageStream.displayItems.map((item) => item.id)).toEqual(["local-user-1", "hook-hook-1-1"]);
|
||||
expect(expectPresent(state.messageStream.displayItems[1]).turnId).toBeUndefined();
|
||||
expect(chatStateDisplayItems(state).map((item) => item.id)).toEqual(["local-user-1", "hook-hook-1-1"]);
|
||||
expect(expectPresent(chatStateDisplayItems(state)[1]).turnId).toBeUndefined();
|
||||
expect(expectPresent(pendingTurnStart(state)).promptSubmitHookItemIds).toEqual(["hook-hook-1-1"]);
|
||||
|
||||
controller.handleNotification({
|
||||
|
|
@ -562,9 +563,9 @@ describe("ChatInboundController", () => {
|
|||
},
|
||||
} satisfies Extract<ServerNotification, { method: "turn/started" }>);
|
||||
|
||||
expect(state.messageStream.displayItems.map((item) => item.id)).toEqual(["local-user-1", "hook-hook-1-1"]);
|
||||
expect(state.messageStream.displayItems.find((item) => item.id === "local-user-1")).not.toHaveProperty("turnId");
|
||||
expect(state.messageStream.displayItems.find((item) => item.id === "hook-hook-1-1")).toMatchObject({ turnId: "turn-active" });
|
||||
expect(chatStateDisplayItems(state).map((item) => item.id)).toEqual(["local-user-1", "hook-hook-1-1"]);
|
||||
expect(chatStateDisplayItems(state).find((item) => item.id === "local-user-1")).not.toHaveProperty("turnId");
|
||||
expect(chatStateDisplayItems(state).find((item) => item.id === "hook-hook-1-1")).toMatchObject({ turnId: "turn-active" });
|
||||
expect(pendingTurnStart(state)).toBeNull();
|
||||
|
||||
controller.handleNotification({
|
||||
|
|
@ -587,15 +588,15 @@ describe("ChatInboundController", () => {
|
|||
},
|
||||
} satisfies Extract<ServerNotification, { method: "turn/completed" }>);
|
||||
|
||||
expect(state.messageStream.displayItems.map((item) => item.id)).toEqual(["u1", "hook-hook-1-1", "a1"]);
|
||||
expect(state.messageStream.displayItems).toEqual(
|
||||
expect(chatStateDisplayItems(state).map((item) => item.id)).toEqual(["u1", "hook-hook-1-1", "a1"]);
|
||||
expect(chatStateDisplayItems(state)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ id: "u1", text: "hello", turnId: "turn-active" }),
|
||||
expect.objectContaining({ id: "hook-hook-1-1", kind: "hook", turnId: "turn-active" }),
|
||||
expect.objectContaining({ id: "a1", text: "done", turnId: "turn-active" }),
|
||||
]),
|
||||
);
|
||||
expect(state.messageStream.displayItems.some((item) => item.id === "local-user-1")).toBe(false);
|
||||
expect(chatStateDisplayItems(state).some((item) => item.id === "local-user-1")).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores completed turn notifications while a new turn is still starting", () => {
|
||||
|
|
@ -605,7 +606,7 @@ describe("ChatInboundController", () => {
|
|||
kind: "starting",
|
||||
pendingTurnStart: { anchorItemId: "local-user-1", promptSubmitHookItemIds: ["hook-hook-1-1"] },
|
||||
};
|
||||
state.messageStream.displayItems = [
|
||||
setChatStateDisplayItems(state, [
|
||||
{ id: "local-user-1", kind: "message", messageKind: "user", role: "user", text: "hello" },
|
||||
{
|
||||
id: "hook-hook-1-1",
|
||||
|
|
@ -615,7 +616,7 @@ describe("ChatInboundController", () => {
|
|||
toolLabel: "hook",
|
||||
status: "completed",
|
||||
},
|
||||
];
|
||||
]);
|
||||
const maybeNameThread = vi.fn();
|
||||
const refreshThreads = vi.fn();
|
||||
const controller = controllerForState(state, { maybeNameThread, refreshThreads });
|
||||
|
|
@ -638,7 +639,7 @@ describe("ChatInboundController", () => {
|
|||
} satisfies Extract<ServerNotification, { method: "turn/completed" }>);
|
||||
|
||||
expect(pendingTurnStart(state)).toEqual({ anchorItemId: "local-user-1", promptSubmitHookItemIds: ["hook-hook-1-1"] });
|
||||
expect(state.messageStream.displayItems.map((item) => item.id)).toEqual(["local-user-1", "hook-hook-1-1"]);
|
||||
expect(chatStateDisplayItems(state).map((item) => item.id)).toEqual(["local-user-1", "hook-hook-1-1"]);
|
||||
expect(maybeNameThread).not.toHaveBeenCalled();
|
||||
expect(refreshThreads).not.toHaveBeenCalled();
|
||||
});
|
||||
|
|
@ -686,7 +687,7 @@ describe("ChatInboundController", () => {
|
|||
|
||||
expect(recordMcpStartupStatus).toHaveBeenCalledWith("github", "failed", "missing token");
|
||||
expect(publishAppServerMetadata).toHaveBeenCalledOnce();
|
||||
expect(state.messageStream.displayItems).toEqual([]);
|
||||
expect(chatStateDisplayItems(state)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -720,7 +721,7 @@ describe("ChatInboundController", () => {
|
|||
controller.resolveUserInput(expectPresent(state.requests.pendingUserInputs[0]), { scope: "Narrow" });
|
||||
expect(respondToServerRequest).toHaveBeenCalledWith(42, { answers: { scope: { answers: ["Narrow"] } } });
|
||||
expect(state.requests.pendingUserInputs).toEqual([]);
|
||||
expect(state.messageStream.displayItems.at(-1)).toMatchObject({
|
||||
expect(chatStateDisplayItems(state).at(-1)).toMatchObject({
|
||||
kind: "userInputResult",
|
||||
role: "tool",
|
||||
text: "Input submitted for 1 question.",
|
||||
|
|
@ -748,7 +749,7 @@ describe("ChatInboundController", () => {
|
|||
controller.cancelUserInput(expectPresent(state.requests.pendingUserInputs[0]));
|
||||
expect(rejectServerRequest).toHaveBeenCalledWith(43, -32000, "User cancelled input request.");
|
||||
expect(state.requests.pendingUserInputs).toEqual([]);
|
||||
expect(state.messageStream.displayItems.at(-1)).toMatchObject({
|
||||
expect(chatStateDisplayItems(state).at(-1)).toMatchObject({
|
||||
kind: "userInputResult",
|
||||
role: "tool",
|
||||
text: "Input request cancelled for 1 question.",
|
||||
|
|
@ -772,7 +773,7 @@ describe("ChatInboundController", () => {
|
|||
expect(respondToServerRequest).not.toHaveBeenCalled();
|
||||
expect(rejectServerRequest).not.toHaveBeenCalled();
|
||||
expect(state.requests.pendingUserInputs).toEqual([current]);
|
||||
expect(state.messageStream.displayItems).toEqual([]);
|
||||
expect(chatStateDisplayItems(state)).toEqual([]);
|
||||
});
|
||||
|
||||
it("records manual permission approvals as colored result items", () => {
|
||||
|
|
@ -788,7 +789,7 @@ describe("ChatInboundController", () => {
|
|||
permissions: {},
|
||||
});
|
||||
expect(state.requests.approvals).toEqual([]);
|
||||
expect(state.messageStream.displayItems.at(-1)).toMatchObject({
|
||||
expect(chatStateDisplayItems(state).at(-1)).toMatchObject({
|
||||
id: "approval-12",
|
||||
kind: "approvalResult",
|
||||
role: "tool",
|
||||
|
|
@ -822,7 +823,7 @@ describe("ChatInboundController", () => {
|
|||
|
||||
expect(respondToServerRequest).not.toHaveBeenCalled();
|
||||
expect(state.requests.approvals).toEqual([current]);
|
||||
expect(state.messageStream.displayItems).toEqual([]);
|
||||
expect(chatStateDisplayItems(state)).toEqual([]);
|
||||
});
|
||||
|
||||
it("handles known server request families and rejects unsupported requests by default", () => {
|
||||
|
|
@ -853,8 +854,12 @@ describe("ChatInboundController", () => {
|
|||
-32601,
|
||||
"Rejected unknown app-server request: appServer/newFutureRequest",
|
||||
);
|
||||
expect(state.messageStream.displayItems.map((item) => item.text)).toEqual(unsupportedMessages);
|
||||
expect(state.messageStream.displayItems.map((item) => item.text).join("\n")).not.toContain("do-not-render");
|
||||
expect(chatStateDisplayItems(state).map((item) => item.text)).toEqual(unsupportedMessages);
|
||||
expect(
|
||||
chatStateDisplayItems(state)
|
||||
.map((item) => item.text)
|
||||
.join("\n"),
|
||||
).not.toContain("do-not-render");
|
||||
});
|
||||
|
||||
it("keeps unknown server request fallback out of the normal message stream", () => {
|
||||
|
|
@ -865,7 +870,7 @@ describe("ChatInboundController", () => {
|
|||
controller.handleServerRequest(unknownRequest());
|
||||
|
||||
expect(rejectServerRequest).toHaveBeenCalledWith(27, -32601, "Rejected unknown app-server request: appServer/newFutureRequest");
|
||||
expect(state.messageStream.displayItems).toEqual([]);
|
||||
expect(chatStateDisplayItems(state)).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects server requests scoped to a different active thread or turn", () => {
|
||||
|
|
@ -943,7 +948,7 @@ describe("ChatInboundController", () => {
|
|||
controller.resolveUserInput(expectPresent(state.requests.pendingUserInputs[0]), { note: "Later" });
|
||||
|
||||
expect(state.requests.pendingUserInputs).toHaveLength(1);
|
||||
expect(state.messageStream.displayItems).toEqual([
|
||||
expect(chatStateDisplayItems(state)).toEqual([
|
||||
expect.objectContaining({ kind: "system", text: "Could not send user input because Codex app-server is not connected." }),
|
||||
]);
|
||||
});
|
||||
|
|
@ -997,7 +1002,7 @@ describe("ChatInboundController", () => {
|
|||
params: { threadId: null, message: "careful" },
|
||||
} satisfies Extract<ServerNotification, { method: "warning" }>);
|
||||
|
||||
expect(state.messageStream.displayItems).toEqual([
|
||||
expect(chatStateDisplayItems(state)).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: "system",
|
||||
text: 'warning: {\n "threadId": null,\n "message": "careful"\n}',
|
||||
|
|
@ -1018,9 +1023,9 @@ describe("ChatInboundController", () => {
|
|||
};
|
||||
state.messageStream.historyCursor = "cursor";
|
||||
state.messageStream.loadingHistory = true;
|
||||
state.messageStream.displayItems = [
|
||||
setChatStateDisplayItems(state, [
|
||||
{ id: "message", kind: "message", role: "assistant", text: "stale", messageKind: "assistantResponse", messageState: "completed" },
|
||||
];
|
||||
]);
|
||||
state.messageStream.turnDiffs = new Map([["turn-active", "@@\n-stale\n+stale"]]);
|
||||
state.composer.draft = "thread draft";
|
||||
state.requests.approvals = [
|
||||
|
|
@ -1061,7 +1066,7 @@ describe("ChatInboundController", () => {
|
|||
expect(state.activeThread.tokenUsage).toBeNull();
|
||||
expect(state.messageStream.historyCursor).toBeNull();
|
||||
expect(state.messageStream.loadingHistory).toBe(false);
|
||||
expect(state.messageStream.displayItems).toEqual([]);
|
||||
expect(chatStateDisplayItems(state)).toEqual([]);
|
||||
expect(state.messageStream.turnDiffs.size).toBe(0);
|
||||
expect(state.composer.draft).toBe("");
|
||||
expect(chatTurnBusy(state)).toBe(false);
|
||||
|
|
@ -1102,7 +1107,7 @@ describe("ChatInboundController", () => {
|
|||
const state = createChatState();
|
||||
state.activeThread.id = "thread-active";
|
||||
state.turn.lifecycle = { kind: "running", turnId: "turn-active" };
|
||||
state.messageStream.displayItems = [
|
||||
setChatStateDisplayItems(state, [
|
||||
{ id: "local-user-1", kind: "message", messageKind: "user", role: "user", text: "hello", turnId: "turn-active" },
|
||||
{
|
||||
id: "a1",
|
||||
|
|
@ -1114,7 +1119,7 @@ describe("ChatInboundController", () => {
|
|||
text: "partial",
|
||||
turnId: "turn-active",
|
||||
},
|
||||
];
|
||||
]);
|
||||
const controller = controllerForState(state);
|
||||
|
||||
controller.handleNotification({
|
||||
|
|
@ -1137,22 +1142,22 @@ describe("ChatInboundController", () => {
|
|||
},
|
||||
} satisfies Extract<ServerNotification, { method: "turn/completed" }>);
|
||||
|
||||
expect(state.messageStream.displayItems.filter((item) => item.kind === "message" && item.role === "user")).toEqual([
|
||||
expect(chatStateDisplayItems(state).filter((item) => item.kind === "message" && item.role === "user")).toEqual([
|
||||
expect.objectContaining({ id: "u1", text: "hello" }),
|
||||
]);
|
||||
expect(state.messageStream.displayItems).toEqual(expect.arrayContaining([expect.objectContaining({ id: "a1", text: "done" })]));
|
||||
expect(state.messageStream.displayItems.some((item) => item.id === "local-user-1")).toBe(false);
|
||||
expect(chatStateDisplayItems(state)).toEqual(expect.arrayContaining([expect.objectContaining({ id: "a1", text: "done" })]));
|
||||
expect(chatStateDisplayItems(state).some((item) => item.id === "local-user-1")).toBe(false);
|
||||
});
|
||||
|
||||
it("reconciles optimistic user echoes by client id before falling back to text only when client ids are absent", () => {
|
||||
const state = createChatState();
|
||||
state.activeThread.id = "thread-active";
|
||||
state.turn.lifecycle = { kind: "running", turnId: "turn-active" };
|
||||
state.messageStream.displayItems = [
|
||||
setChatStateDisplayItems(state, [
|
||||
{ id: "local-user-1", kind: "message", messageKind: "user", role: "user", text: "same text", turnId: "turn-active" },
|
||||
{ id: "local-steer-2", kind: "message", messageKind: "user", role: "user", text: "same text", turnId: "turn-active" },
|
||||
{ id: "local-user-2", kind: "message", messageKind: "user", role: "user", text: "same text", turnId: "turn-other" },
|
||||
];
|
||||
]);
|
||||
const controller = controllerForState(state);
|
||||
|
||||
controller.handleNotification({
|
||||
|
|
@ -1180,19 +1185,19 @@ describe("ChatInboundController", () => {
|
|||
},
|
||||
} satisfies Extract<ServerNotification, { method: "turn/completed" }>);
|
||||
|
||||
expect(state.messageStream.displayItems).toEqual(
|
||||
expect(chatStateDisplayItems(state)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ id: "u1", clientId: "local-user-1", text: "same text" }),
|
||||
expect.objectContaining({ id: "local-steer-2", text: "same text" }),
|
||||
expect.objectContaining({ id: "local-user-2", text: "same text" }),
|
||||
]),
|
||||
);
|
||||
expect(state.messageStream.displayItems.some((item) => item.id === "local-user-1")).toBe(false);
|
||||
expect(chatStateDisplayItems(state).some((item) => item.id === "local-user-1")).toBe(false);
|
||||
|
||||
const fallbackStateWithoutClientId = createChatState();
|
||||
fallbackStateWithoutClientId.activeThread.id = "thread-active";
|
||||
fallbackStateWithoutClientId.turn.lifecycle = { kind: "running", turnId: "turn-active" };
|
||||
fallbackStateWithoutClientId.messageStream.displayItems = [
|
||||
setChatStateDisplayItems(fallbackStateWithoutClientId, [
|
||||
{
|
||||
id: "local-user-without-client-id",
|
||||
kind: "message",
|
||||
|
|
@ -1201,7 +1206,7 @@ describe("ChatInboundController", () => {
|
|||
text: "fallback text",
|
||||
turnId: "turn-active",
|
||||
},
|
||||
];
|
||||
]);
|
||||
const fallbackControllerWithoutClientId = controllerForState(fallbackStateWithoutClientId);
|
||||
|
||||
fallbackControllerWithoutClientId.handleNotification({
|
||||
|
|
@ -1228,19 +1233,17 @@ describe("ChatInboundController", () => {
|
|||
},
|
||||
} satisfies Extract<ServerNotification, { method: "turn/completed" }>);
|
||||
|
||||
expect(fallbackStateWithoutClientId.messageStream.displayItems).toEqual([
|
||||
expect(chatStateDisplayItems(fallbackStateWithoutClientId)).toEqual([
|
||||
expect.objectContaining({ id: "server-u1", text: "fallback text" }),
|
||||
]);
|
||||
expect(fallbackStateWithoutClientId.messageStream.displayItems.some((item) => item.id === "local-user-without-client-id")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(chatStateDisplayItems(fallbackStateWithoutClientId).some((item) => item.id === "local-user-without-client-id")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the observed steer message order when completed turns reconcile by client id", () => {
|
||||
const state = createChatState();
|
||||
state.activeThread.id = "thread-active";
|
||||
state.turn.lifecycle = { kind: "running", turnId: "turn-active" };
|
||||
state.messageStream.displayItems = [
|
||||
setChatStateDisplayItems(state, [
|
||||
{ id: "local-user-1", kind: "message", messageKind: "user", role: "user", text: "start", turnId: "turn-active" },
|
||||
{
|
||||
id: "a1",
|
||||
|
|
@ -1253,7 +1256,7 @@ describe("ChatInboundController", () => {
|
|||
turnId: "turn-active",
|
||||
},
|
||||
{ id: "local-steer-1", kind: "message", messageKind: "user", role: "user", text: "steer", turnId: "turn-active" },
|
||||
];
|
||||
]);
|
||||
const controller = controllerForState(state);
|
||||
|
||||
controller.handleNotification({
|
||||
|
|
@ -1278,8 +1281,8 @@ describe("ChatInboundController", () => {
|
|||
},
|
||||
} satisfies Extract<ServerNotification, { method: "turn/completed" }>);
|
||||
|
||||
expect(state.messageStream.displayItems.map((item) => item.id)).toEqual(["u1", "a1", "u2", "a2"]);
|
||||
expect(state.messageStream.displayItems).toEqual(
|
||||
expect(chatStateDisplayItems(state).map((item) => item.id)).toEqual(["u1", "a1", "u2", "a2"]);
|
||||
expect(chatStateDisplayItems(state)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ id: "u1", clientId: "local-user-1", text: "start" }),
|
||||
expect.objectContaining({ id: "a1", text: "first done" }),
|
||||
|
|
@ -1371,7 +1374,7 @@ describe("ChatInboundController", () => {
|
|||
expect(state.runtime.activeApprovalPolicy).toBe("on-request");
|
||||
expect(state.runtime.activeApprovalsReviewer).toBe("auto_review");
|
||||
expect(state.runtime.activePermissionProfile).toBeNull();
|
||||
expect(state.messageStream.displayItems).toEqual([]);
|
||||
expect(chatStateDisplayItems(state)).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores settings notifications for inactive threads", () => {
|
||||
|
|
@ -1471,21 +1474,21 @@ describe("ChatInboundController", () => {
|
|||
} satisfies Extract<ServerNotification, { method: "thread/goal/updated" }>);
|
||||
|
||||
expect(state.activeThread.goal).toEqual(goal);
|
||||
expect(state.messageStream.displayItems.at(-1)).toMatchObject({ kind: "goal", text: "set: Finish", objective: "Finish" });
|
||||
expect(chatStateDisplayItems(state).at(-1)).toMatchObject({ kind: "goal", text: "set: Finish", objective: "Finish" });
|
||||
|
||||
const afterSetMessageCount = state.messageStream.displayItems.length;
|
||||
const afterSetMessageCount = chatStateDisplayItems(state).length;
|
||||
controller.handleNotification({
|
||||
method: "thread/goal/updated",
|
||||
params: { threadId: "thread-active", turnId: null, goal },
|
||||
} satisfies Extract<ServerNotification, { method: "thread/goal/updated" }>);
|
||||
expect(state.messageStream.displayItems).toHaveLength(afterSetMessageCount);
|
||||
expect(chatStateDisplayItems(state)).toHaveLength(afterSetMessageCount);
|
||||
|
||||
const updatedGoal = { ...goal, objective: "Finish well", updatedAt: 2 };
|
||||
controller.handleNotification({
|
||||
method: "thread/goal/updated",
|
||||
params: { threadId: "thread-active", turnId: null, goal: updatedGoal },
|
||||
} satisfies Extract<ServerNotification, { method: "thread/goal/updated" }>);
|
||||
expect(state.messageStream.displayItems.at(-1)).toMatchObject({
|
||||
expect(chatStateDisplayItems(state).at(-1)).toMatchObject({
|
||||
kind: "goal",
|
||||
text: "updated: Finish well",
|
||||
objective: "Finish well",
|
||||
|
|
@ -1499,7 +1502,7 @@ describe("ChatInboundController", () => {
|
|||
method: "thread/goal/updated",
|
||||
params: { threadId: "thread-active", turnId: null, goal: pausedGoal },
|
||||
} satisfies Extract<ServerNotification, { method: "thread/goal/updated" }>);
|
||||
expect(state.messageStream.displayItems.at(-1)).toMatchObject({
|
||||
expect(chatStateDisplayItems(state).at(-1)).toMatchObject({
|
||||
kind: "goal",
|
||||
text: "paused: Finish well",
|
||||
objective: "Finish well",
|
||||
|
|
@ -1513,18 +1516,18 @@ describe("ChatInboundController", () => {
|
|||
method: "thread/goal/updated",
|
||||
params: { threadId: "thread-active", turnId: null, goal: resumedGoal },
|
||||
} satisfies Extract<ServerNotification, { method: "thread/goal/updated" }>);
|
||||
expect(state.messageStream.displayItems.at(-1)).toMatchObject({
|
||||
expect(chatStateDisplayItems(state).at(-1)).toMatchObject({
|
||||
kind: "goal",
|
||||
text: "resumed: Finish well",
|
||||
objective: "Finish well",
|
||||
});
|
||||
|
||||
const messageCount = state.messageStream.displayItems.length;
|
||||
const messageCount = chatStateDisplayItems(state).length;
|
||||
controller.handleNotification({
|
||||
method: "thread/goal/updated",
|
||||
params: { threadId: "thread-active", turnId: null, goal: { ...resumedGoal, tokensUsed: 10, timeUsedSeconds: 20 } },
|
||||
} satisfies Extract<ServerNotification, { method: "thread/goal/updated" }>);
|
||||
expect(state.messageStream.displayItems).toHaveLength(messageCount);
|
||||
expect(chatStateDisplayItems(state)).toHaveLength(messageCount);
|
||||
|
||||
controller.handleNotification({
|
||||
method: "thread/goal/cleared",
|
||||
|
|
@ -1532,7 +1535,7 @@ describe("ChatInboundController", () => {
|
|||
} satisfies Extract<ServerNotification, { method: "thread/goal/cleared" }>);
|
||||
|
||||
expect(state.activeThread.goal).toBeNull();
|
||||
expect(state.messageStream.displayItems.at(-1)).toMatchObject({
|
||||
expect(chatStateDisplayItems(state).at(-1)).toMatchObject({
|
||||
kind: "goal",
|
||||
text: "cleared: Finish well",
|
||||
objective: "Finish well",
|
||||
|
|
@ -1567,15 +1570,15 @@ describe("ChatInboundController", () => {
|
|||
} satisfies Extract<ServerNotification, { method: "thread/goal/updated" }>);
|
||||
|
||||
expect(state.activeThread.goal).toEqual(completedGoal);
|
||||
expect(state.messageStream.displayItems).toHaveLength(1);
|
||||
expect(state.messageStream.displayItems[0]).toMatchObject({ kind: "goal", text: "completed: Finish", objective: "Finish" });
|
||||
expect(chatStateDisplayItems(state)).toHaveLength(1);
|
||||
expect(chatStateDisplayItems(state)[0]).toMatchObject({ kind: "goal", text: "completed: Finish", objective: "Finish" });
|
||||
|
||||
controller.handleNotification({
|
||||
method: "thread/goal/updated",
|
||||
params: { threadId: "thread-active", turnId: "turn-1", goal: { ...completedGoal, tokensUsed: 43 } },
|
||||
} satisfies Extract<ServerNotification, { method: "thread/goal/updated" }>);
|
||||
|
||||
expect(state.messageStream.displayItems).toHaveLength(1);
|
||||
expect(chatStateDisplayItems(state)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("ignores goal notifications that do not match the active thread", () => {
|
||||
|
|
@ -1621,7 +1624,7 @@ describe("ChatInboundController", () => {
|
|||
params: { threadId: "thread-active", message: "Auto-review denied this command." },
|
||||
} satisfies Extract<ServerNotification, { method: "guardianWarning" }>);
|
||||
|
||||
expect(state.messageStream.displayItems).toMatchObject([
|
||||
expect(chatStateDisplayItems(state)).toMatchObject([
|
||||
{
|
||||
kind: "reviewResult",
|
||||
role: "tool",
|
||||
|
|
@ -1663,14 +1666,14 @@ describe("ChatInboundController", () => {
|
|||
},
|
||||
} satisfies Extract<ServerNotification, { method: "item/autoApprovalReview/completed" }>);
|
||||
|
||||
expect(state.messageStream.displayItems).toHaveLength(1);
|
||||
expect(state.messageStream.displayItems[0]).toMatchObject({
|
||||
expect(chatStateDisplayItems(state)).toHaveLength(1);
|
||||
expect(chatStateDisplayItems(state)[0]).toMatchObject({
|
||||
id: "review-review-1",
|
||||
kind: "reviewResult",
|
||||
text: "Auto-review approved: npm test",
|
||||
executionState: "completed",
|
||||
});
|
||||
const reviewItem = expectPresent(state.messageStream.displayItems[0]);
|
||||
const reviewItem = expectPresent(chatStateDisplayItems(state)[0]);
|
||||
expect("details" in reviewItem ? reviewItem.details[0] : null).toMatchObject({
|
||||
title: "Review",
|
||||
rows: expect.arrayContaining([{ key: "status", value: "approved" }]),
|
||||
|
|
@ -1702,8 +1705,8 @@ describe("ChatInboundController", () => {
|
|||
},
|
||||
} satisfies Extract<ServerNotification, { method: "item/autoApprovalReview/completed" }>);
|
||||
|
||||
expect(state.messageStream.displayItems).toHaveLength(1);
|
||||
expect(state.messageStream.displayItems[0]).toMatchObject({
|
||||
expect(chatStateDisplayItems(state)).toHaveLength(1);
|
||||
expect(chatStateDisplayItems(state)[0]).toMatchObject({
|
||||
id: "review-review-1",
|
||||
kind: "reviewResult",
|
||||
text: "Auto-review approved: npm test",
|
||||
|
|
@ -1736,8 +1739,8 @@ describe("ChatInboundController", () => {
|
|||
params: { threadId: "thread-active", message: "Auto-review approved: npm test" },
|
||||
} satisfies Extract<ServerNotification, { method: "guardianWarning" }>);
|
||||
|
||||
expect(state.messageStream.displayItems).toHaveLength(1);
|
||||
expect(state.messageStream.displayItems[0]).toMatchObject({ id: "review-review-1" });
|
||||
expect(chatStateDisplayItems(state)).toHaveLength(1);
|
||||
expect(chatStateDisplayItems(state)[0]).toMatchObject({ id: "review-review-1" });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
11
tests/features/chat/support/message-stream.ts
Normal file
11
tests/features/chat/support/message-stream.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import type { DisplayItem } from "../../../../src/features/chat/display/types";
|
||||
import { messageStreamDisplayItems, messageStreamWithDisplayItems } from "../../../../src/features/chat/state/message-stream";
|
||||
import type { ChatState } from "../../../../src/features/chat/state/reducer";
|
||||
|
||||
export function chatStateDisplayItems(state: Pick<ChatState, "messageStream">): readonly DisplayItem[] {
|
||||
return messageStreamDisplayItems(state.messageStream);
|
||||
}
|
||||
|
||||
export function setChatStateDisplayItems(state: ChatState, items: readonly DisplayItem[]): void {
|
||||
state.messageStream = messageStreamWithDisplayItems(state.messageStream, items);
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ import type { DisplayItem } from "../../../../src/features/chat/display/types";
|
|||
import { DEFAULT_SETTINGS } from "../../../../src/settings/model";
|
||||
import { notices } from "../../../mocks/obsidian";
|
||||
import { deferred, waitForAsyncWork } from "../../../support/async";
|
||||
import { chatStateDisplayItems, setChatStateDisplayItems } from "../support/message-stream";
|
||||
|
||||
type MockArchiveExportAdapter = ArchiveExportAdapter & {
|
||||
exists: ReturnType<typeof vi.fn<ArchiveExportAdapter["exists"]>>;
|
||||
|
|
@ -301,7 +302,7 @@ describe("chat thread actions", () => {
|
|||
await controller.rollbackThread("source");
|
||||
|
||||
expect(client.rollbackThread).toHaveBeenCalledWith("source");
|
||||
expect(host.stateStore.getState().messageStream.displayItems.slice(0, 2)).toMatchObject([
|
||||
expect(chatStateDisplayItems(host.stateStore.getState()).slice(0, 2)).toMatchObject([
|
||||
{ kind: "message", role: "user", text: "kept prompt", turnId: "kept-turn" },
|
||||
{ kind: "message", role: "assistant", text: "kept answer", turnId: "kept-turn" },
|
||||
]);
|
||||
|
|
@ -425,7 +426,8 @@ function hostMock({
|
|||
settings?: Partial<typeof DEFAULT_SETTINGS>;
|
||||
}) {
|
||||
const state = createChatState();
|
||||
const stateStore = createChatStateStore({ ...state, messageStream: { ...state.messageStream, displayItems } });
|
||||
setChatStateDisplayItems(state, displayItems);
|
||||
const stateStore = createChatStateStore(state);
|
||||
return {
|
||||
stateStore,
|
||||
vaultPath: "/vault",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { pendingRequestBlockNode } from "../../../../../src/features/chat/ui/mes
|
|||
import type { ChatTurnLifecycleState } from "../../../../../src/features/chat/state/reducer";
|
||||
import { messageStreamBlocks as rawMessageStreamBlocks } from "../../../../../src/features/chat/ui/message-stream/stream-blocks";
|
||||
import type { MessageStreamBlock } from "../../../../../src/features/chat/ui/message-stream/context";
|
||||
import { messageStreamViewportNode } from "../../../../../src/features/chat/ui/message-stream/viewport";
|
||||
import { MessageStreamViewport } from "../../../../../src/features/chat/ui/message-stream/viewport";
|
||||
import { renderUiRoot, unmountUiRoot } from "../../../../../src/shared/ui/ui-root";
|
||||
|
||||
export function messageStreamBlocks(
|
||||
|
|
@ -57,10 +57,12 @@ export function renderMessageStreamBlocksInAct(parent: HTMLElement, blocks: Mess
|
|||
void act(() => {
|
||||
renderUiRoot(
|
||||
parent,
|
||||
messageStreamViewportNode({
|
||||
blocks,
|
||||
consumeScrollIntent: () => "auto",
|
||||
}),
|
||||
<MessageStreamViewport
|
||||
state={{
|
||||
blocks,
|
||||
consumeScrollIntent: () => "auto",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { h } from "preact";
|
||||
|
||||
import {
|
||||
composerShellNode,
|
||||
ComposerShell,
|
||||
scrollComposerSuggestionIntoView,
|
||||
syncComposerHeight,
|
||||
type ComposerCallbacks,
|
||||
|
|
@ -16,7 +17,7 @@ import { changeInputValue, composerSuggestionScrollFixture, installObsidianDomSh
|
|||
|
||||
installObsidianDomShims();
|
||||
|
||||
function mountComposerShellNode(
|
||||
function mountComposerShell(
|
||||
parent: HTMLElement,
|
||||
viewId: string,
|
||||
draft: string,
|
||||
|
|
@ -31,7 +32,7 @@ function mountComposerShellNode(
|
|||
const elements: { composer: HTMLTextAreaElement | null } = { composer: null };
|
||||
renderUiRoot(
|
||||
parent,
|
||||
composerShellNode(
|
||||
h(ComposerShell, {
|
||||
viewId,
|
||||
draft,
|
||||
busy,
|
||||
|
|
@ -40,11 +41,32 @@ function mountComposerShellNode(
|
|||
suggestions,
|
||||
selectedSuggestionIndex,
|
||||
callbacks,
|
||||
meta,
|
||||
(composer) => {
|
||||
meta:
|
||||
meta ??
|
||||
({
|
||||
fatal: null,
|
||||
context: {
|
||||
cells: [
|
||||
{ text: "⣀", placeholder: true },
|
||||
{ text: "⣀", placeholder: true },
|
||||
{ text: "⣀", placeholder: true },
|
||||
{ text: "⣀", placeholder: true },
|
||||
],
|
||||
percent: "--%",
|
||||
},
|
||||
statusSummary: "Context unavailable, plan off, auto-review off, fast off, model default, reasoning effort default",
|
||||
model: "default",
|
||||
effort: null,
|
||||
planActive: false,
|
||||
autoReviewActive: false,
|
||||
fastActive: false,
|
||||
modelChoices: [],
|
||||
effortChoices: [],
|
||||
} satisfies ComposerMetaViewModel),
|
||||
onComposer: (composer) => {
|
||||
if (composer) elements.composer = composer;
|
||||
},
|
||||
),
|
||||
}),
|
||||
);
|
||||
if (!elements.composer) throw new Error("Expected composer shell elements to mount.");
|
||||
return { composer: elements.composer };
|
||||
|
|
@ -69,7 +91,7 @@ describe("composer renderer decisions", () => {
|
|||
it("uses the provided composer placeholder for normal input", () => {
|
||||
const parent = document.createElement("div");
|
||||
const callbacks = composerCallbacks();
|
||||
const { composer } = mountComposerShellNode(
|
||||
const { composer } = mountComposerShell(
|
||||
parent,
|
||||
"view",
|
||||
"",
|
||||
|
|
@ -84,7 +106,7 @@ describe("composer renderer decisions", () => {
|
|||
expect(composer.getAttribute("placeholder")).toBe("Ask Codex to work on “Refactor terminal streaming”...");
|
||||
expect(composer.getAttribute("aria-label")).toBe("Message");
|
||||
|
||||
mountComposerShellNode(parent, "view", "", false, false, "Ask Codex to work on “Renamed thread”...", [], 0, callbacks);
|
||||
mountComposerShell(parent, "view", "", false, false, "Ask Codex to work on “Renamed thread”...", [], 0, callbacks);
|
||||
|
||||
expect(composer.getAttribute("placeholder")).toBe("Ask Codex to work on “Renamed thread”...");
|
||||
});
|
||||
|
|
@ -92,7 +114,7 @@ describe("composer renderer decisions", () => {
|
|||
it("renders composer meta as interactive context and runtime text without changing normal text", () => {
|
||||
const parent = document.createElement("div");
|
||||
|
||||
mountComposerShellNode(parent, "view", "", false, false, "Ask Codex to work on this task...", [], 0, composerCallbacks(), {
|
||||
mountComposerShell(parent, "view", "", false, false, "Ask Codex to work on this task...", [], 0, composerCallbacks(), {
|
||||
fatal: null,
|
||||
context: {
|
||||
cells: [
|
||||
|
|
@ -167,7 +189,7 @@ describe("composer renderer decisions", () => {
|
|||
const selectModel = vi.fn();
|
||||
const selectEffort = vi.fn();
|
||||
|
||||
mountComposerShellNode(parent, "view", "", false, false, "Ask Codex to work on this task...", [], 0, callbacks, {
|
||||
mountComposerShell(parent, "view", "", false, false, "Ask Codex to work on this task...", [], 0, callbacks, {
|
||||
fatal: null,
|
||||
context: {
|
||||
cells: [
|
||||
|
|
@ -251,7 +273,7 @@ describe("composer renderer decisions", () => {
|
|||
it("hides composer meta fields only after measured overflow", async () => {
|
||||
const parent = document.createElement("div");
|
||||
|
||||
mountComposerShellNode(parent, "view", "", false, false, "Ask Codex to work on this task...", [], 0, composerCallbacks(), {
|
||||
mountComposerShell(parent, "view", "", false, false, "Ask Codex to work on this task...", [], 0, composerCallbacks(), {
|
||||
fatal: null,
|
||||
context: {
|
||||
cells: [
|
||||
|
|
@ -294,7 +316,7 @@ describe("composer renderer decisions", () => {
|
|||
it("replaces composer meta with fatal status text", () => {
|
||||
const parent = document.createElement("div");
|
||||
|
||||
mountComposerShellNode(parent, "view", "", false, false, "Ask Codex to work on this task...", [], 0, composerCallbacks(), {
|
||||
mountComposerShell(parent, "view", "", false, false, "Ask Codex to work on this task...", [], 0, composerCallbacks(), {
|
||||
fatal: "Codex app-server disconnected",
|
||||
context: {
|
||||
cells: [
|
||||
|
|
@ -321,7 +343,7 @@ describe("composer renderer decisions", () => {
|
|||
it("renders composer suggestions inside the composer root", () => {
|
||||
const parent = document.createElement("div");
|
||||
const onSuggestionInsert = vi.fn();
|
||||
const { composer } = mountComposerShellNode(
|
||||
const { composer } = mountComposerShell(
|
||||
parent,
|
||||
"view",
|
||||
"",
|
||||
|
|
@ -355,7 +377,7 @@ describe("composer renderer decisions", () => {
|
|||
it("clears composer suggestion accessibility state on rerender", () => {
|
||||
const parent = document.createElement("div");
|
||||
const callbacks = composerCallbacks();
|
||||
const { composer } = mountComposerShellNode(
|
||||
const { composer } = mountComposerShell(
|
||||
parent,
|
||||
"view",
|
||||
"",
|
||||
|
|
@ -367,7 +389,7 @@ describe("composer renderer decisions", () => {
|
|||
callbacks,
|
||||
);
|
||||
|
||||
mountComposerShellNode(parent, "view", "", false, false, "Ask Codex to work on this task...", [], 0, callbacks);
|
||||
mountComposerShell(parent, "view", "", false, false, "Ask Codex to work on this task...", [], 0, callbacks);
|
||||
|
||||
const suggestions = parent.querySelector<HTMLElement>(".codex-panel__composer-suggestions");
|
||||
expect(composer.getAttribute("aria-expanded")).toBe("false");
|
||||
|
|
@ -378,7 +400,7 @@ describe("composer renderer decisions", () => {
|
|||
it("reports composer draft changes from the controlled input", () => {
|
||||
const parent = document.createElement("div");
|
||||
const callbacks = composerCallbacks();
|
||||
const { composer } = mountComposerShellNode(parent, "view", "", false, false, "Ask Codex to work on this task...", [], 0, callbacks);
|
||||
const { composer } = mountComposerShell(parent, "view", "", false, false, "Ask Codex to work on this task...", [], 0, callbacks);
|
||||
|
||||
changeInputValue(composer, "Draft text");
|
||||
|
||||
|
|
@ -395,7 +417,7 @@ describe("composer renderer decisions", () => {
|
|||
configurable: true,
|
||||
});
|
||||
try {
|
||||
const { composer } = mountComposerShellNode(parent, "view", "", false, false, "Ask Codex to work on this task...", [], 0, callbacks);
|
||||
const { composer } = mountComposerShell(parent, "view", "", false, false, "Ask Codex to work on this task...", [], 0, callbacks);
|
||||
|
||||
scrollHeight = 120;
|
||||
changeInputValue(composer, "line one\nline two");
|
||||
|
|
@ -453,7 +475,7 @@ describe("composer renderer decisions", () => {
|
|||
it("uses the composer action for interrupt only when a running turn has no steering text", () => {
|
||||
const parent = document.createElement("div");
|
||||
const callbacks = composerCallbacks();
|
||||
const { composer } = mountComposerShellNode(parent, "view", "", true, true, "Ask Codex to work on this task...", [], 0, callbacks);
|
||||
const { composer } = mountComposerShell(parent, "view", "", true, true, "Ask Codex to work on this task...", [], 0, callbacks);
|
||||
let sendButton = parent.querySelector<HTMLButtonElement>(".codex-panel__send");
|
||||
|
||||
expect(sendButton?.getAttribute("aria-label")).toBe("Interrupt");
|
||||
|
|
@ -463,7 +485,7 @@ describe("composer renderer decisions", () => {
|
|||
expect(sendButton?.classList.contains("is-steer")).toBe(false);
|
||||
expect(sendButton?.dataset["icon"]).toBe("square");
|
||||
|
||||
mountComposerShellNode(parent, "view", "adjust course", true, true, "Ask Codex to work on this task...", [], 0, callbacks);
|
||||
mountComposerShell(parent, "view", "adjust course", true, true, "Ask Codex to work on this task...", [], 0, callbacks);
|
||||
sendButton = parent.querySelector<HTMLButtonElement>(".codex-panel__send");
|
||||
expect(sendButton?.getAttribute("aria-label")).toBe("Steer");
|
||||
expect(composer.getAttribute("placeholder")).toBe("Add steering message...");
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { describe, expect, it, vi } from "vitest";
|
|||
import { act } from "preact/test-utils";
|
||||
|
||||
import type { ThreadGoal } from "../../../../../src/domain/threads/goal";
|
||||
import { goalRegionNode, type GoalRegionActions } from "../../../../../src/features/chat/ui/goal";
|
||||
import { GoalPanel, type GoalPanelActions } from "../../../../../src/features/chat/ui/goal";
|
||||
import type { SendShortcut } from "../../../../../src/shared/ui/keyboard";
|
||||
import { renderUiRoot } from "../../../../../src/shared/ui/ui-root";
|
||||
import { installObsidianDomShims } from "../../../../support/dom";
|
||||
|
|
@ -12,7 +12,7 @@ import { installObsidianDomShims } from "../../../../support/dom";
|
|||
installObsidianDomShims();
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
describe("goalRegionNode", () => {
|
||||
describe("GoalPanel", () => {
|
||||
it("renders nothing when there is no goal", async () => {
|
||||
const parent = document.createElement("div");
|
||||
|
||||
|
|
@ -30,7 +30,10 @@ describe("goalRegionNode", () => {
|
|||
const onEditingChange = vi.fn();
|
||||
|
||||
await act(async () => {
|
||||
renderUiRoot(parent, goalRegionNode(null, callbacks, { sendShortcut: "enter", editingRequested: true, onEditingChange }));
|
||||
renderUiRoot(
|
||||
parent,
|
||||
<GoalPanel goal={null} actions={callbacks} options={{ sendShortcut: "enter", editingRequested: true, onEditingChange }} />,
|
||||
);
|
||||
});
|
||||
|
||||
expect(parent.textContent).toContain("Goal");
|
||||
|
|
@ -234,10 +237,10 @@ describe("goalRegionNode", () => {
|
|||
function renderGoal(
|
||||
parent: HTMLElement,
|
||||
currentGoal: ThreadGoal | null,
|
||||
callbacks: GoalRegionActions = actions(),
|
||||
callbacks: GoalPanelActions = actions(),
|
||||
sendShortcut: SendShortcut = "enter",
|
||||
): void {
|
||||
renderUiRoot(parent, goalRegionNode(currentGoal, callbacks, { sendShortcut }));
|
||||
renderUiRoot(parent, <GoalPanel goal={currentGoal} actions={callbacks} options={{ sendShortcut }} />);
|
||||
}
|
||||
|
||||
function actions() {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { h } from "preact";
|
||||
|
||||
import type { ToolbarViewModel } from "../../../../../src/features/chat/ui/toolbar";
|
||||
import { toolbarNode } from "../../../../../src/features/chat/ui/toolbar";
|
||||
import { Toolbar, type ToolbarActions, type ToolbarViewModel } from "../../../../../src/features/chat/ui/toolbar";
|
||||
import { renderUiRoot } from "../../../../../src/shared/ui/ui-root";
|
||||
import { changeInputValue, installObsidianDomShims } from "../../../../support/dom";
|
||||
|
||||
|
|
@ -14,10 +14,8 @@ function expectPresent<T>(value: T | null | undefined): T {
|
|||
return value;
|
||||
}
|
||||
|
||||
type ToolbarActions = Parameters<typeof toolbarNode>[1];
|
||||
|
||||
function mountToolbarNode(parent: HTMLElement, model: ToolbarViewModel, actions: ToolbarActions): void {
|
||||
renderUiRoot(parent, toolbarNode(model, actions));
|
||||
function mountToolbar(parent: HTMLElement, model: ToolbarViewModel, actions: ToolbarActions): void {
|
||||
renderUiRoot(parent, h(Toolbar, { model, actions }));
|
||||
}
|
||||
|
||||
describe("toolbar renderer decisions", () => {
|
||||
|
|
@ -28,7 +26,7 @@ describe("toolbar renderer decisions", () => {
|
|||
const toggleHistory = vi.fn();
|
||||
const baseModel = toolbarModel();
|
||||
|
||||
mountToolbarNode(parent, baseModel, toolbarActions({ startNewThread, toggleChatActions, toggleHistory }));
|
||||
mountToolbar(parent, baseModel, toolbarActions({ startNewThread, toggleChatActions, toggleHistory }));
|
||||
|
||||
const navHeader = parent.querySelector(".codex-panel__toolbar-primary");
|
||||
expect(navHeader?.classList.contains("nav-header")).toBe(true);
|
||||
|
|
@ -68,11 +66,11 @@ describe("toolbar renderer decisions", () => {
|
|||
historyButton?.click();
|
||||
expect(toggleHistory).toHaveBeenCalled();
|
||||
parent.empty();
|
||||
mountToolbarNode(parent, toolbarModel({ newChatDisabled: true }), toolbarActions());
|
||||
mountToolbar(parent, toolbarModel({ newChatDisabled: true }), toolbarActions());
|
||||
expect(parent.querySelector<HTMLButtonElement>(".codex-panel__new-chat")?.disabled).toBe(true);
|
||||
|
||||
parent.empty();
|
||||
mountToolbarNode(parent, toolbarModel({ chatActionsOpen: true, historyOpen: true, statusPanelOpen: true }), toolbarActions());
|
||||
mountToolbar(parent, toolbarModel({ chatActionsOpen: true, historyOpen: true, statusPanelOpen: true }), toolbarActions());
|
||||
expect(parent.querySelector(".codex-panel__history-toggle")?.getAttribute("aria-label")).toBe("Hide thread list");
|
||||
expect(parent.querySelector(".codex-panel__history-toggle")?.classList.contains("is-active")).toBe(true);
|
||||
expect(parent.querySelector(".codex-panel__new-chat")?.getAttribute("aria-label")).toBe("Hide chat actions");
|
||||
|
|
@ -87,7 +85,7 @@ describe("toolbar renderer decisions", () => {
|
|||
const compactConversation = vi.fn();
|
||||
const setGoal = vi.fn();
|
||||
|
||||
mountToolbarNode(
|
||||
mountToolbar(
|
||||
parent,
|
||||
toolbarModel({ chatActionsOpen: true, openPanel: "chat-actions" }),
|
||||
toolbarActions({ startNewThread, compactConversation, setGoal }),
|
||||
|
|
@ -107,7 +105,7 @@ describe("toolbar renderer decisions", () => {
|
|||
it("keeps context out of the toolbar and Codex limits inside the status menu", () => {
|
||||
const parent = document.createElement("div");
|
||||
|
||||
mountToolbarNode(
|
||||
mountToolbar(
|
||||
parent,
|
||||
toolbarModel({
|
||||
statusPanelOpen: true,
|
||||
|
|
@ -161,7 +159,7 @@ describe("toolbar renderer decisions", () => {
|
|||
const parent = document.createElement("div");
|
||||
const refreshStatus = vi.fn();
|
||||
|
||||
mountToolbarNode(
|
||||
mountToolbar(
|
||||
parent,
|
||||
toolbarModel({
|
||||
statusPanelOpen: true,
|
||||
|
|
@ -193,7 +191,7 @@ describe("toolbar renderer decisions", () => {
|
|||
it("renders effective config inside the status menu without a separate toggle", () => {
|
||||
const parent = document.createElement("div");
|
||||
|
||||
mountToolbarNode(
|
||||
mountToolbar(
|
||||
parent,
|
||||
toolbarModel({
|
||||
statusPanelOpen: true,
|
||||
|
|
@ -219,7 +217,7 @@ describe("toolbar renderer decisions", () => {
|
|||
const autoNameThread = vi.fn();
|
||||
const actions = toolbarActions({ startRenameThread, updateRenameDraft, saveRenameThread, cancelRenameThread, autoNameThread });
|
||||
|
||||
mountToolbarNode(
|
||||
mountToolbar(
|
||||
parent,
|
||||
toolbarModel({
|
||||
historyOpen: true,
|
||||
|
|
@ -258,7 +256,7 @@ describe("toolbar renderer decisions", () => {
|
|||
changeInputValue(input, "New title");
|
||||
expect(updateRenameDraft).toHaveBeenCalledWith("editing", "New title");
|
||||
|
||||
mountToolbarNode(
|
||||
mountToolbar(
|
||||
parent,
|
||||
toolbarModel({
|
||||
historyOpen: true,
|
||||
|
|
@ -293,7 +291,7 @@ describe("toolbar renderer decisions", () => {
|
|||
it("renders auto-name loading without disabling the rename draft field", () => {
|
||||
const parent = document.createElement("div");
|
||||
|
||||
mountToolbarNode(
|
||||
mountToolbar(
|
||||
parent,
|
||||
toolbarModel({
|
||||
historyOpen: true,
|
||||
|
|
@ -323,7 +321,7 @@ describe("toolbar renderer decisions", () => {
|
|||
const startArchiveThread = vi.fn();
|
||||
const archiveThread = vi.fn();
|
||||
|
||||
mountToolbarNode(
|
||||
mountToolbar(
|
||||
parent,
|
||||
toolbarModel({
|
||||
historyOpen: true,
|
||||
|
|
|
|||
|
|
@ -2,78 +2,56 @@
|
|||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { act } from "preact/test-utils";
|
||||
import { useEffect } from "preact/hooks";
|
||||
import { signal } from "@preact/signals";
|
||||
|
||||
import { chatTurnBusy, createChatStateStore } from "../../../../src/features/chat/state/reducer";
|
||||
import { renderChatPanelShell, unmountChatPanelShell, useChatPanelShellState } from "../../../../src/features/chat/ui/shell";
|
||||
import { createChatStateStore } from "../../../../src/features/chat/state/reducer";
|
||||
import { renderChatPanelShell, unmountChatPanelShell, type ChatPanelShellSlots } from "../../../../src/features/chat/ui/shell";
|
||||
import type { ChatPanelSurfacePorts } from "../../../../src/features/chat/panel/surface/ports";
|
||||
import { installObsidianDomShims } from "../../../support/dom";
|
||||
import { chatStateDisplayItems } from "../support/message-stream";
|
||||
|
||||
installObsidianDomShims();
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
describe("ChatPanelShell", () => {
|
||||
it("renders the panel regions on the existing view content element", async () => {
|
||||
it("composes toolbar, goal, message stream, and composer in one Preact root", async () => {
|
||||
const store = createChatStateStore();
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
|
||||
await act(async () => {
|
||||
renderChatPanelShell(container, shellRenderers(store));
|
||||
renderChatPanelShell(container, shellProps(store));
|
||||
await settleShellEffects();
|
||||
});
|
||||
|
||||
expect(container.classList.contains("codex-panel")).toBe(true);
|
||||
expect(container.textContent).toContain("Idle");
|
||||
expect(container.textContent).toContain("no goal");
|
||||
expect(container.textContent).toContain("0");
|
||||
expect(container.textContent).toContain("ready");
|
||||
expect(container.querySelector(".codex-panel__region--config")).toBeNull();
|
||||
expect(container.querySelector(".codex-panel__toolbar .codex-panel__toolbar-primary")).not.toBeNull();
|
||||
expect(container.querySelector(".codex-panel__body > .codex-panel__region--message-stream")).toBe(
|
||||
container.querySelector(".codex-panel__body > .codex-panel__messages"),
|
||||
);
|
||||
expect(container.querySelector<HTMLTextAreaElement>(".codex-panel__region--composer textarea")?.value).toBe("");
|
||||
expect(container.querySelector(".codex-panel__message-block .test-message-count")?.textContent).toBe("0");
|
||||
|
||||
await act(async () => {
|
||||
unmountChatPanelShell(container);
|
||||
});
|
||||
});
|
||||
|
||||
it("updates rendered panel content when the store changes", async () => {
|
||||
it("updates signal-aware shell components from the state store without remounting the shell", async () => {
|
||||
const store = createChatStateStore();
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const renderers = shellRenderers(store);
|
||||
const slots = shellSlots(store);
|
||||
const composerRenderState = vi.spyOn(slots.composer, "renderState");
|
||||
|
||||
await act(async () => {
|
||||
renderChatPanelShell(container, renderers);
|
||||
renderChatPanelShell(container, { stateStore: store, showToolbar: true, slots });
|
||||
await settleShellEffects();
|
||||
});
|
||||
composerRenderState.mockClear();
|
||||
|
||||
await act(async () => {
|
||||
store.dispatch({ type: "connection/status-set", status: "Working" });
|
||||
await settleShellEffects();
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("Working");
|
||||
|
||||
await act(async () => {
|
||||
unmountChatPanelShell(container);
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps panel content in its owning region after shell rerenders", async () => {
|
||||
const store = createChatStateStore();
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const renderers = nestedRootShellRenderers(store);
|
||||
|
||||
await act(async () => {
|
||||
renderChatPanelShell(container, renderers);
|
||||
await settleShellEffects();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
store.dispatch({ type: "connection/status-set", status: "Working" });
|
||||
store.dispatch({ type: "ui/panel-set", panel: "status-panel" });
|
||||
store.dispatch({ type: "composer/draft-set", draft: "ready", clearSuggestions: true });
|
||||
store.dispatch({
|
||||
type: "message-stream/system-item-added",
|
||||
item: { id: "system-1", kind: "system", role: "system", text: "Model set." },
|
||||
|
|
@ -81,84 +59,60 @@ describe("ChatPanelShell", () => {
|
|||
await settleShellEffects();
|
||||
});
|
||||
|
||||
expect(container.querySelector(".codex-panel__toolbar .test-toolbar")?.textContent).toBe("Working");
|
||||
expect(container.querySelector(".codex-panel__region--goal .test-goal")?.textContent).toBe("no goal");
|
||||
expect(container.querySelector(".codex-panel__region--message-stream .test-messages")?.textContent).toBe("1");
|
||||
expect(container.querySelector<HTMLTextAreaElement>(".codex-panel__region--composer .test-composer textarea")?.value).toBe("ready");
|
||||
expect(container.querySelector(".codex-panel__region--composer .test-toolbar")).toBeNull();
|
||||
expect(container.querySelector(".codex-panel__region--composer .test-messages")).toBeNull();
|
||||
expect(container.querySelector<HTMLTextAreaElement>(".codex-panel__region--composer textarea")?.value).toBe("ready");
|
||||
expect(container.querySelector(".codex-panel__message-block .test-message-count")?.textContent).toBe("1");
|
||||
expect(composerRenderState).toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
unmountChatPanelShell(container);
|
||||
});
|
||||
});
|
||||
|
||||
it("renders region nodes inside the single shell root", async () => {
|
||||
it("does not invalidate unrelated shell surfaces for composer-only state changes", async () => {
|
||||
const store = createChatStateStore();
|
||||
store.dispatch({
|
||||
type: "thread-list/applied",
|
||||
threads: [{ id: "thread-1", name: "Thread", preview: "", archived: false, createdAt: 1, updatedAt: 1 }],
|
||||
});
|
||||
store.dispatch({ type: "ui/panel-set", panel: "history" });
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const cleanup = vi.fn();
|
||||
const renderers = nodeShellRenderers(store, cleanup);
|
||||
const renameState = vi.fn(() => null);
|
||||
const slots = shellSlots(store, { toolbarRenameState: renameState });
|
||||
const composerRenderState = vi.spyOn(slots.composer, "renderState");
|
||||
const messageStreamRenderState = vi.spyOn(slots.messageStream, "renderState");
|
||||
|
||||
await act(async () => {
|
||||
renderChatPanelShell(container, renderers);
|
||||
renderChatPanelShell(container, { stateStore: store, showToolbar: true, slots });
|
||||
await settleShellEffects();
|
||||
});
|
||||
renameState.mockClear();
|
||||
composerRenderState.mockClear();
|
||||
messageStreamRenderState.mockClear();
|
||||
|
||||
await act(async () => {
|
||||
store.dispatch({ type: "composer/draft-set", draft: "composer only", clearSuggestions: true });
|
||||
await settleShellEffects();
|
||||
});
|
||||
|
||||
expect(container.querySelector(".codex-panel__toolbar .test-toolbar")?.textContent).toBe("Idle");
|
||||
expect(container.querySelector(".codex-panel__region--message-stream .test-messages")?.textContent).toBe("0");
|
||||
|
||||
await act(async () => {
|
||||
unmountChatPanelShell(container);
|
||||
await settleShellEffects();
|
||||
});
|
||||
|
||||
expect(cleanup).toHaveBeenCalledWith("toolbar");
|
||||
expect(cleanup).toHaveBeenCalledWith("goal");
|
||||
expect(cleanup).toHaveBeenCalledWith("messages");
|
||||
expect(cleanup).toHaveBeenCalledWith("composer");
|
||||
});
|
||||
|
||||
it("updates subscribed regions without rerunning node factories when the state store updates", async () => {
|
||||
const store = createChatStateStore();
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const cleanup = vi.fn();
|
||||
const renderers = nodeShellRenderers(store, cleanup);
|
||||
|
||||
await act(async () => {
|
||||
renderChatPanelShell(container, renderers);
|
||||
await settleShellEffects();
|
||||
});
|
||||
renderers.toolbarNode.mockClear();
|
||||
renderers.goalNode.mockClear();
|
||||
renderers.messageStreamNode.mockClear();
|
||||
renderers.composerNode.mockClear();
|
||||
|
||||
await act(async () => {
|
||||
store.dispatch({ type: "connection/status-set", status: "Working" });
|
||||
await settleShellEffects();
|
||||
});
|
||||
|
||||
expect(container.querySelector(".test-toolbar")?.textContent).toBe("Working");
|
||||
expect(renderers.toolbarNode).not.toHaveBeenCalled();
|
||||
expect(renderers.goalNode).not.toHaveBeenCalled();
|
||||
expect(renderers.messageStreamNode).not.toHaveBeenCalled();
|
||||
expect(renderers.composerNode).not.toHaveBeenCalled();
|
||||
expect(container.querySelector<HTMLTextAreaElement>(".codex-panel__region--composer textarea")?.value).toBe("composer only");
|
||||
expect(composerRenderState).toHaveBeenCalled();
|
||||
expect(messageStreamRenderState).not.toHaveBeenCalled();
|
||||
expect(renameState).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
unmountChatPanelShell(container);
|
||||
});
|
||||
});
|
||||
|
||||
it("removes and restores the toolbar region from shell props", async () => {
|
||||
it("removes and restores the toolbar from shell props without replacing the body regions", async () => {
|
||||
const store = createChatStateStore();
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const renderers = shellRenderers(store);
|
||||
const slots = shellSlots(store);
|
||||
|
||||
await act(async () => {
|
||||
renderChatPanelShell(container, { ...renderers, showToolbar: false });
|
||||
renderChatPanelShell(container, { stateStore: store, showToolbar: false, slots });
|
||||
await settleShellEffects();
|
||||
});
|
||||
|
||||
|
|
@ -167,7 +121,7 @@ describe("ChatPanelShell", () => {
|
|||
expect(container.querySelector(".codex-panel__region--composer")).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
renderChatPanelShell(container, { ...renderers, showToolbar: true });
|
||||
renderChatPanelShell(container, { stateStore: store, showToolbar: true, slots });
|
||||
await settleShellEffects();
|
||||
});
|
||||
|
||||
|
|
@ -179,6 +133,34 @@ describe("ChatPanelShell", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("repairs a damaged shell through the single root", async () => {
|
||||
const store = createChatStateStore();
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const slots = shellSlots(store);
|
||||
|
||||
await act(async () => {
|
||||
renderChatPanelShell(container, { stateStore: store, showToolbar: true, slots });
|
||||
await settleShellEffects();
|
||||
});
|
||||
|
||||
container.querySelector<HTMLElement>(":scope .codex-panel__messages")?.remove();
|
||||
|
||||
await act(async () => {
|
||||
store.dispatch({
|
||||
type: "message-stream/system-item-added",
|
||||
item: { id: "system-1", kind: "system", role: "system", text: "Restored." },
|
||||
});
|
||||
await settleShellEffects();
|
||||
});
|
||||
|
||||
expect(container.querySelector(".codex-panel__messages .test-message-count")?.textContent).toBe("1");
|
||||
|
||||
await act(async () => {
|
||||
unmountChatPanelShell(container);
|
||||
});
|
||||
});
|
||||
|
||||
it("sets composer bottom clearance only for fixed visible Obsidian status bars", async () => {
|
||||
const store = createChatStateStore();
|
||||
const container = document.createElement("div");
|
||||
|
|
@ -194,21 +176,14 @@ describe("ChatPanelShell", () => {
|
|||
await act(async () => {
|
||||
statusBar.style.display = "flex";
|
||||
statusBar.style.position = "fixed";
|
||||
renderChatPanelShell(container, shellRenderers(store));
|
||||
renderChatPanelShell(container, shellProps(store));
|
||||
await settleShellEffects();
|
||||
});
|
||||
expect(container.style.getPropertyValue("--codex-panel-status-bar-clearance")).toBe("26px");
|
||||
|
||||
await act(async () => {
|
||||
statusBar.style.position = "static";
|
||||
renderChatPanelShell(container, shellRenderers(store));
|
||||
await settleShellEffects();
|
||||
});
|
||||
expect(container.style.getPropertyValue("--codex-panel-status-bar-clearance")).toBe("0px");
|
||||
|
||||
await act(async () => {
|
||||
statusBar.style.display = "none";
|
||||
renderChatPanelShell(container, shellRenderers(store));
|
||||
renderChatPanelShell(container, shellProps(store));
|
||||
await settleShellEffects();
|
||||
});
|
||||
expect(container.style.getPropertyValue("--codex-panel-status-bar-clearance")).toBe("0px");
|
||||
|
|
@ -218,293 +193,141 @@ describe("ChatPanelShell", () => {
|
|||
});
|
||||
statusBar.remove();
|
||||
});
|
||||
|
||||
it("repairs a removed ui root without inspecting shell children", async () => {
|
||||
const store = createChatStateStore();
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const cleanup = vi.fn();
|
||||
const renderers = trackedRootShellRenderers(store, cleanup);
|
||||
|
||||
await act(async () => {
|
||||
renderChatPanelShell(container, renderers);
|
||||
await settleShellEffects();
|
||||
});
|
||||
|
||||
container.replaceChildren();
|
||||
|
||||
await act(async () => {
|
||||
renderChatPanelShell(container, renderers);
|
||||
await settleShellEffects();
|
||||
});
|
||||
|
||||
expect(cleanup).toHaveBeenCalledWith("toolbar");
|
||||
expect(container.textContent).toContain("toolbar");
|
||||
expect(container.textContent).toContain("goal");
|
||||
expect(container.textContent).toContain("messages");
|
||||
expect(container.textContent).toContain("composer");
|
||||
|
||||
await act(async () => {
|
||||
unmountChatPanelShell(container);
|
||||
});
|
||||
});
|
||||
|
||||
it("repairs damaged shell regions through the single root", async () => {
|
||||
const store = createChatStateStore();
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const cleanup = vi.fn();
|
||||
const renderers = nodeShellRenderers(store, cleanup);
|
||||
|
||||
await act(async () => {
|
||||
renderChatPanelShell(container, renderers);
|
||||
await settleShellEffects();
|
||||
});
|
||||
|
||||
container.querySelector<HTMLElement>(":scope .codex-panel__messages")?.remove();
|
||||
|
||||
await act(async () => {
|
||||
renderChatPanelShell(container, renderers);
|
||||
await settleShellEffects();
|
||||
});
|
||||
|
||||
expect(cleanup).toHaveBeenCalledWith("messages");
|
||||
expect(container.querySelector(".codex-panel__messages .test-messages")?.textContent).toBe("0");
|
||||
|
||||
await act(async () => {
|
||||
unmountChatPanelShell(container);
|
||||
});
|
||||
});
|
||||
|
||||
it("repairs damaged shell regions on subscribed store updates", async () => {
|
||||
const store = createChatStateStore();
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const cleanup = vi.fn();
|
||||
const renderers = nodeShellRenderers(store, cleanup);
|
||||
|
||||
await act(async () => {
|
||||
renderChatPanelShell(container, renderers);
|
||||
await settleShellEffects();
|
||||
});
|
||||
|
||||
container.querySelector<HTMLElement>(":scope .codex-panel__messages")?.remove();
|
||||
|
||||
await act(async () => {
|
||||
store.dispatch({
|
||||
type: "message-stream/system-item-added",
|
||||
item: { id: "system-1", kind: "system", role: "system", text: "Restored." },
|
||||
});
|
||||
await settleShellEffects();
|
||||
});
|
||||
|
||||
expect(cleanup).toHaveBeenCalledWith("messages");
|
||||
expect(container.querySelector(".codex-panel__messages .test-messages")?.textContent).toBe("1");
|
||||
|
||||
await act(async () => {
|
||||
unmountChatPanelShell(container);
|
||||
});
|
||||
});
|
||||
|
||||
it("unmounts every shell region when the shell unmounts", async () => {
|
||||
const store = createChatStateStore();
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const cleanup = vi.fn();
|
||||
const renderers = trackedRootShellRenderers(store, cleanup);
|
||||
|
||||
await act(async () => {
|
||||
renderChatPanelShell(container, renderers);
|
||||
await settleShellEffects();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
unmountChatPanelShell(container);
|
||||
await settleShellEffects();
|
||||
});
|
||||
|
||||
expect(cleanup).toHaveBeenCalledWith("toolbar");
|
||||
expect(cleanup).toHaveBeenCalledWith("goal");
|
||||
expect(cleanup).toHaveBeenCalledWith("messages");
|
||||
expect(cleanup).toHaveBeenCalledWith("composer");
|
||||
});
|
||||
|
||||
it("stops subscribed region rendering after unmount", async () => {
|
||||
const store = createChatStateStore();
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const renderers = shellRenderers(store);
|
||||
|
||||
await act(async () => {
|
||||
renderChatPanelShell(container, renderers);
|
||||
await settleShellEffects();
|
||||
});
|
||||
renderers.toolbarNode.mockClear();
|
||||
|
||||
await act(async () => {
|
||||
unmountChatPanelShell(container);
|
||||
});
|
||||
store.dispatch({ type: "connection/status-set", status: "Closed" });
|
||||
await settleShellEffects();
|
||||
|
||||
expect(renderers.toolbarNode).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
function shellRenderers(store: ReturnType<typeof createChatStateStore>) {
|
||||
function shellProps(store: ReturnType<typeof createChatStateStore>) {
|
||||
return {
|
||||
stateStore: store,
|
||||
showToolbar: true,
|
||||
toolbarNode: vi.fn(() => <ShellStatus />),
|
||||
|
||||
goalNode: vi.fn(() => <ShellGoal />),
|
||||
|
||||
messageStreamNode: vi.fn(() => (
|
||||
<div className="codex-panel__region codex-panel__region--message-stream codex-panel__messages">
|
||||
<ShellMessageCount />
|
||||
</div>
|
||||
)),
|
||||
|
||||
composerNode: vi.fn(() => <ShellComposerStatus />),
|
||||
slots: shellSlots(store),
|
||||
};
|
||||
}
|
||||
|
||||
function nestedRootShellRenderers(store: ReturnType<typeof createChatStateStore>) {
|
||||
interface ShellSlotsOptions {
|
||||
toolbarRenameState?: () => null;
|
||||
}
|
||||
|
||||
function shellSlots(store: ReturnType<typeof createChatStateStore>, options: ShellSlotsOptions = {}): ChatPanelShellSlots {
|
||||
const ports = surfacePorts(store, options);
|
||||
return {
|
||||
stateStore: store,
|
||||
showToolbar: true,
|
||||
toolbarNode: vi.fn(() => (
|
||||
<>
|
||||
<ShellStatus className="test-toolbar" />
|
||||
<div className="test-toolbar-panel">panel</div>
|
||||
</>
|
||||
)),
|
||||
|
||||
goalNode: vi.fn(() => <ShellGoal className="test-goal" />),
|
||||
|
||||
messageStreamNode: vi.fn(() => (
|
||||
<div className="codex-panel__region codex-panel__region--message-stream codex-panel__messages">
|
||||
<ShellMessageCount className="test-messages" />
|
||||
</div>
|
||||
)),
|
||||
|
||||
composerNode: vi.fn(() => (
|
||||
<div className="test-composer">
|
||||
<ShellComposerTextarea />
|
||||
<button type="button">Send</button>
|
||||
</div>
|
||||
)),
|
||||
toolbar: ports.toolbar,
|
||||
goal: ports.goal,
|
||||
messageStream: {
|
||||
renderState: () => ({
|
||||
blocks: [
|
||||
{
|
||||
key: "count",
|
||||
node: <div className="test-message-count">{String(chatStateDisplayItems(store.getState()).length)}</div>,
|
||||
},
|
||||
],
|
||||
consumeScrollIntent: () => "auto",
|
||||
}),
|
||||
},
|
||||
composer: {
|
||||
renderState: () => ({
|
||||
viewId: "view",
|
||||
draft: store.getState().composer.draft,
|
||||
busy: false,
|
||||
canInterrupt: false,
|
||||
normalPlaceholder: "Ask Codex to work on this task...",
|
||||
suggestions: [],
|
||||
selectedSuggestionIndex: 0,
|
||||
callbacks: {
|
||||
onInput: vi.fn(),
|
||||
onUpdateSuggestions: vi.fn(),
|
||||
onKeydown: vi.fn(),
|
||||
onSendOrInterrupt: vi.fn(),
|
||||
onHeightChange: vi.fn(),
|
||||
onSuggestionHover: vi.fn(),
|
||||
onSuggestionInsert: vi.fn(),
|
||||
},
|
||||
meta: {
|
||||
fatal: null,
|
||||
context: {
|
||||
cells: [
|
||||
{ text: "⣀", placeholder: true },
|
||||
{ text: "⣀", placeholder: true },
|
||||
{ text: "⣀", placeholder: true },
|
||||
{ text: "⣀", placeholder: true },
|
||||
],
|
||||
percent: "--%",
|
||||
},
|
||||
statusSummary: "Context unavailable, plan off, auto-review off, fast off, model default, reasoning effort default",
|
||||
model: "default",
|
||||
effort: null,
|
||||
planActive: false,
|
||||
autoReviewActive: false,
|
||||
fastActive: false,
|
||||
modelChoices: [],
|
||||
effortChoices: [],
|
||||
},
|
||||
onComposer: () => undefined,
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function trackedRootShellRenderers(store: ReturnType<typeof createChatStateStore>, cleanup: (region: string) => void) {
|
||||
function surfacePorts(store: ReturnType<typeof createChatStateStore>, options: ShellSlotsOptions): ChatPanelSurfacePorts {
|
||||
return {
|
||||
stateStore: store,
|
||||
showToolbar: true,
|
||||
toolbarNode: vi.fn(() => <TrackedSlot region="toolbar" cleanup={cleanup} />),
|
||||
|
||||
goalNode: vi.fn(() => <TrackedSlot region="goal" cleanup={cleanup} />),
|
||||
|
||||
messageStreamNode: vi.fn(() => (
|
||||
<div className="codex-panel__region codex-panel__region--message-stream codex-panel__messages">
|
||||
<TrackedSlot region="messages" cleanup={cleanup} />
|
||||
</div>
|
||||
)),
|
||||
|
||||
composerNode: vi.fn(() => <TrackedSlot region="composer" cleanup={cleanup} />),
|
||||
toolbar: {
|
||||
state: {
|
||||
connected: () => false,
|
||||
},
|
||||
settings: {
|
||||
vaultPath: () => "/vault",
|
||||
configuredCommand: () => "codex",
|
||||
archiveExportEnabled: () => true,
|
||||
},
|
||||
view: {
|
||||
toolbar: {
|
||||
archiveConfirm: signal(null),
|
||||
renameState: options.toolbarRenameState ?? (() => null),
|
||||
renameVersion: signal(0),
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
toolbar: {
|
||||
startNewThread: vi.fn(),
|
||||
toggleChatActions: vi.fn(),
|
||||
compactConversation: vi.fn(),
|
||||
setGoal: vi.fn(),
|
||||
toggleHistory: vi.fn(),
|
||||
toggleStatusPanel: vi.fn(),
|
||||
connect: vi.fn(),
|
||||
refreshStatus: vi.fn(),
|
||||
resumeThread: vi.fn(),
|
||||
startArchiveThread: vi.fn(),
|
||||
archiveThread: vi.fn(),
|
||||
startRenameThread: vi.fn(),
|
||||
updateRenameDraft: vi.fn(),
|
||||
saveRenameThread: vi.fn(),
|
||||
cancelRenameThread: vi.fn(),
|
||||
autoNameThread: vi.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
goal: {
|
||||
settings: { sendShortcut: () => "enter" },
|
||||
actions: {
|
||||
goal: {
|
||||
saveObjective: async () => undefined,
|
||||
setStatus: async () => undefined,
|
||||
clear: async () => undefined,
|
||||
setEditingOpen: () => undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
composer: {
|
||||
thread: { restoredPlaceholder: () => null },
|
||||
runtime: {
|
||||
requestModel: async () => undefined,
|
||||
requestReasoningEffort: async () => undefined,
|
||||
resetReasoningEffortToConfig: async () => undefined,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function nodeShellRenderers(store: ReturnType<typeof createChatStateStore>, cleanup: (region: string) => void) {
|
||||
return {
|
||||
stateStore: store,
|
||||
showToolbar: true,
|
||||
toolbarNode: vi.fn(() => <TrackedStateSlot region="toolbar" cleanup={cleanup} className="test-toolbar" selector="status" />),
|
||||
|
||||
goalNode: vi.fn(() => <TrackedStateSlot region="goal" cleanup={cleanup} className="test-goal" selector="goal" />),
|
||||
|
||||
messageStreamNode: vi.fn(() => (
|
||||
<div className="codex-panel__region codex-panel__region--message-stream codex-panel__messages">
|
||||
<TrackedStateSlot region="messages" cleanup={cleanup} className="test-messages" selector="message-count" />
|
||||
</div>
|
||||
)),
|
||||
|
||||
composerNode: vi.fn(() => (
|
||||
<TrackedStateSlot region="composer" cleanup={cleanup} className="test-composer" selector="composer-status" />
|
||||
)),
|
||||
};
|
||||
}
|
||||
|
||||
function TrackedSlot({
|
||||
region,
|
||||
cleanup,
|
||||
className,
|
||||
text = region,
|
||||
}: {
|
||||
region: string;
|
||||
cleanup: (region: string) => void;
|
||||
className?: string;
|
||||
text?: string;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cleanup(region);
|
||||
};
|
||||
}, [cleanup, region]);
|
||||
return <div className={className}>{text}</div>;
|
||||
}
|
||||
|
||||
function TrackedStateSlot({
|
||||
region,
|
||||
cleanup,
|
||||
className,
|
||||
selector,
|
||||
}: {
|
||||
region: string;
|
||||
cleanup: (region: string) => void;
|
||||
className?: string;
|
||||
selector: "status" | "goal" | "message-count" | "composer-status";
|
||||
}) {
|
||||
return <TrackedSlot region={region} cleanup={cleanup} {...(className ? { className } : {})} text={useShellText(selector)} />;
|
||||
}
|
||||
|
||||
function ShellStatus({ className }: { className?: string }) {
|
||||
const { connection } = useChatPanelShellState();
|
||||
return <div className={className}>{connection.value.status}</div>;
|
||||
}
|
||||
|
||||
function ShellGoal({ className }: { className?: string }) {
|
||||
const { activeThread } = useChatPanelShellState();
|
||||
return <div className={className}>{activeThread.value.goal?.objective ?? "no goal"}</div>;
|
||||
}
|
||||
|
||||
function ShellMessageCount({ className }: { className?: string }) {
|
||||
const { messageStream } = useChatPanelShellState();
|
||||
return <div className={className}>{String(messageStream.value.displayItems.length)}</div>;
|
||||
}
|
||||
|
||||
function ShellComposerStatus() {
|
||||
return <div>{useShellText("composer-status")}</div>;
|
||||
}
|
||||
|
||||
function ShellComposerTextarea() {
|
||||
return <textarea value={useShellText("composer-status")} readOnly />;
|
||||
}
|
||||
|
||||
function useShellText(selector: "status" | "goal" | "message-count" | "composer-status"): string {
|
||||
const shellState = useChatPanelShellState();
|
||||
switch (selector) {
|
||||
case "status":
|
||||
return shellState.connection.value.status;
|
||||
case "goal":
|
||||
return shellState.activeThread.value.goal?.objective ?? "no goal";
|
||||
case "message-count":
|
||||
return String(shellState.messageStream.value.displayItems.length);
|
||||
case "composer-status":
|
||||
return chatTurnBusy({ ...shellState.latestState(), turn: shellState.turn.value }) ? "busy" : "ready";
|
||||
}
|
||||
}
|
||||
|
||||
async function settleShellEffects(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
|
|
|||
|
|
@ -2,19 +2,18 @@
|
|||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { act } from "preact/test-utils";
|
||||
import { signal } from "@preact/signals";
|
||||
|
||||
import type { Thread } from "../../../../src/domain/threads/model";
|
||||
import { createChatStateStore } from "../../../../src/features/chat/state/reducer";
|
||||
import { runtimeSnapshotForChatState } from "../../../../src/features/chat/runtime/snapshot";
|
||||
import {
|
||||
createToolbarArchiveConfirmState,
|
||||
createToolbarPanelActions,
|
||||
type ToolbarPanelActions,
|
||||
} from "../../../../src/features/chat/panel/regions/toolbar";
|
||||
import type { ChatPanelToolbarPorts } from "../../../../src/features/chat/panel/regions/ports";
|
||||
} from "../../../../src/features/chat/panel/toolbar-actions";
|
||||
import type { ChatPanelSurfacePorts, ChatPanelToolbarPorts } from "../../../../src/features/chat/panel/surface/ports";
|
||||
import type { ChatThreadActions } from "../../../../src/features/chat/threads/action-context";
|
||||
import { renderChatPanelShell, unmountChatPanelShell } from "../../../../src/features/chat/ui/shell";
|
||||
import { chatPanelToolbarRegionNode } from "../../../../src/features/chat/panel/regions/toolbar";
|
||||
import { renderChatPanelShell, unmountChatPanelShell, type ChatPanelShellSlots } from "../../../../src/features/chat/ui/shell";
|
||||
import { installObsidianDomShims } from "../../../support/dom";
|
||||
|
||||
installObsidianDomShims();
|
||||
|
|
@ -40,10 +39,7 @@ describe("chat toolbar archive confirmation signal", () => {
|
|||
renderChatPanelShell(container, {
|
||||
stateStore: store,
|
||||
showToolbar: true,
|
||||
toolbarNode: () => chatPanelToolbarRegionNode(toolbarPorts(store, toolbarActions)),
|
||||
goalNode: () => null,
|
||||
messageStreamNode: () => <div className="codex-panel__region codex-panel__region--message-stream codex-panel__messages" />,
|
||||
composerNode: () => null,
|
||||
slots: shellSlots(store, toolbarActions),
|
||||
});
|
||||
await settle();
|
||||
});
|
||||
|
|
@ -67,24 +63,18 @@ describe("chat toolbar archive confirmation signal", () => {
|
|||
function toolbarPorts(store: ReturnType<typeof createChatStateStore>, toolbarActions: ToolbarPanelActions): ChatPanelToolbarPorts {
|
||||
return {
|
||||
state: {
|
||||
chat: () => store.getState(),
|
||||
connected: () => false,
|
||||
turnBusy: () => false,
|
||||
},
|
||||
settings: {
|
||||
vaultPath: () => "/vault",
|
||||
configuredCommand: () => "codex",
|
||||
archiveExportEnabled: () => true,
|
||||
},
|
||||
runtime: {
|
||||
snapshot: () => runtimeSnapshotForChatState(store.getState()),
|
||||
},
|
||||
view: {
|
||||
toolbar: {
|
||||
archiveConfirmId: () => toolbarActions.archiveConfirmId(),
|
||||
archiveConfirmSubscribe: (listener) => toolbarActions.onArchiveConfirmChange(listener),
|
||||
archiveConfirm: toolbarActions.archiveConfirm,
|
||||
renameState: () => null,
|
||||
renameSubscribe: () => () => undefined,
|
||||
renameVersion: signal(0),
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
|
|
@ -112,6 +102,86 @@ function toolbarPorts(store: ReturnType<typeof createChatStateStore>, toolbarAct
|
|||
};
|
||||
}
|
||||
|
||||
function shellSlots(store: ReturnType<typeof createChatStateStore>, toolbarActions: ToolbarPanelActions): ChatPanelShellSlots {
|
||||
const ports = surfacePorts(store, toolbarActions);
|
||||
return {
|
||||
toolbar: ports.toolbar,
|
||||
goal: ports.goal,
|
||||
messageStream: {
|
||||
renderState: () => ({
|
||||
blocks: [],
|
||||
consumeScrollIntent: () => "auto",
|
||||
}),
|
||||
},
|
||||
composer: {
|
||||
renderState: () => ({
|
||||
viewId: "view",
|
||||
draft: "",
|
||||
busy: false,
|
||||
canInterrupt: false,
|
||||
normalPlaceholder: "Ask Codex to work on this task...",
|
||||
suggestions: [],
|
||||
selectedSuggestionIndex: 0,
|
||||
callbacks: {
|
||||
onInput: vi.fn(),
|
||||
onUpdateSuggestions: vi.fn(),
|
||||
onKeydown: vi.fn(),
|
||||
onSendOrInterrupt: vi.fn(),
|
||||
onHeightChange: vi.fn(),
|
||||
onSuggestionHover: vi.fn(),
|
||||
onSuggestionInsert: vi.fn(),
|
||||
},
|
||||
meta: {
|
||||
fatal: null,
|
||||
context: {
|
||||
cells: [
|
||||
{ text: "⣀", placeholder: true },
|
||||
{ text: "⣀", placeholder: true },
|
||||
{ text: "⣀", placeholder: true },
|
||||
{ text: "⣀", placeholder: true },
|
||||
],
|
||||
percent: "--%",
|
||||
},
|
||||
statusSummary: "Context unavailable, plan off, auto-review off, fast off, model default, reasoning effort default",
|
||||
model: "default",
|
||||
effort: null,
|
||||
planActive: false,
|
||||
autoReviewActive: false,
|
||||
fastActive: false,
|
||||
modelChoices: [],
|
||||
effortChoices: [],
|
||||
},
|
||||
onComposer: () => undefined,
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function surfacePorts(store: ReturnType<typeof createChatStateStore>, toolbarActions: ToolbarPanelActions): ChatPanelSurfacePorts {
|
||||
return {
|
||||
toolbar: toolbarPorts(store, toolbarActions),
|
||||
goal: {
|
||||
settings: { sendShortcut: () => "enter" },
|
||||
actions: {
|
||||
goal: {
|
||||
saveObjective: async () => undefined,
|
||||
setStatus: async () => undefined,
|
||||
clear: async () => undefined,
|
||||
setEditingOpen: () => undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
composer: {
|
||||
thread: { restoredPlaceholder: () => null },
|
||||
runtime: {
|
||||
requestModel: async () => undefined,
|
||||
requestReasoningEffort: async () => undefined,
|
||||
resetReasoningEffortToConfig: async () => undefined,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function threadFixture(id: string, name: string): Thread {
|
||||
return {
|
||||
id,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import type { ServerNotification } from "../../../src/app-server/connection/rpc-
|
|||
import { notices } from "../../mocks/obsidian";
|
||||
import { deferred, waitForAsyncWork } from "../../support/async";
|
||||
import { installObsidianDomShims } from "../../support/dom";
|
||||
import { chatStateDisplayItems } from "./support/message-stream";
|
||||
|
||||
const ESTIMATED_MESSAGE_BLOCK_HEIGHT = 96;
|
||||
|
||||
|
|
@ -256,7 +257,7 @@ describe("CodexChatView connection lifecycle", () => {
|
|||
});
|
||||
expect((view as unknown as { state: ChatState }).state.activeThread.id).toBe("thread-new");
|
||||
expect((view as unknown as { state: ChatState }).state.activeThread.goal?.objective).toBe("Ship the feature");
|
||||
expect((view as unknown as { state: ChatState }).state.messageStream.displayItems).toContainEqual(
|
||||
expect(chatStateDisplayItems((view as unknown as { state: ChatState }).state)).toContainEqual(
|
||||
expect.objectContaining({ kind: "goal", text: "set: Ship the feature", objective: "Ship the feature" }),
|
||||
);
|
||||
expect(view.containerEl.textContent).toContain("Ship the feature");
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createServerDiagnostics } from "../../../src/domain/server/diagnostics";
|
||||
import {
|
||||
|
|
@ -7,18 +7,19 @@ import {
|
|||
type RuntimeConfigSnapshot,
|
||||
} from "../../../src/app-server/protocol/runtime-config";
|
||||
import { createChatState } from "../../../src/features/chat/state/reducer";
|
||||
import { composerMetaViewModel, composerPlaceholder } from "../../../src/features/chat/panel/regions/composer";
|
||||
import { composerMetaViewModel, composerPlaceholder } from "../../../src/features/chat/panel/surface/composer";
|
||||
import { effortStatusLines, modelStatusLines, statusSummaryLines } from "../../../src/features/chat/display/status/runtime";
|
||||
import { runtimeComposerChoices } from "../../../src/features/chat/panel/regions/composer";
|
||||
import { runtimeComposerChoices } from "../../../src/features/chat/panel/surface/composer";
|
||||
import { runtimeSnapshotForChatState } from "../../../src/features/chat/runtime/snapshot";
|
||||
import { toolbarViewModel } from "../../../src/features/chat/panel/regions/toolbar";
|
||||
import { toolbarViewModel } from "../../../src/features/chat/panel/surface/toolbar";
|
||||
import type { ChatState } from "../../../src/features/chat/state/reducer";
|
||||
import type { ModelMetadata } from "../../../src/domain/catalog/metadata";
|
||||
import type { Thread } from "../../../src/domain/threads/model";
|
||||
import { chatPanelComposerMetaViewModel, chatPanelComposerPlaceholder } from "../../../src/features/chat/panel/regions/composer";
|
||||
import { chatPanelGoalProps } from "../../../src/features/chat/panel/regions/goal";
|
||||
import type { ChatPanelComposerPorts, ChatPanelGoalPorts } from "../../../src/features/chat/panel/regions/ports";
|
||||
import { chatPanelComposerMetaViewModel, chatPanelComposerPlaceholder } from "../../../src/features/chat/panel/surface/composer";
|
||||
import { chatPanelGoalProps } from "../../../src/features/chat/panel/surface/goal";
|
||||
import type { ChatPanelComposerPorts, ChatPanelGoalPorts } from "../../../src/features/chat/panel/surface/ports";
|
||||
import type { ThreadGoal } from "../../../src/domain/threads/goal";
|
||||
import { setChatStateDisplayItems } from "./support/message-stream";
|
||||
|
||||
describe("chat view model", () => {
|
||||
it("builds toolbar rows from immutable chat state snapshots", () => {
|
||||
|
|
@ -39,7 +40,8 @@ describe("chat view model", () => {
|
|||
configuredCommand: "codex",
|
||||
archiveConfirmThreadId: "thread-2",
|
||||
archiveExportEnabled: true,
|
||||
renameState: (threadId) => (threadId === "thread-1" ? { draft: "Active", generating: false } : null),
|
||||
renameRevision: 1,
|
||||
renameState: (threadId, _renameRevision) => (threadId === "thread-1" ? { draft: "Active", generating: false } : null),
|
||||
});
|
||||
|
||||
expect(model.openPanel).toBe("history");
|
||||
|
|
@ -89,7 +91,7 @@ describe("chat view model", () => {
|
|||
it("uses a neutral composer context indicator when usage is unavailable", () => {
|
||||
const state = createChatState();
|
||||
state.activeThread.id = "thread-1";
|
||||
state.messageStream.displayItems = [
|
||||
setChatStateDisplayItems(state, [
|
||||
{
|
||||
id: "item",
|
||||
turnId: "turn-1",
|
||||
|
|
@ -99,7 +101,7 @@ describe("chat view model", () => {
|
|||
text: "Existing turn",
|
||||
role: "assistant",
|
||||
},
|
||||
];
|
||||
]);
|
||||
state.connection.runtimeConfig = runtimeConfigFixture({ model: "gpt-5.5" });
|
||||
|
||||
expect(composerMetaViewModel(state, runtimeSnapshotFixture(state))).toMatchObject({
|
||||
|
|
@ -250,11 +252,6 @@ describe("chat view model", () => {
|
|||
const statuses: [string, string][] = [];
|
||||
const clears: string[] = [];
|
||||
const ports = {
|
||||
state: {
|
||||
chat: () => {
|
||||
throw new Error("Goal status actions should not reread the active thread.");
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
sendShortcut: () => "enter",
|
||||
},
|
||||
|
|
@ -285,29 +282,25 @@ describe("chat view model", () => {
|
|||
expect(clears).toEqual(["thread-rendered"]);
|
||||
});
|
||||
|
||||
it("builds composer meta from one captured chat state and runtime snapshot", () => {
|
||||
it("builds composer meta from one captured chat state", () => {
|
||||
const state = createChatState();
|
||||
state.connection.runtimeConfig = runtimeConfigFixture({ model: "gpt-5.5", model_reasoning_effort: "high" });
|
||||
state.connection.availableModels = [modelFixture("gpt-5.5")];
|
||||
const snapshot = runtimeSnapshotFixture(state);
|
||||
const chat = vi.fn(() => state);
|
||||
const runtimeSnapshot = vi.fn(() => snapshot);
|
||||
|
||||
const model = chatPanelComposerMetaViewModel({
|
||||
state: { chat },
|
||||
thread: {
|
||||
restoredPlaceholder: () => null,
|
||||
},
|
||||
runtime: {
|
||||
snapshot: runtimeSnapshot,
|
||||
requestModel: async () => undefined,
|
||||
requestReasoningEffort: async () => undefined,
|
||||
resetReasoningEffortToConfig: async () => undefined,
|
||||
},
|
||||
} satisfies ChatPanelComposerPorts);
|
||||
const model = chatPanelComposerMetaViewModel(
|
||||
{
|
||||
thread: {
|
||||
restoredPlaceholder: () => null,
|
||||
},
|
||||
runtime: {
|
||||
requestModel: async () => undefined,
|
||||
requestReasoningEffort: async () => undefined,
|
||||
resetReasoningEffortToConfig: async () => undefined,
|
||||
},
|
||||
} satisfies ChatPanelComposerPorts,
|
||||
state,
|
||||
);
|
||||
|
||||
expect(chat).toHaveBeenCalledOnce();
|
||||
expect(runtimeSnapshot).toHaveBeenCalledOnce();
|
||||
expect(model).toMatchObject({
|
||||
model: "gpt-5.5",
|
||||
effort: "high",
|
||||
|
|
@ -330,18 +323,19 @@ describe("chat view model", () => {
|
|||
state.threadList.listedThreads = [threadFixture("thread-1", null)];
|
||||
|
||||
expect(
|
||||
chatPanelComposerPlaceholder({
|
||||
state: { chat: () => state },
|
||||
thread: {
|
||||
restoredPlaceholder: () => ({ threadId: "thread-1", title: "Restored", explicitName: "Restored" }),
|
||||
},
|
||||
runtime: {
|
||||
snapshot: () => runtimeSnapshotFixture(state),
|
||||
requestModel: async () => undefined,
|
||||
requestReasoningEffort: async () => undefined,
|
||||
resetReasoningEffortToConfig: async () => undefined,
|
||||
},
|
||||
} satisfies ChatPanelComposerPorts),
|
||||
chatPanelComposerPlaceholder(
|
||||
{
|
||||
thread: {
|
||||
restoredPlaceholder: () => ({ threadId: "thread-1", title: "Restored", explicitName: "Restored" }),
|
||||
},
|
||||
runtime: {
|
||||
requestModel: async () => undefined,
|
||||
requestReasoningEffort: async () => undefined,
|
||||
resetReasoningEffortToConfig: async () => undefined,
|
||||
},
|
||||
} satisfies ChatPanelComposerPorts,
|
||||
state,
|
||||
),
|
||||
).toBe("Ask Codex to work on “Restored”...");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
116
tests/scripts/eslint-config.test.ts
Normal file
116
tests/scripts/eslint-config.test.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import path from "node:path";
|
||||
import { ESLint } from "eslint";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
|
||||
describe("eslint config", () => {
|
||||
it("reports imperative DOM writes in non-bridge source files", async () => {
|
||||
const messages = await lintSource(
|
||||
"src/features/chat/runtime/effective.ts",
|
||||
`
|
||||
export function setStatus(element: HTMLElement): void {
|
||||
element.textContent = "Loading";
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
expect(messages).toContain("codex-panel/no-imperative-dom");
|
||||
});
|
||||
|
||||
it("reports Obsidian HTMLElement mutation helpers in non-bridge source files", async () => {
|
||||
const messages = await lintSource(
|
||||
"src/features/chat/runtime/effective.ts",
|
||||
`
|
||||
export function setStatus(element: HTMLElement): void {
|
||||
element.addClass("is-ready");
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
expect(messages).toContain("codex-panel/no-imperative-dom");
|
||||
});
|
||||
|
||||
it("does not confuse plain value properties with DOM writes", async () => {
|
||||
const messages = await lintSource(
|
||||
"src/features/chat/runtime/effective.ts",
|
||||
`
|
||||
interface Box {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export function setBoxValue(box: Box): void {
|
||||
box.value = "ready";
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
expect(messages).not.toContain("codex-panel/no-imperative-dom");
|
||||
});
|
||||
|
||||
it("does not confuse signal value writes with DOM writes", async () => {
|
||||
const messages = await lintSource(
|
||||
"src/features/chat/runtime/effective.ts",
|
||||
`
|
||||
import { signal } from "@preact/signals";
|
||||
|
||||
export function setSignalStatus(): string {
|
||||
const status = signal("idle");
|
||||
status.value = "ready";
|
||||
return status.value;
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
expect(messages).not.toContain("codex-panel/no-imperative-dom");
|
||||
});
|
||||
|
||||
it("allows event wiring but not DOM writes in event-only bridge files", async () => {
|
||||
const messages = await lintSource(
|
||||
"src/shared/lifecycle/abortable.ts",
|
||||
`
|
||||
export function attach(signal: AbortSignal, element: HTMLElement): void {
|
||||
signal.addEventListener("abort", () => undefined);
|
||||
element.replaceChildren();
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
expect(messages.filter((message) => message === "codex-panel/no-imperative-dom")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("allows DOM root attachment in explicit Preact renderer bridge files", async () => {
|
||||
const messages = await lintSource(
|
||||
"src/features/threads-view/renderer.tsx",
|
||||
`
|
||||
export function renderThreadsView(parent: HTMLElement): void {
|
||||
parent.addClass("codex-panel-threads");
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
expect(messages).not.toContain("codex-panel/no-imperative-dom");
|
||||
});
|
||||
|
||||
it("keeps all chat state modules deterministic", async () => {
|
||||
const messages = await lintSource(
|
||||
"src/features/chat/state/message-stream.ts",
|
||||
`
|
||||
export function timestamp(): number {
|
||||
return Date.now();
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
expect(messages).toContain("no-restricted-syntax");
|
||||
});
|
||||
});
|
||||
|
||||
async function lintSource(filePath: string, source: string): Promise<string[]> {
|
||||
const eslint = new ESLint({
|
||||
cwd: repoRoot,
|
||||
overrideConfigFile: path.join(repoRoot, "eslint.config.mjs"),
|
||||
});
|
||||
const [result] = await eslint.lintText(source, { filePath: path.join(repoRoot, filePath) });
|
||||
return (result?.messages ?? []).map((message) => message.ruleId ?? message.message);
|
||||
}
|
||||
Loading…
Reference in a new issue