Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
1251 lines
38 KiB
TypeScript
1251 lines
38 KiB
TypeScript
import { act, fireEvent, render, waitFor } from "@testing-library/react";
|
|
import { createRef } from "react";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import { EditorView } from "@tiptap/pm/view";
|
|
import { RichTextMentionServiceProvider } from "@tutti-os/ui-rich-text/editor";
|
|
import {
|
|
createRichTextMentionService,
|
|
type RichTextMentionService
|
|
} from "@tutti-os/ui-rich-text/service";
|
|
import type {
|
|
RichTextMentionResolved,
|
|
RichTextTriggerProvider
|
|
} from "@tutti-os/ui-rich-text/types";
|
|
import {
|
|
registerAgentCustomMentionKind,
|
|
resetAgentCustomMentionKindsForTests
|
|
} from "../../../shared/agentCustomMentionKinds";
|
|
import { AgentTargetPresentationProvider } from "../../../shared/AgentTargetPresentationContext";
|
|
import { AgentRichTextEditor } from "./AgentRichTextEditor";
|
|
import type { AgentRichTextEditorHandle } from "./AgentRichTextEditor.types";
|
|
import { isAgentRichTextAbsolutePathPasteCandidate } from "./agentRichTextEditorSupport";
|
|
|
|
describe("isAgentRichTextAbsolutePathPasteCandidate", () => {
|
|
it("accepts a single absolute path", () => {
|
|
expect(isAgentRichTextAbsolutePathPasteCandidate("/workspace/a.txt")).toBe(
|
|
true
|
|
);
|
|
expect(
|
|
isAgentRichTextAbsolutePathPasteCandidate(" /Users/me/a.txt\n")
|
|
).toBe(true);
|
|
});
|
|
|
|
it("rejects quoted, spaced, relative, and multi-path pastes", () => {
|
|
expect(
|
|
isAgentRichTextAbsolutePathPasteCandidate('" /workspace/a.txt"')
|
|
).toBe(false);
|
|
expect(
|
|
isAgentRichTextAbsolutePathPasteCandidate("/workspace/my file.txt")
|
|
).toBe(false);
|
|
expect(isAgentRichTextAbsolutePathPasteCandidate("workspace/a.txt")).toBe(
|
|
false
|
|
);
|
|
expect(
|
|
isAgentRichTextAbsolutePathPasteCandidate(
|
|
"/workspace/a.txt\n/workspace/b.txt"
|
|
)
|
|
).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("AgentRichTextEditor file paste", () => {
|
|
it("dispatches images and regular files from one paste", async () => {
|
|
const onPasteFiles = vi.fn();
|
|
const onPasteImages = vi.fn();
|
|
const image = new File(["image"], "screen.png", { type: "image/png" });
|
|
const document = new File(["document"], "notes.md", {
|
|
type: "text/markdown"
|
|
});
|
|
const rendered = render(
|
|
<AgentRichTextEditor
|
|
value=""
|
|
disabled={false}
|
|
placeholder="Prompt"
|
|
onChange={vi.fn()}
|
|
onSubmit={vi.fn()}
|
|
onPasteFiles={onPasteFiles}
|
|
onPasteImages={onPasteImages}
|
|
/>
|
|
);
|
|
|
|
const editor = await waitFor(() => {
|
|
const element = rendered.container.querySelector<HTMLElement>(
|
|
'[contenteditable="true"]'
|
|
);
|
|
expect(element).not.toBeNull();
|
|
return element!;
|
|
});
|
|
fireEvent.paste(editor, {
|
|
clipboardData: {
|
|
files: [image, document],
|
|
getData: () => ""
|
|
}
|
|
});
|
|
|
|
expect(onPasteFiles).toHaveBeenCalledWith([document]);
|
|
await waitFor(() =>
|
|
expect(onPasteImages).toHaveBeenCalledWith([
|
|
expect.objectContaining({ name: "screen.png", mimeType: "image/png" })
|
|
])
|
|
);
|
|
});
|
|
|
|
it("inserts a resolved absolute path as a file mention", async () => {
|
|
const onResolvePastedPath = vi.fn().mockResolvedValue({
|
|
kind: "file",
|
|
path: "/host/Users/me/demo.txt",
|
|
displayName: "demo.txt"
|
|
});
|
|
const onChange = vi.fn();
|
|
const rendered = render(
|
|
<AgentRichTextEditor
|
|
value=""
|
|
disabled={false}
|
|
placeholder="Prompt"
|
|
onChange={onChange}
|
|
onSubmit={vi.fn()}
|
|
onResolvePastedPath={onResolvePastedPath}
|
|
/>
|
|
);
|
|
|
|
const editor = await waitFor(() => {
|
|
const element = rendered.container.querySelector<HTMLElement>(
|
|
'[contenteditable="true"]'
|
|
);
|
|
expect(element).not.toBeNull();
|
|
return element!;
|
|
});
|
|
fireEvent.paste(editor, {
|
|
clipboardData: {
|
|
files: [],
|
|
getData: (type: string) =>
|
|
type === "text/plain" ? "/Users/me/demo.txt" : ""
|
|
}
|
|
});
|
|
|
|
await waitFor(() =>
|
|
expect(onResolvePastedPath).toHaveBeenCalledWith("/Users/me/demo.txt")
|
|
);
|
|
await waitFor(() =>
|
|
expect(onChange.mock.calls.at(-1)?.[0]).toContain(
|
|
"[@demo.txt](/host/Users/me/demo.txt)"
|
|
)
|
|
);
|
|
});
|
|
|
|
it("falls back to plain text when absolute path resolution returns null", async () => {
|
|
const onResolvePastedPath = vi.fn().mockResolvedValue(null);
|
|
const onChange = vi.fn();
|
|
const rendered = render(
|
|
<AgentRichTextEditor
|
|
value=""
|
|
disabled={false}
|
|
placeholder="Prompt"
|
|
onChange={onChange}
|
|
onSubmit={vi.fn()}
|
|
onResolvePastedPath={onResolvePastedPath}
|
|
/>
|
|
);
|
|
|
|
const editor = await waitFor(() => {
|
|
const element = rendered.container.querySelector<HTMLElement>(
|
|
'[contenteditable="true"]'
|
|
);
|
|
expect(element).not.toBeNull();
|
|
return element!;
|
|
});
|
|
fireEvent.paste(editor, {
|
|
clipboardData: {
|
|
files: [],
|
|
getData: (type: string) =>
|
|
type === "text/plain" ? "/missing/path.txt" : ""
|
|
}
|
|
});
|
|
|
|
await waitFor(() =>
|
|
expect(onResolvePastedPath).toHaveBeenCalledWith("/missing/path.txt")
|
|
);
|
|
await waitFor(() =>
|
|
expect(onChange.mock.calls.at(-1)?.[0]).toBe("/missing/path.txt")
|
|
);
|
|
});
|
|
|
|
it("falls back to plain text when absolute path resolution rejects", async () => {
|
|
const onResolvePastedPath = vi
|
|
.fn()
|
|
.mockRejectedValue(new Error("host unavailable"));
|
|
const onChange = vi.fn();
|
|
const rendered = render(
|
|
<AgentRichTextEditor
|
|
value=""
|
|
disabled={false}
|
|
placeholder="Prompt"
|
|
onChange={onChange}
|
|
onSubmit={vi.fn()}
|
|
onResolvePastedPath={onResolvePastedPath}
|
|
/>
|
|
);
|
|
|
|
const editor = await waitFor(() => {
|
|
const element = rendered.container.querySelector<HTMLElement>(
|
|
'[contenteditable="true"]'
|
|
);
|
|
expect(element).not.toBeNull();
|
|
return element!;
|
|
});
|
|
fireEvent.paste(editor, {
|
|
clipboardData: {
|
|
files: [],
|
|
getData: (type: string) =>
|
|
type === "text/plain" ? "/Users/me/demo.txt" : ""
|
|
}
|
|
});
|
|
|
|
await waitFor(() =>
|
|
expect(onResolvePastedPath).toHaveBeenCalledWith("/Users/me/demo.txt")
|
|
);
|
|
await waitFor(() =>
|
|
expect(onChange.mock.calls.at(-1)?.[0]).toBe("/Users/me/demo.txt")
|
|
);
|
|
});
|
|
|
|
it("routes pasted images through file preparation when prompt images are unsupported", async () => {
|
|
const onPasteFiles = vi.fn();
|
|
const onPasteImages = vi.fn();
|
|
const onPromptImagesUnsupported = vi.fn();
|
|
const image = new File(["image"], "screen.png", { type: "image/png" });
|
|
const rendered = render(
|
|
<AgentRichTextEditor
|
|
value=""
|
|
disabled={false}
|
|
placeholder="Prompt"
|
|
promptImagesSupported={false}
|
|
onChange={vi.fn()}
|
|
onSubmit={vi.fn()}
|
|
onPasteFiles={onPasteFiles}
|
|
onPasteImages={onPasteImages}
|
|
onPromptImagesUnsupported={onPromptImagesUnsupported}
|
|
/>
|
|
);
|
|
|
|
const editor = await waitFor(() => {
|
|
const element = rendered.container.querySelector<HTMLElement>(
|
|
'[contenteditable="true"]'
|
|
);
|
|
expect(element).not.toBeNull();
|
|
return element!;
|
|
});
|
|
fireEvent.paste(editor, {
|
|
clipboardData: {
|
|
files: [image],
|
|
getData: () => ""
|
|
}
|
|
});
|
|
|
|
expect(onPasteFiles).toHaveBeenCalledWith([image]);
|
|
expect(onPasteImages).not.toHaveBeenCalled();
|
|
expect(onPromptImagesUnsupported).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("inserts, updates, and removes a composer file inside the editor", async () => {
|
|
const ref = createRef<AgentRichTextEditorHandle>();
|
|
const onChange = vi.fn();
|
|
const rendered = render(
|
|
<AgentRichTextEditor
|
|
ref={ref}
|
|
value=""
|
|
disabled={false}
|
|
placeholder="Prompt"
|
|
removeMentionLabel="Remove file"
|
|
onChange={onChange}
|
|
onSubmit={vi.fn()}
|
|
/>
|
|
);
|
|
await waitFor(() => expect(ref.current).not.toBeNull());
|
|
|
|
act(() =>
|
|
ref.current?.insertComposerFiles([
|
|
{ id: "file-1", name: "report.pdf", status: "uploading" }
|
|
])
|
|
);
|
|
|
|
expect(
|
|
rendered.container.querySelector('[data-uploading="true"]')
|
|
).not.toBeNull();
|
|
expect(onChange).toHaveBeenLastCalledWith(
|
|
expect.stringContaining("mention://composer-file/file-1")
|
|
);
|
|
|
|
act(() =>
|
|
ref.current?.updateComposerFiles([
|
|
{
|
|
errorCode: "file_too_large",
|
|
id: "file-1",
|
|
name: "report.pdf",
|
|
status: "error"
|
|
}
|
|
])
|
|
);
|
|
const failedMention = rendered.container.querySelector(
|
|
'[data-upload-error="true"]'
|
|
);
|
|
expect(failedMention).not.toBeNull();
|
|
expect(failedMention?.textContent).toBe("report.pdf");
|
|
expect(failedMention).toHaveAttribute("title", "File is too large");
|
|
expect(failedMention).toHaveAttribute(
|
|
"aria-label",
|
|
"report.pdf, File is too large"
|
|
);
|
|
|
|
fireEvent.mouseDown(rendered.getByLabelText("Remove file"));
|
|
await waitFor(() => expect(onChange).toHaveBeenLastCalledWith(""));
|
|
});
|
|
});
|
|
|
|
describe("AgentRichTextEditor Agent mention presentation", () => {
|
|
it("uses the target directory icon for a restored session mention", async () => {
|
|
const iconUrl = "data:image/png;base64,gemini";
|
|
const href =
|
|
"mention://agent-session/session-1?agentTargetId=extension%3Agemini&workspaceId=workspace-1";
|
|
const { container } = render(
|
|
<AgentTargetPresentationProvider
|
|
agentTargets={[
|
|
{
|
|
agentTargetId: "extension:gemini",
|
|
iconUrl,
|
|
name: "Gemini CLI",
|
|
provider: "acp:gemini",
|
|
workspaceId: "workspace-1"
|
|
}
|
|
]}
|
|
>
|
|
<AgentRichTextEditor
|
|
value={`Review [@Gemini session](${href})`}
|
|
disabled={false}
|
|
placeholder="Prompt"
|
|
onChange={vi.fn()}
|
|
onSubmit={vi.fn()}
|
|
/>
|
|
</AgentTargetPresentationProvider>
|
|
);
|
|
|
|
await waitFor(() =>
|
|
expect(
|
|
container.querySelector('[data-agent-mention-kind="session"] img')
|
|
).toHaveAttribute("src", iconUrl)
|
|
);
|
|
});
|
|
|
|
it("keeps the target catalog presentation over a stale mention-service result", async () => {
|
|
const currentIconUrl = "data:image/png;base64,current-codex";
|
|
const staleIconUrl = "data:image/png;base64,stale-provider";
|
|
const resolveMention = vi.fn(() => ({
|
|
label: "Stale Agent",
|
|
presentation: {
|
|
agentProviderId: "stale-provider",
|
|
iconUrl: staleIconUrl
|
|
}
|
|
}));
|
|
const provider: RichTextTriggerProvider<string> = {
|
|
id: "agent-target",
|
|
trigger: "@",
|
|
query: () => [],
|
|
getItemKey: (item) => item,
|
|
getItemLabel: (item) => item,
|
|
toInsertResult: (item) => ({ kind: "text", text: item }),
|
|
resolveMention
|
|
};
|
|
const mentionService = createRichTextMentionService({
|
|
providers: [provider]
|
|
});
|
|
const rendered = render(
|
|
<RichTextMentionServiceProvider service={mentionService}>
|
|
<AgentTargetPresentationProvider
|
|
agentTargets={[
|
|
{
|
|
agentTargetId: "shared-agent:current",
|
|
iconUrl: currentIconUrl,
|
|
name: "Current Agent",
|
|
provider: "codex",
|
|
workspaceId: "workspace-1"
|
|
}
|
|
]}
|
|
>
|
|
<AgentRichTextEditor
|
|
value="[@Canonical Agent](mention://agent-target/shared-agent:current?workspaceId=workspace-1)"
|
|
disabled={false}
|
|
placeholder="Prompt"
|
|
onChange={vi.fn()}
|
|
onSubmit={vi.fn()}
|
|
/>
|
|
</AgentTargetPresentationProvider>
|
|
</RichTextMentionServiceProvider>
|
|
);
|
|
|
|
await waitFor(() => expect(resolveMention).toHaveBeenCalled());
|
|
const mention = rendered.container.querySelector(
|
|
'[data-agent-mention-kind="agent-target"]'
|
|
);
|
|
expect(mention).toHaveTextContent("Current Agent");
|
|
expect(mention?.querySelector("img")).toHaveAttribute(
|
|
"src",
|
|
currentIconUrl
|
|
);
|
|
expect(rendered.container.innerHTML).not.toContain(staleIconUrl);
|
|
|
|
mentionService.dispose();
|
|
});
|
|
|
|
it("falls back to the canonical label and semantic icon when a resolved mention becomes missing", async () => {
|
|
let resolved: RichTextMentionResolved | null = {
|
|
label: "Resolved Weather",
|
|
presentation: { iconUrl: "https://icons.example/weather.png" }
|
|
};
|
|
const provider = createWorkspaceAppMentionProvider(() => resolved);
|
|
const mentionService = createRichTextMentionService({
|
|
providers: [provider]
|
|
});
|
|
const identity = {
|
|
providerId: "workspace-app",
|
|
entityId: "weather",
|
|
label: "Weather",
|
|
scope: { workspaceId: "workspace-1" }
|
|
};
|
|
const rendered = render(
|
|
<RichTextMentionServiceProvider service={mentionService}>
|
|
<AgentRichTextEditor
|
|
value="[@Weather](mention://workspace-app/weather?workspaceId=workspace-1)"
|
|
disabled={false}
|
|
placeholder="Prompt"
|
|
onChange={vi.fn()}
|
|
onSubmit={vi.fn()}
|
|
/>
|
|
</RichTextMentionServiceProvider>
|
|
);
|
|
|
|
await waitFor(() => {
|
|
const mention = rendered.container.querySelector(
|
|
'[data-agent-mention-kind="workspace-app"]'
|
|
);
|
|
expect(mention).toHaveTextContent("Resolved Weather");
|
|
expect(mention?.querySelector("img")).toHaveAttribute(
|
|
"src",
|
|
"https://icons.example/weather.png"
|
|
);
|
|
});
|
|
|
|
resolved = null;
|
|
act(() => mentionService.invalidate(identity));
|
|
|
|
await waitFor(() => {
|
|
const mention = rendered.container.querySelector(
|
|
'[data-agent-mention-kind="workspace-app"]'
|
|
);
|
|
expect(mention).toHaveTextContent("Weather");
|
|
expect(mention?.querySelector("img")).toBeNull();
|
|
});
|
|
|
|
mentionService.dispose();
|
|
});
|
|
|
|
it("drops a resolved icon when the provider no longer returns one", async () => {
|
|
let resolved: RichTextMentionResolved | null = {
|
|
label: "Resolved Weather",
|
|
presentation: { iconUrl: "https://icons.example/weather.png" }
|
|
};
|
|
const provider = createWorkspaceAppMentionProvider(() => resolved);
|
|
const mentionService = createRichTextMentionService({
|
|
providers: [provider]
|
|
});
|
|
const identity = {
|
|
providerId: "workspace-app",
|
|
entityId: "weather",
|
|
label: "Weather",
|
|
scope: { workspaceId: "workspace-1" }
|
|
};
|
|
const rendered = render(
|
|
<RichTextMentionServiceProvider service={mentionService}>
|
|
<AgentRichTextEditor
|
|
value="[@Weather](mention://workspace-app/weather?workspaceId=workspace-1)"
|
|
disabled={false}
|
|
placeholder="Prompt"
|
|
onChange={vi.fn()}
|
|
onSubmit={vi.fn()}
|
|
/>
|
|
</RichTextMentionServiceProvider>
|
|
);
|
|
|
|
await waitFor(() =>
|
|
expect(
|
|
rendered.container.querySelector(
|
|
'[data-agent-mention-kind="workspace-app"] img'
|
|
)
|
|
).toHaveAttribute("src", "https://icons.example/weather.png")
|
|
);
|
|
|
|
resolved = { label: "Resolved Weather" };
|
|
act(() => mentionService.invalidate(identity));
|
|
|
|
await waitFor(() =>
|
|
expect(
|
|
rendered.container.querySelector(
|
|
'[data-agent-mention-kind="workspace-app"] img'
|
|
)
|
|
).toBeNull()
|
|
);
|
|
|
|
mentionService.dispose();
|
|
});
|
|
|
|
it("detaches from a removed mention service and stops resolving", async () => {
|
|
const resolveMention = vi.fn(() => ({
|
|
label: "Resolved Weather",
|
|
presentation: { iconUrl: "https://icons.example/weather.png" }
|
|
}));
|
|
const provider = createWorkspaceAppMentionProvider(resolveMention);
|
|
const mentionService = createRichTextMentionService({
|
|
providers: [provider]
|
|
});
|
|
const identity = {
|
|
providerId: "workspace-app",
|
|
entityId: "weather",
|
|
label: "Weather",
|
|
scope: { workspaceId: "workspace-1" }
|
|
};
|
|
const editor = (service: RichTextMentionService | null) => (
|
|
<RichTextMentionServiceProvider
|
|
service={service as RichTextMentionService}
|
|
>
|
|
<AgentRichTextEditor
|
|
value="[@Weather](mention://workspace-app/weather?workspaceId=workspace-1)"
|
|
disabled={false}
|
|
placeholder="Prompt"
|
|
onChange={vi.fn()}
|
|
onSubmit={vi.fn()}
|
|
/>
|
|
</RichTextMentionServiceProvider>
|
|
);
|
|
const rendered = render(editor(mentionService));
|
|
|
|
await waitFor(() =>
|
|
expect(
|
|
rendered.container.querySelector(
|
|
'[data-agent-mention-kind="workspace-app"]'
|
|
)
|
|
).toHaveTextContent("Resolved Weather")
|
|
);
|
|
const callsBeforeRemoval = resolveMention.mock.calls.length;
|
|
|
|
rendered.rerender(editor(null));
|
|
await waitFor(() => {
|
|
const mention = rendered.container.querySelector(
|
|
'[data-agent-mention-kind="workspace-app"]'
|
|
);
|
|
expect(mention).toHaveTextContent("Weather");
|
|
expect(mention?.querySelector("img")).toBeNull();
|
|
});
|
|
|
|
act(() => mentionService.invalidate(identity));
|
|
await act(async () => {
|
|
await Promise.resolve();
|
|
});
|
|
expect(resolveMention).toHaveBeenCalledTimes(callsBeforeRemoval);
|
|
|
|
mentionService.dispose();
|
|
});
|
|
|
|
it("does not resolve existing mentions again after ordinary text input", async () => {
|
|
const provider = createWorkspaceAppMentionProvider(() => ({
|
|
label: "Resolved Weather"
|
|
}));
|
|
const mentionService = createRichTextMentionService({
|
|
providers: [provider]
|
|
});
|
|
const resolve = vi.spyOn(mentionService, "resolve");
|
|
const ref = createRef<AgentRichTextEditorHandle>();
|
|
const rendered = render(
|
|
<RichTextMentionServiceProvider service={mentionService}>
|
|
<AgentRichTextEditor
|
|
ref={ref}
|
|
value="[@Weather](mention://workspace-app/weather?workspaceId=workspace-1)"
|
|
disabled={false}
|
|
placeholder="Prompt"
|
|
onChange={vi.fn()}
|
|
onSubmit={vi.fn()}
|
|
/>
|
|
</RichTextMentionServiceProvider>
|
|
);
|
|
|
|
await waitFor(() =>
|
|
expect(rendered.container).toHaveTextContent("Resolved Weather")
|
|
);
|
|
const callsBeforeInput = resolve.mock.calls.length;
|
|
act(() => {
|
|
ref.current?.focusAtEnd();
|
|
ref.current?.insertPlainTextAtSelection("!");
|
|
});
|
|
await waitFor(() =>
|
|
expect(
|
|
rendered.container.querySelector('[contenteditable="true"]')
|
|
).toHaveTextContent("Weather!")
|
|
);
|
|
await act(async () => {
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(resolve).toHaveBeenCalledTimes(callsBeforeInput);
|
|
mentionService.dispose();
|
|
});
|
|
|
|
it("resolves multiple mentions with linear call fan-out", async () => {
|
|
const provider = createWorkspaceAppMentionProvider((identity) => ({
|
|
label: `Resolved ${identity.entityId}`
|
|
}));
|
|
const mentionService = createRichTextMentionService({
|
|
providers: [provider]
|
|
});
|
|
const resolve = vi.spyOn(mentionService, "resolve");
|
|
const mentionCount = 10;
|
|
const value = Array.from(
|
|
{ length: mentionCount },
|
|
(_, index) =>
|
|
`[@App ${index}](mention://workspace-app/app-${index}?workspaceId=workspace-1)`
|
|
).join(" ");
|
|
const rendered = render(
|
|
<RichTextMentionServiceProvider service={mentionService}>
|
|
<AgentRichTextEditor
|
|
value={value}
|
|
disabled={false}
|
|
placeholder="Prompt"
|
|
onChange={vi.fn()}
|
|
onSubmit={vi.fn()}
|
|
/>
|
|
</RichTextMentionServiceProvider>
|
|
);
|
|
|
|
await waitFor(() =>
|
|
expect(
|
|
rendered.container.querySelectorAll(
|
|
'[data-node-view-wrapper][data-agent-mention-kind="workspace-app"]'
|
|
)
|
|
).toHaveLength(mentionCount)
|
|
);
|
|
await waitFor(() =>
|
|
expect(rendered.container).toHaveTextContent("Resolved app-9")
|
|
);
|
|
|
|
expect(resolve).toHaveBeenCalledTimes(mentionCount);
|
|
mentionService.dispose();
|
|
});
|
|
});
|
|
|
|
function createWorkspaceAppMentionProvider(
|
|
resolveMention: NonNullable<RichTextTriggerProvider<string>["resolveMention"]>
|
|
): RichTextTriggerProvider<string> {
|
|
return {
|
|
id: "workspace-app",
|
|
trigger: "@",
|
|
query: () => [],
|
|
getItemKey: (item) => item,
|
|
getItemLabel: (item) => item,
|
|
toInsertResult: (item) => ({ kind: "text", text: item }),
|
|
resolveMention
|
|
};
|
|
}
|
|
|
|
describe("AgentRichTextEditor prompt insertion", () => {
|
|
it("keeps a middle newline visible without jumping to earlier content", async () => {
|
|
const animationFrames: FrameRequestCallback[] = [];
|
|
const requestAnimationFrameSpy = vi
|
|
.spyOn(window, "requestAnimationFrame")
|
|
.mockImplementation((callback) => {
|
|
animationFrames.push(callback);
|
|
return animationFrames.length;
|
|
});
|
|
const cancelAnimationFrameSpy = vi
|
|
.spyOn(window, "cancelAnimationFrame")
|
|
.mockImplementation(() => {});
|
|
const coordsAtPosSpy = vi
|
|
.spyOn(EditorView.prototype, "coordsAtPos")
|
|
.mockReturnValue({ bottom: 148, left: 0, right: 0, top: 124 });
|
|
const rangeRectDescriptor = Object.getOwnPropertyDescriptor(
|
|
Range.prototype,
|
|
"getBoundingClientRect"
|
|
);
|
|
Object.defineProperty(Range.prototype, "getBoundingClientRect", {
|
|
configurable: true,
|
|
value: () => createRect({ bottom: 0, top: 0 })
|
|
});
|
|
const onChange = vi.fn();
|
|
|
|
try {
|
|
const rendered = render(
|
|
<AgentRichTextEditor
|
|
value={"one\ntwo\nthree\nfour\nfive\nsix"}
|
|
disabled={false}
|
|
placeholder="Prompt"
|
|
onChange={onChange}
|
|
onSubmit={vi.fn()}
|
|
/>
|
|
);
|
|
const editor = await waitFor(() => {
|
|
const element = rendered.container.querySelector<HTMLElement>(
|
|
'[contenteditable="true"]'
|
|
);
|
|
expect(element).not.toBeNull();
|
|
return element!;
|
|
});
|
|
Object.defineProperties(editor, {
|
|
clientHeight: { configurable: true, value: 72 },
|
|
scrollHeight: { configurable: true, value: 240 }
|
|
});
|
|
editor.scrollTop = 96;
|
|
vi.spyOn(editor, "getBoundingClientRect").mockReturnValue(
|
|
createRect({ bottom: 172, top: 100 })
|
|
);
|
|
|
|
const textNode = Array.from(
|
|
editor.querySelector("p")?.childNodes ?? []
|
|
).find(
|
|
(node) =>
|
|
node.nodeType === Node.TEXT_NODE && node.textContent === "three"
|
|
);
|
|
expect(textNode).not.toBeNull();
|
|
act(() => {
|
|
editor.focus();
|
|
const selection = window.getSelection();
|
|
if (selection && textNode) {
|
|
const range = document.createRange();
|
|
range.setStart(textNode, textNode.textContent?.length ?? 0);
|
|
range.collapse(true);
|
|
selection.removeAllRanges();
|
|
selection.addRange(range);
|
|
document.dispatchEvent(
|
|
new Event("selectionchange", { bubbles: true })
|
|
);
|
|
}
|
|
});
|
|
|
|
fireEvent.keyDown(editor, { key: "Enter", shiftKey: true });
|
|
await waitFor(() =>
|
|
expect(onChange).toHaveBeenLastCalledWith(
|
|
"one\ntwo\nthree\n\nfour\nfive\nsix"
|
|
)
|
|
);
|
|
act(() => {
|
|
animationFrames.splice(0).forEach((callback) => callback(0));
|
|
});
|
|
|
|
expect(coordsAtPosSpy).toHaveBeenCalled();
|
|
expect(editor.scrollTop).toBe(96);
|
|
} finally {
|
|
if (rangeRectDescriptor) {
|
|
Object.defineProperty(
|
|
Range.prototype,
|
|
"getBoundingClientRect",
|
|
rangeRectDescriptor
|
|
);
|
|
} else {
|
|
Reflect.deleteProperty(Range.prototype, "getBoundingClientRect");
|
|
}
|
|
coordsAtPosSpy.mockRestore();
|
|
cancelAnimationFrameSpy.mockRestore();
|
|
requestAnimationFrameSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("drops a late mention resolution after the controlled draft is replaced", async () => {
|
|
let resolveOldMention: ((value: RichTextMentionResolved) => void) | null =
|
|
null;
|
|
const oldMentionResolution = new Promise<RichTextMentionResolved>(
|
|
(resolve) => {
|
|
resolveOldMention = resolve;
|
|
}
|
|
);
|
|
const resolveMention = vi.fn((identity) =>
|
|
identity.entityId === "app-a" ? oldMentionResolution : null
|
|
);
|
|
const provider: RichTextTriggerProvider<string> = {
|
|
id: "workspace-app",
|
|
trigger: "@",
|
|
query: () => [],
|
|
getItemKey: (item) => item,
|
|
getItemLabel: (item) => item,
|
|
toInsertResult: (item) => ({ kind: "text", text: item }),
|
|
resolveMention
|
|
};
|
|
const mentionService = createRichTextMentionService({
|
|
providers: [provider]
|
|
});
|
|
const ref = createRef<AgentRichTextEditorHandle>();
|
|
const onChange = vi.fn();
|
|
const draftA =
|
|
"[@App A](mention://workspace-app/app-a?workspaceId=workspace-1)";
|
|
const draftB =
|
|
"[@App B](mention://workspace-app/app-b?workspaceId=workspace-1)";
|
|
const editor = (value: string, contentScopeKey: string) => (
|
|
<RichTextMentionServiceProvider service={mentionService}>
|
|
<AgentRichTextEditor
|
|
ref={ref}
|
|
contentScopeKey={contentScopeKey}
|
|
disabled={false}
|
|
onChange={onChange}
|
|
onSubmit={vi.fn()}
|
|
placeholder="Prompt"
|
|
value={value}
|
|
/>
|
|
</RichTextMentionServiceProvider>
|
|
);
|
|
const rendered = render(editor(draftA, "session-a"));
|
|
await waitFor(() =>
|
|
expect(resolveMention).toHaveBeenCalledWith(
|
|
expect.objectContaining({ entityId: "app-a" })
|
|
)
|
|
);
|
|
|
|
rendered.rerender(editor(draftB, "session-b"));
|
|
await waitFor(() =>
|
|
expect(
|
|
rendered.container.querySelector('[data-agent-mention-href*="app-b"]')
|
|
).not.toBeNull()
|
|
);
|
|
onChange.mockClear();
|
|
act(() => {
|
|
ref.current?.focusAtEnd();
|
|
ref.current?.insertPlainTextAtSelection("!");
|
|
});
|
|
expect(onChange).toHaveBeenLastCalledWith(`${draftB}!`);
|
|
|
|
await act(async () => {
|
|
resolveOldMention?.({
|
|
label: "Stale App",
|
|
presentation: { iconUrl: "https://icons.example/stale.png" }
|
|
});
|
|
await oldMentionResolution;
|
|
});
|
|
|
|
await waitFor(() =>
|
|
expect(
|
|
rendered.container.querySelector('[contenteditable="true"]')
|
|
).toHaveTextContent("App B!")
|
|
);
|
|
expect(rendered.container.innerHTML).not.toContain(
|
|
"https://icons.example/stale.png"
|
|
);
|
|
expect(onChange).toHaveBeenCalledTimes(1);
|
|
|
|
rendered.unmount();
|
|
mentionService.dispose();
|
|
});
|
|
|
|
it("ignores stale controlled echoes while a transition catches up", async () => {
|
|
const ref = createRef<AgentRichTextEditorHandle>();
|
|
const props = {
|
|
disabled: false,
|
|
onChange: vi.fn(),
|
|
onSubmit: vi.fn(),
|
|
placeholder: "Prompt"
|
|
};
|
|
const rendered = render(
|
|
<AgentRichTextEditor ref={ref} value="a" {...props} />
|
|
);
|
|
await waitFor(() => expect(ref.current).not.toBeNull());
|
|
|
|
act(() => {
|
|
ref.current?.focusAtEnd();
|
|
ref.current?.insertPlainTextAtSelection("b");
|
|
ref.current?.insertPlainTextAtSelection("c");
|
|
});
|
|
expect(
|
|
rendered.container.querySelector('[contenteditable="true"]')
|
|
).toHaveTextContent("abc");
|
|
|
|
rendered.rerender(<AgentRichTextEditor ref={ref} value="ab" {...props} />);
|
|
await waitFor(() =>
|
|
expect(
|
|
rendered.container.querySelector('[contenteditable="true"]')
|
|
).toHaveTextContent("abc")
|
|
);
|
|
|
|
rendered.rerender(<AgentRichTextEditor ref={ref} value="abc" {...props} />);
|
|
rendered.rerender(
|
|
<AgentRichTextEditor ref={ref} value="replacement" {...props} />
|
|
);
|
|
await waitFor(() =>
|
|
expect(
|
|
rendered.container.querySelector('[contenteditable="true"]')
|
|
).toHaveTextContent("replacement")
|
|
);
|
|
});
|
|
|
|
it("does not mistake an old local value for an echo after the draft scope changes", async () => {
|
|
const ref = createRef<AgentRichTextEditorHandle>();
|
|
const props = {
|
|
disabled: false,
|
|
onChange: vi.fn(),
|
|
onSubmit: vi.fn(),
|
|
placeholder: "Prompt"
|
|
};
|
|
const rendered = render(
|
|
<AgentRichTextEditor
|
|
ref={ref}
|
|
contentScopeKey="session-a"
|
|
value="a"
|
|
{...props}
|
|
/>
|
|
);
|
|
await waitFor(() => expect(ref.current).not.toBeNull());
|
|
|
|
act(() => {
|
|
ref.current?.focusAtEnd();
|
|
ref.current?.insertPlainTextAtSelection("b");
|
|
ref.current?.insertPlainTextAtSelection("c");
|
|
});
|
|
expect(
|
|
rendered.container.querySelector('[contenteditable="true"]')
|
|
).toHaveTextContent("abc");
|
|
|
|
rendered.rerender(
|
|
<AgentRichTextEditor
|
|
ref={ref}
|
|
contentScopeKey="session-b"
|
|
value="ab"
|
|
{...props}
|
|
/>
|
|
);
|
|
|
|
await waitFor(() =>
|
|
expect(
|
|
rendered.container.querySelector('[contenteditable="true"]')
|
|
).toHaveTextContent("ab")
|
|
);
|
|
});
|
|
|
|
it("preserves the current selection when the controlled value changes in the same draft scope", async () => {
|
|
const ref = createRef<AgentRichTextEditorHandle>();
|
|
const onChange = vi.fn();
|
|
const props = {
|
|
contentScopeKey: "session-a",
|
|
disabled: false,
|
|
onChange,
|
|
onSubmit: vi.fn(),
|
|
placeholder: "Prompt"
|
|
};
|
|
const rendered = render(
|
|
<AgentRichTextEditor ref={ref} value="hello world" {...props} />
|
|
);
|
|
await waitFor(() => expect(ref.current).not.toBeNull());
|
|
const editor = rendered.container.querySelector<HTMLElement>(
|
|
'[contenteditable="true"]'
|
|
);
|
|
const textNode = editor?.querySelector("p")?.firstChild;
|
|
expect(textNode).not.toBeNull();
|
|
|
|
act(() => {
|
|
editor?.focus();
|
|
const selection = window.getSelection();
|
|
if (selection && textNode) {
|
|
const range = document.createRange();
|
|
range.setStart(textNode, 6);
|
|
range.collapse(true);
|
|
selection.removeAllRanges();
|
|
selection.addRange(range);
|
|
editor?.dispatchEvent(new Event("selectionchange", { bubbles: true }));
|
|
}
|
|
});
|
|
|
|
rendered.rerender(
|
|
<AgentRichTextEditor ref={ref} value="hello brave world" {...props} />
|
|
);
|
|
act(() => {
|
|
ref.current?.insertPlainTextAtSelection("X");
|
|
});
|
|
|
|
expect(onChange).toHaveBeenLastCalledWith("hello Xbrave world");
|
|
});
|
|
|
|
it("invalidates layout after a programmatic document update", async () => {
|
|
const onContentLayoutInvalidated = vi.fn();
|
|
const rendered = render(
|
|
<AgentRichTextEditor
|
|
value="hello"
|
|
disabled={false}
|
|
placeholder="Prompt"
|
|
onChange={vi.fn()}
|
|
onContentLayoutInvalidated={onContentLayoutInvalidated}
|
|
onSubmit={vi.fn()}
|
|
/>
|
|
);
|
|
await waitFor(() =>
|
|
expect(
|
|
rendered.container.querySelector('[contenteditable="true"]')
|
|
).not.toBeNull()
|
|
);
|
|
onContentLayoutInvalidated.mockClear();
|
|
|
|
rendered.rerender(
|
|
<AgentRichTextEditor
|
|
value={"hello\nworld"}
|
|
disabled={false}
|
|
placeholder="Prompt"
|
|
onChange={vi.fn()}
|
|
onContentLayoutInvalidated={onContentLayoutInvalidated}
|
|
onSubmit={vi.fn()}
|
|
/>
|
|
);
|
|
|
|
await waitFor(() =>
|
|
expect(onContentLayoutInvalidated).toHaveBeenCalledTimes(1)
|
|
);
|
|
});
|
|
|
|
it("does not open mention suggestions for a restored controlled value", async () => {
|
|
const ref = createRef<AgentRichTextEditorHandle>();
|
|
const onFileMentionSuggestionChange = vi.fn();
|
|
const props = {
|
|
disabled: false,
|
|
onChange: vi.fn(),
|
|
onFileMentionSuggestionChange,
|
|
onSubmit: vi.fn(),
|
|
placeholder: "Prompt"
|
|
};
|
|
const rendered = render(
|
|
<AgentRichTextEditor ref={ref} value="" {...props} />
|
|
);
|
|
await waitFor(() => expect(ref.current).not.toBeNull());
|
|
onFileMentionSuggestionChange.mockClear();
|
|
|
|
rendered.rerender(
|
|
<AgentRichTextEditor
|
|
ref={ref}
|
|
value="@tutti-os/workbench-electron 得新增包是吗"
|
|
{...props}
|
|
/>
|
|
);
|
|
|
|
await waitFor(() =>
|
|
expect(
|
|
rendered.container.querySelector('[contenteditable="true"]')
|
|
).toHaveTextContent("@tutti-os/workbench-electron 得新增包是吗")
|
|
);
|
|
act(() => ref.current?.focusAtEnd());
|
|
|
|
expect(
|
|
onFileMentionSuggestionChange.mock.calls.some(
|
|
([suggestion]) => suggestion !== null
|
|
)
|
|
).toBe(false);
|
|
expect(rendered.container.querySelector(".suggestion")).toBeNull();
|
|
|
|
onFileMentionSuggestionChange.mockClear();
|
|
const editor = rendered.container.querySelector<HTMLElement>(
|
|
'[contenteditable="true"]'
|
|
);
|
|
expect(editor).not.toBeNull();
|
|
fireEvent.keyDown(editor!, { key: "x" });
|
|
act(() => ref.current?.insertPlainTextAtSelection("x"));
|
|
|
|
expect(onFileMentionSuggestionChange).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
query: "tutti-os/workbench-electron 得新增包是吗x"
|
|
})
|
|
);
|
|
});
|
|
|
|
it("inserts multiline plain text at the current selection without submitting", async () => {
|
|
const ref = createRef<AgentRichTextEditorHandle>();
|
|
const onChange = vi.fn();
|
|
const onContentLayoutInvalidated = vi.fn();
|
|
const onSubmit = vi.fn();
|
|
render(
|
|
<AgentRichTextEditor
|
|
ref={ref}
|
|
value="hello"
|
|
disabled={false}
|
|
placeholder="Prompt"
|
|
onChange={onChange}
|
|
onContentLayoutInvalidated={onContentLayoutInvalidated}
|
|
onSubmit={onSubmit}
|
|
/>
|
|
);
|
|
await waitFor(() => expect(ref.current).not.toBeNull());
|
|
|
|
let nextPrompt: string | null = null;
|
|
act(() => {
|
|
ref.current?.focusAtEnd();
|
|
nextPrompt =
|
|
ref.current?.insertPlainTextAtSelection("\nworld 👋") ?? null;
|
|
});
|
|
|
|
expect(nextPrompt).toBe("hello\nworld 👋");
|
|
expect(onChange).toHaveBeenLastCalledWith("hello\nworld 👋");
|
|
expect(onContentLayoutInvalidated).toHaveBeenCalled();
|
|
expect(onSubmit).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("replaces the selected range", async () => {
|
|
const ref = createRef<AgentRichTextEditorHandle>();
|
|
const onChange = vi.fn();
|
|
const rendered = render(
|
|
<AgentRichTextEditor
|
|
ref={ref}
|
|
value="hello world"
|
|
disabled={false}
|
|
placeholder="Prompt"
|
|
onChange={onChange}
|
|
onSubmit={vi.fn()}
|
|
/>
|
|
);
|
|
await waitFor(() => expect(ref.current).not.toBeNull());
|
|
const editor = rendered.container.querySelector<HTMLElement>(
|
|
'[contenteditable="true"]'
|
|
);
|
|
expect(editor).not.toBeNull();
|
|
|
|
act(() => {
|
|
editor?.focus();
|
|
const selection = window.getSelection();
|
|
const textNode = editor?.querySelector("p")?.firstChild;
|
|
if (selection && textNode) {
|
|
const range = document.createRange();
|
|
range.setStart(textNode, 6);
|
|
range.setEnd(textNode, 11);
|
|
selection.removeAllRanges();
|
|
selection.addRange(range);
|
|
editor?.dispatchEvent(new Event("selectionchange", { bubbles: true }));
|
|
}
|
|
});
|
|
|
|
let nextPrompt: string | null = null;
|
|
act(() => {
|
|
nextPrompt = ref.current?.insertPlainTextAtSelection("Tutti") ?? null;
|
|
});
|
|
expect(nextPrompt).toBe("hello Tutti");
|
|
expect(onChange).toHaveBeenLastCalledWith("hello Tutti");
|
|
});
|
|
});
|
|
|
|
function createRect({ bottom, top }: { bottom: number; top: number }): DOMRect {
|
|
return {
|
|
bottom,
|
|
height: bottom - top,
|
|
left: 0,
|
|
right: 100,
|
|
top,
|
|
width: 100,
|
|
x: 0,
|
|
y: top,
|
|
toJSON: () => ({})
|
|
};
|
|
}
|
|
|
|
describe("AgentRichTextEditor mention clipboard", () => {
|
|
it("round-trips a built-in mention through text/plain and text/html", async () => {
|
|
const prompt = "Read [@report.md](/workspace/report.md) next";
|
|
const copied = await copyEditorPrompt(prompt);
|
|
|
|
expect(copied["text/plain"]).toBe(prompt);
|
|
expect(copied["text/html"]).toContain("data-agent-file-mention");
|
|
expect(copied["text/html"]).toContain("/workspace/report.md");
|
|
expect(await pasteEditorPrompt(copied)).toBe(prompt);
|
|
});
|
|
|
|
it("round-trips a registered custom mention without losing its href", async () => {
|
|
registerAgentCustomMentionKind({
|
|
kind: "external-note",
|
|
present: (mention) => ({
|
|
name: mention.label,
|
|
summary: mention.scope?.preview
|
|
})
|
|
});
|
|
try {
|
|
const prompt =
|
|
"Use [@Note A](mention://external-note/note-a?preview=hello&spaceId=space-1)";
|
|
const copied = await copyEditorPrompt(prompt);
|
|
|
|
expect(copied["text/plain"]).toBe(prompt);
|
|
expect(copied["text/html"]).toContain("data-agent-mention-kind");
|
|
expect(copied["text/html"]).toContain("external-note");
|
|
expect(await pasteEditorPrompt(copied)).toBe(prompt);
|
|
} finally {
|
|
resetAgentCustomMentionKindsForTests();
|
|
}
|
|
});
|
|
});
|
|
|
|
async function copyEditorPrompt(
|
|
prompt: string
|
|
): Promise<Record<string, string>> {
|
|
const rendered = render(
|
|
<AgentRichTextEditor
|
|
value={prompt}
|
|
disabled={false}
|
|
placeholder="Prompt"
|
|
onChange={vi.fn()}
|
|
onSubmit={vi.fn()}
|
|
/>
|
|
);
|
|
const editor = await waitFor(() => {
|
|
const element = rendered.container.querySelector<HTMLElement>(
|
|
'[contenteditable="true"]'
|
|
);
|
|
expect(element).not.toBeNull();
|
|
return element!;
|
|
});
|
|
selectEditorContents(editor);
|
|
const copied: Record<string, string> = {};
|
|
fireEvent.copy(editor, {
|
|
clipboardData: {
|
|
files: [],
|
|
getData: (type: string) => copied[type] ?? "",
|
|
setData: (type: string, value: string) => {
|
|
copied[type] = value;
|
|
}
|
|
}
|
|
});
|
|
rendered.unmount();
|
|
return copied;
|
|
}
|
|
|
|
async function pasteEditorPrompt(
|
|
clipboard: Record<string, string>
|
|
): Promise<string> {
|
|
const onChange = vi.fn<(value: string) => void>();
|
|
const rendered = render(
|
|
<AgentRichTextEditor
|
|
value=""
|
|
disabled={false}
|
|
placeholder="Prompt"
|
|
onChange={onChange}
|
|
onSubmit={vi.fn()}
|
|
/>
|
|
);
|
|
const editor = await waitFor(() => {
|
|
const element = rendered.container.querySelector<HTMLElement>(
|
|
'[contenteditable="true"]'
|
|
);
|
|
expect(element).not.toBeNull();
|
|
return element!;
|
|
});
|
|
fireEvent.paste(editor, {
|
|
clipboardData: {
|
|
files: [],
|
|
getData: (type: string) => clipboard[type] ?? ""
|
|
}
|
|
});
|
|
await waitFor(() => expect(onChange).toHaveBeenCalled());
|
|
const result = onChange.mock.calls.at(-1)?.[0] ?? "";
|
|
rendered.unmount();
|
|
return result;
|
|
}
|
|
|
|
function selectEditorContents(editor: HTMLElement): void {
|
|
editor.focus();
|
|
const selection = window.getSelection();
|
|
const range = document.createRange();
|
|
range.selectNodeContents(editor);
|
|
selection?.removeAllRanges();
|
|
selection?.addRange(range);
|
|
document.dispatchEvent(new Event("selectionchange", { bubbles: true }));
|
|
}
|