mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Enforce explicit DOM bridge boundaries
This commit is contained in:
parent
318ffb4532
commit
3471abb91e
52 changed files with 722 additions and 407 deletions
13
biome.jsonc
13
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"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
16
scripts/lint/no-dom-events-imports.grit
Normal file
16
scripts/lint/no-dom-events-imports.grit
Normal 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")
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -57,7 +57,6 @@ export interface ChatPanelEnvironment {
|
|||
view: {
|
||||
panelRoot: () => HTMLElement | null;
|
||||
viewWindow: () => Window | null;
|
||||
containsElement: (element: Element) => boolean;
|
||||
refreshTabHeader: () => void;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ export function createTurnBundle(host: ChatPanelTurnHost, input: ChatPanelTurnIn
|
|||
responder: inboundHandler,
|
||||
composerHasFocus: () => composerController.hasFocus(),
|
||||
focusComposer: () => {
|
||||
composerController.focus();
|
||||
composerController.focusComposer();
|
||||
},
|
||||
refreshLiveState,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
57
src/features/chat/panel/composer-controller.dom.ts
Normal file
57
src/features/chat/panel/composer-controller.dom.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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[] {
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
55
src/features/chat/ui/goal.dom.ts
Normal file
55
src/features/chat/ui/goal.dom.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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`;
|
||||
|
|
|
|||
|
|
@ -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>();
|
||||
|
|
|
|||
48
src/features/chat/ui/message-stream/message-stream.dom.ts
Normal file
48
src/features/chat/ui/message-stream/message-stream.dom.ts
Normal 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("");
|
||||
});
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
33
src/features/chat/ui/toolbar.dom.ts
Normal file
33
src/features/chat/ui/toolbar.dom.ts
Normal 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")),
|
||||
};
|
||||
}
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
6
src/shared/lifecycle/abort-signal.ts
Normal file
6
src/shared/lifecycle/abort-signal.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export function listenAbortSignal(signal: AbortSignal, listener: () => void): () => void {
|
||||
signal.addEventListener("abort", listener, { once: true });
|
||||
return () => {
|
||||
signal.removeEventListener("abort", listener);
|
||||
};
|
||||
}
|
||||
|
|
@ -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} />;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
Loading…
Reference in a new issue