From 3471abb91e15a8deb8dfe24d9a7ebe2d06665a0f Mon Sep 17 00:00:00 2001 From: murashit Date: Sat, 27 Jun 2026 11:36:00 +0900 Subject: [PATCH] Enforce explicit DOM bridge boundaries --- biome.jsonc | 13 +++ docs/design.md | 2 +- docs/development.md | 2 +- scripts/lint/no-dom-events-imports.grit | 16 ++++ scripts/lint/no-implicit-dom-bridges.grit | 29 +++++- .../services/ephemeral-structured-turn.ts | 5 +- .../application/composer/boundary-scroll.ts | 10 +- src/features/chat/host/contracts.ts | 1 - src/features/chat/host/session.ts | 2 +- src/features/chat/host/shell-bundle.ts | 5 +- src/features/chat/host/thread-bundle.ts | 2 +- src/features/chat/host/turn-bundle.ts | 2 +- .../chat/host/{view.ts => view.obsidian.ts} | 1 - .../chat/panel/composer-controller.dom.ts | 57 +++++++++++ .../chat/panel/composer-controller.ts | 55 ++++++----- src/features/chat/panel/shell.dom.tsx | 6 +- src/features/chat/panel/toolbar-actions.ts | 23 ++--- src/features/chat/ui/composer.dom.ts | 85 ++++++++++++++++- src/features/chat/ui/composer.tsx | 95 ++++--------------- src/features/chat/ui/goal.dom.ts | 55 +++++++++++ src/features/chat/ui/goal.tsx | 68 ++++--------- .../ui/message-stream/flow-scroll.measure.ts | 24 ++--- .../ui/message-stream/message-stream.dom.ts | 48 ++++++++++ .../message-stream/pending-request-block.tsx | 37 +------- .../ui/message-stream/text-content.dom.tsx | 89 +++++++++++------ src/features/chat/ui/message-stream/text.tsx | 12 +-- src/features/chat/ui/toolbar.dom.ts | 33 +++++++ src/features/chat/ui/toolbar.tsx | 8 +- .../{command.ts => command.obsidian.ts} | 0 src/features/selection-rewrite/model.ts | 2 + .../selection-rewrite/popover.dom.tsx | 85 ++++++++--------- .../{position.ts => position.dom.ts} | 0 src/features/selection-rewrite/session.ts | 4 +- src/features/threads-view/session.ts | 9 +- src/features/threads-view/shell.dom.tsx | 22 +++-- .../{view.ts => view.obsidian.ts} | 0 src/main.ts | 6 +- src/plugin-runtime.ts | 2 +- src/shared/lifecycle/abort-signal.ts | 6 ++ src/shared/ui/components.obsidian.tsx | 9 +- src/shared/ui/dom-events.dom.ts | 32 +++++++ src/workspace/panel-coordinator.ts | 2 +- .../composer/boundary-scroll.test.ts | 14 +-- .../features/chat/host/session-graph.test.ts | 5 +- .../chat/host/view-connection.test.ts | 4 +- .../chat/panel/toolbar-actions.test.ts | 4 +- tests/features/chat/ui/composer.test.ts | 9 +- .../selection-rewrite-command.test.ts | 2 +- .../selection-rewrite.test.ts | 2 +- tests/features/threads-view/view.test.ts | 2 +- tests/main.test.ts | 56 +++++------ tests/scripts/grit-policy.test.mjs | 67 ++++++++++++- 52 files changed, 722 insertions(+), 407 deletions(-) create mode 100644 scripts/lint/no-dom-events-imports.grit rename src/features/chat/host/{view.ts => view.obsidian.ts} (97%) create mode 100644 src/features/chat/panel/composer-controller.dom.ts create mode 100644 src/features/chat/ui/goal.dom.ts create mode 100644 src/features/chat/ui/message-stream/message-stream.dom.ts create mode 100644 src/features/chat/ui/toolbar.dom.ts rename src/features/selection-rewrite/{command.ts => command.obsidian.ts} (100%) rename src/features/selection-rewrite/{position.ts => position.dom.ts} (100%) rename src/features/threads-view/{view.ts => view.obsidian.ts} (100%) create mode 100644 src/shared/lifecycle/abort-signal.ts diff --git a/biome.jsonc b/biome.jsonc index fc95bd23..6962e357 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -49,6 +49,19 @@ "path": "./scripts/lint/no-implicit-dom-bridges.grit", "includes": ["**/src/**/*.ts", "**/src/**/*.tsx"] }, + { + "path": "./scripts/lint/no-dom-events-imports.grit", + "includes": [ + "**/src/**/*.ts", + "**/src/**/*.tsx", + "!**/src/**/*.dom.ts", + "!**/src/**/*.dom.tsx", + "!**/src/**/*.obsidian.ts", + "!**/src/**/*.obsidian.tsx", + "!**/src/**/*.measure.ts", + "!**/src/**/*.measure.tsx" + ] + }, { "path": "./scripts/lint/no-ui-root-imports.grit", "includes": ["**/src/**/*.ts", "**/src/**/*.tsx"] diff --git a/docs/design.md b/docs/design.md index fa07a18e..d435f760 100644 --- a/docs/design.md +++ b/docs/design.md @@ -48,7 +48,7 @@ Chat-visible state belongs in `ChatStateStore` and named reducer actions. Signal Preact Signals are a shell-local projection adapter, not a second state system. Surface projections should read narrow shell-state contracts instead of making components or presenters depend on broad reducer slices. Domain, application, host, presentation, and component modules should keep using pure selectors, reducer actions, and explicit props rather than importing signals directly. -Imperative DOM bridges are allowed when an external API or measurement problem requires an `HTMLElement`. They should be named as `.dom`, `.obsidian`, or `.measure` files and should not become a second UI composition system inside Preact-owned surfaces. +Imperative DOM bridges are allowed when an external API, host lifecycle, hit-test, focus/selection operation, or measurement problem requires an `HTMLElement`. Normal modules should keep DOM meaning behind named adapters; files that read, measure, write, query, focus, or wire DOM directly should be named with a `.dom`, `.obsidian`, or `.measure` suffix and should not become a second UI composition system inside Preact-owned surfaces. ## Interaction Principles diff --git a/docs/development.md b/docs/development.md index 461af3a2..7cfb4aab 100644 --- a/docs/development.md +++ b/docs/development.md @@ -63,7 +63,7 @@ Generated app-server types should stay behind app-server connection and protocol Chat panel-visible state belongs in `ChatStateStore` and should flow through named reducer actions and the shell-state adapter. Use Preact Signals only in `src/features/chat/panel/shell-state.tsx`; lint enforces this boundary. When a surface needs fewer dependencies, add or reuse a named shell-state projection instead of importing `@preact/signals` elsewhere. -Use imperative DOM writes only for explicit bridge modules, Obsidian-owned API boundaries, or rendering and measurement code that cannot be expressed cleanly as Preact components. Name those files with a `.dom`, `.obsidian`, or `.measure` suffix so Biome can enforce the boundary without file-specific allowlists. +Use DOM reads, writes, measurements, hit-tests, focus/selection operations, and DOM event listener wiring only from explicit bridge modules, Obsidian-owned API boundaries, or rendering and measurement code that cannot be expressed cleanly as Preact components. Normal `.ts` and `.tsx` modules may keep refs and call named adapters, but they should not interpret DOM structure or layout directly. Name bridge files with a `.dom`, `.obsidian`, or `.measure` suffix so Biome can enforce the boundary without file-specific allowlists. Use `.tsx` only in rendering-owned source folders: chat panel and UI modules, Obsidian/settings surfaces, shared UI components, and explicit `.dom.tsx` rendering boundaries such as the selection rewrite popover and threads view shell. Non-rendering source should use `.ts`; Biome enforces this so lower-level app-server, domain, application, presentation, workspace, and generic shared modules do not grow JSX dependencies. diff --git a/scripts/lint/no-dom-events-imports.grit b/scripts/lint/no-dom-events-imports.grit new file mode 100644 index 00000000..ba5abf11 --- /dev/null +++ b/scripts/lint/no-dom-events-imports.grit @@ -0,0 +1,16 @@ +language js + +private pattern js_module_reference() { + or { + JsImport(), + JsExportNamedFromClause(), + JsExportFromClause(), + TsImportType(), + JsImportCallExpression() + } +} + +js_module_reference() as $stmt where { + $stmt <: contains `$source` where { $source <: r"^[\"'].*shared/ui/dom-events\.dom[\"']$" }, + register_diagnostic(span=$stmt, message="Import DOM event listener helpers only from explicit .dom, .obsidian, or .measure bridge files.", severity="error") +} diff --git a/scripts/lint/no-implicit-dom-bridges.grit b/scripts/lint/no-implicit-dom-bridges.grit index c8d41240..95bd8a43 100644 --- a/scripts/lint/no-implicit-dom-bridges.grit +++ b/scripts/lint/no-implicit-dom-bridges.grit @@ -26,9 +26,32 @@ or { `$target.classList.remove($...)` as $stmt, `$target.classList.toggle($...)` as $stmt, `$target.addEventListener($...)` as $stmt, - `$target.removeEventListener($...)` as $stmt + `$target.removeEventListener($...)` as $stmt, + `$target.querySelector($...)` as $stmt, + `$target.querySelectorAll($...)` as $stmt, + `$target.closest($...)` as $stmt, + `$target.contains($...)` as $stmt, + `$target.getBoundingClientRect()` as $stmt, + `$target.focus($...)` as $stmt, + `$target.select()` as $stmt, + `$target.setSelectionRange($...)` as $stmt, + `$target.setCssProps($...)` as $stmt, + `$target.scrollHeight` as $stmt, + `$target.scrollWidth` as $stmt, + `$target.clientHeight` as $stmt, + `$target.clientWidth` as $stmt, + `$target.offsetTop` as $stmt, + `$target.offsetHeight` as $stmt, + `$target.selectionStart` as $stmt, + `$target.selectionEnd` as $stmt, + `$target.selectionDirection` as $stmt, + `$target.activeElement` as $stmt, + `$target.ownerDocument` as $stmt, + `$target.defaultView` as $stmt, + `$target.style.$property` as $stmt } where { not { $filename <: r".*\.(?:dom|obsidian|measure)\.tsx?$" }, - not { $target <: `signal` }, - register_diagnostic(span=$stmt, message="Keep imperative DOM writes and event wiring in files named with a .dom, .obsidian, or .measure suffix.", severity="error") + not { $stmt <: `$target.addEventListener("abort", $...)` }, + not { $stmt <: `$target.removeEventListener("abort", $...)` }, + register_diagnostic(span=$stmt, message="Keep DOM reads, writes, measurements, hit-tests, focus, and event wiring in files named with a .dom, .obsidian, or .measure suffix.", severity="error") } diff --git a/src/app-server/services/ephemeral-structured-turn.ts b/src/app-server/services/ephemeral-structured-turn.ts index 32d967ae..d12fd17d 100644 --- a/src/app-server/services/ephemeral-structured-turn.ts +++ b/src/app-server/services/ephemeral-structured-turn.ts @@ -1,3 +1,4 @@ +import { listenAbortSignal } from "../../shared/lifecycle/abort-signal"; import type { ModelMetadataClient } from "../catalog"; import { AppServerClient, @@ -281,9 +282,9 @@ function rejectOnAbort(promise: Promise, signal: AbortSignal | undefined, const onAbort = (): void => { reject(abortError()); }; - signal.addEventListener("abort", onAbort, { once: true }); + const disposeAbortListener = listenAbortSignal(signal, onAbort); promise.then(resolve, reject).finally(() => { - signal.removeEventListener("abort", onAbort); + disposeAbortListener(); }); }); } diff --git a/src/features/chat/application/composer/boundary-scroll.ts b/src/features/chat/application/composer/boundary-scroll.ts index f3484572..c325e3dc 100644 --- a/src/features/chat/application/composer/boundary-scroll.ts +++ b/src/features/chat/application/composer/boundary-scroll.ts @@ -28,8 +28,8 @@ export interface ComposerBoundaryScrollKeyEvent { export interface ComposerBoundaryScrollTextState { value: string; - selectionStart: number; - selectionEnd: number; + cursorStart: number; + cursorEnd: number; } export interface ComposerBoundaryScrollOptions { @@ -46,7 +46,7 @@ export function composerBoundaryScrollDirection( const keyAction = composerBoundaryScrollKeyAction(event); if (!keyAction) return null; if (keyAction.kind === "scroll-to" || keyAction.amount === "page") return keyAction; - if (composer.selectionStart !== composer.selectionEnd) return null; + if (composer.cursorStart !== composer.cursorEnd) return null; return keyAction.direction === -1 ? composerCursorOnFirstLine(composer) && composerCursorAtVisualBoundary(keyAction.direction, composer, options) @@ -83,11 +83,11 @@ function composerBoundaryScrollByAction( } function composerCursorOnFirstLine(composer: ComposerBoundaryScrollTextState): boolean { - return !composer.value.slice(0, composer.selectionStart).includes("\n"); + return !composer.value.slice(0, composer.cursorStart).includes("\n"); } function composerCursorOnLastLine(composer: ComposerBoundaryScrollTextState): boolean { - return !composer.value.slice(composer.selectionEnd).includes("\n"); + return !composer.value.slice(composer.cursorEnd).includes("\n"); } function composerCursorAtVisualBoundary( diff --git a/src/features/chat/host/contracts.ts b/src/features/chat/host/contracts.ts index b1e4e863..6f22797e 100644 --- a/src/features/chat/host/contracts.ts +++ b/src/features/chat/host/contracts.ts @@ -57,7 +57,6 @@ export interface ChatPanelEnvironment { view: { panelRoot: () => HTMLElement | null; viewWindow: () => Window | null; - containsElement: (element: Element) => boolean; refreshTabHeader: () => void; }; } diff --git a/src/features/chat/host/session.ts b/src/features/chat/host/session.ts index 3b48c80f..7c6079c7 100644 --- a/src/features/chat/host/session.ts +++ b/src/features/chat/host/session.ts @@ -131,7 +131,7 @@ export class ChatPanelSession implements ChatPanelHandle { } focusComposer(): void { - this.graph.composer.controller.focus(); + this.graph.composer.controller.focusComposer(); } applyThreadArchived(threadId: string): void { diff --git a/src/features/chat/host/shell-bundle.ts b/src/features/chat/host/shell-bundle.ts index 675bf34d..6a0e5e01 100644 --- a/src/features/chat/host/shell-bundle.ts +++ b/src/features/chat/host/shell-bundle.ts @@ -9,6 +9,7 @@ import { MessageStreamPresenter } from "../panel/surface/message-stream-presente import type { ChatMessageScrollController } from "../panel/surface/message-stream-scroll"; import type { ChatPanelToolbarSurface } from "../panel/surface/toolbar-projection"; import { createToolbarUiActions, type ToolbarPanelActions } from "../panel/toolbar-actions"; +import { toolbarOutsidePointerHit } from "../ui/toolbar.dom"; import type { ChatPanelComposerBundle } from "./composer-bundle"; import type { ChatPanelConnectionBundle } from "./connection-bundle"; import type { ChatPanelEnvironment } from "./contracts"; @@ -132,9 +133,7 @@ export function createShellBundle(host: ChatPanelShellBundleHost, input: ChatPan parts, closeToolbarPanelOnOutsidePointer: (event) => { toolbarPanelActions.closeOnOutsidePointer({ - target: event.target, - viewWindow: environment.view.viewWindow() as (Window & { Element: typeof Element }) | null, - contains: (element) => environment.view.containsElement(element), + hit: toolbarOutsidePointerHit(event, environment.view.panelRoot(), environment.view.viewWindow()), renameEditing: rename.isEditing(), }); }, diff --git a/src/features/chat/host/thread-bundle.ts b/src/features/chat/host/thread-bundle.ts index 463f0254..b2463aef 100644 --- a/src/features/chat/host/thread-bundle.ts +++ b/src/features/chat/host/thread-bundle.ts @@ -225,7 +225,7 @@ export function createThreadActionBundle(host: ChatPanelThreadHost, input: ChatP resumeThread: (threadId) => lifecycle.resume.resumeThread(threadId), addSystemMessage: status.addSystemMessage, focusComposer: () => { - composerController.focus(); + composerController.focusComposer(); }, }); return { actions, toolbarPanelActions, navigation }; diff --git a/src/features/chat/host/turn-bundle.ts b/src/features/chat/host/turn-bundle.ts index 20d257b4..f355be55 100644 --- a/src/features/chat/host/turn-bundle.ts +++ b/src/features/chat/host/turn-bundle.ts @@ -96,7 +96,7 @@ export function createTurnBundle(host: ChatPanelTurnHost, input: ChatPanelTurnIn responder: inboundHandler, composerHasFocus: () => composerController.hasFocus(), focusComposer: () => { - composerController.focus(); + composerController.focusComposer(); }, refreshLiveState, }); diff --git a/src/features/chat/host/view.ts b/src/features/chat/host/view.obsidian.ts similarity index 97% rename from src/features/chat/host/view.ts rename to src/features/chat/host/view.obsidian.ts index f68ae51f..2a9130c6 100644 --- a/src/features/chat/host/view.ts +++ b/src/features/chat/host/view.obsidian.ts @@ -32,7 +32,6 @@ export class CodexChatView extends ItemView { view: { panelRoot: () => this.contentEl, viewWindow: () => this.containerEl.doc.defaultView, - containsElement: (element) => this.containerEl.contains(element), refreshTabHeader: () => { this.refreshTabHeader(); }, diff --git a/src/features/chat/panel/composer-controller.dom.ts b/src/features/chat/panel/composer-controller.dom.ts new file mode 100644 index 00000000..6a1d45e8 --- /dev/null +++ b/src/features/chat/panel/composer-controller.dom.ts @@ -0,0 +1,57 @@ +import { textareaCursorAtVisualBoundary } from "../../../shared/ui/textarea-caret.measure"; +import { type ComposerBoundaryScrollAction, composerBoundaryScrollDirection } from "../application/composer/boundary-scroll"; +import { composerSuggestionSignature } from "../application/composer/suggestions"; +import { syncComposerHeight } from "../ui/composer.dom"; + +export interface ComposerElementInsertion { + value: string; + cursor: number; +} + +export function focusComposer(composer: HTMLTextAreaElement | null, options: { preventScroll?: boolean } = {}): void { + composer?.focus(options); +} + +export function composerHasFocus(composer: HTMLTextAreaElement | null): boolean { + return composer !== null && composer.ownerDocument.activeElement === composer; +} + +export function composerTextBeforeCursor(composer: HTMLTextAreaElement | null): string | null { + if (!composer) return null; + return composer.value.slice(0, composer.selectionStart); +} + +export function composerSuggestionSignatureFromElement(composer: HTMLTextAreaElement | null): string | null { + if (!composer) return null; + return composerSuggestionSignature(composer.value, composer.selectionStart); +} + +export function composerInsertionSource(composer: HTMLTextAreaElement | null): ComposerElementInsertion | null { + if (!composer) return null; + return { + value: composer.value, + cursor: composer.selectionStart, + }; +} + +export function composerBoundaryScrollActionFromElement( + event: KeyboardEvent, + composer: HTMLTextAreaElement | null, +): ComposerBoundaryScrollAction | null { + if (!composer) return null; + const textState = { + value: composer.value, + cursorStart: composer.selectionStart, + cursorEnd: composer.selectionEnd, + }; + return composerBoundaryScrollDirection(event, textState, { + cursorAtVisualBoundary: (direction) => textareaCursorAtVisualBoundary(direction, composer), + }); +} + +export function applyComposerInsertionToElement(composer: HTMLTextAreaElement | null, cursor: number): void { + if (!composer) return; + syncComposerHeight(composer); + composer.focus(); + composer.setSelectionRange(cursor, cursor); +} diff --git a/src/features/chat/panel/composer-controller.ts b/src/features/chat/panel/composer-controller.ts index 55f59749..b57cb81a 100644 --- a/src/features/chat/panel/composer-controller.ts +++ b/src/features/chat/panel/composer-controller.ts @@ -1,22 +1,29 @@ import type { CodexInput } from "../../../domain/chat/input"; import { isComposerSendKey, type SendShortcut } from "../../../shared/ui/keyboard"; -import { textareaCursorAtVisualBoundary } from "../../../shared/ui/textarea-caret.measure"; -import { type ComposerBoundaryScrollAction, composerBoundaryScrollDirection } from "../application/composer/boundary-scroll"; +import type { ComposerBoundaryScrollAction } from "../application/composer/boundary-scroll"; import type { NoteCandidateProvider } from "../application/composer/note-context"; import { activeComposerSuggestions, applyComposerSuggestionInsertion, type ComposerSuggestion, composerSuggestionNavigationDirection, - composerSuggestionSignature, type NoteCandidate, nextComposerSuggestionIndex, } from "../application/composer/suggestions"; import { userInputWithWikiLinkMentionsAndSkills } from "../application/composer/wikilink-context"; import type { ChatAction, ChatState } from "../application/state/root-reducer"; import type { ChatStateStore } from "../application/state/store"; -import type { ComposerShellProps } from "../ui/composer"; -import { type ComposerCallbacks, syncComposerHeight } from "../ui/composer"; +import type { ComposerCallbacks, ComposerShellProps } from "../ui/composer"; +import { syncComposerHeight } from "../ui/composer.dom"; +import { + applyComposerInsertionToElement, + composerBoundaryScrollActionFromElement, + composerHasFocus, + composerInsertionSource, + composerSuggestionSignatureFromElement, + composerTextBeforeCursor, + focusComposer, +} from "./composer-controller.dom"; import type { ChatPanelComposerShellState } from "./shell-state"; import type { ChatPanelComposerProjection } from "./surface/composer-projection"; @@ -91,15 +98,15 @@ export class ChatComposerController { ...(options.clearSuggestions === undefined ? {} : { clearSuggestions: options.clearSuggestions }), }); this.options.onDraftChange(); - if (options.focus) this.composer?.focus(); + if (options.focus) focusComposer(this.composer); } - focus(): void { - this.composer?.focus({ preventScroll: true }); + focusComposer(): void { + focusComposer(this.composer, { preventScroll: true }); } hasFocus(): boolean { - return this.composer !== null && this.composer.ownerDocument.activeElement === this.composer; + return composerHasFocus(this.composer); } dispose(): void { @@ -153,12 +160,7 @@ export class ChatComposerController { } private handleBoundaryScrollKeydown(event: KeyboardEvent): boolean { - if (!this.composer) return false; - - const composer = this.composer; - const action = composerBoundaryScrollDirection(event, composer, { - cursorAtVisualBoundary: (direction) => textareaCursorAtVisualBoundary(direction, composer), - }); + const action = composerBoundaryScrollActionFromElement(event, this.composer); if (!action) return false; if (action.kind === "scroll-by" && action.amount === "text-lines" && !this.options.scrollThreadFromComposerEdges()) return false; @@ -168,19 +170,18 @@ export class ChatComposerController { } private updateSuggestions(): void { - if (!this.composer) { + const beforeCursor = composerTextBeforeCursor(this.composer); + if (beforeCursor === null) { this.clearSuggestions(); return; } - const cursor = this.composer.selectionStart; const signature = this.suggestionSignature(); const state = this.state; if (state.composer.suggestionsDismissedSignature === signature) { this.dispatchSuggestions({ type: "composer/suggestions-set", suggestions: [] }); return; } - const beforeCursor = this.composer.value.slice(0, cursor); const suggestions = activeComposerSuggestions( beforeCursor, this.noteCandidates(), @@ -221,7 +222,8 @@ export class ChatComposerController { if (state.composer.suggestionsDismissedSignature === signature) { return { suggestions: [], selected: 0, dismissedSignature: signature }; } - const beforeCursor = this.composer.value.slice(0, this.composer.selectionStart); + const beforeCursor = composerTextBeforeCursor(this.composer); + if (beforeCursor === null) return { suggestions: [], selected: 0, dismissedSignature: null }; const suggestions = activeComposerSuggestions( beforeCursor, this.noteCandidates(), @@ -244,17 +246,15 @@ export class ChatComposerController { } private insertSuggestion(suggestion: ComposerSuggestion | undefined, activation: "enter" | "tab" = "enter"): void { - if (!this.composer || !suggestion) return; + if (!suggestion) return; + const source = composerInsertionSource(this.composer); + if (!source) return; - const cursor = this.composer.selectionStart; - const value = this.composer.value; - const insertion = applyComposerSuggestionInsertion(value, cursor, suggestion, { activation }); + const insertion = applyComposerSuggestionInsertion(source.value, source.cursor, suggestion, { activation }); this.dispatch({ type: "composer/draft-set", draft: insertion.value, clearSuggestions: true }); this.options.onDraftChange(); - syncComposerHeight(this.composer); - this.composer.focus(); - this.composer.setSelectionRange(insertion.cursor, insertion.cursor); + applyComposerInsertionToElement(this.composer, insertion.cursor); } private clearSuggestions(): void { @@ -275,8 +275,7 @@ export class ChatComposerController { } private suggestionSignature(): string | null { - if (!this.composer) return null; - return composerSuggestionSignature(this.composer.value, this.composer.selectionStart); + return composerSuggestionSignatureFromElement(this.composer); } private noteCandidates(): NoteCandidate[] { diff --git a/src/features/chat/panel/shell.dom.tsx b/src/features/chat/panel/shell.dom.tsx index ae2bf1c7..7e30c767 100644 --- a/src/features/chat/panel/shell.dom.tsx +++ b/src/features/chat/panel/shell.dom.tsx @@ -1,4 +1,5 @@ import type { ComponentChild as UiNode } from "preact"; +import { listenDomEvent } from "../../../shared/ui/dom-events.dom"; import { renderUiRoot, unmountUiRoot } from "../../../shared/ui/ui-root.dom"; import type { ChatStateStore } from "../application/state/store"; import type { ToolbarActions } from "../ui/toolbar"; @@ -168,10 +169,7 @@ function startStatusBarClearanceSync(container: HTMLElement): () => void { bodyObserver.disconnect(); }); - win.addEventListener("resize", sync); - cleanupCallbacks.push(() => { - win.removeEventListener("resize", sync); - }); + cleanupCallbacks.push(listenDomEvent(win, "resize", sync)); sync(); return () => { diff --git a/src/features/chat/panel/toolbar-actions.ts b/src/features/chat/panel/toolbar-actions.ts index f9ed3170..40d49787 100644 --- a/src/features/chat/panel/toolbar-actions.ts +++ b/src/features/chat/panel/toolbar-actions.ts @@ -7,6 +7,7 @@ import type { ThreadRenameEditorActions } from "../application/threads/rename-ed import type { ThreadManagementActions } from "../application/threads/thread-management-actions"; import type { ThreadNavigationActions } from "../application/threads/thread-navigation-actions"; import type { ToolbarActions } from "../ui/toolbar"; +import type { ToolbarOutsidePointerHit } from "../ui/toolbar.dom"; export interface ToolbarPanelActionsHost { stateStore: ChatStateStore; @@ -36,14 +37,10 @@ export interface ToolbarUiActionDependencies { } interface ToolbarOutsidePointerContext { - target: EventTarget | null; - viewWindow: ToolbarDomWindow | null; - contains: (element: Element) => boolean; + hit: ToolbarOutsidePointerHit; renameEditing: boolean; } -type ToolbarDomWindow = Window & { Element: typeof Element }; - export function createToolbarPanelActions(host: ToolbarPanelActionsHost): ToolbarPanelActions { const state = (): ChatState => host.stateStore.getState(); const dispatch = (action: ChatAction): void => { @@ -98,15 +95,11 @@ export function createToolbarPanelActions(host: ToolbarPanelActionsHost): Toolba closeOnOutsidePointer(context: ToolbarOutsidePointerContext): void { if (!hasOpenPanel()) return; - const target = context.target; - if (isToolbarElement(target, context.viewWindow)) { - const insideToolbarPanel = target.closest(".codex-panel__toolbar-primary, .codex-panel__toolbar-panel"); - if (insideToolbarPanel && context.contains(insideToolbarPanel)) { - if (archiveConfirmId() && !target.closest(".codex-panel__archive-confirm")) { - setArchiveConfirm(null); - } - return; + if (context.hit.insideToolbarPanel) { + if (archiveConfirmId() && !context.hit.insideArchiveConfirm) { + setArchiveConfirm(null); } + return; } if (archiveConfirmId()) { @@ -187,7 +180,3 @@ export function createToolbarUiActions(deps: ToolbarUiActionDependencies): Toolb }, }; } - -function isToolbarElement(target: EventTarget | null, viewWindow: ToolbarDomWindow | null): target is Element { - return Boolean(viewWindow && target instanceof viewWindow.Element); -} diff --git a/src/features/chat/ui/composer.dom.ts b/src/features/chat/ui/composer.dom.ts index b15d190d..2ed271d5 100644 --- a/src/features/chat/ui/composer.dom.ts +++ b/src/features/chat/ui/composer.dom.ts @@ -1,14 +1,97 @@ import { setIcon } from "obsidian"; +import { disposeDomListeners, listenDomEscapeKey, listenDomEvent, listenOutsideDomEvent } from "../../../shared/ui/dom-events.dom"; +import { syncTextareaHeight } from "../../../shared/ui/textarea-autogrow.measure"; + const COMPOSER_META_EFFORT_HIDDEN_CLASS = "is-effort-hidden"; const COMPOSER_META_MODEL_HIDDEN_CLASS = "is-model-hidden"; +export interface ComposerTextSelection { + start: number; + end: number; + direction: "forward" | "backward" | "none"; +} + +export interface ComposerMetaPickerState { + kind: "model" | "effort"; + left: number; +} + export function renderComposerMetaIcon(element: HTMLElement, icon: string): void { element.replaceChildren(); setIcon(element, icon); } -export function updateComposerMetaStatusOverflow(status: HTMLElement): void { +export function syncComposerHeight(composer: HTMLTextAreaElement | null): boolean { + const previousHeight = composer?.style.height ?? ""; + const previousOverflowY = composer?.style.overflowY ?? ""; + syncTextareaHeight(composer, { + minHeightFallback: 56, + maxHeightFallback: composer ? Math.min(208, composer.win.innerHeight * 0.4) : 208, + }); + return Boolean(composer && (composer.style.height !== previousHeight || composer.style.overflowY !== previousOverflowY)); +} + +export function preserveComposerSelection( + composer: HTMLTextAreaElement | null, + previousDraft: string, + nextDraft: string, +): ComposerTextSelection | null { + if (!composer || previousDraft !== nextDraft) return null; + return { + start: composer.selectionStart, + end: composer.selectionEnd, + direction: composer.selectionDirection, + }; +} + +export function restoreComposerSelection(composer: HTMLTextAreaElement | null, selection: ComposerTextSelection | null): void { + if (!composer || !selection) return; + composer.setSelectionRange(selection.start, selection.end, selection.direction); +} + +export function observeComposerMetaStatusOverflow(status: HTMLElement): () => void { + const win = status.win; + let frame = 0; + const update = () => { + frame = 0; + updateComposerMetaStatusOverflow(status); + }; + const scheduleUpdate = () => { + if (frame) win.cancelAnimationFrame(frame); + frame = win.requestAnimationFrame(update); + }; + update(); + const ResizeObserverCtor = (win as Window & { ResizeObserver?: typeof ResizeObserver }).ResizeObserver; + const observer = ResizeObserverCtor ? new ResizeObserverCtor(scheduleUpdate) : null; + observer?.observe(status); + const disposeResize = listenDomEvent(win, "resize", scheduleUpdate); + return () => { + if (frame) win.cancelAnimationFrame(frame); + observer?.disconnect(); + disposeResize(); + }; +} + +export function closeComposerMetaPickerOnOutsidePointer(metaRoot: HTMLElement, onClose: () => void): () => void { + return disposeDomListeners(listenOutsideDomEvent(metaRoot, "mousedown", onClose), listenDomEscapeKey(metaRoot.ownerDocument, onClose)); +} + +export function composerMetaPickerState( + kind: ComposerMetaPickerState["kind"], + trigger: HTMLElement | null, + metaRoot: HTMLElement | null, +): ComposerMetaPickerState { + if (!trigger || !metaRoot) return { kind, left: 0 }; + const triggerRect = trigger.getBoundingClientRect(); + const metaRect = metaRoot.getBoundingClientRect(); + return { + kind, + left: Math.max(0, triggerRect.left - metaRect.left), + }; +} + +function updateComposerMetaStatusOverflow(status: HTMLElement): void { status.classList.remove(COMPOSER_META_EFFORT_HIDDEN_CLASS, COMPOSER_META_MODEL_HIDDEN_CLASS); if (!composerMetaStatusOverflowing(status)) return; if (status.querySelector(".codex-panel__composer-meta-field--effort")) { diff --git a/src/features/chat/ui/composer.tsx b/src/features/chat/ui/composer.tsx index 44bf6225..54702181 100644 --- a/src/features/chat/ui/composer.tsx +++ b/src/features/chat/ui/composer.tsx @@ -2,9 +2,17 @@ import type { ButtonHTMLAttributes, Ref, ComponentChild as UiNode } from "preact import { useLayoutEffect, useRef, useState } from "preact/hooks"; import { IconButton } from "../../../shared/ui/components.obsidian"; -import { disposeDomListeners, listenDomEvent } from "../../../shared/ui/dom-events.dom"; -import { syncTextareaHeight } from "../../../shared/ui/textarea-autogrow.measure"; -import { renderComposerMetaIcon, scrollComposerSuggestionIntoView, updateComposerMetaStatusOverflow } from "./composer.dom"; +import { + type ComposerMetaPickerState, + closeComposerMetaPickerOnOutsidePointer, + composerMetaPickerState, + observeComposerMetaStatusOverflow, + preserveComposerSelection, + renderComposerMetaIcon, + restoreComposerSelection, + scrollComposerSuggestionIntoView, + syncComposerHeight, +} from "./composer.dom"; export interface ComposerSuggestion { display: string; @@ -119,9 +127,7 @@ export function ComposerShell({ }, [suggestions, selectedSuggestionIndex]); useLayoutEffect(() => { previousDraftRef.current = draft; - const composer = composerRef.current; - if (!composer || !preservedSelection) return; - composer.setSelectionRange(preservedSelection.start, preservedSelection.end, preservedSelection.direction); + restoreComposerSelection(composerRef.current, preservedSelection); }); const sendMode = composerSendMode(busy, canInterrupt, draft); const normalizedSelectedSuggestionIndex = suggestions.length === 0 ? 0 : Math.min(selectedSuggestionIndex, suggestions.length - 1); @@ -165,19 +171,6 @@ export function ComposerShell({ ); } -function preserveComposerSelection( - composer: HTMLTextAreaElement | null, - previousDraft: string, - nextDraft: string, -): { start: number; end: number; direction: "forward" | "backward" | "none" } | null { - if (!composer || previousDraft !== nextDraft) return null; - return { - start: composer.selectionStart, - end: composer.selectionEnd, - direction: composer.selectionDirection, - }; -} - function ComposerMeta({ meta, sendMode, @@ -195,40 +188,15 @@ function ComposerMeta({ useLayoutEffect(() => { const status = statusRef.current; if (!status) return; - const win = status.win; - let frame = 0; - const update = () => { - frame = 0; - updateComposerMetaStatusOverflow(status); - }; - const scheduleUpdate = () => { - if (frame) win.cancelAnimationFrame(frame); - frame = win.requestAnimationFrame(update); - }; - update(); - const ResizeObserverCtor = (win as Window & { ResizeObserver?: typeof ResizeObserver }).ResizeObserver; - const observer = ResizeObserverCtor ? new ResizeObserverCtor(scheduleUpdate) : null; - observer?.observe(status); - const disposeResize = listenDomEvent(win, "resize", scheduleUpdate); - return () => { - if (frame) win.cancelAnimationFrame(frame); - observer?.disconnect(); - disposeResize(); - }; + return observeComposerMetaStatusOverflow(status); }, [meta]); useLayoutEffect(() => { if (!picker) return; const metaRoot = metaRef.current; - const doc = metaRoot?.ownerDocument; - if (!metaRoot || !doc) return; - const closeOnOutsideMouse = (event: MouseEvent) => { - if (event.target && metaRoot.contains(event.target as Node)) return; + if (!metaRoot) return; + return closeComposerMetaPickerOnOutsidePointer(metaRoot, () => { setPicker(null); - }; - const closeOnEscape = (event: KeyboardEvent) => { - if (event.key === "Escape") setPicker(null); - }; - return disposeDomListeners(listenDomEvent(doc, "mousedown", closeOnOutsideMouse), listenDomEvent(doc, "keydown", closeOnEscape)); + }); }, [picker]); const openPicker = (kind: ComposerMetaPickerKind) => { const nextPicker = composerMetaPickerState( @@ -318,26 +286,7 @@ function ComposerMeta({ ); } -type ComposerMetaPickerKind = "model" | "effort"; - -interface ComposerMetaPickerState { - kind: ComposerMetaPickerKind; - left: number; -} - -function composerMetaPickerState( - kind: ComposerMetaPickerKind, - trigger: HTMLElement | null, - metaRoot: HTMLElement | null, -): ComposerMetaPickerState { - if (!trigger || !metaRoot) return { kind, left: 0 }; - const triggerRect = trigger.getBoundingClientRect(); - const metaRect = metaRoot.getBoundingClientRect(); - return { - kind, - left: Math.max(0, triggerRect.left - metaRect.left), - }; -} +type ComposerMetaPickerKind = ComposerMetaPickerState["kind"]; function ComposerContextMeter({ context }: { context: ComposerMetaViewModel["context"] }): UiNode { return ( @@ -505,16 +454,6 @@ function ComposerIconButton({ ); } -export function syncComposerHeight(composer: HTMLTextAreaElement | null): boolean { - const previousHeight = composer?.style.height ?? ""; - const previousOverflowY = composer?.style.overflowY ?? ""; - syncTextareaHeight(composer, { - minHeightFallback: 56, - maxHeightFallback: composer ? Math.min(208, composer.win.innerHeight * 0.4) : 208, - }); - return Boolean(composer && (composer.style.height !== previousHeight || composer.style.overflowY !== previousOverflowY)); -} - function ComposerSuggestions({ containerRef, selectedRef, diff --git a/src/features/chat/ui/goal.dom.ts b/src/features/chat/ui/goal.dom.ts new file mode 100644 index 00000000..230cee36 --- /dev/null +++ b/src/features/chat/ui/goal.dom.ts @@ -0,0 +1,55 @@ +import { disposeDomListeners, listenDomEscapeKey, listenOutsideDomEvent } from "../../../shared/ui/dom-events.dom"; +import { syncTextareaHeight } from "../../../shared/ui/textarea-autogrow.measure"; + +export function syncGoalObjectiveHeight(textarea: HTMLTextAreaElement | null): void { + syncTextareaHeight(textarea, { + minHeightFallback: 56, + maxHeightFallback: textarea ? Math.min(180, textarea.win.innerHeight * 0.3) : 180, + }); +} + +export function focusGoalObjectiveEditor(textarea: HTMLTextAreaElement | null): void { + textarea?.focus(); +} + +export function observeGoalObjectiveOverflow(content: HTMLElement, onOverflowChange: (overflows: boolean) => void): () => void { + const win = content.win; + let frame = 0; + const update = () => { + frame = 0; + onOverflowChange(content.scrollHeight > goalObjectiveCollapseHeight(content) + 1); + }; + update(); + frame = win.requestAnimationFrame(update); + return () => { + if (frame) win.cancelAnimationFrame(frame); + }; +} + +export function closeGoalEditorOnOutsidePointer(root: HTMLElement, onCancel: () => void): () => void { + const closeOnEscape = (event: KeyboardEvent): void => { + event.preventDefault(); + onCancel(); + }; + return disposeDomListeners( + listenOutsideDomEvent(root, "pointerdown", onCancel, true), + listenDomEscapeKey(root.ownerDocument, closeOnEscape), + ); +} + +export function collapseGoalObjectiveOnOutsidePointer(root: HTMLElement, onCollapse: () => void): () => void { + return listenOutsideDomEvent(root, "pointerdown", onCollapse, true); +} + +function goalObjectiveCollapseHeight(element: HTMLElement): number { + const lineHeight = computedLineHeight(element); + return lineHeight * 3; +} + +function computedLineHeight(element: HTMLElement): number { + const style = element.win.getComputedStyle(element); + const lineHeight = Number.parseFloat(style.lineHeight); + if (Number.isFinite(lineHeight) && lineHeight > 0) return lineHeight; + const fontSize = Number.parseFloat(style.fontSize); + return Number.isFinite(fontSize) && fontSize > 0 ? fontSize * 1.5 : 24; +} diff --git a/src/features/chat/ui/goal.tsx b/src/features/chat/ui/goal.tsx index 6861b19a..e8fca62a 100644 --- a/src/features/chat/ui/goal.tsx +++ b/src/features/chat/ui/goal.tsx @@ -3,9 +3,14 @@ import { useEffect, useLayoutEffect, useRef, useState } from "preact/hooks"; import type { ThreadGoal, ThreadGoalStatus } from "../../../domain/threads/goal"; import { IconButton } from "../../../shared/ui/components.obsidian"; -import { disposeDomListeners, listenDomEvent } from "../../../shared/ui/dom-events.dom"; import { isComposerSendKey, type SendShortcut } from "../../../shared/ui/keyboard"; -import { syncTextareaHeight } from "../../../shared/ui/textarea-autogrow.measure"; +import { + closeGoalEditorOnOutsidePointer, + collapseGoalObjectiveOnOutsidePointer, + focusGoalObjectiveEditor, + observeGoalObjectiveOverflow, + syncGoalObjectiveHeight, +} from "./goal.dom"; export interface GoalPanelActions { onSave: (objective: string, tokenBudget: number | null) => void; @@ -69,51 +74,30 @@ export function GoalPanel({ useLayoutEffect(() => { if (!editing) return; - objectiveRef.current?.focus(); + focusGoalObjectiveEditor(objectiveRef.current); }, [editing]); useLayoutEffect(() => { if (editing) return; const content = objectiveContentRef.current; if (!content) return; - const update = () => { - setObjectiveOverflows(content.scrollHeight > goalObjectiveCollapseHeight(content) + 1); - }; - update(); - content.win.requestAnimationFrame(update); + return observeGoalObjectiveOverflow(content, setObjectiveOverflows); }, [editing, resetObjective]); useEffect(() => { if (!editing) return; - const doc = goalRef.current?.ownerDocument; - if (!doc) return; - const cancelEditing = () => { - actions.onCancelEditing(); - }; - const closeOnOutsidePointer = (event: PointerEvent) => { - if (event.target instanceof Node && goalRef.current?.contains(event.target)) return; - cancelEditing(); - }; - const closeOnEscape = (event: KeyboardEvent) => { - if (event.key !== "Escape") return; - event.preventDefault(); - cancelEditing(); - }; - return disposeDomListeners( - listenDomEvent(doc, "pointerdown", closeOnOutsidePointer, true), - listenDomEvent(doc, "keydown", closeOnEscape), - ); + const root = goalRef.current; + if (!root) return; + return closeGoalEditorOnOutsidePointer(root, actions.onCancelEditing); }, [actions, editing]); useEffect(() => { if (!objectiveExpanded) return; - const doc = goalRef.current?.ownerDocument; - if (!doc) return; - const collapseOnOutsidePointer = (event: PointerEvent) => { - if (event.target instanceof Node && goalRef.current?.contains(event.target)) return; + const root = goalRef.current; + if (!root) return; + return collapseGoalObjectiveOnOutsidePointer(root, () => { actions.onObjectiveExpandedChange(false); - }; - return listenDomEvent(doc, "pointerdown", collapseOnOutsidePointer, true); + }); }, [actions, objectiveExpanded]); if (!goal && !editing) return null; @@ -255,26 +239,6 @@ function goalStatusClassName(status: ThreadGoalStatus | null): string { return ""; } -function syncGoalObjectiveHeight(textarea: HTMLTextAreaElement | null): void { - syncTextareaHeight(textarea, { - minHeightFallback: 56, - maxHeightFallback: textarea ? Math.min(180, textarea.win.innerHeight * 0.3) : 180, - }); -} - -function goalObjectiveCollapseHeight(element: HTMLElement): number { - const lineHeight = computedLineHeight(element); - return lineHeight * 3; -} - -function computedLineHeight(element: HTMLElement): number { - const style = element.win.getComputedStyle(element); - const lineHeight = Number.parseFloat(style.lineHeight); - if (Number.isFinite(lineHeight) && lineHeight > 0) return lineHeight; - const fontSize = Number.parseFloat(style.fontSize); - return Number.isFinite(fontSize) && fontSize > 0 ? fontSize * 1.5 : 24; -} - function goalUsage(goal: ThreadGoal): string { const tokens = goal.tokenBudget === null ? `${String(goal.tokensUsed)} tokens` : `${String(goal.tokensUsed)} / ${String(goal.tokenBudget)} tokens`; diff --git a/src/features/chat/ui/message-stream/flow-scroll.measure.ts b/src/features/chat/ui/message-stream/flow-scroll.measure.ts index 4c1a9827..ff546eaa 100644 --- a/src/features/chat/ui/message-stream/flow-scroll.measure.ts +++ b/src/features/chat/ui/message-stream/flow-scroll.measure.ts @@ -1,5 +1,6 @@ import { Component, h, type ComponentChild as UiNode } from "preact"; +import { disposeDomListeners, listenDomEvent } from "../../../../shared/ui/dom-events.dom"; import { MESSAGE_CONTENT_RENDERED_EVENT } from "./content-events"; type MessageScrollDirection = -1 | 1; @@ -168,10 +169,6 @@ function attachMessageFlowContainer(runtime: MessageFlowRuntime, container: HTML scrollMessageFlowToEnd(runtime); scheduleMessageFlowEndRestore(runtime); }; - container.addEventListener("scroll", handleScroll, { passive: true }); - container.addEventListener(MESSAGE_CONTENT_RENDERED_EVENT, handleContentChange, true); - container.addEventListener("toggle", handleContentChange, true); - const win = container.ownerDocument.defaultView; if (win?.ResizeObserver) { runtime.resizeObserver = new win.ResizeObserver(() => { @@ -181,13 +178,18 @@ function attachMessageFlowContainer(runtime: MessageFlowRuntime, container: HTML } runtime.restoreFrame = null; - cleanupMessageFlowContainer.set(runtime, () => { - container.removeEventListener("scroll", handleScroll); - container.removeEventListener(MESSAGE_CONTENT_RENDERED_EVENT, handleContentChange, true); - container.removeEventListener("toggle", handleContentChange, true); - runtime.resizeObserver?.disconnect(); - runtime.resizeObserver = null; - }); + cleanupMessageFlowContainer.set( + runtime, + disposeDomListeners( + listenDomEvent(container, "scroll", handleScroll, { passive: true }), + listenDomEvent(container, MESSAGE_CONTENT_RENDERED_EVENT, handleContentChange, true), + listenDomEvent(container, "toggle", handleContentChange, true), + () => { + runtime.resizeObserver?.disconnect(); + runtime.resizeObserver = null; + }, + ), + ); } const cleanupMessageFlowContainer = new WeakMap void>(); diff --git a/src/features/chat/ui/message-stream/message-stream.dom.ts b/src/features/chat/ui/message-stream/message-stream.dom.ts new file mode 100644 index 00000000..343b7f6b --- /dev/null +++ b/src/features/chat/ui/message-stream/message-stream.dom.ts @@ -0,0 +1,48 @@ +import { listenOutsideDomEvent } from "../../../../shared/ui/dom-events.dom"; + +export interface McpElicitationValidityMessage { + fieldId: string; + message: string; +} + +export function closeMessageRoleMenuOnOutsidePointer(root: HTMLElement, onClose: () => void): () => void { + return listenOutsideDomEvent(root, "pointerdown", onClose, true); +} + +export function focusPendingRequestControl(container: HTMLElement | null): void { + if (!container) return; + for (const selector of [ + ".codex-panel__user-input-radio:checked", + ".codex-panel__user-input-text", + ".codex-panel__mcp-elicitation-input", + ".codex-panel__mcp-elicitation-checkbox", + ".codex-panel__mcp-elicitation-radio:checked", + ".codex-panel__mcp-elicitation-radio", + ".codex-panel__user-input-radio", + ".codex-panel__pending-request-button.mod-cta", + ".codex-panel__pending-request-button", + ]) { + const target = container.querySelector(selector); + if (!target) continue; + target.focus({ preventScroll: true }); + return; + } +} + +export function applyMcpElicitationFormValidity(form: HTMLFormElement | null, messages: readonly McpElicitationValidityMessage[]): boolean { + if (!form) return true; + clearMcpElicitationCustomValidity(form); + for (const { fieldId, message } of messages) { + const input = Array.from(form.querySelectorAll("input[data-mcp-elicitation-field]")).find( + (candidate) => candidate.dataset["mcpElicitationField"] === fieldId, + ); + input?.setCustomValidity(message); + } + return form.reportValidity(); +} + +function clearMcpElicitationCustomValidity(form: HTMLFormElement): void { + form.querySelectorAll(".codex-panel__mcp-elicitation-checkbox").forEach((input) => { + input.setCustomValidity(""); + }); +} diff --git a/src/features/chat/ui/message-stream/pending-request-block.tsx b/src/features/chat/ui/message-stream/pending-request-block.tsx index a5bb89d8..5f50e5d7 100644 --- a/src/features/chat/ui/message-stream/pending-request-block.tsx +++ b/src/features/chat/ui/message-stream/pending-request-block.tsx @@ -11,6 +11,7 @@ import type { PendingUserInputViewModel, } from "../../presentation/pending-requests/view-model"; import type { PendingRequestBlockActions } from "./context"; +import { applyMcpElicitationFormValidity, focusPendingRequestControl, type McpElicitationValidityMessage } from "./message-stream.dom"; import { createStatusMessageClassName } from "./status"; export function pendingRequestBlockNode( @@ -93,26 +94,6 @@ function PendingRequestBlock({ ); } -function focusPendingRequestControl(container: HTMLElement | null): void { - if (!container) return; - for (const selector of [ - ".codex-panel__user-input-radio:checked", - ".codex-panel__user-input-text", - ".codex-panel__mcp-elicitation-input", - ".codex-panel__mcp-elicitation-checkbox", - ".codex-panel__mcp-elicitation-radio:checked", - ".codex-panel__mcp-elicitation-radio", - ".codex-panel__user-input-radio", - ".codex-panel__pending-request-button.mod-cta", - ".codex-panel__pending-request-button", - ]) { - const target = container.querySelector(selector); - if (!target) continue; - target.focus({ preventScroll: true }); - return; - } -} - function McpElicitationCard({ elicitation, mcpElicitationDrafts, @@ -175,25 +156,15 @@ function validateMcpElicitationForm( elicitation: PendingMcpElicitationViewModel, drafts: ReadonlyMap, ): boolean { - if (!form) return true; - clearMcpElicitationCustomValidity(form); + const messages: McpElicitationValidityMessage[] = []; for (const field of elicitation.fields) { if (field.type !== "multi-select") continue; const selectedCount = selectedMcpElicitationValues(drafts.get(field.draftKey) ?? field.defaultDraft).size; const message = mcpElicitationMultiSelectValidationMessage(field, selectedCount); if (!message) continue; - const input = Array.from(form.querySelectorAll("input[data-mcp-elicitation-field]")).find( - (candidate) => candidate.dataset["mcpElicitationField"] === field.id, - ); - input?.setCustomValidity(message); + messages.push({ fieldId: field.id, message }); } - return form.reportValidity(); -} - -function clearMcpElicitationCustomValidity(form: HTMLFormElement): void { - form.querySelectorAll(".codex-panel__mcp-elicitation-checkbox").forEach((input) => { - input.setCustomValidity(""); - }); + return applyMcpElicitationFormValidity(form, messages); } function mcpElicitationMultiSelectValidationMessage(field: PendingMcpElicitationFieldViewModel, selectedCount: number): string | null { diff --git a/src/features/chat/ui/message-stream/text-content.dom.tsx b/src/features/chat/ui/message-stream/text-content.dom.tsx index a1dc24d2..cd30dae5 100644 --- a/src/features/chat/ui/message-stream/text-content.dom.tsx +++ b/src/features/chat/ui/message-stream/text-content.dom.tsx @@ -1,6 +1,7 @@ import type { Ref, ComponentChild as UiNode } from "preact"; import { useEffect, useLayoutEffect, useRef, useState } from "preact/hooks"; +import { listenDomEvent, listenOutsideDomEvent } from "../../../../shared/ui/dom-events.dom"; import type { MessageStreamTextView } from "../../presentation/message-stream/text-view"; import { MESSAGE_CONTENT_RENDERED_EVENT } from "./content-events"; import type { TextItemContentContext } from "./context"; @@ -19,26 +20,24 @@ export function CollapsibleTextContent({ view, context }: { view: MessageStreamT const update = () => { setOverflows(content.scrollHeight > userMessageCollapseHeight(content) + 1); }; - content.addEventListener(MESSAGE_CONTENT_RENDERED_EVENT, update); + const disposeRendered = listenDomEvent(content, MESSAGE_CONTENT_RENDERED_EVENT, update); update(); content.win.requestAnimationFrame(update); - return () => { - content.removeEventListener(MESSAGE_CONTENT_RENDERED_EVENT, update); - }; + return disposeRendered; }, [view.id, view.body, view.renderMode]); useEffect(() => { if (!overflows || !expanded) return; - const doc = collapseRef.current?.ownerDocument; - if (!doc) return; - const collapseOnOutsidePointer = (event: PointerEvent) => { - if (event.target instanceof Node && collapseRef.current?.contains(event.target)) return; - context.onDisclosureToggle?.("userMessageExpanded", view.id, false); - }; - doc.addEventListener("pointerdown", collapseOnOutsidePointer, true); - return () => { - doc.removeEventListener("pointerdown", collapseOnOutsidePointer, true); - }; + const collapse = collapseRef.current; + if (!collapse) return; + return listenOutsideDomEvent( + collapse, + "pointerdown", + () => { + context.onDisclosureToggle?.("userMessageExpanded", view.id, false); + }, + true, + ); }, [context, expanded, view.id, overflows]); return ( @@ -71,12 +70,22 @@ export function CollapsibleTextContent({ view, context }: { view: MessageStreamT interface TextContentProps { view: MessageStreamTextView; context: TextItemContentContext; - contentRef?: Ref; + contentRef?: Ref | undefined; collapsed?: boolean; } export function TextContent({ view, context, contentRef, collapsed = false }: TextContentProps): UiNode { - const rendersMarkdown = view.renderMode !== "text"; + if (view.renderMode === "text") { + return ( + + {view.body} + + ); + } + return ; +} + +function MarkdownTextContent({ view, context, contentRef, collapsed = false }: TextContentProps): UiNode { const text = view.body; const localRef = useRef(null); const contextRef = useRef(context); @@ -89,33 +98,59 @@ export function TextContent({ view, context, contentRef, collapsed = false }: Te const currentContext = contextRef.current; if (view.renderMode === "obsidianMarkdown") { currentContext.renderObsidianMarkdown(content, text); - } else if (view.renderMode === "streamMarkdown") { - currentContext.renderStreamMarkdown(content, text); } else { - content.textContent = text; + currentContext.renderStreamMarkdown(content, text); } }, [view.renderMode, text]); + return ( + { + localRef.current = element; + assignTextContentRef(contentRef, element); + }} + collapsed={collapsed} + markdown + /> + ); +} + +function TextContentContainer({ + children, + contentRef, + collapsed, + markdown = false, +}: { + children?: UiNode; + contentRef?: Ref | undefined; + collapsed: boolean; + markdown?: boolean; +}): UiNode { return (
{ - localRef.current = element; - if (typeof contentRef === "function") { - contentRef(element); - } else if (contentRef) { - contentRef.current = element; - } + assignTextContentRef(contentRef, element); }} className={[ "codex-panel__message-content", - rendersMarkdown ? "markdown-rendered" : "", + markdown ? "markdown-rendered" : "", collapsed ? "codex-panel__message-content--collapsed" : "", ] .filter(Boolean) .join(" ")} - /> + > + {children} +
); } +function assignTextContentRef(contentRef: Ref | undefined, element: HTMLDivElement | null): void { + if (typeof contentRef === "function") { + contentRef(element); + } else if (contentRef) { + contentRef.current = element; + } +} + function userMessageCollapseHeight(element: HTMLElement): number { const viewportHeight = element.win.innerHeight; if (viewportHeight <= 0) return USER_MESSAGE_COLLAPSE_HEIGHT_PX; diff --git a/src/features/chat/ui/message-stream/text.tsx b/src/features/chat/ui/message-stream/text.tsx index 5e8be574..aa963a43 100644 --- a/src/features/chat/ui/message-stream/text.tsx +++ b/src/features/chat/ui/message-stream/text.tsx @@ -1,7 +1,6 @@ import { Fragment, type ComponentChild as UiNode } from "preact"; import { useEffect, useRef } from "preact/hooks"; import { IconButton } from "../../../../shared/ui/components.obsidian"; -import { listenDomEvent } from "../../../../shared/ui/dom-events.dom"; import type { EditedFilesTextView, MentionedFileTextView, @@ -10,6 +9,7 @@ import type { TextItemDetailSectionView, } from "../../presentation/message-stream/text-view"; import type { TextItemActionContext, TextItemContext, TextItemDetailStateContext, TextItemMetadataContext } from "./context"; +import { closeMessageRoleMenuOnOutsidePointer } from "./message-stream.dom"; import { CollapsibleTextContent, TextContent } from "./text-content.dom"; export function textNode(view: MessageStreamTextView, context: TextItemContext): UiNode { @@ -46,13 +46,11 @@ function TextHeader({ view, context }: { view: MessageStreamTextView; context: T useEffect(() => { if (!forkMenuOpen) return; - const doc = roleRef.current?.ownerDocument; - if (!doc) return; - const closeOnOutsidePointer = (event: PointerEvent) => { - if (event.target instanceof Node && roleRef.current?.contains(event.target)) return; + const role = roleRef.current; + if (!role) return; + return closeMessageRoleMenuOnOutsidePointer(role, () => { context.onForkMenuToggle?.(null); - }; - return listenDomEvent(doc, "pointerdown", closeOnOutsidePointer, true); + }); }, [context, forkMenuOpen]); const copyAction = diff --git a/src/features/chat/ui/toolbar.dom.ts b/src/features/chat/ui/toolbar.dom.ts new file mode 100644 index 00000000..f8c9aeaf --- /dev/null +++ b/src/features/chat/ui/toolbar.dom.ts @@ -0,0 +1,33 @@ +export function focusToolbarRenameInput(input: HTMLInputElement | null): void { + if (!input) return; + if (input.ownerDocument.activeElement === input) return; + input.focus(); + input.select(); +} + +export interface ToolbarOutsidePointerHit { + insideToolbarPanel: boolean; + insideArchiveConfirm: boolean; +} + +export function toolbarOutsidePointerHit( + event: PointerEvent, + root: HTMLElement | null, + viewWindow: Window | null, +): ToolbarOutsidePointerHit { + const target = event.target; + const domWindow = viewWindow as (Window & { Element: typeof Element }) | null; + if (!root || !domWindow || !(target instanceof domWindow.Element)) { + return { insideToolbarPanel: false, insideArchiveConfirm: false }; + } + + const toolbarPanel = target.closest(".codex-panel__toolbar-primary, .codex-panel__toolbar-panel"); + if (!toolbarPanel || !root.contains(toolbarPanel)) { + return { insideToolbarPanel: false, insideArchiveConfirm: false }; + } + + return { + insideToolbarPanel: true, + insideArchiveConfirm: Boolean(target.closest(".codex-panel__archive-confirm")), + }; +} diff --git a/src/features/chat/ui/toolbar.tsx b/src/features/chat/ui/toolbar.tsx index f7daed0d..511fa9d3 100644 --- a/src/features/chat/ui/toolbar.tsx +++ b/src/features/chat/ui/toolbar.tsx @@ -2,6 +2,7 @@ import type { ButtonHTMLAttributes, ComponentChild as UiNode } from "preact"; import { useLayoutEffect, useRef } from "preact/hooks"; import { IconButton, ObsidianToolbarAction, type ObsidianToolbarActionProps } from "../../../shared/ui/components.obsidian"; import type { RateLimitSummary } from "../presentation/runtime/status"; +import { focusToolbarRenameInput } from "./toolbar.dom"; type ButtonProps = ButtonHTMLAttributes & { disabled?: boolean | undefined; @@ -417,12 +418,7 @@ function ThreadRenameRow({ thread, actions }: { thread: ToolbarThreadRow; action const generating = thread.rename?.generating ?? false; const draft = thread.rename?.draft ?? thread.title; useLayoutEffect(() => { - const input = inputRef.current; - if (!input) return; - if (input.ownerDocument.activeElement !== input) { - input.focus(); - input.select(); - } + focusToolbarRenameInput(inputRef.current); }, [draft]); return ( diff --git a/src/features/selection-rewrite/command.ts b/src/features/selection-rewrite/command.obsidian.ts similarity index 100% rename from src/features/selection-rewrite/command.ts rename to src/features/selection-rewrite/command.obsidian.ts diff --git a/src/features/selection-rewrite/model.ts b/src/features/selection-rewrite/model.ts index ccb85384..17820a53 100644 --- a/src/features/selection-rewrite/model.ts +++ b/src/features/selection-rewrite/model.ts @@ -1,6 +1,8 @@ import type { EditorPosition } from "obsidian"; import type { ReasoningEffort } from "../../domain/catalog/metadata"; +export type SelectionRewriteInstructionHistoryDirection = -1 | 1; + type SelectionRewriteStatus = SelectionRewriteState["status"]; const APPLY_CONTEXT_RADIUS = 1_000; diff --git a/src/features/selection-rewrite/popover.dom.tsx b/src/features/selection-rewrite/popover.dom.tsx index 55d1f8e3..f0ed8ff4 100644 --- a/src/features/selection-rewrite/popover.dom.tsx +++ b/src/features/selection-rewrite/popover.dom.tsx @@ -3,14 +3,20 @@ import type { TargetedKeyboardEvent, ComponentChild as UiNode } from "preact"; import { IconButton } from "../../shared/ui/components.obsidian"; import { DiffLineList, unifiedDiffDisplayLines } from "../../shared/ui/diff"; +import { listenDomEscapeKey, listenDomEvent, listenOutsideDomEvent } from "../../shared/ui/dom-events.dom"; import { isComposerSendKey, type SendShortcut } from "../../shared/ui/keyboard"; import { syncTextareaHeight } from "../../shared/ui/textarea-autogrow.measure"; -import { type TextareaCaretBoundaryDirection, textareaCursorAtVisualBoundary } from "../../shared/ui/textarea-caret.measure"; +import { textareaCursorAtVisualBoundary } from "../../shared/ui/textarea-caret.measure"; import { renderUiRoot, unmountUiRoot } from "../../shared/ui/ui-root.dom"; import { buildSelectionUnifiedDiff } from "./diff"; -import { canApplySelectionRewrite, type SelectionRewriteRuntimeSettings, type SelectionRewriteState } from "./model"; +import { + canApplySelectionRewrite, + type SelectionRewriteInstructionHistoryDirection, + type SelectionRewriteRuntimeSettings, + type SelectionRewriteState, +} from "./model"; -import { positionSelectionRewritePopover } from "./position"; +import { positionSelectionRewritePopover } from "./position.dom"; import { SelectionRewriteSession, type SelectionRewriteSessionStatus } from "./session"; const POPOVER_MARGIN = 8; @@ -62,26 +68,37 @@ export class SelectionRewritePopover { const elements = this.createElements(); this.elements = elements; - this.addDomListener(this.options.viewWindow, "resize", () => { - this.position(); - }); - this.addDomListener( - this.options.viewWindow, - "scroll", - () => { + this.addCleanup( + listenDomEvent(this.options.viewWindow, "resize", () => { this.position(); - }, - true, + }), ); - this.addDomListener(this.options.viewDocument, "keydown", (event) => { - if (event.key === "Escape") { + this.addCleanup( + listenDomEvent( + this.options.viewWindow, + "scroll", + () => { + this.position(); + }, + true, + ), + ); + this.addCleanup( + listenDomEscapeKey(this.options.viewDocument, (event) => { event.preventDefault(); this.cancel(); - } - }); - this.addDomListener(this.options.viewDocument, "pointerdown", (event) => { - if (!this.elements?.root.contains(event.target as Node | null)) this.cancel(); - }); + }), + ); + this.addCleanup( + listenOutsideDomEvent( + elements.root, + "pointerdown", + () => { + this.cancel(); + }, + true, + ), + ); this.session.setStatus(""); this.syncInstructionHeight(); @@ -246,35 +263,15 @@ export class SelectionRewritePopover { instruction.setSelectionRange(cursor, cursor); } - private addDomListener( - target: Window, - type: K, - callback: (event: WindowEventMap[K]) => void, - options?: boolean | AddEventListenerOptions, - ): void; - private addDomListener( - target: Document, - type: K, - callback: (event: DocumentEventMap[K]) => void, - options?: boolean | AddEventListenerOptions, - ): void; - private addDomListener( - target: Window | Document, - type: string, - callback: EventListener, - options?: boolean | AddEventListenerOptions, - ): void { - target.addEventListener(type, callback, options); - this.cleanups.push(() => { - target.removeEventListener(type, callback, options); - }); + private addCleanup(cleanup: Cleanup): void { + this.cleanups.push(cleanup); } } function selectionRewriteInstructionHistoryDirection( event: TargetedKeyboardEvent, instruction: HTMLTextAreaElement, -): TextareaCaretBoundaryDirection | null { +): SelectionRewriteInstructionHistoryDirection | null { if (event.isComposing || event.metaKey || event.altKey || event.shiftKey) return null; const direction = selectionRewriteInstructionHistoryKeyDirection(event); @@ -285,7 +282,7 @@ function selectionRewriteInstructionHistoryDirection( function selectionRewriteInstructionHistoryKeyDirection( event: TargetedKeyboardEvent, -): TextareaCaretBoundaryDirection | null { +): SelectionRewriteInstructionHistoryDirection | null { if (!event.ctrlKey) { if (event.key === "ArrowUp") return -1; if (event.key === "ArrowDown") return 1; @@ -299,7 +296,7 @@ function selectionRewriteInstructionHistoryKeyDirection( } function selectionRewriteInstructionCursorOnLogicalBoundary( - direction: TextareaCaretBoundaryDirection, + direction: SelectionRewriteInstructionHistoryDirection, instruction: HTMLTextAreaElement, ): boolean { if (instruction.selectionStart !== instruction.selectionEnd) return false; diff --git a/src/features/selection-rewrite/position.ts b/src/features/selection-rewrite/position.dom.ts similarity index 100% rename from src/features/selection-rewrite/position.ts rename to src/features/selection-rewrite/position.dom.ts diff --git a/src/features/selection-rewrite/session.ts b/src/features/selection-rewrite/session.ts index e50644a5..2438da97 100644 --- a/src/features/selection-rewrite/session.ts +++ b/src/features/selection-rewrite/session.ts @@ -1,5 +1,5 @@ -import type { TextareaCaretBoundaryDirection } from "../../shared/ui/textarea-caret.measure"; import { + type SelectionRewriteInstructionHistoryDirection, type SelectionRewriteLifecycleEvent, type SelectionRewriteRuntimeSettings, type SelectionRewriteState, @@ -141,7 +141,7 @@ export class SelectionRewriteSession { if (this.generationRun.kind === "running") this.generationRun.abortController.abort(); } - navigateInstructionHistory(direction: TextareaCaretBoundaryDirection): boolean { + navigateInstructionHistory(direction: SelectionRewriteInstructionHistoryDirection): boolean { if (selectionRewriteInstructionHistory.length === 0) return false; if (this.historyCursor === null) { diff --git a/src/features/threads-view/session.ts b/src/features/threads-view/session.ts index f1d9e761..a0b561cc 100644 --- a/src/features/threads-view/session.ts +++ b/src/features/threads-view/session.ts @@ -12,7 +12,7 @@ import type { OpenCodexPanelSnapshot } from "../../workspace/panel-coordinator"; import type { ThreadCatalogActiveReader, ThreadCatalogEventSink } from "../../workspace/thread-catalog"; import { createThreadOperations, type ThreadOperations } from "../threads/thread-operations"; import { createThreadTitleService, type ThreadTitleService } from "../threads/thread-title-service"; -import { renderThreadsViewShell, unmountThreadsViewShell } from "./shell.dom"; +import { isThreadsArchiveConfirmPointer, renderThreadsViewShell, unmountThreadsViewShell } from "./shell.dom"; import { type ThreadsGeneratingRenameState, type ThreadsRenameLifecycleEvent, @@ -319,12 +319,7 @@ export class ThreadsViewSession { private cancelArchiveConfirmOnOutsidePointer(event: PointerEvent): void { if (!this.archiveConfirmThreadId) return; - const target = event.target; - const viewWindow = this.viewWindow() as Window & { Element: typeof Element }; - if (target instanceof viewWindow.Element) { - const archiveConfirm = target.closest(".codex-panel-threads__archive-confirm"); - if (archiveConfirm && this.environment.root.contains(archiveConfirm)) return; - } + if (isThreadsArchiveConfirmPointer(event, this.environment.root, this.viewWindow())) return; this.archiveConfirmThreadId = null; this.render(); } diff --git a/src/features/threads-view/shell.dom.tsx b/src/features/threads-view/shell.dom.tsx index 1234f309..1781b53f 100644 --- a/src/features/threads-view/shell.dom.tsx +++ b/src/features/threads-view/shell.dom.tsx @@ -37,6 +37,21 @@ export function unmountThreadsViewShell(parent: HTMLElement | null): void { unmountUiRoot(parent); } +export function isThreadsArchiveConfirmPointer(event: PointerEvent, root: HTMLElement, viewWindow: Window): boolean { + const target = event.target; + const domWindow = viewWindow as Window & { Element: typeof Element }; + if (!(target instanceof domWindow.Element)) return false; + const archiveConfirm = target.closest(".codex-panel-threads__archive-confirm"); + return Boolean(archiveConfirm && root.contains(archiveConfirm)); +} + +function focusThreadsRenameInput(input: HTMLInputElement | null): void { + if (!input) return; + if (input.ownerDocument.activeElement === input) return; + input.focus(); + input.select(); +} + function ThreadsViewShell({ model, actions }: { model: ThreadsViewShellModel; actions: ThreadsViewShellActions }): UiNode { return ( <> @@ -181,12 +196,7 @@ function ArchiveModeButton({ function RenameRow({ row, actions, className }: { row: ThreadsRowModel; actions: ThreadsViewShellActions; className: string }): UiNode { const inputRef = useRef(null); useLayoutEffect(() => { - const input = inputRef.current; - if (!input) return; - if (input.ownerDocument.activeElement !== input) { - input.focus(); - input.select(); - } + focusThreadsRenameInput(inputRef.current); }, [row.rename.draft]); return ( diff --git a/src/features/threads-view/view.ts b/src/features/threads-view/view.obsidian.ts similarity index 100% rename from src/features/threads-view/view.ts rename to src/features/threads-view/view.obsidian.ts diff --git a/src/main.ts b/src/main.ts index 18d2b47f..32dbc58f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,10 +1,10 @@ import { Plugin } from "obsidian"; import { VIEW_TYPE_CODEX_PANEL, VIEW_TYPE_CODEX_THREADS, VIEW_TYPE_CODEX_TURN_DIFF } from "./constants"; -import { CodexChatView } from "./features/chat/host/view"; +import { CodexChatView } from "./features/chat/host/view.obsidian"; import { CodexChatTurnDiffView } from "./features/chat/ui/turn-diff/view.obsidian"; -import { registerSelectionRewriteCommand } from "./features/selection-rewrite/command"; -import { CodexThreadsView } from "./features/threads-view/view"; +import { registerSelectionRewriteCommand } from "./features/selection-rewrite/command.obsidian"; +import { CodexThreadsView } from "./features/threads-view/view.obsidian"; import { CodexPanelRuntime } from "./plugin-runtime"; import { type CodexPanelSettings, DEFAULT_SETTINGS, getVaultPath, normalizeSettings, settingsMatchStoredSettings } from "./settings/model"; import { CodexPanelSettingTab } from "./settings/tab.obsidian"; diff --git a/src/plugin-runtime.ts b/src/plugin-runtime.ts index 7bee24f7..ff6ee295 100644 --- a/src/plugin-runtime.ts +++ b/src/plugin-runtime.ts @@ -19,7 +19,7 @@ import type { import { CodexChatTurnDiffView } from "./features/chat/ui/turn-diff/view.obsidian"; import { openThreadPicker, type ThreadPickerHost } from "./features/thread-picker/modal.obsidian"; import type { ThreadsViewHost, ThreadsViewSettingsAccess } from "./features/threads-view/session"; -import { CodexThreadsView } from "./features/threads-view/view"; +import { CodexThreadsView } from "./features/threads-view/view.obsidian"; import type { CodexPanelSettingTabHost } from "./settings/host"; import { WorkspacePanelCoordinator } from "./workspace/panel-coordinator"; import { createThreadCatalog, type ThreadCatalog } from "./workspace/thread-catalog"; diff --git a/src/shared/lifecycle/abort-signal.ts b/src/shared/lifecycle/abort-signal.ts new file mode 100644 index 00000000..e6a179aa --- /dev/null +++ b/src/shared/lifecycle/abort-signal.ts @@ -0,0 +1,6 @@ +export function listenAbortSignal(signal: AbortSignal, listener: () => void): () => void { + signal.addEventListener("abort", listener, { once: true }); + return () => { + signal.removeEventListener("abort", listener); + }; +} diff --git a/src/shared/ui/components.obsidian.tsx b/src/shared/ui/components.obsidian.tsx index 794ab675..c9357b96 100644 --- a/src/shared/ui/components.obsidian.tsx +++ b/src/shared/ui/components.obsidian.tsx @@ -2,6 +2,8 @@ import { ButtonComponent, DropdownComponent, ExtraButtonComponent, setIcon, Text import type { ButtonHTMLAttributes, JSX, Ref, ComponentChild as UiNode } from "preact"; import { useLayoutEffect, useRef } from "preact/hooks"; +import { disposeDomListeners, listenDomEvent } from "./dom-events.dom"; + interface ObsidianIconProps { icon: string; className?: string; @@ -200,16 +202,15 @@ export function ObsidianExtraButton({ const stopPointerDown = (event: PointerEvent): void => { event.stopPropagation(); }; - button.extraSettingsEl.addEventListener("pointerdown", stopPointerDown); + const disposePointerDown = listenDomEvent(button.extraSettingsEl, "pointerdown", stopPointerDown); if (className) { for (const classPart of className.split(" ").filter(Boolean)) { button.extraSettingsEl.addClass(classPart); } } - return () => { - button.extraSettingsEl.removeEventListener("pointerdown", stopPointerDown); + return disposeDomListeners(disposePointerDown, () => { container.empty(); - }; + }); }, [className, icon, label, onClick]); return ; diff --git a/src/shared/ui/dom-events.dom.ts b/src/shared/ui/dom-events.dom.ts index 25edc005..aba0d931 100644 --- a/src/shared/ui/dom-events.dom.ts +++ b/src/shared/ui/dom-events.dom.ts @@ -16,6 +16,12 @@ export function listenDomEvent( listener: (event: HTMLElementEventMap[K]) => void, options?: boolean | AddEventListenerOptions, ): () => void; +export function listenDomEvent( + target: EventTarget, + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | AddEventListenerOptions, +): () => void; export function listenDomEvent( target: EventTarget, type: string, @@ -33,3 +39,29 @@ export function disposeDomListeners(...dispose: readonly (() => void)[]): () => for (const item of dispose) item(); }; } + +export function listenOutsideDomEvent( + root: HTMLElement, + type: K, + listener: (event: DocumentEventMap[K]) => void, + options?: boolean | AddEventListenerOptions, +): () => void { + const domWindow = root.ownerDocument.defaultView as (Window & { Node: typeof Node }) | null; + return listenDomEvent( + root.ownerDocument, + type, + (event) => { + const target = event.target; + const targetNode = domWindow && target instanceof domWindow.Node ? target : null; + if (targetNode && root.contains(targetNode)) return; + listener(event); + }, + options, + ); +} + +export function listenDomEscapeKey(target: Document, listener: (event: KeyboardEvent) => void): () => void { + return listenDomEvent(target, "keydown", (event) => { + if (event.key === "Escape") listener(event); + }); +} diff --git a/src/workspace/panel-coordinator.ts b/src/workspace/panel-coordinator.ts index 6ab6bd0e..6df6e2b5 100644 --- a/src/workspace/panel-coordinator.ts +++ b/src/workspace/panel-coordinator.ts @@ -3,7 +3,7 @@ import type { App, WorkspaceLeaf } from "obsidian"; import { VIEW_TYPE_CODEX_PANEL } from "../constants"; import { hasPendingRequests, pendingRequestCounts } from "../domain/pending-requests/aggregate"; import type { ChatWorkspacePanelSurface } from "../features/chat/host/contracts"; -import { CodexChatView } from "../features/chat/host/view"; +import { CodexChatView } from "../features/chat/host/view.obsidian"; import type { ChatPanelSnapshot } from "../features/chat/panel/snapshot"; type ThreadPanelTarget = diff --git a/tests/features/chat/application/composer/boundary-scroll.test.ts b/tests/features/chat/application/composer/boundary-scroll.test.ts index 5353ea07..e9cf0337 100644 --- a/tests/features/chat/application/composer/boundary-scroll.test.ts +++ b/tests/features/chat/application/composer/boundary-scroll.test.ts @@ -42,7 +42,7 @@ describe("composer boundary scroll shortcuts", () => { it("scrolls by page from any composer line for PageUp and PageDown", () => { expect(direction("PageUp", "first\nsecond", 9)).toEqual({ kind: "scroll-by", direction: -1, amount: "page" }); expect(direction("PageDown", "first\nsecond", 3)).toEqual({ kind: "scroll-by", direction: 1, amount: "page" }); - expect(direction("PageDown", "first\nsecond", 3, { selectionEnd: 8 })).toEqual({ + expect(direction("PageDown", "first\nsecond", 3, { cursorEnd: 8 })).toEqual({ kind: "scroll-by", direction: 1, amount: "page", @@ -58,7 +58,7 @@ describe("composer boundary scroll shortcuts", () => { it("scrolls to stream edges from any composer line for Home and End", () => { expect(direction("Home", "first\nsecond", 9)).toEqual({ kind: "scroll-to", edge: "start" }); expect(direction("End", "first\nsecond", 3)).toEqual({ kind: "scroll-to", edge: "end" }); - expect(direction("End", "first\nsecond", 3, { selectionEnd: 8 })).toEqual({ kind: "scroll-to", edge: "end" }); + expect(direction("End", "first\nsecond", 3, { cursorEnd: 8 })).toEqual({ kind: "scroll-to", edge: "end" }); }); it("keeps normal cursor movement away from composer edges", () => { @@ -79,7 +79,7 @@ describe("composer boundary scroll shortcuts", () => { }); it("ignores selections, composition, and modified arrow keys", () => { - expect(direction("ArrowUp", "first\nsecond", 3, { selectionEnd: 4 })).toBeNull(); + expect(direction("ArrowUp", "first\nsecond", 3, { cursorEnd: 4 })).toBeNull(); expect(direction("ArrowUp", "first\nsecond", 3, { isComposing: true })).toBeNull(); expect(direction("ArrowUp", "first\nsecond", 3, { shiftKey: true })).toBeNull(); expect(direction("ArrowUp", "first\nsecond", 3, { altKey: true })).toBeNull(); @@ -89,7 +89,7 @@ describe("composer boundary scroll shortcuts", () => { function direction( key: string, value: string, - selectionStart: number, + cursorStart: number, options: Partial<{ ctrlKey: boolean; metaKey: boolean; @@ -97,7 +97,7 @@ function direction( shiftKey: boolean; isComposing: boolean; repeat: boolean; - selectionEnd: number; + cursorEnd: number; visualBoundary: boolean | ((direction: -1 | 1) => boolean); }> = {}, ): ComposerBoundaryScrollAction | null { @@ -120,8 +120,8 @@ function direction( }, { value, - selectionStart, - selectionEnd: options.selectionEnd ?? selectionStart, + cursorStart, + cursorEnd: options.cursorEnd ?? cursorStart, }, visualOptions, ); diff --git a/tests/features/chat/host/session-graph.test.ts b/tests/features/chat/host/session-graph.test.ts index b13f5c12..af3fd79f 100644 --- a/tests/features/chat/host/session-graph.test.ts +++ b/tests/features/chat/host/session-graph.test.ts @@ -136,7 +136,7 @@ describe("createChatPanelSessionGraph actions", () => { }); it("starts a new thread from graph state and composer actions", async () => { - const focusComposer = vi.spyOn(ChatComposerController.prototype, "focus").mockImplementation(() => undefined); + const focusComposer = vi.spyOn(ChatComposerController.prototype, "focusComposer").mockImplementation(() => undefined); const refreshLiveState = vi.fn(); const requestWorkspaceLayoutSave = vi.fn(); const refreshTabHeader = vi.fn(); @@ -178,7 +178,7 @@ describe("createChatPanelSessionGraph actions", () => { }); it("does not clear the current thread while a turn is busy", async () => { - const focusComposer = vi.spyOn(ChatComposerController.prototype, "focus").mockImplementation(() => undefined); + const focusComposer = vi.spyOn(ChatComposerController.prototype, "focusComposer").mockImplementation(() => undefined); const { graph, stateStore } = sessionGraphFixture(); stateStore.dispatch({ type: "turn/started", @@ -294,7 +294,6 @@ describe("createChatPanelSessionGraph actions", () => { view: { panelRoot: () => panelRoot, viewWindow: () => window, - containsElement: (element) => panelRoot.contains(element), refreshTabHeader: vi.fn(), ...overrides.view, }, diff --git a/tests/features/chat/host/view-connection.test.ts b/tests/features/chat/host/view-connection.test.ts index 51ee6b62..8336bdf9 100644 --- a/tests/features/chat/host/view-connection.test.ts +++ b/tests/features/chat/host/view-connection.test.ts @@ -18,7 +18,7 @@ import { deferred, waitForAsyncWork } from "../../../support/async"; import { installObsidianDomShims } from "../../../support/dom"; type TestCodexChatHost = CodexChatHost; -let CodexChatView: typeof import("../../../../src/features/chat/host/view")["CodexChatView"]; +let CodexChatView: typeof import("../../../../src/features/chat/host/view.obsidian")["CodexChatView"]; interface TrackedView { view: { onClose(): Promise | void }; opened: boolean; @@ -115,7 +115,7 @@ describe("CodexChatView connection lifecycle", () => { let restoreDefaultMessageViewportMetrics: (() => void) | null = null; beforeAll(async () => { - ({ CodexChatView } = await import("../../../../src/features/chat/host/view")); + ({ CodexChatView } = await import("../../../../src/features/chat/host/view.obsidian")); }); beforeEach(() => { diff --git a/tests/features/chat/panel/toolbar-actions.test.ts b/tests/features/chat/panel/toolbar-actions.test.ts index 51583783..5f798139 100644 --- a/tests/features/chat/panel/toolbar-actions.test.ts +++ b/tests/features/chat/panel/toolbar-actions.test.ts @@ -35,9 +35,7 @@ describe("createToolbarPanelActions", () => { expect(stateStore.getState().ui.toolbarPanel).toBe("history"); actions.closeOnOutsidePointer({ - target: document.createElement("button"), - viewWindow: window, - contains: () => false, + hit: { insideToolbarPanel: false, insideArchiveConfirm: false }, renameEditing: false, }); diff --git a/tests/features/chat/ui/composer.test.ts b/tests/features/chat/ui/composer.test.ts index ba75fecb..50cc395c 100644 --- a/tests/features/chat/ui/composer.test.ts +++ b/tests/features/chat/ui/composer.test.ts @@ -3,13 +3,8 @@ import { h } from "preact"; import { describe, expect, it, vi } from "vitest"; import type { ComposerMetaViewModel } from "../../../../src/features/chat/ui/composer"; -import { - type ComposerCallbacks, - ComposerShell, - type ComposerSuggestion, - syncComposerHeight, -} from "../../../../src/features/chat/ui/composer"; -import { scrollComposerSuggestionIntoView } from "../../../../src/features/chat/ui/composer.dom"; +import { type ComposerCallbacks, ComposerShell, type ComposerSuggestion } from "../../../../src/features/chat/ui/composer"; +import { scrollComposerSuggestionIntoView, syncComposerHeight } from "../../../../src/features/chat/ui/composer.dom"; import { renderUiRoot } from "../../../../src/shared/ui/ui-root.dom"; import { waitForAsyncWork } from "../../../support/async"; import { changeInputValue, composerSuggestionScrollFixture, installObsidianDomShims } from "../../../support/dom"; diff --git a/tests/features/selection-rewrite/selection-rewrite-command.test.ts b/tests/features/selection-rewrite/selection-rewrite-command.test.ts index 8f0a9551..101f61d2 100644 --- a/tests/features/selection-rewrite/selection-rewrite-command.test.ts +++ b/tests/features/selection-rewrite/selection-rewrite-command.test.ts @@ -3,7 +3,7 @@ import { MarkdownView, TFile } from "obsidian"; import { describe, expect, it, vi } from "vitest"; -import { registerSelectionRewriteCommand } from "../../../src/features/selection-rewrite/command"; +import { registerSelectionRewriteCommand } from "../../../src/features/selection-rewrite/command.obsidian"; import type { SelectionRewritePopoverOptions } from "../../../src/features/selection-rewrite/popover.dom"; const popoverMock = vi.hoisted(() => { diff --git a/tests/features/selection-rewrite/selection-rewrite.test.ts b/tests/features/selection-rewrite/selection-rewrite.test.ts index 86057201..8b146778 100644 --- a/tests/features/selection-rewrite/selection-rewrite.test.ts +++ b/tests/features/selection-rewrite/selection-rewrite.test.ts @@ -20,7 +20,7 @@ import { } from "../../../src/features/selection-rewrite/model"; import { selectionRewriteOutputParseResultFromText } from "../../../src/features/selection-rewrite/output"; import { SelectionRewritePopover } from "../../../src/features/selection-rewrite/popover.dom"; -import { positionSelectionRewritePopover } from "../../../src/features/selection-rewrite/position"; +import { positionSelectionRewritePopover } from "../../../src/features/selection-rewrite/position.dom"; import { buildSelectionRewritePrompt } from "../../../src/features/selection-rewrite/prompt"; import * as selectionRewriteRunner from "../../../src/features/selection-rewrite/runner"; import { runSelectionRewrite } from "../../../src/features/selection-rewrite/runner"; diff --git a/tests/features/threads-view/view.test.ts b/tests/features/threads-view/view.test.ts index c347842c..1b6eff5a 100644 --- a/tests/features/threads-view/view.test.ts +++ b/tests/features/threads-view/view.test.ts @@ -564,7 +564,7 @@ function threadsHost(overrides: Record = {}) { } async function threadsView(host = threadsHost()) { - const { CodexThreadsView } = await import("../../../src/features/threads-view/view"); + const { CodexThreadsView } = await import("../../../src/features/threads-view/view.obsidian"); const containerEl = document.createElement("div"); return new CodexThreadsView( { diff --git a/tests/main.test.ts b/tests/main.test.ts index 0a8008cd..5b077290 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -6,7 +6,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { VIEW_TYPE_CODEX_PANEL, VIEW_TYPE_CODEX_THREADS } from "../src/constants"; import type { Thread } from "../src/domain/threads/model"; import type { CodexChatHost } from "../src/features/chat/host/contracts"; -import type { CodexChatView } from "../src/features/chat/host/view"; +import type { CodexChatView } from "../src/features/chat/host/view.obsidian"; import type CodexPanelPlugin from "../src/main"; import { DEFAULT_SETTINGS } from "../src/settings/model"; import { WorkspacePanelCoordinator } from "../src/workspace/panel-coordinator"; @@ -88,7 +88,7 @@ describe("CodexPanelPlugin workspace panel reconciliation", () => { it("loads and focuses a deferred restored panel before opening another panel", async () => { const restoredLeaf = leaf({ state: { threadId: "thread-1", threadTitle: "Restored thread" } }); const plugin = await pluginWithLeaves([restoredLeaf]); - const { CodexChatView } = await import("../src/features/chat/host/view"); + const { CodexChatView } = await import("../src/features/chat/host/view.obsidian"); restoredLeaf.loadIfDeferred.mockImplementation(async () => { restoredLeaf.view = chatView(CodexChatView, restoredLeaf); }); @@ -118,7 +118,7 @@ describe("CodexPanelPlugin workspace panel reconciliation", () => { }); it("focuses an already open thread before reusing an empty panel", async () => { - const { CodexChatView } = await import("../src/features/chat/host/view"); + const { CodexChatView } = await import("../src/features/chat/host/view.obsidian"); const openLeaf = leaf(); openLeaf.view = chatView(CodexChatView, openLeaf); const openView = openLeaf.view as CodexChatView; @@ -146,7 +146,7 @@ describe("CodexPanelPlugin workspace panel reconciliation", () => { }); it("reuses an idle empty panel before opening a new panel", async () => { - const { CodexChatView } = await import("../src/features/chat/host/view"); + const { CodexChatView } = await import("../src/features/chat/host/view.obsidian"); const busyLeaf = leaf(); busyLeaf.view = chatView(CodexChatView, busyLeaf); const busyView = busyLeaf.view as CodexChatView; @@ -182,7 +182,7 @@ describe("CodexPanelPlugin workspace panel reconciliation", () => { }); it("does not reuse an idle panel with a pending MCP elicitation as empty", async () => { - const { CodexChatView } = await import("../src/features/chat/host/view"); + const { CodexChatView } = await import("../src/features/chat/host/view.obsidian"); const pendingLeaf = leaf(); pendingLeaf.view = chatView(CodexChatView, pendingLeaf); const pendingView = pendingLeaf.view as CodexChatView; @@ -204,7 +204,7 @@ describe("CodexPanelPlugin workspace panel reconciliation", () => { }); it("opens picker Enter selections in the most recent panel when the thread is not already open", async () => { - const { CodexChatView } = await import("../src/features/chat/host/view"); + const { CodexChatView } = await import("../src/features/chat/host/view.obsidian"); const olderLeaf = leaf(); olderLeaf.view = chatView(CodexChatView, olderLeaf); const olderView = olderLeaf.view as CodexChatView; @@ -223,7 +223,7 @@ describe("CodexPanelPlugin workspace panel reconciliation", () => { }); it("opens picker Enter selections in the active Codex panel before the right-sidebar fallback", async () => { - const { CodexChatView } = await import("../src/features/chat/host/view"); + const { CodexChatView } = await import("../src/features/chat/host/view.obsidian"); const fallbackLeaf = leaf(); fallbackLeaf.view = chatView(CodexChatView, fallbackLeaf); const fallbackView = fallbackLeaf.view as CodexChatView; @@ -243,7 +243,7 @@ describe("CodexPanelPlugin workspace panel reconciliation", () => { }); it("focuses an already open thread before picker Enter overwrites the current panel", async () => { - const { CodexChatView } = await import("../src/features/chat/host/view"); + const { CodexChatView } = await import("../src/features/chat/host/view.obsidian"); const openLeaf = leaf(); openLeaf.view = chatView(CodexChatView, openLeaf); const openView = openLeaf.view as CodexChatView; @@ -266,7 +266,7 @@ describe("CodexPanelPlugin workspace panel reconciliation", () => { const restoredLeaf = leaf({ state: { threadId: "restored-thread", threadTitle: "Restored thread" } }); const plugin = await pluginWithLeaves([restoredLeaf]); (plugin.app.workspace.getMostRecentLeaf as ReturnType).mockReturnValue(restoredLeaf); - const { CodexChatView } = await import("../src/features/chat/host/view"); + const { CodexChatView } = await import("../src/features/chat/host/view.obsidian"); const view = chatView(CodexChatView, restoredLeaf); const openThread = vi.spyOn(view.surface, "openThread").mockResolvedValue(undefined); const focusThread = vi.spyOn(view.surface, "focusThread").mockResolvedValue(undefined); @@ -285,7 +285,7 @@ describe("CodexPanelPlugin workspace panel reconciliation", () => { const newLeaf = leaf(); const plugin = await pluginWithLeaves([]); (plugin.app.workspace.getRightLeaf as ReturnType).mockReturnValue(newLeaf); - const { CodexChatView } = await import("../src/features/chat/host/view"); + const { CodexChatView } = await import("../src/features/chat/host/view.obsidian"); const view = chatView(CodexChatView, newLeaf); newLeaf.setViewState.mockImplementation(async () => { newLeaf.view = view; @@ -302,7 +302,7 @@ describe("CodexPanelPlugin workspace panel reconciliation", () => { }); it("activates the active Codex panel instead of the first existing panel", async () => { - const { CodexChatView } = await import("../src/features/chat/host/view"); + const { CodexChatView } = await import("../src/features/chat/host/view.obsidian"); const firstLeaf = leaf(); firstLeaf.view = chatView(CodexChatView, firstLeaf); const firstView = firstLeaf.view as CodexChatView; @@ -327,8 +327,8 @@ describe("CodexPanelPlugin workspace panel reconciliation", () => { }); it("marks snapshots for the last focused Codex panel", async () => { - const { CodexChatView } = await import("../src/features/chat/host/view"); - const { CodexThreadsView } = await import("../src/features/threads-view/view"); + const { CodexChatView } = await import("../src/features/chat/host/view.obsidian"); + const { CodexThreadsView } = await import("../src/features/threads-view/view.obsidian"); const firstLeaf = leaf(); firstLeaf.view = chatView(CodexChatView, firstLeaf); vi.spyOn((firstLeaf.view as CodexChatView).surface, "openPanelSnapshot").mockReturnValue( @@ -367,7 +367,7 @@ describe("CodexPanelPlugin workspace panel reconciliation", () => { }); it("hydrates restored panels when Obsidian activates an open Codex tab", async () => { - const { CodexChatView } = await import("../src/features/chat/host/view"); + const { CodexChatView } = await import("../src/features/chat/host/view.obsidian"); const panelLeaf = leaf(); panelLeaf.view = chatView(CodexChatView, panelLeaf); const hydrateRestoredThread = vi.spyOn((panelLeaf.view as CodexChatView).surface, "hydrateRestoredThread").mockResolvedValue(undefined); @@ -390,7 +390,7 @@ describe("CodexPanelPlugin workspace panel reconciliation", () => { it("loads and hydrates the startup foreground Codex panel through workspace reconciliation", async () => { vi.useFakeTimers(); - const { CodexChatView } = await import("../src/features/chat/host/view"); + const { CodexChatView } = await import("../src/features/chat/host/view.obsidian"); const activeLeaf = leaf({ state: { threadId: "thread-1", threadTitle: "Restored thread" } }); const view = chatView(CodexChatView, activeLeaf); const hydrateRestoredThread = vi.spyOn(view.surface, "hydrateRestoredThread").mockResolvedValue(undefined); @@ -411,7 +411,7 @@ describe("CodexPanelPlugin workspace panel reconciliation", () => { it("hydrates the right sidebar Codex panel on startup when the active leaf is a note", async () => { vi.useFakeTimers(); - const { CodexChatView } = await import("../src/features/chat/host/view"); + const { CodexChatView } = await import("../src/features/chat/host/view.obsidian"); const restoredLeaf = leaf({ state: { threadId: "thread-1", threadTitle: "Restored thread" } }); const noteLeaf = nonPanelLeaf(); const view = chatView(CodexChatView, restoredLeaf); @@ -434,7 +434,7 @@ describe("CodexPanelPlugin workspace panel reconciliation", () => { }); it("initially marks the active Codex panel before focus events arrive", async () => { - const { CodexChatView } = await import("../src/features/chat/host/view"); + const { CodexChatView } = await import("../src/features/chat/host/view.obsidian"); const firstLeaf = leaf(); firstLeaf.view = chatView(CodexChatView, firstLeaf); vi.spyOn((firstLeaf.view as CodexChatView).surface, "openPanelSnapshot").mockReturnValue( @@ -454,7 +454,7 @@ describe("CodexPanelPlugin workspace panel reconciliation", () => { }); it("initially falls back to the most recent right sidebar Codex panel", async () => { - const { CodexChatView } = await import("../src/features/chat/host/view"); + const { CodexChatView } = await import("../src/features/chat/host/view.obsidian"); const recentLeaf = leaf(); recentLeaf.view = chatView(CodexChatView, recentLeaf); vi.spyOn((recentLeaf.view as CodexChatView).surface, "openPanelSnapshot").mockReturnValue( @@ -478,7 +478,7 @@ describe("CodexPanelPlugin workspace panel reconciliation", () => { const newLeaf = leaf(); const plugin = await pluginWithLeaves([]); (plugin.app.workspace.getRightLeaf as ReturnType).mockReturnValue(newLeaf); - const { CodexChatView } = await import("../src/features/chat/host/view"); + const { CodexChatView } = await import("../src/features/chat/host/view.obsidian"); const view = chatView(CodexChatView, newLeaf); newLeaf.setViewState.mockImplementation(async () => { newLeaf.view = view; @@ -495,7 +495,7 @@ describe("CodexPanelPlugin workspace panel reconciliation", () => { }); it("does not refresh shared thread lists after known archive mutations", async () => { - const { CodexChatView } = await import("../src/features/chat/host/view"); + const { CodexChatView } = await import("../src/features/chat/host/view.obsidian"); const connectedLeaf = leaf(); connectedLeaf.view = chatView(CodexChatView, connectedLeaf); const connectedView = connectedLeaf.view as CodexChatView; @@ -509,7 +509,7 @@ describe("CodexPanelPlugin workspace panel reconciliation", () => { }); it("closes matching chat panels only when archive notification requests it", async () => { - const { CodexChatView } = await import("../src/features/chat/host/view"); + const { CodexChatView } = await import("../src/features/chat/host/view.obsidian"); const restoredMatchingLeaf = leaf({ state: { threadId: "thread-1", threadTitle: "Restored" } }); const matchingLeaf = leaf(); matchingLeaf.view = chatView(CodexChatView, matchingLeaf); @@ -535,7 +535,7 @@ describe("CodexPanelPlugin workspace panel reconciliation", () => { }); it("does not refresh shared thread lists after known rename mutations", async () => { - const { CodexChatView } = await import("../src/features/chat/host/view"); + const { CodexChatView } = await import("../src/features/chat/host/view.obsidian"); const connectedLeaf = leaf(); connectedLeaf.view = chatView(CodexChatView, connectedLeaf); const connectedView = connectedLeaf.view as CodexChatView; @@ -549,7 +549,7 @@ describe("CodexPanelPlugin workspace panel reconciliation", () => { }); it("single-flights shared thread list refreshes and caches successful results", async () => { - const { CodexChatView } = await import("../src/features/chat/host/view"); + const { CodexChatView } = await import("../src/features/chat/host/view.obsidian"); const connectedLeaf = leaf(); connectedLeaf.view = chatView(CodexChatView, connectedLeaf); const connectedView = connectedLeaf.view as CodexChatView; @@ -582,7 +582,7 @@ describe("CodexPanelPlugin workspace panel reconciliation", () => { }); it("keeps shared thread list refreshes separate across app-server cache contexts", async () => { - const { CodexChatView } = await import("../src/features/chat/host/view"); + const { CodexChatView } = await import("../src/features/chat/host/view.obsidian"); const connectedLeaf = leaf(); connectedLeaf.view = chatView(CodexChatView, connectedLeaf); const connectedView = connectedLeaf.view as CodexChatView; @@ -622,7 +622,7 @@ describe("CodexPanelPlugin workspace panel reconciliation", () => { }); it("does not reuse a connected panel whose app-server context does not match the shared query", async () => { - const { CodexChatView } = await import("../src/features/chat/host/view"); + const { CodexChatView } = await import("../src/features/chat/host/view.obsidian"); const connectedLeaf = leaf(); connectedLeaf.view = chatView(CodexChatView, connectedLeaf); const connectedView = connectedLeaf.view as CodexChatView; @@ -644,7 +644,7 @@ describe("CodexPanelPlugin workspace panel reconciliation", () => { }); it("keeps the previous shared thread list when refresh fails", async () => { - const { CodexChatView } = await import("../src/features/chat/host/view"); + const { CodexChatView } = await import("../src/features/chat/host/view.obsidian"); const connectedLeaf = leaf(); connectedLeaf.view = chatView(CodexChatView, connectedLeaf); const connectedView = connectedLeaf.view as CodexChatView; @@ -664,7 +664,7 @@ describe("CodexPanelPlugin workspace panel reconciliation", () => { }); it("uses a short-lived client when the operation declares an unhandled server-request policy", async () => { - const { CodexChatView } = await import("../src/features/chat/host/view"); + const { CodexChatView } = await import("../src/features/chat/host/view.obsidian"); const connectedLeaf = leaf(); connectedLeaf.view = chatView(CodexChatView, connectedLeaf); const connectedView = connectedLeaf.view as CodexChatView; @@ -691,7 +691,7 @@ describe("CodexPanelPlugin workspace panel reconciliation", () => { }); it("refreshes shared thread lists from a remaining connected panel after the archived panel is detached", async () => { - const { CodexChatView } = await import("../src/features/chat/host/view"); + const { CodexChatView } = await import("../src/features/chat/host/view.obsidian"); const sourceLeaf = leaf(); sourceLeaf.view = chatView(CodexChatView, sourceLeaf); const sourceView = sourceLeaf.view as CodexChatView; diff --git a/tests/scripts/grit-policy.test.mjs b/tests/scripts/grit-policy.test.mjs index de7b779a..f5bbab5b 100644 --- a/tests/scripts/grit-policy.test.mjs +++ b/tests/scripts/grit-policy.test.mjs @@ -16,7 +16,9 @@ const projectPluginByName = new Map( ); const APP_SERVER_PROTOCOL_BOUNDARY_MESSAGE = "Source modules outside root src/app-server must use domain models and app-server services instead of app-server protocol modules. Chat turn-item conversion may consume turn protocol at its app-server boundary; feature state and UI must use Panel-owned models."; -const DOM_BOUNDARY_MESSAGE = "Keep imperative DOM writes and event wiring in files named with a .dom, .obsidian, or .measure suffix."; +const DOM_BOUNDARY_MESSAGE = + "Keep DOM reads, writes, measurements, hit-tests, focus, and event wiring in files named with a .dom, .obsidian, or .measure suffix."; +const DOM_EVENTS_IMPORT_MESSAGE = "Import DOM event listener helpers only from explicit .dom, .obsidian, or .measure bridge files."; let appServerBoundaryPolicyReportPromise; let renderingAndCssPolicyReportPromise; @@ -138,6 +140,7 @@ runner = new Runner(() => runner.stop()); it("keeps chat architecture policies behind their intended boundaries", async () => { const cwd = await tempBiomeWorkspace([ "no-implicit-dom-bridges.grit", + "no-dom-events-imports.grit", "no-preact-signal-imports.grit", "no-state-module-side-effects.grit", "no-ui-root-imports.grit", @@ -177,6 +180,7 @@ export const loadedSignals = signals; ` export function render(container: HTMLElement): void { const child = document.body; + const input = container as HTMLInputElement; container.createDiv(); container.append(child); container.setAttribute("role", "region"); @@ -189,6 +193,30 @@ export function render(container: HTMLElement): void { container.classList.remove("codex-panel-chat--stale"); container.classList.toggle("codex-panel-chat--busy", false); container.addEventListener("click", () => undefined); + container.querySelector(".codex-panel-chat"); + container.querySelectorAll(".codex-panel-chat"); + container.closest(".codex-panel-chat"); + container.contains(child); + container.getBoundingClientRect(); + container.focus(); + input.select(); + input.setSelectionRange(0, 0); + container.setCssProps({ display: "block" }); + container.scrollHeight; + container.scrollWidth; + container.clientHeight; + container.clientWidth; + container.offsetTop; + container.offsetHeight; + input.selectionStart; + input.selectionEnd; + input.selectionDirection; + const doc = container.ownerDocument; + doc.defaultView; + container.style.display; + container.style.color; + container.style.setProperty("display", "block"); + container.style.removeProperty("display"); child.remove(); } `.trimStart(), @@ -198,6 +226,7 @@ export function render(container: HTMLElement): void { ` export function render(container: HTMLElement): void { const child = document.body; + const input = container as HTMLInputElement; container.createDiv(); container.append(child); container.setAttribute("role", "region"); @@ -210,8 +239,40 @@ export function render(container: HTMLElement): void { container.classList.remove("codex-panel-chat--stale"); container.classList.toggle("codex-panel-chat--busy", false); container.addEventListener("click", () => undefined); + container.querySelector(".codex-panel-chat"); + container.querySelectorAll(".codex-panel-chat"); + container.closest(".codex-panel-chat"); + container.contains(child); + container.getBoundingClientRect(); + container.focus(); + input.select(); + input.setSelectionRange(0, 0); + container.setCssProps({ display: "block" }); + container.scrollHeight; + container.scrollWidth; + container.clientHeight; + container.clientWidth; + container.offsetTop; + container.offsetHeight; + input.selectionStart; + input.selectionEnd; + input.selectionDirection; + const doc = container.ownerDocument; + doc.defaultView; + container.style.display; + container.style.color; + container.style.setProperty("display", "block"); + container.style.removeProperty("display"); child.remove(); } +`.trimStart(), + ); + await writeFile( + path.join(cwd, "src/features/chat/ui/dom-event-escape.tsx"), + ` +import { listenDomEvent } from "../../../shared/ui/dom-events.dom"; + +export const listen = listenDomEvent; `.trimStart(), ); await writeFile( @@ -277,6 +338,7 @@ export function timestamp(): number { "src/shared/ui/components.tsx", "src/shared/ui/signal-escapes.tsx", "src/features/chat/ui/dom-bridge-escape.tsx", + "src/features/chat/ui/dom-event-escape.tsx", "src/features/chat/ui/dom-bridge.dom.tsx", "src/app-server/services/runtime-overrides.ts", "src/features/chat/ui/root-import.tsx", @@ -297,8 +359,9 @@ export function timestamp(): number { "Do not import @preact/signals from this module.", ]); expect(pluginMessages(report, "src/features/chat/ui/dom-bridge-escape.tsx")).toEqual( - Array.from({ length: 13 }, () => DOM_BOUNDARY_MESSAGE), + Array.from({ length: 37 }, () => DOM_BOUNDARY_MESSAGE), ); + expect(pluginMessages(report, "src/features/chat/ui/dom-event-escape.tsx")).toEqual([DOM_EVENTS_IMPORT_MESSAGE]); expect(pluginDiagnostics(report, "src/features/chat/ui/dom-bridge.dom.tsx")).toEqual([]); expect(pluginDiagnostics(report, "src/app-server/services/runtime-overrides.ts")).toEqual([]); expect(pluginMessages(report, "src/features/chat/ui/root-import.tsx")).toEqual([