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", platform: "node",
target: "es2022", target: "es2022",
jsx: "automatic", jsx: "automatic",
jsxImportSource: "preact",
outfile: "main.js", outfile: "main.js",
sourcemap: false, sourcemap: false,
minify: true, minify: true,

86
package-lock.json generated
View file

@ -9,14 +9,11 @@
"version": "2.4.0", "version": "2.4.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"react": "^18.3.1", "preact": "^10.29.2"
"react-dom": "^18.3.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@types/node": "^25.6.0", "@types/node": "^25.6.0",
"@types/react": "^18.3.29",
"@types/react-dom": "^18.3.7",
"esbuild": "^0.28.0", "esbuild": "^0.28.0",
"eslint": "^10.3.0", "eslint": "^10.3.0",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
@ -2023,34 +2020,6 @@
"undici-types": ">=7.24.0 <7.24.7" "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": { "node_modules/@types/tern": {
"version": "0.23.9", "version": "0.23.9",
"resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.9.tgz", "resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.9.tgz",
@ -3042,13 +3011,6 @@
"node": ">=4" "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": { "node_modules/data-urls": {
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
@ -5678,6 +5640,7 @@
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/js-yaml": { "node_modules/js-yaml": {
@ -6211,6 +6174,7 @@
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0" "js-tokens": "^3.0.0 || ^4.0.0"
@ -6845,6 +6809,16 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/prelude-ls": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@ -6934,31 +6908,6 @@
], ],
"license": "MIT" "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": { "node_modules/react-is": {
"version": "16.13.1", "version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
@ -7252,15 +7201,6 @@
"node": ">=v12.22.7" "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": { "node_modules/semver": {
"version": "7.8.0", "version": "7.8.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",

View file

@ -40,8 +40,6 @@
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@types/node": "^25.6.0", "@types/node": "^25.6.0",
"@types/react": "^18.3.29",
"@types/react-dom": "^18.3.7",
"esbuild": "^0.28.0", "esbuild": "^0.28.0",
"eslint": "^10.3.0", "eslint": "^10.3.0",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
@ -57,7 +55,6 @@
"vitest": "^4.1.7" "vitest": "^4.1.7"
}, },
"dependencies": { "dependencies": {
"react": "^18.3.1", "preact": "^10.29.2"
"react-dom": "^18.3.1"
} }
} }

View file

@ -1,5 +1,5 @@
import type { App, Component } from "obsidian"; import type { App, Component } from "obsidian";
import type { ReactNode } from "react"; import type { ReactNode } from "preact/compat";
import type { DisplayItem } from "./display/types"; import type { DisplayItem } from "./display/types";
import { copyTextWithNotice } from "../../shared/ui/clipboard"; 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 { ApprovalAction, PendingApproval } from "../../approvals/model";
import type { ChatAction, ChatState, ChatStateStore } from "../../chat-state"; import type { ChatAction, ChatState, ChatStateStore } from "../../chat-state";

View file

@ -70,6 +70,7 @@ export class ThreadResumeController {
this.host.clearDeferredRestoredThreadHydration(); this.host.clearDeferredRestoredThreadHydration();
this.host.resetThreadTurnPresence(false); this.host.resetThreadTurnPresence(false);
this.host.notifyActiveThreadIdentityChanged(); this.host.notifyActiveThreadIdentityChanged();
this.host.forceMessagesToBottom();
this.host.render(); this.host.render();
this.host.refreshLiveState(); 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 type { ComposerSuggestion } from "../composer/suggestions";
import { IconButton } from "../../../shared/ui/react-components"; import { IconButton } from "../../../shared/ui/react-components";
@ -21,6 +22,10 @@ export interface ComposerCallbacks {
onSuggestionInsert: (suggestion: ComposerSuggestion) => void; onSuggestionInsert: (suggestion: ComposerSuggestion) => void;
} }
type ButtonProps = ButtonHTMLAttributes & {
disabled?: boolean | undefined;
};
export function renderComposerShell( export function renderComposerShell(
parent: HTMLElement, parent: HTMLElement,
viewId: string, viewId: string,
@ -102,7 +107,7 @@ function ComposerShell({
onClick={callbacks.onUpdateSuggestions} onClick={callbacks.onUpdateSuggestions}
onSelect={callbacks.onUpdateSuggestions} onSelect={callbacks.onUpdateSuggestions}
onKeyDown={(event) => { onKeyDown={(event) => {
callbacks.onKeydown(event.nativeEvent); callbacks.onKeydown(event);
}} }}
/> />
<div className="codex-panel-ui__action-stack codex-panel__composer-actions"> <div className="codex-panel-ui__action-stack codex-panel__composer-actions">
@ -152,7 +157,7 @@ function ComposerIconButton({
icon: string; icon: string;
label: string; label: string;
className: string; className: string;
} & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "className" | "type">): ReactNode { } & Omit<ButtonProps, "className" | "type">): ReactNode {
return ( return (
<IconButton <IconButton
{...props} {...props}
@ -237,7 +242,7 @@ function ComposerSuggestions({
return ( return (
<div <div
key={optionId} key={optionId}
ref={selected ? selectedRef : undefined} ref={selected ? selectedRef : null}
className={`suggestion-item codex-panel__composer-suggestion ${selected ? "is-selected" : ""}`} className={`suggestion-item codex-panel__composer-suggestion ${selected ? "is-selected" : ""}`}
id={optionId} id={optionId}
role="option" 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 { displayBlocksForItems } from "../display/blocks";
import { executionState } from "../display/state"; 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 { import {
approvalActionOptions, approvalActionOptions,
@ -312,6 +312,9 @@ function OtherUserInputOption({
name={groupName} name={groupName}
value="__other__" value="__other__"
checked={otherSelected} checked={otherSelected}
onClick={(event) => {
if (event.currentTarget.checked) selectOther();
}}
onChange={(event) => { onChange={(event) => {
if (event.currentTarget.checked) selectOther(); if (event.currentTarget.checked) selectOther();
}} }}
@ -335,7 +338,7 @@ function OtherUserInputOption({
}} }}
onChange={(event) => { onChange={(event) => {
setInputValue(event.currentTarget.value); 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); if (nativeEvent.isComposing !== true && !composingRef.current) commitOtherValue(event.currentTarget.value);
}} }}
/> />

View file

@ -105,6 +105,9 @@ export class MessageScrollController {
this.observeMessageBlocks(container); this.observeMessageBlocks(container);
this.cancelRenderFrame(); this.cancelRenderFrame();
if (plan.shouldScrollToBottom && this.options.messagesPinnedToBottom()) {
this.pinToBottom(container);
}
this.renderFrame = container.win.requestAnimationFrame(() => { this.renderFrame = container.win.requestAnimationFrame(() => {
this.renderFrame = null; this.renderFrame = null;
if (plan.generation !== this.renderGeneration) return; 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 { renderReactRoot, unmountReactRoot } from "../../../shared/ui/react-root";
import type { ChatState, ChatStateStore } from "../chat-state"; import type { ChatState, ChatStateStore } from "../chat-state";
@ -19,8 +19,11 @@ interface ChatPanelShellSlots {
composer?: HTMLElement; composer?: HTMLElement;
} }
const shellSlotsByContainer = new WeakMap<HTMLElement, ChatPanelShellSlots>();
export function renderChatPanelShell(container: HTMLElement, props: ChatPanelShellProps): void { export function renderChatPanelShell(container: HTMLElement, props: ChatPanelShellProps): void {
const mountedSlots: ChatPanelShellSlots = {}; const mountedSlots = shellSlotsByContainer.get(container) ?? {};
shellSlotsByContainer.set(container, mountedSlots);
container.addClass("codex-panel"); container.addClass("codex-panel");
renderReactRoot( renderReactRoot(
container, container,
@ -36,6 +39,7 @@ export function renderChatPanelShell(container: HTMLElement, props: ChatPanelShe
export function unmountChatPanelShell(container: HTMLElement | null): void { export function unmountChatPanelShell(container: HTMLElement | null): void {
if (!container) return; if (!container) return;
shellSlotsByContainer.delete(container);
unmountReactRoot(container); unmountReactRoot(container);
} }
@ -50,6 +54,7 @@ function ChatPanelShell({
return ( return (
<> <>
<ChatPanelSlot <ChatPanelSlot
key="toolbar"
name="toolbar" name="toolbar"
className="codex-panel__toolbar" className="codex-panel__toolbar"
stateStore={stateStore} stateStore={stateStore}
@ -57,9 +62,10 @@ function ChatPanelShell({
slot={toolbar} slot={toolbar}
onSlotReady={onSlotReady} onSlotReady={onSlotReady}
/> />
<div className="codex-panel__body"> <div key="body" className="codex-panel__body">
<div className="codex-panel__slot codex-panel__slot--config" /> <div key="config" className="codex-panel__slot codex-panel__slot--config" />
<ChatPanelSlot <ChatPanelSlot
key="messages"
name="messages" name="messages"
className="codex-panel__slot codex-panel__slot--messages" className="codex-panel__slot codex-panel__slot--messages"
renderTargetClassName="codex-panel__messages" renderTargetClassName="codex-panel__messages"
@ -69,6 +75,7 @@ function ChatPanelShell({
onSlotReady={onSlotReady} onSlotReady={onSlotReady}
/> />
<ChatPanelSlot <ChatPanelSlot
key="composer"
name="composer" name="composer"
className="codex-panel__slot codex-panel__slot--composer" className="codex-panel__slot codex-panel__slot--composer"
stateStore={stateStore} stateStore={stateStore}
@ -106,7 +113,6 @@ function ChatPanelSlot({
const snapshot = useSyncExternalStore( const snapshot = useSyncExternalStore(
(listener) => stateStore.subscribe(listener), (listener) => stateStore.subscribe(listener),
() => slot.snapshot(stateStore.getState()), () => slot.snapshot(stateStore.getState()),
() => slot.snapshot(stateStore.getState()),
); );
const ref = useRef<HTMLDivElement | null>(null); const ref = useRef<HTMLDivElement | null>(null);
const renderGeneration = useRef(0); const renderGeneration = useRef(0);
@ -126,7 +132,15 @@ function ChatPanelSlot({
}, [name, onSlotReady, renderKey, renderTargetClassName, slot]); }, [name, onSlotReady, renderKey, renderTargetClassName, slot]);
return ( 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} {renderTargetClassName ? <div className={renderTargetClassName} /> : null}
</div> </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 { toolResultView, type ToolResultDetailSection, type ToolResultDisplayItem, type ToolResultView } from "../display/tool-view";
import { renderRawDiffLines } from "../../../shared/diff/render"; 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 type { EffectiveConfigSection, RateLimitSummary } from "../../../runtime/view";
import { IconButton, ObsidianIcon } from "../../../shared/ui/react-components"; import { IconButton, ObsidianIcon } from "../../../shared/ui/react-components";
import { renderReactRoot } from "../../../shared/ui/react-root"; import { renderReactRoot } from "../../../shared/ui/react-root";
import type { ToolbarDiagnosticSection, ToolbarThreadRow, ToolbarViewModel } from "../toolbar-model"; import type { ToolbarDiagnosticSection, ToolbarThreadRow, ToolbarViewModel } from "../toolbar-model";
type ButtonProps = ButtonHTMLAttributes & {
disabled?: boolean | undefined;
};
export interface ToolbarActions { export interface ToolbarActions {
toggleHistory: () => void; toggleHistory: () => void;
toggleAutoReview: () => void; toggleAutoReview: () => void;
@ -66,7 +71,7 @@ function ToolbarIconButton({
icon: string; icon: string;
label: string; label: string;
className?: string; className?: string;
} & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "className" | "type">): ReactNode { } & Omit<ButtonProps, "className" | "type">): ReactNode {
return ( return (
<IconButton <IconButton
{...props} {...props}
@ -444,7 +449,7 @@ function ThreadRenameRow({ thread, actions }: { thread: ToolbarThreadRow; action
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === "Enter") { if (event.key === "Enter") {
event.preventDefault(); 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; return;
} }
if (event.key === "Escape") { if (event.key === "Escape") {
@ -498,7 +503,7 @@ function ToolbarPanelItem({
renderContent?: () => ReactNode; renderContent?: () => ReactNode;
onClick?: () => void; onClick?: () => void;
}): ReactNode { }): ReactNode {
const onKeyDown = (event: KeyboardEvent<HTMLElement>) => { const onKeyDown = (event: TargetedKeyboardEvent<HTMLElement>) => {
if (disabled || (event.key !== "Enter" && event.key !== " ")) return; if (disabled || (event.key !== "Enter" && event.key !== " ")) return;
event.preventDefault(); event.preventDefault();
onClick?.(); 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 { renderDisplayDiffLines } from "../../../shared/diff/render";
import { displayDiffLines } from "../../../shared/diff/unified"; 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 { activeAgentRunSummary } from "../display/agent";
import { executionState } from "../display/state"; import { executionState } from "../display/state";

View file

@ -1,5 +1,6 @@
import { Notice, type Editor } from "obsidian"; 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 { renderDisplayDiffLines } from "../../shared/diff/render";
import { displayDiffLines } from "../../shared/diff/unified"; import { displayDiffLines } from "../../shared/diff/unified";
@ -16,6 +17,7 @@ import {
type SelectionRewriteRuntimeSettings, type SelectionRewriteRuntimeSettings,
type SelectionRewriteState, type SelectionRewriteState,
} from "./model"; } from "./model";
import { SelectionRewriteOutputError } from "./output"; import { SelectionRewriteOutputError } from "./output";
import { positionSelectionRewritePopover } from "./position"; import { positionSelectionRewritePopover } from "./position";
import { buildSelectionRewritePrompt } from "./prompt"; import { buildSelectionRewritePrompt } from "./prompt";
@ -285,11 +287,7 @@ export class SelectionRewritePopover {
}} }}
onInstructionKeyDown={(event) => { onInstructionKeyDown={(event) => {
const hasReplacement = this.options.state.replacementText !== null; const hasReplacement = this.options.state.replacementText !== null;
if ( if (!(hasReplacement ? isSelectionRewriteActionKey(event) : isComposerSendKey(event, this.options.sendShortcut))) {
!(hasReplacement
? isSelectionRewriteActionKey(event.nativeEvent)
: isComposerSendKey(event.nativeEvent, this.options.sendShortcut))
) {
return; return;
} }
event.preventDefault(); event.preventDefault();
@ -297,7 +295,7 @@ export class SelectionRewritePopover {
void this.generate(); void this.generate();
}} }}
onApplyKeyDown={(event) => { onApplyKeyDown={(event) => {
if (!isSelectionRewriteActionKey(event.nativeEvent)) return; if (!isSelectionRewriteActionKey(event)) return;
event.preventDefault(); event.preventDefault();
this.apply(); this.apply();
}} }}
@ -342,11 +340,11 @@ interface SelectionRewritePopoverViewProps {
instruction: string; instruction: string;
instructionRef: (element: HTMLTextAreaElement | null) => void; instructionRef: (element: HTMLTextAreaElement | null) => void;
onApply: () => void; onApply: () => void;
onApplyKeyDown: (event: KeyboardEvent<HTMLButtonElement>) => void; onApplyKeyDown: (event: TargetedKeyboardEvent<HTMLButtonElement>) => void;
onCancel: () => void; onCancel: () => void;
onGenerate: () => void; onGenerate: () => void;
onInstructionInput: (value: string) => void; onInstructionInput: (value: string) => void;
onInstructionKeyDown: (event: KeyboardEvent<HTMLTextAreaElement>) => void; onInstructionKeyDown: (event: TargetedKeyboardEvent<HTMLTextAreaElement>) => void;
status: SelectionRewritePopoverStatusState; status: SelectionRewritePopoverStatusState;
streamPreview: string; 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 { IconButton } from "../../shared/ui/react-components";
import { renderReactRoot, unmountReactRoot } from "../../shared/ui/react-root"; import { renderReactRoot, unmountReactRoot } from "../../shared/ui/react-root";
import type { ThreadsRowModel } from "./state"; import type { ThreadsRowModel } from "./state";
type ButtonProps = ButtonHTMLAttributes & {
disabled?: boolean | undefined;
};
export interface ThreadsViewModel { export interface ThreadsViewModel {
status: string | null; status: string | null;
loading: boolean; loading: boolean;
@ -82,7 +87,7 @@ function ThreadRow({ row, actions }: { row: ThreadsRowModel; actions: ThreadsVie
if (row.rename.active || archiveConfirm.active) return; if (row.rename.active || archiveConfirm.active) return;
actions.openThread(row.thread.id); actions.openThread(row.thread.id);
}; };
const openFromKeyboard = (event: KeyboardEvent<HTMLDivElement>) => { const openFromKeyboard = (event: TargetedKeyboardEvent<HTMLDivElement>) => {
if (row.rename.active || archiveConfirm.active) return; if (row.rename.active || archiveConfirm.active) return;
if (event.key !== "Enter" && event.key !== " ") return; if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault(); event.preventDefault();
@ -204,7 +209,7 @@ function RenameRow({ row, actions }: { row: ThreadsRowModel; actions: ThreadsVie
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === "Enter") { if (event.key === "Enter") {
event.preventDefault(); 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; return;
} }
if (event.key === "Escape") { if (event.key === "Escape") {
@ -247,7 +252,7 @@ function ThreadsIconButton({
icon: string; icon: string;
label: string; label: string;
className: string; className: string;
} & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "className" | "type">): ReactNode { } & Omit<ButtonProps, "className" | "type">): ReactNode {
return ( return (
<IconButton <IconButton
{...props} {...props}

View file

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

View file

@ -1,6 +1,8 @@
import type { ReactNode } from "react"; import type { ReactNode } from "preact/compat";
import { flushSync } from "react-dom"; import { flushSync } from "preact/compat";
import { createRoot, type Root } from "react-dom/client"; import { createRoot } from "preact/compat/client";
type Root = ReturnType<typeof createRoot>;
const roots = new WeakMap<HTMLElement, Root>(); const roots = new WeakMap<HTMLElement, Root>();
const guardedContainers = new WeakSet<HTMLElement>(); const guardedContainers = new WeakSet<HTMLElement>();

View file

@ -1,7 +1,8 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import { describe, expect, it, vi } from "vitest"; 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 type { DisplayItem } from "../../../../../src/features/chat/display/types";
import { implementPlanCandidateFromState } from "../../../../../src/features/chat/chat-message-renderer"; 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"); expect(renderTextWithWikiLinks).toHaveBeenCalledWith(expect.any(HTMLElement), "npm test");
const details = expectPresent(result.querySelector<HTMLDetailsElement>("details")); const details = expectPresent(result.querySelector<HTMLDetailsElement>("details"));
act(() => { void act(() => {
details.open = true; details.open = true;
details.dispatchEvent(new Event("toggle", { bubbles: false })); 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"); expect(details?.querySelector("summary")?.textContent).toBe("Show more");
if (details) { if (details) {
act(() => { void act(() => {
details.open = true; details.open = true;
details.dispatchEvent(new Event("toggle")); details.dispatchEvent(new Event("toggle"));
}); });

View file

@ -1,5 +1,6 @@
import { vi } from "vitest"; 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 { PendingApproval } from "../../../../../src/features/chat/approvals/model";
import type { PendingUserInput } from "../../../../../src/features/chat/user-input/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 { export function actEvent(action: () => void): void {
act(action); void act(action);
} }
export function renderMessageStreamBlocksInAct(parent: HTMLElement, blocks: MessageStreamBlock[]): void { export function renderMessageStreamBlocksInAct(parent: HTMLElement, blocks: MessageStreamBlock[]): void {
act(() => { void act(() => {
renderMessageStreamBlocks(parent, blocks); renderMessageStreamBlocks(parent, blocks);
}); });
} }
export function renderReactRootInAct(parent: HTMLElement, node: ReactNode): void { export function renderReactRootInAct(parent: HTMLElement, node: ReactNode): void {
act(() => { void act(() => {
renderReactRoot(parent, node); renderReactRoot(parent, node);
}); });
} }
export function unmountReactRootInAct(parent: HTMLElement): void { export function unmountReactRootInAct(parent: HTMLElement): void {
act(() => { void act(() => {
unmountReactRoot(parent); unmountReactRoot(parent);
}); });
} }

View file

@ -1,7 +1,7 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import { describe, expect, it, vi } from "vitest"; 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 type { DisplayItem } from "../../../../../src/features/chat/display/types";
import { topLevelDetailsSummaries } from "../../../../support/dom"; 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)"); 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")); const details = expectPresent(group.querySelector<HTMLDetailsElement>(".codex-panel__activity-group"));
act(() => { void act(() => {
details.open = false; details.open = false;
details.dispatchEvent(new Event("toggle", { bubbles: 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"]); expect([...block.querySelectorAll("details summary")].map((summary) => summary.textContent)).toEqual(["Details"]);
const details = expectPresent(block.querySelector<HTMLDetailsElement>("details")); const details = expectPresent(block.querySelector<HTMLDetailsElement>("details"));
act(() => { void act(() => {
details.open = true; details.open = true;
details.dispatchEvent(new Event("toggle", { bubbles: false })); details.dispatchEvent(new Event("toggle", { bubbles: false }));
}); });

View file

@ -1,7 +1,7 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import { describe, expect, it, vi } from "vitest"; 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 { chatTurnBusy, createChatStateStore } from "../../../../src/features/chat/chat-state";
import { renderChatPanelShell, unmountChatPanelShell } from "../../../../src/features/chat/ui/shell"; import { renderChatPanelShell, unmountChatPanelShell } from "../../../../src/features/chat/ui/shell";

View file

@ -429,8 +429,7 @@ describe("CodexChatView connection lifecycle", () => {
const view = await chatView(); const view = await chatView();
await view.onOpen(); await view.onOpen();
const composer = composerElement(view); const focus = vi.spyOn(HTMLTextAreaElement.prototype, "focus").mockImplementation(() => undefined);
const focus = vi.spyOn(composer, "focus");
await view.openThread("thread-1"); await view.openThread("thread-1");
await view.focusThread("thread-1"); await view.focusThread("thread-1");
@ -509,20 +508,17 @@ describe("CodexChatView connection lifecycle", () => {
const messages = view.containerEl.querySelector<HTMLElement>(".codex-panel__messages"); const messages = view.containerEl.querySelector<HTMLElement>(".codex-panel__messages");
expect(messages).not.toBeNull(); expect(messages).not.toBeNull();
if (!messages) return; if (!messages) return;
Object.defineProperty(messages, "scrollHeight", { value: 1000, configurable: true }); const restoreMessagesLayout = mockMessagesLayout({ scrollHeight: 1000, clientHeight: 100 });
Object.defineProperty(messages, "clientHeight", { value: 100, configurable: true });
messages.scrollTop = 0; messages.scrollTop = 0;
view.setComposerText("/resume thread-1"); view.setComposerText("/resume thread-1");
await (view as unknown as { submitComposerAction: () => Promise<void> }).submitComposerAction(); await (view as unknown as { submitComposerAction: () => Promise<void> }).submitComposerAction();
await new Promise<void>((resolve) => { await waitForMessagesFrame(messages);
messages.win.requestAnimationFrame(() => { await waitForMessagesFrame(messages);
resolve();
});
});
const renderedMessages = view.containerEl.querySelector<HTMLElement>(".codex-panel__messages"); const renderedMessages = view.containerEl.querySelector<HTMLElement>(".codex-panel__messages");
expect(renderedMessages?.scrollTop).toBe(1000); expect(renderedMessages?.scrollTop).toBe(1000);
restoreMessagesLayout();
}); });
it("routes slash archive through shared panel notifications", async () => { 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"); const messages = view.containerEl.querySelector<HTMLElement>(".codex-panel__messages");
expect(messages).not.toBeNull(); expect(messages).not.toBeNull();
if (!messages) return; if (!messages) return;
Object.defineProperty(messages, "scrollHeight", { value: 1000, configurable: true }); const restoreMessagesLayout = mockMessagesLayout({ scrollHeight: 1000, clientHeight: 100 });
Object.defineProperty(messages, "clientHeight", { value: 100, configurable: true });
messages.scrollTop = 0; messages.scrollTop = 0;
await view.openThread("thread-1"); await view.openThread("thread-1");
await new Promise<void>((resolve) => { await waitForMessagesFrame(messages);
messages.win.requestAnimationFrame(() => { await waitForMessagesFrame(messages);
resolve();
});
});
const renderedMessages = view.containerEl.querySelector<HTMLElement>(".codex-panel__messages"); const renderedMessages = view.containerEl.querySelector<HTMLElement>(".codex-panel__messages");
expect(renderedMessages?.scrollTop).toBe(1000); expect(renderedMessages?.scrollTop).toBe(1000);
restoreMessagesLayout();
}); });
it("renders resumed thread metadata before history hydration completes", async () => { 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"); 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 { function chatHost(overrides: Partial<CodexChatHost> = {}): CodexChatHost {
return { return {
settings: { settings: {

View file

@ -1,6 +1,6 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import { act } from "react"; import { act } from "preact/test-utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { buildSelectionUnifiedDiff } from "../../../src/features/selection-rewrite/diff"; 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"]')); const generate = expectPresent(document.querySelector<HTMLButtonElement>('button[aria-label="Generate"]'));
expect(generate.disabled).toBe(true); expect(generate.disabled).toBe(true);
act(() => { void act(() => {
setTextareaValue(instruction, "Make it concise."); setTextareaValue(instruction, "Make it concise.");
instruction.dispatchEvent(new Event("input", { bubbles: true })); instruction.dispatchEvent(new Event("input", { bubbles: true }));
}); });
@ -266,7 +266,7 @@ describe("selection rewrite popover", () => {
openPopover(popover); openPopover(popover);
const instruction = expectPresent(document.querySelector<HTMLTextAreaElement>(".codex-panel-selection-rewrite__instruction")); const instruction = expectPresent(document.querySelector<HTMLTextAreaElement>(".codex-panel-selection-rewrite__instruction"));
act(() => { void act(() => {
setTextareaValue(instruction, "Make it concise."); setTextareaValue(instruction, "Make it concise.");
instruction.dispatchEvent(new Event("input", { bubbles: true })); instruction.dispatchEvent(new Event("input", { bubbles: true }));
}); });
@ -414,13 +414,13 @@ function rewriteState(overrides: Partial<SelectionRewriteState> = {}): Selection
} }
function openPopover(popover: SelectionRewritePopover): void { function openPopover(popover: SelectionRewritePopover): void {
act(() => { void act(() => {
popover.open(); popover.open();
}); });
} }
function closePopover(popover: SelectionRewritePopover): void { function closePopover(popover: SelectionRewritePopover): void {
act(() => { void act(() => {
popover.close(); popover.close();
}); });
} }

View file

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