Enforce explicit DOM bridge boundaries

This commit is contained in:
murashit 2026-06-27 11:36:00 +09:00
parent 318ffb4532
commit 3471abb91e
52 changed files with 722 additions and 407 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,3 +1,4 @@
import { listenAbortSignal } from "../../shared/lifecycle/abort-signal";
import type { ModelMetadataClient } from "../catalog";
import {
AppServerClient,
@ -281,9 +282,9 @@ function rejectOnAbort<T>(promise: Promise<T>, 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();
});
});
}

View file

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

View file

@ -57,7 +57,6 @@ export interface ChatPanelEnvironment {
view: {
panelRoot: () => HTMLElement | null;
viewWindow: () => Window | null;
containsElement: (element: Element) => boolean;
refreshTabHeader: () => void;
};
}

View file

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

View file

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

View file

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

View file

@ -96,7 +96,7 @@ export function createTurnBundle(host: ChatPanelTurnHost, input: ChatPanelTurnIn
responder: inboundHandler,
composerHasFocus: () => composerController.hasFocus(),
focusComposer: () => {
composerController.focus();
composerController.focusComposer();
},
refreshLiveState,
});

View file

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

View file

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

View file

@ -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[] {

View file

@ -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 () => {

View file

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

View file

@ -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")) {

View file

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

View file

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

View file

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

View file

@ -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<MessageFlowRuntime, () => void>();

View file

@ -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<HTMLElement>(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<HTMLInputElement>("input[data-mcp-elicitation-field]")).find(
(candidate) => candidate.dataset["mcpElicitationField"] === fieldId,
);
input?.setCustomValidity(message);
}
return form.reportValidity();
}
function clearMcpElicitationCustomValidity(form: HTMLFormElement): void {
form.querySelectorAll<HTMLInputElement>(".codex-panel__mcp-elicitation-checkbox").forEach((input) => {
input.setCustomValidity("");
});
}

View file

@ -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<HTMLElement>(selector);
if (!target) continue;
target.focus({ preventScroll: true });
return;
}
}
function McpElicitationCard({
elicitation,
mcpElicitationDrafts,
@ -175,25 +156,15 @@ function validateMcpElicitationForm(
elicitation: PendingMcpElicitationViewModel,
drafts: ReadonlyMap<string, string>,
): 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<HTMLInputElement>("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<HTMLInputElement>(".codex-panel__mcp-elicitation-checkbox").forEach((input) => {
input.setCustomValidity("");
});
return applyMcpElicitationFormValidity(form, messages);
}
function mcpElicitationMultiSelectValidationMessage(field: PendingMcpElicitationFieldViewModel, selectedCount: number): string | null {

View file

@ -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<HTMLDivElement>;
contentRef?: Ref<HTMLDivElement> | undefined;
collapsed?: boolean;
}
export function TextContent({ view, context, contentRef, collapsed = false }: TextContentProps): UiNode {
const rendersMarkdown = view.renderMode !== "text";
if (view.renderMode === "text") {
return (
<TextContentContainer contentRef={contentRef} collapsed={collapsed}>
{view.body}
</TextContentContainer>
);
}
return <MarkdownTextContent view={view} context={context} contentRef={contentRef} collapsed={collapsed} />;
}
function MarkdownTextContent({ view, context, contentRef, collapsed = false }: TextContentProps): UiNode {
const text = view.body;
const localRef = useRef<HTMLDivElement | null>(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 (
<TextContentContainer
contentRef={(element) => {
localRef.current = element;
assignTextContentRef(contentRef, element);
}}
collapsed={collapsed}
markdown
/>
);
}
function TextContentContainer({
children,
contentRef,
collapsed,
markdown = false,
}: {
children?: UiNode;
contentRef?: Ref<HTMLDivElement> | undefined;
collapsed: boolean;
markdown?: boolean;
}): UiNode {
return (
<div
ref={(element) => {
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}
</div>
);
}
function assignTextContentRef(contentRef: Ref<HTMLDivElement> | 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;

View file

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

View file

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

View file

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

View file

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

View file

@ -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<K extends keyof WindowEventMap>(
target: Window,
type: K,
callback: (event: WindowEventMap[K]) => void,
options?: boolean | AddEventListenerOptions,
): void;
private addDomListener<K extends keyof DocumentEventMap>(
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<HTMLTextAreaElement>,
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<HTMLTextAreaElement>,
): 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;

View file

@ -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) {

View file

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

View file

@ -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<HTMLInputElement | null>(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 (

View file

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

View file

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

View file

@ -0,0 +1,6 @@
export function listenAbortSignal(signal: AbortSignal, listener: () => void): () => void {
signal.addEventListener("abort", listener, { once: true });
return () => {
signal.removeEventListener("abort", listener);
};
}

View file

@ -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 <span ref={ref} />;

View file

@ -16,6 +16,12 @@ export function listenDomEvent<K extends keyof HTMLElementEventMap>(
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<K extends keyof DocumentEventMap>(
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);
});
}

View file

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

View file

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

View file

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

View file

@ -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> | 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(() => {

View file

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

View file

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

View file

@ -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(() => {

View file

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

View file

@ -564,7 +564,7 @@ function threadsHost(overrides: Record<string, unknown> = {}) {
}
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(
{

View file

@ -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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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;

View file

@ -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([