mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Fix wikilink completion cursor restoration
This commit is contained in:
parent
e6a9b7d2b2
commit
d749f4e031
5 changed files with 209 additions and 1 deletions
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue