From d749f4e031c063d2bdd5847b4984cd44d9b24035 Mon Sep 17 00:00:00 2001 From: murashit Date: Mon, 29 Jun 2026 16:36:24 +0900 Subject: [PATCH] Fix wikilink completion cursor restoration --- .../chat/panel/composer-controller.ts | 10 +- src/features/chat/ui/composer.dom.ts | 5 + src/features/chat/ui/composer.tsx | 15 +++ .../chat/panel/composer-controller.test.ts | 59 +++++++++ tests/features/chat/panel/shell.test.tsx | 121 ++++++++++++++++++ 5 files changed, 209 insertions(+), 1 deletion(-) diff --git a/src/features/chat/panel/composer-controller.ts b/src/features/chat/panel/composer-controller.ts index fba45073..c626e484 100644 --- a/src/features/chat/panel/composer-controller.ts +++ b/src/features/chat/panel/composer-controller.ts @@ -29,7 +29,7 @@ import { } from "../application/composer/wikilink-context"; import type { ChatAction, ChatState } from "../application/state/root-reducer"; import type { ChatStateStore } from "../application/state/store"; -import type { ComposerCallbacks, ComposerShellProps } from "../ui/composer"; +import type { ComposerCallbacks, ComposerPendingSelection, ComposerShellProps } from "../ui/composer"; import { syncComposerHeight } from "../ui/composer.dom"; import { applyComposerInsertionToElement, @@ -81,6 +81,7 @@ export class ChatComposerController { private selectionContextSnapshots: SelectionContextReference[] = []; private preservedSelectionContextSnapshots: readonly SelectionContextReference[] | null = null; private preservedAttachments: readonly ComposerAttachment[] | null = null; + private pendingSelection: ComposerPendingSelection | null = null; constructor(private readonly options: ChatComposerControllerOptions) {} @@ -106,6 +107,8 @@ export class ChatComposerController { normalPlaceholder: projection.placeholder, suggestions: state.composer.suggestions, selectedSuggestionIndex: state.composer.suggestSelected, + pendingSelection: this.pendingSelection, + onPendingSelectionApplied: this.clearPendingSelection, callbacks: this.composerCallbacks(actions), meta: projection.meta, onComposer: this.setComposerElement, @@ -324,11 +327,16 @@ export class ChatComposerController { this.pruneActiveNoteContextSnapshots(insertion.value); this.pruneSelectionContextSnapshots(insertion.value); + this.pendingSelection = { value: insertion.value, cursor: insertion.cursor }; this.dispatch({ type: "composer/draft-set", draft: insertion.value, clearSuggestions: true }); this.options.onDraftChange(); applyComposerInsertionToElement(this.composer, insertion.cursor); } + private readonly clearPendingSelection = (): void => { + this.pendingSelection = null; + }; + private clearSuggestions(): void { this.dispatchSuggestions({ type: "composer/suggestions-set", suggestions: [], selected: 0 }); } diff --git a/src/features/chat/ui/composer.dom.ts b/src/features/chat/ui/composer.dom.ts index 2ed271d5..da748234 100644 --- a/src/features/chat/ui/composer.dom.ts +++ b/src/features/chat/ui/composer.dom.ts @@ -50,6 +50,11 @@ export function restoreComposerSelection(composer: HTMLTextAreaElement | null, s composer.setSelectionRange(selection.start, selection.end, selection.direction); } +export function restoreComposerCursor(composer: HTMLTextAreaElement | null, cursor: number): void { + if (!composer) return; + composer.setSelectionRange(cursor, cursor); +} + export function observeComposerMetaStatusOverflow(status: HTMLElement): () => void { const win = status.win; let frame = 0; diff --git a/src/features/chat/ui/composer.tsx b/src/features/chat/ui/composer.tsx index 13cb1e07..00f7ee08 100644 --- a/src/features/chat/ui/composer.tsx +++ b/src/features/chat/ui/composer.tsx @@ -9,6 +9,7 @@ import { observeComposerMetaStatusOverflow, preserveComposerSelection, renderComposerMetaIcon, + restoreComposerCursor, restoreComposerSelection, scrollComposerSuggestionIntoView, syncComposerHeight, @@ -24,6 +25,11 @@ export interface ComposerSuggestion { suffixOnInsert?: string; } +export interface ComposerPendingSelection { + value: string; + cursor: number; +} + export interface ComposerMetaViewModel { fatal: string | null; context: ComposerContextMeterViewModel; @@ -86,6 +92,8 @@ export interface ComposerShellProps { meta: ComposerMetaViewModel; suggestions: readonly ComposerSuggestion[]; selectedSuggestionIndex: number; + pendingSelection?: ComposerPendingSelection | null; + onPendingSelectionApplied?: () => void; callbacks: ComposerCallbacks; onComposer: (composer: HTMLTextAreaElement | null) => void; } @@ -99,6 +107,8 @@ export function ComposerShell({ meta, suggestions, selectedSuggestionIndex, + pendingSelection = null, + onPendingSelectionApplied, callbacks, onComposer, }: ComposerShellProps): UiNode { @@ -132,6 +142,11 @@ export function ComposerShell({ previousDraftRef.current = draft; restoreComposerSelection(composerRef.current, preservedSelection); }); + useLayoutEffect(() => { + if (!pendingSelection) return; + if (pendingSelection.value === draft) restoreComposerCursor(composerRef.current, pendingSelection.cursor); + onPendingSelectionApplied?.(); + }, [draft, pendingSelection, onPendingSelectionApplied]); const sendMode = composerSendMode(busy, canInterrupt, draft); const normalizedSelectedSuggestionIndex = suggestions.length === 0 ? 0 : Math.min(selectedSuggestionIndex, suggestions.length - 1); const selectedSuggestionId = suggestions.length > 0 ? composerSuggestionOptionId(viewId, normalizedSelectedSuggestionIndex) : undefined; diff --git a/tests/features/chat/panel/composer-controller.test.ts b/tests/features/chat/panel/composer-controller.test.ts index 05d57f11..3b52c40b 100644 --- a/tests/features/chat/panel/composer-controller.test.ts +++ b/tests/features/chat/panel/composer-controller.test.ts @@ -102,6 +102,65 @@ describe("ChatComposerController", () => { expect(parent.querySelector(".codex-panel__composer-suggestion")?.textContent).toContain("/"); }); + it("keeps Tab wikilink insertion before closing brackets while Enter lands after them", () => { + const stateStore = createChatStateStore(); + const parent = document.createElement("div"); + const notes = [ + { + basename: "Beta Note", + displayName: "Beta Note", + path: "topics/Beta Note.md", + mtime: 30, + linktext: "Beta Note", + headings: [{ heading: "Overview", linkHeading: "Overview", level: 1 }], + recentIndex: null, + }, + ]; + let controller: ChatComposerController | null = null; + const renderShell = vi.fn(() => { + if (!controller) throw new Error("Expected controller."); + renderComposerController(parent, controller, stateStore); + }); + controller = new ChatComposerController({ + noteCandidateProvider: noteProvider({ candidates: () => notes }), + contextReferenceProvider: contextProvider(), + sourcePath: () => "", + stateStore, + viewId: "view", + sendShortcut: () => "enter", + scrollThreadFromComposerEdges: () => false, + threadScrollFromComposer: vi.fn(), + canInterrupt: (_state) => false, + composerProjection: defaultComposerProjection, + currentModelForSuggestions: () => null, + togglePlan: vi.fn(), + toggleAutoReview: vi.fn(), + toggleFast: vi.fn(), + onDraftChange: vi.fn(), + onHeightChange: vi.fn(), + }); + stateStore.subscribe(renderShell); + + renderShell(); + setTextAreaValue(composer(parent), "[[bet"); + composer(parent).setSelectionRange(5, 5); + composer(parent).dispatchEvent(new Event("input", { bubbles: true })); + composer(parent).dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Tab" })); + composer(parent).dispatchEvent(new KeyboardEvent("keyup", { bubbles: true, key: "Tab" })); + + expect(composer(parent).value).toBe("[[Beta Note]]"); + expect(composer(parent).selectionStart).toBe("[[Beta Note".length); + + setTextAreaValue(composer(parent), "[[bet"); + composer(parent).setSelectionRange(5, 5); + composer(parent).dispatchEvent(new Event("input", { bubbles: true })); + composer(parent).dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" })); + composer(parent).dispatchEvent(new KeyboardEvent("keyup", { bubbles: true, key: "Enter" })); + + expect(composer(parent).value).toBe("[[Beta Note]]"); + expect(composer(parent).selectionStart).toBe("[[Beta Note]]".length); + }); + it("saves pasted images, inserts an Obsidian embed, and sends a local image attachment", async () => { const stateStore = createChatStateStore(); const parent = document.createElement("div"); diff --git a/tests/features/chat/panel/shell.test.tsx b/tests/features/chat/panel/shell.test.tsx index 6a96be68..68a4483d 100644 --- a/tests/features/chat/panel/shell.test.tsx +++ b/tests/features/chat/panel/shell.test.tsx @@ -2,9 +2,13 @@ import { act } from "preact/test-utils"; import { describe, expect, it, vi } from "vitest"; +import type { ComposerContextReferenceProvider } from "../../../../src/features/chat/application/composer/context-references"; +import type { NoteCandidateProvider } from "../../../../src/features/chat/application/composer/note-context"; import { messageStreamItems } from "../../../../src/features/chat/application/state/message-stream"; import { createChatStateStore } from "../../../../src/features/chat/application/state/store"; +import { ChatComposerController } from "../../../../src/features/chat/panel/composer-controller"; import { type ChatPanelShellParts, renderChatPanelShell, unmountChatPanelShell } from "../../../../src/features/chat/panel/shell.dom"; +import type { ChatPanelComposerShellState } from "../../../../src/features/chat/panel/shell-state"; import type { ChatPanelComposerSurface } from "../../../../src/features/chat/panel/surface/composer-projection"; import type { ChatPanelGoalSurface } from "../../../../src/features/chat/panel/surface/goal-projection"; import type { ChatPanelToolbarSurface } from "../../../../src/features/chat/panel/surface/toolbar-projection"; @@ -67,6 +71,69 @@ describe("ChatPanelShell", () => { }); }); + it("keeps Tab wikilink insertion before closing brackets through shell signal updates", async () => { + const store = createChatStateStore(); + const container = document.createElement("div"); + document.body.appendChild(container); + const notes = [ + { + basename: "Beta Note", + displayName: "Beta Note", + path: "topics/Beta Note.md", + mtime: 30, + linktext: "Beta Note", + headings: [{ heading: "Overview", linkHeading: "Overview", level: 1 }], + recentIndex: null, + }, + ]; + const parts = shellParts(); + parts.composer.presenter = new ChatComposerController({ + noteCandidateProvider: noteProvider({ candidates: () => notes }), + contextReferenceProvider: contextProvider(), + sourcePath: () => "", + stateStore: store, + viewId: "view", + sendShortcut: () => "enter", + scrollThreadFromComposerEdges: () => false, + threadScrollFromComposer: vi.fn(), + canInterrupt: (_state) => false, + composerProjection: composerProjectionFixture, + currentModelForSuggestions: () => null, + togglePlan: vi.fn(), + toggleAutoReview: vi.fn(), + toggleFast: vi.fn(), + onDraftChange: vi.fn(), + onHeightChange: vi.fn(), + }); + + await act(async () => { + renderChatPanelShell(container, { ...shellProps(store), parts }); + await settleShellEffects(); + }); + + await act(async () => { + const input = composer(container); + setTextAreaValue(input, "[[bet"); + input.setSelectionRange(5, 5); + input.dispatchEvent(new Event("input", { bubbles: true })); + await settleShellEffects(); + }); + + await act(async () => { + const input = composer(container); + input.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Tab" })); + input.dispatchEvent(new KeyboardEvent("keyup", { bubbles: true, key: "Tab" })); + await settleShellEffects(); + }); + + expect(composer(container).value).toBe("[[Beta Note]]"); + expect(composer(container).selectionStart).toBe("[[Beta Note".length); + + await act(async () => { + unmountChatPanelShell(container); + }); + }); + it("keeps composer rendering off message stream updates until turn presence changes", async () => { const store = createChatStateStore(); const container = document.createElement("div"); @@ -408,6 +475,60 @@ function shellParts( }; } +function composer(container: HTMLElement): HTMLTextAreaElement { + const input = container.querySelector(".codex-panel__region--composer textarea"); + if (!input) throw new Error("Expected composer input."); + return input; +} + +function setTextAreaValue(textarea: HTMLTextAreaElement, value: string): void { + const descriptor = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value"); + if (!descriptor?.set) throw new Error("Missing textarea value setter."); + descriptor.set.call(textarea, value); +} + +function noteProvider(overrides: Partial = {}): NoteCandidateProvider { + return { + candidates: () => [], + resolveMention: () => null, + dispose: vi.fn(), + ...overrides, + }; +} + +function contextProvider( + contextReferences: ComposerContextReferenceProvider["contextReferences"] = () => ({ activeNote: null, selection: null }), +): ComposerContextReferenceProvider { + return { + contextReferences, + dispose: vi.fn(), + }; +} + +function composerProjectionFixture(_state: ChatPanelComposerShellState) { + return { + placeholder: "Ask Codex to work on this task...", + meta: { + fatal: null, + context: { + cells: [ + { text: "⣀", placeholder: true }, + { text: "⣀", placeholder: true }, + { text: "⣀", placeholder: true }, + { text: "⣀", placeholder: true }, + ], + percent: "--%", + }, + statusSummary: "Context unavailable, plan off, auto-review off, fast off, model default, reasoning effort default", + model: "default", + effort: null, + planActive: false, + autoReviewActive: false, + fastActive: false, + }, + }; +} + const testMessageStreamContext: MessageStreamContext = { activeThreadId: "thread", workspaceRoot: "/vault",