Switch UI runtime to Preact compat

This commit is contained in:
murashit 2026-05-31 12:37:18 +09:00
parent 2065bf3999
commit 86cfda539c
26 changed files with 160 additions and 150 deletions

View file

@ -12,6 +12,7 @@ await esbuild.build({
platform: "node",
target: "es2022",
jsx: "automatic",
jsxImportSource: "preact",
outfile: "main.js",
sourcemap: false,
minify: true,

86
package-lock.json generated
View file

@ -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",

View file

@ -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"
}
}

View file

@ -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";

View file

@ -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";

View file

@ -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();
}

View file

@ -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);
}}
/>
<div className="codex-panel-ui__action-stack codex-panel__composer-actions">
@ -152,7 +157,7 @@ function ComposerIconButton({
icon: string;
label: string;
className: string;
} & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "className" | "type">): ReactNode {
} & Omit<ButtonProps, "className" | "type">): ReactNode {
return (
<IconButton
{...props}
@ -237,7 +242,7 @@ function ComposerSuggestions({
return (
<div
key={optionId}
ref={selected ? selectedRef : undefined}
ref={selected ? selectedRef : null}
className={`suggestion-item codex-panel__composer-suggestion ${selected ? "is-selected" : ""}`}
id={optionId}
role="option"

View file

@ -1,4 +1,4 @@
import { Fragment, forwardRef, useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react";
import { Fragment, forwardRef, useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "preact/compat";
import { displayBlocksForItems } from "../display/blocks";
import { executionState } from "../display/state";

View file

@ -1,4 +1,4 @@
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react";
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "preact/compat";
import {
approvalActionOptions,
@ -312,6 +312,9 @@ function OtherUserInputOption({
name={groupName}
value="__other__"
checked={otherSelected}
onClick={(event) => {
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);
}}
/>

View file

@ -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;

View file

@ -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<HTMLElement, ChatPanelShellSlots>();
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 (
<>
<ChatPanelSlot
key="toolbar"
name="toolbar"
className="codex-panel__toolbar"
stateStore={stateStore}
@ -57,9 +62,10 @@ function ChatPanelShell({
slot={toolbar}
onSlotReady={onSlotReady}
/>
<div className="codex-panel__body">
<div className="codex-panel__slot codex-panel__slot--config" />
<div key="body" className="codex-panel__body">
<div key="config" className="codex-panel__slot codex-panel__slot--config" />
<ChatPanelSlot
key="messages"
name="messages"
className="codex-panel__slot codex-panel__slot--messages"
renderTargetClassName="codex-panel__messages"
@ -69,6 +75,7 @@ function ChatPanelShell({
onSlotReady={onSlotReady}
/>
<ChatPanelSlot
key="composer"
name="composer"
className="codex-panel__slot codex-panel__slot--composer"
stateStore={stateStore}
@ -106,7 +113,6 @@ function ChatPanelSlot({
const snapshot = useSyncExternalStore(
(listener) => stateStore.subscribe(listener),
() => slot.snapshot(stateStore.getState()),
() => slot.snapshot(stateStore.getState()),
);
const ref = useRef<HTMLDivElement | null>(null);
const renderGeneration = useRef(0);
@ -126,7 +132,15 @@ function ChatPanelSlot({
}, [name, onSlotReady, renderKey, renderTargetClassName, slot]);
return (
<div ref={ref} className={className}>
<div
ref={(element) => {
ref.current = element;
if (!element) return;
const renderTarget = renderTargetClassName ? element.querySelector<HTMLElement>(`:scope > .${renderTargetClassName}`) : element;
if (renderTarget) onSlotReady(name, renderTarget);
}}
className={className}
>
{renderTargetClassName ? <div className={renderTargetClassName} /> : null}
</div>
);

View file

@ -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";

View file

@ -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<ButtonHTMLAttributes<HTMLButtonElement>, "className" | "type">): ReactNode {
} & Omit<ButtonProps, "className" | "type">): ReactNode {
return (
<IconButton
{...props}
@ -444,7 +449,7 @@ function ThreadRenameRow({ thread, actions }: { thread: ToolbarThreadRow; action
onKeyDown={(event) => {
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<HTMLElement>) => {
const onKeyDown = (event: TargetedKeyboardEvent<HTMLElement>) => {
if (disabled || (event.key !== "Enter" && event.key !== " ")) return;
event.preventDefault();
onClick?.();

View file

@ -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";

View file

@ -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";

View file

@ -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<HTMLButtonElement>) => void;
onApplyKeyDown: (event: TargetedKeyboardEvent<HTMLButtonElement>) => void;
onCancel: () => void;
onGenerate: () => void;
onInstructionInput: (value: string) => void;
onInstructionKeyDown: (event: KeyboardEvent<HTMLTextAreaElement>) => void;
onInstructionKeyDown: (event: TargetedKeyboardEvent<HTMLTextAreaElement>) => void;
status: SelectionRewritePopoverStatusState;
streamPreview: string;
}

View file

@ -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<HTMLDivElement>) => {
const openFromKeyboard = (event: TargetedKeyboardEvent<HTMLDivElement>) => {
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<ButtonHTMLAttributes<HTMLButtonElement>, "className" | "type">): ReactNode {
} & Omit<ButtonProps, "className" | "type">): ReactNode {
return (
<IconButton
{...props}

View file

@ -1,5 +1,6 @@
import { setIcon } from "obsidian";
import { useLayoutEffect, useRef, type ButtonHTMLAttributes, type MutableRefObject, type ReactNode, type Ref } from "react";
import type { ButtonHTMLAttributes } from "preact";
import { useLayoutEffect, useRef, type MutableRefObject, type ReactNode, type Ref } from "preact/compat";
export interface ObsidianIconProps {
icon: string;
@ -17,10 +18,12 @@ export function ObsidianIcon({ icon, className }: ObsidianIconProps): ReactNode
return <span ref={ref} className={className} aria-hidden="true" />;
}
export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
export interface IconButtonProps extends ButtonHTMLAttributes {
icon: string;
label: string;
buttonRef?: Ref<HTMLButtonElement>;
disabled?: boolean | undefined;
type?: "button" | "submit" | "reset" | undefined;
}
export function IconButton({ icon, label, buttonRef, className, children, ...props }: IconButtonProps): ReactNode {

View file

@ -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<typeof createRoot>;
const roots = new WeakMap<HTMLElement, Root>();
const guardedContainers = new WeakSet<HTMLElement>();

View file

@ -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<HTMLDetailsElement>("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"));
});

View file

@ -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<typeof rawMessageStr
}
export function actEvent(action: () => 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);
});
}

View file

@ -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<HTMLDetailsElement>(".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<HTMLDetailsElement>("details"));
act(() => {
void act(() => {
details.open = true;
details.dispatchEvent(new Event("toggle", { bubbles: false }));
});

View file

@ -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";

View file

@ -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<HTMLElement>(".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<void> }).submitComposerAction();
await new Promise<void>((resolve) => {
messages.win.requestAnimationFrame(() => {
resolve();
});
});
await waitForMessagesFrame(messages);
await waitForMessagesFrame(messages);
const renderedMessages = view.containerEl.querySelector<HTMLElement>(".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<HTMLElement>(".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<void>((resolve) => {
messages.win.requestAnimationFrame(() => {
resolve();
});
});
await waitForMessagesFrame(messages);
await waitForMessagesFrame(messages);
const renderedMessages = view.containerEl.querySelector<HTMLElement>(".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<void> {
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<T extends object>(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> = {}): CodexChatHost {
return {
settings: {

View file

@ -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<HTMLButtonElement>('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<HTMLTextAreaElement>(".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<SelectionRewriteState> = {}): Selection
}
function openPopover(popover: SelectionRewritePopover): void {
act(() => {
void act(() => {
popover.open();
});
}
function closePopover(popover: SelectionRewritePopover): void {
act(() => {
void act(() => {
popover.close();
});
}

View file

@ -18,6 +18,7 @@
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"jsx": "react-jsx",
"jsxImportSource": "preact",
"types": ["node", "vitest"],
"lib": ["ES2022", "DOM"]
},