mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 17:30:31 +00:00
1520 lines
61 KiB
TypeScript
1520 lines
61 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { h } from "preact";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import type { SkillMetadata } from "../../../../src/domain/catalog/metadata";
|
|
import type { ComposerAttachment, ComposerAttachmentHandler } from "../../../../src/features/chat/application/composer/attachments";
|
|
import type {
|
|
ComposerContextReferenceProvider,
|
|
ComposerContextReferences,
|
|
} from "../../../../src/features/chat/application/composer/context-references";
|
|
import type { NoteCandidateProvider } from "../../../../src/features/chat/application/composer/note-context";
|
|
import type { ChatStateStore } from "../../../../src/features/chat/application/state/store";
|
|
import { createChatStateStore } from "../../../../src/features/chat/application/state/store";
|
|
import { threadStreamItems } from "../../../../src/features/chat/application/state/thread-stream";
|
|
import { pendingWebSubmissionItem } from "../../../../src/features/chat/application/turns/web-submission";
|
|
import type { ThreadStreamItem } from "../../../../src/features/chat/domain/thread-stream/items";
|
|
import { ChatComposerController, type ChatComposerRenderActions } from "../../../../src/features/chat/panel/composer-controller";
|
|
import type { ChatPanelComposerModel } from "../../../../src/features/chat/panel/shell-selectors";
|
|
import { ComposerShell } from "../../../../src/features/chat/ui/composer";
|
|
import { renderUiRoot, unmountUiRoot } from "../../../../src/shared/dom/preact-root.dom";
|
|
import { deferred } from "../../../support/async";
|
|
import { installObsidianDomShims } from "../../../support/dom";
|
|
import { composerModelFromChatState } from "../support/shell-selectors";
|
|
import { chatStateFixture, chatStateWith } from "../support/state";
|
|
|
|
installObsidianDomShims();
|
|
|
|
const renderedComposerParents = new Set<HTMLElement>();
|
|
const composerControllerTestCleanups: (() => void)[] = [];
|
|
|
|
afterEach(() => {
|
|
while (composerControllerTestCleanups.length > 0) composerControllerTestCleanups.pop()?.();
|
|
for (const parent of renderedComposerParents) unmountUiRoot(parent);
|
|
renderedComposerParents.clear();
|
|
});
|
|
|
|
function renderComposerController(
|
|
parent: HTMLElement,
|
|
controller: ChatComposerController,
|
|
stateStore: ChatStateStore,
|
|
actions: ChatComposerRenderActions = { submit: vi.fn() },
|
|
): void {
|
|
renderedComposerParents.add(parent);
|
|
renderUiRoot(parent, h(ComposerShell, controller.renderState(composerModelFromChatState(stateStore.getState()), actions)));
|
|
}
|
|
|
|
function trackComposerControllerTestCleanup(cleanup: () => void): void {
|
|
composerControllerTestCleanups.push(cleanup);
|
|
}
|
|
|
|
function resumeComposerThread(stateStore: ChatStateStore, threadId: string): void {
|
|
stateStore.dispatch({
|
|
type: "active-thread/resumed",
|
|
approvalPolicyKnown: true,
|
|
sandboxPolicyKnown: true,
|
|
permissionProfileKnown: true,
|
|
approvalPolicy: null,
|
|
sandboxPolicy: null,
|
|
activePermissionProfile: null,
|
|
thread: {
|
|
id: threadId,
|
|
preview: "",
|
|
name: null,
|
|
archived: false,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
provenance: { kind: "interactive" },
|
|
},
|
|
cwd: "/vault",
|
|
model: null,
|
|
reasoningEffort: null,
|
|
serviceTier: null,
|
|
approvalsReviewer: null,
|
|
});
|
|
}
|
|
|
|
type ComposerControllerOptions = ConstructorParameters<typeof ChatComposerController>[0];
|
|
|
|
function composerControllerFixture(
|
|
options: { stateStore?: ChatStateStore; controller?: Partial<ComposerControllerOptions>; renderActions?: ChatComposerRenderActions } = {},
|
|
): {
|
|
controller: ChatComposerController;
|
|
parent: HTMLElement;
|
|
renderShell: () => void;
|
|
stateStore: ChatStateStore;
|
|
} {
|
|
const stateStore = options.stateStore ?? createChatStateStore();
|
|
const parent = document.createElement("div");
|
|
const controller = new ChatComposerController({
|
|
noteCandidateProvider: noteProvider(),
|
|
contextReferenceProvider: contextProvider(),
|
|
sourcePath: () => "",
|
|
viewId: "view",
|
|
referenceActiveNoteOnSend: () => false,
|
|
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(),
|
|
canFocus: () => true,
|
|
...options.controller,
|
|
stateStore,
|
|
});
|
|
const renderShell = vi.fn(() => renderComposerController(parent, controller, stateStore, options.renderActions));
|
|
trackComposerControllerTestCleanup(stateStore.subscribe(renderShell));
|
|
return { controller, parent, renderShell, stateStore };
|
|
}
|
|
|
|
describe("ChatComposerController", () => {
|
|
it("focuses only while its panel is foreground unless workspace publication forces it", () => {
|
|
let foreground = false;
|
|
const { controller, parent, renderShell } = composerControllerFixture({
|
|
controller: { canFocus: () => foreground },
|
|
});
|
|
document.body.append(parent);
|
|
trackComposerControllerTestCleanup(() => parent.remove());
|
|
renderShell();
|
|
const composer = parent.querySelector("textarea");
|
|
if (!(composer instanceof HTMLTextAreaElement)) throw new Error("Expected composer textarea.");
|
|
|
|
controller.focusComposer();
|
|
expect(document.activeElement).not.toBe(composer);
|
|
controller.setDraft("restored", { focus: true });
|
|
expect(document.activeElement).not.toBe(composer);
|
|
|
|
controller.focusComposer({ force: true });
|
|
expect(document.activeElement).toBe(composer);
|
|
composer.blur();
|
|
foreground = true;
|
|
controller.focusComposer();
|
|
expect(document.activeElement).toBe(composer);
|
|
});
|
|
|
|
it("locks composer input while exposing a pending web import to the send control", () => {
|
|
const { controller, stateStore } = composerControllerFixture();
|
|
const pending = pendingWebSubmissionItem("local-web", "https://example.com", "summarize");
|
|
if (!pending) throw new Error("Expected pending web submission");
|
|
stateStore.dispatch({
|
|
type: "web-submission/pending",
|
|
submission: {
|
|
id: pending.id,
|
|
item: pending,
|
|
targetThreadId: null,
|
|
originalDraft: "/web https://example.com summarize",
|
|
phase: "cancellable",
|
|
},
|
|
});
|
|
|
|
const props = controller.renderState(composerModelFromChatState(stateStore.getState()), { submit: vi.fn() });
|
|
|
|
expect(props.submissionDisabled).toBe(true);
|
|
expect(props.webSubmissionCancellable).toBe(true);
|
|
});
|
|
|
|
it("locks composer input without offering cancel after a web submission commits", () => {
|
|
const { controller, stateStore } = composerControllerFixture();
|
|
const pending = pendingWebSubmissionItem("local-web", "https://example.com", "summarize");
|
|
if (!pending) throw new Error("Expected pending web submission");
|
|
stateStore.dispatch({
|
|
type: "web-submission/pending",
|
|
submission: {
|
|
id: pending.id,
|
|
item: pending,
|
|
targetThreadId: null,
|
|
originalDraft: "/web https://example.com summarize",
|
|
phase: "committed",
|
|
},
|
|
} as never);
|
|
|
|
const props = controller.renderState(composerModelFromChatState(stateStore.getState()), { submit: vi.fn() });
|
|
|
|
expect(props.submissionDisabled).toBe(true);
|
|
expect(props.webSubmissionCancellable).toBe(false);
|
|
});
|
|
|
|
it("keeps a pending web submission after a turn that completes during the fetch", () => {
|
|
const stateStore = createChatStateStore(chatStateWith(chatStateFixture(), { activeThread: { id: "thread" } }));
|
|
const pending = pendingWebSubmissionItem("local-web", "https://example.com", "summarize");
|
|
if (!pending) throw new Error("Expected pending web submission");
|
|
const assistant: ThreadStreamItem = {
|
|
id: "assistant",
|
|
kind: "dialogue",
|
|
dialogueKind: "assistantResponse",
|
|
role: "assistant",
|
|
text: "done",
|
|
dialogueState: "completed",
|
|
turnId: "turn",
|
|
};
|
|
stateStore.dispatch({ type: "turn/started", threadId: "thread", turnId: "turn" });
|
|
stateStore.dispatch({
|
|
type: "web-submission/pending",
|
|
submission: {
|
|
id: pending.id,
|
|
item: pending,
|
|
targetThreadId: "thread",
|
|
originalDraft: "/web https://example.com",
|
|
phase: "cancellable",
|
|
},
|
|
});
|
|
stateStore.dispatch({ type: "turn/completed", turnId: "turn", status: "completed", items: [assistant] });
|
|
|
|
expect(threadStreamItems(stateStore.getState().threadStream).map((item) => item.id)).toEqual(["assistant"]);
|
|
expect(stateStore.getState().pendingSubmission?.id).toBe(pending.id);
|
|
});
|
|
|
|
it("derives composer placeholder and meta from the projection", () => {
|
|
const projection = vi.fn((model: ChatPanelComposerModel) => ({
|
|
placeholder: `Projected ${model.draft || "empty"}`,
|
|
meta: defaultComposerProjection(model).meta,
|
|
}));
|
|
const { controller, stateStore } = composerControllerFixture({ controller: { composerProjection: projection } });
|
|
|
|
const props = controller.renderState(composerModelFromChatState(stateStore.getState()), { submit: vi.fn() });
|
|
|
|
expect(props.normalPlaceholder).toBe("Projected empty");
|
|
expect(props.meta.statusSummary).toBe(
|
|
"Context unavailable, plan off, auto-review off, fast off, model default, reasoning effort default",
|
|
);
|
|
});
|
|
|
|
it("updates slash suggestions when the input changes", () => {
|
|
const stateStore = createChatStateStore();
|
|
const parent = document.createElement("div");
|
|
const controllerRef: { current: ChatComposerController | null } = { current: null };
|
|
const renderShell = vi.fn(() => {
|
|
if (!controllerRef.current) throw new Error("Expected controller.");
|
|
renderComposerController(parent, controllerRef.current, stateStore);
|
|
});
|
|
const controller = new ChatComposerController({
|
|
noteCandidateProvider: noteProvider(),
|
|
contextReferenceProvider: contextProvider(),
|
|
sourcePath: () => "",
|
|
stateStore,
|
|
viewId: "view",
|
|
referenceActiveNoteOnSend: () => false,
|
|
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(),
|
|
canFocus: () => true,
|
|
onHeightChange: vi.fn(),
|
|
});
|
|
controllerRef.current = controller;
|
|
trackComposerControllerTestCleanup(stateStore.subscribe(renderShell));
|
|
|
|
renderShell();
|
|
setTextAreaValue(composer(parent), "/");
|
|
composer(parent).setSelectionRange(1, 1);
|
|
composer(parent).dispatchEvent(new Event("input", { bubbles: true }));
|
|
|
|
expect(stateStore.getState().composer.draft).toBe("/");
|
|
expect(stateStore.getState().composer.suggestions.length).toBeGreaterThan(0);
|
|
expect(parent.querySelector(".codex-panel__composer-suggestion")?.textContent).toContain("/");
|
|
});
|
|
|
|
it("applies ephemeral-thread suggestion restrictions on input without waiting for keyup", () => {
|
|
const stateStore = createChatStateStore(
|
|
chatStateFixture({
|
|
activeThread: {
|
|
id: "side-thread",
|
|
lifetime: { kind: "ephemeral", sourceThreadId: "source-thread", sourceThreadTitle: "Source" },
|
|
},
|
|
}),
|
|
);
|
|
const { controller, parent } = composerControllerFixture({ stateStore });
|
|
|
|
renderComposerController(parent, controller, stateStore);
|
|
setTextAreaValue(composer(parent), "/f");
|
|
composer(parent).setSelectionRange(2, 2);
|
|
composer(parent).dispatchEvent(new Event("input", { bubbles: true }));
|
|
|
|
expect(stateStore.getState().composer.suggestions.map((suggestion) => suggestion.replacement)).not.toContain("/fork");
|
|
expect(stateStore.getState().composer.suggestions.map((suggestion) => suggestion.replacement)).not.toContain("/fast");
|
|
});
|
|
|
|
it("keeps a disconnected subagent composer read-only without slash suggestions", () => {
|
|
const stateStore = createChatStateStore(
|
|
chatStateFixture({
|
|
activeThread: {
|
|
id: "child-thread",
|
|
provenance: {
|
|
kind: "subagent",
|
|
subagentKind: "thread-spawn",
|
|
parentThreadId: "parent-thread",
|
|
sessionId: "session",
|
|
depth: 1,
|
|
agentNickname: "Scout",
|
|
agentRole: "explorer",
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
stateStore.dispatch({ type: "connection/scoped-cleared" });
|
|
const { controller, parent } = composerControllerFixture({ stateStore });
|
|
|
|
renderComposerController(parent, controller, stateStore);
|
|
const props = controller.renderState(composerModelFromChatState(stateStore.getState()), { submit: vi.fn() });
|
|
setTextAreaValue(composer(parent), "/");
|
|
composer(parent).setSelectionRange(1, 1);
|
|
composer(parent).dispatchEvent(new Event("input", { bubbles: true }));
|
|
|
|
expect(props.submissionDisabled).toBe(true);
|
|
expect(stateStore.getState().composer.suggestions).toEqual([]);
|
|
});
|
|
|
|
it("updates Obsidian tag suggestions when the input changes", () => {
|
|
const { controller, parent, stateStore } = composerControllerFixture({
|
|
controller: {
|
|
noteCandidateProvider: noteProvider({ tags: () => ["project/codex"] }),
|
|
},
|
|
});
|
|
|
|
renderComposerController(parent, controller, stateStore);
|
|
setTextAreaValue(composer(parent), "#pro");
|
|
composer(parent).setSelectionRange(4, 4);
|
|
composer(parent).dispatchEvent(new Event("input", { bubbles: true }));
|
|
|
|
expect(stateStore.getState().composer.suggestions[0]).toMatchObject({
|
|
display: "#project/codex",
|
|
replacement: "#project/codex",
|
|
});
|
|
expect(parent.querySelector(".codex-panel__composer-suggestion")?.textContent).toContain("#project/codex");
|
|
});
|
|
|
|
it("does not read Obsidian tags for non-tag suggestions", () => {
|
|
const tags = vi.fn(() => ["project/codex"]);
|
|
const { controller, parent, stateStore } = composerControllerFixture({
|
|
controller: {
|
|
noteCandidateProvider: noteProvider({ tags }),
|
|
},
|
|
});
|
|
|
|
renderComposerController(parent, controller, stateStore);
|
|
setTextAreaValue(composer(parent), "/");
|
|
composer(parent).setSelectionRange(1, 1);
|
|
composer(parent).dispatchEvent(new Event("input", { bubbles: true }));
|
|
|
|
expect(stateStore.getState().composer.suggestions.length).toBeGreaterThan(0);
|
|
expect(tags).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("inserts configured relative daily-note references as wikilinks", () => {
|
|
const dailyNoteReferences = vi.fn(() => [
|
|
{
|
|
keyword: "today" as const,
|
|
display: "Today",
|
|
name: "2026-07-10",
|
|
path: "Journal/2026-07-10.md",
|
|
linktext: "Journal/2026-07-10",
|
|
},
|
|
]);
|
|
const { controller, parent, stateStore } = composerControllerFixture({
|
|
controller: {
|
|
noteCandidateProvider: noteProvider({ dailyNoteReferences }),
|
|
sourcePath: () => "Inbox.md",
|
|
},
|
|
});
|
|
|
|
renderComposerController(parent, controller, stateStore);
|
|
setTextAreaValue(composer(parent), "@today");
|
|
composer(parent).setSelectionRange(6, 6);
|
|
composer(parent).dispatchEvent(new Event("input", { bubbles: true }));
|
|
composer(parent).dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }));
|
|
|
|
expect(composer(parent).value).toBe("[[Journal/2026-07-10]]");
|
|
expect(dailyNoteReferences).toHaveBeenCalledWith("Inbox.md");
|
|
});
|
|
|
|
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",
|
|
referenceActiveNoteOnSend: () => false,
|
|
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(),
|
|
canFocus: () => true,
|
|
onHeightChange: vi.fn(),
|
|
});
|
|
trackComposerControllerTestCleanup(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");
|
|
const attachmentHandler: ComposerAttachmentHandler = {
|
|
saveFiles: vi.fn().mockResolvedValue([
|
|
{
|
|
kind: "image",
|
|
name: "diagram",
|
|
path: "Codex Attachments/diagram.png",
|
|
marker: "![[Codex Attachments/diagram.png]]",
|
|
},
|
|
]),
|
|
};
|
|
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(),
|
|
contextReferenceProvider: contextProvider(),
|
|
attachmentHandler,
|
|
sourcePath: () => "",
|
|
stateStore,
|
|
viewId: "view",
|
|
referenceActiveNoteOnSend: () => false,
|
|
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(),
|
|
canFocus: () => true,
|
|
onHeightChange: vi.fn(),
|
|
});
|
|
trackComposerControllerTestCleanup(stateStore.subscribe(renderShell));
|
|
|
|
renderShell();
|
|
composer(parent).dispatchEvent(transferEvent("paste", "clipboardData", [new File(["image"], "diagram.png", { type: "image/png" })]));
|
|
await flushComposerAttachment();
|
|
|
|
expect(attachmentHandler.saveFiles).toHaveBeenCalledOnce();
|
|
expect(composer(parent).value).toBe("![[Codex Attachments/diagram.png]]");
|
|
expect(controller.preparedInput(composer(parent).value).input).toEqual([
|
|
{ type: "text", text: "![[Codex Attachments/diagram.png]]" },
|
|
{ type: "fileReference", name: "diagram", path: "Codex Attachments/diagram.png" },
|
|
{ type: "localImage", path: "Codex Attachments/diagram.png" },
|
|
]);
|
|
});
|
|
|
|
it("does not insert a saved attachment into a later thread or draft", async () => {
|
|
const saved = deferred<ComposerAttachment[]>();
|
|
const stateStore = createChatStateStore(chatStateWith(chatStateFixture(), { activeThread: { id: "thread-first" } }));
|
|
const { controller, parent, renderShell } = composerControllerFixture({
|
|
stateStore,
|
|
controller: {
|
|
attachmentHandler: { saveFiles: vi.fn(() => saved.promise) },
|
|
},
|
|
});
|
|
renderShell();
|
|
controller.setDraft("first draft");
|
|
composer(parent).setSelectionRange(5, 5);
|
|
composer(parent).dispatchEvent(transferEvent("paste", "clipboardData", [new File(["image"], "first.png", { type: "image/png" })]));
|
|
|
|
stateStore.dispatch({ type: "active-thread/cleared" });
|
|
controller.setDraft("later draft");
|
|
composer(parent).setSelectionRange(2, 2);
|
|
saved.resolve([attachmentFixture("first")]);
|
|
await flushComposerAttachment();
|
|
|
|
expect(composer(parent).value).toBe("later draft");
|
|
expect(controller.captureInputSnapshot().attachments).toEqual([]);
|
|
});
|
|
|
|
it("does not insert a saved attachment after leaving and returning to the same thread state", async () => {
|
|
const saved = deferred<ComposerAttachment[]>();
|
|
const stateStore = createChatStateStore(chatStateWith(chatStateFixture(), { activeThread: { id: "thread" } }));
|
|
const { controller, parent, renderShell } = composerControllerFixture({
|
|
stateStore,
|
|
controller: {
|
|
attachmentHandler: { saveFiles: vi.fn(() => saved.promise) },
|
|
},
|
|
});
|
|
renderShell();
|
|
controller.setDraft("same draft");
|
|
composer(parent).setSelectionRange(4, 4);
|
|
composer(parent).dispatchEvent(transferEvent("paste", "clipboardData", [new File(["image"], "first.png", { type: "image/png" })]));
|
|
|
|
stateStore.dispatch({ type: "active-thread/cleared" });
|
|
resumeComposerThread(stateStore, "thread");
|
|
controller.setDraft("same draft");
|
|
composer(parent).setSelectionRange(4, 4);
|
|
saved.resolve([attachmentFixture("first")]);
|
|
await flushComposerAttachment();
|
|
|
|
expect(composer(parent).value).toBe("same draft");
|
|
expect(controller.captureInputSnapshot().attachments).toEqual([]);
|
|
});
|
|
|
|
it("keeps a pending attachment at its placeholder while the draft is edited", async () => {
|
|
const saved = deferred<ComposerAttachment[]>();
|
|
const { controller, parent, renderShell } = composerControllerFixture({
|
|
controller: {
|
|
attachmentHandler: { saveFiles: vi.fn(() => saved.promise) },
|
|
},
|
|
});
|
|
renderShell();
|
|
controller.setDraft("before after");
|
|
composer(parent).setSelectionRange(6, 6);
|
|
composer(parent).dispatchEvent(transferEvent("paste", "clipboardData", [new File(["image"], "first.png", { type: "image/png" })]));
|
|
|
|
setTextAreaValue(composer(parent), `edited ${composer(parent).value}`);
|
|
composer(parent).dispatchEvent(new Event("input", { bubbles: true }));
|
|
saved.resolve([attachmentFixture("first")]);
|
|
await flushComposerAttachment();
|
|
|
|
expect(composer(parent).value).toBe("edited before\n![[Codex Attachments/first.png]]\n after");
|
|
expect(controller.captureInputSnapshot().attachments).toEqual([attachmentFixture("first")]);
|
|
});
|
|
|
|
it("inserts multiple transfers that began at the same draft anchor", async () => {
|
|
const first = deferred<ComposerAttachment[]>();
|
|
const second = deferred<ComposerAttachment[]>();
|
|
let call = 0;
|
|
const { controller, parent, renderShell } = composerControllerFixture({
|
|
controller: {
|
|
attachmentHandler: {
|
|
saveFiles: vi.fn(() => {
|
|
call += 1;
|
|
return call === 1 ? first.promise : second.promise;
|
|
}),
|
|
},
|
|
},
|
|
});
|
|
renderShell();
|
|
controller.setDraft("note");
|
|
composer(parent).setSelectionRange(4, 4);
|
|
composer(parent).dispatchEvent(transferEvent("paste", "clipboardData", [new File(["image"], "first.png", { type: "image/png" })]));
|
|
composer(parent).dispatchEvent(transferEvent("paste", "clipboardData", [new File(["image"], "second.png", { type: "image/png" })]));
|
|
|
|
second.resolve([attachmentFixture("second")]);
|
|
await flushComposerAttachment();
|
|
first.resolve([attachmentFixture("first")]);
|
|
await flushComposerAttachment();
|
|
|
|
expect(composer(parent).value).toBe("note\n![[Codex Attachments/first.png]]\n![[Codex Attachments/second.png]]");
|
|
expect(controller.captureInputSnapshot().attachments.map((attachment) => attachment.name)).toEqual(["first", "second"]);
|
|
});
|
|
|
|
it("preserves pasted image attachments when connection exit restores a cancellable web draft", async () => {
|
|
const stateStore = createChatStateStore();
|
|
const parent = document.createElement("div");
|
|
const attachmentHandler: ComposerAttachmentHandler = {
|
|
saveFiles: vi.fn().mockResolvedValue([
|
|
{
|
|
kind: "image",
|
|
name: "diagram",
|
|
path: "Codex Attachments/diagram.png",
|
|
marker: "![[Codex Attachments/diagram.png]]",
|
|
},
|
|
]),
|
|
};
|
|
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(),
|
|
contextReferenceProvider: contextProvider(),
|
|
attachmentHandler,
|
|
sourcePath: () => "",
|
|
stateStore,
|
|
viewId: "view",
|
|
referenceActiveNoteOnSend: () => false,
|
|
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(),
|
|
canFocus: () => true,
|
|
onHeightChange: vi.fn(),
|
|
});
|
|
trackComposerControllerTestCleanup(stateStore.subscribe(renderShell));
|
|
|
|
renderShell();
|
|
composer(parent).dispatchEvent(transferEvent("paste", "clipboardData", [new File(["image"], "diagram.png", { type: "image/png" })]));
|
|
await flushComposerAttachment();
|
|
const marker = composer(parent).value;
|
|
const snapshot = controller.captureInputSnapshot();
|
|
const originalDraft = `/web https://example.com Inspect ${marker}`;
|
|
const pending = pendingWebSubmissionItem("local-web", "https://example.com", `Inspect ${marker}`);
|
|
if (!pending) throw new Error("Expected pending web submission");
|
|
|
|
controller.setDraft(originalDraft);
|
|
stateStore.dispatch({
|
|
type: "web-submission/pending",
|
|
submission: {
|
|
id: pending.id,
|
|
item: pending,
|
|
targetThreadId: null,
|
|
originalDraft,
|
|
phase: "cancellable",
|
|
},
|
|
});
|
|
controller.setDraft("", { clearSuggestions: true, preserveContext: true });
|
|
stateStore.dispatch({ type: "connection/scoped-cleared" });
|
|
const restoredSnapshot = controller.captureInputSnapshot();
|
|
|
|
expect(controller.draft).toBe(originalDraft);
|
|
expect(controller.preparedInput(`Inspect ${marker}`, snapshot).input).toEqual([
|
|
{ type: "text", text: `Inspect ${marker}` },
|
|
{ type: "fileReference", name: "diagram", path: "Codex Attachments/diagram.png" },
|
|
{ type: "localImage", path: "Codex Attachments/diagram.png" },
|
|
]);
|
|
expect(controller.preparedInput(`Inspect ${marker}`, restoredSnapshot).input).toEqual([
|
|
{ type: "text", text: `Inspect ${marker}` },
|
|
{ type: "fileReference", name: "diagram", path: "Codex Attachments/diagram.png" },
|
|
{ type: "localImage", path: "Codex Attachments/diagram.png" },
|
|
]);
|
|
});
|
|
|
|
it("accepts protected file dragovers before dropped files are readable", () => {
|
|
const stateStore = createChatStateStore();
|
|
const parent = document.createElement("div");
|
|
const controller = new ChatComposerController({
|
|
noteCandidateProvider: noteProvider(),
|
|
contextReferenceProvider: contextProvider(),
|
|
sourcePath: () => "",
|
|
stateStore,
|
|
viewId: "view",
|
|
referenceActiveNoteOnSend: () => false,
|
|
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(),
|
|
canFocus: () => true,
|
|
onHeightChange: vi.fn(),
|
|
});
|
|
const dataTransfer = {
|
|
files: [],
|
|
items: [{ kind: "file", getAsFile: vi.fn(() => null) }],
|
|
types: ["Files"],
|
|
dropEffect: "none",
|
|
};
|
|
const event = new Event("dragover", { bubbles: true, cancelable: true });
|
|
Object.defineProperty(event, "dataTransfer", { value: dataTransfer });
|
|
|
|
renderComposerController(parent, controller, stateStore);
|
|
composer(parent).dispatchEvent(event);
|
|
|
|
expect(event.defaultPrevented).toBe(true);
|
|
expect(dataTransfer.dropEffect).toBe("copy");
|
|
});
|
|
|
|
it("keeps the composer editable but requires a new Send after attachment saves settle", async () => {
|
|
const stateStore = createChatStateStore();
|
|
const parent = document.createElement("div");
|
|
const attachment: ComposerAttachment = {
|
|
kind: "image",
|
|
name: "diagram",
|
|
path: "Codex Attachments/diagram.png",
|
|
marker: "![[Codex Attachments/diagram.png]]",
|
|
};
|
|
const saveResolver: { current?: (attachments: ComposerAttachment[]) => void } = {};
|
|
const attachmentHandler: ComposerAttachmentHandler = {
|
|
saveFiles: vi.fn(
|
|
() =>
|
|
new Promise<ComposerAttachment[]>((resolve) => {
|
|
saveResolver.current = resolve;
|
|
}),
|
|
),
|
|
};
|
|
let controller: ChatComposerController | null = null;
|
|
const renderShell = vi.fn(() => {
|
|
if (!controller) throw new Error("Expected controller.");
|
|
renderComposerController(parent, controller, stateStore, { submit });
|
|
});
|
|
const submit = vi.fn();
|
|
controller = new ChatComposerController({
|
|
noteCandidateProvider: noteProvider(),
|
|
contextReferenceProvider: contextProvider(),
|
|
attachmentHandler,
|
|
sourcePath: () => "",
|
|
stateStore,
|
|
viewId: "view",
|
|
referenceActiveNoteOnSend: () => false,
|
|
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(),
|
|
canFocus: () => true,
|
|
onHeightChange: vi.fn(),
|
|
});
|
|
trackComposerControllerTestCleanup(stateStore.subscribe(renderShell));
|
|
|
|
renderShell();
|
|
composer(parent).dispatchEvent(transferEvent("paste", "clipboardData", [new File(["image"], "diagram.png", { type: "image/png" })]));
|
|
const sendButton = parent.querySelector<HTMLButtonElement>(".codex-panel__send");
|
|
expect(sendButton?.disabled).toBe(true);
|
|
expect(composer(parent).readOnly).toBe(false);
|
|
expect(composer(parent).value).toContain("Saving attachment…");
|
|
composer(parent).dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "Enter" }));
|
|
|
|
expect(attachmentHandler.saveFiles).toHaveBeenCalledOnce();
|
|
expect(submit).not.toHaveBeenCalled();
|
|
|
|
if (!saveResolver.current) throw new Error("Expected save resolver.");
|
|
saveResolver.current([attachment]);
|
|
await flushComposerAttachment();
|
|
await flushComposerAttachment();
|
|
|
|
expect(composer(parent).value).toBe("![[Codex Attachments/diagram.png]]");
|
|
expect(parent.querySelector<HTMLButtonElement>(".codex-panel__send")?.disabled).toBe(false);
|
|
expect(submit).not.toHaveBeenCalled();
|
|
|
|
composer(parent).dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "Enter" }));
|
|
expect(submit).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("removes a failed attachment placeholder without submitting the draft", async () => {
|
|
const saved = deferred<ComposerAttachment[]>();
|
|
const submit = vi.fn();
|
|
const onAttachmentError = vi.fn();
|
|
const { controller, parent, renderShell, stateStore } = composerControllerFixture({
|
|
controller: {
|
|
attachmentHandler: { saveFiles: vi.fn(() => saved.promise) },
|
|
onAttachmentError,
|
|
},
|
|
renderActions: { submit },
|
|
});
|
|
renderShell();
|
|
controller.setDraft("Keep this draft");
|
|
composer(parent).dispatchEvent(transferEvent("paste", "clipboardData", [new File(["image"], "diagram.png", { type: "image/png" })]));
|
|
composer(parent).dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "Enter" }));
|
|
|
|
saved.reject(new Error("Attachment save failed."));
|
|
await flushComposerAttachment();
|
|
await flushComposerAttachment();
|
|
|
|
expect(composer(parent).value).toBe("Keep this draft");
|
|
expect(stateStore.getState().composer.pendingAttachmentSaveIds).toEqual([]);
|
|
expect(onAttachmentError).toHaveBeenCalledWith("Attachment save failed.");
|
|
expect(submit).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("restores selected draft text and caret when attachment saving fails", async () => {
|
|
const saved = deferred<ComposerAttachment[]>();
|
|
const { controller, parent, renderShell } = composerControllerFixture({
|
|
controller: {
|
|
attachmentHandler: { saveFiles: vi.fn(() => saved.promise) },
|
|
},
|
|
});
|
|
controller.setDraft("keep selected text");
|
|
renderShell();
|
|
composer(parent).setSelectionRange(5, 13);
|
|
composer(parent).dispatchEvent(transferEvent("paste", "clipboardData", [new File(["image"], "diagram.png", { type: "image/png" })]));
|
|
composer(parent).setSelectionRange(0, 0);
|
|
|
|
saved.reject(new Error("Attachment save failed."));
|
|
await flushComposerAttachment();
|
|
await flushComposerAttachment();
|
|
|
|
expect(composer(parent).value).toBe("keep selected text");
|
|
expect(composer(parent).selectionStart).toBe(0);
|
|
expect(composer(parent).selectionEnd).toBe(0);
|
|
});
|
|
|
|
it("settles an attachment after its placeholder label is edited", async () => {
|
|
const saved = deferred<ComposerAttachment[]>();
|
|
const { controller, parent, renderShell } = composerControllerFixture({
|
|
controller: {
|
|
attachmentHandler: { saveFiles: vi.fn(() => saved.promise) },
|
|
},
|
|
});
|
|
controller.setDraft("Keep this draft");
|
|
renderShell();
|
|
composer(parent).dispatchEvent(transferEvent("paste", "clipboardData", [new File(["image"], "diagram.png", { type: "image/png" })]));
|
|
controller.setDraft(composer(parent).value.replace("Saving attachment…", "Uploading…"));
|
|
composer(parent).setSelectionRange(0, 0);
|
|
|
|
saved.resolve([
|
|
{
|
|
kind: "image",
|
|
name: "diagram",
|
|
path: "Codex Attachments/diagram.png",
|
|
marker: "![[Codex Attachments/diagram.png]]",
|
|
},
|
|
]);
|
|
await flushComposerAttachment();
|
|
await flushComposerAttachment();
|
|
|
|
expect(composer(parent).value).toBe("Keep this draft\n![[Codex Attachments/diagram.png]]");
|
|
expect(composer(parent).value).not.toContain("codex-panel-pending-attachment:");
|
|
expect(composer(parent).selectionStart).toBe(0);
|
|
});
|
|
|
|
it("removes an edited attachment placeholder when saving fails", async () => {
|
|
const saved = deferred<ComposerAttachment[]>();
|
|
const { controller, parent, renderShell } = composerControllerFixture({
|
|
controller: {
|
|
attachmentHandler: { saveFiles: vi.fn(() => saved.promise) },
|
|
},
|
|
});
|
|
controller.setDraft("Keep this draft");
|
|
renderShell();
|
|
composer(parent).dispatchEvent(transferEvent("paste", "clipboardData", [new File(["image"], "diagram.png", { type: "image/png" })]));
|
|
controller.setDraft(composer(parent).value.replace("Saving attachment…", "Uploading…"));
|
|
|
|
saved.reject(new Error("Attachment save failed."));
|
|
await flushComposerAttachment();
|
|
await flushComposerAttachment();
|
|
|
|
expect(composer(parent).value).toBe("Keep this draft");
|
|
expect(composer(parent).value).not.toContain("codex-panel-pending-attachment:");
|
|
});
|
|
|
|
it("restores selected text without synthetic separators after the placeholder label is edited", async () => {
|
|
const saved = deferred<ComposerAttachment[]>();
|
|
const { controller, parent, renderShell } = composerControllerFixture({
|
|
controller: {
|
|
attachmentHandler: { saveFiles: vi.fn(() => saved.promise) },
|
|
},
|
|
});
|
|
controller.setDraft("keep selected text");
|
|
renderShell();
|
|
composer(parent).setSelectionRange(5, 13);
|
|
composer(parent).dispatchEvent(transferEvent("paste", "clipboardData", [new File(["image"], "diagram.png", { type: "image/png" })]));
|
|
controller.setDraft(composer(parent).value.replace("Saving attachment…", "Uploading…"));
|
|
composer(parent).setSelectionRange(0, 0);
|
|
|
|
saved.reject(new Error("Attachment save failed."));
|
|
await flushComposerAttachment();
|
|
await flushComposerAttachment();
|
|
|
|
expect(composer(parent).value).toBe("keep selected text");
|
|
expect(composer(parent).selectionStart).toBe(0);
|
|
});
|
|
|
|
it("allows an interrupt after a pending attachment placeholder is removed", () => {
|
|
const saved = deferred<ComposerAttachment[]>();
|
|
const submit = vi.fn();
|
|
const { controller, parent, renderShell } = composerControllerFixture({
|
|
controller: {
|
|
attachmentHandler: { saveFiles: vi.fn(() => saved.promise) },
|
|
canInterrupt: () => true,
|
|
},
|
|
renderActions: { submit },
|
|
});
|
|
renderShell();
|
|
composer(parent).dispatchEvent(transferEvent("paste", "clipboardData", [new File(["image"], "diagram.png", { type: "image/png" })]));
|
|
controller.setDraft("");
|
|
|
|
const sendButton = parent.querySelector<HTMLButtonElement>(".codex-panel__send");
|
|
expect(sendButton?.getAttribute("aria-label")).toBe("Interrupt");
|
|
expect(sendButton?.disabled).toBe(false);
|
|
sendButton?.click();
|
|
|
|
expect(submit).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("does not reinsert a saved attachment after the user removes its placeholder", async () => {
|
|
const saved = deferred<ComposerAttachment[]>();
|
|
const attachment: ComposerAttachment = {
|
|
kind: "image",
|
|
name: "diagram",
|
|
path: "Codex Attachments/diagram.png",
|
|
marker: "![[Codex Attachments/diagram.png]]",
|
|
};
|
|
const { controller, parent, renderShell, stateStore } = composerControllerFixture({
|
|
controller: {
|
|
attachmentHandler: { saveFiles: vi.fn(() => saved.promise) },
|
|
},
|
|
});
|
|
renderShell();
|
|
controller.setDraft("Keep this draft");
|
|
composer(parent).dispatchEvent(transferEvent("paste", "clipboardData", [new File(["image"], "diagram.png", { type: "image/png" })]));
|
|
controller.setDraft("Keep this edited draft");
|
|
|
|
saved.resolve([attachment]);
|
|
await flushComposerAttachment();
|
|
await flushComposerAttachment();
|
|
|
|
expect(composer(parent).value).toBe("Keep this edited draft");
|
|
expect(stateStore.getState().composer.pendingAttachmentSaveIds).toEqual([]);
|
|
});
|
|
|
|
it("does not submit or apply saved attachments after disposal", async () => {
|
|
const stateStore = createChatStateStore();
|
|
const parent = document.createElement("div");
|
|
const saved = deferred<ComposerAttachment[]>();
|
|
const attachmentHandler: ComposerAttachmentHandler = {
|
|
saveFiles: vi.fn(() => saved.promise),
|
|
};
|
|
const submit = vi.fn();
|
|
const controller = new ChatComposerController({
|
|
noteCandidateProvider: noteProvider(),
|
|
contextReferenceProvider: contextProvider(),
|
|
attachmentHandler,
|
|
sourcePath: () => "",
|
|
stateStore,
|
|
viewId: "view",
|
|
referenceActiveNoteOnSend: () => false,
|
|
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(),
|
|
canFocus: () => true,
|
|
onHeightChange: vi.fn(),
|
|
});
|
|
|
|
renderComposerController(parent, controller, stateStore, { submit });
|
|
composer(parent).dispatchEvent(transferEvent("paste", "clipboardData", [new File(["image"], "diagram.png", { type: "image/png" })]));
|
|
composer(parent).dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "Enter" }));
|
|
controller.dispose();
|
|
saved.resolve([
|
|
{
|
|
kind: "image",
|
|
name: "diagram",
|
|
path: "Codex Attachments/diagram.png",
|
|
marker: "![[Codex Attachments/diagram.png]]",
|
|
},
|
|
]);
|
|
await flushComposerAttachment();
|
|
await flushComposerAttachment();
|
|
|
|
expect(submit).not.toHaveBeenCalled();
|
|
expect(stateStore.getState().composer.draft).toBe("");
|
|
expect(controller.captureInputSnapshot().attachments).toEqual([]);
|
|
});
|
|
|
|
it("saves dropped non-image files, inserts a wikilink, and sends a file reference", async () => {
|
|
const stateStore = createChatStateStore();
|
|
const parent = document.createElement("div");
|
|
const attachmentHandler: ComposerAttachmentHandler = {
|
|
saveFiles: vi.fn().mockResolvedValue([
|
|
{
|
|
kind: "file",
|
|
name: "paper",
|
|
path: "Codex Attachments/paper.pdf",
|
|
marker: "[[Codex Attachments/paper.pdf]]",
|
|
},
|
|
]),
|
|
};
|
|
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(),
|
|
contextReferenceProvider: contextProvider(),
|
|
attachmentHandler,
|
|
sourcePath: () => "",
|
|
stateStore,
|
|
viewId: "view",
|
|
referenceActiveNoteOnSend: () => false,
|
|
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(),
|
|
canFocus: () => true,
|
|
onHeightChange: vi.fn(),
|
|
});
|
|
trackComposerControllerTestCleanup(stateStore.subscribe(renderShell));
|
|
|
|
renderShell();
|
|
composer(parent).dispatchEvent(transferEvent("drop", "dataTransfer", [new File(["pdf"], "paper.pdf", { type: "application/pdf" })]));
|
|
await flushComposerAttachment();
|
|
|
|
expect(attachmentHandler.saveFiles).toHaveBeenCalledOnce();
|
|
expect(composer(parent).value).toBe("[[Codex Attachments/paper.pdf]]");
|
|
expect(controller.preparedInput(composer(parent).value).input).toEqual([
|
|
{ type: "text", text: "[[Codex Attachments/paper.pdf]]" },
|
|
{ type: "fileReference", name: "paper", path: "Codex Attachments/paper.pdf" },
|
|
]);
|
|
});
|
|
|
|
it("freezes active file context when inserting the active suggestion", async () => {
|
|
const stateStore = createChatStateStore();
|
|
const parent = document.createElement("div");
|
|
let references: ComposerContextReferences = {
|
|
activeNote: { name: "Alpha", path: "notes/Alpha.md", linktext: "Alpha" },
|
|
selection: 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({ resolveFileReference: () => null }),
|
|
contextReferenceProvider: contextProvider(() => references),
|
|
sourcePath: () => "Inbox.md",
|
|
stateStore,
|
|
viewId: "view",
|
|
referenceActiveNoteOnSend: () => false,
|
|
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(),
|
|
canFocus: () => true,
|
|
onHeightChange: vi.fn(),
|
|
});
|
|
trackComposerControllerTestCleanup(stateStore.subscribe(renderShell));
|
|
|
|
renderShell();
|
|
setTextAreaValue(composer(parent), "@active");
|
|
composer(parent).setSelectionRange(7, 7);
|
|
composer(parent).dispatchEvent(new Event("input", { bubbles: true }));
|
|
composer(parent).dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }));
|
|
const completedActiveNoteReference = composer(parent).value;
|
|
const snapshot = controller.captureInputSnapshot();
|
|
references = { activeNote: null, selection: null };
|
|
|
|
expect(completedActiveNoteReference).toBe("[[Alpha]]");
|
|
expect(controller.preparedInput(completedActiveNoteReference).input).toContainEqual({
|
|
type: "fileReference",
|
|
name: "Alpha",
|
|
path: "notes/Alpha.md",
|
|
});
|
|
|
|
controller.setDraft("", { clearSuggestions: true });
|
|
expect(controller.preparedInput(completedActiveNoteReference, snapshot).input).toContainEqual({
|
|
type: "fileReference",
|
|
name: "Alpha",
|
|
path: "notes/Alpha.md",
|
|
});
|
|
});
|
|
|
|
it("uses the captured active file when slash commands prepare input asynchronously", () => {
|
|
const stateStore = createChatStateStore();
|
|
let references: ComposerContextReferences = {
|
|
activeNote: { name: "Alpha", path: "notes/Alpha.md", linktext: "Alpha" },
|
|
selection: null,
|
|
};
|
|
const controller = new ChatComposerController({
|
|
noteCandidateProvider: noteProvider({ resolveFileReference: () => null }),
|
|
contextReferenceProvider: contextProvider(() => references),
|
|
sourcePath: () => "Inbox.md",
|
|
stateStore,
|
|
viewId: "view",
|
|
referenceActiveNoteOnSend: () => true,
|
|
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(),
|
|
canFocus: () => true,
|
|
onHeightChange: vi.fn(),
|
|
});
|
|
|
|
const snapshot = controller.captureInputSnapshot();
|
|
references = {
|
|
activeNote: { name: "Beta", path: "notes/Beta.md", linktext: "Beta" },
|
|
selection: null,
|
|
};
|
|
|
|
expect(controller.preparedInput("Rewrite intro", snapshot).input).toEqual([
|
|
{ type: "text", text: "Rewrite intro" },
|
|
{ type: "fileReference", name: "<active>", path: "notes/Alpha.md" },
|
|
{
|
|
type: "additionalContext",
|
|
key: "codex_panel_obsidian_context",
|
|
kind: "untrusted",
|
|
value: "Obsidian references for the current user input:\n- <active> -> notes/Alpha.md",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("freezes selection context when inserting the selection suggestion", async () => {
|
|
const stateStore = createChatStateStore();
|
|
const parent = document.createElement("div");
|
|
let references = {
|
|
activeNote: null,
|
|
selection: {
|
|
name: "Alpha",
|
|
path: "notes/Alpha.md",
|
|
linktext: "notes/Alpha",
|
|
range: { from: { line: 41, ch: 4 }, to: { line: 46, ch: 0 } },
|
|
text: "initial selection",
|
|
},
|
|
};
|
|
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({
|
|
resolveFileReference: (target) => (target === "notes/Alpha" ? { name: "Alpha", path: "notes/Alpha.md" } : null),
|
|
}),
|
|
contextReferenceProvider: contextProvider(() => references),
|
|
sourcePath: () => "",
|
|
stateStore,
|
|
viewId: "view",
|
|
referenceActiveNoteOnSend: () => false,
|
|
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(),
|
|
canFocus: () => true,
|
|
onHeightChange: vi.fn(),
|
|
});
|
|
trackComposerControllerTestCleanup(stateStore.subscribe(renderShell));
|
|
|
|
renderShell();
|
|
setTextAreaValue(composer(parent), "@sel");
|
|
composer(parent).setSelectionRange(4, 4);
|
|
composer(parent).dispatchEvent(new Event("input", { bubbles: true }));
|
|
composer(parent).dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }));
|
|
const completedSelectionReference = composer(parent).value;
|
|
const snapshot = controller.captureInputSnapshot();
|
|
references = {
|
|
activeNote: null,
|
|
selection: {
|
|
name: "Beta",
|
|
path: "notes/Beta.md",
|
|
linktext: "notes/Beta",
|
|
range: { from: { line: 0, ch: 0 }, to: { line: 0, ch: 4 } },
|
|
text: "changed selection",
|
|
},
|
|
};
|
|
|
|
const prepared = controller.preparedInput(completedSelectionReference, snapshot);
|
|
|
|
expect(composer(parent).value).toBe("[[notes/Alpha]] (L42:C5-L47:C1)");
|
|
expect(prepared.input).toContainEqual({
|
|
type: "additionalContext",
|
|
key: "codex_panel_obsidian_context",
|
|
kind: "untrusted",
|
|
attachment: { kind: "obsidian", inlineExcerpts: 1 },
|
|
value:
|
|
"Obsidian references for the current user input:\n- [[notes/Alpha]] (L42:C5-L47:C1) -> notes/Alpha.md (inline excerpt below)\n\nInline excerpts:\n[[notes/Alpha]] (L42:C5-L47:C1):\ninitial selection",
|
|
});
|
|
|
|
controller.setDraft("", { clearSuggestions: true });
|
|
expect(controller.preparedInput(completedSelectionReference, snapshot).input).toContainEqual({
|
|
type: "additionalContext",
|
|
key: "codex_panel_obsidian_context",
|
|
kind: "untrusted",
|
|
attachment: { kind: "obsidian", inlineExcerpts: 1 },
|
|
value:
|
|
"Obsidian references for the current user input:\n- [[notes/Alpha]] (L42:C5-L47:C1) -> notes/Alpha.md (inline excerpt below)\n\nInline excerpts:\n[[notes/Alpha]] (L42:C5-L47:C1):\ninitial selection",
|
|
});
|
|
expect(controller.preparedInput(completedSelectionReference).input).toContainEqual({
|
|
type: "additionalContext",
|
|
key: "codex_panel_obsidian_context",
|
|
kind: "untrusted",
|
|
value: "Obsidian references for the current user input:\n- [[notes/Alpha]] -> notes/Alpha.md",
|
|
});
|
|
});
|
|
|
|
it("rerenders suggestion selection from keyboard navigation", () => {
|
|
const stateStore = createChatStateStore();
|
|
const parent = document.createElement("div");
|
|
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(),
|
|
contextReferenceProvider: contextProvider(),
|
|
sourcePath: () => "",
|
|
stateStore,
|
|
viewId: "view",
|
|
referenceActiveNoteOnSend: () => false,
|
|
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(),
|
|
canFocus: () => true,
|
|
onHeightChange: vi.fn(),
|
|
});
|
|
trackComposerControllerTestCleanup(stateStore.subscribe(renderShell));
|
|
|
|
renderShell();
|
|
setTextAreaValue(composer(parent), "/");
|
|
composer(parent).setSelectionRange(1, 1);
|
|
composer(parent).dispatchEvent(new Event("input", { bubbles: true }));
|
|
|
|
const firstSelected = selectedSuggestion(parent);
|
|
expect(firstSelected.textContent).toContain("/clear");
|
|
|
|
composer(parent).dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "ArrowDown" }));
|
|
expect(stateStore.getState().composer.suggestSelected).toBe(1);
|
|
expect(selectedSuggestion(parent).textContent).not.toBe(firstSelected.textContent);
|
|
|
|
composer(parent).dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "p", ctrlKey: true }));
|
|
expect(stateStore.getState().composer.suggestSelected).toBe(0);
|
|
expect(selectedSuggestion(parent).textContent).toBe(firstSelected.textContent);
|
|
});
|
|
|
|
it("keeps suggestions closed after inserting at a cursor before later trigger text", () => {
|
|
const stateStore = createChatStateStore();
|
|
stateStore.dispatch({ type: "connection/metadata-applied", availableSkills: [skill("obsidian-search")] });
|
|
stateStore.dispatch({ type: "composer/draft-set", draft: "/pla then $ob" });
|
|
const parent = document.createElement("div");
|
|
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(),
|
|
contextReferenceProvider: contextProvider(),
|
|
sourcePath: () => "",
|
|
stateStore,
|
|
viewId: "view",
|
|
referenceActiveNoteOnSend: () => false,
|
|
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(),
|
|
canFocus: () => true,
|
|
onHeightChange: vi.fn(),
|
|
});
|
|
trackComposerControllerTestCleanup(stateStore.subscribe(renderShell));
|
|
|
|
renderShell();
|
|
composer(parent).setSelectionRange(4, 4);
|
|
composer(parent).dispatchEvent(new KeyboardEvent("keyup", { bubbles: true, key: "a" }));
|
|
|
|
const planSuggestion = expectPresent(parent.querySelector<HTMLElement>(".codex-panel__composer-suggestion"));
|
|
expect(planSuggestion.textContent).toContain("/plan");
|
|
|
|
planSuggestion.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
|
|
|
|
expect(composer(parent).value).toBe("/plan then $ob");
|
|
expect(composer(parent).selectionStart).toBe("/plan".length);
|
|
expect(stateStore.getState().composer.suggestions).toEqual([]);
|
|
expect(composer(parent).getAttribute("aria-expanded")).toBe("false");
|
|
expect(composer(parent).hasAttribute("aria-activedescendant")).toBe(false);
|
|
});
|
|
|
|
it("delegates composer runtime toggles", () => {
|
|
const togglePlan = vi.fn();
|
|
const { controller, parent, stateStore } = composerControllerFixture({ controller: { togglePlan } });
|
|
|
|
renderComposerController(parent, controller, stateStore);
|
|
|
|
parent.querySelector<HTMLElement>(".codex-panel__composer-meta-icon")?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
|
|
|
|
expect(togglePlan).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("delegates submit events through render actions", () => {
|
|
const stateStore = createChatStateStore();
|
|
stateStore.dispatch({ type: "composer/draft-set", draft: "hello" });
|
|
const submit = vi.fn();
|
|
const { controller, parent } = composerControllerFixture({ stateStore });
|
|
|
|
renderComposerController(parent, controller, stateStore, { submit });
|
|
composer(parent).dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }));
|
|
|
|
expect(submit).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("scrolls by page from the composer even when line edge scrolling is disabled", () => {
|
|
const threadScrollFromComposer = vi.fn();
|
|
const { controller, parent, stateStore } = composerControllerFixture({ controller: { threadScrollFromComposer } });
|
|
|
|
renderComposerController(parent, controller, stateStore);
|
|
setTextAreaValue(composer(parent), "first\nsecond");
|
|
composer(parent).setSelectionRange(3, 3);
|
|
const event = new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "PageDown" });
|
|
composer(parent).dispatchEvent(event);
|
|
|
|
expect(threadScrollFromComposer).toHaveBeenCalledWith({ kind: "scroll-by", direction: 1, amount: "page" });
|
|
expect(event.defaultPrevented).toBe(true);
|
|
});
|
|
|
|
it("scrolls to stream edges from the composer even when line edge scrolling is disabled", () => {
|
|
const threadScrollFromComposer = vi.fn();
|
|
const { controller, parent, stateStore } = composerControllerFixture({ controller: { threadScrollFromComposer } });
|
|
|
|
renderComposerController(parent, controller, stateStore);
|
|
setTextAreaValue(composer(parent), "first\nsecond");
|
|
composer(parent).setSelectionRange(3, 8);
|
|
const home = new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "Home" });
|
|
composer(parent).dispatchEvent(home);
|
|
const end = new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "End" });
|
|
composer(parent).dispatchEvent(end);
|
|
|
|
expect(threadScrollFromComposer).toHaveBeenNthCalledWith(1, { kind: "scroll-to", edge: "start" });
|
|
expect(threadScrollFromComposer).toHaveBeenNthCalledWith(2, { kind: "scroll-to", edge: "end" });
|
|
expect(home.defaultPrevented).toBe(true);
|
|
expect(end.defaultPrevented).toBe(true);
|
|
});
|
|
|
|
it("leaves composer line edge scrolling disabled by the setting", () => {
|
|
const threadScrollFromComposer = vi.fn();
|
|
const { controller, parent, stateStore } = composerControllerFixture({ controller: { threadScrollFromComposer } });
|
|
|
|
renderComposerController(parent, controller, stateStore);
|
|
setTextAreaValue(composer(parent), "first\nsecond");
|
|
composer(parent).setSelectionRange("first\nsecond".length, "first\nsecond".length);
|
|
const event = new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "n", ctrlKey: true });
|
|
composer(parent).dispatchEvent(event);
|
|
|
|
expect(threadScrollFromComposer).not.toHaveBeenCalled();
|
|
expect(event.defaultPrevented).toBe(false);
|
|
});
|
|
|
|
it("clears the Preact-owned textarea ref when the composer unmounts", () => {
|
|
const stateStore = createChatStateStore();
|
|
stateStore.dispatch({ type: "composer/draft-set", draft: "state draft" });
|
|
const { controller, parent } = composerControllerFixture({ stateStore });
|
|
|
|
renderComposerController(parent, controller, stateStore);
|
|
const mountedComposer = composer(parent);
|
|
setTextAreaValue(mountedComposer, "stale dom draft");
|
|
const focus = vi.spyOn(mountedComposer, "focus");
|
|
|
|
unmountUiRoot(parent);
|
|
controller.setDraft("state draft", { focus: true });
|
|
|
|
expect(controller.trimmedDraft).toBe("state draft");
|
|
expect(focus).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
function noteProvider(overrides: Partial<NoteCandidateProvider> = {}): NoteCandidateProvider {
|
|
return {
|
|
candidates: () => [],
|
|
dailyNoteReferences: () => [],
|
|
tags: () => [],
|
|
resolveFileReference: () => null,
|
|
dispose: vi.fn(),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function contextProvider(
|
|
contextReferences: ComposerContextReferenceProvider["contextReferences"] = () => ({ activeNote: null, selection: null }),
|
|
): ComposerContextReferenceProvider {
|
|
return {
|
|
contextReferences,
|
|
dispose: vi.fn(),
|
|
};
|
|
}
|
|
|
|
function skill(name: string): SkillMetadata {
|
|
return {
|
|
name,
|
|
description: `${name} description`,
|
|
path: `/vault/skills/${name}/SKILL.md`,
|
|
enabled: true,
|
|
};
|
|
}
|
|
|
|
function defaultComposerProjection(_model: ChatPanelComposerModel) {
|
|
return {
|
|
placeholder: "Ask Codex...",
|
|
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,
|
|
},
|
|
};
|
|
}
|
|
|
|
function composer(parent: HTMLElement): HTMLTextAreaElement {
|
|
return expectPresent(parent.querySelector<HTMLTextAreaElement>(".codex-panel__composer-input"));
|
|
}
|
|
|
|
function selectedSuggestion(parent: HTMLElement): HTMLElement {
|
|
return expectPresent(parent.querySelector<HTMLElement>(".codex-panel__composer-suggestion.is-selected"));
|
|
}
|
|
|
|
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 transferEvent(type: "paste" | "drop", key: "clipboardData" | "dataTransfer", files: readonly File[]): Event {
|
|
const event = new Event(type, { bubbles: true, cancelable: true });
|
|
Object.defineProperty(event, key, {
|
|
value: { files },
|
|
});
|
|
return event;
|
|
}
|
|
|
|
function attachmentFixture(name: string): ComposerAttachment {
|
|
return {
|
|
kind: "image",
|
|
name,
|
|
path: `Codex Attachments/${name}.png`,
|
|
marker: `![[Codex Attachments/${name}.png]]`,
|
|
};
|
|
}
|
|
|
|
async function flushComposerAttachment(): Promise<void> {
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
}
|
|
|
|
function expectPresent<T>(value: T | null | undefined): T {
|
|
expect(value).not.toBeNull();
|
|
expect(value).not.toBeUndefined();
|
|
return value as T;
|
|
}
|