diff --git a/esbuild.config.mjs b/esbuild.config.mjs
index 20768bae..74e44164 100644
--- a/esbuild.config.mjs
+++ b/esbuild.config.mjs
@@ -12,6 +12,7 @@ await esbuild.build({
platform: "node",
target: "es2022",
jsx: "automatic",
+ jsxImportSource: "preact",
outfile: "main.js",
sourcemap: false,
minify: true,
diff --git a/package-lock.json b/package-lock.json
index aac80b0a..b5f0bc09 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,14 +9,11 @@
"version": "2.4.0",
"license": "Apache-2.0",
"dependencies": {
- "react": "^18.3.1",
- "react-dom": "^18.3.1"
+ "preact": "^10.29.2"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^25.6.0",
- "@types/react": "^18.3.29",
- "@types/react-dom": "^18.3.7",
"esbuild": "^0.28.0",
"eslint": "^10.3.0",
"eslint-config-prettier": "^10.1.8",
@@ -2023,34 +2020,6 @@
"undici-types": ">=7.24.0 <7.24.7"
}
},
- "node_modules/@types/prop-types": {
- "version": "15.7.15",
- "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
- "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@types/react": {
- "version": "18.3.29",
- "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.29.tgz",
- "integrity": "sha512-ch0qJdr2JY0r04NXSprbK6TXOgnaJ1Tz23fm5W+z0/CBah6BSBc3n96h7K9GOtwh0HrilNWHIBzE1Ko4Dcw/Wg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@types/prop-types": "*",
- "csstype": "^3.2.2"
- }
- },
- "node_modules/@types/react-dom": {
- "version": "18.3.7",
- "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
- "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
- "dev": true,
- "license": "MIT",
- "peerDependencies": {
- "@types/react": "^18.0.0"
- }
- },
"node_modules/@types/tern": {
"version": "0.23.9",
"resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.9.tgz",
@@ -3042,13 +3011,6 @@
"node": ">=4"
}
},
- "node_modules/csstype": {
- "version": "3.2.3",
- "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
- "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/data-urls": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
@@ -5678,6 +5640,7 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "dev": true,
"license": "MIT"
},
"node_modules/js-yaml": {
@@ -6211,6 +6174,7 @@
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
@@ -6845,6 +6809,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/preact": {
+ "version": "10.29.2",
+ "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.2.tgz",
+ "integrity": "sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/preact"
+ }
+ },
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -6934,31 +6908,6 @@
],
"license": "MIT"
},
- "node_modules/react": {
- "version": "18.3.1",
- "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
- "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
- "license": "MIT",
- "dependencies": {
- "loose-envify": "^1.1.0"
- },
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/react-dom": {
- "version": "18.3.1",
- "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
- "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
- "license": "MIT",
- "dependencies": {
- "loose-envify": "^1.1.0",
- "scheduler": "^0.23.2"
- },
- "peerDependencies": {
- "react": "^18.3.1"
- }
- },
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
@@ -7252,15 +7201,6 @@
"node": ">=v12.22.7"
}
},
- "node_modules/scheduler": {
- "version": "0.23.2",
- "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
- "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
- "license": "MIT",
- "dependencies": {
- "loose-envify": "^1.1.0"
- }
- },
"node_modules/semver": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
diff --git a/package.json b/package.json
index 554f85f7..a5489aac 100644
--- a/package.json
+++ b/package.json
@@ -40,8 +40,6 @@
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^25.6.0",
- "@types/react": "^18.3.29",
- "@types/react-dom": "^18.3.7",
"esbuild": "^0.28.0",
"eslint": "^10.3.0",
"eslint-config-prettier": "^10.1.8",
@@ -57,7 +55,6 @@
"vitest": "^4.1.7"
},
"dependencies": {
- "react": "^18.3.1",
- "react-dom": "^18.3.1"
+ "preact": "^10.29.2"
}
}
diff --git a/src/features/chat/chat-message-renderer.ts b/src/features/chat/chat-message-renderer.ts
index aa01f760..b05f2d28 100644
--- a/src/features/chat/chat-message-renderer.ts
+++ b/src/features/chat/chat-message-renderer.ts
@@ -1,5 +1,5 @@
import type { App, Component } from "obsidian";
-import type { ReactNode } from "react";
+import type { ReactNode } from "preact/compat";
import type { DisplayItem } from "./display/types";
import { copyTextWithNotice } from "../../shared/ui/clipboard";
diff --git a/src/features/chat/controllers/requests/pending-request-controller.ts b/src/features/chat/controllers/requests/pending-request-controller.ts
index 79d608bf..4aa27b3b 100644
--- a/src/features/chat/controllers/requests/pending-request-controller.ts
+++ b/src/features/chat/controllers/requests/pending-request-controller.ts
@@ -1,4 +1,4 @@
-import type { ReactNode } from "react";
+import type { ReactNode } from "preact/compat";
import type { ApprovalAction, PendingApproval } from "../../approvals/model";
import type { ChatAction, ChatState, ChatStateStore } from "../../chat-state";
diff --git a/src/features/chat/controllers/thread/thread-resume-controller.ts b/src/features/chat/controllers/thread/thread-resume-controller.ts
index 45a9195d..bffdc976 100644
--- a/src/features/chat/controllers/thread/thread-resume-controller.ts
+++ b/src/features/chat/controllers/thread/thread-resume-controller.ts
@@ -70,6 +70,7 @@ export class ThreadResumeController {
this.host.clearDeferredRestoredThreadHydration();
this.host.resetThreadTurnPresence(false);
this.host.notifyActiveThreadIdentityChanged();
+ this.host.forceMessagesToBottom();
this.host.render();
this.host.refreshLiveState();
}
diff --git a/src/features/chat/ui/composer.tsx b/src/features/chat/ui/composer.tsx
index 729907a7..222eadc5 100644
--- a/src/features/chat/ui/composer.tsx
+++ b/src/features/chat/ui/composer.tsx
@@ -1,4 +1,5 @@
-import { useLayoutEffect, useRef, type ButtonHTMLAttributes, type ReactNode } from "react";
+import type { ButtonHTMLAttributes } from "preact";
+import { useLayoutEffect, useRef, type ReactNode } from "preact/compat";
import type { ComposerSuggestion } from "../composer/suggestions";
import { IconButton } from "../../../shared/ui/react-components";
@@ -21,6 +22,10 @@ export interface ComposerCallbacks {
onSuggestionInsert: (suggestion: ComposerSuggestion) => void;
}
+type ButtonProps = ButtonHTMLAttributes & {
+ disabled?: boolean | undefined;
+};
+
export function renderComposerShell(
parent: HTMLElement,
viewId: string,
@@ -102,7 +107,7 @@ function ComposerShell({
onClick={callbacks.onUpdateSuggestions}
onSelect={callbacks.onUpdateSuggestions}
onKeyDown={(event) => {
- callbacks.onKeydown(event.nativeEvent);
+ callbacks.onKeydown(event);
}}
/>
@@ -152,7 +157,7 @@ function ComposerIconButton({
icon: string;
label: string;
className: string;
-} & Omit
, "className" | "type">): ReactNode {
+} & Omit): ReactNode {
return (
{
+ if (event.currentTarget.checked) selectOther();
+ }}
onChange={(event) => {
if (event.currentTarget.checked) selectOther();
}}
@@ -335,7 +338,7 @@ function OtherUserInputOption({
}}
onChange={(event) => {
setInputValue(event.currentTarget.value);
- const nativeEvent = event.nativeEvent as Event & { isComposing?: boolean };
+ const nativeEvent = event as Event & { isComposing?: boolean };
if (nativeEvent.isComposing !== true && !composingRef.current) commitOtherValue(event.currentTarget.value);
}}
/>
diff --git a/src/features/chat/ui/scroll.ts b/src/features/chat/ui/scroll.ts
index 124ae63a..76a3d442 100644
--- a/src/features/chat/ui/scroll.ts
+++ b/src/features/chat/ui/scroll.ts
@@ -105,6 +105,9 @@ export class MessageScrollController {
this.observeMessageBlocks(container);
this.cancelRenderFrame();
+ if (plan.shouldScrollToBottom && this.options.messagesPinnedToBottom()) {
+ this.pinToBottom(container);
+ }
this.renderFrame = container.win.requestAnimationFrame(() => {
this.renderFrame = null;
if (plan.generation !== this.renderGeneration) return;
diff --git a/src/features/chat/ui/shell.tsx b/src/features/chat/ui/shell.tsx
index 980c3877..3e157185 100644
--- a/src/features/chat/ui/shell.tsx
+++ b/src/features/chat/ui/shell.tsx
@@ -1,4 +1,4 @@
-import { useLayoutEffect, useRef, useSyncExternalStore, type ReactNode } from "react";
+import { useLayoutEffect, useRef, useSyncExternalStore, type ReactNode } from "preact/compat";
import { renderReactRoot, unmountReactRoot } from "../../../shared/ui/react-root";
import type { ChatState, ChatStateStore } from "../chat-state";
@@ -19,8 +19,11 @@ interface ChatPanelShellSlots {
composer?: HTMLElement;
}
+const shellSlotsByContainer = new WeakMap();
+
export function renderChatPanelShell(container: HTMLElement, props: ChatPanelShellProps): void {
- const mountedSlots: ChatPanelShellSlots = {};
+ const mountedSlots = shellSlotsByContainer.get(container) ?? {};
+ shellSlotsByContainer.set(container, mountedSlots);
container.addClass("codex-panel");
renderReactRoot(
container,
@@ -36,6 +39,7 @@ export function renderChatPanelShell(container: HTMLElement, props: ChatPanelShe
export function unmountChatPanelShell(container: HTMLElement | null): void {
if (!container) return;
+ shellSlotsByContainer.delete(container);
unmountReactRoot(container);
}
@@ -50,6 +54,7 @@ function ChatPanelShell({
return (
<>
-
-
+
+
stateStore.subscribe(listener),
() => slot.snapshot(stateStore.getState()),
- () => slot.snapshot(stateStore.getState()),
);
const ref = useRef(null);
const renderGeneration = useRef(0);
@@ -126,7 +132,15 @@ function ChatPanelSlot({
}, [name, onSlotReady, renderKey, renderTargetClassName, slot]);
return (
-
+
{
+ ref.current = element;
+ if (!element) return;
+ const renderTarget = renderTargetClassName ? element.querySelector
(`:scope > .${renderTargetClassName}`) : element;
+ if (renderTarget) onSlotReady(name, renderTarget);
+ }}
+ className={className}
+ >
{renderTargetClassName ? : null}
);
diff --git a/src/features/chat/ui/tool-result.tsx b/src/features/chat/ui/tool-result.tsx
index a4ec70a2..0260512c 100644
--- a/src/features/chat/ui/tool-result.tsx
+++ b/src/features/chat/ui/tool-result.tsx
@@ -1,4 +1,4 @@
-import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
+import { useLayoutEffect, useRef, useState, type ReactNode } from "preact/compat";
import { toolResultView, type ToolResultDetailSection, type ToolResultDisplayItem, type ToolResultView } from "../display/tool-view";
import { renderRawDiffLines } from "../../../shared/diff/render";
diff --git a/src/features/chat/ui/toolbar.tsx b/src/features/chat/ui/toolbar.tsx
index 406aaeb2..e0380c91 100644
--- a/src/features/chat/ui/toolbar.tsx
+++ b/src/features/chat/ui/toolbar.tsx
@@ -1,10 +1,15 @@
-import { useLayoutEffect, useRef, type ButtonHTMLAttributes, type KeyboardEvent, type ReactNode } from "react";
+import type { ButtonHTMLAttributes, TargetedKeyboardEvent } from "preact";
+import { useLayoutEffect, useRef, type ReactNode } from "preact/compat";
import type { EffectiveConfigSection, RateLimitSummary } from "../../../runtime/view";
import { IconButton, ObsidianIcon } from "../../../shared/ui/react-components";
import { renderReactRoot } from "../../../shared/ui/react-root";
import type { ToolbarDiagnosticSection, ToolbarThreadRow, ToolbarViewModel } from "../toolbar-model";
+type ButtonProps = ButtonHTMLAttributes & {
+ disabled?: boolean | undefined;
+};
+
export interface ToolbarActions {
toggleHistory: () => void;
toggleAutoReview: () => void;
@@ -66,7 +71,7 @@ function ToolbarIconButton({
icon: string;
label: string;
className?: string;
-} & Omit
, "className" | "type">): ReactNode {
+} & Omit): ReactNode {
return (
{
if (event.key === "Enter") {
event.preventDefault();
- if (!event.nativeEvent.isComposing && !generating) actions.saveRenameThread(thread.threadId, event.currentTarget.value);
+ if (!event.isComposing && !generating) actions.saveRenameThread(thread.threadId, event.currentTarget.value);
return;
}
if (event.key === "Escape") {
@@ -498,7 +503,7 @@ function ToolbarPanelItem({
renderContent?: () => ReactNode;
onClick?: () => void;
}): ReactNode {
- const onKeyDown = (event: KeyboardEvent) => {
+ const onKeyDown = (event: TargetedKeyboardEvent) => {
if (disabled || (event.key !== "Enter" && event.key !== " ")) return;
event.preventDefault();
onClick?.();
diff --git a/src/features/chat/ui/turn-diff.tsx b/src/features/chat/ui/turn-diff.tsx
index 3b394226..acb131ab 100644
--- a/src/features/chat/ui/turn-diff.tsx
+++ b/src/features/chat/ui/turn-diff.tsx
@@ -1,4 +1,4 @@
-import { useLayoutEffect, useRef, type ReactNode } from "react";
+import { useLayoutEffect, useRef, type ReactNode } from "preact/compat";
import { renderDisplayDiffLines } from "../../../shared/diff/render";
import { displayDiffLines } from "../../../shared/diff/unified";
diff --git a/src/features/chat/ui/work-items.tsx b/src/features/chat/ui/work-items.tsx
index 13142ca3..0cbe8086 100644
--- a/src/features/chat/ui/work-items.tsx
+++ b/src/features/chat/ui/work-items.tsx
@@ -1,4 +1,4 @@
-import { useLayoutEffect, useState, type ReactNode } from "react";
+import { useLayoutEffect, useState, type ReactNode } from "preact/compat";
import { activeAgentRunSummary } from "../display/agent";
import { executionState } from "../display/state";
diff --git a/src/features/selection-rewrite/popover.tsx b/src/features/selection-rewrite/popover.tsx
index 7a3bac5a..8bae6a91 100644
--- a/src/features/selection-rewrite/popover.tsx
+++ b/src/features/selection-rewrite/popover.tsx
@@ -1,5 +1,6 @@
import { Notice, type Editor } from "obsidian";
-import { useLayoutEffect, useRef, type KeyboardEvent, type ReactNode } from "react";
+import type { TargetedKeyboardEvent } from "preact";
+import { useLayoutEffect, useRef, type ReactNode } from "preact/compat";
import { renderDisplayDiffLines } from "../../shared/diff/render";
import { displayDiffLines } from "../../shared/diff/unified";
@@ -16,6 +17,7 @@ import {
type SelectionRewriteRuntimeSettings,
type SelectionRewriteState,
} from "./model";
+
import { SelectionRewriteOutputError } from "./output";
import { positionSelectionRewritePopover } from "./position";
import { buildSelectionRewritePrompt } from "./prompt";
@@ -285,11 +287,7 @@ export class SelectionRewritePopover {
}}
onInstructionKeyDown={(event) => {
const hasReplacement = this.options.state.replacementText !== null;
- if (
- !(hasReplacement
- ? isSelectionRewriteActionKey(event.nativeEvent)
- : isComposerSendKey(event.nativeEvent, this.options.sendShortcut))
- ) {
+ if (!(hasReplacement ? isSelectionRewriteActionKey(event) : isComposerSendKey(event, this.options.sendShortcut))) {
return;
}
event.preventDefault();
@@ -297,7 +295,7 @@ export class SelectionRewritePopover {
void this.generate();
}}
onApplyKeyDown={(event) => {
- if (!isSelectionRewriteActionKey(event.nativeEvent)) return;
+ if (!isSelectionRewriteActionKey(event)) return;
event.preventDefault();
this.apply();
}}
@@ -342,11 +340,11 @@ interface SelectionRewritePopoverViewProps {
instruction: string;
instructionRef: (element: HTMLTextAreaElement | null) => void;
onApply: () => void;
- onApplyKeyDown: (event: KeyboardEvent) => void;
+ onApplyKeyDown: (event: TargetedKeyboardEvent) => void;
onCancel: () => void;
onGenerate: () => void;
onInstructionInput: (value: string) => void;
- onInstructionKeyDown: (event: KeyboardEvent) => void;
+ onInstructionKeyDown: (event: TargetedKeyboardEvent) => void;
status: SelectionRewritePopoverStatusState;
streamPreview: string;
}
diff --git a/src/features/threads-view/renderer.tsx b/src/features/threads-view/renderer.tsx
index 63908ebe..46d1d863 100644
--- a/src/features/threads-view/renderer.tsx
+++ b/src/features/threads-view/renderer.tsx
@@ -1,9 +1,14 @@
-import { useLayoutEffect, useRef, type ButtonHTMLAttributes, type KeyboardEvent, type ReactNode } from "react";
+import type { ButtonHTMLAttributes, TargetedKeyboardEvent } from "preact";
+import { useLayoutEffect, useRef, type ReactNode } from "preact/compat";
import { IconButton } from "../../shared/ui/react-components";
import { renderReactRoot, unmountReactRoot } from "../../shared/ui/react-root";
import type { ThreadsRowModel } from "./state";
+type ButtonProps = ButtonHTMLAttributes & {
+ disabled?: boolean | undefined;
+};
+
export interface ThreadsViewModel {
status: string | null;
loading: boolean;
@@ -82,7 +87,7 @@ function ThreadRow({ row, actions }: { row: ThreadsRowModel; actions: ThreadsVie
if (row.rename.active || archiveConfirm.active) return;
actions.openThread(row.thread.id);
};
- const openFromKeyboard = (event: KeyboardEvent) => {
+ const openFromKeyboard = (event: TargetedKeyboardEvent) => {
if (row.rename.active || archiveConfirm.active) return;
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
@@ -204,7 +209,7 @@ function RenameRow({ row, actions }: { row: ThreadsRowModel; actions: ThreadsVie
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
- if (!event.nativeEvent.isComposing && !row.rename.generating) actions.saveRename(row.thread.id, event.currentTarget.value);
+ if (!event.isComposing && !row.rename.generating) actions.saveRename(row.thread.id, event.currentTarget.value);
return;
}
if (event.key === "Escape") {
@@ -247,7 +252,7 @@ function ThreadsIconButton({
icon: string;
label: string;
className: string;
-} & Omit, "className" | "type">): ReactNode {
+} & Omit): ReactNode {
return (
;
}
-export interface IconButtonProps extends ButtonHTMLAttributes {
+export interface IconButtonProps extends ButtonHTMLAttributes {
icon: string;
label: string;
buttonRef?: Ref;
+ disabled?: boolean | undefined;
+ type?: "button" | "submit" | "reset" | undefined;
}
export function IconButton({ icon, label, buttonRef, className, children, ...props }: IconButtonProps): ReactNode {
diff --git a/src/shared/ui/react-root.tsx b/src/shared/ui/react-root.tsx
index 4f7235f1..1387167d 100644
--- a/src/shared/ui/react-root.tsx
+++ b/src/shared/ui/react-root.tsx
@@ -1,6 +1,8 @@
-import type { ReactNode } from "react";
-import { flushSync } from "react-dom";
-import { createRoot, type Root } from "react-dom/client";
+import type { ReactNode } from "preact/compat";
+import { flushSync } from "preact/compat";
+import { createRoot } from "preact/compat/client";
+
+type Root = ReturnType;
const roots = new WeakMap();
const guardedContainers = new WeakSet();
diff --git a/tests/features/chat/ui/message-stream/blocks-and-messages.test.tsx b/tests/features/chat/ui/message-stream/blocks-and-messages.test.tsx
index 7bf9dbe0..d0e3e52d 100644
--- a/tests/features/chat/ui/message-stream/blocks-and-messages.test.tsx
+++ b/tests/features/chat/ui/message-stream/blocks-and-messages.test.tsx
@@ -1,7 +1,8 @@
// @vitest-environment jsdom
import { describe, expect, it, vi } from "vitest";
-import { act, createElement } from "react";
+import { createElement } from "preact/compat";
+import { act } from "preact/test-utils";
import type { DisplayItem } from "../../../../../src/features/chat/display/types";
import { implementPlanCandidateFromState } from "../../../../../src/features/chat/chat-message-renderer";
@@ -297,7 +298,7 @@ describe("message stream block identity and message actions", () => {
expect(renderTextWithWikiLinks).toHaveBeenCalledWith(expect.any(HTMLElement), "npm test");
const details = expectPresent(result.querySelector("details"));
- act(() => {
+ void act(() => {
details.open = true;
details.dispatchEvent(new Event("toggle", { bubbles: false }));
});
@@ -890,7 +891,7 @@ describe("message stream block identity and message actions", () => {
expect(details?.querySelector("summary")?.textContent).toBe("Show more");
if (details) {
- act(() => {
+ void act(() => {
details.open = true;
details.dispatchEvent(new Event("toggle"));
});
diff --git a/tests/features/chat/ui/message-stream/test-helpers.tsx b/tests/features/chat/ui/message-stream/test-helpers.tsx
index 8e04fd22..1a12e748 100644
--- a/tests/features/chat/ui/message-stream/test-helpers.tsx
+++ b/tests/features/chat/ui/message-stream/test-helpers.tsx
@@ -1,5 +1,6 @@
import { vi } from "vitest";
-import { act, type ReactNode } from "react";
+import type { ReactNode } from "preact/compat";
+import { act } from "preact/test-utils";
import type { PendingApproval } from "../../../../../src/features/chat/approvals/model";
import type { PendingUserInput } from "../../../../../src/features/chat/user-input/model";
@@ -50,23 +51,23 @@ export function renderMessageBlockElement(block: ReturnType void): void {
- act(action);
+ void act(action);
}
export function renderMessageStreamBlocksInAct(parent: HTMLElement, blocks: MessageStreamBlock[]): void {
- act(() => {
+ void act(() => {
renderMessageStreamBlocks(parent, blocks);
});
}
export function renderReactRootInAct(parent: HTMLElement, node: ReactNode): void {
- act(() => {
+ void act(() => {
renderReactRoot(parent, node);
});
}
export function unmountReactRootInAct(parent: HTMLElement): void {
- act(() => {
+ void act(() => {
unmountReactRoot(parent);
});
}
diff --git a/tests/features/chat/ui/message-stream/work-log.test.tsx b/tests/features/chat/ui/message-stream/work-log.test.tsx
index a1964b77..fe2fe8b1 100644
--- a/tests/features/chat/ui/message-stream/work-log.test.tsx
+++ b/tests/features/chat/ui/message-stream/work-log.test.tsx
@@ -1,7 +1,7 @@
// @vitest-environment jsdom
import { describe, expect, it, vi } from "vitest";
-import { act } from "react";
+import { act } from "preact/test-utils";
import type { DisplayItem } from "../../../../../src/features/chat/display/types";
import { topLevelDetailsSummaries } from "../../../../support/dom";
@@ -395,7 +395,7 @@ describe("work log renderer decisions", () => {
expect(group.querySelector(".codex-panel__agent-activity .codex-panel__tool-summary")?.textContent).toBe("spawn child (completed)");
const details = expectPresent(group.querySelector(".codex-panel__activity-group"));
- act(() => {
+ void act(() => {
details.open = false;
details.dispatchEvent(new Event("toggle", { bubbles: false }));
});
@@ -724,7 +724,7 @@ describe("work log renderer decisions", () => {
expect([...block.querySelectorAll("details summary")].map((summary) => summary.textContent)).toEqual(["Details"]);
const details = expectPresent(block.querySelector("details"));
- act(() => {
+ void act(() => {
details.open = true;
details.dispatchEvent(new Event("toggle", { bubbles: false }));
});
diff --git a/tests/features/chat/ui/shell.test.tsx b/tests/features/chat/ui/shell.test.tsx
index 8d9bf78c..9257d5d7 100644
--- a/tests/features/chat/ui/shell.test.tsx
+++ b/tests/features/chat/ui/shell.test.tsx
@@ -1,7 +1,7 @@
// @vitest-environment jsdom
import { describe, expect, it, vi } from "vitest";
-import { act } from "react";
+import { act } from "preact/test-utils";
import { chatTurnBusy, createChatStateStore } from "../../../../src/features/chat/chat-state";
import { renderChatPanelShell, unmountChatPanelShell } from "../../../../src/features/chat/ui/shell";
diff --git a/tests/features/chat/view-connection.test.ts b/tests/features/chat/view-connection.test.ts
index af0e2cbf..1e54f161 100644
--- a/tests/features/chat/view-connection.test.ts
+++ b/tests/features/chat/view-connection.test.ts
@@ -429,8 +429,7 @@ describe("CodexChatView connection lifecycle", () => {
const view = await chatView();
await view.onOpen();
- const composer = composerElement(view);
- const focus = vi.spyOn(composer, "focus");
+ const focus = vi.spyOn(HTMLTextAreaElement.prototype, "focus").mockImplementation(() => undefined);
await view.openThread("thread-1");
await view.focusThread("thread-1");
@@ -509,20 +508,17 @@ describe("CodexChatView connection lifecycle", () => {
const messages = view.containerEl.querySelector(".codex-panel__messages");
expect(messages).not.toBeNull();
if (!messages) return;
- Object.defineProperty(messages, "scrollHeight", { value: 1000, configurable: true });
- Object.defineProperty(messages, "clientHeight", { value: 100, configurable: true });
+ const restoreMessagesLayout = mockMessagesLayout({ scrollHeight: 1000, clientHeight: 100 });
messages.scrollTop = 0;
view.setComposerText("/resume thread-1");
await (view as unknown as { submitComposerAction: () => Promise }).submitComposerAction();
- await new Promise((resolve) => {
- messages.win.requestAnimationFrame(() => {
- resolve();
- });
- });
+ await waitForMessagesFrame(messages);
+ await waitForMessagesFrame(messages);
const renderedMessages = view.containerEl.querySelector(".codex-panel__messages");
expect(renderedMessages?.scrollTop).toBe(1000);
+ restoreMessagesLayout();
});
it("routes slash archive through shared panel notifications", async () => {
@@ -650,19 +646,16 @@ describe("CodexChatView connection lifecycle", () => {
const messages = view.containerEl.querySelector(".codex-panel__messages");
expect(messages).not.toBeNull();
if (!messages) return;
- Object.defineProperty(messages, "scrollHeight", { value: 1000, configurable: true });
- Object.defineProperty(messages, "clientHeight", { value: 100, configurable: true });
+ const restoreMessagesLayout = mockMessagesLayout({ scrollHeight: 1000, clientHeight: 100 });
messages.scrollTop = 0;
await view.openThread("thread-1");
- await new Promise((resolve) => {
- messages.win.requestAnimationFrame(() => {
- resolve();
- });
- });
+ await waitForMessagesFrame(messages);
+ await waitForMessagesFrame(messages);
const renderedMessages = view.containerEl.querySelector(".codex-panel__messages");
expect(renderedMessages?.scrollTop).toBe(1000);
+ restoreMessagesLayout();
});
it("renders resumed thread metadata before history hydration completes", async () => {
@@ -933,6 +926,43 @@ function composerPlaceholder(view: { containerEl: HTMLElement }): string | null
return composerElement(view).getAttribute("placeholder");
}
+function waitForMessagesFrame(messages: HTMLElement): Promise {
+ return new Promise((resolve) => {
+ messages.win.requestAnimationFrame(() => {
+ resolve();
+ });
+ });
+}
+
+function mockMessagesLayout(metrics: { scrollHeight: number; clientHeight: number }): () => void {
+ const scrollHeightDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollHeight");
+ const clientHeightDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "clientHeight");
+ Object.defineProperty(HTMLElement.prototype, "scrollHeight", {
+ configurable: true,
+ get() {
+ return this instanceof HTMLElement && this.classList.contains("codex-panel__messages") ? metrics.scrollHeight : 0;
+ },
+ });
+ Object.defineProperty(HTMLElement.prototype, "clientHeight", {
+ configurable: true,
+ get() {
+ return this instanceof HTMLElement && this.classList.contains("codex-panel__messages") ? metrics.clientHeight : 0;
+ },
+ });
+ return () => {
+ restorePrototypeProperty(HTMLElement.prototype, "scrollHeight", scrollHeightDescriptor);
+ restorePrototypeProperty(HTMLElement.prototype, "clientHeight", clientHeightDescriptor);
+ };
+}
+
+function restorePrototypeProperty(target: T, property: keyof T, descriptor: PropertyDescriptor | undefined): void {
+ if (descriptor) {
+ Object.defineProperty(target, property, descriptor);
+ } else {
+ Reflect.deleteProperty(target, property);
+ }
+}
+
function chatHost(overrides: Partial = {}): CodexChatHost {
return {
settings: {
diff --git a/tests/features/selection-rewrite/selection-rewrite.test.ts b/tests/features/selection-rewrite/selection-rewrite.test.ts
index dbf67b12..c5684666 100644
--- a/tests/features/selection-rewrite/selection-rewrite.test.ts
+++ b/tests/features/selection-rewrite/selection-rewrite.test.ts
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
-import { act } from "react";
+import { act } from "preact/test-utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { buildSelectionUnifiedDiff } from "../../../src/features/selection-rewrite/diff";
@@ -243,7 +243,7 @@ describe("selection rewrite popover", () => {
const generate = expectPresent(document.querySelector('button[aria-label="Generate"]'));
expect(generate.disabled).toBe(true);
- act(() => {
+ void act(() => {
setTextareaValue(instruction, "Make it concise.");
instruction.dispatchEvent(new Event("input", { bubbles: true }));
});
@@ -266,7 +266,7 @@ describe("selection rewrite popover", () => {
openPopover(popover);
const instruction = expectPresent(document.querySelector(".codex-panel-selection-rewrite__instruction"));
- act(() => {
+ void act(() => {
setTextareaValue(instruction, "Make it concise.");
instruction.dispatchEvent(new Event("input", { bubbles: true }));
});
@@ -414,13 +414,13 @@ function rewriteState(overrides: Partial = {}): Selection
}
function openPopover(popover: SelectionRewritePopover): void {
- act(() => {
+ void act(() => {
popover.open();
});
}
function closePopover(popover: SelectionRewritePopover): void {
- act(() => {
+ void act(() => {
popover.close();
});
}
diff --git a/tsconfig.json b/tsconfig.json
index 1d211954..57075074 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -18,6 +18,7 @@
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"jsx": "react-jsx",
+ "jsxImportSource": "preact",
"types": ["node", "vitest"],
"lib": ["ES2022", "DOM"]
},