Fix wikilink completion cursor restoration

This commit is contained in:
murashit 2026-06-29 16:36:24 +09:00
parent e6a9b7d2b2
commit d749f4e031
5 changed files with 209 additions and 1 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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<HTMLTextAreaElement>(".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> = {}): 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",